Skip to content

Commit 46071cc

Browse files
committed
feat: Add debug stats with -v flag
- Add Debug callback to OptimalSearch for search space analysis - Show per-row candidate counts and max depth reached - Track search progress with periodic logging every 10s - Warn when layout has edge crossings - Improve font fallback stack for handdrawn style - Document -v/--verbose flag in README
1 parent ca61b9b commit 46071cc

3 files changed

Lines changed: 104 additions & 13 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,12 @@ stacktower render examples/test/diamond.json -t tower -o diamond.svg
7373

7474
## Options Reference
7575

76+
### Global Options
77+
78+
| Flag | Description |
79+
|------|-------------|
80+
| `-v`, `--verbose` | Enable debug logging (search space info, timing details) |
81+
7682
### Parse Options
7783

7884
| Flag | Description |

internal/cli/render.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,21 @@ func withOptimalSearchProgress(ctx context.Context, timeoutSec int) ordering.Ord
287287
}
288288
o.lastBest = bestScore
289289
},
290+
Debug: func(info ordering.DebugInfo) {
291+
logger.Debugf("Search space: %d rows, max depth reached: %d/%d", info.TotalRows, info.MaxDepth, info.TotalRows)
292+
293+
bottlenecks := 0
294+
for _, r := range info.Rows {
295+
if r.Candidates > 100 {
296+
logger.Debugf(" Row %d: %d nodes, %d candidates", r.Row, r.NodeCount, r.Candidates)
297+
bottlenecks++
298+
}
299+
}
300+
301+
if info.MaxDepth < info.TotalRows && bottlenecks > 0 {
302+
logger.Debugf("Search incomplete: %d rows have >100 candidates, causing combinatorial explosion", bottlenecks)
303+
}
304+
},
290305
}
291306
return o
292307
}

pkg/render/tower/ordering/optimal.go

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,24 @@ import (
1313
"github.com/matzehuels/stacktower/pkg/dag/perm"
1414
)
1515

16-
const maxCandidates = 10000
16+
const maxCandidatesBase = 10000
1717

1818
type OptimalSearch struct {
1919
Progress func(explored, pruned, best int)
2020
Timeout time.Duration
21+
Debug func(info DebugInfo)
22+
}
23+
24+
type DebugInfo struct {
25+
Rows []RowDebugInfo
26+
MaxDepth int
27+
TotalRows int
28+
}
29+
30+
type RowDebugInfo struct {
31+
Row int
32+
NodeCount int
33+
Candidates int
2134
}
2235

2336
func (o OptimalSearch) OrderRows(g *dag.DAG) map[int][]string {
@@ -42,12 +55,13 @@ func (o OptimalSearch) OrderRows(g *dag.DAG) map[int][]string {
4255
defer cancel()
4356

4457
s := &solver{
45-
g: g,
46-
fg: newFastGraph(g, rows),
47-
rows: rows,
48-
rowNodes: make(map[int][]*dag.Node, len(rows)),
49-
ctx: ctx,
50-
cancel: cancel,
58+
g: g,
59+
fg: newFastGraph(g, rows),
60+
rows: rows,
61+
rowNodes: make(map[int][]*dag.Node, len(rows)),
62+
candLimit: calcCandidateLimit(len(rows)),
63+
ctx: ctx,
64+
cancel: cancel,
5165
}
5266
s.bestScore.Store(int64(initialScore))
5367
s.bestPath.Store(toIndexPath(g, rows, initial))
@@ -66,6 +80,10 @@ func (o OptimalSearch) OrderRows(g *dag.DAG) map[int][]string {
6680
o.report(int(s.explored.Load()), int(s.pruned.Load()), int(s.bestScore.Load()))
6781
}
6882

83+
if o.Debug != nil {
84+
o.Debug(s.collectDebugInfo(initial))
85+
}
86+
6987
return toStringOrder(s.rowNodes, s.rows, s.bestPath.Load().([][]int))
7088
}
7189

@@ -76,20 +94,32 @@ func (o OptimalSearch) report(explored, pruned, best int) {
7694
}
7795

7896
type solver struct {
79-
g *dag.DAG
80-
fg *fastGraph
81-
rows []int
82-
rowNodes map[int][]*dag.Node
97+
g *dag.DAG
98+
fg *fastGraph
99+
rows []int
100+
rowNodes map[int][]*dag.Node
101+
candLimit int
83102

84103
bestScore atomic.Int64
85104
bestPath atomic.Value
86105
explored atomic.Int64
87106
pruned atomic.Int64
107+
maxDepth atomic.Int64
88108

89109
ctx context.Context
90110
cancel context.CancelFunc
91111
}
92112

113+
func calcCandidateLimit(numRows int) int {
114+
if numRows <= 3 {
115+
return maxCandidatesBase
116+
}
117+
// Linear scaling: more rows = fewer candidates per row
118+
// 5 rows → 2000, 10 rows → 1000, 20 rows → 500
119+
limit := maxCandidatesBase / numRows
120+
return max(100, min(1000, limit))
121+
}
122+
93123
func (s *solver) search() {
94124
workers := runtime.GOMAXPROCS(0)
95125
parallelRow := s.findParallelRow()
@@ -201,6 +231,14 @@ func (s *solver) dfs(depth, score int, path [][]int, ws *dag.CrossingWorkspace)
201231
return
202232
}
203233

234+
// Track max depth reached
235+
for {
236+
cur := s.maxDepth.Load()
237+
if int64(depth) <= cur || s.maxDepth.CompareAndSwap(cur, int64(depth)) {
238+
break
239+
}
240+
}
241+
204242
if score >= int(s.bestScore.Load()) {
205243
s.pruned.Add(1)
206244
return
@@ -261,7 +299,7 @@ func (s *solver) generateC1PCandidates(depth int, nodes []*dag.Node, prevOrder [
261299
return s.fallbackPermutations(n)
262300
}
263301

264-
limit := maxCandidates
302+
limit := s.candLimit
265303
if n <= 8 {
266304
limit = tree.ValidCount()
267305
}
@@ -306,7 +344,7 @@ func (s *solver) fallbackPermutations(n int) [][]int {
306344
if n <= 8 {
307345
return perm.Generate(n, -1)
308346
}
309-
return perm.Generate(n, maxCandidates)
347+
return perm.Generate(n, s.candLimit)
310348
}
311349

312350
func (s *solver) updateBest(path [][]int, score int) {
@@ -331,6 +369,38 @@ func (s *solver) updateBest(path [][]int, score int) {
331369
}
332370
}
333371

372+
func (s *solver) collectDebugInfo(initialOrder map[int][]string) DebugInfo {
373+
info := DebugInfo{
374+
TotalRows: len(s.rows),
375+
MaxDepth: int(s.maxDepth.Load()),
376+
Rows: make([]RowDebugInfo, len(s.rows)),
377+
}
378+
379+
path := toIndexPath(s.g, s.rows, initialOrder)
380+
381+
for i, r := range s.rows {
382+
nodes := s.rowNodes[r]
383+
rowInfo := RowDebugInfo{
384+
Row: r,
385+
NodeCount: len(nodes),
386+
}
387+
388+
if len(nodes) <= 1 {
389+
rowInfo.Candidates = 1
390+
} else if i == 0 {
391+
rowInfo.Candidates = min(perm.Factorial(len(nodes)), s.candLimit)
392+
} else {
393+
prevNodes := s.rowNodes[s.rows[i-1]]
394+
candidates := s.generateC1PCandidates(i, nodes, path[i-1], prevNodes)
395+
rowInfo.Candidates = len(candidates)
396+
}
397+
398+
info.Rows[i] = rowInfo
399+
}
400+
401+
return info
402+
}
403+
334404
func (s *solver) monitor(fn func(int, int, int)) {
335405
ticker := time.NewTicker(250 * time.Millisecond)
336406
defer ticker.Stop()

0 commit comments

Comments
 (0)