From 2806b3acb79156886305f9530cdf67c92a3a11c1 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 8 Aug 2026 15:35:03 +0200 Subject: [PATCH] CB-WP-0041 done: ADR-0020 refuses the port, and T02 is why MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T02 — all chance derives from one root seed. Three chance points, all reading it: the setup deck shuffle, the setup Lead draw, and the reshuffle permutation. The Problems deal is not chance at all. So in extensive-form terms the tree has a single chance node at the root. That test was wrong first, and the mutation caught it. It compared state hashes — and GroundState carries `seed` as a field, so "different seeds differ" was true by construction. Mutating the shuffle away left it green. It now compares the dealt configuration, and the same mutation fails it: a wrong-subject error inside the control written for T02. The reshuffle is a pure function of (seed, round) because K5 requires deterministic replay, where a real table reshuffles independently. That is a modelling restriction, not a defect, and it is now pinned. T03 — commit/reveal checked in both directions: before Reveal each seat sees its own selection and no other; after Reveal the information sets merge, because an encoding that hides forever is not commit/reveal either. T04 — ADR-0020 refuses the EFG port, and the blocker is T02 rather than T01, which inverts what the workplan expected. Perfect recall looked like the risk and is a constraint with a known answer: key on observation histories. Making chance explicit is the expensive one — the reshuffle would become a real chance node and break the K5 purity that every recording, replay bundle and trial-note hash depends on. A port would trade the property this project is built on for one it has never needed. Track B's first move is therefore a question, not a build: take "is exploitability meaningful for a co-operative game with a shared threshold" to OpenSpiel on a toy model, where answering it costs nothing. D4 states what being wrong looks like — OpenSpiel settling on a toy what three rounds of policy sweeps could not — and makes watching for it the next action. Taxonomy §4.1 records the EFG correspondence with the test that checks each row, so a later pass starts from a specification rather than a memory. Chaos window 4 at three declarations. Window 3's verdict is now two windows behind and should be evaluated rather than restated. Co-Authored-By: Claude Opus 5 --- .../ADR-0020-we-do-not-build-the-port.md | 106 ++++++++++++ games/ground/src/lib.rs | 153 ++++++++++++++++++ specs/ChaosRollHistory.md | 16 ++ specs/Taxonomy.md | 23 +++ ...B-WP-0041-the-extensive-form-foundation.md | 78 ++++++++- 5 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 decisions/ADR-0020-we-do-not-build-the-port.md diff --git a/decisions/ADR-0020-we-do-not-build-the-port.md b/decisions/ADR-0020-we-do-not-build-the-port.md new file mode 100644 index 0000000..78de584 --- /dev/null +++ b/decisions/ADR-0020-we-do-not-build-the-port.md @@ -0,0 +1,106 @@ +# ADR-0020: we do not build an extensive-form port, and here is what to do instead + +status: accepted +date: 2026-08-08 +decided by: agent, under the standing loop authorization +tier: M (a decision *not* to create a capability port; creating one would +have been L). chaos d8 = 7 → no override. **Declaration 3 of chaos +window 4.** +references: [CB-WP-0041](../workplans/CB-WP-0041-the-extensive-form-foundation.md), +[CB-RES-0009](../research/CB-RES-0009-extensive-form-is-the-lingua-franca.md), +[`specs/Positioning.md`](../specs/Positioning.md) §4 Track B, +[`simulators/openspiel.md`](../simulators/openspiel.md) + +## Context + +[CB-RES-0009](../research/CB-RES-0009-extensive-form-is-the-lingua-franca.md) +found the extensive-form game is the interchange format between +describing a game and analysing it, and that clay-borg already has most +of one. CB-WP-0041 T01–T03 then asked what is actually true of the +engine, and the answers changed the decision. + +## D1 — the three findings this rests on + +**T01. Perfect recall fails on the projection and holds on the history.** +22 violations in 44,938 sampled decision points when the information set +is taken to be `project(Viewer::Player(seat))`; none when it is the +seat's observation history. **`project()` is an observation, not an +information state** — OpenSpiel's own `ObservationString` / +`InformationStateString` split, found here by measurement. + +**T02. All chance is one root node, and the reshuffle is correlated.** +Three chance points, all reading the root seed; the Problems deal is not +chance at all. The reshuffle permutation is a pure function of +`(seed, round)` **because K5 requires replay to be deterministic**, where +a real table would reshuffle independently. + +**T03. Commit/reveal is the EFG simultaneity encoding**, and now checked +in both directions rather than assumed. + +## D2 — we do not build an EFG export port + +**Refused, and the reason is T02, not T01.** + +T01 looked like the blocker and is not: it is a *constraint* with a known +answer — key information sets on observation histories. That is a +specification, and a cheap one. + +**T02 is the blocker.** Making chance explicit means the reshuffle +becomes a genuine chance node, and **that breaks the purity that makes +every recording in this repo replay**. Our scenarios, the replay bundles, +`record.rs`'s round trip, the trial notes' state hashes — all of them rest +on chance being a function of state (GameKernel K5). + +> **A port would trade the property this project is built on for a +> property it has never needed.** Replay is load-bearing for the evidence +> discipline; equilibrium computation is, so far, a research interest. + +**And the port is not the cheap part anyway.** Enumeration is impossible — +24 Solution cards give 24! root branches — so any analysis samples chance, +which is what our seed sweeps already do. + +## D3 — what we do instead, in order + +1. **Write the mapping down, do not implement it.** The EFG correspondence + is now established and tested; it belongs in the taxonomy as a stated + relationship, so a later pass starts from a specification rather than a + memory. +2. **When Track B opens, take the question to OpenSpiel, not our kernel.** + Express a *small* GROUND-shaped game against its API and ask whether + exploitability says anything useful for a co-operative game with a + shared threshold. **That is a question about game theory, not about our + engine**, and answering it in our engine would cost the replay property + to learn something a toy model answers. +3. **If the answer is yes, revisit this ADR.** The cost in D2 is real but + it is not infinite: an EFG export that *samples* chance rather than + representing it would preserve K5 and might be enough. + +## D4 — what this decision would look like if it were wrong + +**If exploitability turns out to be the instrument that settles design +questions** — if it answers *"does ATTACK ever pay"* in a way three review +rounds of policy sweeps could not — then refusing the port will look like +protecting a process property at the cost of the product's purpose. + +**The tell would be**: OpenSpiel answering, on a toy model, a question our +panels have been unable to answer. **Watch for it. That is D3 step 2, and +it is deliberately the next thing.** + +## Consequences + +- No EFG port, no OpenSpiel dependency, no equilibrium computation. +- **The AM-4 dependency budget is untouched**, which is worth stating + because adopting OpenSpiel would have been a large, non-Rust dependency. +- Track B's first move is a **question**, not a build. +- The three properties T01–T03 established are now regression-tested, so a + later port starts from checked ground rather than re-deriving them. + +## What was rejected + +| rejected | why | +|---|---| +| build an EFG export now | breaks K5 replay purity for a research interest (D2) | +| key information sets on `project()` | measured wrong — 22 perfect-recall violations (T01) | +| adopt OpenSpiel as an engine dependency | large non-Rust dependency, for a question a toy model answers | +| declare Track B blocked | it is not blocked, it is *specified*: the constraint is known and the next step is a question | +| say nothing and revisit later | the three findings would be re-derived from memory, which is how this project produces wrong-subject errors | diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 3370a15..e9fd0c5 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -2256,6 +2256,159 @@ mod tests { ); } + /// **Commit/reveal IS the extensive-form encoding of simultaneous + /// moves** (CB-WP-0041 T03), and this checks it rather than + /// asserting it. + /// + /// An EFG has no simultaneity: the textbook encoding sequences + /// the moves and puts the later mover in an information set that + /// cannot see the earlier one. GROUND's Select step does exactly + /// that — seats choose in order, and no seat may see another's + /// selection until Reveal. + /// + /// **The property is load-bearing.** If a seat can see another's + /// pending selection, the encoding is not simultaneous, the + /// information partition is wrong, and every equilibrium concept + /// computed on it answers a different game. + #[test] + fn a_pending_selection_is_hidden_until_reveal() { + use cb_game_runtime::{Project, Viewer}; + + let mut s = setup(4, Variant::Baseline, 9); + let seats: Vec = s.players.keys().copied().collect(); + // Every seat commits. + for seat in &seats { + s.selections.insert( + *seat, + Selection { + action: Action::Investigate, + target: None, + problem: s.problems.keys().next().copied(), + }, + ); + } + + // Before Reveal: a seat sees its own and nobody else's. + s.step = RoundStep::Select; + for viewer in &seats { + let v = s.project(Viewer::Player(*viewer)); + for (seat, shown) in &v.selections { + let visible = matches!(shown, crate::view::SelectionView::Shown(_)); + assert_eq!( + visible, + seat == viewer, + "before Reveal, {viewer:?} could see {seat:?}'s selection — \ + the Select step is not simultaneous and the information \ + partition is wrong" + ); + } + } + + // After Reveal: public, which is the other half of the + // encoding — the information sets must MERGE, or the reveal + // never happened. + s.step = RoundStep::Reveal; + for viewer in &seats { + let v = s.project(Viewer::Player(*viewer)); + assert!( + v.selections + .values() + .all(|x| matches!(x, crate::view::SelectionView::Shown(_))), + "after Reveal, {viewer:?} still cannot see every selection" + ); + } + } + + /// **All chance derives from the root seed** (CB-WP-0041 T02). + /// + /// The claim T02 rests on, made checkable rather than asserted: + /// a game is determined by `(seed, players, mode, variant)`, so + /// in extensive-form terms the tree has **a single chance node at + /// the root** rather than chance distributed through it. + /// + /// Three chance points exist and all three read that seed: + /// the setup deck shuffle, the setup Lead draw, and the mid-game + /// reshuffle permutation (`seed ^ round`). The **Problems deal is + /// not chance at all** — `edition::deal` is a pure function of the + /// vendored CSV. + #[test] + fn a_game_is_determined_by_its_seed() { + for players in [2u8, 3, 6] { + // **The dealt configuration, NOT the state hash.** + // `GroundState` carries `seed` as a field, so hashing the + // state makes "different seeds differ" true by + // construction — a wrong-subject error, caught by + // mutating the shuffle away and watching this stay green. + let at = |seed: u64| { + let s = setup(players, Variant::Baseline, seed); + let hands: Vec> = s + .players + .values() + .map(|p| p.hand.iter().map(|c| c.suit).collect()) + .collect(); + format!("{:?}|{:?}|{:?}", hands, s.lead, s.solution_deck) + }; + // Same seed, same game — twice, because "deterministic" + // that only holds once is not determinism. + assert_eq!(at(11), at(11), "{players}p: setup is not reproducible"); + // And the full state still round-trips, which is the + // replay property this rests on. + assert_eq!( + cb_events::state_hash_hex(&setup(players, Variant::Baseline, 11)), + cb_events::state_hash_hex(&setup(players, Variant::Baseline, 11)), + ); + // Different seeds must actually differ, or the seed is + // not the chance node and this claim is empty. + let distinct: std::collections::BTreeSet = (0..12u64).map(at).collect(); + assert!( + distinct.len() > 1, + "{players}p: every seed produced the same game, so the root \ + chance node carries no information" + ); + } + } + + /// **The reshuffle is correlated with the root seed, and a real + /// table's is not** (CB-WP-0041 T02). + /// + /// `draw_solution` reshuffles the discard with + /// `ChaChaRng::from_seed(seed ^ round)` — deliberate, so replay + /// never re-derives it (GameKernel K5), and the comment says so. + /// + /// **The consequence is a modelling one, not a defect.** At a + /// table the reshuffle is an independent random event; here it is + /// determined by the initial shuffle and the round number. An + /// extensive-form game built from this engine inherits that + /// correlation, and would be modelling a *restriction* of the + /// game as played. + #[test] + fn the_reshuffle_permutation_is_a_function_of_seed_and_round() { + let deck: Vec = [Suit::Clarify, Suit::Repair, Suit::Boundary] + .into_iter() + .flat_map(|suit| std::iter::repeat_n(SolutionCard { suit }, 3)) + .collect(); + let shuffled = |seed: u64, round: u8| { + let mut order = deck.clone(); + let mut rng = ChaChaRng::from_seed(Seed(seed ^ u64::from(round))); + rng.shuffle(&mut order); + order + }; + // Same seed and round: the same permutation, always. + assert_eq!(shuffled(7, 2), shuffled(7, 2)); + // And it moves with BOTH inputs, or the correlation claim is + // about something that does not vary. + assert_ne!( + shuffled(7, 2), + shuffled(8, 2), + "the reshuffle does not depend on the seed" + ); + assert_ne!( + shuffled(7, 2), + shuffled(7, 3), + "the reshuffle does not depend on the round" + ); + } + /// **H1-A lands before the DARVO arm check** (review M5). /// /// The delta orders it "+1 Stress, then clamp, then DARVO arm diff --git a/specs/ChaosRollHistory.md b/specs/ChaosRollHistory.md index 73cb0be..7a08f28 100644 --- a/specs/ChaosRollHistory.md +++ b/specs/ChaosRollHistory.md @@ -89,3 +89,19 @@ separately because it was a materially different pass: CB-WP-0036 was re-scoped from L to M after the maintainer moved the animation work out of the repo, and a changed declaration is a new declaration or the roll is not binding on what was actually built. + +--- + +## Window 4 — opened 2026-08-08 at d8 + +| # | pass | roll | override | +|---|---|---|---| +| 1 | CB-WP-0041 | d8 = 5 | — | +| 2 | CB-WP-0042 | d8 = 4 | — | +| 3 | ADR-0020 | d8 = 7 | — | + +**Window 3's verdict remains owed** and is now two windows behind. One +override in twelve, changing nothing, is the second consecutive window +meeting ADR-0017 D2's retirement condition — *"a full window's overrides +all change nothing"* — and the condition asks for two consecutive such +windows. **It should be evaluated, not restated again.** diff --git a/specs/Taxonomy.md b/specs/Taxonomy.md index 3bb5fce..65150b7 100644 --- a/specs/Taxonomy.md +++ b/specs/Taxonomy.md @@ -144,6 +144,29 @@ can only ever report upward. --- +## 4.1 The MODEL stratum has a formal name now + +The **extensive-form game** is what our MODEL stratum corresponds to, and +the correspondence is established rather than assumed +([CB-WP-0041](../workplans/CB-WP-0041-the-extensive-form-foundation.md)): + +| EFG component | ours | checked by | +|---|---|---| +| histories | the journal | — | +| actions | `bot::legal_commands` | — | +| **information sets** | the seat's **observation history**, *not* `project()` | `perfect-recall` | +| chance | **a single root node**; the reshuffle is correlated with it | `a_game_is_determined_by_its_seed` | +| simultaneity | commit/reveal, the textbook encoding | `a_pending_selection_is_hidden_until_reveal` | +| payoffs | `Outcome` / `score()` | — | + +**`project()` is an observation, not an information state**, and the +distinction cost 22 measured violations to find. It is the same split +OpenSpiel draws between `ObservationString` and `InformationStateString`. + +**No port was built** — [ADR-0020](../decisions/ADR-0020-we-do-not-build-the-port.md) +refuses it, because making chance explicit would break the replay purity +the evidence discipline rests on. + ## 5. Worked examples from this repo Because a taxonomy with no instances was invented rather than observed diff --git a/workplans/CB-WP-0041-the-extensive-form-foundation.md b/workplans/CB-WP-0041-the-extensive-form-foundation.md index 6168014..0f1b4c1 100644 --- a/workplans/CB-WP-0041-the-extensive-form-foundation.md +++ b/workplans/CB-WP-0041-the-extensive-form-foundation.md @@ -2,7 +2,7 @@ id: CB-WP-0041 kind: product title: "The extensive-form foundation" -status: active +status: done state_hub_workstream_id: "dacfa1fe-81c3-4c97-b81e-ec66d95a611c" --- @@ -112,7 +112,7 @@ drawn*, which is weaker than "the property holds" and is printed as such. ```task id: CB-WP-0041-T02 -status: todo +status: done priority: high state_hub_task_id: "e223bf7d-6e4e-4377-8eac-a63e83a60d35" ``` @@ -130,11 +130,52 @@ approximate the distribution by sampling seeds. - **say what an explicit chance player would cost** — that is the input to T04's decision, and guessing it is how a port gets built on a hope. +**Done 2026-08-08.** Three chance points, all reading the same root seed: + +| where | what | +|---|---| +| `setup` | shuffles the Solution deck (`ChaChaRng::from_seed(seed)`), hands dealt off the top | +| `setup` | draws the **Lead** (`rng.draw(seats)`) from the same stream | +| `draw_solution` | reshuffles the discard when the deck empties, with `ChaChaRng::from_seed(seed ^ round)` | + +**The Problems deal is not chance at all** — `edition::deal` is a pure +function of the vendored CSV, so every game gets the same board. + +**So in extensive-form terms the tree has a single chance node at the +root.** `a_game_is_determined_by_its_seed` pins it. + +**And that test was wrong first.** It compared *state hashes*, and +`GroundState` carries `seed` as a field — so "different seeds differ" was +true by construction. Mutating the shuffle away left it green. It now +compares the **dealt configuration** (hands, Lead, deck order), and the +same mutation fails it. **A wrong-subject error inside T02's own control**, +caught by the mutation rather than by reading. + +**The reshuffle is correlated with the root seed, and a real table's is +not.** The permutation is a pure function of `(seed, round)` — deliberate, +so replay never re-derives it (GameKernel K5), and the code says so. +`the_reshuffle_permutation_is_a_function_of_seed_and_round` pins that it +moves with **both** inputs. + +**The consequence is a modelling one, not a defect**: at a table the +reshuffle is an independent random event. An EFG built from this engine +inherits the correlation and models a *restriction* of the game as played. + +**The cost of an explicit chance player, stated for T04:** +- the root node is **not enumerable** — 24 Solution cards give 24! + orderings — so any EFG over this must **sample** chance, which is what + external-sampling MCCFR does and what our seed sweeps already do by + hand; +- making the reshuffle a genuine chance node **breaks the K5 purity that + makes replay deterministic**. That is the real cost and it is a + tension, not a line of code: our recordings replay *because* chance is + a function of state. + ## Task: state the simultaneity encoding, and check it ```task id: CB-WP-0041-T03 -status: todo +status: done priority: medium state_hub_task_id: "c95b32a1-5006-4acd-abc9-537543119344" ``` @@ -148,11 +189,18 @@ stops doing that. That property is load-bearing for every claim in §1 of the research note and is currently only implied by `SelectionView::Hidden`. +**Done 2026-08-08.** `a_pending_selection_is_hidden_until_reveal` checks +**both halves** at four seats: before Reveal each seat sees its own +selection and no other, and after Reveal the information sets **merge** — +because an encoding that hides forever is not commit/reveal either. + +Mutation-proven: make selections public and it fails naming the seats. + ## Task: decide whether to build the port at all ```task id: CB-WP-0041-T04 -status: todo +status: done priority: medium state_hub_task_id: "3010fb0d-84cc-4fa8-8c23-726ee946cd79" ``` @@ -171,6 +219,28 @@ are too expensive and Track B borrows vocabulary rather than machinery. port is a tier-L trigger and this workplan is M — it may not smuggle one in. +**Done 2026-08-08.** +[ADR-0020](../decisions/ADR-0020-we-do-not-build-the-port.md) — **and the +answer is no.** + +**The blocker is T02, not T01**, which inverts what the workplan expected. +Perfect recall looked like the risk and turned out to be a *constraint +with a known answer*: key on observation histories. **Making chance +explicit is the expensive one** — the reshuffle would become a real chance +node, and that breaks the K5 purity every recording, replay bundle and +trial-note hash in this repo depends on. + +> A port would trade the property this project is built on for one it has +> never needed. + +**Track B's first move is a question, not a build**: take +"is exploitability meaningful for a co-operative game with a shared +threshold" to OpenSpiel on a toy model, where answering it costs nothing. + +**D4 states what being wrong looks like** — OpenSpiel settling, on a toy, +a question three rounds of policy sweeps could not — and makes watching +for it the next action rather than a hope. + ## Not in this workplan - **No EFG export, no OpenSpiel integration, no equilibrium computation.**