The card
How a card is computed: the band route, the technique list, difficulty, time and the publishing checks. Generated from the docstrings by tools/gen_reference_md.py; public names only.
pencilmark.export
Human-oriented exchange formats: one for the puzzle, one for its grade.
puzzle (format "sudoku-puzzle", format_version 1): specified in Puzzle format, implemented in pencilmark/exchange/ (strict reader, order-preserving writer, canonical text, fingerprint), schema docs/sudoku-puzzle.schema.json. In short: "board", "digits", flat "givens" (0 empty, null no cell), optional "solution" / "candidates", and "rules" holding every constraint, houses included ({"type": "box_grid", "origin": "r1c1", "box": [3, 3]} for a classic, {"type": "grid", ...} plus "box" rules for a jigsaw), cells named "r{row}c{col}"; "meta" is free-form information that never changes solving. Unknown keys / rule types are refused.
grade (format "sudoku-grade", format_version 1):
grader {"name": "pencilmark", "version": "1.1.0"}: the engine that made the card; a card from another version can differ
difficulty what a human feels per cell: continuous, above 0 (a nearly finished grid ~0.2), no ceiling; classic medians track Andrew Stuart's
scale (data/card_score.json "map")
time minutes for today's median player (classic units x minutes_per_unit, data/time_model.json); on request
time_pNN = the NN-th percentile over today's players and time_mean = the mean (log-normal, sigma spread_sigma)
band "Very Easy" … "Extreme" or "Unknown" (not solvable without computer search); on request band_index 0..6
contains what a solver meets going easy -> hard (band path): [{"code", "chip", "name", "band", "uses", "alternatives"}];
a chip "XWG/KTE/SCL" = each of these alone also gets you through here; singles listed without counts
notes publishing notes: [{"code": "spike", "severity": "reject" | "note", "text": "…"}]
steps [{"n", "code", "name", "placement" ("r2c8=3"), "eliminations" (["r4c5#7"]), "explanation", "detail"}]
| Name | What it does |
|---|---|
def code_names | chip code -> technique name (data/codes.csv) |
def chip_of | (chip code, band index) for a technique, using its most specific pattern name where the band depends on it:… |
def tech_band | band index of an engine technique (data/techniques.csv; SPLIT techniques at their lowest shape's band). |
def read_meta | metadata from a dict: its "meta" object plus known top-level keys (the keyword form of to_export), empty… |
def export_puzzle | engine Puzzle -> "sudoku-puzzle" dict (format_version 1, Puzzle format; pencilmark/exchange/). |
def import_puzzle | sudoku-puzzle (format_version 1, Puzzle format) -> puzzle JSON (pencilmark.puzzle_json),… |
def region_geometry | type-free region geometry the time model reads : blob_cells = share of cells in 2-D complete groups that are… |
def time_units | expected solving time in standard-classic units (1.0 = a median classic) from the grader model's time model… |
def card_fields | the field mask (Grade card 2): the requested top-level card keys; None = the default fields. |
def explain_levels | explain= option -> the step explanation levels: None / False = none, True = all, "notation" / "short" /… |
def symmetry_of | symmetries of the givens pattern (and board shape): [{"kind", "axis"?, "of": "givens"}]. |
def grade | grade() under max_seconds, stage by stage: the technique list (contains) truncates at the limit as before;… |
def code_names()
chip code -> technique name (data/codes.csv)
def chip_of(tech_id, specific=None)
(chip code, band index) for a technique, using its most specific pattern name where the band depends on it: short X-Chains (Skyscraper / 2-String Kite / Turbot) Hard, longer ones Very Hard; Franken X-Wing Very Hard.
def tech_band(t)
band index of an engine technique (data/techniques.csv; SPLIT techniques at their lowest shape's band).
def read_meta(d)
metadata from a dict: its "meta" object plus known top-level keys (the keyword form of to_export), empty values dropped.
def export_puzzle(p, meta=None, solution=None, source=None, title=None, rules_text=None)
engine Puzzle -> "sudoku-puzzle" dict (format_version 1, Puzzle format; pencilmark/exchange/). meta: free-form metadata dict; source / title / rules_text keywords are merged into meta.
def import_puzzle(d)
sudoku-puzzle (format_version 1, Puzzle format) -> puzzle JSON (pencilmark.puzzle_json), read strictly by pencilmark/exchange/.
def region_geometry(p)
type-free region geometry the time model reads : blob_cells = share of cells in 2-D complete groups that are not rectangles (jigsaw regions; diagonals are lines, excluded); extra_area_pc = 2-D complete groups per cell beyond one (windoku / centre-dot / extra regions). 0 / 0 on a standard grid.
def time_units(effort_l1, empty_cells, geometry=None)
expected solving time in standard-classic units (1.0 = a median classic) from the grader model's time model (data/time_model.json): exp(const + k_effort * L1 + k_empty * ln(empty cells) + geometry) + t0, L1 = log of the priced step sum (card "effort_l1"); fitted on era-adjusted per-player times. None without the inputs.
def card_fields(fields=None, steps=False, se=False)
the field mask (Grade card 2): the requested top-level card keys; None = the default fields. format, format_version and grader are always present. steps=True / se=True are shorthands.
def explain_levels(explain)
explain= option -> the step explanation levels: None / False = none, True = all, "notation" / "short" / "teach" or a list
def symmetry_of(p)
symmetries of the givens pattern (and board shape): [{"kind", "axis"?, "of": "givens"}]. Rule symmetry ("of": "all") is not detected yet.
def grade(p, P, card=None, with_steps=None, max_seconds=None, fields=None, explain=None, alternatives=True)
grade() under max_seconds, stage by stage: the technique list (contains) truncates at the limit as before; the difficulty route stops at its own limit (engine deadline) and the card is then band "Unknown" with difficulty / time null, the techniques found so far in contains and "stopped": "time limit" (Grade card 1).
pencilmark.band_path
Band-by-band human solve and technique hints.
The solve uses Very Easy moves until none are left, then Easy, then Medium, and so on, dropping back to the lowest band after every move. Within a band it takes the simplest technique first (study order, data/codes.csv), then the cheapest move. Each step is named by its most specific shape (a Skyscraper, not "X-Chain"; a Franken X-Wing, not "X-Wing"), and its band comes from that shape.
The techniques this solve uses are the puzzle's hints: what a human solving from easy to hard will meet. SUBSET maps each code to the codes it is a special case of (always true: every X-Wing is a Skyscraper), so a learner who knows a technique also covers its special cases (covered(), lesson()).
band_path(puzzle, P) -> {"solved", "band", "steps": [{code, name, band, place, elims}], "profile": {code: uses}}
| Name | What it does |
|---|---|
def covered | a learner who knows known can do code: they know it, or a technique it is always a case of. |
def classify | (code, name, band) of one state_moves move, by its most specific shape. |
def variant_gate | engine techniques to switch off for this puzzle: variant techniques without any variant rule, sum techniques… |
def band_path | solve band by band (see module doc). |
def contents | What's in the puzzle, deterministically (same puzzle -> same card). |
def fold | contents shown to players: a full house is always both a naked and a hidden single and ends nearly every… |
def allowed | content filter: no hint is a banned technique or a generalisation of one (not ready for Skyscrapers means… |
def lesson | is this a lesson puzzle for target given known techniques: the target itself appears (a Skyscraper… |
def step_notation | one band-path step in the notation solvers and forums share: "X-Wing: 3 r58 c68 => r679c6<>3", "Hidden… |
def explain_steps | teaching text for band-path steps: rebuild each position, let the engine find that technique's moves there… |
def covered(code, known)
a learner who knows known can do code: they know it, or a technique it is always a case of.
def classify(m, p)
(code, name, band) of one state_moves move, by its most specific shape.
def variant_gate(p)
engine techniques to switch off for this puzzle: variant techniques without any variant rule, sum techniques without a sum rule, law of leftovers without sums or irregular / extra regions.
def band_path(p, P, max_steps=500, disabled=(), target=None, rng=None, ban_codes=(), guard=None, prefix=(), max_band=6)
solve band by band (see module doc). Moves are taken in full-notes mode: each placement clears its peers. target = a cell: stop once it is placed, preferring (within the current band) a move that places it -- "what does it take to place this cell from here". max_band: stop (unsolved) where only a harder move would continue -- contents() reruns can only count if they stay in the card's band, so they never list above it.
def contents(p, P, alternatives=True, deadline=None, base=None)
What's in the puzzle, deterministically (same puzzle -> same card). 1. band_path (easy to hard, simplest technique first). 2. Shrink: each listed technique is tried for removal (banned by its exact code), later in the study order first, so the simpler of two interchangeable ones stays; kept only if _route_ok (same band, nothing new but special cases of what's listed, its work not moved to a harder technique or a generalisation of it). 3. Alternatives: for each listed technique X, every same-band technique Y that is NOT on X's subset chain (neither is a special case of the other) and was available where X was used, is tested by banning X: if the puzzle then solves with the listed techniques plus Y (_route_ok, Y actually used), X/Y is a joint chip -- "solvable if you know either". Same-chain relations stay out: the player's subset table covers them (a Turbot learner can do Skyscrapers). Full houses fold into the listed single. -> band_path result + "removed", "alternatives" {code: [codes]}. deadline (time.time() value, opt-in; default None = deterministic): shrink / alternatives stop when it passes and the result carries "truncated": True (the base route is always complete; the list may then not be minimal / miss joint chips). base: band_path(p, P) already computed by the caller (it is not computed again).
def fold(profile)
contents shown to players: a full house is always both a naked and a hidden single and ends nearly every house of every solve, so it carries no information -- its uses count under the listed single (hidden single first, as the simpler; only when no other single is listed does FHS appear itself).
def allowed(profile, banned)
content filter: no hint is a banned technique or a generalisation of one (not ready for Skyscrapers means not ready for Turbot Fish / X-Chains either). Special cases of a banned code stay allowed (an X-Wing is fine when Skyscrapers are banned).
def lesson(profile, target, known=())
is this a lesson puzzle for target given known techniques: the target itself appears (a Skyscraper puzzle is solvable by a Turbot learner but doesn't teach Turbot), and every other hint is covered by known + target.
def step_notation(p, st, just=None)
one band-path step in the notation solvers and forums share: "X-Wing: 3 r58 c68 => r679c6<>3", "Hidden Single: r3c5=6". The pattern part comes from the explainer's justification when there is one.
def explain_steps(p, P, steps)
teaching text for band-path steps: rebuild each position, let the engine find that technique's moves there (one short solve with only that technique), take the move with exactly the same placement / eliminations and explain it (pencilmark/explain). -> [{"short", "teach"} or None] aligned with steps.
pencilmark.card
The card route: difficulty and time effort on the written route, and the publishing notes, for export.grade.
card = grade_card(puzzle, P, band) # band = (band name, required techniques) from the band path
Returns {"status", "score", "effort_l1", "notes"}: score is the card's difficulty (card_score.score_parts, with the grader model), effort_l1 the log step-value sum the time model reads, notes the publishing checks (publish_flags.notes). score and effort_l1 are None when the route cannot grade the puzzle.
| Name | What it does |
|---|---|
def grade_card |
def grade_card(puzzle, P, band, check_rules=True)
No docstring.
pencilmark.card_score
Card difficulty score: step value = d^beta x F / avail x exp(delta_tech) on the written human route; difficulty = mapped log of the average step effort per empty cell (+ a technique-free search-effort term). Parameters: the grader model (grader_model.py): price (difficulty), route (time effort, search effort), score.
| Name | What it does |
|---|---|
def steps | the written route under params P -> ([(technique name, price, F)], cells each step touches) |
def refute_lr_cell | technique-free search effort of the puzzle: the engine's generic refutation profile (singles + pair… |
def steps_enum | steps(p, P) plus, per step, the enumeration size of the cages its cells touch (0 without cages). |
def score_parts | (card score, L1 = log of the step-value sum) for one puzzle; None if the engine must guess. |
def steps(p, P)
the written route under params P -> ([(technique name, price, F)], cells each step touches)
def refute_lr_cell(p)
technique-free search effort of the puzzle: the engine's generic refutation profile (singles + pair relations + n-ary consistency; when stuck, every candidate trialled, shortest refutations applied) -> log1p(sum over stuck points of log(candidates / shortest refutations) per empty cell). Puzzle-only: no technique catalogue, no prices.
def steps_enum(p, P)
steps(p, P) plus, per step, the enumeration size of the cages its cells touch (0 without cages).
def score_parts(p, model=None)
(card score, L1 = log of the step-value sum) for one puzzle; None if the engine must guess. The score is priced with the model's difficulty prices, L1 (the effort the time model reads) with its route prices; both on the written route each price set produces.
pencilmark.scoring
Per-move difficulty, race-model time, and puzzle-level grade.
Difficulty of a move (SE-like scale, Full House in a square 9-cell house = 1):
direct singles: d = B[tech] * base_shape^gamma * size(D)
+ sum_i K[kind_i] * shape_i^gamma * uncovered_i
the set-cover formulation of the C# naked-single code: every extra group
you have to combine costs K * shape * (items still unexplained), for
naked singles items = digits, for hidden singles items = cells.
notes techniques: d = B[tech] * shape^gamma * size(D)
TakeNotes: d = B[TAKE_NOTES] (writing notes is itself a skill gate)
size(D) = ((D-1)/8)^alpha: how many alternatives a basic check discriminates between; can't be fitted from 9x9-only data, set from first principles.
Time (race model): every available move m is noticed at rate exp(-beta * d_m) / (c * scan), scan = empty cells. Expected time to the next move is c * scan / sum_m exp(-beta * d_m); plus writing digits, writing and erasing notes. Many simultaneous moves -> fast (sudoku.coach's tediousness); bigger board -> more scan, but also more simultaneous moves.
Grade: soft bottleneck over the chosen steps -- d_max + tau * log( sum_i exp((d_i - d_max)/tau) * ref_steps / n_steps ) clipped at d_max. Counting hard steps as a DENSITY means a samurai (five 9x9s each needing one X-Wing) grades like one of its 9x9s, while a single 9x9 needing five X-Wings grades higher than one needing one.
| Name | What it does |
|---|---|
def copy_params | copy of the params dict with its dict and list values copied one level deep |
def complete_params | Fill params fitted before a technique/kind existed with defaults. |
def meta_vector | Meta vector: META_NAMES (the 10 pricing columns) + the 6 structural descriptors DESC_NAMES (csolver… |
def meta_priced | True when the move is priced by the structural meta model today. |
class Scorer | prices a move from its features under a params dict (structural pricing when meta_theta is set) |
def work_time | Tediousness as a time (sudoku.coach's idea): predict_time with every move priced as if it were easy (capped… |
def grade | {"work_time": seconds of routine work (work_time)}: what the publishing checks' tedious note reads |
def copy_params(p)
copy of the params dict with its dict and list values copied one level deep
def complete_params(P)
Fill params fitted before a technique/kind existed with defaults.
def meta_vector(meta, desc=True)
Meta vector: META_NAMES (the 10 pricing columns) + the 6 structural descriptors DESC_NAMES (csolver desc.cpp, meta[10:18]; zeros for moves / older binaries without them) = the 18 columns of C++ featurize m_meta. desc=False: the 10 pricing columns only (what Scorer.difficulty prices with).
def meta_priced(meta)
True when the move is priced by the structural meta model today. meta[18] = 1: the meta tuple was exported for a technique priced by its own B (desc.cpp, every technique carries a meta in canonical mode).
class Scorer
prices a move from its features under a params dict (structural pricing when meta_theta is set)
def size(self, D)
board-size factor ((D - 1) / 8) ** alpha, 1 for 9 digits
def difficulty(self, feat, D)
price of one move from feat = (tech, base_shape, cover, extra[, meta[, load]]) on a D-digit board
def work_time(steps, puzzle, params)
Tediousness as a time (sudoku.coach's idea): predict_time with every move priced as if it were easy (capped at the Hidden Single base price), so only the VOLUME of work remains -- scan area (empty cells), how many moves are available, writing / erasing notes. predicted time - work time = think time, the extra cost of hard steps. Decorrelated from difficulty by construction.
def grade(steps, puzzle, params=None, solved=True)
{"work_time": seconds of routine work (work_time)}: what the publishing checks' tedious note reads
pencilmark.grader_model
src/pencilmark/grader_model.py
The grader model: every fitted parameter the card uses, loaded once (internal; the public API has no model argument).
route engine parameters for the band route, the written route's time prices and the search-effort term
(data/params.json, completed with scoring defaults)
price the difficulty prices: route with card_score.json "price_weights" in place of its structural weights (the
card-fit weights, fitted on human times; everything else is shared with route)
score the card-score configuration: step-value exponents, enumeration and search-effort weights, difficulty map
(data/card_score.json)
time the time model (data/time_model.json)
default() is what ships; the golden corpus (tools/golden.py) is pinned to it. Callers that change parameters take a copy (scoring.copy_params) and never edit these dicts.
Research: parameters have names, and a changed model grades through use():
m = default().with_overrides({"score.map.A": "x1.2", "time.k_effort": 0.3})
with use(m):
card = pencilmark.rate(puzzle)
Names: "<set>.<key>" for a top-level value of route / price / score / time, "<set>.<key>.<subkey>" inside a mapping ("score.map.A"), "route.price.<Technique>" / "price.price.<Technique>" for a technique's base price (B, by engine name; used only for techniques the structural model does not price). A value is a number, or "x<factor>" to scale the current one. names() lists every name with its value.
| Name | What it does |
|---|---|
class GraderModel | |
def default | the shipped model, loaded on first use |
def use | grade with model instead of the shipped one inside the block (research; not thread-safe) |
class GraderModel
No docstring.
def names(self)
{name: value} for every scalar parameter (lists are named as a whole)
def get(self, name)
No docstring.
def with_overrides(self, overrides)
a copy with the named parameters changed (number, or "x<factor>" to scale); unknown names are an error
def load(cls)
the shipped model from pencilmark/data
def default()
the shipped model, loaded on first use
def use(model)
grade with model instead of the shipped one inside the block (research; not thread-safe)
pencilmark.metrics
Grading helpers over the engine's steps: the uniqueness-checked solve (grade_puzzle_checked), the pricing families and the user-facing bands (Very Easy .. Extreme, data/techniques.csv).
| Name | What it does |
|---|---|
def family_of | |
def grade_puzzle_checked | Uniqueness-checked grading entry point (use this for any external input). |
def band_of | Band index (0 Very Easy .. |
def family_of(tech)
No docstring.
def grade_puzzle_checked(puzzle, P, explain=False, time_limit=60.0)
Uniqueness-checked grading entry point (use this for any external input). 1. Independent CP-SAT check: 0 solutions -> {"status": "no solution"}; 2+ -> {"status": "multiple solutions", "solutions": [two examples]}. 2. Unique -> solve and grade with uniqueness techniques allowed (assume_unique=True). 3. Unknown (CP-SAT can't express a rule / timed out) -> grade with assume_unique=False, so no uniqueness reasoning (UR, BUG+1, ...) is applied to an unproven puzzle. Returns {"status", "unique", "grade", "steps"}.
def band_of(tech)
Band index (0 Very Easy .. 6 Extreme) of a technique code; unknown codes fall back to the family.
pencilmark.publish_flags
src/pencilmark/publish_flags.py
Common-sense publishing notes ('would not be chosen for publishing'), sudokuwiki-style. Bands never change.
Hand-coded rules (sudoku.coach likes are too sparse and its puzzles human-curated, so
common sense first; the coach analysis only informed the spike rule's direction):
spike a Medium+ puzzle whose 3 hardest steps carry more than `spike` (0.6 = 60%) of its effort (warning; enjoyment
check on LMD ratings and sudoku.coach likes, controlling difficulty: -0.08 / -0.03)
decorative a variant rule class that is neither needed for uniqueness nor used by any solve step
barely_used a variant rule class used in fewer than `barely_used` (3) steps of the whole solve (a 'Sudoku X' should use its X)
redundant a variant rule class the puzzle is unique without, although the solve uses it (soft note)
trivial more than `trivial` (0.6 = 60%) of the cells are given (barely a puzzle)
tedious Very Easy / Easy but over `tedious` (60) minutes of work (long and routine)
uniqueness needs a uniqueness-based technique (UR, BUG, Gurth): relies on assuming a unique solution
trial needs trial / forcing chains: computer-strength logic, not a human-friendly finish
unusual a difficulty outside the middle 80% of published puzzles of the same band (classic 9x9 and variants
apart, TYPICAL; Very Easy has no floor, Extreme no ceiling): a Very Easy puzzle that scans like an Easy
one, a Medium that plays like a Very Hard (reject)
Each note is (code, severity, text); severity 'reject' = would not be published, 'note' = informational.
Every check is adjustable with an object (quality_limits): {"limit": n} replaces its limit (checks with one), {"severity":
"reject" | "warning" | "off"} overrides its severity or turns it off.
| Name | What it does |
|---|---|
def quality_limits | quality object -> {check: {"limit": number or None, "severity": "reject" | "warning" | "off" | None}} for… |
def notes | the publishing notes [(code, severity, text, ...)] of a graded puzzle, as the quality object asks |
def quality_limits(q=None)
quality object -> {check: {"limit": number or None, "severity": "reject" | "warning" | "off" | None}} for every check. Each entry of q is an object with "limit" (checks with a limit: a number >= 0) and / or "severity"; severity None = the check's own (Grade card 4). Raises ValueError for unknown checks, keys or values.
def notes(puzzle, P, steps, tier, required, work_min, check_rules=True, difficulty=None)
the publishing notes [(code, severity, text, ...)] of a graded puzzle, as the quality object asks
pencilmark.se
Sudoku Explainer-style ratings from our route. Opt-in on the card: pencilmark.rate(puzzle, se=True).
ER = the hardest step on our easiest human route (SE's "rating"); EP = hardest before the first placement ("pearl"); ED = the first step's value ("diamond", hardest before the first elimination / placement). Values per technique from the table expert solvers quote (Juillerat's Sudoku Explainer / serate): hidden single 1.2 (box), naked single 2.3, pointing 2.6, naked pair 3.0, X-Wing 3.2, hidden pair 3.4, naked triple 3.6, swordfish 3.8, ... Measured on the 747 classics of the frozen benchmark against the DiufSudoku fork's own rating (2026-10-31): ER within 0.3 for 81%, median gap 0.1; the largest gaps are routes that use a naked single where SE finds a hidden one (1.2-1.3 against 2.3). Approximate by construction: our route is not SE's route. A technique SE has no value for takes the value of the technique it is a special case of (codes.csv subset_of); with none (variant logic), it falls back to 7.0. Classic sudoku only is meaningful.
| Name | What it does |
|---|---|
def ratings | steps: band_path steps (dicts with code / place / elims) -> {"er", "ep", "ed", "scale": "classic"}. |
def ratings(steps)
steps: band_path steps (dicts with code / place / elims) -> {"er", "ep", "ed", "scale": "classic"}.
pencilmark.usage
Variant usage and balance (card concepts).
rule_usage(puzzle, params): solve with explanations and record, per step, which rule ELEMENTS justified it -- a diagonal, a cage, a dot / pair relation, a line (by kind), an arrow, extra regions, shading -- so a card can say "X-diagonals used 4x". Classic houses (rows, columns, boxes) are not elements.
necessity(puzzle): for each element class, drop every rule of that class and ask CP-SAT whether the puzzle is still unique; a class whose removal keeps the solution unique is not needed for uniqueness (it may still be needed for the logical path).
Both are read-only views over cbackend.solve(explain=True) / pencilmark.cpsat_verify; nothing here changes grading.
| Name | What it does |
|---|---|
def step_elements | Set of element classes one explained step uses. |
def rule_usage | -> {"steps": [set of elements per step], "counts": Counter(element -> steps using it), "first_use":… |
def element_classes | Element class -> how to remove it from a copy of the puzzle (constraint indices, group names, relations). |
def necessity | -> {element class: True (needed: the puzzle is not unique without it) / False / None (unknown)}. |
def step_elements(p, step, gk=None)
Set of element classes one explained step uses.
def rule_usage(puzzle, params, solution=None)
-> {"steps": [set of elements per step], "counts": Counter(element -> steps using it), "first_use": {element: step index}, "solved": bool}
def element_classes(p)
Element class -> how to remove it from a copy of the puzzle (constraint indices, group names, relations).
def necessity(puzzle, time_limit=30.0)
-> {element class: True (needed: the puzzle is not unique without it) / False / None (unknown)}. The puzzle is rebuilt from its uncompiled definition: relations generated from lines are dropped and regenerated by finalize, so removing a line class removes its relations too.