Skip to content

Commit bb2e26f

Browse files
Merge pull request #18 from SaridakisStamatisChristos/codex/add-batch-tools-and-benchmarking-features
Add batch puzzle tools and benchmark script
2 parents 9f96f6c + 0ebcee6 commit bb2e26f

File tree

4 files changed

+140
-9
lines changed

4 files changed

+140
-9
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ sudoku-dlx rate --grid "<81chars>"
4646
sudoku-dlx canon --grid "<81chars>" # D4 × bands/stacks × inner row/col × digit relabel
4747
# Produces a stable 81-char string for deduping datasets.
4848

49+
# Batch tools
50+
sudoku-dlx gen-batch --out puzzles.txt --count 1000 --givens 30 --symmetry rot180 --minimal
51+
sudoku-dlx rate-file --in puzzles.txt --csv ratings.csv
52+
python bench/bench_file.py --in puzzles.txt
53+
4954
# Dedupe a file of puzzles (fast)
5055
sudoku-dlx dedupe --in puzzles.txt --out unique.txt
5156

bench/bench_file.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import argparse
2+
import statistics as st
3+
import sys
4+
import time
5+
6+
from sudoku_dlx import from_string, solve
7+
8+
9+
def main() -> int:
10+
parser = argparse.ArgumentParser(
11+
description="Benchmark solve() on a file of puzzles (81-chars per line)."
12+
)
13+
parser.add_argument("--in", dest="in_path", required=True)
14+
ns = parser.parse_args()
15+
16+
times: list[float] = []
17+
solved = 0
18+
with open(ns.in_path, "r", encoding="utf-8") as handle:
19+
for line in handle:
20+
s = "".join(ch for ch in line.strip() if not ch.isspace())
21+
if not s:
22+
continue
23+
grid = from_string(s)
24+
t0 = time.perf_counter()
25+
result = solve(grid)
26+
if result is None:
27+
continue
28+
times.append((time.perf_counter() - t0) * 1000)
29+
solved += 1
30+
if not times:
31+
print("no puzzles measured", file=sys.stderr)
32+
return 2
33+
mean = st.mean(times)
34+
p95 = st.quantiles(times, n=20)[-1]
35+
print(f"# puzzles: {solved} · mean {mean:.2f} ms · p95 {p95:.2f} ms")
36+
return 0
37+
38+
39+
if __name__ == "__main__":
40+
raise SystemExit(main())

src/sudoku_dlx/cli.py

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
from __future__ import annotations
2+
13
import argparse
4+
import csv
25
import pathlib
6+
import random
37
import sys
48
from typing import Optional
59

@@ -70,6 +74,52 @@ def cmd_canon(ns: argparse.Namespace) -> int:
7074
return 0
7175

7276

77+
def cmd_gen_batch(ns: argparse.Namespace) -> int:
78+
"""Generate many canonicalized, unique puzzles quickly."""
79+
80+
out_path = pathlib.Path(ns.out)
81+
seen: set[str] = set()
82+
rng = random.Random(ns.seed)
83+
unique: list[str] = []
84+
while len(unique) < ns.count:
85+
grid = generate(
86+
seed=rng.randrange(2**31 - 1),
87+
target_givens=ns.givens,
88+
minimal=ns.minimal,
89+
symmetry=ns.symmetry,
90+
)
91+
canon = canonical_form(grid)
92+
if canon in seen:
93+
continue
94+
seen.add(canon)
95+
unique.append(canon)
96+
out_path.parent.mkdir(parents=True, exist_ok=True)
97+
with out_path.open("w", encoding="utf-8") as handle:
98+
for value in unique:
99+
handle.write(value + "\n")
100+
print(f"# generated: {len(unique)}", file=sys.stderr)
101+
return 0
102+
103+
104+
def cmd_rate_file(ns: argparse.Namespace) -> int:
105+
inp = pathlib.Path(ns.in_path)
106+
rows: list[tuple[str, float]] = []
107+
with inp.open("r", encoding="utf-8") as handle:
108+
for line in handle:
109+
s = "".join(ch for ch in line.strip() if not ch.isspace())
110+
if not s:
111+
continue
112+
score = rate(from_string(s))
113+
rows.append((s, score))
114+
print(f"{score:.1f}")
115+
if ns.csv_path:
116+
with open(ns.csv_path, "w", newline="", encoding="utf-8") as csv_handle:
117+
writer = csv.writer(csv_handle)
118+
writer.writerow(["grid", "score"])
119+
writer.writerows(rows)
120+
return 0
121+
122+
73123
def cmd_dedupe(ns: argparse.Namespace) -> int:
74124
inp = pathlib.Path(ns.in_path)
75125
outp = pathlib.Path(ns.out_path)
@@ -126,20 +176,32 @@ def main(argv: Optional[list[str]] = None) -> int:
126176
canon_parser.set_defaults(func=cmd_canon)
127177

128178
dedupe_parser = sub.add_parser(
129-
"dedupe",
130-
help="dedupe puzzles by canonical form (one 81-char grid per line)",
179+
"dedupe", help="dedupe puzzles by canonical form (one 81-char grid per line)"
131180
)
181+
dedupe_parser.add_argument("--in", dest="in_path", required=True, help="input text file")
132182
dedupe_parser.add_argument(
133-
"--in", dest="in_path", required=True, help="input text file with one grid per line"
134-
)
135-
dedupe_parser.add_argument(
136-
"--out",
137-
dest="out_path",
138-
required=True,
139-
help="output file path for unique canonical grids",
183+
"--out", dest="out_path", required=True, help="output file for unique canonical grids"
140184
)
141185
dedupe_parser.set_defaults(func=cmd_dedupe)
142186

187+
genb_parser = sub.add_parser("gen-batch", help="generate N unique puzzles to a file")
188+
genb_parser.add_argument("--out", required=True, help="output file (81-char per line)")
189+
genb_parser.add_argument("--count", type=int, default=100, help="number of puzzles")
190+
genb_parser.add_argument("--givens", type=int, default=30)
191+
genb_parser.add_argument(
192+
"--symmetry", choices=["none", "rot180", "mix"], default="mix"
193+
)
194+
genb_parser.add_argument("--minimal", action="store_true")
195+
genb_parser.add_argument("--seed", type=int, default=None)
196+
genb_parser.set_defaults(func=cmd_gen_batch)
197+
198+
ratef_parser = sub.add_parser("rate-file", help="rate each puzzle in a file")
199+
ratef_parser.add_argument("--in", dest="in_path", required=True)
200+
ratef_parser.add_argument(
201+
"--csv", dest="csv_path", help="optional CSV output path"
202+
)
203+
ratef_parser.set_defaults(func=cmd_rate_file)
204+
143205
gen_parser = sub.add_parser("gen", help="generate a puzzle")
144206
gen_parser.add_argument("--seed", type=int, default=None)
145207
gen_parser.add_argument("--givens", type=int, default=28, help="target number of clues (approx)")

tests/test_batch_tools.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from sudoku_dlx import cli
2+
3+
4+
def test_gen_batch_and_rate_file(tmp_path):
5+
out = tmp_path / "puzzles.txt"
6+
rc = cli.main(
7+
[
8+
"gen-batch",
9+
"--out",
10+
str(out),
11+
"--count",
12+
"5",
13+
"--givens",
14+
"35",
15+
"--symmetry",
16+
"none",
17+
]
18+
)
19+
assert rc == 0
20+
assert out.exists()
21+
lines = [ln.strip() for ln in out.read_text(encoding="utf-8").splitlines() if ln.strip()]
22+
assert len(lines) == 5
23+
rc = cli.main(["rate-file", "--in", str(out)])
24+
assert rc == 0

0 commit comments

Comments
 (0)