Skip to content

Commit ee181b3

Browse files
Add CLI tools for format conversion, CNF export, and batch explain
1 parent 2d66152 commit ee181b3

File tree

8 files changed

+279
-4
lines changed

8 files changed

+279
-4
lines changed

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,22 @@ sudoku-dlx solve --grid "<81chars>" --trace out.json
109109
# Open web/visualizer.html and load out.json
110110
```
111111
112+
## Formats & batch explain
113+
Convert between txt/csv/jsonl:
114+
```bash
115+
sudoku-dlx convert --in puzzles.txt --out puzzles.csv
116+
```
117+
118+
Explain many puzzles to NDJSON:
119+
```bash
120+
sudoku-dlx explain-file --in puzzles.txt --out steps.ndjson --max-steps 200
121+
```
122+
123+
Export to DIMACS CNF:
124+
```bash
125+
sudoku-dlx to-cnf --grid "<81chars>" --out puzzle.cnf
126+
```
127+
112128
## Cross-check with SAT (optional)
113129
Install the optional extra:
114130

docs/batch.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,21 @@ sudoku-dlx stats-file --in puzzles.txt --limit 5000 --sample 1000 --json stats.j
2121
```bash
2222
sudoku-dlx dedupe --in puzzles.txt --out unique.txt
2323
```
24+
25+
## Convert between formats
26+
Supported: txt (one 81-char per line), csv (column grid), jsonl/ndjson ({"grid": "..."} per line).
27+
```bash
28+
sudoku-dlx convert --in puzzles.txt --out puzzles.csv
29+
sudoku-dlx convert --in puzzles.csv --out puzzles.jsonl
30+
```
31+
32+
## Batch explain
33+
Produce one JSON object per line with steps and progress:
34+
```bash
35+
sudoku-dlx explain-file --in puzzles.txt --out steps.ndjson --max-steps 200
36+
```
37+
38+
## Export to CNF
39+
```bash
40+
sudoku-dlx to-cnf --grid "<81chars>" --out puzzle.cnf
41+
```

docs/cli.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
Run `sudoku-dlx --help` for a full list.
44

5+
<!-- core -->
56
## Solve
67
```bash
78
sudoku-dlx solve --grid "<81chars>" [--pretty] [--stats] [--trace out.json] [--crosscheck sat]
@@ -44,3 +45,21 @@ sudoku-dlx rate-file --in puzzles.txt --json > scores.ndjson
4445
# Stats with sampling & histogram CSV
4546
sudoku-dlx stats-file --in puzzles.txt --limit 5000 --sample 1000 --json stats.json
4647
```
48+
49+
<!-- extras -->
50+
## Convert formats
51+
```bash
52+
# auto-detects txt/csv/jsonl by extension
53+
sudoku-dlx convert --in puzzles.txt --out puzzles.csv
54+
sudoku-dlx convert --in puzzles.csv --out puzzles.jsonl
55+
```
56+
57+
## Explain (batch)
58+
```bash
59+
sudoku-dlx explain-file --in puzzles.txt --out steps.ndjson --max-steps 200
60+
```
61+
62+
## Export to DIMACS CNF
63+
```bash
64+
sudoku-dlx to-cnf --grid "<81chars>" --out puzzle.cnf
65+
```

src/sudoku_dlx/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
from .canonical import canonical_form
1717
from .generate import generate
1818
from .rating import rate
19-
from .crosscheck import sat_solve
19+
from .crosscheck import sat_solve, cnf_dimacs_lines
20+
from .formats import read_grids, write_grids, detect_format
2021
from .solver import (
2122
SOLVER,
2223
generate_minimal,
@@ -46,6 +47,10 @@
4647
"canonical_form",
4748
"generate",
4849
"sat_solve",
50+
"cnf_dimacs_lines",
51+
"read_grids",
52+
"write_grids",
53+
"detect_format",
4954
# Legacy exports
5055
"SOLVER",
5156
"generate_minimal",

src/sudoku_dlx/cli.py

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
from typing import Optional
55

66
from .api import analyze, build_reveal_trace, from_string, is_valid, solve, to_string
7-
from .crosscheck import sat_solve
7+
from .crosscheck import sat_solve, cnf_dimacs_lines
88
from .explain import explain
99
from .canonical import canonical_form
1010
from .generate import generate
1111
from .rating import rate
12+
from .formats import detect_format, read_grids, write_grids
1213
from statistics import mean
1314

1415

@@ -122,6 +123,44 @@ def cmd_check(ns: argparse.Namespace) -> int:
122123
return 0
123124

124125

126+
def cmd_convert(ns: argparse.Namespace) -> int:
127+
infmt = ns.in_format or detect_format(ns.in_path)
128+
outfmt = ns.out_format or detect_format(ns.out_path)
129+
grids = read_grids(ns.in_path, infmt)
130+
write_grids(ns.out_path, grids, outfmt)
131+
print(f"# converted {len(grids)} grids {infmt}{outfmt}", file=sys.stderr)
132+
return 0
133+
134+
135+
def cmd_to_cnf(ns: argparse.Namespace) -> int:
136+
grid = from_string(_read_grid_arg(ns))
137+
outp = pathlib.Path(ns.out_path)
138+
outp.parent.mkdir(parents=True, exist_ok=True)
139+
with outp.open("w", encoding="utf-8") as handle:
140+
for line in cnf_dimacs_lines(grid):
141+
handle.write(line)
142+
if not line.endswith("\n"):
143+
handle.write("\n")
144+
return 0
145+
146+
147+
def cmd_explain_file(ns: argparse.Namespace) -> int:
148+
inp = pathlib.Path(ns.in_path)
149+
outp = pathlib.Path(ns.out_path)
150+
grids = read_grids(str(inp), ns.in_format)
151+
outp.parent.mkdir(parents=True, exist_ok=True)
152+
written = 0
153+
with outp.open("w", encoding="utf-8") as handle:
154+
for s in grids:
155+
grid = from_string(s)
156+
data = explain(grid, max_steps=ns.max_steps)
157+
obj = {"grid": s, **data}
158+
handle.write(json.dumps(obj, separators=(",", ":"), sort_keys=True) + "\n")
159+
written += 1
160+
print(f"# wrote {written} explanations to {outp}", file=sys.stderr)
161+
return 0
162+
163+
125164
def cmd_explain(ns: argparse.Namespace) -> int:
126165
grid = from_string(_read_grid_arg(ns))
127166
data = explain(grid, max_steps=ns.max_steps)
@@ -383,6 +422,26 @@ def main(argv: Optional[list[str]] = None) -> int:
383422
check_parser.add_argument("--json", action="store_true", help="output JSON")
384423
check_parser.set_defaults(func=cmd_check)
385424

425+
convert_parser = sub.add_parser("convert", help="convert between txt/csv/jsonl formats")
426+
convert_parser.add_argument("--in", dest="in_path", required=True)
427+
convert_parser.add_argument("--out", dest="out_path", required=True)
428+
convert_parser.add_argument("--in-format", dest="in_format", choices=["txt", "csv", "jsonl"])
429+
convert_parser.add_argument("--out-format", dest="out_format", choices=["txt", "csv", "jsonl"])
430+
convert_parser.set_defaults(func=cmd_convert)
431+
432+
tocnf_parser = sub.add_parser("to-cnf", help="export one puzzle to DIMACS CNF")
433+
tocnf_parser.add_argument("--grid", help="81-char string; 0/./- for blanks")
434+
tocnf_parser.add_argument("--file", help="path to a file with 9 lines of 9 chars")
435+
tocnf_parser.add_argument("--out", dest="out_path", required=True)
436+
tocnf_parser.set_defaults(func=cmd_to_cnf)
437+
438+
explainf_parser = sub.add_parser("explain-file", help="explain many puzzles into NDJSON")
439+
explainf_parser.add_argument("--in", dest="in_path", required=True)
440+
explainf_parser.add_argument("--out", dest="out_path", required=True)
441+
explainf_parser.add_argument("--in-format", dest="in_format", choices=["txt", "csv", "jsonl"])
442+
explainf_parser.add_argument("--max-steps", type=int, default=200)
443+
explainf_parser.set_defaults(func=cmd_explain_file)
444+
386445
explain_parser = sub.add_parser(
387446
"explain", help="human-style steps (naked/hidden single, locked candidates)"
388447
)

src/sudoku_dlx/crosscheck.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
"""SAT cross-check utilities using python-sat (optional extra)."""
44

5-
from typing import List, Optional
5+
from typing import Iterable, List, Optional
66

77
Grid = List[List[int]]
88

@@ -59,6 +59,18 @@ def _encode_cnf(grid: Grid) -> list[list[int]]:
5959
return cnf
6060

6161

62+
def cnf_dimacs_lines(grid: Grid) -> Iterable[str]:
63+
"""Yield DIMACS CNF lines for ``grid`` using variables in ``[1, 729]``."""
64+
65+
cnf = _encode_cnf(grid)
66+
num_vars = 9 * 9 * 9
67+
num_clauses = len(cnf)
68+
yield f"p cnf {num_vars} {num_clauses}"
69+
for clause in cnf:
70+
literals = " ".join(str(int(lit)) for lit in clause)
71+
yield f"{literals} 0"
72+
73+
6274
def sat_solve(grid: Grid) -> Optional[Grid]:
6375
"""Solve a Sudoku grid via SAT; returns the solved grid or ``None`` if unavailable."""
6476

@@ -83,4 +95,4 @@ def sat_solve(grid: Grid) -> Optional[Grid]:
8395
return solved
8496

8597

86-
__all__ = ["sat_solve"]
98+
__all__ = ["sat_solve", "cnf_dimacs_lines"]

src/sudoku_dlx/formats.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
from __future__ import annotations
2+
3+
from typing import Iterable, List, Optional
4+
import csv
5+
import json
6+
import pathlib
7+
8+
9+
# All “grid strings” are 81 chars, dots for blanks.
10+
11+
def _strip_grid_line(s: str) -> str:
12+
return "".join(ch for ch in s.strip() if not ch.isspace())
13+
14+
15+
def _is_81(s: str) -> bool:
16+
return len(s) == 81
17+
18+
19+
def detect_format(path: str) -> str:
20+
p = pathlib.Path(path)
21+
ext = p.suffix.lower().lstrip(".")
22+
if ext in {"txt", "sdk"}:
23+
return "txt"
24+
if ext in {"csv"}:
25+
return "csv"
26+
if ext in {"jsonl", "ndjson"}:
27+
return "jsonl"
28+
# default: try txt
29+
return "txt"
30+
31+
32+
def read_grids(path: str, fmt: Optional[str] = None) -> List[str]:
33+
fmt = fmt or detect_format(path)
34+
p = pathlib.Path(path)
35+
if fmt == "txt":
36+
out: List[str] = []
37+
for line in p.read_text(encoding="utf-8").splitlines():
38+
s = _strip_grid_line(line)
39+
if not s:
40+
continue
41+
if not _is_81(s):
42+
raise ValueError(f"bad grid length (expected 81): {s!r}")
43+
out.append(s)
44+
return out
45+
if fmt == "csv":
46+
out: List[str] = []
47+
with p.open("r", encoding="utf-8", newline="") as f:
48+
sniffer = csv.Sniffer()
49+
text = f.read()
50+
f.seek(0)
51+
try:
52+
dialect = sniffer.sniff(text)
53+
except Exception:
54+
dialect = csv.excel
55+
reader = csv.DictReader(f, dialect=dialect)
56+
if reader.fieldnames is None or len(reader.fieldnames) == 0:
57+
raise ValueError("CSV missing header row")
58+
field = "grid" if "grid" in reader.fieldnames else reader.fieldnames[0]
59+
for row in reader:
60+
cell = row.get(field, "")
61+
s = _strip_grid_line(cell)
62+
if _is_81(s):
63+
out.append(s)
64+
return out
65+
if fmt == "jsonl":
66+
out: List[str] = []
67+
with p.open("r", encoding="utf-8") as f:
68+
for line in f:
69+
if not line.strip():
70+
continue
71+
obj = json.loads(line)
72+
s = _strip_grid_line(obj.get("grid", ""))
73+
if _is_81(s):
74+
out.append(s)
75+
return out
76+
raise ValueError(f"unknown format: {fmt}")
77+
78+
79+
def write_grids(path: str, grids: Iterable[str], fmt: Optional[str] = None) -> None:
80+
fmt = fmt or detect_format(path)
81+
p = pathlib.Path(path)
82+
p.parent.mkdir(parents=True, exist_ok=True)
83+
if fmt == "txt":
84+
p.write_text("\n".join(grids) + "\n", encoding="utf-8")
85+
return
86+
if fmt == "csv":
87+
with p.open("w", encoding="utf-8", newline="") as f:
88+
writer = csv.writer(f)
89+
writer.writerow(["grid"])
90+
for s in grids:
91+
writer.writerow([s])
92+
return
93+
if fmt == "jsonl":
94+
with p.open("w", encoding="utf-8") as f:
95+
for s in grids:
96+
f.write(json.dumps({"grid": s}, separators=(",", ":")) + "\n")
97+
return
98+
raise ValueError(f"unknown format: {fmt}")
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import json
2+
3+
from sudoku_dlx import cli
4+
5+
PUZ = (
6+
"53..7...."
7+
"6..195..."
8+
".98....6."
9+
"8...6...3"
10+
"4..8.3..1"
11+
"7...2...6"
12+
".6....28."
13+
"...419..5"
14+
"....8..79"
15+
)
16+
17+
18+
def test_convert_txt_csv_roundtrip(tmp_path):
19+
ptxt = tmp_path / "p.txt"
20+
ptxt.write_text(PUZ + "\n" + PUZ + "\n", encoding="utf-8")
21+
pcsv = tmp_path / "p.csv"
22+
rc = cli.main(["convert", "--in", str(ptxt), "--out", str(pcsv)])
23+
assert rc == 0
24+
ptxt2 = tmp_path / "q.txt"
25+
rc = cli.main(["convert", "--in", str(pcsv), "--out", str(ptxt2)])
26+
assert rc == 0
27+
assert ptxt2.read_text(encoding="utf-8").strip().splitlines()[0] == PUZ
28+
29+
30+
def test_to_cnf_writes_dimacs(tmp_path):
31+
out = tmp_path / "p.cnf"
32+
rc = cli.main(["to-cnf", "--grid", PUZ, "--out", str(out)])
33+
assert rc == 0
34+
lines = out.read_text(encoding="utf-8").splitlines()
35+
assert lines[0].startswith("p cnf ")
36+
assert all(line.endswith(" 0") or line.startswith("p ") for line in lines)
37+
38+
39+
def test_explain_file_ndjson(tmp_path):
40+
ptxt = tmp_path / "p.txt"
41+
ptxt.write_text(PUZ + "\n", encoding="utf-8")
42+
out = tmp_path / "steps.ndjson"
43+
rc = cli.main(["explain-file", "--in", str(ptxt), "--out", str(out)])
44+
assert rc == 0
45+
data = [json.loads(x) for x in out.read_text(encoding="utf-8").splitlines() if x.strip()]
46+
assert len(data) == 1
47+
obj = data[0]
48+
assert "grid" in obj and "steps" in obj and "progress" in obj

0 commit comments

Comments
 (0)