Compare commits

..

No commits in common. "4b40a537a601559ccc966304c0c4c0a3ff691287" and "55a475b1a97db64f317a094c75efdc71c64482b0" have entirely different histories.

5 changed files with 1 additions and 581 deletions

View file

@ -237,7 +237,6 @@ 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

View file

@ -1,243 +0,0 @@
//! **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<String>,
}
/// Wraps a policy and records what each seat saw before it moved.
struct Recorder<P: Policy> {
inner: P,
log: Rc<RefCell<Vec<(PlayerId, Observed)>>>,
own: Rc<RefCell<BTreeMap<PlayerId, Vec<String>>>>,
hist: Rc<RefCell<BTreeMap<PlayerId, Vec<String>>>>,
}
impl<P: Policy> Policy for Recorder<P> {
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<String> = Vec::new();
let mut info_state_violations: Vec<String> = Vec::new();
let mut witness: Option<(String, Vec<String>, Vec<String>)> = 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<String, Vec<String>> = BTreeMap::new();
let mut by_info_state: BTreeMap<String, Vec<String>> = 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<Box<dyn Policy>> = (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<dyn Policy>
})
.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.");
}

View file

@ -178,7 +178,7 @@ id = "CB-REV-0002/7"
name = "variant panels"
target = "panels"
cadence = "all"
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"
checks = "both H1 measurement harnesses run; a short cell fails, and so does a cell whose games did not reach an outcome"
notes = """
Registered because the second adversarial review asked what the harness
would report if the work silently stopped, and the answer was "green, and

View file

@ -1,175 +0,0 @@
---
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** T01T03 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.

View file

@ -1,161 +0,0 @@
---
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).