The engine interface
The Python side of the C++ engine: the puzzle model, the calls into the engine, its move records, the technique registry. Generated from the docstrings by tools/gen_reference_md.py; public names only.
pencilmark.model
Group-agnostic puzzle model.
A puzzle is cells (with 2D coordinates, used only for group-shape scoring), digits 1..D, and constraints. Nothing downstream assumes rows/columns/boxes:
houses -- groups of exactly D cells that must contain every digit once
(rows, cols, boxes, jigsaw regions, full diagonals, windoku
windows, every sub-grid unit of a samurai...)
cages -- all-different groups smaller than D, optionally with a sum
(killer cages)
relations -- pairwise constraints between specific cells: 'neq' (anti-knight,
anti-king, ...), 'nonconsec' (|a-b| != 1)
domains -- per-cell allowed-digit masks (odd/even shading, etc.)
| Name | What it does |
|---|---|
def bit | digit d (1-based) -> its one-bit candidate mask |
def popcount | number of candidates in the mask m |
def digits_of | the digits of a candidate mask, smallest first |
def shape_ratio | Radius of gyration of a cell set relative to the most compact (square) arrangement of the same number of… |
class Group | cells whose digits never repeat (house, region, diagonal, window, cage) with its kind, cage total and shape |
class Puzzle | engine puzzle: D digits, cell coordinates, groups, pair relations, n-ary constraints, domains and givens;… |
def standard | Classic size x size sudoku with box_h x box_w boxes. |
def parse_grid | grid text (one character per cell or space-separated tokens; . |
def add_diagonals | add the two main diagonals of a size x size grid as groups -> p |
def add_windoku | add the four windoku windows of a 9x9 grid as groups -> p |
def jigsaw | regions: size*size chars, each a region id; replaces boxes. |
def add_nonconsecutive | add a nonconsec relation between every orthogonally adjacent pair of a size x size grid -> p |
def add_antiknight | add a neq relation between every pair of cells a knight's move apart in a size x size grid -> p |
def add_cages | cages: list of (sum_or_None, [cell indices]) |
def subgrid_ids | Row-major cell ids of samurai sub-grid k (0=TL,1=TR,2=centre,3=BL,4=BR). |
def apply_variant_to_subgrid | Add a variant's extra constraints to one 9x9 sub-grid of a samurai -- the group-agnostic model doesn't care… |
def samurai | Five overlapping 9x9 grids on a 21x21 canvas (corner grids share a box with the centre grid). |
def add_predicate | Generic custom rule over a small group of cells: fn(*digits) -> bool, compiled to an n-ary table constraint… |
def bit(d: int) -> int
digit d (1-based) -> its one-bit candidate mask
def popcount(m: int) -> int
number of candidates in the mask m
def digits_of(m: int)
the digits of a candidate mask, smallest first
def shape_ratio(coords) -> float
Radius of gyration of a cell set relative to the most compact (square) arrangement of the same number of cells. 1.0 = square, larger = more spread out (a 9-cell row is ~2.24). Same formula as the C# pencilmark.
class Group
cells whose digits never repeat (house, region, diagonal, window, cage) with its kind, cage total and shape
class Puzzle
engine puzzle: D digits, cell coordinates, groups, pair relations, n-ary constraints, domains and givens; call finalize() before solving
def finalize(self)
compile lines and clones, then build the derived tables (houses, peers, pair-relation exclusions) -> self
def n_empty(self)
number of cells without a given
def standard(size: int, box_h: int, box_w: int, grid: str='', name='') -> Puzzle
Classic size x size sudoku with box_h x box_w boxes. grid uses '.'/'0' for blanks and 1-9 then A-Z for digits >9 (or space-separated numbers).
def parse_grid(grid: str, size: int) -> dict
grid text (one character per cell or space-separated tokens; . 0 - empty, A = 10) -> {cell index: digit}
def add_diagonals(p: Puzzle, size: int) -> Puzzle
add the two main diagonals of a size x size grid as groups -> p
def add_windoku(p: Puzzle) -> Puzzle
add the four windoku windows of a 9x9 grid as groups -> p
def jigsaw(size: int, regions: str, grid: str='', name='') -> Puzzle
regions: size*size chars, each a region id; replaces boxes.
def add_nonconsecutive(p: Puzzle, size: int) -> Puzzle
add a nonconsec relation between every orthogonally adjacent pair of a size x size grid -> p
def add_antiknight(p: Puzzle, size: int) -> Puzzle
add a neq relation between every pair of cells a knight's move apart in a size x size grid -> p
def add_cages(p: Puzzle, cages) -> Puzzle
cages: list of (sum_or_None, [cell indices])
def subgrid_ids(p: Puzzle, k: int) -> list
Row-major cell ids of samurai sub-grid k (0=TL,1=TR,2=centre,3=BL,4=BR).
def apply_variant_to_subgrid(p: Puzzle, k: int, variant: str) -> Puzzle
Add a variant's extra constraints to one 9x9 sub-grid of a samurai -- the group-agnostic model doesn't care that they overlap other grids.
def samurai(grids: list[str] | None=None, name='') -> Puzzle
Five overlapping 9x9 grids on a 21x21 canvas (corner grids share a box with the centre grid). grids: optional 5 x 81-char strings in order TL, TR, centre, BL, BR.
def add_predicate(p: Puzzle, cells, fn, name='custom', max_tuples=200000) -> Puzzle
Generic custom rule over a small group of cells: fn(*digits) -> bool, compiled to an n-ary table constraint (allowed tuples) by enumerating the cells' domains. Every engine path that understands tables (TableCombination, brute force, featurize) and the CP-SAT verifier then handle it, with no new solver code. Example (diamond: centre digit divides the sum of its four orthogonal neighbours): add_predicate(p, [ctr, n, e, s, w], lambda c, *v: sum(v) % c == 0, "diamond"). Keep groups small (domain product <= max_tuples); larger sums should use sum constraints.
pencilmark.cbackend
C++ backend for pencilmark.solver (sources and build script in csolver/).
Drop-in fast paths with behavioural parity to the Python reference:
from pencilmark import cbackend as CB
steps, solved = CB.solve(puzzle, params) # ~ Solver(puzzle, Scorer(params)).solve()
sols = CB.brute_solve(puzzle, limit=2) # ~ pencilmark.solver.brute_solve
Steps are real pencilmark.solver.Step / Move objects, so pencilmark.scoring.grade() works unchanged.
| Name | What it does |
|---|---|
def code_name | Move.code (int from C++) -> short pattern code / fish name. |
class CMove | pencilmark.solver.Move plus structured metadata of the C++-only techniques: xmeta (dict, see XMETA_KEYS),… |
def to_cpuzzle | Finalized pencilmark.model.Puzzle -> C++ puzzle (structure only; givens are passed per solve). |
def detect_lines | Line structure hidden in pair relations (translators emit thermos, whispers, kropki dots as pair tables):… |
def solve | Run the C++ solver. |
def explain_record | the engine's structured justification of one state_moves(details=True) move (as solve(explain=True) gives),… |
def state_moves | Every move available at ONE state (puzzle.givens = filled cells; puzzle.domains = the marks / allowed digits… |
class BruteResult | brute_solve result: the solutions found (a list, as before) plus .complete: False when the node limit… |
def brute_solve | pencilmark.solver.brute_solve equivalent; start = (vals, cand) or None. |
def computer_search | Pure computer search: unbounded trial, forcing chains / nets (basic, dynamic, nested), Nishio nets, pattern… |
def search_disabled | params['search']: 'human' -> no computer search; bounded human case work stays (ShortTrial = contradiction… |
def no_guess_params | Guessing is off by default. |
def code_name(code)
Move.code (int from C++) -> short pattern code / fish name.
class CMove(S.Move)
pencilmark.solver.Move plus structured metadata of the C++-only techniques: xmeta (dict, see XMETA_KEYS), family, code.
def to_cpuzzle(p)
Finalized pencilmark.model.Puzzle -> C++ puzzle (structure only; givens are passed per solve). Relation tables are taken from p.rel_ex, so any relation kind (including ones registered at runtime) works. Cached on the full arguments, so a puzzle whose rules or domains change gets a new build.
def detect_lines(p)
Line structure hidden in pair relations (translators emit thermos, whispers, kropki dots as pair tables): chains of >= 3 cells become {"type":"line"} constraints for the line layer (the pair tables stay).
def solve(puzzle, params=None, disabled=(), solution=None, allow_direct=True, max_steps=5000, cpuzzle=None, assume_unique=True, all_tiers=False, explain=False, colour_seeds=None, cand=None)
Run the C++ solver. Returns (steps, solved) with steps as pencilmark.solver.Step objects (alts = feature tuples, move.d set). cand = per-cell candidate mask of a mid-solve state (as state_moves(cand=)): pass the original puzzle + a cpuzzle built once from it, with puzzle.givens = the filled cells.
def explain_record(puzzle, move, cpuzzle=None)
the engine's structured justification of one state_moves(details=True) move (as solve(explain=True) gives), or None when the move carries no records
def state_moves(puzzle, params=None, all_tiers=True, assume_unique=False, limit_per_tech=0, disabled=(), solution=None, cpuzzle=None, written=None, details=False, cand=None, skip=())
Every move available at ONE state (puzzle.givens = filled cells; puzzle.domains = the marks / allowed digits per cell), across all technique families (direct and notes; tiers not gated -- all_tiers is accepted for clarity, the enumeration is always ungated). Returns a list of dicts sorted by price: tech (code), name, code (pattern code name or None), place ((cell, digit) or None), elim [(cell, digit), ...], d (the move's priced difficulty, Scorer.difficulty incl. covers and the soft cap). limit_per_tech keeps the cheapest n moves per technique (0 = all). assume_unique: allow uniqueness techniques (proven-unique puzzles only). solution: optional, used by Guess only. dev75+: written = per-cell bitmask of the candidates written down in this state (a replay's marks; sets the held / coverage descriptors; None = by technique: direct techniques use no notes, the others full notes); details=True adds meta (the move's meta tuple incl. descriptors), desc ({name: value}), xmeta, scan (raw count of patterns of the move's kind at this state, behind desc 'search') and pattern (cells). cand = per-cell candidate bitmask of a mid-solve state, applied after the givens: pass the ORIGINAL puzzle (and a cpuzzle built once from it) instead of rewriting puzzle.domains -- fast (no rebuild) and the puzzle's own domains stay what pattern detection expects. skip = techniques whose notes-tier strategies are not run: unlike disabled, every other strategy sees the same disabled flags, so the result is exactly the full listing's moves minus (some of) the skipped techniques'.
class BruteResult(list)
brute_solve result: the solutions found (a list, as before) plus .complete: False when the node limit stopped the search before a verdict (then [] does NOT mean "no solution", and one solution does not mean "unique").
def brute_solve(puzzle, limit=2, start=None, node_limit=2000000, cpuzzle=None)
pencilmark.solver.brute_solve equivalent; start = (vals, cand) or None. Returns a list of solutions (BruteResult: check .complete -- False = node limit hit, verdict unknown).
def computer_search()
Pure computer search: unbounded trial, forcing chains / nets (basic, dynamic, nested), Nishio nets, pattern overlay. Humans don't solve this way (CTC hosts: 0 'bifurcate' at 119 engine guess points).
def search_disabled(params)
params['search']: 'human' -> no computer search; bounded human case work stays (ShortTrial = contradiction within 4 rounds, i.e. a short Bowman's Bingo; WhatIf, EitherWay, MentalChain, case analysis). 'computer' / absent -> all. params['disabled_techniques']: extra technique ids to switch off.
def no_guess_params(sc_p, params, disabled=())
Guessing is off by default. Human mode without params['allow_guess']: where a step would guess, the solver refutes one candidate by full search instead (Trial and error, band Extreme), so every unique puzzle gets a band. allow_guess=True: the solver guesses there and the card says Unknown. A caller that switches TRIAL off explicitly (a singles-only batch, a one-technique probe) stops where it is stuck.
pencilmark.engine_records
src/pencilmark/engine_records.py
Decoding the engine's explanation records: the structured justification of one move (solve(explain=True), cbackend.explain_record) from the integer records the C++ engine writes (csrc/solver.hpp EX_* tags): its pattern, base and cover units, chains, almost locked sets, the cases tried, the rules used. The explainer (pencilmark/explain) reads these instead of finding the pattern again.
pencilmark.solver
Technique codes, names and the Move / Step records shared by the C++ solver's decoder, the scorer and the explainer. (The solver itself lives in csrc/solver.cpp.)
Each move carries a compact feature tuple consumed by scoring.Scorer: (tech, base_shape, cover, extra) cover = tuple of (kind, shape, uncovered_before) -- the set-cover sequence used to find a single (naked: covering digits with groups; hidden: covering cells with crosshatch sources), in the order a human would plausibly add them.
| Name | What it does |
|---|---|
class Move | |
class Step |
class Move
No docstring.
class Step
No docstring.
pencilmark.registry
The technique registry: every fact about a technique, a chip code or an engine pattern code, read from three tables in pencilmark/data. Grader, card, generator and explainer modules derive their lookups from here; nothing else holds a copy. The C++ engine keeps its own enum (csrc/solver.hpp), which tests/test_registry.py checks against this table.
data/techniques.csv, one row per engine technique (solver.TECH_NAMES):
id the engine's technique id (enum Tech in csrc/solver.hpp)
const the constant naming it in csrc/solver.hpp and pencilmark/solver.py
name the engine name (params, steps, explanations)
role logic | notes (writing pencil marks) | guess | pseudo (a switch, not a move: CanonicalEnumeration)
code the chip code on the card (codes.csv); blank for TakeNotes and CanonicalEnumeration, which are never
shown
display the chip's name for this technique; blank = the code's name in codes.csv
band the band of a step by this technique (BANDS); for a split technique, its lowest shape's band; blank
for steps that have no band (notes, guess, pseudo)
split 1: the band depends on the shape found (X-Wing vs Franken X-Wing, Skyscraper vs long X-Chain), so the
route names the step by its shape (codes.csv shape_band)
family the pricing family (FAMILIES): the engine's structural groups for features; blank for the roles
other than logic
gate any: switched on only for puzzles with some variant rule; sum: only with a sum rule; blank: always
direct 1: a move made without pencil marks (also csrc/solver.hpp is_direct)
mental 1: worked out in the head on the no-notes view (a subset, short chain or case split without notes):
priced at least as its written form (params mental_floor), left out of the card score's first solve
price_fallback blank: the technique has its own fitted price B. Otherwise it has none: it is always priced by the
structural meta model and borrows this technique's B when params carry no meta model
data/codes.csv, one row per chip code a route step can carry:
code three-letter chip code, Andrew Stuart's (sudokuwiki.org) where he has the strategy
name the code's name (CLI lists, alternatives)
shape_band band of a step named by this shape (a Skyscraper is Hard, a long X-Chain Very Hard); blank for codes that
only name a technique (the technique's band applies)
order study order inside a band: the route takes the lowest first; ties go to the cheaper move
subset_of the codes this one is always a special case of (every X-Wing is a Skyscraper), space-separated
se_classic Sudoku Explainer rating (Juillerat's table, the one expert solvers quote); "-": SE has no such
technique (se.py rates it 7.0)
explain the explanation catalogue entry that teaches it (explain.CATALOG: names, aliases, links to explainer
sites); blank when there is none
note why: the reason for a subset_of entry or an order (free text, not read by the code)
data/patterns.csv, one row per pattern code the engine puts on a move (Move.code, an int):
cpp_code the int the engine writes
engine_code its name in Python (cbackend.code_name)
technique the engine technique whose steps this code renames (blank: the code does not rename the step)
code the chip code of a step with this pattern
step_name the step's name on the route
explain_name the pattern's name in explanations (the explain package; blank = the technique's)
catalog_key the explanation catalogue entry for this pattern (explain.CATALOG)
| Name | What it does |
|---|---|
class Technique | One row of data/techniques.csv (columns in the module docstring). |
class Code | One row of data/codes.csv (columns in the module docstring). |
class Pattern | One row of data/patterns.csv (columns in the module docstring). |
def techniques | Every engine technique, in file order (data/techniques.csv). |
def codes | Chip code -> its row of data/codes.csv, in file order. |
def patterns | Every engine pattern code, by cpp_code (data/patterns.csv). |
def by_name | Engine name -> technique. |
def chip | (chip code, chip name) of a technique that has a code. |
class Technique
One row of data/techniques.csv (columns in the module docstring).
class Code
One row of data/codes.csv (columns in the module docstring).
class Pattern
One row of data/patterns.csv (columns in the module docstring).
def techniques() -> tuple[Technique, ...]
Every engine technique, in file order (data/techniques.csv).
def codes() -> dict[str, Code]
Chip code -> its row of data/codes.csv, in file order.
def patterns() -> tuple[Pattern, ...]
Every engine pattern code, by cpp_code (data/patterns.csv).
def by_name() -> dict[str, Technique]
Engine name -> technique.
def chip(t: Technique) -> tuple[str, str]
(chip code, chip name) of a technique that has a code.
pencilmark.cpsat_verify
src/pencilmark/cpsat_verify.py
Uniqueness checks with OR-Tools CP-SAT when installed (pip install "pencilmark[verify]"), else the engine's exhaustive C++ search.
solutions(p, limit=2, time_limit=60) -> list of solutions (each a list of digits, len p.N), or None if undecided: the puzzle uses a rule the checker does not model, or the search hit its node / time limit. decide() (used by rate, solve and count_solutions) runs the C++ search (cbackend.brute_solve) first, which settles most puzzles in microseconds. With OR-Tools installed that first search gets QUICK_NODES and CP-SAT takes over when it runs out (a sparse killer on a big board: 0.4 s instead of 27 s); without it, or when CP-SAT cannot decide, the C++ search gets the full NODE_LIMIT. CP-SAT is independent of the engine's propagation. Pair relations are encoded generically as allowed-pair tables from model.RELATIONS, so any relation kind works.
| Name | What it does |
|---|---|
def have_ortools | True if OR-Tools CP-SAT can be imported (checked once). |
def supported | True if every relation / constraint of the finalized puzzle is one the checker models. |
def build_model | |
def cpsat_solutions | CP-SAT only (needs OR-Tools): solutions, or None if a rule can't be expressed or the time limit is hit. |
def solutions | up to limit solutions, or None if undecided. |
def count | |
def verified_solutions | Drop-in for cbackend.brute_solve(p, limit, node_limit) when verifying a translation: CP-SAT first when… |
def decide | -> (solutions, decided). |
def have_ortools()
True if OR-Tools CP-SAT can be imported (checked once).
def supported(p)
True if every relation / constraint of the finalized puzzle is one the checker models.
def build_model(p)
No docstring.
def cpsat_solutions(p, limit=2, time_limit=60.0, workers=1)
CP-SAT only (needs OR-Tools): solutions, or None if a rule can't be expressed or the time limit is hit.
def solutions(p, limit=2, time_limit=60.0, workers=1, node_limit=NODE_LIMIT)
up to limit solutions, or None if undecided. With OR-Tools: CP-SAT (None if a rule can't be expressed or the time limit is hit). Without: the engine's exhaustive search on the rules CP-SAT models (None for others, or if the node limit is hit), so both installs reach the same verdicts.
def count(p, limit=2, time_limit=60.0)
No docstring.
def verified_solutions(p, limit=2, node_limit=NODE_LIMIT, time_limit=60.0)
Drop-in for cbackend.brute_solve(p, limit, node_limit) when verifying a translation: CP-SAT first when OR-Tools is installed, then the engine's exhaustive search (possibly partial if it hits the node limit).
def decide(p, limit=2, node_limit=NODE_LIMIT, time_limit=60.0)
-> (solutions, decided). decided False: neither search could finish (solutions may be partial).
pencilmark._data
Package data (model parameters and tables shipped with pencilmark).
| Name | What it does |
|---|---|
def path | a file in pencilmark/data as an importlib Traversable. |
def exists | True if pencilmark/data has a file called name |
def read_text | UTF-8 text of a file in pencilmark/data |
def load_json | parsed JSON of a file in pencilmark/data |
def load_params | raw engine parameters (before scoring.complete_params). |
def path(name)
a file in pencilmark/data as an importlib Traversable.
def exists(name)
True if pencilmark/data has a file called name
def read_text(name)
UTF-8 text of a file in pencilmark/data
def load_json(name)
parsed JSON of a file in pencilmark/data
def load_params(name='params.json')
raw engine parameters (before scoring.complete_params).