Enumerator
Enumeration is genre.enumerate — an fn pointer on the runtime
&'static Genre handle that streams uniquely-solvable puzzles for
a given size, calling on_puzzle for each hit and on_stats
periodically for progress UIs.
pub enumerate: fn(
&EnumConfig,
&AtomicBool,
&mut dyn FnMut(&Puzzle),
&mut dyn FnMut(EnumStats),
) -> String,
The default behind genre.enumerate is walk_canonical — a true
canonical-form walker shared across every genre, driven entirely by
the runtime Genre handle (genre.material_vocab,
genre.derive_clue_at, genre.clue_domain, genre.empty_at,
genre.build_constraints). No genre overrides it today.
Clue derivation per cell goes through genre.derive_clue_at(p, sol, coord) -> Option<ClueValue>. The default body dispatches through
genre.clue_pattern (the canonical Pin / Count /
ConnectedSize shortcut for u8 genres); future multi-byte clue
genres (Tapa, Yajilin) override derive_clue_at directly. See the
ClueValue discussion in Traits → Puzzle for the full picture.
The walker has three nested phases:
- Layouts. Walk the cell-layer material space at the given
size, in canonical-form order under D4 (square) / D2 (non-square)
lex-min. Genres with empty
material_vocab(sudoku, slitherlink) collapse to a single all-floor layout. - Completions. Bare-solve each layout to enumerate the legal full assignments under the genre’s interior rules (no clues active). Skip completions whose maximally-clued puzzle isn’t uniquely solvable — no clue subset of an ambiguous full-clue set can be unique.
- Clue subsets. For each accepted completion, walk lex-ascending k-combinations of clueable cells for k = 1, 2, … Smallest-puzzle-first ordering. Stabilizer-based dedup filters out clue arrangements that are D4-images of each other within a single layout’s orbit, before invoking the solver.
A monotonicity short-circuit runs alongside the subset walk: if
a candidate subset M contains some previously-confirmed unique
subset R as a strict superset, M must also be unique (extra clues
are consistent with the same bare-solve completion, so adding them
can’t introduce a second solution). The walker tracks recorded
unique masks in popcount-bucketed Vec<u64> per completion and
skips the solver call when M is subsumed. Solving was ~90% of
per-subset cost, so this typically yields an order-of-magnitude
speedup once minimal-unique anchors fill in at low k.
See notes/canonical-enumeration.md for the full design rationale,
soundness arguments for the completions skip, and follow-on
directions (value-relabeling-aware dedup, beautification).
EnumConfig
pub struct EnumConfig {
pub dims: Dimensions,
pub limit: usize, // 0 = unlimited
pub time_budget: Duration,
pub holes: bool, // include Material::Hole in the cell alphabet
pub max_bare_solutions: usize, // cap on completions per layout (default 10_000)
}
pub struct EnumStats {
pub layouts_examined: u64,
pub total_layouts: u64,
pub completions_examined: u64,
pub subsets_examined: u64,
pub unique: u64,
}
holes widens akari from 2-state (floor/wall) to 3-state
(floor/wall/hole). Other genres don’t declare Hole on the cell
layer, so the flag is a no-op for them. Default is true; set to
false for larger akari grids where the 3-state space blows up.
max_bare_solutions caps the bare-solve completion enumeration
per layout. Sudoku 4×4 has exactly 288 completions; slither 4×4
has thousands. The cap mostly exists so a pathological scaffold
with millions of legal completions can’t hang the walker.
The return type is String — a free-form “stop reason” the caller
echoes back ("exhausted", "limit", "deadline", "cancelled",
or "bad-config:<why>").
Why callbacks, not iterators
Every real caller wants two things an iterator handles awkwardly:
- Progress reporting, since canonical-form scans can examine millions of layouts even at small sizes; silent UIs are broken.
- Early exit on multiple conditions — output limit, wall-clock budget, user cancel. Returning an iterator forces every consumer to re-thread those exit conditions through the pull loop; baking them into the contract keeps callers trivial.
cancel is a separate &AtomicBool rather than a config field so
EnumConfig stays Serialize + DeserializeOwned — runtime
cancellation has no business in a serialized config.
Discovery and coverage (planned)
The discovery + coverage feedback loop is described elsewhere — pair the enumerator with the technique library and the trial-search oracle to mine specimens (small puzzles annotated with what was hard about them), then run every technique against every persisted specimen for per-technique hit counts and coverage residue.
The CLI verbs discover and coverage are placeholders for that
loop; the mining loop runs ad-hoc against Genre::enumerate until
those land.