Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Adding a new puzzle genre

End-to-end checklist for landing a new genre. Akari, sudoku, slitherlink, and takuzu are the worked examples — open them side by side when following along. Takuzu is the smallest of them (single mod.rs + parse.rs + tests.rs, no pzv codec, no scaffold passes), so start there if you’re after the minimum viable genre.

The post-collapse architecture means a genre is now mostly one file: core/src/puzzles/<genre>/mod.rs with a GenreTrait impl on a zero-sized marker. Per-genre CLI files, eframe files, and bench files no longer exist — the universal GenericCli, eframe app, and bench harness all dispatch through the runtime &'static Genre handle, so adding a genre to the live binaries is one entry in the GENRES slice plus a few re-exports.


TL;DR

Two scenarios:

  1. The genre’s rules already have Rule variants — common case once Connected, NoTwoByTwo, ValueGroupedSize etc. land. You’re writing the genre marker + GenreTrait impl + parsers + a GenreTrait::generate override (when the universal recipe isn’t enough) + tests + UI wiring + bench wiring. No engine changes.

  2. You need a new Rule variant — same as (1), plus you add the variant to Rule, encode it in eval.rs, optionally land a propagator fast path, and land an OR-tools encoding (the match in algorithm::ortools::solver is exhaustive — your build won’t compile without it).

The order below assumes (1); the “New Rule variant” sidebar folds in (2).


File map

A complete genre touches:

penmark/
├── core/
│   ├── src/
│   │   ├── lib.rs                        ← add `pub mod <genre>;` under puzzles
│   │   ├── genre.rs                      ← add `pub static MYGENRE: &Genre = genre_static!(MyGenre);`
│   │   │                                    + append to `GENRES` slice
│   │   └── puzzles/
│   │       └── <genre>/
│   │           ├── mod.rs                ← marker struct + impl GenreTrait + type alias + constructors
│   │           ├── parse.rs              ← Janko text format parser + writer
│   │           ├── pzv.rs                ← puzz.link / pzv URL parser
│   │           ├── generate.rs           ← (optional) Genre::generate override
│   │           ├── scaffold.rs           ← (optional) ScaffoldPass + CluePostFilter slices
│   │           ├── tests.rs              ← unit tests + ortools cross-checks
│   │           └── inspect.rs            ← (optional) solution accessors
│   └── benches/penmark.rs                ← add `register_<genre>(c)` call
├── cli/
│   └── src/main.rs                       ← genres come from `core::genre::GENRES` — no manual list
├── eframe/
│   └── src/main.rs                       ← same — iterates `GENRES`
└── data/
    └── puzzles/<genre>.jsonl.gz          ← canonical per-genre store, populated by
                                            `penmark import`. Nothing to commit by hand.

There’s no per-genre enumerate.rs — the universal walk_canonical covers enumeration for every genre using only the existing trait surface (material_vocab, clue_pattern, clue_domain, empty_at, build_constraints). See the Enumerator chapter for the walker’s algorithm.

The optional files only exist when the genre needs them:

  • generate.rs — only if the universal scaffold_solve_derive recipe doesn’t produce diverse output. Sudoku and slither override because they invent topology before the bare-solve can run; akari uses the default.
  • scaffold.rs — only if you ship ScaffoldPass / CluePostFilter slices. Akari ships six wall styles and seven clue filters; sudoku and slither ship none.
  • inspect.rs — only when your solution shape (e.g. akari bulb positions) needs accessors that don’t fit the generic Solution.

Step-by-step

Substitute <Genre> for the title-case name (e.g. Masyu) and <genre> for the kebab-case slug.

1. Pick your substrates

Look at core/src/coord.rs::SubstrateCells, EdgesH, EdgesV, Corners. Akari uses just Cells; slitherlink uses EdgesH + EdgesV + Corners. Most loop genres need edges + corners; most cell-marking genres need just cells.

2. Pick your rules

Walk core/src/constraint.rs::Rule and pick the variants whose semantics match your puzzle.

GenreRules
akariExactCount, AtMost, AtLeastOne, CellMin, Decided
slitherlinkExactCount, DegreeIn, Path { closed: true }
sudokuPin, Distinct
masyu (planned)Path { closed: true }, DegreeIn, plus MasyuBlack / MasyuWhite
nurikabe (planned)ValueGroupedSize, Connected, NoTwoByTwo

If every rule already exists and has an eval.rs arm that’s not stubbed Pending, you’re in scenario (1). Otherwise see the New Rule variant sidebar.

3. Genre module: core/src/puzzles/<genre>/mod.rs

Define the genre marker, the (vestigial) type alias, impl GenreTrait, and constructors. The trait surface is large but most methods have sensible defaults. Required:

pub type MyPuzzle = Puzzle;  // vestigial; just shorthand for Puzzle

#[derive(Clone, Copy, Debug, Default)]
pub struct MyGenre;

impl GenreTrait for MyGenre {
    const NAME: &'static str = "mygenre";
    const DISPLAY_NAME: &'static str = "MyGenre";
    const LAYERS: &'static [Layer] = &[Layer::Cells];
    const MARK_RENDER: MarkRenderStyle = MarkRenderStyle::Glyph;

    /// Runtime handle — must match the literal added in genre.rs.
    const GENRE: &'static crate::genre::Genre = crate::genre::MYGENRE;

    fn full_domain(p: &Puzzle, _coord: Coord) -> Vec<Mark> {
        // For binary genres: vec![Mark::Binary(false), Mark::Binary(true)]
        // For numeric: (1..=p.rows() as u8).map(Mark::Numeric).collect()
    }

    fn default_substrate(dims: Dimensions) -> Substrate { /* ... */ }

    fn build_constraints(
        substrate: &Substrate,
        clues: &Grid<Option<ClueValue>>,
    ) -> Vec<Constraint> {
        // Walk substrate + clue grid. For u8 genres, use the
        // convenience helper `for_each_clue_byte` which
        // pattern-matches `ClueValue::Number(_)` for you.
    }

    fn clue_pattern() -> CluePattern {
        // Pin / Count{target,region} / ConnectedSize{target}.
        // The convenience layer over `derive_clue_at` — see below.
    }

    fn sample() -> Puzzle { /* small demo puzzle */ }
}

The macro in core/src/genre.rs coerces these monomorphized items into the runtime Genre struct’s fn-pointer fields:

// core/src/genre.rs
pub static MYGENRE: &Genre = genre_static!(MyGenre);

pub static GENRES: &[&Genre] = &[AKARI, SLITHER, SUDOKU, TAKUZU, MYGENRE];

Optional overrides used by various genres:

  • material_vocab() — non-Floor materials available on the cells layer (akari: [Wall, Hole]).
  • clue_host_material() — substrate material that holds clue cells (default Floor; akari overrides to Wall; slither to LabelOnly). Drives Puzzle::is_clue_target.
  • clue_domain(p, coord)Vec<u8> of valid clue values at the coord; empty means no clue allowed there.
  • cell_clue(p, r, c) — read non-Pin clues from constraints (akari’s ExactCount over Neighbors4, slither’s over edges). Returns the display shorthand: a single u8. Multi-byte payloads fold to their first byte.
  • extract_clues(p) — substrate-filtered clue grid (akari only walls hold clues). Returns Grid<Option<ClueValue>>; u8 genres emit ClueValue::Number(_).
  • on_clue_added(p, r, c, v) — promote material on clue add (akari promotes Floor → Wall). v: &ClueValue.
  • derive_clue_at(p, sol, coord) -> Option<ClueValue> — inverse of cell_clue. The walker calls this once per clueable cell to derive the canonical clue from a bare-solve completion. Default body dispatches through clue_pattern() and wraps in ClueValue::Number. Override directly when your clue shape doesn’t fit Pin / Count / ConnectedSize (Tapa run sequences, future Yajilin arrows).
  • dims_for(rows, cols) — sudoku rounds to nearest factor pair and returns Boxed{box_w, box_h}.
  • format_value(v) — sudoku overrides for hex / base-36 on big grids.
  • overlays(p, marks, status) — paint overlays (akari lit cells, sudoku candidates + conflicts, slither loop fill at solve).
  • from_janko_text / from_puzzlink_url / to_puzzlink_url — text-format parsers and writers.

Multi-byte clue payloads. The four shipped genres carry single-byte clues, so ClueValue::Number(_) is the only variant they emit. Genres whose clues are richer (Tapa’s run-length sequences, future Yajilin arrows) use ClueValue::Runs(_) or a new variant, and override derive_clue_at directly instead of relying on clue_pattern. The ClueValue enum is closed — adding a variant is one enum entry plus exhaustive-match warnings pointing to every site that needs an arm.

4. Generator hook

The universal GenreTrait::generate default is scaffold_solve_derive: scatter materials per material_vocab, bare-solve, derive clues per clue_pattern, reduce by orbit. Akari uses this verbatim.

Override GenreTrait::generate when the recipe doesn’t produce diverse output. The override forwards to a crate-private helper:

fn generate(cfg: &GenConfig, cancel: &AtomicBool) -> Option<Puzzle> {
    generate::mygenre_generate(cfg, cancel)
}

The helper in core/src/puzzles/<genre>/generate.rs reads cfg.dims, cfg.seed, cfg.time_budget, plus whatever cfg.extra / cfg.clue_density keys make sense for the genre. Sudoku reads cfg.clue_density[Cells] as target_givens; slither reads cfg.extra["inside_density"].

Two CLI knobs you’ll usually also override:

const DEFAULT_GEN_DENSITY: f32 = 0.36;  // CLI's --density default
fn apply_cli_density(d: f32, cfg: &mut GenConfig) {
    // Map --density into the right cfg slot for your genre.
}

If you ship structural pre-passes (border ring, maze carving, hole-pin patterns) or clue value filters (only-evens etc), declare them in core/src/puzzles/<genre>/scaffold.rs and override scaffold_passes() / clue_post_filters(). See core/src/puzzles/akari/scaffold.rs for the template.

5. Parser: core/src/puzzles/<genre>/parse.rs

Two formats per genre:

  • Janko — small-grid text format from janko.at. Useful for hand-built fixtures and dataset smoke tests.
  • puzz.link / pzv — URL format used by puzz.link. Lives in pzv.rs; puzzlekit datasets ship in this format.

Round-trip in tests: parse(s).to_janko() == s for at least a 3×3 plus a corner case.

6. Tests: core/src/puzzles/<genre>/tests.rs

Minimum:

  • coord_count_formula_holds_for_various_sizes — coord enumeration matches grid math.
  • empty_NxN_constraint_count — right number of Constraints for an empty puzzle.
  • parse_janko_round_trip over a fixed string.
  • parse_puzzlink_round_trip from a real-corpus URL.
  • simple_and_fast_agree_on_* over a hand-built puzzle whose solution count is known.
  • ortools_agrees_with_fast_* (#[cfg(feature = "ortools")]) — at least one cross-check. Catches engine drift.

7. Module registration: core/src/lib.rs

One line: add pub mod <genre>; in the pub mod puzzles { ... } block.

8. Genre registration: core/src/genre.rs

Two edits:

pub static MYGENRE: &Genre = genre_static!(MyGenre);

pub static GENRES: &[&Genre] = &[AKARI, SLITHER, SUDOKU, TAKUZU, MYGENRE];

That’s it for CLI and eframe — both iterate GENRES at startup and dispatch on the runtime handle. There is no separate genres list to maintain in cli/src/main.rs or eframe/src/main.rs.

9. Frontend rendering

The eframe app’s painter dispatches on puzzle.genre.mark_render (Lamp / Glyph / EdgeStroke / …); clicks dispatch on (mode, mark_render); overlays come from (puzzle.genre.overlays)(puzzle, marks, status).

If your genre needs a render style none of the existing variants cover, add a MarkRenderStyle variant in core/src/render.rs and a paint pass in eframe/src/painter.rs::paint_marks. Most new genres won’t.

10. Bench registration: core/benches/penmark.rs

The bench harness iterates GENRES and dispatches solver / grader / generator / pipeline benchmarks for each. Most genres don’t need manual wiring beyond appearing in GENRES; genres that ship a hand-curated corpus instead of generated fixtures register that corpus in core/benches/common/mod.rs. Filter the bench matrix via criterion’s name match: cargo bench --bench penmark -- mygenre.

11. Dataset coverage — automatic

The pr_report harness in penmark-eval walks data/puzzles/<genre>.jsonl.gz end-to-end (FastSolver + grader, bucketed by rows × cols) for every PR run. Once your genre has records under data/puzzles/<genre>.jsonl.gz, the PR comment picks it up automatically — no per-genre test file to maintain. Earlier iterations of penmark kept hand-rolled <genre>_dataset_smoke.rs tests for this; they were retired in favor of the per-genre dataset phase in the eval pipeline.

12. Sanity check

cargo build --workspace --features ortools
cargo test -p penmark-core --features ortools
cargo bench --bench penmark -- mygenre  # smokes the bench matrix
cargo run -p penmark-cli -- list-impls  # shows the new genre
cargo run -p penmark-cli -- generate mygenre --rows R --cols C

New Rule variant sidebar

If your genre needs a constraint shape no existing Rule covers, add one before doing the steps above. The change crosses several files because adding a variant breaks every exhaustive match.

  1. Variant: add to Rule enum in core/src/constraint.rs. Add the matching RuleKind variant. Add a Violation variant — used by eval to surface which constraint failed.
  2. Eval: add an arm in core/src/algorithm/eval.rs::eval_rule. At minimum return ConstraintOutcome::Pending until decided, plus the obvious-violation cases. Slow-path-only is fine for a first cut.
  3. Tightness: add a tightness arm in Rule::tightness. High tightness means “prunes a lot per commit”; the solver tries it first.
  4. Propagator (optional): core/src/algorithm/fast/propagator.rs has per-rule fast paths for forced_moves_at. Eval-only is fine; trial-1 picks up the slack. Add a fast path later if benches demand.
  5. OR-tools: core/src/algorithm/ortools/solver.rs::encode_constraint is exhaustive on Rule — your build won’t compile until you add an arm. Two patterns:
    • Eager: pick the closest CP-SAT primitive (add_or / add_at_most_one / add_linear_constraint). Use for anything expressible as linear / boolean over per-coord indicators.
    • Lazy cut: push a LazyCheck variant. CP-SAT 0.4 doesn’t have direct primitives for graph-shape rules (Connected, Path, BoundedSize). solve_inner calls each lazy check after every CP-SAT response and adds an add_or no-good cut on violation.
  6. Grader (optional): if your rule yields a recognisable human technique, add a Technique impl in core/src/algorithm/grader/. Most rules don’t add new techniques — the existing ones cover most patterns generically.
  7. Docs: update the encoding table in External solvers.

Definition of done

  • cargo test -p penmark-core --features ortools passes.
  • cargo bench --bench penmark -- <genre> -- --test smokes.
  • cargo run -p penmark-cli -- list-impls shows the new genre.
  • cargo run -p penmark-cli -- generate <genre> --rows R --cols C produces a valid puzzle.
  • At least one ortools_agrees_with_fast_* test in core/src/puzzles/<genre>/tests.rs::solver.
  • eframe app loads and a sample puzzle renders + solves interactively.

Cross-references

  • Generator chapter — GenConfig, genre.generate, scaffold passes, clue filters.
  • Fast solver — internals before writing a propagator fast path.
  • External solvers — OR-Tools encoding strategy table.