Tagged serialization and dispatch
Every serialized puzzle (and every serialized game) carries its own
genre tag. The wire format is the same in every direction (db row,
data/puzzles/<genre>.jsonl.gz line, penmark import input, future
WASM IPC) — a self-describing JSON envelope:
{
"genre": "akari",
"data": { "rows": 7, "cols": 7, "walls": [...], ... },
"marks": { // optional — present iff this is a saved game
"cell:00": ["bulb", "empty"],
"cell:01": ["empty"],
...
}
}
The marks field is the saved-game extension: present means “this
is the puzzle plus where the player is now”; absent means “bare
puzzle.” A serialized game always carries the givens — data
is the full puzzle definition, every time. There is no marks-only
wire form, by design: a save-game blob is portable on its own and
needs no separate puzzle reference to render or play.
After the trait→struct collapse there is exactly one path into the format:
Puzzle::to_tagged_json/from_tagged_json— for every caller, whether or not it knows the genre yet.from_tagged_jsonreads the"genre"field from the JSON envelope, looks the name up viacore::genre::by_name, and constructs aPuzzlewhose.genrefield is that runtime handle. The pre-collapseAnyPuzzleenum (one arm per genre) is gone — the runtime handle carries everything it used to encode in its discriminant.Puzzle::to_tagged_json_with_marks/from_tagged_json_with_marks— the saved-game pair. The latter returns(Puzzle, Option<Marks>).
impl Puzzle {
pub fn to_tagged_json(&self) -> String;
pub fn from_tagged_json(s: &str) -> Result<Self, TagError>;
pub fn to_tagged_json_with_marks(&self, marks: &Marks) -> String;
pub fn from_tagged_json_with_marks(s: &str)
-> Result<(Self, Option<Marks>), TagError>;
}
/// Errors from tagged-JSON parsing.
pub enum TagError {
Json(serde_json::Error),
UnknownGenre(String),
GenreMismatch { expected: &'static str, found: String },
}
Round-trip property tests pin the agreement:
- Puzzle: for every
Puzzlevaluep,p.to_tagged_json()parses back to aPuzzlewhose.genreand storage equal the original. - Game: for every
(puzzle, sequence of valid moves), building the game, applying the moves, callingto_tagged_json_with_marks, parsing viafrom_tagged_json_with_marks, and rebuilding viaGame::from_marksproduces a game whosestatusandcandidates(c)match the original at every coord.
Adding a new genre = one &'static Genre literal + one entry in
the GENRES slice (see Adding a genre). The serialization paths
require no changes — they look up by name.
How variants ride inside
Variants of an existing genre — Killer Sudoku, Thermo, Sandwich,
Sudoku-X, German Whispers — are not new genres. They’re additional
constraints stacked onto the same puzzle’s constraints vector, so
the genre tag on the wire stays the same regardless of how exotic
the puzzle gets.
canonical/sudoku/variants.rs is the worked example:
- A
VariantTagenum names every recognised variant (Killer,Thermometer,Sandwich,AntiKnight,NonConsecutive, …). - A
Constraintsstruct carries oneVec<…>field per variant with payload (killer: Vec<KillerCage>,thermometer: Vec<Thermometer>, …), each#[serde(default, skip_serializing_if = "Vec::is_empty")]so absent variants vanish from the JSON. - A
TOGGLE_VARIANTSconst lists the payload-less ones (Diagonals, AntiKnight, NonConsecutive, …); they ride in avariants: Vec<VariantTag>list on the puzzle.
A Killer-plus-Thermo-plus-Sudoku-X puzzle round-trips as:
{
"genre": "sudoku",
"data": {
"box_size": 3,
"givens": { ... },
"variants": ["diagonals"],
"constraints": {
"killer": [{ "cells": [...], "total": 23 }, ...],
"thermometer": [{ "bulb": [3,3], "path": [...] }, ...]
}
}
}
Two clean properties fall out:
- The dispatch tag never grows. Every Sudoku — vanilla, Killer,
Thermo, all of them — serializes as
"genre": "sudoku". The variant universe expands inside Sudoku’s data shape. New variant = new field onConstraints+ newVariantTagarm; no change to the genre registry, no change to the db schema, no change to the import pipeline. - Querying for variants stays cheap without normalising. The
db row’s
dataJSON is searchable by tag (data->'variants' @> '["diagonals"]'in Postgres,json_extract(data, '$.variants')in SQLite) — no separatepuzzle_variantsjoin table required unless query patterns demand it. If “show me every Killer” earns a denormalised indexed column, it’s the same write-side helper pattern asgenre: extractvariants[]into a sibling text array.
What this rules out: “Killer Sudoku is its own genre with its own dispatch arm.” That’d contradict the constraint-composition thesis the rest of the design is built on, and force every cross-genre caller to know that Killer is just-Sudoku-but-different. The genre is the rule vocabulary the puzzle uses; variants are constraints stacked on top.
What this unifies
- Database —
puzzles(id, genre TEXT, data JSON, rows, cols, difficulty, hard_score, ...)wheredatais the tagged blob andgenreis denormalised from the tag for indexing. Loader isPuzzle::from_tagged_json(row.data)?. ACHECKconstraint or a write-side helper enforces that the column and the blob’s tag agree. data/puzzles/<genre>.jsonl.gz— one tagged blob per line. The per-genre file split stays (keeps file sizes / query patterns reasonable), but each line is self-describing — a stray Sudoku blob in the Akari file is rejected at read time via the tag / expected-genre check.penmark import— reads puzzlehound’s collated output, parses each line viaPuzzle::from_tagged_json, validates the resulting.genre.nameagainst the target file’s expected genre, appends. The “what genre is this?” question is asked once (during the tag lookup) and the rest of the pipeline operates on the resultingPuzzle.- Saved games —
Puzzle::to_tagged_json_with_marks(&marks)produces the same envelope with amarksfield appended. A save / share / URL-hash carries everything needed to render and play in one blob; the givens travel with the marks, so a recipient can play with no prior context. - WASM (when it lands) — the JS side hands a tagged blob to
Rust, which parses through
Puzzle::from_tagged_json[_with_marks]and dispatches perpuzzle.genre. No parallel envelope format, no out-of-band genre header, no separate “puzzle channel” and “game-state channel”.
The thing this doesn’t do is force every caller to think about
dispatch. Puzzle::from_tagged_json(blob) returns a Puzzle that
already knows its genre; method calls go through the fn-pointer
field, not through a match.