Skip to main content

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​

src/pencilmark/model.py

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.)
NameWhat it does
def bitdigit d (1-based) -> its one-bit candidate mask
def popcountnumber of candidates in the mask m
def digits_ofthe digits of a candidate mask, smallest first
def shape_ratioRadius of gyration of a cell set relative to the most compact (square) arrangement of the same number of…
class Groupcells whose digits never repeat (house, region, diagonal, window, cage) with its kind, cage total and shape
class Puzzleengine puzzle: D digits, cell coordinates, groups, pair relations, n-ary constraints, domains and givens;…
def standardClassic size x size sudoku with box_h x box_w boxes.
def parse_gridgrid text (one character per cell or space-separated tokens; .
def add_diagonalsadd the two main diagonals of a size x size grid as groups -> p
def add_windokuadd the four windoku windows of a 9x9 grid as groups -> p
def jigsawregions: size*size chars, each a region id; replaces boxes.
def add_nonconsecutiveadd a nonconsec relation between every orthogonally adjacent pair of a size x size grid -> p
def add_antiknightadd a neq relation between every pair of cells a knight's move apart in a size x size grid -> p
def add_cagescages: list of (sum_or_None, [cell indices])
def subgrid_idsRow-major cell ids of samurai sub-grid k (0=TL,1=TR,2=centre,3=BL,4=BR).
def apply_variant_to_subgridAdd a variant's extra constraints to one 9x9 sub-grid of a samurai -- the group-agnostic model doesn't care…
def samuraiFive overlapping 9x9 grids on a 21x21 canvas (corner grids share a box with the centre grid).
def add_predicateGeneric custom rule over a small group of cells: fn(*digits) -> bool, compiled to an n-ary table constraint…

def bit(d: int) -> int​

source

digit d (1-based) -> its one-bit candidate mask

def popcount(m: int) -> int​

source

number of candidates in the mask m

def digits_of(m: int)​

source

the digits of a candidate mask, smallest first

def shape_ratio(coords) -> float​

source

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​

source

cells whose digits never repeat (house, region, diagonal, window, cage) with its kind, cage total and shape

class Puzzle​

source

engine puzzle: D digits, cell coordinates, groups, pair relations, n-ary constraints, domains and givens; call finalize() before solving

def finalize(self)​

source

compile lines and clones, then build the derived tables (houses, peers, pair-relation exclusions) -> self

def n_empty(self)​

source

number of cells without a given

def standard(size: int, box_h: int, box_w: int, grid: str='', name='') -> Puzzle​

source

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​

source

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​

source

add the two main diagonals of a size x size grid as groups -> p

def add_windoku(p: Puzzle) -> Puzzle​

source

add the four windoku windows of a 9x9 grid as groups -> p

def jigsaw(size: int, regions: str, grid: str='', name='') -> Puzzle​

source

regions: size*size chars, each a region id; replaces boxes.

def add_nonconsecutive(p: Puzzle, size: int) -> Puzzle​

source

add a nonconsec relation between every orthogonally adjacent pair of a size x size grid -> p

def add_antiknight(p: Puzzle, size: int) -> Puzzle​

source

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​

source

cages: list of (sum_or_None, [cell indices])

def subgrid_ids(p: Puzzle, k: int) -> list​

source

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​

source

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​

source

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​

source

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​

src/pencilmark/cbackend.py

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.

NameWhat it does
def code_nameMove.code (int from C++) -> short pattern code / fish name.
class CMovepencilmark.solver.Move plus structured metadata of the C++-only techniques: xmeta (dict, see XMETA_KEYS),…
def to_cpuzzleFinalized pencilmark.model.Puzzle -> C++ puzzle (structure only; givens are passed per solve).
def detect_linesLine structure hidden in pair relations (translators emit thermos, whispers, kropki dots as pair tables):…
def solveRun the C++ solver.
def explain_recordthe engine's structured justification of one state_moves(details=True) move (as solve(explain=True) gives),…
def state_movesEvery move available at ONE state (puzzle.givens = filled cells; puzzle.domains = the marks / allowed digits…
class BruteResultbrute_solve result: the solutions found (a list, as before) plus .complete: False when the node limit…
def brute_solvepencilmark.solver.brute_solve equivalent; start = (vals, cand) or None.
def computer_searchPure computer search: unbounded trial, forcing chains / nets (basic, dynamic, nested), Nishio nets, pattern…
def search_disabledparams['search']: 'human' -> no computer search; bounded human case work stays (ShortTrial = contradiction…
def no_guess_paramsGuessing is off by default.

def code_name(code)​

source

Move.code (int from C++) -> short pattern code / fish name.

class CMove(S.Move)​

source

pencilmark.solver.Move plus structured metadata of the C++-only techniques: xmeta (dict, see XMETA_KEYS), family, code.

def to_cpuzzle(p)​

source

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)​

source

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)​

source

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)​

source

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=())​

source

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)​

source

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)​

source

pencilmark.solver.brute_solve equivalent; start = (vals, cand) or None. Returns a list of solutions (BruteResult: check .complete -- False = node limit hit, verdict unknown).

source

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)​

source

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=())​

source

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​

src/pencilmark/solver.py

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.

NameWhat it does
class Move
class Step

class Move​

source

No docstring.

class Step​

source

No docstring.

pencilmark.registry​

src/pencilmark/registry.py

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)
NameWhat it does
class TechniqueOne row of data/techniques.csv (columns in the module docstring).
class CodeOne row of data/codes.csv (columns in the module docstring).
class PatternOne row of data/patterns.csv (columns in the module docstring).
def techniquesEvery engine technique, in file order (data/techniques.csv).
def codesChip code -> its row of data/codes.csv, in file order.
def patternsEvery engine pattern code, by cpp_code (data/patterns.csv).
def by_nameEngine name -> technique.
def chip(chip code, chip name) of a technique that has a code.

class Technique​

source

One row of data/techniques.csv (columns in the module docstring).

class Code​

source

One row of data/codes.csv (columns in the module docstring).

class Pattern​

source

One row of data/patterns.csv (columns in the module docstring).

def techniques() -> tuple[Technique, ...]​

source

Every engine technique, in file order (data/techniques.csv).

def codes() -> dict[str, Code]​

source

Chip code -> its row of data/codes.csv, in file order.

def patterns() -> tuple[Pattern, ...]​

source

Every engine pattern code, by cpp_code (data/patterns.csv).

def by_name() -> dict[str, Technique]​

source

Engine name -> technique.

def chip(t: Technique) -> tuple[str, str]​

source

(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.

NameWhat it does
def have_ortoolsTrue if OR-Tools CP-SAT can be imported (checked once).
def supportedTrue if every relation / constraint of the finalized puzzle is one the checker models.
def build_model
def cpsat_solutionsCP-SAT only (needs OR-Tools): solutions, or None if a rule can't be expressed or the time limit is hit.
def solutionsup to limit solutions, or None if undecided.
def count
def verified_solutionsDrop-in for cbackend.brute_solve(p, limit, node_limit) when verifying a translation: CP-SAT first when…
def decide-> (solutions, decided).

def have_ortools()​

source

True if OR-Tools CP-SAT can be imported (checked once).

def supported(p)​

source

True if every relation / constraint of the finalized puzzle is one the checker models.

def build_model(p)​

source

No docstring.

def cpsat_solutions(p, limit=2, time_limit=60.0, workers=1)​

source

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)​

source

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)​

source

No docstring.

def verified_solutions(p, limit=2, node_limit=NODE_LIMIT, time_limit=60.0)​

source

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)​

source

-> (solutions, decided). decided False: neither search could finish (solutions may be partial).

pencilmark._data​

src/pencilmark/_data.py

Package data (model parameters and tables shipped with pencilmark).

NameWhat it does
def patha file in pencilmark/data as an importlib Traversable.
def existsTrue if pencilmark/data has a file called name
def read_textUTF-8 text of a file in pencilmark/data
def load_jsonparsed JSON of a file in pencilmark/data
def load_paramsraw engine parameters (before scoring.complete_params).

def path(name)​

source

a file in pencilmark/data as an importlib Traversable.

def exists(name)​

source

True if pencilmark/data has a file called name

def read_text(name)​

source

UTF-8 text of a file in pencilmark/data

def load_json(name)​

source

parsed JSON of a file in pencilmark/data

def load_params(name='params.json')​

source

raw engine parameters (before scoring.complete_params).