ADR-0023 + CB-WP-0049 T01: a policy is bound by what its seat can see
Some checks failed
ci / check (push) Has been cancelled

Policy::choose takes the whole GroundState -- every face-down Problem's
suit and value, every seat's hand -- which is exactly what project()
exists to withhold. No shipped policy reads it, but the first thing a
competitive policy must do is VALUE a Problem, and value is the hidden
field. The trap goes live on the first line of the F27 work.

Established behaviourally rather than by narrowing 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. The mirror of ADR-0013 D1 -- same kernel, two
searches, opposite permissions, discriminated by WHEN the question is
asked; a policy plays from inside an information set, so retrospective
permission would be strategy fusion.

Running the control found two defects IN THE CONTROL:

1. The rearrangements rotated hidden values 2<->3 together, leaving max
   invariant -- 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.
2. It accused `random` of peeking, because it reused one policy instance
   and compared a first call against a fourth. It takes a constructor
   now, so every variant is judged from identical policy state.

Both are a difference in output read as evidence about hidden state --
the wrong-subject family, found twice inside a control written to detect
wrong subjects.

Three mutations, three red. The control is proven against a deliberate
violator before being trusted about compliant policies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 22:45:55 +02:00
parent 036658ac76
commit c6607e8233
3 changed files with 515 additions and 0 deletions

View file

@ -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<GroundState> {
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<P: Policy>(
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");
}
}
}