413 lines
15 KiB
Rust
413 lines
15 KiB
Rust
|
|
//! 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<Move>, 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<GroundState> {
|
||
|
|
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<Vec<Move>> {
|
||
|
|
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<Vec<Move>> {
|
||
|
|
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<PlayerId> = 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<Box<dyn crate::bot::Policy>> = (0..players)
|
||
|
|
.map(|_| Box::new(crate::bot::GreedyPolicy) as Box<dyn crate::bot::Policy>)
|
||
|
|
.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"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|