Generator
Penmark generates puzzles through genre.generate (an fn pointer on
the runtime Genre handle). There’s no separate Generator trait —
generation is a per-genre function, with a universal default recipe
that genres override only when their structural space requires
bespoke setup.
impl GenreTrait for SudokuGenre {
fn generate(
cfg: &GenConfig,
cancel: &AtomicBool,
) -> Option<Puzzle> {
// Genre-specific impl — sudoku needs diagonal seeding for
// solution diversity since the universal recipe's bare-solve
// returns the same canonical solution every time.
sudoku_generate(cfg, cancel)
}
// …
}
The default behind genre.generate is scaffold_solve_derive:
- Scaffold — pin a structural pre-pass (
genre.scaffold_passes, selected by name viacfg.scaffold) if any, then sprinkle per-(layer, material)density across remainingFloorcells. - Bare-solve —
FastSolverfinds one valid completion of the scaffolded substrate. Skip + retry if no solution exists. - Derive clues — for each cell whose
genre.clue_domainis non-empty, callgenre.derive_clue_at(p, sol, coord)and place the returnedClueValueon the puzzle. The defaultderive_clue_atbody dispatches throughgenre.clue_pattern(Pin,Count{target, region}, orConnectedSize) and wraps theu8result inClueValue::Number; genres whose clue shape doesn’t fit (Tapa runs, future Yajilin arrows) overridederive_clue_atdirectly. - Reduce — drop clues by symmetry orbit while uniqueness holds,
stopping at the
cfg.clue_density[Layer::Cells]floor. (Or applygenre.clue_post_filtersinstead, whencfg.clue_filteris set.) - Verify —
FastSolver.solve(p, 2).len() == 1gates the return. Loop on failure untilcfg.time_budgetelapses orcancelflips.
Akari uses this default verbatim. Sudoku and slither override
generate because each invents its own structural seed
(sudoku: random diagonal; slither: connected inside-region) before
the bare-solve can find a useful completion. Their helpers live in
core/src/puzzles/<genre>/generate.rs as crate-private
<genre>_generate functions; the GenreTrait::generate impl is a
one-line forwarder.
GenConfig
Universal config for every genre’s generate:
pub struct GenConfig {
pub dims: Dimensions, // Rect or Boxed
pub material_density: HashMap<(Layer, Material), f32>,
pub clue_density: HashMap<Layer, f32>, // Reduce floor; default 1.0
pub symmetry: Symmetry,
pub seed: Option<u64>,
pub time_budget: Duration,
pub scaffold: Option<String>, // Genre::scaffold_passes name
pub clue_filter: Option<String>, // Genre::clue_post_filters name
pub extra: HashMap<String, f32>, // genre-specific knobs
}
extra is the escape hatch for knobs that don’t map onto material
or clue density — slitherlink uses extra["inside_density"] to drive
loop topology, for example. Genres document the keys they read.
Extension points
Two hooks let a genre tune the universal recipe without overriding
generate wholesale:
genre.scaffold_passes()
A static slice of named structural pre-passes. Each runs before
sprinkle and pins materials in a deterministic or topologically-
constrained pattern. Akari ships six (walled, grid, maze,
layered, holes_donut, holes_islands); sudoku and slither ship
none.
pub struct ScaffoldPass {
pub name: &'static str,
pub apply: fn(&mut Puzzle, Dimensions, &mut dyn rand::RngCore),
pub respects_symmetry: bool,
}
The user picks one by name via cfg.scaffold. Sprinkle is additive
over the pre-pass — it only writes Floor cells, leaving the
pre-placed walls / holes alone.
genre.clue_post_filters()
Value-filters that run after clue derivation. When
cfg.clue_filter is set, the named filter replaces the
density-driven reduce pass. Akari ships seven (only_0..only_4,
only_evens, only_odds); sudoku and slither ship none.
pub struct CluePostFilter {
pub name: &'static str,
pub allows: fn(value: u8) -> bool,
}
CLI mapping
Each genre exposes two CLI-friendly knobs through GenreTrait:
trait GenreTrait: 'static + Sized {
const DEFAULT_GEN_DENSITY: f32 = 0.22;
fn apply_cli_density(density: f32, cfg: &mut GenConfig) { /* default */ }
}
DEFAULT_GEN_DENSITY is the value the CLI’s --density flag falls
back to when omitted (akari 0.22, sudoku 0.36, slither 0.45). The
runtime Genre struct surfaces both as genre.default_gen_density
and genre.apply_cli_density for call-site dispatch.
apply_cli_density writes the user’s value into the right GenConfig
slot — material density for akari, clue_density[Cells] for sudoku,
extra["inside_density"] for slither. Adding a 4th genre with a
fundamentally new knob means overriding both.
CLI
penmark generate <genre> --rows R --cols C --count N --density D --seed S --budget-ms B [--no-db] looks up the genre handle by name,
calls (genre.generate)(cfg, &cancel) in a loop with per-iteration
seed offsets, prints each puzzle in Janko format, and (unless
--no-db) inserts into the local SQLite library.
penmark scaffold <genre> --rows R --cols C prints
(genre.empty_at)(dims) as a sanity check.