diff --git a/specs/OrnamentRegister.md b/specs/OrnamentRegister.md new file mode 100644 index 0000000..6c799ba --- /dev/null +++ b/specs/OrnamentRegister.md @@ -0,0 +1,69 @@ +# Ornament register — what we claim does not matter + +The declarations required by [`Ornamentation.md`](Ornamentation.md) §2.1. + +**Every row is a claim that something does not affect the game.** This +project's finding register is largely a list of times that claim was +wrong, so each declaration carries a **falsifier**: the rule or +observation that would make it mechanism. A declaration that cannot be +wrong is not a claim (§2.1, invariant I5). + +`grounded` is how the claim is supported — `rule` (the edition says so), +`measured` (we checked), or `provisional` (nobody has ruled, and we chose). +**`provisional` is a legitimate state and must be said out loud**; quietly +canonising a provisional default is a mistake this project has already +made once. + + + +| id | ornaments | grounded | state | raised | +|---|---|---|---|---| +| O1 | hand order | provisional | declared | 2026-08-07 | +| O2 | who shuffles and deals | provisional | declared | 2026-08-07 | +| O3 | where a seat is drawn | rule | declared | 2026-08-07 | +| O4 | card typography and illustration | provisional | declared | 2026-08-07 | + + + +- **O1 — hand order.** The *contents* of a hand are mechanism; the order + the cards sit in is not. No numbered rule in `GroundRules.md` refers to + a card's position within a hand, and `PlayerView.hand` is a `Vec` whose + order comes from dealing, not from a rule. + **Falsifier:** any rule that refers to a card's position in hand — + *"play your leftmost"*, *"reveal the top of your hand"* — or any engine + behaviour that varies with it. Invariant I1 is the standing check: + sorting must not move `state_hash`, because a hash that moved would + break every trial note's binding (ADR-0019 D3) and every replay. + **This is the one that prompted the category**, from the request to + drag cards between hand slots. + +- **O2 — who shuffles and deals.** The deck's permutation *is* mechanism + and comes from the seed; the ceremony around it is not. Nothing in the + rules names a dealer, and the hands are identical whoever performs it. + **Falsifier:** a rule that gives the dealer a decision, an advantage, or + an order of dealing that changes who receives which card. Note the deal + is **not** ornamental — only the ceremony around it is. + +- **O3 — where a seat is drawn.** Seat *order* is mechanism: GR-R08 + rotates Lead, so who follows whom binds. **Where a seat appears on the + drawn ellipse does not**, provided the drawn order preserves the + rotation order. + **Falsifier:** any rule referring to physical position — *"the player + across from you"*, *"your left-hand neighbour"* as distinct from the + next in rotation. This is `rule`-grounded rather than provisional + because GR-R08 states the ordering that binds. + +- **O4 — card typography and illustration.** How a card looks does not + change what it does. **But it is not ours to invent**: ADR-0015's + discipline holds, and `Player_Mats` and `Glossary` — which carry exactly + this — are still unvendored (F18). + **Falsifier:** a card whose meaning is carried by its layout or art + rather than its text, which is a real possibility in a game about + perception and is the reason this is `provisional` rather than `rule`. + **Nothing may be declared about these files until they are read.** + +## What is deliberately absent + +**No row for animation, timing or movement.** Those are ornamentation, and +they belong to `clay-animate` (§3). A declaration here is a claim about +*the game*; how a renderer performs it is that renderer's business. diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs index dcb1c69..b828fe5 100644 --- a/tools/cb-play/src/hotseat.rs +++ b/tools/cb-play/src/hotseat.rs @@ -1215,6 +1215,7 @@ mod tests { serve: Some(0), trial: None, mode: games_ground::ScoringMode::SharedGround, + pace: crate::table::Pace::Speed, }, std::io::Cursor::new(Vec::new()), out, @@ -1404,6 +1405,7 @@ mod tests { serve: Some(0), trial: None, mode: games_ground::ScoringMode::SharedGround, + pace: crate::table::Pace::Speed, }, std::io::Cursor::new(Vec::new()), out, diff --git a/tools/cb-play/src/inspect.rs b/tools/cb-play/src/inspect.rs index 07827cc..d953aa5 100644 --- a/tools/cb-play/src/inspect.rs +++ b/tools/cb-play/src/inspect.rs @@ -764,6 +764,7 @@ mod tests { serve: None, trial: None, mode: games_ground::ScoringMode::SharedGround, + pace: crate::table::Pace::Speed, }; let mut sink: Vec = Vec::new(); let summary = diff --git a/tools/cb-play/src/main.rs b/tools/cb-play/src/main.rs index 46fddc0..158fd52 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -29,6 +29,9 @@ play: --replay DIR write a .cbreplay bundle of the finished game to DIR --record FILE write the finished game as a scenario YAML --trial FILE write a trial log: what the player said, bound to where + --pace P speed (default) or interactive: how much ornamentation + is performed. Never changes the game -- the recording is + byte-identical either way (specs/Ornamentation.md) --mode M scoring mode: shared (GR-E02), common (GR-E03), coalitions (GR-E04). Default shared. --serve PORT play human seats in a browser on 127.0.0.1:PORT instead @@ -127,6 +130,18 @@ fn parse_args(argv: &[String]) -> Result { }; i += 2; } + // CB-WP-0036 T02. A SEPARATE axis from --mode: that one is + // ScoringMode, a rule of the game; this is how much of what + // the rules cannot see gets performed (Ornamentation §4). + // + // **The flag exists before anything reads it, deliberately.** + // It is the seam `clay-animate` attaches to, and a seam is + // cheap now where a retrofit would not be. + "--pace" => { + play_flags.push(flag.into()); + config.pace = value(i, argv, flag)?.parse()?; + i += 2; + } "--trial" => { play_flags.push(flag.into()); config.trial = Some(value(i, argv, flag)?.into()); @@ -290,6 +305,7 @@ mod tests { serve: None, trial: None, mode: games_ground::ScoringMode::SharedGround, + pace: table::Pace::Speed, }; let script = "0\n".repeat(400); let mut out: Vec = Vec::new(); @@ -312,6 +328,78 @@ mod tests { } } + /// **Ornamentation §5, I3** — the invariant the clay-borg / + /// clay-animate split rests on. + /// + /// > The same seed and the same decisions produce a **byte-identical + /// > recording** at any pace. + /// + /// Asserted rather than intended. If this ever fails, something that + /// was called ornamentation has become mechanism, and the boundary in + /// `specs/Ornamentation.md` has stopped being real — which §7 names + /// as wrong at the root rather than patchable at the edges. + #[test] + fn pace_cannot_change_the_game() { + let at = |pace| { + let config = Config { + seed: 42, + players: 3, + human_seats: vec![0], + bot: "greedy".into(), + replay_dir: None, + record: None, + serve: None, + trial: None, + mode: games_ground::ScoringMode::SharedGround, + pace, + }; + let script = "0\n".repeat(400); + let mut out: Vec = Vec::new(); + let s = table::play(&config, script.as_bytes(), &mut out).expect("game"); + ( + serde_yaml::to_string(&s.scenario).expect("yaml"), + s.end_state_hash, + ) + }; + let (speed_yaml, speed_hash) = at(table::Pace::Speed); + let (inter_yaml, inter_hash) = at(table::Pace::Interactive); + + // The recording, byte for byte. + assert_eq!( + speed_yaml, inter_yaml, + "the recording differs by pace, so a renderer has become mechanism" + ); + // And the state hash, which is what §1.1 uses to tell the two + // categories apart in the first place. + assert_eq!(speed_hash, inter_hash, "pace moved the state hash"); + } + + /// `--pace` parses, defaults to speed, and refuses what it cannot do. + /// + /// **Speed is the default** because `sim`, `trials`, the benchmarks + /// and every bot game run at it (Ornamentation §4). + #[test] + fn pace_parses_and_defaults_to_speed() { + assert_eq!(Config::default().pace, table::Pace::Speed); + assert_eq!( + play_args(&["--pace", "interactive"]).expect("parse").pace, + table::Pace::Interactive + ); + assert_eq!( + play_args(&["--pace", "speed"]).expect("parse").pace, + table::Pace::Speed + ); + // A misspelling must not silently mean the default: pace is the + // seam clay-animate attaches to, and a typo that quietly selects + // Speed would look like the renderer being broken. + let e = match play_args(&["--pace", "cinematic"]) { + Err(e) => e, + Ok(_) => panic!("a misspelt pace was accepted"), + }; + assert!(e.contains("cinematic"), "{e}"); + assert!(e.contains("speed") && e.contains("interactive"), "{e}"); + } + /// K13 at the boundary that matters: what the human is *shown* must /// not contain another seat's face-down selection. /// @@ -331,6 +419,7 @@ mod tests { serve: None, trial: None, mode: games_ground::ScoringMode::SharedGround, + pace: table::Pace::Speed, }; let mut out: Vec = Vec::new(); table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game"); @@ -395,6 +484,7 @@ mod tests { serve: None, trial: None, mode: games_ground::ScoringMode::SharedGround, + pace: table::Pace::Speed, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game"); @@ -457,6 +547,7 @@ mod tests { serve: None, trial: None, mode: games_ground::ScoringMode::SharedGround, + pace: table::Pace::Speed, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("game"); diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 5eb6efd..b9941a1 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -47,6 +47,43 @@ pub struct Config { /// patch, so two of the three shipped modes were unreachable from the /// only way anyone actually plays. pub mode: games_ground::ScoringMode, + /// How much ornamentation is performed (CB-WP-0036, + /// [`specs/Ornamentation.md`]). + /// + /// **A separate axis from `mode`, deliberately.** `mode` is + /// `ScoringMode` — a rule of the game. `pace` is how much of what the + /// rules cannot see gets shown. Calling both of them "mode" on one + /// driver is a collision waiting to be mis-read. + pub pace: Pace, +} + +/// Speed or Interactive (Ornamentation §4). +/// +/// **Speed is the default and always will be.** `sim`, `trials`, the +/// benchmarks and every bot game run at it, and ornamentation must cost +/// them *nothing* — not "little". +/// +/// **Pace can never select different mechanism** (Ornamentation §5, I3): +/// the same seed and the same decisions produce a byte-identical +/// recording at either pace. That is the invariant the whole +/// clay-borg / clay-animate split rests on, so it is asserted, not +/// assumed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Pace { + #[default] + Speed, + Interactive, +} + +impl std::str::FromStr for Pace { + type Err = String; + fn from_str(s: &str) -> Result { + match s.to_ascii_lowercase().as_str() { + "speed" | "fast" => Ok(Pace::Speed), + "interactive" | "inter" => Ok(Pace::Interactive), + other => Err(format!("unknown --pace {other:?} (speed, interactive)")), + } + } } impl Default for Config { @@ -61,6 +98,7 @@ impl Default for Config { serve: None, trial: None, mode: games_ground::ScoringMode::SharedGround, + pace: Pace::Speed, } } } diff --git a/tools/loop-lint.py b/tools/loop-lint.py index abe0e2b..fe348db 100644 --- a/tools/loop-lint.py +++ b/tools/loop-lint.py @@ -306,6 +306,51 @@ def check_gate_registry(root=REPO): return out +def check_ornament_falsifier(root=REPO): + """Ornamentation I5 — an open declaration names what would refute it. + + **A declaration is a claim that something does not matter**, and this + project's finding register is largely a list of times that claim was + wrong. One that cannot be wrong is not a claim, it is a preference + with a table row. + + Presence, never adequacy -- the same split as ADR-0018 D5. + """ + out = [] + reg = os.path.join(root, "specs", "OrnamentRegister.md") + if not os.path.exists(reg): + return out + with open(reg) as fh: + text = fh.read() + m = re.search(r"(.*?)" + r"", text, re.S) + if not m: + return out + for line in m.group(1).splitlines(): + line = line.strip() + if not line.startswith("|") or line.startswith("|---"): + continue + cells = [c.strip() for c in line.strip("|").split("|")] + if len(cells) != 5 or cells[0] == "id": + continue + oid, state = cells[0], cells[3] + # A refuted or withdrawn row is history; the rule binds a claim + # that is still being made. + if state != "declared": + continue + body = re.search(rf"^- \*\*{re.escape(oid)} [^\n]*(?:\n(?!- \*\*O\d).*)*", + text, re.M) + if not body or "Falsifier:" not in body.group(0): + out.append(Finding( + "ornament", + "specs/OrnamentRegister.md", + f"{oid} is declared ornamentation and names no falsifier " + f"(Ornamentation.md I5). Say what would make it mechanism. " + f"NOTE: this checks presence, not adequacy.", + )) + return out + + def check_sensitivity_stated(root=REPO): """GameDesign §1.4 / ADR-0018 — a finding whose claim is arithmetic must name the variable it depends on. @@ -384,6 +429,7 @@ CHECKS = ( check_reporting_tools_self_test, check_gate_registry, check_sensitivity_stated, + check_ornament_falsifier, ) @@ -473,6 +519,29 @@ def self_test(): check("sensitivity: a note has no measurement to be sensitive about", not check_sensitivity_stated(tmp), "GameDesign §3.1") + # Ornamentation I5. Same shape: it must say NO and it must say YES. + def orn(state, prose): + body = ("\n\n" + "| id | ornaments | grounded | state | raised |\n" + "|---|---|---|---|---|\n" + f"| O9 | a thing | provisional | {state} | 2026-01-01 |\n" + "\n\n\n" + f"- **O9 — a thing.** {prose}\n") + with open(os.path.join(tmp, "specs", "OrnamentRegister.md"), "w") as fh: + fh.write(body) + + orn("declared", "It does not matter.") + check("ornament: a declaration with no falsifier is caught", + len(check_ornament_falsifier(tmp)) == 1, + "a claim that cannot be wrong is not a claim") + orn("declared", "It does not matter. **Falsifier:** a rule naming it.") + check("ornament: naming a falsifier clears it", + not check_ornament_falsifier(tmp), + "without this it would fire on everything") + orn("refuted", "It does not matter.") + check("ornament: a refuted row is history, not a live claim", + not check_ornament_falsifier(tmp)) + wp("ready", ["done", "todo"]) f = check_workplan_lifecycle(tmp) check("lifecycle detects `ready` after work has started", diff --git a/workplans/CB-WP-0036-interactive-mode.md b/workplans/CB-WP-0036-interactive-mode.md index f1dcd97..15ced26 100644 --- a/workplans/CB-WP-0036-interactive-mode.md +++ b/workplans/CB-WP-0036-interactive-mode.md @@ -2,7 +2,7 @@ id: CB-WP-0036 kind: product title: "Pace and ornamentation" -status: active +status: done state_hub_workstream_id: "698651ed-cccd-46f2-84cc-07b0bdb85ad0" --- @@ -88,7 +88,7 @@ any renderer — is the falsifier for the whole split. ```task id: CB-WP-0036-T02 -status: todo +status: done priority: medium state_hub_task_id: "e978b93b-fcbd-4853-a27d-5eca00f9ca19" ``` @@ -104,11 +104,30 @@ state_hub_task_id: "e978b93b-fcbd-4853-a27d-5eca00f9ca19" it is the seam `clay-animate` will attach to, and a seam is cheap while a retrofit is not. +**Done 2026-08-07.** `--pace speed|interactive`, `Pace::Speed` the +`Default`. + +**I3 is asserted, not intended.** `pace_cannot_change_the_game` plays the +same scripted game at both paces and compares the **serialised recording +byte for byte**, plus the end state hash. Mutation-proven: leak the pace +into the seed and it fails with *"the recording differs by pace, so a +renderer has become mechanism"*. + +**A misspelt pace is refused rather than defaulting.** `--pace cinematic` +errors and names both values. Quietly falling back to Speed would look +exactly like `clay-animate` being broken, and that is the seam this flag +exists to be. + +I4 needs no measurement yet and that is worth saying plainly: **nothing +reads `pace`**, so the cost to `sim`, `trials` and the benchmarks is zero +by construction rather than by benchmark. The measurement becomes real +when something reads it. + ## Task: declarations, once there are any to make ```task id: CB-WP-0036-T03 -status: todo +status: done priority: low state_hub_task_id: "bf2a68b0-5985-40ff-b4f5-33c40d92cff0" ``` @@ -116,12 +135,29 @@ state_hub_task_id: "bf2a68b0-5985-40ff-b4f5-33c40d92cff0" Ornamentation declarations per §2.1 — what is ornamented, why it does not bind, what would falsify it. -**Not started deliberately.** GameDesign §2's rule applies: **no kind -exists without an instance that instantiates it.** The first real -declarations are most likely to come from F18's unvendored files — -`Player_Mats` and `Glossary` are ornamentation-bearing data this engine -has never read — so **vendoring those is the trigger**, not a schema -designed in advance. +**Done 2026-08-07.** [`specs/OrnamentRegister.md`](../specs/OrnamentRegister.md), +four declarations. + +**This reverses the reasoning written here earlier**, which said the first +declarations would come from F18's unvendored files and that vendoring was +the trigger. That was wrong: **instances already existed.** Hand order is +the one that prompted the whole category — the request to drag cards +between slots — and *"who deals"* was the maintainer's own example. Waiting +for F18 would have left the register empty while its instances sat in the +conversation that created it. + +The four: **O1 hand order** (provisional), **O2 who shuffles and deals** +(provisional), **O3 where a seat is drawn** (`rule`-grounded — seat +*order* is mechanism, GR-R08 rotates Lead; the drawn position is not), +**O4 typography and illustration** (provisional, and **nothing may be +declared about `Player_Mats` or `Glossary` until they are read** — F18). + +**I5 is executable**: `check_ornament_falsifier` in `loop-lint` fails any +row still `declared` that names no falsifier. Mutation-proven on O1 — +strip its falsifier and the lint goes red. Three self-test controls: it +catches a bare declaration, clears one that names a falsifier, and skips +`refuted` rows as history. **Presence, never adequacy**, and the finding +text says so — the same split as ADR-0018 D5. ## No longer in this repo