diff --git a/decisions/ADR-0023-a-policy-is-bound-by-what-its-seat-can-see.md b/decisions/ADR-0023-a-policy-is-bound-by-what-its-seat-can-see.md new file mode 100644 index 0000000..c736c11 --- /dev/null +++ b/decisions/ADR-0023-a-policy-is-bound-by-what-its-seat-can-see.md @@ -0,0 +1,92 @@ +# ADR-0023: a policy is bound by what its seat can see + +status: accepted +date: 2026-08-08 +decided by: agent, under the standing loop authorization +tier: S (states a rule that already held in practice, and makes it +checkable; no interface moved) +references: [ADR-0013](ADR-0013-the-retrospective-search.md) (why the +retrospective search may see everything), [CB-RES-0009](../research/CB-RES-0009-extensive-form-is-the-lingua-franca.md) +(information sets), [specs/GameDesign.md](../specs/GameDesign.md) §1.4 +(sensitivity), F27 + +## Context + +`Policy::choose` takes `&GroundState`. `GroundState` carries every +face-down Problem's suit and value and every seat's hand. +`GroundState::project` exists precisely to withhold those: +`ProblemView::FaceDown` carries nothing but the fact that a Problem is +there. + +**So the trait hands a bot exactly the information the projection was +written to hide.** No shipped policy reads it — `GreedyPolicy::rank` +touches only `stress`, `claimed_by` and the legal set — but nothing +prevents it, and nothing would notice. + +This stops being latent immediately. CB-WP-0049 writes policies whose job +is to **value Problems** in order to test F27, and a Problem's value is +the field the projection hides. A seat that ranks a claim by the value of +a face-down card is playing a game nobody can play, and every number +measured with it would be about that game. + +## D1 — a policy may not depend on what its seat cannot see + +A measurement made with a peeking policy is not a measurement of GROUND. + +**This is the mirror of ADR-0013 D1**, and the pairing is the point. The +retrospective search is *allowed* to see everything, because after the +game there is one world and a line found in it was executable in the only +world there was. A policy plays **during** the game, from inside an +information set, so the same permission would be a different claim +entirely — the classic strategy-fusion error. + +Same repository, two searches over the same kernel, opposite permissions, +and the discriminator is **when the question is asked**. + +## D2 — checked behaviourally, not by the type system + +The obvious enforcement is to change `choose` to take `&GroundView`. We do +not do that first, because a **behavioural control catches more**: + +> Vary only what the seat cannot see, and assert the policy's choice does +> not move. + +- It binds **every** policy, including ones written later and ones written + outside this crate, without their cooperation. +- It survives a policy that gets the view and reconstructs hidden state by + other means. +- It is the sensitivity discipline the project already runs on + (GameDesign §1.4): name the variable, vary it, watch the number. + +A type change makes one route unavailable; the control makes the property +false-if-violated. **The type change is still worth doing** and is not +refused here — it is simply not the thing that establishes the property. + +## D3 — the control must be shown to fail + +A peek control that no policy can fail is decoration (ADR-0006 D3). The +test carries a deliberately peeking policy — one that ranks by the value +of face-down Problems — and asserts **that** policy is caught. A control +proven only against compliant policies has not been proven at all. + +## Consequences + +- Every policy is covered by the peek control, and a new one that peeks + goes red on arrival rather than on review. +- Hidden-information variation becomes a fixture: the same helper that + proves policies blind can measure how much a line depended on what was + hidden. +- Policies that must reason about unseen Problems have to do it as + *inference from what is visible* (a face-down Problem exists, and the + scenario states the deal), which is the honest form of the same + reasoning. +- Bots remain unable to test claims about mechanisms they do not attend to + — an orthogonal limit, recorded as F27, and **not** addressed by this. + +## What was rejected + +| rejected | why | +|---|---| +| changing `choose` to `&GroundView` first | closes one route; the property still would not be checked, and outside policies could still peek | +| trusting review | four reviews on this repo found five, three, four and two FATAL items; peeking is invisible in a diff that looks like ranking | +| allowing peeking for "stronger" bots | a stronger bot at a game nobody can play measures that game; ADR-0013 D1's permission is retrospective and does not transfer | diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index 879fc6a..b76c9c5 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -1099,3 +1099,230 @@ mod tests { // _every_seat_count` in lib.rs is the same arithmetic, inverted rather // than removed, and carries why the numbers changed. } + +/// **A policy is bound by what its seat can see** ([ADR-0023]). +/// +/// `Policy::choose` takes the whole `GroundState`, which carries every +/// face-down Problem's suit and value and every seat's hand — exactly +/// what `project` exists to withhold. Nothing in the type system stops a +/// policy reading it, so this establishes the property behaviourally: +/// **vary only what the seat cannot see, and the choice must not move.** +/// +/// This binds every policy, including ones written later and ones written +/// outside this crate, without their cooperation. +/// +/// [ADR-0023]: ../../../decisions/ADR-0023-a-policy-is-bound-by-what-its-seat-can-see.md +#[cfg(test)] +pub mod blindness { + use super::*; + + /// Every rearrangement of what `seat` cannot see, as states that are + /// **identical in that seat's projection**. + /// + /// Concretely: permute the suits and values of every face-down + /// Problem, and permute the other seats' hands. A seat that plays the + /// game can tell none of these apart. + pub fn hidden_rearrangements(state: &GroundState, seat: PlayerId) -> Vec { + let mut out = Vec::new(); + // **The multiset must move, not only the arrangement.** The first + // version rotated every hidden value 2<->3 together, which leaves + // `max` and (with equal counts) `sum` invariant — so a policy + // ranking by the largest hidden value was NOT caught. A control + // whose variation is invariant under the statistic a violator + // reads is not a control. These force all-2, all-3 and an + // alternating split, so any symmetric function of the hidden + // values moves across the family. + for shift in 0..=3u8 { + let mut alt = state.clone(); + for (i, p) in alt.problems.values_mut().enumerate() { + if p.face_up { + continue; + } + // A different card under the same back. + p.value = match shift { + 0 => 2, + 1 => 3, + 2 => 2 + (i as u8 % 2), + _ => 3 - (i as u8 % 2), + }; + p.suit = rotate_suit(p.suit, shift + 1); + } + for (other, ps) in alt.players.iter_mut() { + if *other == seat { + continue; + } + for c in ps.hand.iter_mut() { + c.suit = rotate_suit(c.suit, shift); + } + } + out.push(alt); + } + out + } + + fn rotate_suit(s: crate::Suit, by: u8) -> crate::Suit { + use crate::Suit::*; + let order = [Clarify, Repair, Boundary, Change]; + let i = order.iter().position(|x| *x == s).unwrap_or(0); + order[(i + by as usize) % order.len()] + } + + /// Does `policy` choose the same thing across every rearrangement? + /// + /// Returns the disagreement if there is one, so the caller can report + /// *what* moved rather than only that something did. + /// **Takes a constructor, not a policy.** `choose` may advance + /// internal state — `RandomPolicy` draws from its own stream — so + /// reusing one instance compares a first call against a fourth and + /// reports every stateful policy as a peeker. The first version did + /// exactly that and accused `random`. Each variant is judged from an + /// identical starting policy, which is the only way the difference + /// between two runs is the state and nothing else. + pub fn is_blind( + mut make: impl FnMut() -> P, + state: &GroundState, + seat: PlayerId, + ) -> Result<(), String> { + let legal = legal_commands(state, seat); + if legal.is_empty() { + return Ok(()); + } + let base = make().choose(state, seat, &legal, false); + for alt in hidden_rearrangements(state, seat) { + // **The same legal set, deliberately.** Rearranging hidden + // cards can change which moves are legal, and a policy that + // picks a different INDEX into a different list has not + // necessarily seen anything. Holding the list fixed varies + // only the state, which is the variable under test. + let mut p = make(); + let got = p.choose(&alt, seat, &legal, false); + if got != base { + return Err(format!( + "{} chose {:?} and then {:?} over the same legal moves, \ + with only hidden cards rearranged", + p.name(), + base, + got + )); + } + } + Ok(()) + } +} + +#[cfg(test)] +mod blindness_tests { + use super::blindness::*; + use super::*; + use cb_game_runtime::{ScenarioGame, Setup}; + + fn deal(players: u8, seed: u64) -> GroundState { + GroundState::setup( + &Setup { + players, + preset: format!("standard-{players}p"), + patch: Default::default(), + }, + seed, + ) + .expect("preset") + } + + /// **A policy that peeks IS CAUGHT** (ADR-0023 D3). + /// + /// The control is proven against a deliberate violator before it is + /// trusted about compliant ones. A peek control that nothing can fail + /// is decoration (ADR-0006 D3) — and this is the shape of policy + /// CB-WP-0049 is about to write, ranking a claim by a Problem's + /// value, which is the field the projection hides. + #[test] + fn the_peek_control_catches_a_peeking_policy() { + struct Peeker; + impl Policy for Peeker { + fn name(&self) -> &'static str { + "peeker" + } + fn choose( + &mut self, + state: &GroundState, + _seat: PlayerId, + legal: &[GroundCommand], + _may_pass: bool, + ) -> Choice { + // Rank by the value of the highest FACE-DOWN Problem — + // information the seat does not have. + let hidden: u8 = state + .problems + .values() + .filter(|p| !p.face_up) + .map(|p| p.value) + .max() + .unwrap_or(0); + Choice::Command((hidden as usize) % legal.len()) + } + } + let st = deal(3, 7); + let seat = PlayerId(0); + assert!( + st.problems.values().any(|p| !p.face_up), + "the fixture must actually hide something" + ); + let err = is_blind(|| Peeker, &st, seat) + .expect_err("a policy reading face-down values must be caught"); + assert!(err.contains("peeker"), "{err}"); + } + + /// And the shipped policies are blind. + #[test] + fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() { + for seed in [1u64, 7, 42] { + for players in [2u8, 4, 6] { + let st = deal(players, seed); + for seat in st.players.keys().copied() { + is_blind(|| GreedyPolicy, &st, seat) + .unwrap_or_else(|e| panic!("{players}p seed {seed}: {e}")); + // Random is seeded from its own stream, so it is + // blind for a different reason — included because + // "every shipped policy" must mean every one. + is_blind(|| RandomPolicy::new(seed), &st, seat) + .unwrap_or_else(|e| panic!("{players}p seed {seed}: {e}")); + } + } + } + } + + /// The rearrangements must actually rearrange. + /// + /// A control that varies nothing passes for every policy, including + /// the peeker — so the fixture's own sensitivity is asserted rather + /// than assumed. + #[test] + fn the_rearrangements_change_the_hidden_cards() { + let st = deal(4, 3); + let alts = hidden_rearrangements(&st, PlayerId(0)); + assert!(!alts.is_empty()); + let hidden = |s: &GroundState| -> Vec<(u8, String)> { + s.problems + .values() + .filter(|p| !p.face_up) + .map(|p| (p.value, format!("{:?}", p.suit))) + .collect() + }; + assert!( + alts.iter().any(|a| hidden(a) != hidden(&st)), + "no rearrangement changed a hidden card" + ); + // And they leave the VISIBLE game alone, or the test would be + // varying two things at once. + let seen = |s: &GroundState| -> Vec<(u8, String)> { + s.problems + .values() + .filter(|p| p.face_up) + .map(|p| (p.value, format!("{:?}", p.suit))) + .collect() + }; + for a in &alts { + assert_eq!(seen(a), seen(&st), "a rearrangement moved a face-up card"); + } + } +} diff --git a/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md b/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md new file mode 100644 index 0000000..d3ec99e --- /dev/null +++ b/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md @@ -0,0 +1,196 @@ +--- +id: CB-WP-0049 +kind: product +title: "A seat that plays its own objective" +status: in_progress +--- + +# Purpose + +``` +structural tier M (adds a measurement instrument whose output changes + what published panels mean) +declared tier M +``` + +Closes the gap [CB-WP-0048](CB-WP-0048-a-configuration-not-a-variant.md) +opened: configurations are now selectable and **nothing can judge them**. + +## The finding this exists to answer + +**F27.** `scenario-panel` measured 4 scenarios × 3 modes × 3 seat bands +and found group success *exactly* equal across all three scoring modes in +all 36 cells. The arithmetic is right and the reason is simple: +`GreedyPolicy` never reads `state.mode`. The same games are played and +only the winner set is carved differently. + +So **the two competitive modes are scoring lenses over cooperative +play**, and the same will be true of every aspect module: a bot that does +not attend to a mechanism cannot test a claim about that mechanism's +incentives. CB-RES-0010 §5.4's last bullet is F27 restated from the +design side. + +This is now the binding constraint on the whole programme. `scoped_plus_attack_soothe` +is selectable today (CB-WP-0048 T01) and no instrument in this repo can +say whether it is any good. + +## The prerequisite nobody had noticed + +`Policy::choose` takes `&GroundState`, which carries **every face-down +Problem's suit and value and every seat's hand**. `project` exists to +withhold exactly those. + +No shipped policy reads them. But the first thing a competitive policy +must do is **value a Problem**, and value is the field the projection +hides — so the trap becomes live on the first line of the work this +workplan is about. [ADR-0023](../decisions/ADR-0023-a-policy-is-bound-by-what-its-seat-can-see.md). + +## Task: a policy is bound by what its seat can see + +```task +id: CB-WP-0049-T01 +status: done +priority: high +``` + +Per ADR-0023: established **behaviourally**, not by changing the trait — +vary only what the seat cannot see and the choice must not move. That +binds every policy, including ones written later and outside this crate, +without their cooperation. + +**Done 2026-08-08.** `bot::blindness` provides `hidden_rearrangements` +and `is_blind`; `GreedyPolicy` and `RandomPolicy` are proven blind across +three seeds × three seat bands × every seat. + +### Two defects in the control itself, both caught by running it + +**1. The first rearrangement was invariant under what a violator reads.** +It rotated every hidden value 2↔3 *together*, which leaves `max` +unchanged and (at equal counts) `sum` too — so the deliberate peeker, +which ranks by the largest hidden value, **was not caught**. A control +whose variation is invariant under the statistic a violator reads is not +a control. The family now forces all-2, all-3 and two alternating splits, +so any symmetric function of the hidden values moves across it. + +**2. It accused `random` of peeking.** `is_blind` reused one policy +instance, so it compared a first call against a fourth — and +`RandomPolicy` advances its own stream. It takes a **constructor** now, +so every variant is judged from an identical starting policy and the only +difference between two runs is the state. + +Both are the same mistake in different clothes: **a difference in the +output was read as evidence about the hidden state**, when the first was +insensitive to it and the second was caused by something else entirely. +That is the wrong-subject family, found twice inside one control written +to detect wrong subjects. + +| mutation | what went red | +|---|---| +| rearrangements leave the multiset invariant | *"a policy reading face-down values must be caught"* | +| rearrangements vary nothing at all | *"no rearrangement changed a hidden card"* + the peeker escapes | +| one policy instance reused across variants | *"random chose Command(5) and then Command(2)"* | + +**The control is proven against a deliberate violator** before being +trusted about compliant policies (ADR-0023 D3) — a peek control nothing +can fail is decoration. + +## Task: an objective, taken from the game's own scoring + +```task +id: CB-WP-0049-T02 +status: todo +priority: high +``` + +What a seat is trying to maximise, per `ScoringMode`: + +| mode | objective | +|---|---| +| SHARED GROUND | the group total | +| COMMON PROBLEM | own claimed value − own Blame | +| BONDED COALITIONS | own coalition's summed personal score | + +**Derived from `GroundState::score`, never reimplemented.** All three are +already computed there for the outcome; a second copy in the bot would be +a second definition of winning, and the two would disagree the first time +ground-game rules on F28. + +**Controls:** +- **the objective agrees with `Outcome` on a finished game**, for every + mode — asserted against the real scoring rather than a fixture; +- **a seat's objective is not the group's** under the competitive modes, + shown on a concrete board rather than argued; +- **the objective is blind** (T01's control applies to anything that + reaches a policy). + +## Task: a seat that plays it, and the F27 re-measurement + +```task +id: CB-WP-0049-T03 +status: todo +priority: high +``` + +A policy that maximises its own objective, delegating to +`GreedyPolicy::rank` wherever the objective is silent — **delegating, not +copying**, which CB-WP-0039 learned the hard way when a re-typed "greedy +with one preference changed" differed in five places and burned the +Freedom token in round one of every game. + +Then re-run `scenario-panel`. + +**The result is not predicted here.** Either the modes now diverge — F27 +resolves, and the competitive modes are real — or they still do not, and +that is a much more interesting finding about GROUND: that the game's +scoring modes do not reach its decisions. Both outcomes are publishable +and the workplan must not be written as though one is expected. + +**Controls:** +- **the panel's group-success column moves, or it is reported as not + moving** — a null result stated as a null result; +- **the competitive policy is blind** (T01); +- **greedy's numbers are unchanged**, so the new policy is an addition + and not a silent edit of the published baseline. + +## Task: module-aware evaluation + +```task +id: CB-WP-0049-T04 +status: todo +priority: normal +``` + +A seat under `problem_stress.scoped` should prefer clearing a Problem +whose scope includes it; under `attack_relief.self_soothe_ge4`, ATTACK +becomes a Stress tool at the gate. Both are **visible** to the seat +(`ProblemMarker` is in the projection), so neither needs hidden state. + +**The composition risk is the same one schema 2 exists to retire.** One +policy per module is `H2AwarePolicy` — the blob at a new layer. Evaluation +should compose per aspect the way a configuration does, and if that is not +achievable cheaply, the honest move is one policy that reads the resolved +`Rules` rather than several that each assume one. + +**Controls:** +- **a module-aware seat differs from greedy ONLY where the module is + live** — under the baseline the two must be identical, which is the + control that separates "attends to the module" from "plays differently"; +- **blind** (T01). + +## Not done here + +- **The trait still takes `&GroundState`.** ADR-0023 D2 chose the + behavioural control first, on the grounds that it binds policies the + type system cannot reach. Narrowing `choose` to `&GroundView` remains + worth doing and is not refused — it is simply not what establishes the + property. +- **Opponent modelling is out of scope.** These policies play their own + objective; none of them models another seat playing theirs. A + competitive mode where nobody anticipates a rival is still a weak test + of that mode, and saying so is part of T03's result. +- **No lookahead.** `search.rs` is retrospective and **may not be reused + here**: ADR-0013 D1 permits it to see everything precisely because + after the game there is one world. A policy plays from inside an + information set, so the same permission would be strategy fusion. Same + kernel, two searches, opposite permissions, and the discriminator is + when the question is asked.