diff --git a/Makefile b/Makefile index 8eb0f83..914d2a2 100644 --- a/Makefile +++ b/Makefile @@ -237,6 +237,7 @@ difficulty: panels: @cargo run --release -q -p games-ground --example attack-value @cargo run --release -q -p games-ground --example regulation + @cargo run --release -q -p games-ground --example perfect-recall # CB-WP-0022 T05: the design-finding register, reported over # specs/GroundRules.md. Shows the QUEUE by default; the log of closed diff --git a/games/ground/examples/perfect-recall.rs b/games/ground/examples/perfect-recall.rs new file mode 100644 index 0000000..dd48798 --- /dev/null +++ b/games/ground/examples/perfect-recall.rs @@ -0,0 +1,243 @@ +//! **Does this engine satisfy perfect recall?** (CB-WP-0041 T01) +//! +//! Perfect recall — every player remembers their own past actions and +//! observations — is the assumption **CFR and exploitability both rest +//! on** ([`CB-RES-0009`]). Nobody had checked whether ours holds. +//! +//! Formally: for a seat `i`, any two histories in the same information +//! set of `i` must agree on the sequence of `i`'s own past actions. +//! +//! **What this can and cannot say.** It samples histories and looks for a +//! pair that lands in the same information set with different own-action +//! prefixes. So: +//! +//! > **It can prove perfect recall FAILS. It cannot prove it holds.** +//! +//! A clean run means "no violation found in this sample", which is a +//! weaker statement than "the property holds" and is reported as such. +//! Saying otherwise would be an ACCOUNT failure over an INSTRUMENT that +//! cannot support it ([`Taxonomy.md`] §2.2). +//! +//! [`CB-RES-0009`]: ../../../research/CB-RES-0009-extensive-form-is-the-lingua-franca.md +//! [`Taxonomy.md`]: ../../../specs/Taxonomy.md + +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::rc::Rc; + +use cb_game_runtime::{Project, ScenarioGame, Setup, Viewer}; +use cb_kernel::PlayerId; +use games_ground::bot::{play, Choice, Policy, RandomPolicy}; +use games_ground::{GroundCommand, GroundState}; + +/// One decision point: what the seat could see, and what it had done. +struct Observed { + /// **Reading A** — the information set taken to be the seat's + /// CURRENT projection, which is what `project(Viewer::Player(seat))` + /// returns and what the page renders. + observation: String, + /// **Reading B** — the information set taken to be the seat's whole + /// OBSERVATION HISTORY: every view it has seen and every action it + /// has taken, in order. + /// + /// OpenSpiel draws exactly this distinction — + /// `ObservationString` versus `InformationStateString` — and the + /// second exists because the first does not give perfect recall. + info_state: String, + /// The seat's own past actions, in order — the thing perfect recall + /// says must be determined by the information set. + own_actions: Vec, +} + +/// Wraps a policy and records what each seat saw before it moved. +struct Recorder { + inner: P, + log: Rc>>, + own: Rc>>>, + hist: Rc>>>, +} + +impl Policy for Recorder

{ + fn name(&self) -> &'static str { + "recorder" + } + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Choice { + // The information set is what this seat can see — nothing more. + let view = state.project(Viewer::Player(seat)); + let observation = serde_json::to_string(&view).expect("view serialises"); + let own_actions = self.own.borrow().get(&seat).cloned().unwrap_or_default(); + // The observation history: what this seat has seen and done, in + // order, up to and including now. + let mut hist = self.hist.borrow_mut(); + let h = hist.entry(seat).or_default(); + h.push(observation.clone()); + let info_state = h.join("\u{1e}"); + drop(hist); + self.log.borrow_mut().push(( + seat, + Observed { + observation, + info_state, + own_actions, + }, + )); + + let choice = self.inner.choose(state, seat, legal, may_pass); + let taken = match &choice { + Choice::Command(i) => format!("{:?}", legal[*i]), + Choice::Pass => "pass".to_string(), + }; + self.own + .borrow_mut() + .entry(seat) + .or_default() + .push(taken.clone()); + // The action is part of what the seat remembers. + self.hist.borrow_mut().entry(seat).or_default().push(taken); + choice + } +} + +fn main() { + // Random play, because the question is about the SHAPE of the + // information partition and a greedy policy visits a narrow slice of + // it. A policy that always makes the same choice cannot produce two + // histories in one information set with different prefixes. + let seeds = 0..400u64; + let mut points = 0usize; + let mut observation_violations: Vec = Vec::new(); + let mut info_state_violations: Vec = Vec::new(); + let mut witness: Option<(String, Vec, Vec)> = None; + + for players in [2u8, 3, 4, 6] { + // Each reading maps its key to the own-action prefix first + // seen with it. A second, different prefix is a violation. + let mut by_observation: BTreeMap> = BTreeMap::new(); + let mut by_info_state: BTreeMap> = BTreeMap::new(); + + for seed in seeds.clone() { + let Ok(st) = GroundState::setup( + &Setup { + players, + preset: format!("standard-{players}p"), + patch: Default::default(), + }, + seed, + ) else { + continue; + }; + let log = Rc::new(RefCell::new(Vec::new())); + let own = Rc::new(RefCell::new(BTreeMap::new())); + let hist = Rc::new(RefCell::new(BTreeMap::new())); + let mut ps: Vec> = (0..players) + .map(|i| { + Box::new(Recorder { + inner: RandomPolicy::new(seed ^ u64::from(i) ^ 0x9E37), + log: log.clone(), + own: own.clone(), + hist: hist.clone(), + }) as Box + }) + .collect(); + let Ok(_) = play(st, &mut ps) else { continue }; + + for (seat, obs) in log.borrow().iter() { + points += 1; + // The key includes WHO is looking: two seats with + // identical views are different information sets. + let a = format!("{seat:?}|{}", obs.observation); + match by_observation.get(&a) { + None => { + by_observation.insert(a, obs.own_actions.clone()); + } + Some(prior) if *prior == obs.own_actions => {} + Some(prior) => { + if observation_violations.is_empty() { + witness = Some(( + obs.observation.clone(), + prior.clone(), + obs.own_actions.clone(), + )); + } + observation_violations.push(format!("{players}p seed {seed} {seat:?}")); + } + } + + let b = format!("{seat:?}|{}", obs.info_state); + match by_info_state.get(&b) { + None => { + by_info_state.insert(b, obs.own_actions.clone()); + } + Some(prior) if *prior == obs.own_actions => {} + Some(prior) => { + info_state_violations.push(format!( + "{players}p seed {seed} {seat:?}: {prior:?} vs {:?}", + obs.own_actions + )); + } + } + } + } + } + + println!("perfect recall — CB-WP-0041 T01\n"); + println!(" decision points sampled {points}\n"); + println!(" Reading A — information set = the seat's CURRENT projection"); + println!( + " violations {}", + observation_violations.len() + ); + println!(" Reading B — information set = the seat's OBSERVATION HISTORY"); + println!( + " violations {}\n", + info_state_violations.len() + ); + + if let Some((view, a, b)) = &witness { + println!(" Reading A witness — one view, two own-action prefixes:"); + println!(" A: {a:?}"); + println!(" B: {b:?}"); + println!(" shared view: {}\n", &view[..view.len().min(220)]); + } + + // **Reading B is the load-bearing claim.** If observation histories + // do not give perfect recall, no EFG can be built from this engine + // and Track B closes. + assert!( + info_state_violations.is_empty(), + "perfect recall FAILS even under observation histories: {} violation(s). \ + No extensive-form game can be built from this engine, and every \ + equilibrium concept is unsound here.\n{}", + info_state_violations.len(), + info_state_violations.first().cloned().unwrap_or_default() + ); + + // **Reading A failing is the FINDING, not an error.** It is why + // OpenSpiel separates ObservationString from InformationStateString. + assert!( + !observation_violations.is_empty(), + "Reading A found no violation. Either the sample is too small or the \ + projection carries more history than it appears to — either way the \ + conclusion below is unsupported and must be re-derived." + ); + + println!(" RESULT"); + println!(" The current projection is an OBSERVATION, not an information"); + println!( + " state: {} sampled pairs share a view while disagreeing about", + observation_violations.len() + ); + println!(" what the seat itself had done. Perfect recall FAILS on that"); + println!(" reading, and an EFG keyed on `project()` would be unsound."); + println!(); + println!(" Keyed on the observation HISTORY, no violation was found."); + println!(" **That is not a proof** — this check can falsify perfect"); + println!(" recall and cannot establish it. A clean run means no"); + println!(" counterexample was drawn from this sample."); +} diff --git a/gates.toml b/gates.toml index 93116d7..d3501fd 100644 --- a/gates.toml +++ b/gates.toml @@ -178,7 +178,7 @@ id = "CB-REV-0002/7" name = "variant panels" target = "panels" cadence = "all" -checks = "both H1 measurement harnesses run; a short cell fails, and so does a cell whose games did not reach an outcome" +checks = "the measurement harnesses run; a short cell fails, a cell whose games did not reach an outcome fails, and perfect recall is re-derived on both readings" notes = """ Registered because the second adversarial review asked what the harness would report if the work silently stopped, and the answer was "green, and diff --git a/workplans/CB-WP-0041-the-extensive-form-foundation.md b/workplans/CB-WP-0041-the-extensive-form-foundation.md new file mode 100644 index 0000000..3fbc691 --- /dev/null +++ b/workplans/CB-WP-0041-the-extensive-form-foundation.md @@ -0,0 +1,175 @@ +--- +id: CB-WP-0041 +kind: product +title: "The extensive-form foundation" +status: active +--- + +# Purpose + +``` +structural tier M (states what the kernel claims about itself as a + game-theoretic object, and prepares — but does not + build — a capability port) +chaos d8 = 5 → no override +declared tier M +``` + +**Declaration 1 of chaos window 4.** Window 3 closed at 12 declarations +with one override that changed nothing, and **its verdict is still owed** +([`ChaosRollHistory.md`](../specs/ChaosRollHistory.md)). + +## Why + +[CB-RES-0009](../research/CB-RES-0009-extensive-form-is-the-lingua-franca.md) +found that the **extensive-form game is the interchange format** between +describing a game and analysing it — Ludii's universality result grounds +its language in EFGs, and OpenSpiel's CFR, best-response and +exploitability consume them. + +**And that we already have most of one**, under other names: + +| EFG component | ours | +|---|---| +| histories | the journal — `Applied { actor, command, events }` in order | +| actions | `bot::legal_commands(state, seat)` | +| **information sets** | **`state.project(Viewer::Player(seat))`** | +| payoffs | `Outcome` / `score()` | +| terminal | `outcome.is_some()` | + +**This workplan does not build the port.** It answers what is true of the +engine today, because a port built on an unchecked assumption is worse +than no port — and one of the three gaps invalidates every equilibrium +concept if it turns out badly. + +## Task: does the engine satisfy perfect recall? + +```task +id: CB-WP-0041-T01 +status: done +priority: high +``` + +**Perfect recall** — every player remembers their own past actions and +observations — is the assumption **CFR and exploitability both rest on**. +Nobody has checked whether ours holds. + +Formally, for a seat `i` and any two histories `h`, `h'` in the same +information set of `i`: the *sequence of `i`'s own actions and +information sets* along `h` and `h'` must be identical. + +**Controls:** +- **derived from the journal and the projection**, not asserted — the + check must be able to say NO; +- **a positive control**: a deliberately forgetful projection must fail + it, or the check proves nothing; +- **stated per seat count**, since the partition depends on how many + seats there are to hide from; +- **the answer may be that we do NOT have perfect recall**, and that is + reported as plainly as the other outcome. It would be a real finding + and would make Track B's adoption unsound as it stands. + +**Done 2026-08-08. The answer is "it depends what you call an information +set", and the distinction is the result.** 44,938 decision points, random +play, 2/3/4/6 seats. + +| reading | information set is… | violations | +|---|---|---| +| **A** | the seat's **current projection** — what `project(Viewer::Player(seat))` returns and the page renders | **22** | +| **B** | the seat's **observation history** — every view seen and action taken, in order | **0** | + +**Reading A fails, and the witness is concrete**: two histories reach a +byte-identical view — round 3, Select, same hand, same claimed Problem — +where the seat had played `SOLVE, GROUND—OU(protect)` in one and +`SUPPORT, SOLVE` in the other. **The view does not tell the seat what it +did.** + +**The mechanism is that our state is a snapshot, not a history.** +Selections clear each round and effects coincide, so a player cannot +reconstruct their own past from the present. In a real game the player's +memory supplies it; in the state, nothing does. + +**This is precisely OpenSpiel's `ObservationString` vs +`InformationStateString` split**, arrived at here by measurement rather +than by reading it off. `project()` is an *observation*. + +**So Track B is not closed — it is constrained**, and usefully: + +> **An extensive-form game built from this engine must key information +> sets on observation histories, never on `project()`.** + +**Both directions are asserted.** Reading B empty, *and* Reading A +non-empty — because if the sample stops finding Reading A violations the +conclusion is unsupported and must be re-derived, not quietly kept. + +**What this cannot say.** It samples; it can falsify perfect recall and +cannot establish it. Reading B's zero means *no counterexample was +drawn*, which is weaker than "the property holds" and is printed as such. + +## Task: say precisely what our chance is + +```task +id: CB-WP-0041-T02 +status: todo +priority: high +``` + +`setup` shuffles, deals and draws Lead from a seeded RNG, and the +mid-game reshuffle derives from seed and round. **So a clay-borg game is +one chance realisation, not a game with chance nodes**, and the panels +approximate the distribution by sampling seeds. + +**Controls:** +- **state it, do not fix it.** Monte Carlo over seeds is legitimate and + is what we do; the defect would be calling it an EFG; +- **name every point where chance enters**, from the code, not from + memory; +- **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. + +## Task: state the simultaneity encoding, and check it + +```task +id: CB-WP-0041-T03 +status: todo +priority: medium +``` + +Commit/reveal **is** the textbook EFG encoding of simultaneous moves: +sequence them, and hide the earlier move in an information set. + +**Control:** it is not enough to say so. **The projection must actually +hide another seat's selection before Reveal**, and a test must fail if it +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`. + +## Task: decide whether to build the port at all + +```task +id: CB-WP-0041-T04 +status: todo +priority: medium +``` + +`decisions/ADR-*.md`, written **after** T01–T03 and not before. + +**It must be able to conclude "no".** Options include exporting an EFG, +adopting OpenSpiel's API directly for analysis only, or deciding the gaps +are too expensive and Track B borrows vocabulary rather than machinery. + +**Controls:** +- **the decision cites T01's answer**, because if perfect recall fails, + most of the option space closes; +- **it states what it would cost to be wrong**; +- **a port is declared separately, at its own tier.** Creating a capability + port is a tier-L trigger and this workplan is M — it may not smuggle one + in. + +## Not in this workplan + +- **No EFG export, no OpenSpiel integration, no equilibrium computation.** +- **No answer to "is exploitability meaningful for a co-operative game + with a shared threshold"** — that is the other open question from + CB-RES-0009 §6, it is a question about game theory rather than about our + engine, and it wants its own pass. diff --git a/workplans/CB-WP-0042-h2-scoped-problem-stress.md b/workplans/CB-WP-0042-h2-scoped-problem-stress.md new file mode 100644 index 0000000..1563f0c --- /dev/null +++ b/workplans/CB-WP-0042-h2-scoped-problem-stress.md @@ -0,0 +1,161 @@ +--- +id: CB-WP-0042 +kind: product +title: "H2 — scoped problem stress" +status: ready +--- + +# Purpose + +``` +structural tier M (touches a canonical interface: edition loading + becomes variant-parameterised, and the kernel gains + per-Problem state) +chaos d8 = 4 → no override +declared tier M +``` + +**Declaration 2 of chaos window 4.** + +## Why, and it is a direct answer to ours + +We reported that H1-A behaves as a **solve-rate tax**: a flat +1 to every +seat while any Problem is unclaimed, so the pressure scales with the +number of Problems while the intended effect does not. + +`ground-game` responded with **H2 — scoped problem stress**. Unclaimed +Problems now tick **only the seats in their scope**: + +| scope | who takes the +1 | +|---|---| +| `global` | all seats | +| `personal` | the Problem's **owner** | +| `bond` | the owner's **Bond network** — owner plus every seat reachable over Bond edges only, never Rivalry; degree 0 falls back to personal | + +Assigned by hidden priority: **0 (Surface) global, 1 personal, 2 personal, +3 bond, 4 personal** — which is a seat-band dial without a difficulty +card, since 2p never has the bond card in play. + +**It does not stack with H1.** `replaces_experiments: [h1-problem-stress]`, +base is `ground-darvo-r0`, and there is **no ATTACK self-soothe**. + +## Why this is bigger than H1 was + +H1 was two arithmetic deltas on existing state. H2 needs three things we +do not have: + +1. **Variant-scoped edition data.** H2 overrides `Problems.csv` with a + new `stress_scope` column. `edition.rs` `include_str!`s r0's copy at + compile time — **the data is currently a constant, not a parameter.** +2. **Per-Problem ownership**, assigned at setup. That is **new state**, + so it reaches the state hash and every recording. +3. **Bond-network reachability** — a graph traversal over Bond edges + only, with a degree-0 fallback. + +## Task: variant-scoped edition data + +```task +id: CB-WP-0042-T01 +status: todo +priority: high +``` + +**Controls:** +- **the baseline's data is bit-for-bit what it was**, and its state hashes + do not move — the control that protected CB-WP-0038 and the one that + invalidates every prior measurement if it fails; +- the H2 package is **vendored with digests** like every other borrowed + file, and `edition-check` covers it — sibling discovery walks the disk + now, so an undigested file fails (CB-REV-0003 #8); +- **`stress_scope`'s assignment is read from the file, not hardcoded.** + The delta states the priority→scope mapping; **it is the edition's to + state and ours to read**, and F25 exists because we hardcoded numbers + the edition already carried. + +## Task: ownership at setup + +```task +id: CB-WP-0042-T02 +status: todo +priority: high +``` + +`H2-OWN`: ascending hidden priority among in-play non-global Problems, +starting at Lead, stepping clockwise. Global Problems have no owner. + +**Controls:** +- **deterministic**, and asserted so — the delta says "deterministic for + sims" and a seeded-but-unstated order would be untestable; +- **the baseline carries no owners and its hash does not move.** New + state that is `None` under baseline must serialise as it did before, or + every recorded scenario breaks; +- **exactly one owner per eligible Problem**, checked at every seat count, + because the assignment walks two sequences at once and off-by-one is + the obvious failure. + +## Task: scoped pressure + +```task +id: CB-WP-0042-T03 +status: todo +priority: high +``` + +`H2-A`, at Round End, before the clamp and the DARVO arm check — the same +ordering H1-A needed, and the same trap. + +**Controls:** +- **each scope fails on its own**, by mutation: global hitting only the + owner, personal hitting everyone, bond stopping at the owner; +- **`stacking: true` is tested** — two open bond Problems must tick the + network **twice**, and a careless implementation applies it once; +- **Rivalry edges are not traversed.** The delta says Bond only, and a + traversal that follows any relation is the single most likely defect; +- **degree 0 falls back to personal**, which is the branch a test forgets; +- ordering asserted as in CB-WP-0038: pressure before the arm check. + +## Task: SOLVE is not restricted by scope + +```task +id: CB-WP-0042-T04 +status: todo +priority: medium +``` + +`H2-SOLVE`: any seat with a matching suit may claim; the owner need not be +the solver, and altruistic clearing is **intended**. + +**Control:** our engine has no owner concept today, so this is already +true — which makes it a **regression test, not a feature**. T02 adds +ownership, and the risk is that ownership silently becomes a permission. +The test must fail if it does. + +## Task: measure, and report what fails + +```task +id: CB-WP-0042-T05 +status: todo +priority: high +``` + +Same instrument as H1, same panel, both variants in one run. + +**Controls:** +- **greedy and `reactive` both**, because H1's whole verdict turned on + which policy was asked (CB-EV-0031); +- **report against ground-game's own criteria** for H2, from their design + note — **read it, do not reuse H1's §3.2 from memory**; +- **the bond-scope hypothesis is the interesting one and must be measured + directly**: their claim is that a shared tick makes a Bond network + *jointly motivated* to solve that card. Whether a policy that does not + model other seats can express that at all is an open question, and the + honest answer may be "our panel cannot test this hypothesis"; +- **`make panels` runs it**, or the figures come from an ungated binary + again (CB-REV-0002 #7). + +## Not in this workplan + +- **No stacking with H1.** The package forbids it explicitly. +- **No felt-play.** H2's central claim is about *motivation* — a bonded + pair caring about each other's card — and 200-game aggregates measure + dynamics, not that ([`Taxonomy.md`](../specs/Taxonomy.md) §4).