Skip to content

Commit ca61b9b

Browse files
committed
feat: Fix OOM and timeout issues in optimal ordering
- Make PQ-tree enumeration lazy using Heap's algorithm (prevents OOM on large graphs) - Fix timeout handling to actually respect configured timeout - Add periodic progress logging every 10 seconds - Add warning when layout finishes with crossings > 0 - Improve font fallback stack for handdrawn style - Update CI to run on all PRs - Document JSON format schema in README - Update license to Apache-2.0
1 parent 4f70494 commit ca61b9b

5 files changed

Lines changed: 111 additions & 67 deletions

File tree

internal/cli/render.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,12 @@ func buildLayoutOpts(ctx context.Context, opts *renderOpts) ([]tower.Option, err
256256

257257
func withOptimalSearchProgress(ctx context.Context, timeoutSec int) ordering.Orderer {
258258
logger := loggerFromContext(ctx)
259-
o := &optimalSearchOrderer{prog: newProgress(logger), logger: logger, lastBest: -1}
259+
o := &optimalSearchOrderer{
260+
prog: newProgress(logger),
261+
logger: logger,
262+
lastBest: -1,
263+
start: time.Now(),
264+
}
260265

261266
o.OptimalSearch = ordering.OptimalSearch{
262267
Timeout: time.Duration(timeoutSec) * time.Second,
@@ -269,8 +274,16 @@ func withOptimalSearchProgress(ctx context.Context, timeoutSec int) ordering.Ord
269274
switch {
270275
case o.lastBest < 0:
271276
logger.Infof("Initial: %d crossings (explored: %d, pruned: %d)", bestScore, explored, pruned)
277+
o.lastLog = time.Now()
272278
case bestScore < o.lastBest:
273279
logger.Infof("Improved: %d crossings (↓%d)", bestScore, o.lastBest-bestScore)
280+
o.lastLog = time.Now()
281+
default:
282+
if time.Since(o.lastLog) >= 10*time.Second {
283+
elapsed := time.Since(o.start).Truncate(time.Second)
284+
logger.Infof("Searching... %v/%ds elapsed, %d crossings (pruned: %d)", elapsed, timeoutSec, bestScore, pruned)
285+
o.lastLog = time.Now()
286+
}
274287
}
275288
o.lastBest = bestScore
276289
},
@@ -284,6 +297,7 @@ type optimalSearchOrderer struct {
284297
logger *log.Logger
285298
lastExplored, lastPruned int
286299
lastBest int
300+
start, lastLog time.Time
287301
}
288302

289303
func (o *optimalSearchOrderer) OrderRows(g *dag.DAG) map[int][]string {
@@ -294,6 +308,9 @@ func (o *optimalSearchOrderer) OrderRows(g *dag.DAG) map[int][]string {
294308
o.logger.Infof("Best: %d crossings (explored: %d, pruned: %d)",
295309
crossings, o.lastExplored, o.lastPruned)
296310
}
311+
if crossings > 0 {
312+
o.logger.Warn("Layout has edge crossings; try increasing the timeout (--ordering-timeout)")
313+
}
297314
return result
298315
}
299316

pkg/dag/perm/pqtree.go

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -358,77 +358,90 @@ func (t *PQTree) Enumerate(limit int) [][]int {
358358
}
359359

360360
var results [][]int
361-
t.enumerate(t.root, nil, limit, &results)
361+
t.enumerateLazy(t.root, nil, func(perm []int) bool {
362+
results = append(results, perm)
363+
return limit <= 0 || len(results) < limit
364+
})
362365
return results
363366
}
364367

365-
func (t *PQTree) enumerate(node *pqNode, prefix []int, limit int, results *[][]int) bool {
366-
if limit > 0 && len(*results) >= limit {
367-
return false
368-
}
369-
368+
// enumerateLazy generates permutations one at a time via callback.
369+
// Returns false if callback signaled stop, true otherwise.
370+
func (t *PQTree) enumerateLazy(node *pqNode, prefix []int, emit func([]int) bool) bool {
370371
if node.kind == leafNode {
371-
*results = append(*results, append(slices.Clone(prefix), node.value))
372-
return true
372+
return emit(append(slices.Clone(prefix), node.value))
373373
}
374374

375-
perms := t.childPermutations(node)
376-
for _, perm := range perms {
377-
if limit > 0 && len(*results) >= limit {
375+
return t.forEachChildPerm(node, func(children []*pqNode) bool {
376+
return t.enumerateChildrenLazy(children, prefix, emit)
377+
})
378+
}
379+
380+
// For Q-nodes: yields forward and reverse only.
381+
// For P-nodes: generates permutations one at a time without storing them all.
382+
func (t *PQTree) forEachChildPerm(node *pqNode, fn func([]*pqNode) bool) bool {
383+
if node.kind == qNode {
384+
if !fn(node.children) {
378385
return false
379386
}
380-
if !t.enumerateChildren(perm, prefix, limit, results) {
381-
return false
387+
if len(node.children) <= 1 {
388+
return true
382389
}
390+
rev := slices.Clone(node.children)
391+
slices.Reverse(rev)
392+
return fn(rev)
383393
}
384394

385-
return true
386-
}
395+
// P-node: Generate permutations lazily
396+
n := len(node.children)
397+
if n == 0 {
398+
return fn(nil)
399+
}
400+
if n == 1 {
401+
return fn(node.children)
402+
}
387403

388-
func (t *PQTree) childPermutations(node *pqNode) [][]*pqNode {
389-
if node.kind == qNode {
390-
fwd := slices.Clone(node.children)
391-
rev := slices.Clone(node.children)
392-
slices.Reverse(rev)
393-
return [][]*pqNode{fwd, rev}
404+
perm := slices.Clone(node.children)
405+
state := make([]int, n)
406+
407+
// Emit first permutation (identity)
408+
if !fn(slices.Clone(perm)) {
409+
return false
394410
}
395411

396-
indexPerms := Generate(len(node.children), -1)
397-
result := make([][]*pqNode, len(indexPerms))
398-
for i, perm := range indexPerms {
399-
ordered := make([]*pqNode, len(perm))
400-
for j, idx := range perm {
401-
ordered[j] = node.children[idx]
412+
// iteratively generate remaining permutations
413+
for i := 0; i < n; {
414+
if state[i] < i {
415+
if i&1 == 0 {
416+
perm[0], perm[i] = perm[i], perm[0]
417+
} else {
418+
perm[state[i]], perm[i] = perm[i], perm[state[i]]
419+
}
420+
if !fn(slices.Clone(perm)) {
421+
return false
422+
}
423+
state[i]++
424+
i = 0
425+
} else {
426+
state[i] = 0
427+
i++
402428
}
403-
result[i] = ordered
404429
}
405-
return result
430+
return true
406431
}
407432

408-
func (t *PQTree) enumerateChildren(children []*pqNode, prefix []int, limit int, results *[][]int) bool {
433+
func (t *PQTree) enumerateChildrenLazy(children []*pqNode, prefix []int, emit func([]int) bool) bool {
409434
if len(children) == 0 {
410-
*results = append(*results, slices.Clone(prefix))
411-
return true
435+
return emit(slices.Clone(prefix))
412436
}
413437

414438
first := children[0]
415439
rest := children[1:]
416440

417-
var firstPerms [][]int
418-
t.enumerate(first, nil, 0, &firstPerms)
419-
420-
for _, fp := range firstPerms {
421-
if limit > 0 && len(*results) >= limit {
422-
return false
423-
}
424-
425-
newPrefix := append(slices.Clone(prefix), fp...)
426-
if !t.enumerateChildren(rest, newPrefix, limit, results) {
427-
return false
428-
}
429-
}
430-
431-
return true
441+
return t.enumerateLazy(first, nil, func(firstPerm []int) bool {
442+
newPrefix := append(slices.Clone(prefix), firstPerm...)
443+
return t.enumerateChildrenLazy(rest, newPrefix, emit)
444+
})
432445
}
433446

434447
func (t *PQTree) ValidCount() int {

pkg/render/tower/ordering/optimal.go

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,18 +100,28 @@ func (s *solver) search() {
100100
var wg sync.WaitGroup
101101
sem := make(chan struct{}, workers)
102102

103+
dispatch:
103104
for _, startPerm := range starts {
104105
if s.bestScore.Load() == 0 {
105106
break
106107
}
107108

108-
sem <- struct{}{}
109-
wg.Add(1)
109+
// Acquire worker slot, respecting context timeout
110+
select {
111+
case sem <- struct{}{}:
112+
case <-s.ctx.Done():
113+
break dispatch
114+
}
110115

116+
wg.Add(1)
111117
go func(start []int) {
112118
defer wg.Done()
113119
defer func() { <-sem }()
114120

121+
if s.ctx.Err() != nil {
122+
return
123+
}
124+
115125
path := make([][]int, len(s.rows))
116126
copy(path, prefix)
117127
path[parallelRow] = start
@@ -187,7 +197,7 @@ func (s *solver) generateStartPermutations(parallelRow int, prefix [][]int, work
187197
}
188198

189199
func (s *solver) dfs(depth, score int, path [][]int, ws *dag.CrossingWorkspace) {
190-
if depth&0x0F == 0 && s.ctx.Err() != nil {
200+
if s.ctx.Err() != nil {
191201
return
192202
}
193203

@@ -229,7 +239,7 @@ func (s *solver) dfs(depth, score int, path [][]int, ws *dag.CrossingWorkspace)
229239
path[depth] = candidate
230240
s.dfs(depth+1, newScore, path, ws)
231241

232-
if s.bestScore.Load() == 0 {
242+
if s.bestScore.Load() == 0 || s.ctx.Err() != nil {
233243
return
234244
}
235245
}

pkg/render/tower/render.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func WithPopups() RenderOption { return func(r *renderer) { r.popups = true } }
3434
const (
3535
nebraskaPanelHeightLandscape = 260.0
3636
nebraskaPanelHeightPortrait = 480.0
37+
fontFamily = `'Patrick Hand', 'Comic Sans MS', 'Bradley Hand', 'Segoe Script', sans-serif`
3738
)
3839

3940
func calcNebraskaPanelHeight(frameWidth, frameHeight float64) float64 {
@@ -113,8 +114,8 @@ func renderNebraskaPanel(buf *bytes.Buffer, frameWidth, frameHeight float64, ran
113114
panelY := frameHeight + nebraskaPanelPadding
114115
centerX := frameWidth / 2
115116

116-
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" text-anchor="middle" font-family="'Patrick Hand', cursive" font-size="30" fill="#333" font-weight="bold">Nebraska Guy Ranking</text>`+"\n",
117-
centerX, panelY+nebraskaTitleY)
117+
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" text-anchor="middle" font-family="%s" font-size="30" fill="#333" font-weight="bold">Nebraska Guy Ranking</text>`+"\n",
118+
centerX, panelY+nebraskaTitleY, fontFamily)
118119
fmt.Fprintf(buf, ` <path d="M %.1f %.1f q 60 4 120 -1 t 135 3" fill="none" stroke="#333" stroke-width="2.5" stroke-linecap="round"/>`+"\n",
119120
centerX-128, panelY+nebraskaTitleY+nebraskaUnderlineY)
120121

@@ -179,7 +180,7 @@ const nebraskaCSS = `
179180
a, .package-entry { cursor: pointer; }
180181
.nebraska-entry {
181182
text-align: center;
182-
font-family: 'Patrick Hand', cursive;
183+
font-family: 'Patrick Hand', 'Comic Sans MS', 'Bradley Hand', 'Segoe Script', sans-serif;
183184
overflow: hidden;
184185
height: 100%;
185186
}

pkg/render/tower/styles/handdrawn/handdrawn.go

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const (
2222
warnSymbolShift = 8.0
2323
textWidthRatio = 0.45
2424
textHeightRatio = 1.0
25+
26+
// Font stack: Patrick Hand (Google Fonts), then common casual/handwriting fonts
27+
fontFamily = `'Patrick Hand', 'Comic Sans MS', 'Bradley Hand', 'Segoe Script', sans-serif`
2528
)
2629

2730
type HandDrawn struct{ seed uint64 }
@@ -87,11 +90,11 @@ func (h *HandDrawn) RenderText(buf *bytes.Buffer, b styles.Block) {
8790
b.CX-textW/2, b.CY-textH/2, textW, textH, grey)
8891

8992
if rotate {
90-
fmt.Fprintf(buf, ` <text x="%.2f" y="%.2f" text-anchor="middle" dominant-baseline="middle" font-family="'Patrick Hand', cursive" font-size="%.1f" fill="#333" transform="rotate(-90 %.2f %.2f)">%s</text>`+"\n",
91-
b.CX, b.CY, size, b.CX, b.CY, styles.EscapeXML(b.ID))
93+
fmt.Fprintf(buf, ` <text x="%.2f" y="%.2f" text-anchor="middle" dominant-baseline="middle" font-family="%s" font-size="%.1f" fill="#333" transform="rotate(-90 %.2f %.2f)">%s</text>`+"\n",
94+
b.CX, b.CY, fontFamily, size, b.CX, b.CY, styles.EscapeXML(b.ID))
9295
} else {
93-
fmt.Fprintf(buf, ` <text x="%.2f" y="%.2f" text-anchor="middle" dominant-baseline="middle" font-family="'Patrick Hand', cursive" font-size="%.1f" fill="#333">%s</text>`+"\n",
94-
b.CX, b.CY, size, styles.EscapeXML(b.ID))
96+
fmt.Fprintf(buf, ` <text x="%.2f" y="%.2f" text-anchor="middle" dominant-baseline="middle" font-family="%s" font-size="%.1f" fill="#333">%s</text>`+"\n",
97+
b.CX, b.CY, fontFamily, size, styles.EscapeXML(b.ID))
9598
}
9699
})
97100
buf.WriteString(" </g>\n")
@@ -125,8 +128,8 @@ func (h *HandDrawn) RenderPopup(buf *bytes.Buffer, b styles.Block) {
125128

126129
textY := popupTextStartY
127130
for _, line := range descLines {
128-
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" font-family="'Patrick Hand', cursive" font-size="%.0f" fill="#444">%s</text>`+"\n",
129-
popupTextX, textY, popupTextSize, styles.EscapeXML(line))
131+
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" font-family="%s" font-size="%.0f" fill="#444">%s</text>`+"\n",
132+
popupTextX, textY, fontFamily, popupTextSize, styles.EscapeXML(line))
130133
textY += popupLineHeight
131134
}
132135

@@ -144,19 +147,19 @@ func (h *HandDrawn) RenderPopup(buf *bytes.Buffer, b styles.Block) {
144147
}
145148

146149
if p.LastCommit != "" {
147-
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" text-anchor="middle" font-family="'Patrick Hand', cursive" font-size="%.0f" fill="#444">%slast commit: %s</text>`+"\n",
148-
dateX, rightY, popupTextSize, warnPrefix, p.LastCommit)
150+
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" text-anchor="middle" font-family="%s" font-size="%.0f" fill="#444">%slast commit: %s</text>`+"\n",
151+
dateX, rightY, fontFamily, popupTextSize, warnPrefix, p.LastCommit)
149152
rightY += popupLineHeight * dateLineSpacing
150153
}
151154
if p.LastRelease != "" && p.LastRelease != "0001-01-01" {
152-
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" text-anchor="middle" font-family="'Patrick Hand', cursive" font-size="%.0f" fill="#444">%slast release: %s</text>`+"\n",
153-
dateX, rightY, popupTextSize, warnPrefix, p.LastRelease)
155+
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" text-anchor="middle" font-family="%s" font-size="%.0f" fill="#444">%slast release: %s</text>`+"\n",
156+
dateX, rightY, fontFamily, popupTextSize, warnPrefix, p.LastRelease)
154157
}
155158

156159
if p.Stars > 0 {
157160
starsCenterY := statsStartY + (popupLineHeight*float64(statsRows))/2 - popupStarShift
158-
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" text-anchor="middle" dominant-baseline="middle" font-family="'Patrick Hand', cursive" font-size="%.0f" fill="#222" font-weight="bold">★ %s</text>`+"\n",
159-
leftCenterX, starsCenterY, popupStarSize, formatNumber(p.Stars))
161+
fmt.Fprintf(buf, ` <text x="%.1f" y="%.1f" text-anchor="middle" dominant-baseline="middle" font-family="%s" font-size="%.0f" fill="#222" font-weight="bold">★ %s</text>`+"\n",
162+
leftCenterX, starsCenterY, fontFamily, popupStarSize, formatNumber(p.Stars))
160163
}
161164
}
162165

0 commit comments

Comments
 (0)