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

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:

  1. Scaffold — pin a structural pre-pass (genre.scaffold_passes, selected by name via cfg.scaffold) if any, then sprinkle per-(layer, material) density across remaining Floor cells.
  2. Bare-solveFastSolver finds one valid completion of the scaffolded substrate. Skip + retry if no solution exists.
  3. Derive clues — for each cell whose genre.clue_domain is non-empty, call genre.derive_clue_at(p, sol, coord) and place the returned ClueValue on the puzzle. The default derive_clue_at body dispatches through genre.clue_pattern (Pin, Count{target, region}, or ConnectedSize) and wraps the u8 result in ClueValue::Number; genres whose clue shape doesn’t fit (Tapa runs, future Yajilin arrows) override derive_clue_at directly.
  4. Reduce — drop clues by symmetry orbit while uniqueness holds, stopping at the cfg.clue_density[Layer::Cells] floor. (Or apply genre.clue_post_filters instead, when cfg.clue_filter is set.)
  5. VerifyFastSolver.solve(p, 2).len() == 1 gates the return. Loop on failure until cfg.time_budget elapses or cancel flips.

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.