//! Was this deal winnable — and here is one line (CB-WP-0025 T05). //! //! Implements [`specs/RetrospectiveAnalysis.md`]. The question is //! **retrospective**: given the deal as it actually was, does a line of //! play exist that reaches the threshold? //! //! ## Why this is allowed to see everything //! //! Strategy fusion — the classic objection to searching an //! imperfect-information game — is a defect of *aggregating over //! determinizations to choose a move*. **After the game there is one //! world.** The deal is known, so a line found in it is executable in the //! only world there is (ADR-0013 D1). //! //! What survives the objection is that the line may not have been //! *findable* at the time, and that is answered per move by //! [`Move::visible`] rather than by refusing to search. //! //! ## The bound //! //! Exhaustive over the last `K` rounds, with a node budget as a secondary //! cut. When nothing is found the caller must say **"no winning line //! found in the last K rounds"** — never "unwinnable", which a bounded //! search cannot establish (spec §2.3). use crate::bot::legal_commands; use crate::{GroundCommand, GroundState, ProblemState}; use cb_kernel::{Actor, Aggregate, PlayerId}; /// One move of a witness, with whether the seat could have chosen it /// knowing only what it could see. #[derive(Debug, Clone)] pub struct Move { pub actor: Actor, pub command: GroundCommand, /// `false` when the move depends on something the acting seat could /// not see — spec §2.2. Concretely: it targets a Problem that was /// **face down** to that seat, so choosing it required knowing what /// was under it. /// /// System moves are always `true`: the table does them, not a player. pub visible: bool, } /// What the search found. #[derive(Debug, Clone)] pub enum Verdict { /// A line exists. `nodes` is what it cost to find. Winnable { line: Vec, nodes: usize }, /// Nothing found **within the bound**. This is not "unwinnable". NoneFound { nodes: usize, /// `true` if the space was searched to exhaustion; `false` if the /// node budget cut it short. The distinction is the difference /// between "no line exists in these K rounds" and "we stopped /// looking", and callers must not collapse it. exhausted: bool, }, } /// Would this command have been choosable knowing only what `seat` saw? /// /// A `SelectAction` naming a Problem that is face-down to that seat is /// `hidden`: picking it required knowing what was underneath. Everything /// else is `visible` — a seat's own hand is in its own projection, and /// since GR-P05 (CB-WP-0023) SOLVE is only offered on face-up Problems /// anyway, so INVESTIGATE is where hidden information actually bites. fn is_visible(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> bool { let _ = seat; match cmd { GroundCommand::SelectAction { problem: Some(p), .. } => matches!( state.problems.get(p), Some(ProblemState { face_up: true, .. }) ), _ => true, } } struct Search { nodes: usize, budget: usize, /// Set when the budget stopped us, so `NoneFound` can distinguish /// "searched it all" from "gave up". cut: bool, } impl Search { /// Apply a command to a copy. `None` if the aggregate rejects it — /// which is not an error here: the search offers candidates and /// `validate` is the authority, exactly as `legal_commands` does. fn step( &mut self, state: &GroundState, actor: Actor, cmd: &GroundCommand, ) -> Option { self.nodes += 1; let mut next = state.clone(); let events = next.validate(actor, cmd).ok()?; for e in &events { next.fold(e); } Some(next) } /// One player branch: apply, recurse, and prepend the move if the /// subtree won. fn branch( &mut self, state: &GroundState, seat: PlayerId, cmd: &GroundCommand, rounds_left: u8, ) -> Option> { let next = self.step(state, Actor::Player(seat), cmd)?; let mut rest = self.go(&next, rounds_left)?; let mut line = vec![Move { actor: Actor::Player(seat), command: cmd.clone(), visible: is_visible(state, seat, cmd), }]; line.append(&mut rest); Some(line) } /// Depth-first over whatever must happen next, mirroring the driver's /// round structure (`bot::play_journaled`). /// /// Returns the moves appended after `state`, or `None`. fn go(&mut self, state: &GroundState, rounds_left: u8) -> Option> { if let Some(outcome) = &state.outcome { return outcome.group_success.then(Vec::new); } if rounds_left == 0 { return None; } if self.nodes >= self.budget { self.cut = true; return None; } let seats: Vec = state.players.keys().copied().collect(); // **Obligatory first.** GR-R02: a seat with no selection this // round must make one, and nothing else can happen until it does. // If every branch fails, the line is dead — falling through would // try system commands the aggregate is going to reject anyway. // // The first version branched on "the first seat that has any legal // command" and `break`ed when its branches were spent, which threw // away every later seat's options: seat 1 never acted if seat 0 // was already selected but still had a legal move. if let Some(seat) = seats.iter().find(|s| !state.selections.contains_key(s)) { for cmd in &legal_commands(state, *seat) { if let Some(line) = self.branch(state, *seat, cmd, rounds_left) { return Some(line); } if self.cut { return None; } } return None; } // **Optional next.** After Reveal a seat may choose a GROUND mode, // answer a Support, or name a DARVO target. Some of those are // obligatory, but the aggregate enforces that by rejecting // `Resolve` until they are done — so this needs no phase logic of // its own, and the do-nothing case is simply the fall-through // below. for seat in &seats { for cmd in &legal_commands(state, *seat) { if let Some(line) = self.branch(state, *seat, cmd, rounds_left) { return Some(line); } if self.cut { return None; } } } // Nobody need act: the table advances. Try each system command; the // aggregate rejects the ones that are out of order, so this needs // no phase logic of its own. for sys in [ GroundCommand::Reveal, GroundCommand::Resolve, GroundCommand::EndRound, ] { let Some(next) = self.step(state, Actor::System, &sys) else { continue; }; let spent = u8::from(matches!(sys, GroundCommand::EndRound)); if let Some(mut rest) = self.go(&next, rounds_left - spent) { let mut line = vec![Move { actor: Actor::System, command: sys, visible: true, }]; line.append(&mut rest); return Some(line); } if self.cut { return None; } } None } } /// Search the last `rounds` rounds from `state` for a line reaching /// `group_success`. /// /// **`state` must be a real position from the game being asked about.** /// The caller supplies it; this does not re-deal, because a re-dealt game /// is a different question. pub fn winnable_within(state: &GroundState, rounds: u8, budget: usize) -> Verdict { let mut s = Search { nodes: 0, budget, cut: false, }; match s.go(state, rounds) { Some(line) => Verdict::Winnable { line, nodes: s.nodes, }, None => Verdict::NoneFound { nodes: s.nodes, exhausted: !s.cut, }, } } /// How many moves of a witness required unseen information. pub fn hidden_moves(line: &[Move]) -> usize { line.iter().filter(|m| !m.visible).count() } #[cfg(all(test, feature = "scenarios"))] mod tests { use super::*; use cb_game_runtime::{ScenarioGame, Setup}; fn setup(players: u8, seed: u64) -> GroundState { GroundState::setup( &Setup { players, preset: format!("standard-{players}p"), patch: Default::default(), }, seed, ) .expect("preset") } /// **The hard gate (spec §2.1): a witness must replay.** /// /// Re-execute the emitted line from the same start state through /// `validate`/`fold` — the same path the scenario runner takes — and /// require it to end in `group_success`. A witness that does not /// replay asserts the opposite of the truth to a player who just /// lost. /// Rewind a real game to the start of its last `k` rounds. /// /// Stops **after** applying the EndRound numbered `total - k`. An /// earlier version broke *before* it, which left that round's own play /// applied and searched one round less than it claimed. fn last_rounds(players: u8, seed: u64, k: usize) -> GroundState { let mut ps: Vec> = (0..players) .map(|_| Box::new(crate::bot::GreedyPolicy) as Box) .collect(); let game = crate::bot::play(setup(players, seed), &mut ps).expect("a complete game"); let total = game .steps .iter() .filter(|(_, c)| matches!(c, GroundCommand::EndRound)) .count(); let mut st = setup(players, seed); let mut ends = 0usize; for (a, c) in &game.steps { if let Ok(ev) = st.validate(*a, c) { for e in &ev { st.fold(e); } } if matches!(c, GroundCommand::EndRound) { ends += 1; if ends >= total.saturating_sub(k) { break; } } } st } #[test] fn every_witness_replays_to_a_win() { let state = last_rounds(3, 7, 2); let Verdict::Winnable { line, .. } = winnable_within(&state, 2, 200_000) else { panic!("3p seed 7 is winnable in its last two rounds — greedy actually won it"); }; let mut replay = state.clone(); for m in &line { let events = replay .validate(m.actor, &m.command) .unwrap_or_else(|e| panic!("witness move rejected on replay: {e:?}")); for e in &events { replay.fold(e); } } let outcome = replay.outcome.as_ref().expect("the replay must finish"); assert!( outcome.group_success, "the witness replayed but did not win: {} of {}", outcome.total, outcome.threshold ); } /// The negative control. Without it, a search that returns /// `NoneFound` for everything would pass the test above by never /// producing a witness to check. #[test] fn a_budget_of_nothing_reports_a_cut_not_a_verdict() { let state = last_rounds(3, 7, 2); match winnable_within(&state, 2, 1) { Verdict::NoneFound { exhausted, .. } => assert!( !exhausted, "a search stopped by its budget must not claim it searched exhaustively — \ that is the difference between `no line exists` and `we stopped looking`" ), Verdict::Winnable { .. } => panic!("one node cannot find a whole line"), } } /// A position with no rounds left cannot be won, and the search must /// say so **without** claiming exhaustion of a space it never entered. #[test] fn no_rounds_left_finds_nothing() { let state = last_rounds(3, 7, 2); match winnable_within(&state, 0, 100) { Verdict::NoneFound { nodes, exhausted } => { assert_eq!(nodes, 0, "a zero-round search must not expand anything"); assert!(exhausted, "it searched its (empty) space to exhaustion"); } Verdict::Winnable { .. } => panic!("no rounds left cannot win"), } } /// **A position that cannot be won returns none, exhaustively** — the /// control without which "winnable" is unfalsifiable. /// /// The construction: 2p seed 7, searched over its **last round only**. /// Greedy lost that game, and one round is a small enough space to /// search to exhaustion (~8k nodes), so this is a real negative rather /// than a budget cut wearing a verdict's clothes. #[test] fn a_position_that_cannot_be_won_says_so_and_means_it() { let state = last_rounds(2, 7, 1); match winnable_within(&state, 1, 500_000) { Verdict::NoneFound { exhausted, nodes } => { assert!( exhausted, "the space must be searched out, or this proves nothing ({nodes} nodes)" ); assert!( nodes > 100, "suspiciously few nodes for a real search: {nodes}" ); } Verdict::Winnable { line, .. } => { panic!("found a {}-move win in a game 2p seed 7 lost", line.len()) } } } /// The `visible` marking must be able to say NO, or it is decoration. /// INVESTIGATE names a face-down Problem — that is the case where a /// seat could not have known what it was choosing. #[test] fn a_move_onto_a_face_down_problem_is_marked_hidden() { let state = setup(3, 7); let hidden_key = state .problems .iter() .find(|(_, p)| !p.face_up) .map(|(k, _)| *k) .expect("a fresh deal has face-down Problems"); let face_up_key = state .problems .iter() .find(|(_, p)| p.face_up) .map(|(k, _)| *k) .expect("a fresh deal has the Surface Problem face up"); let onto = |p: u32| GroundCommand::SelectAction { action: crate::Action::Investigate, target: None, problem: Some(p), }; assert!( !is_visible(&state, PlayerId(0), &onto(hidden_key)), "targeting a face-down Problem required knowing what was under it" ); assert!( is_visible(&state, PlayerId(0), &onto(face_up_key)), "a face-up Problem is visible — the marking must be able to say YES too" ); } }