Skip to main content

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​

src/pencilmark/export.py

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"}]
NameWhat it does
def code_nameschip 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_bandband index of an engine technique (data/techniques.csv; SPLIT techniques at their lowest shape's band).
def read_metametadata from a dict: its "meta" object plus known top-level keys (the keyword form of to_export), empty…
def export_puzzleengine Puzzle -> "sudoku-puzzle" dict (format_version 1, Puzzle format; pencilmark/exchange/).
def import_puzzlesudoku-puzzle (format_version 1, Puzzle format) -> puzzle JSON (pencilmark.puzzle_json),…
def region_geometrytype-free region geometry the time model reads : blob_cells = share of cells in 2-D complete groups that are…
def time_unitsexpected solving time in standard-classic units (1.0 = a median classic) from the grader model's time model…
def card_fieldsthe field mask (Grade card 2): the requested top-level card keys; None = the default fields.
def explain_levelsexplain= option -> the step explanation levels: None / False = none, True = all, "notation" / "short" /…
def symmetry_ofsymmetries of the givens pattern (and board shape): [{"kind", "axis"?, "of": "givens"}].
def gradegrade() under max_seconds, stage by stage: the technique list (contains) truncates at the limit as before;…

def code_names()​

source

chip code -> technique name (data/codes.csv)

def chip_of(tech_id, specific=None)​

source

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

source

band index of an engine technique (data/techniques.csv; SPLIT techniques at their lowest shape's band).

def read_meta(d)​

source

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

source

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

source

sudoku-puzzle (format_version 1, Puzzle format) -> puzzle JSON (pencilmark.puzzle_json), read strictly by pencilmark/exchange/.

def region_geometry(p)​

source

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

source

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

source

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

source

explain= option -> the step explanation levels: None / False = none, True = all, "notation" / "short" / "teach" or a list

def symmetry_of(p)​

source

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

source

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​

src/pencilmark/band_path.py

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}}
NameWhat it does
def covereda 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_gateengine techniques to switch off for this puzzle: variant techniques without any variant rule, sum techniques…
def band_pathsolve band by band (see module doc).
def contentsWhat's in the puzzle, deterministically (same puzzle -> same card).
def foldcontents shown to players: a full house is always both a naked and a hidden single and ends nearly every…
def allowedcontent filter: no hint is a banned technique or a generalisation of one (not ready for Skyscrapers means…
def lessonis this a lesson puzzle for target given known techniques: the target itself appears (a Skyscraper…
def step_notationone band-path step in the notation solvers and forums share: "X-Wing: 3 r58 c68 => r679c6<>3", "Hidden…
def explain_stepsteaching text for band-path steps: rebuild each position, let the engine find that technique's moves there…

def covered(code, known)​

source

a learner who knows known can do code: they know it, or a technique it is always a case of.

def classify(m, p)​

source

(code, name, band) of one state_moves move, by its most specific shape.

def variant_gate(p)​

source

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

source

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

source

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

source

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

source

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

source

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

source

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

source

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​

src/pencilmark/card.py

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.

NameWhat it does
def grade_card

def grade_card(puzzle, P, band, check_rules=True)​

source

No docstring.

pencilmark.card_score​

src/pencilmark/card_score.py

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.

NameWhat it does
def stepsthe written route under params P -> ([(technique name, price, F)], cells each step touches)
def refute_lr_celltechnique-free search effort of the puzzle: the engine's generic refutation profile (singles + pair…
def steps_enumsteps(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)​

source

the written route under params P -> ([(technique name, price, F)], cells each step touches)

def refute_lr_cell(p)​

source

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

source

steps(p, P) plus, per step, the enumeration size of the cages its cells touch (0 without cages).

def score_parts(p, model=None)​

source

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

src/pencilmark/scoring.py

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.

NameWhat it does
def copy_paramscopy of the params dict with its dict and list values copied one level deep
def complete_paramsFill params fitted before a technique/kind existed with defaults.
def meta_vectorMeta vector: META_NAMES (the 10 pricing columns) + the 6 structural descriptors DESC_NAMES (csolver…
def meta_pricedTrue when the move is priced by the structural meta model today.
class Scorerprices a move from its features under a params dict (structural pricing when meta_theta is set)
def work_timeTediousness 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)​

source

copy of the params dict with its dict and list values copied one level deep

def complete_params(P)​

source

Fill params fitted before a technique/kind existed with defaults.

def meta_vector(meta, desc=True)​

source

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

source

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​

source

prices a move from its features under a params dict (structural pricing when meta_theta is set)

def size(self, D)​

source

board-size factor ((D - 1) / 8) ** alpha, 1 for 9 digits

def difficulty(self, feat, D)​

source

price of one move from feat = (tech, base_shape, cover, extra[, meta[, load]]) on a D-digit board

def work_time(steps, puzzle, params)​

source

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

source

{"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.

NameWhat it does
class GraderModel
def defaultthe shipped model, loaded on first use
def usegrade with model instead of the shipped one inside the block (research; not thread-safe)

class GraderModel​

source

No docstring.

def names(self)​

source

{name: value} for every scalar parameter (lists are named as a whole)

def get(self, name)​

source

No docstring.

def with_overrides(self, overrides)​

source

a copy with the named parameters changed (number, or "x<factor>" to scale); unknown names are an error

def load(cls)​

source

the shipped model from pencilmark/data

def default()​

source

the shipped model, loaded on first use

def use(model)​

source

grade with model instead of the shipped one inside the block (research; not thread-safe)

pencilmark.metrics​

src/pencilmark/metrics.py

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

NameWhat it does
def family_of
def grade_puzzle_checkedUniqueness-checked grading entry point (use this for any external input).
def band_ofBand index (0 Very Easy ..

def family_of(tech)​

source

No docstring.

def grade_puzzle_checked(puzzle, P, explain=False, time_limit=60.0)​

source

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

source

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.
NameWhat it does
def quality_limitsquality object -> {check: {"limit": number or None, "severity": "reject" | "warning" | "off" | None}} for…
def notesthe publishing notes [(code, severity, text, ...)] of a graded puzzle, as the quality object asks

def quality_limits(q=None)​

source

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

source

the publishing notes [(code, severity, text, ...)] of a graded puzzle, as the quality object asks

pencilmark.se​

src/pencilmark/se.py

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.

NameWhat it does
def ratingssteps: band_path steps (dicts with code / place / elims) -> {"er", "ep", "ed", "scale": "classic"}.

def ratings(steps)​

source

steps: band_path steps (dicts with code / place / elims) -> {"er", "ep", "ed", "scale": "classic"}.

pencilmark.usage​

src/pencilmark/usage.py

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.

NameWhat it does
def step_elementsSet 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_classesElement 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)​

source

Set of element classes one explained step uses.

def rule_usage(puzzle, params, solution=None)​

source

-> {"steps": [set of elements per step], "counts": Counter(element -> steps using it), "first_use": {element: step index}, "solved": bool}

def element_classes(p)​

source

Element class -> how to remove it from a copy of the puzzle (constraint indices, group names, relations).

def necessity(puzzle, time_limit=30.0)​

source

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