Skip to main content

Add a rule kind

Each rule family with kinds keeps one registry in src/pencilmark/rules/: line.py (LINE), edge.py (EDGE) and cell.py (CELL). The reader, the writer, describe(), the drawing and the generator all consult it, so a new kind starts there. The quick suite then names most of what is still missing: the JSON Schema, the default colour and the rule page are checked against the registry. This page follows a new line kind, the case with the most steps; edge and cell kinds follow the same order (section 6).

1. The registry entry​

Add a LineKind to LINE in src/pencilmark/rules/line.py:

LineKind("nabner", "Nabner", "No two digits on a nabner line are equal or consecutive."),

The fields after the one-sentence rule are optional: params (keys such as value, required when the board is not 9×9), closable, width and ends (how it is drawn), name_fn / a function as text when the words depend on the rule's values, and generated once the generator can place it. The dataclass's comments say what each takes.

2. What the engine sees​

The engine knows no public kinds: the reader translates each rule. Choose the cheapest of three routes.

  1. Existing engine rules only. If the kind can be written with rules the engine already has, translate it in line_to_engine (and back in line_from_engine) in rules/line.py, as double_arrow becomes an equal sum. Nothing else changes in the engine. The writer then sees only the engine rules, so the kind may come back under another name with the same meaning (a four-cell double arrow is written as a zipper).
  2. A new engine line kind built from existing constraints. Most lines are this. Append the name to PM_LINE_KINDS in csrc/nary.hpp (the ids are frozen: append, never insert) and to LINE_KINDS in src/pencilmark/model.py, in the same order (tests/test_registry.py checks it). Then compile it in Puzzle.finalize in model.py: into pair relations from RELATIONS (thermo steps are lt, palindrome mirrors eq, and a nabner line would be notdiff 1 and neqval on every pair of its cells; list the kind in PAIRWISE_LINE_KINDS too), or into an n-ary constraint the engine already solves (set, between, equal_sum or table) carrying "line_kind": kind, as renban, between, zipper and lockout do. The writer drops what the line compiled to and writes the line back. Rebuild the engine (pip install -e .) after the C++ change.
  3. A new kind of constraint. Only when no combination of the above can say the rule: a new NT_ type in csrc/nary.hpp, its enumeration in csrc/nary.cpp, and techniques that use it (Add a technique). Start a discussion in an issue first.

3. The format and the drawing​

  • JSON Schema. Add the kind to the kind enum of rule_line in docs/sudoku-puzzle.schema.json, and any new parameter to its properties.
  • Default colour. Add it to lines in docs/palette.json. Use the colour most setters use for it, if there is one; Drawing and style says where the current defaults come from.
  • Width in Drawing and style. If width is a number rather than a palette size name, state it in the kind's row of docs/library/drawing-style.md (tests/test_format.py checks the two agree).
  • Swatch. Add a line-<kind> entry to SWATCHES in tools/gen_swatches.py, with digits along the path that show the rule.

4. The docs​

Add a row for the kind to the table in docs/formats/rules/lines-and-arrows.mdx: the kind, its parameters, the rule in a sentence, a link to a page that explains it to solvers, and <Swatch name="line-<kind>" />. Follow the writing style. The Palette page and the drawings are regenerated by the docs build.

5. Explanations and readers​

  • Words in explanations. Name the line in LINE_NAMES in src/pencilmark/explain/base.py ("nabner line"). If it needs its own lesson, map the engine kind to a catalogue key in _LINE_KEY, add the entry to CATALOG and a sentence to _VARIANT_LESSONS in explain/catalog.py. Without these, explanations fall back to "the nabner line".
  • Imported puzzles. If SudokuPad or f-puzzles puzzles use the rule, teach the readers in sources/ to recognise it: _sp_translate.py matches rule text and line colours, _fpuz_translate.py the f-puzzles constraint names.

6. Edge and cell kinds​

An edge kind is an EdgeKind in EDGE (rules/edge.py). Its meaning is a pair relation between the two cells: add the case to edge_relation (public rule to relation) and to edge_family (relation back to the clue, for the writer), and, if the kind can be negated ("all clues shown"), to negative_relation. Then the rule_edge part of the schema, docs/formats/rules/edges-and-corners.mdx and an edge-<kind> swatch.

A cell kind is a CellKind in CELL (rules/cell.py): its variant name, its parameter, whether the negative is defined, and its mark. Its meaning is a domain restriction on the cell; follow how odd and low reach the engine in exchange/read.py. If it is drawn shaded, give it a colour of its own name in both themes of docs/palette.json (the renderer reads the colour by kind). Then the schema, docs/formats/rules/cell-clues.mdx and a swatch.

7. Check and record​

python tests/test_line_matrix.py --write # the snapshot of every line kind gains the new one's cases
python tools/tasks.py check # quick suite, doc examples, golden corpus
python tools/tasks.py serve # the rule page, its swatch and the Palette page

The snapshot files (tests/fixtures/line_matrix.jsonl, edge_matrix.jsonl, cell_matrix.jsonl) record every kind's reading, writing and description; rewrite them for the new kind only, and read the new lines before committing them. A new kind should change no golden record. Add a line under "Unreleased" in CHANGELOG.md, and an example puzzle that uses the kind to tests/golden/inputs.jsonl (then python tools/golden.py write, in its own commit) so the corpus grades it.