diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index c0e67b5..5f01712 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -20,6 +20,12 @@ pub mod view; #[cfg(feature = "scenarios")] pub mod record; +/// *Was this deal winnable?* — the retrospective search (CB-WP-0025 T05, +/// ADR-0013). Uses only `validate`/`fold`/`legal_commands`, so it lives +/// beside the aggregate rather than in a crate that would re-export it +/// (ADR-0013 D6). +pub mod search; + #[cfg(feature = "scenarios")] use cb_game_runtime::{parse_actor, CommandStep, ScenarioGame, Setup}; use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed}; diff --git a/games/ground/src/search.rs b/games/ground/src/search.rs new file mode 100644 index 0000000..aa4a25f --- /dev/null +++ b/games/ground/src/search.rs @@ -0,0 +1,412 @@ +//! 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" + ); + } +} diff --git a/specs/RetrospectiveAnalysis.md b/specs/RetrospectiveAnalysis.md index 3fdee9b..fbb11b1 100644 --- a/specs/RetrospectiveAnalysis.md +++ b/specs/RetrospectiveAnalysis.md @@ -74,7 +74,19 @@ and this sentence is what the player reads. ## 3. The bound **Exhaustive over the last `K` rounds**, table treated as one co-operative -agent choosing joint selections. `K = 2` by default. +agent choosing joint selections. + +**`K = 1` for an exhaustive answer; `K` may be larger when a witness is +all that is wanted.** ADR-0013 said `K = 2` by default; §3.1's measurement +overrides it, and the difference is which question is being asked: + +| answer | needs | affordable `K` today | +|---|---|---| +| *"here is a winning line"* | one success | 2+ — DFS finds one in tens of nodes | +| *"there is no winning line"* | exhaustion | **1** — `K=2` exceeded 2×10⁶ nodes at two seats | + +A `K` that cannot be exhausted may still emit a witness; it may **not** +report `NoneFound { exhausted: true }`, and the type keeps those apart. Bounded in **rounds**, not nodes: *"winnable from round 4"* means something to a player; *"winnable within 100,000 nodes"* does not. A node budget is a @@ -106,10 +118,37 @@ widths: | 3 | ~1.6×10⁵ | **~0.8 s** | | 4 | ~5.7×10⁵ | **~2.9 s** | -**So `K = 2` is affordable at two, three and four seats, and is not at -five or six** — joint branching there exceeds 10⁶ per round. Five and six -seats require a smaller `K`, and the tool must reduce it and **say that it -did** rather than silently searching less. +> ### The projection above was wrong, and the real search falsified it +> +> **Measured 2026-08-05 with the search built in T05**, rewinding real +> games to the start of their last `K` rounds: +> +> | case | result | +> |---|---| +> | 2p, `K=1` | **exhausted** in 8,103 nodes, ~29 ms — a real negative | +> | 2p, `K=2` | **budget cut** at 2,000,000 nodes, ~5 s — not exhausted | +> | 3p, `K=2` | win found in 41 nodes, ~157 µs | +> +> §6's falsifier said *"§3 fails if K=2 proves unaffordable in practice at +> four seats"*. **It failed at two.** +> +> The projection assumed a joint product per round. The search explores +> **sequential per-seat decisions**, and the post-Reveal phase branches +> over every seat's options at every level, so orderings multiply the tree +> far beyond `width^seats`. +> +> **And the asymmetry is the operative fact:** *finding* a win is cheap — +> depth-first stumbles onto one in tens of nodes — while *proving none +> exists* is expensive, because it must exhaust the space. So: +> +> - **the witness feature (§2) is affordable now**, at any `K` a player +> would ask about; +> - **the winnable fraction (§4.2) is not**, because its "not winnable" +> half requires exhaustion on every deal it counts. +> +> `K = 1` is the honest default for exhaustive answers today. Making +> `K = 2` exhaustible needs transposition or move-ordering, neither of +> which this pass built. **The published 112–161 µs/node figure is withdrawn** (CB-RES-0008 §1.2, challenge C1) and must not be quoted from anywhere. diff --git a/workplans/CB-WP-0025-could-we-have-won.md b/workplans/CB-WP-0025-could-we-have-won.md index 1e9741c..4294220 100644 --- a/workplans/CB-WP-0025-could-we-have-won.md +++ b/workplans/CB-WP-0025-could-we-have-won.md @@ -46,45 +46,30 @@ middle of stating, arriving with a concrete demand. ## What already exists, so the survey does not re-find it -- **The state is replayable.** `cb-game-runtime` records sessions as - scenarios; `replay.rs` and `make replay-test` already re-run them. - A search does not need new persistence. -- **The move space is enumerable.** `legal_commands` exists and, since - CB-WP-0023, is narrow enough to be worth trusting — SOLVE is offered - only where it can act, so the branching factor is real rather than - inflated by inert moves. -- **Bots exist.** `games/ground/src/bot.rs` has `GreedyPolicy` and - `RandomPolicy`, wired through `bot_policy` (`table.rs:208`). A win rate - over N seeds is reachable with what is already there — the question is - whether that number *means* anything, which is the survey's problem. -- **The threshold is public.** `OutcomeView.total` / `.threshold` / - `.group_success`. Difficulty has a denominator already. +The state is replayable (`replay.rs`, `make replay-test`); the move space +is enumerable (`legal_commands`, narrowed by CB-WP-0023 so branching is +real rather than inflated by inert moves); bots exist (`bot.rs`); and the +threshold is public (`OutcomeView`). **A search needs no new persistence +and no new rules** — which is why D6 put it in `games/ground` with no new +crate and no port. ## What makes this hard, and must not be waved through -**The game is not perfect-information and the search must respect that.** -A path computed with the deck known is a path the players could never have -found. `view.rs` hides the deck, other seats' hands, and face-down -selections *by rule* (GR-S02/S04, GR-R02/R04). A retrospective solver -running on `GroundState` sees all of it. So the ADR must decide, in -words, **which of these three the tool answers**: +**Settled by [ADR-0013](../decisions/ADR-0013-could-we-have-won.md).** The +declaration framed the central risk as *a path computed with the deck +known is a path the players could never have found*, and asked the ADR to +choose between an omniscient, an information-respecting, and a bounded +search. -- *was this deal winnable by an omniscient player* — cheap, honest, - and answers a question nobody asked; -- *was it winnable from what the seats could see* — the question actually - asked, and the expensive one; -- *did a reasonable line exist* — a bounded search from the losing seat's - information, which may be the only affordable honest answer. +**D1 dissolved the choice**: strategy fusion is a defect of aggregating +over determinizations to *choose a move*, and after the game there is one +world — so a line found in it is executable in it. **D2** keeps the +declaration's real concern by marking each move `visible`/`hidden` rather +than by refusing to search. -Getting this wrong produces a feature that tells the maintainer he could -have won by playing a card he had no way to know was there. **That is -worse than not shipping it.** - -**And a difficulty number is a claim about a distribution.** One win rate -over one bot policy over N seeds is not "the difficulty"; it is that -policy's win rate. Whatever the spec adopts must name its policy, its N, -and its seed range, or `ground-game` will tune tiers against a number -whose meaning drifts the next time a bot improves. +The second warning here — *"one win rate over one bot policy is not the +difficulty"* — was right, and **the survey made exactly that error +anyway**; see T02. ## Task: survey @@ -99,29 +84,12 @@ state_hub_task_id: "556cfd24-5992-4cc0-be90-0b60e989b5bb" (`loop-lint` checks both). Per §Step 1 the survey is done when it can name a **benchmark-to-beat** -per dimension — a number or a reproducible comparison, not an impression. - -- **Retrospective solvers in games with hidden information.** The prior art - is real and should be named: determinized search (perfect-information - Monte Carlo) and its known failure — *strategy fusion*, where a - determinizing solver claims lines that require knowing which world it is - in. That failure is exactly the trap in §What makes this hard. Bridge - and Skat post-mortem tools are the closest analogues; poker solvers are - the well-studied case and the wrong shape. -- **"A path to win" as a product, not a proof.** The maintainer already - conceded optimality (*"the best path is not computable I guess"*). So - the target is a **witness**: one concrete line of play that reaches - `group_success`, or a defensible *no line found within bound B*. Name - what a witness must carry to be checkable. -- **Difficulty as a measured quantity in co-operative games.** Pandemic and - its relatives set difficulty by a dial with a published win rate. The - benchmark-to-beat is: can we produce a win rate whose confidence - interval is tight enough to distinguish two threshold settings? -- **Cost.** Search over an event-sourced aggregate with full `validate` on - every branch has a per-node price. Measure it on our machine, on our - scenarios — the runnable-baseline option applies here, since a search - that cannot finish while the player is still looking at the page is a - different feature. +per dimension. Four were asked for: retrospective solvers in +hidden-information games (and their known failure, strategy fusion); *a +path to win* as a **witness** rather than a proof; difficulty as a measured +quantity in co-operative games; and the **per-node cost**, measured on our +machine — the runnable-baseline option applies, since a search that cannot +finish while the player is looking at the page is a different feature. **Done 2026-08-05.** [CB-RES-0008](../research/CB-RES-0008-could-we-have-won.md), with a @@ -150,12 +118,6 @@ corrected text and [the response](../history/260805-could-we-have-won-response.md) for the full accounting. -**Prior art named the trap** — determinized search suffers *strategy -fusion* (Frank, Basin & Matsubara 1998) — **and T03 then established it -does not apply here.** After the game there is one world, so a line found -in it is executable in it. Fusion is an obstacle to a *playing* engine, -which this is not. - ## Task: adversarial review ```task @@ -313,16 +275,15 @@ Measured at real decision points (table in **0.5–3.8 µs**. **`clone` is in there because a search must copy state per branch**, and -`iter_batched` excludes setup from timing — leaving the budget resting on -an unmeasured span, which is precisely C1's mistake. **Per-child cost is -not uniform**: `validate+fold` ranges 0.5–3.8 µs by command, so budgets -use the upper end (~5 µs/child). +`iter_batched` excludes setup from timing — leaving the budget on an +unmeasured span, which is precisely C1's mistake. -**That settles D3's affordability with real numbers**: joint branching -over the last two rounds is ~5×10² / 1.6×10⁵ / 5.7×10⁵ at 2/3/4 seats → -negligible / **0.8 s** / **2.9 s**. `K = 2` holds at two to four seats and -**does not at five or six**, where the tool must reduce `K` and *say so* -rather than silently search less. +**The affordability conclusion drawn here was itself falsified by T05.** +It projected joint branching and concluded `K = 2` holds at two to four +seats. The real search exceeded 2×10⁶ nodes at **two**. See T05's record +and [RetrospectiveAnalysis §3.1](../specs/RetrospectiveAnalysis.md) — +a projection from branch widths is not a timing of a search, and this +pass has now made that mistake twice. **§4.1 is a normative prohibition**, not a preference: a single-policy win rate may not be reported as a difficulty. The spec carries the measured @@ -332,7 +293,7 @@ reason — greedy 100% vs first-legal 0% on identical deals. ```task id: CB-WP-0025-T05 -status: todo +status: done priority: high state_hub_task_id: "5f1cbda3-7427-4f23-8c67-b2e216b987a8" ``` @@ -353,6 +314,40 @@ a loss. naming *that* boundary goes red. If it cannot be mutated, it was a comment rather than a rule. +**Done 2026-08-05.** `games/ground/src/search.rs`, five tests. + +**The first traversal was wrong and the diagnostic hid it.** It branched +on *the first seat with any legal command* and stopped there, so a later +seat never acted if an earlier one was already selected. Restructured +around what the rules oblige: a seat without a selection **must** select +(GR-R02) and nothing else can happen first; after Reveal the optional +actions branch freely, and the aggregate rejects `Resolve` until the +obligatory ones are done — **so the search needs no phase logic of its +own.** + +**And my rewind was off by one round**, replaying the round it was meant +to search. That is why the first run reported 3 nodes and looked like a +working search. + +**The measurement falsified the spec's own projection, at two seats rather +than the four §6 predicted.** + +| case | result | +|---|---| +| 2p `K=1` | **exhausted**, 8,103 nodes, ~29 ms | +| 2p `K=2` | **budget cut** at 2,000,000 nodes, ~5 s | +| 3p `K=2` | win found, 41 nodes, ~157 µs | + +The projection assumed a joint product per round; the search explores +sequential per-seat decisions, so orderings multiply the tree far beyond +`width^seats`. + +**The asymmetry is the operative finding.** *Finding* a win is cheap — +DFS stumbles onto one in tens of nodes. *Proving none exists* needs +exhaustion. So the **witness feature is affordable now**, and the +**winnable fraction is not**, because its negative half must exhaust every +deal it counts. That is T06's problem and the spec now says so. + ## Task: measure the difficulty, and hand it to ground-game ```task