Skip to content

Commit 39fca7b

Browse files
Merge pull request #25 from SaridakisStamatisChristos/codex/add-trace-export-and-web-visualizer
Add solution reveal trace export and visualizer
2 parents 7607e1b + bed1800 commit 39fca7b

File tree

6 files changed

+211
-2
lines changed

6 files changed

+211
-2
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,11 @@ sudoku-dlx stats-file --in puzzles.txt --json stats.json --csv diff_hist.csv
7373
# Minimal & symmetry (slower; strict guarantee)
7474
sudoku-dlx gen --seed 123 --givens 28 --minimal
7575
sudoku-dlx gen --seed 123 --givens 28 --minimal --symmetry rot180
76+
77+
# Trace & Visualize
78+
sudoku-dlx solve --grid "<81chars>" --trace out.json
79+
# Then open web/visualizer.html in your browser and load out.json
80+
# (works on GitHub Pages)
7681
```
7782

7883
What this gives you

src/sudoku_dlx/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
analyze,
1111
solve,
1212
to_string,
13+
build_reveal_trace,
1314
)
1415
from .canonical import canonical_form
1516
from .generate import generate
@@ -33,6 +34,7 @@
3334
"SolveResult",
3435
"from_string",
3536
"to_string",
37+
"build_reveal_trace",
3638
"is_valid",
3739
"solve",
3840
"analyze",

src/sudoku_dlx/api.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from dataclasses import dataclass
44
from time import perf_counter
5-
from typing import List, Optional, Dict, Any
5+
from typing import List, Optional, Dict, Any, Iterable, Tuple
66

77
Grid = List[List[int]]
88

@@ -91,6 +91,33 @@ def count_solutions(grid: Grid, limit: int = 2) -> int:
9191
return engine.count(rows, limit=limit)
9292

9393

94+
def build_reveal_trace(initial: Grid, solved: Grid, stats: Stats) -> Dict[str, Any]:
95+
"""
96+
Build a simple, deterministic 'solution_reveal' trace:
97+
- initial: 81-char string (with '.')
98+
- solution: 81-char string
99+
- steps: list of {r,c,v} filling all blanks in row-major order
100+
- stats: ms, nodes, backtracks
101+
This is NOT a DLX cover/uncover trace, but is ideal for visual replay.
102+
"""
103+
init_s = to_string(initial)
104+
sol_s = to_string(solved)
105+
steps: List[Dict[str, int]] = []
106+
for i, ch in enumerate(init_s):
107+
if ch == ".":
108+
r, c = divmod(i, 9)
109+
v = int(sol_s[i])
110+
steps.append({"r": r, "c": c, "v": v})
111+
return {
112+
"version": "reveal-1",
113+
"kind": "solution_reveal",
114+
"initial": init_s,
115+
"solution": sol_s,
116+
"steps": steps,
117+
"stats": {"ms": int(round(stats.ms)), "nodes": int(stats.nodes), "backtracks": int(stats.backtracks)},
118+
}
119+
120+
94121
def analyze(grid: Grid) -> Dict[str, Any]:
95122
"""
96123
Return a compact analysis dict for a Sudoku grid. Keys:
@@ -140,6 +167,7 @@ def analyze(grid: Grid) -> Dict[str, Any]:
140167
"SolveResult",
141168
"from_string",
142169
"to_string",
170+
"build_reveal_trace",
143171
"is_valid",
144172
"solve",
145173
"count_solutions",

src/sudoku_dlx/cli.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import time
1010
from typing import Optional
1111

12-
from .api import analyze, from_string, is_valid, solve, to_string
12+
from .api import analyze, build_reveal_trace, from_string, is_valid, solve, to_string
1313
from .canonical import canonical_form
1414
from .generate import generate
1515
from .rating import rate
@@ -60,6 +60,11 @@ def cmd_solve(ns: argparse.Namespace) -> int:
6060
_print_grid(result.grid)
6161
else:
6262
print(to_string(result.grid))
63+
if ns.trace:
64+
trace = build_reveal_trace(grid, result.grid, result.stats)
65+
outp = pathlib.Path(ns.trace)
66+
outp.parent.mkdir(parents=True, exist_ok=True)
67+
outp.write_text(json.dumps(trace, indent=2, sort_keys=True), encoding="utf-8")
6368
if ns.stats:
6469
print(
6570
f"# solved in {result.stats.ms:.2f} ms · nodes {result.stats.nodes} · backtracks {result.stats.backtracks}",
@@ -279,6 +284,7 @@ def main(argv: Optional[list[str]] = None) -> int:
279284
solve_parser.add_argument("--file", help="path to a file with 9 lines of 9 chars")
280285
solve_parser.add_argument("--pretty", action="store_true", help="print 9x9 grid format")
281286
solve_parser.add_argument("--stats", action="store_true", help="print timing & node stats to stderr")
287+
solve_parser.add_argument("--trace", help="write a solution-reveal trace JSON to this path")
282288
solve_parser.set_defaults(func=cmd_solve)
283289

284290
rate_parser = sub.add_parser("rate", help="estimate difficulty in [0,10]")

tests/test_trace.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from textwrap import dedent
2+
import json
3+
from sudoku_dlx import cli, from_string, to_string, solve, build_reveal_trace
4+
5+
PUZ = dedent(
6+
"""
7+
53..7....
8+
6..195...
9+
.98....6.
10+
8...6...3
11+
4..8.3..1
12+
7...2...6
13+
.6....28.
14+
...419..5
15+
....8..79
16+
"""
17+
).strip()
18+
19+
20+
def _apply_reveal(initial81: str, steps):
21+
grid = [list(initial81[r * 9 : (r + 1) * 9]) for r in range(9)]
22+
for step in steps:
23+
r, c, v = step["r"], step["c"], step["v"]
24+
grid[r][c] = str(v)
25+
return "".join("".join(row) for row in grid)
26+
27+
28+
def test_build_reveal_trace_roundtrip():
29+
g = from_string(PUZ)
30+
res = solve(g)
31+
assert res is not None
32+
tr = build_reveal_trace(g, res.grid, res.stats)
33+
assert tr["kind"] == "solution_reveal"
34+
assert len(tr["initial"]) == 81 and len(tr["solution"]) == 81
35+
# replay steps produces the solved string
36+
after = _apply_reveal(tr["initial"], tr["steps"])
37+
assert after == tr["solution"]
38+
39+
40+
def test_cli_solve_writes_trace(tmp_path):
41+
out = tmp_path / "trace.json"
42+
rc = cli.main(["solve", "--grid", PUZ, "--trace", str(out)])
43+
assert rc == 0
44+
data = json.loads(out.read_text(encoding="utf-8"))
45+
assert data["kind"] == "solution_reveal"
46+
assert "stats" in data and "steps" in data
47+
# steps must fill exactly the blanks from initial
48+
blanks = sum(1 for ch in data["initial"] if ch == ".")
49+
assert blanks == len(data["steps"])

web/visualizer.html

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Sudoku DLX — Solution Replay</title>
7+
<style>
8+
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; margin: 24px; }
9+
.grid { border-collapse: collapse; margin: 16px 0; }
10+
.grid td { width: 32px; height: 32px; text-align: center; font-size: 18px; border: 1px solid #ccc; }
11+
.grid td.bold-right { border-right: 2px solid #000; }
12+
.grid td.bold-bottom { border-bottom: 2px solid #000; }
13+
.stats { margin: 8px 0; color: #444; }
14+
.controls button { margin-right: 8px; }
15+
.filled { background: #eaf7ff; }
16+
.given { background: #f6f6f6; font-weight: 600; }
17+
</style>
18+
</head>
19+
<body>
20+
<h1>Sudoku DLX — Solution Replay</h1>
21+
<p>Load a <code>solution_reveal</code> JSON produced by <code>sudoku-dlx solve --trace out.json</code>.</p>
22+
<input type="file" id="file" accept="application/json" />
23+
<div class="stats" id="stats"></div>
24+
<div class="controls">
25+
<button id="btn-begin" disabled>&laquo; Begin</button>
26+
<button id="btn-prev" disabled>&lsaquo; Step</button>
27+
<button id="btn-play" disabled>Play</button>
28+
<button id="btn-next" disabled>Step &rsaquo;</button>
29+
<button id="btn-end" disabled>End &raquo;</button>
30+
</div>
31+
<table class="grid" id="grid"></table>
32+
33+
<script>
34+
const gridEl = document.getElementById('grid');
35+
const statsEl = document.getElementById('stats');
36+
let trace = null, idx = 0, playing = null, initial = null, givenMask = null;
37+
38+
function buildGrid() {
39+
gridEl.innerHTML = '';
40+
for (let r = 0; r < 9; r++) {
41+
const tr = document.createElement('tr');
42+
for (let c = 0; c < 9; c++) {
43+
const td = document.createElement('td');
44+
td.id = `cell-${r}-${c}`;
45+
if ((c+1) % 3 === 0 && c < 8) td.classList.add('bold-right');
46+
if ((r+1) % 3 === 0 && r < 8) td.classList.add('bold-bottom');
47+
tr.appendChild(td);
48+
}
49+
gridEl.appendChild(tr);
50+
}
51+
}
52+
function setCell(r,c,val,klass) {
53+
const td = document.getElementById(`cell-${r}-${c}`);
54+
td.textContent = val || '';
55+
td.classList.remove('filled', 'given');
56+
if (klass) td.classList.add(klass);
57+
}
58+
function loadInitial(initial81) {
59+
initial = initial81;
60+
givenMask = [];
61+
for (let i=0;i<81;i++) {
62+
const r = Math.floor(i/9), c = i%9;
63+
const ch = initial81[i];
64+
const isGiven = ch !== '.';
65+
givenMask.push(isGiven);
66+
setCell(r,c,isGiven ? ch : '', isGiven ? 'given' : null);
67+
}
68+
}
69+
function replayTo(k) {
70+
// reset to initial
71+
loadInitial(initial);
72+
for (let i=0;i<k;i++) {
73+
const {r,c,v} = trace.steps[i];
74+
setCell(r,c,String(v),'filled');
75+
}
76+
idx = k;
77+
updateButtons();
78+
}
79+
function updateButtons() {
80+
const disabled = !trace;
81+
document.getElementById('btn-begin').disabled = disabled || idx===0;
82+
document.getElementById('btn-prev').disabled = disabled || idx===0;
83+
document.getElementById('btn-next').disabled = disabled || idx>=trace.steps.length;
84+
document.getElementById('btn-end').disabled = disabled || idx>=trace.steps.length;
85+
document.getElementById('btn-play').disabled = disabled;
86+
document.getElementById('btn-play').textContent = playing ? 'Pause' : 'Play';
87+
}
88+
89+
document.getElementById('file').addEventListener('change', async (e) => {
90+
const file = e.target.files[0];
91+
if (!file) return;
92+
const text = await file.text();
93+
const data = JSON.parse(text);
94+
if (data.kind !== 'solution_reveal') { alert('Unexpected trace kind'); return; }
95+
trace = data; idx = 0;
96+
buildGrid();
97+
loadInitial(trace.initial);
98+
statsEl.textContent = `steps: ${trace.steps.length} · ${trace.stats.ms} ms · nodes ${trace.stats.nodes} · backtracks ${trace.stats.backtracks}`;
99+
updateButtons();
100+
});
101+
102+
document.getElementById('btn-begin').onclick = () => replayTo(0);
103+
document.getElementById('btn-prev').onclick = () => replayTo(Math.max(0, idx-1));
104+
document.getElementById('btn-next').onclick = () => replayTo(Math.min(trace.steps.length, idx+1));
105+
document.getElementById('btn-end').onclick = () => replayTo(trace.steps.length);
106+
document.getElementById('btn-play').onclick = () => {
107+
if (!trace) return;
108+
if (playing) { clearInterval(playing); playing = null; updateButtons(); return; }
109+
playing = setInterval(() => {
110+
if (idx >= trace.steps.length) { clearInterval(playing); playing = null; updateButtons(); return; }
111+
replayTo(idx+1);
112+
}, 60);
113+
updateButtons();
114+
};
115+
116+
buildGrid();
117+
</script>
118+
</body>
119+
</html>

0 commit comments

Comments
 (0)