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:
-
The genre’s rules already have
Rulevariants — common case onceConnected,NoTwoByTwo,ValueGroupedSizeetc. land. You’re writing the genre marker +GenreTraitimpl + parsers + aGenreTrait::generateoverride (when the universal recipe isn’t enough) + tests + UI wiring + bench wiring. No engine changes. -
You need a new
Rulevariant — same as (1), plus you add the variant toRule, encode it ineval.rs, optionally land a propagator fast path, and land an OR-tools encoding (the match inalgorithm::ortools::solveris 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 universalscaffold_solve_deriverecipe 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 shipScaffoldPass/CluePostFilterslices. 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 genericSolution.
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::Substrate — Cells, 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.
| Genre | Rules |
|---|---|
| akari | ExactCount, AtMost, AtLeastOne, CellMin, Decided |
| slitherlink | ExactCount, DegreeIn, Path { closed: true } |
| sudoku | Pin, 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 (defaultFloor; akari overrides toWall; slither toLabelOnly). DrivesPuzzle::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’sExactCountoverNeighbors4, slither’s over edges). Returns the display shorthand: a singleu8. Multi-byte payloads fold to their first byte.extract_clues(p)— substrate-filtered clue grid (akari only walls hold clues). ReturnsGrid<Option<ClueValue>>; u8 genres emitClueValue::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 ofcell_clue. The walker calls this once per clueable cell to derive the canonical clue from a bare-solve completion. Default body dispatches throughclue_pattern()and wraps inClueValue::Number. Override directly when your clue shape doesn’t fitPin/Count/ConnectedSize(Tapa run sequences, future Yajilin arrows).dims_for(rows, cols)— sudoku rounds to nearest factor pair and returnsBoxed{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) useClueValue::Runs(_)or a new variant, and overridederive_clue_atdirectly instead of relying onclue_pattern. TheClueValueenum is closed — adding a variant is one enum entry plus exhaustive-matchwarnings 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 ofConstraints for an empty puzzle.parse_janko_round_tripover a fixed string.parse_puzzlink_round_tripfrom 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.
- Variant: add to
Ruleenum incore/src/constraint.rs. Add the matchingRuleKindvariant. Add aViolationvariant — used by eval to surface which constraint failed. - Eval: add an arm in
core/src/algorithm/eval.rs::eval_rule. At minimum returnConstraintOutcome::Pendinguntil decided, plus the obvious-violation cases. Slow-path-only is fine for a first cut. - Tightness: add a tightness arm in
Rule::tightness. High tightness means “prunes a lot per commit”; the solver tries it first. - Propagator (optional):
core/src/algorithm/fast/propagator.rshas per-rule fast paths forforced_moves_at. Eval-only is fine; trial-1 picks up the slack. Add a fast path later if benches demand. - OR-tools:
core/src/algorithm/ortools/solver.rs::encode_constraintis exhaustive onRule— 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
LazyCheckvariant. CP-SAT 0.4 doesn’t have direct primitives for graph-shape rules (Connected,Path,BoundedSize).solve_innercalls each lazy check after every CP-SAT response and adds anadd_orno-good cut on violation.
- Eager: pick the closest CP-SAT primitive (
- Grader (optional): if your rule yields a recognisable human
technique, add a
Techniqueimpl incore/src/algorithm/grader/. Most rules don’t add new techniques — the existing ones cover most patterns generically. - Docs: update the encoding table in External solvers.
Definition of done
-
cargo test -p penmark-core --features ortoolspasses. -
cargo bench --bench penmark -- <genre> -- --testsmokes. -
cargo run -p penmark-cli -- list-implsshows the new genre. -
cargo run -p penmark-cli -- generate <genre> --rows R --cols Cproduces a valid puzzle. - At least one
ortools_agrees_with_fast_*test incore/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.