diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 302b682..44608b8 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -14,10 +14,11 @@ | workplan | CB-WP-0004 | done | — | workplans/CB-WP-0004-mechanical-work.md | | workplan | CB-WP-0005 | done | — | workplans/CB-WP-0005-assertion-coverage.md | | workplan | CB-WP-0006 | done | — | workplans/CB-WP-0006-instrument-the-table.md | -| workplan | CB-WP-0007 | in_progress | — | workplans/CB-WP-0007-session-shape.md | +| workplan | CB-WP-0007 | done | — | workplans/CB-WP-0007-session-shape.md | | workplan | CB-WP-0008 | done | — | workplans/CB-WP-0008-ship-stage-0.md | | workplan | CB-WP-0009 | done | — | workplans/CB-WP-0009-adaptive-gates.md | -| workplan | CB-WP-0010 | proposed | — | workplans/CB-WP-0010-consolidation.md | +| workplan | CB-WP-0010 | done | — | workplans/CB-WP-0010-consolidation.md | +| workplan | CB-WP-0011 | todo | — | workplans/CB-WP-0011-inspectable-table.md | | task | CB-WP-0001-T01 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T02 | done | — | workplans/CB-WP-0001-inner-loop.md | | task | CB-WP-0001-T03 | done | — | workplans/CB-WP-0001-inner-loop.md | @@ -72,8 +73,8 @@ | task | CB-WP-0007-T02 | cancel | — | workplans/CB-WP-0007-session-shape.md | | task | CB-WP-0007-T03 | done | — | workplans/CB-WP-0007-session-shape.md | | task | CB-WP-0007-T04 | cancel | — | workplans/CB-WP-0007-session-shape.md | -| task | CB-WP-0007-T05 | todo | — | workplans/CB-WP-0007-session-shape.md | -| task | CB-WP-0007-T06 | todo | — | workplans/CB-WP-0007-session-shape.md | +| task | CB-WP-0007-T05 | cancel | — | workplans/CB-WP-0007-session-shape.md | +| task | CB-WP-0007-T06 | cancel | — | workplans/CB-WP-0007-session-shape.md | | task | CB-WP-0008-T01 | done | — | workplans/CB-WP-0008-ship-stage-0.md | | task | CB-WP-0008-T02 | done | — | workplans/CB-WP-0008-ship-stage-0.md | | task | CB-WP-0008-T03 | done | — | workplans/CB-WP-0008-ship-stage-0.md | @@ -82,6 +83,9 @@ | task | CB-WP-0009-T02 | done | — | workplans/CB-WP-0009-adaptive-gates.md | | task | CB-WP-0009-T03 | done | — | workplans/CB-WP-0009-adaptive-gates.md | | task | CB-WP-0009-T04 | done | — | workplans/CB-WP-0009-adaptive-gates.md | -| task | CB-WP-0010-T01 | todo | — | workplans/CB-WP-0010-consolidation.md | -| task | CB-WP-0010-T02 | todo | — | workplans/CB-WP-0010-consolidation.md | -| task | CB-WP-0010-T03 | todo | — | workplans/CB-WP-0010-consolidation.md | +| task | CB-WP-0010-T01 | done | — | workplans/CB-WP-0010-consolidation.md | +| task | CB-WP-0010-T02 | done | — | workplans/CB-WP-0010-consolidation.md | +| task | CB-WP-0010-T03 | done | — | workplans/CB-WP-0010-consolidation.md | +| task | CB-WP-0011-T01 | todo | — | workplans/CB-WP-0011-inspectable-table.md | +| task | CB-WP-0011-T02 | todo | — | workplans/CB-WP-0011-inspectable-table.md | +| task | CB-WP-0011-T03 | todo | — | workplans/CB-WP-0011-inspectable-table.md | diff --git a/crates/cb-game-runtime/src/replay.rs b/crates/cb-game-runtime/src/replay.rs index a42679c..00ab0e5 100644 --- a/crates/cb-game-runtime/src/replay.rs +++ b/crates/cb-game-runtime/src/replay.rs @@ -133,12 +133,18 @@ pub fn write_bundle( Ok(bundle) } -/// Re-execute a bundle and require it to reproduce the recorded hash. +/// Open a bundle for re-execution: the manifest, the restored initial +/// state, and the recorded steps in order. /// -/// Returns `Err` when the bundle is corrupt, incomplete, internally -/// inconsistent, or does not reproduce — all of which are the point. A -/// round-trip that cannot fail proves nothing. -pub fn replay(bundle: &Path) -> Result +/// Extracted from [`replay`] by CB-WP-0011 T02 so the inspector walks a +/// bundle through **the same reader the replay gate uses**. A second +/// reader would let the inspector show states a replay never reached — +/// and it would be a duplicated fact, checked by nothing, in the one +/// place where being wrong is silent. +/// +/// Every control [`replay`] performed before touching the command log is +/// performed here, including the one that makes the seed load-bearing. +pub fn open(bundle: &Path) -> Result<(Manifest, G, Vec), String> where G: ScenarioGame, { @@ -165,17 +171,33 @@ where &std::fs::read(bundle.join(INITIAL)).map_err(|e| err("read initial", e))?, ) .map_err(|e| err("parse initial", e))?; - let mut state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?; + let state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?; // K11 framing: a truncated or corrupt command log is rejected here. let raw = std::fs::read(bundle.join(COMMANDS)).map_err(|e| err("read commands", e))?; let records = parse(&raw).map_err(|e| err("command log", e))?; + let mut steps = Vec::with_capacity(records.len()); + for record in &records { + steps.push(serde_json::from_slice(record).map_err(|e| err("decode step", e))?); + } + + Ok((manifest, state, steps)) +} + +/// Re-execute a bundle and require it to reproduce the recorded hash. +/// +/// Returns `Err` when the bundle is corrupt, incomplete, internally +/// inconsistent, or does not reproduce — all of which are the point. A +/// round-trip that cannot fail proves nothing. +pub fn replay(bundle: &Path) -> Result +where + G: ScenarioGame, +{ + let (manifest, mut state, steps) = open::(bundle)?; let mut applied = 0usize; - for record in &records { - let step: CommandStep = - serde_json::from_slice(record).map_err(|e| err("decode step", e))?; - let (actor, command) = G::parse_command(&step)?; + for step in &steps { + let (actor, command) = G::parse_command(step)?; if let Ok(produced) = state.validate(actor, &command) { for event in produced { state.fold(&event); diff --git a/evidence/CB-EV-0009-inspectable-table.md b/evidence/CB-EV-0009-inspectable-table.md new file mode 100644 index 0000000..9f6237b --- /dev/null +++ b/evidence/CB-EV-0009-inspectable-table.md @@ -0,0 +1,191 @@ +# CB-EV-0009 — the chaos roll fired, and what it cost + +CB-WP-0011 T03. Measured 2026-08-02 at `HEAD` after T02. Pass kind +`product`. + +This is the first pass in which the chaos mechanism changed a tier, so +the first question is the one the calibration window exists to answer. + +--- + +## 1. Did tier S produce a worse outcome than tier L would have? + +**No — and the reason is not "tier L was unnecessary". It is that the +roll forced a decomposition that was better than the one I had planned.** + +The declaration: + +``` +structural tier L (INTENT stage 1 creates a new capability port) +chaos d4 = 4 → override fires +override roll shuf -e S M L → S +``` + +Before the roll I had the shape of the tier-L pass in hand: a survey +(`CB-RES-0006`) whose leading constraint would have been **AM-4a's 3,750 +lines of headroom** — 246,250 of a 250,000 target, measured — followed by +an ADR choosing a 2D toolkit and standing up `cb-render-api` / +`cb-render-null`. + +Tier S has no survey and no ADR, and the hard gate — *no implementation +code for a capability exists before its ADR is committed* — is not a tier +weight and does not roll down. So the two rules met head-on, and the +resolution was to **split the declaration**: take the part of stage 1 +that creates no port and adds no dependency, and leave the port to its +own declaration and its own roll. + +What that split surfaced is the finding this pass acted on: + +> `cb-play` already had a renderer. It had had one since stage 0. It was +> showing **24 of the 41** leaf paths a populated `GroundView` carries. + +A tier-L pass would have opened by surveying 2D toolkits. It would have +been surveying how to *draw* a table whose text renderer was silently +dropping the DARVO state machine, the GROUND practice, the scoring mode, +the Focus tokens, the discard pile, per-seat protection, and every part +of the outcome except the headline. **The port was the wrong first +question**, and nothing in the structural tier derivation could have said +so, because the trigger fires on "creates a capability port" — a property +of the *plan*, not of the code. + +**Stated against my own prior.** In the turn before the roll I recommended +tier L and said the AM-4a headroom "should lead the survey". The roll +deleted the survey and the pass was better for it. One data point, and it +is one data point — but it is the *first* evidence the CHAOS gate has +produced in six declarations, and it points the way the mechanism's +defenders hoped and I did not expect. + +**What tier L would have caught that this pass did not.** Being honest in +the other direction: this pass made a real interface change — +`cb_game_runtime::replay::open`, extracted so the inspector and the replay +gate share one bundle reader — with no review and no ADR. It is small, +dev-only, and flagged in its commit per the chaos limits, and I believe it +is right. But "I believe it is right" is exactly what a tier-L review +exists not to accept, and that is the cost side of the ledger. + +**Carried, not concluded.** Six of twelve declarations used, one override. +The retire condition in `gates.toml` asks whether an overridden tier ever +produces *a different outcome than the argued one*. It just did. That is +recorded as the gate's first `caught` entry, and the window stays open — +one favourable fire is not a calibration. + +## 2. The field-coverage gap, before and after + +| | leaf paths shown | +|---|---| +| before (stage-0 renderer) | **24** of 41 | +| after | **41** of 41, plus 1 declared omitted | + +**Measured, not counted.** The old renderer was spliced back in beside the +new fixture and run. That matters, because the first two figures I wrote +for this — "42 of 43, up from 24", in the T01 commit message — were +counted by hand and both were wrong. The trusted-arithmetic class, in a +pass whose entire subject is a gate against unverified claims. + +The measurement also corrects itself in the honest direction: +`outcome.winners.*` shows as missing under the new token but *was* being +rendered, in a different format. So **16 fields were genuinely absent**, +not 17. That is the number to quote. + +The one deliberate omission is `players.*.hand` — `null` for every seat +but the viewer, where the absence is what `hand N card(s)` renders. + +**The gate caught a field its own author had missed**, on its first run, +before it had ever been committed: `players.*.hand` was in neither list. +That is the entry worth having in `M-D1-MUT`'s ledger, more than the three +mutations I designed on purpose. + +## 3. What no test could have caught, and why + +Worth writing down as a class, because it is new here: + +> Every assertion a renderer test naturally makes — *the output mentions +> the round*, *the output is non-empty*, *P2's hand does not appear* — is +> satisfied by a renderer that shows a third of the state. + +This is the harness-does-nothing class in **presentation** form. The +harness runs, the assertions are real, and they are all satisfied by the +defect. What breaks it is asserting over the *shape of the input* rather +than the content of the output: walk the serialized view, require every +leaf path to be classified, and make silence cost a build. + +Paths, not keys. `problem` occurs under a DARVO target, a GROUND choice +and a Selection; a key-set walk would let one of the three vouch for the +other two. + +## 4. Cost, shape, and the meta budget + +| pass | kind | responses | cost | $/response | +|---|---|---|---|---| +| CB-WP-0006 | meta | 158 | $57.22 | 0.362 | +| CB-WP-0007 | meta | 21 | $7.95 | 0.298 | +| CB-WP-0008 | product | 134 | $17.38 | 0.123 | +| CB-WP-0009 | meta | 46 | $11.31 | 0.246 | +| CB-WP-0010 | product | 26 | $4.08 | 0.157 | +| **CB-WP-0011** | **product** | **45** | **$4.23** | **0.094** | + +**The cheapest pass per response yet recorded**, against a previous best +of 0.123. Two mechanical causes, both boring and both worth keeping: + +- it opened immediately after a compaction (§ below), and +- every task had a `cargo test` between it and being wrong. + +The CB-WP-0009 figure moved from $6.73 (as reported in CB-EV-0008 §4) to +$11.31. That is not a correction: CB-EV-0008 was written *during* +CB-WP-0009, and the responses after it are attributed to the pass they +belong to. A pass cannot measure its own final cost, and reporting one +mid-pass will always read low. + +### Session shape + +| metric | this pass | previous pass opened at | target | +|---|---|---|---| +| SH-1 mean context | **147,808** | 338,852 `[HARD]` | ≤ 200,000 | +| SH-2 p90 context | **149,262** | 339,342 `[SOFT]` | ≤ 300,000 | +| SH-3 batching | 0.0% `[SOFT]` | 0.0% | ≥ 20% | + +Both context metrics went from breach to comfortably inside, and the +lever was one `/compact` before the pass opened. That is now the second +time the same lever has produced the cheapest pass on record +(CB-WP-0008 was the first). Two observations are not a law, but the +prediction is cheap and falsifiable: **the next pass opened above the +SH-1 hard line will cost more per response than 0.123.** + +SH-3 stays at 0.0% against a 20% floor and remains unfalsified and +unremedied. It has now read 0.0% for five consecutive passes. + +### Meta budget + +**58% of the trailing three, against a soft 25%** — up from 45%, during +two consecutive `product` passes. + +That is not an instrument defect, but it is a property worth naming: the +budget is a **cost share**, so two cheap product passes move it less than +one expensive meta pass moved it up. CB-WP-0009's $11.31 is 58% of the +$19.62 the window holds, and it leaves the window on the next pass. + +**Falsifiable prediction:** if the next pass is `product`, the trailing-3 +meta share drops to **0%**, because CB-WP-0009 will be the pass that +rolled off. If it does not, the windowing is wrong in a way neither +CB-EV-0007 §3 nor CB-EV-0008 §1 found. + +No product work was displaced by meta work in this pass — nothing meta +was opened. The number is over the line and reported under the rule that +requires reporting it. + +## 5. Open + +- **Stage 1 is not shipped.** This is its inspectable half. INTENT's + stage-1 line stays open; the port, the visualization and + drag-to-propose are untouched. +- **The port declaration is still owed**, structurally tier L, with its + own chaos roll, and with AM-4a's 3,750-line headroom as its leading + constraint. That number is unchanged by this pass — nothing was added + to the shipped runtime. +- **CHAOS has its first `caught` entry** and stays open to 2026-09-30. +- **GATE-REVIEW still has none**, one pass older. +- **SH-3 at 0.0% for five passes.** Either the floor is wrong or the + behaviour is, and neither has been argued. +- **`cb-play` is now two tools in one binary.** Play and inspect share a + renderer and nothing else. If a third mode arrives, that is the second + use, and the split should be reconsidered then rather than now. diff --git a/gates.toml b/gates.toml index 111de34..29b43be 100644 --- a/gates.toml +++ b/gates.toml @@ -120,7 +120,9 @@ target = "" checks = "d4 on each tier declaration, 12-declaration calibration window" added = "2026-07-30" review_by = "2026-09-30" -caught = [] +caught = [ + "CB-WP-0011: first fire in 6 declarations — d4=4 rolled stage 1 from structural L to S; the deleted survey would have opened on 2D toolkits while the existing text renderer was showing 24 of 41 view fields (CB-EV-0009 §1)", +] retire_if = "the window closes with no overridden tier producing a different outcome than the argued one — the evaluation this window exists to make possible" [[gate]] diff --git a/tools/cb-play/src/inspect.rs b/tools/cb-play/src/inspect.rs new file mode 100644 index 0000000..6b2466d --- /dev/null +++ b/tools/cb-play/src/inspect.rs @@ -0,0 +1,878 @@ +//! The inspector: one seat's whole picture, rendered as text +//! (CB-WP-0011 T01). +//! +//! Moved out of `table.rs`, where it was born in stage 0 as a prompt +//! header. That is exactly what was wrong with it: written to show a +//! human their legal moves, it showed the six fields a chooser needs and +//! silently dropped the rest of the projection — the whole DARVO state +//! machine, the whole GROUND practice, the scoring mode, the Focus +//! tokens, the discard pile, and every part of the outcome except the +//! headline. +//! +//! **No test could have caught that**, which is the point worth writing +//! down. Every assertion a renderer test naturally makes — "the output +//! mentions round", "the output is non-empty", "P2's hand does not +//! appear" — is satisfied by a render that shows a third of the state. +//! The harness-does-nothing class, in its presentation form. +//! +//! So the renderer ships with [`tests::every_view_field_is_classified`], +//! which walks the *serialized shape* of `GroundView` and requires every +//! leaf path to be listed as either rendered or deliberately omitted. A +//! field added to the projection and forgotten here fails the build. +//! +//! **Stated non-goal, unchanged from stage 0:** no TUI, no colour, no +//! readline. This is the inspectable half of INTENT stage 1; the 2D half +//! needs a rendering port, which needs an ADR, which this pass does not +//! have (see the workplan's tier declaration). + +use cb_game_runtime::{Project, ScenarioGame}; +use cb_kernel::{Aggregate, PlayerId}; +use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; +use games_ground::GroundState; + +pub fn suit_name(s: games_ground::Suit) -> &'static str { + match s { + games_ground::Suit::Clarify => "Clarify", + games_ground::Suit::Repair => "Repair", + games_ground::Suit::Boundary => "Boundary", + games_ground::Suit::Change => "Change", + } +} + +pub fn seat_name(p: PlayerId) -> String { + format!("P{}", p.0 + 1) +} + +fn cards(list: &[games_ground::SolutionCard]) -> String { + if list.is_empty() { + return "-".into(); + } + list.iter() + .map(|c| suit_name(c.suit)) + .collect::>() + .join(", ") +} + +/// GR-A11/A12: the sub-choice, with its argument. Rendered from the +/// variant rather than `{:?}` so the argument seat reads as `P3`, not +/// `PlayerId(2)` — the inspector speaks the table's vocabulary. +fn ground_choice(c: &games_ground::GroundChoice) -> String { + use games_ground::GroundChoice as G; + match c { + G::RestoreProblem { problem } => format!("restore [{problem}]"), + G::CancelAttack { attacker } => format!("cancel attack from {}", seat_name(*attacker)), + G::ProtectProblem { problem } => format!("protect [{problem}]"), + G::RemoveBlame { owner } => format!("remove blame from {}", seat_name(*owner)), + G::BreakRelation { with } => format!("break relation with {}", seat_name(*with)), + G::RejectReverse => "reject reverse".into(), + } +} + +/// The per-seat social line: everything the seat has *declared* this +/// round, as opposed to what it *is*. Empty for a seat that has declared +/// nothing, so a quiet table stays readable. +fn render_declarations(id: PlayerId, view: &GroundView) -> String { + let mut parts: Vec = Vec::new(); + if let Some(target) = view.focus.get(&id) { + parts.push(format!("focus→{}", seat_name(*target))); + } + if let Some(mode) = view.ground_modes.get(&id) { + let name = match mode { + games_ground::GroundMode::Gr => "GR", + games_ground::GroundMode::Ou => "OU", + games_ground::GroundMode::Nd => "ND", + }; + parts.push(format!("ground {name}")); + } + if let Some(choice) = view.ground_choices.get(&id) { + parts.push(ground_choice(choice)); + } + if let Some(r) = view.support_responses.get(&id) { + parts.push(format!("support {r:?}")); + } + if let Some(t) = view.darvo_targets.get(&id) { + let mut bits: Vec = Vec::new(); + if let Some(p) = t.problem { + bits.push(format!("[{p}]")); + } + if let Some(p) = t.player { + bits.push(seat_name(p)); + } + parts.push(format!( + "darvo target {}", + if bits.is_empty() { + "none".into() + } else { + bits.join(" ") + } + )); + } + if parts.is_empty() { + String::new() + } else { + format!(" {}\n", parts.join(" ")) + } +} + +fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String { + let hand = match &p.hand { + Some(list) => format!("hand [{}]", cards(list)), + None => format!("hand {} card(s)", p.hand_size), + }; + format!( + " {}{} stress {} freedom {}{} darvo {:?} protect {} blame {} {hand}\n", + seat_name(id), + if is_viewer { " (you)" } else { " " }, + p.stress, + if p.freedom_ready { "READY" } else { "SPENT" }, + if p.freedom_gate_lifted { + " gate-lifted" + } else { + "" + }, + p.darvo, + p.protection, + p.blame_from.len(), + ) +} + +/// One seat's whole picture, from the projection and nothing else. +pub fn render(view: &GroundView) -> String { + let mut out = String::new(); + out.push_str(&format!( + "\nround {} step {:?} lead {} mode {:?} deck {} discard [{}]\n", + view.round, + view.step, + seat_name(view.lead), + view.mode, + view.solution_deck_len, + cards(&view.solution_discard), + )); + for (id, p) in &view.players { + out.push_str(&render_player(*id, p, Some(*id) == view.viewer)); + out.push_str(&render_declarations(*id, view)); + } + + out.push_str(" problems:"); + for (priority, problem) in &view.problems { + match problem { + ProblemView::FaceDown => out.push_str(&format!(" [{priority}] face-down")), + ProblemView::FaceUp { + suit, + value, + denied, + claimed_by, + protected_this_round, + } => { + out.push_str(&format!( + " [{priority}] {} {}{}{}{}", + suit_name(*suit), + value, + if *denied { " DENIED" } else { "" }, + if *protected_this_round { + " PROTECTED" + } else { + "" + }, + match claimed_by { + Some(p) => format!(" claimed by {}", seat_name(*p)), + None => String::new(), + } + )); + } + } + } + out.push('\n'); + + if !view.relations.is_empty() { + out.push_str(" relations:"); + for (pair, relation) in &view.relations { + out.push_str(&format!(" {pair} {relation:?}")); + } + out.push('\n'); + } + + if !view.selections.is_empty() { + out.push_str(" selections:"); + for (id, sel) in &view.selections { + match sel { + SelectionView::Hidden => out.push_str(&format!(" {} face-down", seat_name(*id))), + SelectionView::Shown(s) => out.push_str(&format!( + " {} {:?}{}{}", + seat_name(*id), + s.action, + match s.target { + Some(t) => format!("→{}", seat_name(t)), + None => String::new(), + }, + match s.problem { + Some(p) => format!("→[{p}]"), + None => String::new(), + } + )), + } + } + out.push('\n'); + } + + if let Some(o) = &view.outcome { + out.push_str(&format!( + " OUTCOME total {} / threshold {} group {}\n", + o.total, + o.threshold, + if o.group_success { + "SUCCESS" + } else { + "failure" + }, + )); + out.push_str(" personal:"); + for (id, score) in &o.personal { + out.push_str(&format!(" {} {score}", seat_name(*id))); + } + out.push('\n'); + if let Some(m) = o.mastery { + out.push_str(&format!(" mastery {m}\n")); + } + for c in &o.coalitions { + out.push_str(&format!( + " coalition [{}] score {}\n", + c.members + .iter() + .map(|m| seat_name(*m)) + .collect::>() + .join(", "), + c.score, + )); + } + out.push_str(&format!( + " winners {}\n", + o.winners + .iter() + .map(|w| seat_name(*w)) + .collect::>() + .join(", "), + )); + } + out +} + +// ------------------------------------------------------------------ walk + +/// Which seat's projection a walk renders. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Eyes { + Seat(PlayerId), + Spectator, +} + +impl Eyes { + pub fn parse(raw: &str) -> Result { + if raw.eq_ignore_ascii_case("spectator") { + return Ok(Eyes::Spectator); + } + raw.parse::() + .map(|n| Eyes::Seat(PlayerId(n))) + .map_err(|_| format!("--as expects a 0-based seat or 'spectator', got {raw:?}")) + } + + fn label(self) -> String { + match self { + Eyes::Seat(p) => format!("{} (their hand only)", seat_name(p)), + Eyes::Spectator => "a spectator (no hands)".into(), + } + } + + fn viewer(self) -> cb_game_runtime::Viewer { + match self { + Eyes::Seat(p) => cb_game_runtime::Viewer::Player(p), + Eyes::Spectator => cb_game_runtime::Viewer::Spectator, + } + } +} + +/// What a completed walk reports. +#[derive(Debug)] +pub struct Walk { + pub source: String, + pub steps: usize, + /// Steps the aggregate refused. A recorded game may legitimately + /// contain them — a scenario can assert a rejection — so they are + /// counted and shown, not treated as an error. + pub rejected: usize, + pub end_state_hash: String, + /// `Some` only for a bundle, which records the hash its own producer + /// computed. A scenario file may or may not pin one. + pub expected_hash: Option, +} + +/// Replay a recorded game and render the table after every step. +/// +/// This is the answer to *"what did the table look like when it went +/// wrong?"* that previously required adding a `dbg!` and re-running. +/// +/// The bundle path goes through `replay::open`, the same reader +/// `make replay-test` uses, so the states shown here are the states a +/// replay reaches — structurally, not by assertion. The hash is then +/// checked anyway, because a structural argument that is never executed +/// is the class of claim this project keeps finding to be wrong. +pub fn walk( + source: &std::path::Path, + eyes: Eyes, + out: &mut W, +) -> Result { + let (mut state, steps, expected_hash) = load(source)?; + + let name = source.display().to_string(); + let _ = writeln!( + out, + "inspecting {name} as {} — {} step(s)", + eyes.label(), + steps.len() + ); + let _ = write!(out, "{}", render(&state.project(eyes.viewer()))); + + let mut rejected = 0usize; + for (i, step) in steps.iter().enumerate() { + let (actor, command) = GroundState::parse_command(step)?; + match state.validate(actor, &command) { + Ok(produced) => { + for event in produced { + state.fold(&event); + } + let _ = writeln!(out, "\n[{}] {} {}", i + 1, step.actor, describe_step(step)); + } + Err(rejection) => { + rejected += 1; + let _ = writeln!( + out, + "\n[{}] {} {} — REJECTED: {rejection:?}", + i + 1, + step.actor, + describe_step(step) + ); + } + } + let _ = write!(out, "{}", render(&state.project(eyes.viewer()))); + } + + let end_state_hash = cb_events::state_hash_hex(&state); + if let Some(expected) = &expected_hash { + if &end_state_hash != expected { + return Err(format!( + "the walk did not reproduce the recorded end state: {end_state_hash} != {expected}" + )); + } + } + let _ = writeln!( + out, + "\nend-state hash {end_state_hash}{}", + match &expected_hash { + Some(_) => " (matches the recording)", + None => " (the source pins no hash)", + } + ); + + Ok(Walk { + source: name, + steps: steps.len(), + rejected, + end_state_hash, + expected_hash, + }) +} + +/// A `.cbreplay` bundle or a scenario YAML. Both already reconstruct a +/// command sequence; neither needed a new format for this. +fn load( + source: &std::path::Path, +) -> Result< + ( + GroundState, + Vec, + Option, + ), + String, +> { + if source.is_dir() { + let (manifest, state, steps) = cb_game_runtime::replay::open::(source)?; + return Ok((state, steps, Some(manifest.end_state_hash))); + } + let yaml = + std::fs::read_to_string(source).map_err(|e| format!("read {}: {e}", source.display()))?; + let file = cb_game_runtime::ScenarioFile::from_yaml(&yaml) + .map_err(|e| format!("parse {}: {e}", source.display()))?; + let state = GroundState::setup(&file.setup, file.seed)?; + Ok((state, file.commands, file.expect.state_hash)) +} + +fn describe_step(step: &cb_game_runtime::CommandStep) -> String { + let mut out = step.cmd.clone(); + for (key, value) in &step.args { + let rendered = match value { + serde_yaml::Value::String(s) => s.clone(), + other => serde_yaml::to_string(other) + .unwrap_or_default() + .trim() + .to_string(), + }; + out.push_str(&format!(" {key}={rendered}")); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use games_ground::view::OutcomeView; + use games_ground::*; + use std::collections::BTreeMap; + + /// Every leaf path in the serialized `GroundView`, with map keys and + /// array indices collapsed to `*`. + /// + /// Paths, not keys: `problem` appears under a DARVO target, a GROUND + /// choice and a Selection, and a key-set walk would let one of the + /// three vouch for the other two. + fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec) { + match v { + serde_json::Value::Object(map) => { + for (k, child) in map { + let next = if prefix.is_empty() { + k.clone() + } else { + format!("{prefix}.{k}") + }; + paths(child, &next, out); + } + } + serde_json::Value::Array(items) => { + for item in items { + paths(item, &format!("{prefix}.*"), out); + } + } + _ => out.push(prefix.to_string()), + } + } + + /// A `BTreeMap` serializes as an object, so its *keys* look like + /// struct fields. Collapse any path segment that is a map key — + /// seat names, Problem priorities, `Pair` keys — to `*`. + fn normalize(path: &str) -> String { + let maps = [ + "players", + "relations", + "problems", + "focus", + "selections", + "ground_modes", + "ground_choices", + "support_responses", + "darvo_targets", + "personal", + ]; + let mut parts: Vec = Vec::new(); + let mut collapse_next = false; + for seg in path.split('.') { + if collapse_next { + parts.push("*".into()); + collapse_next = false; + continue; + } + parts.push(seg.to_string()); + collapse_next = maps.contains(&seg); + } + parts.join(".") + } + + /// What the inspector shows, and the token that proves it does. The + /// token is asserted against the fixture's output — delete a field + /// from `render` and its row goes red naming the field. + const RENDERED: &[(&str, &str)] = &[ + ("round", "round 3"), + ("step", "step Resolve"), + ("lead", "lead P2"), + ("mode", "mode BondedCoalitions"), + ("viewer", "(you)"), + ("solution_deck_len", "deck 11"), + ("solution_discard.*.suit", "discard [Repair, Change]"), + ("players.*.stress", "stress 4"), + ("players.*.freedom_ready", "freedom SPENT"), + ("players.*.freedom_gate_lifted", "gate-lifted"), + ("players.*.darvo", "darvo Attack"), + ("players.*.protection", "protect 2"), + ("players.*.blame_from.*", "blame 2"), + ("players.*.hand.*.suit", "hand [Clarify, Boundary]"), + ("players.*.hand_size", "hand 4 card(s)"), + ("relations.*", "Rivalry"), + ("problems.*.state", "face-down"), + ("problems.*.suit", "Boundary"), + ("problems.*.value", "Boundary 9"), + ("problems.*.denied", "DENIED"), + ("problems.*.protected_this_round", "PROTECTED"), + ("problems.*.claimed_by", "claimed by P3"), + ("focus.*", "focus→P3"), + ("ground_modes.*", "ground OU"), + ("ground_choices.*.choice", "cancel attack from"), + ("ground_choices.*.attacker", "cancel attack from P2"), + ("support_responses.*", "support FlipToBond"), + ("darvo_targets.*.problem", "darvo target [7]"), + ("darvo_targets.*.player", "darvo target [7] P1"), + ("selections.*.state", "face-down"), + ("selections.*.action", "Investigate"), + ("selections.*.target", "Investigate→P2"), + ("selections.*.problem", "→[7]"), + ("outcome.total", "total 18"), + ("outcome.threshold", "threshold 15"), + ("outcome.group_success", "group SUCCESS"), + ("outcome.personal.*", "P1 6"), + ("outcome.mastery", "mastery 3"), + ("outcome.coalitions.*.members.*", "coalition [P1, P2]"), + ("outcome.coalitions.*.score", "score 11"), + ("outcome.winners.*", "winners P1, P2"), + ]; + + /// Deliberately not shown, each with the reason. + /// + /// **This list is an unchecked claim**, and saying so is cheaper than + /// pretending otherwise: the test proves a `RENDERED` row is really + /// rendered, and proves nothing about an `OMITTED` one. What it does + /// enforce is that the claim was *made* — a new field cannot arrive + /// silently in either list. + const OMITTED: &[(&str, &str)] = &[ + // `viewer: null` is a spectator, rendered by the *absence* of + // "(you)" — there is no token for it, and adding one would mean + // printing a line that says nothing. + // `null` for every seat but the viewer — GR-S02. There is no + // content to show, and the absence is exactly what the count + // form (`hand 4 card(s)`) renders. A token here would assert + // that the inspector prints something about a thing it must not + // print anything about. + ( + "players.*.hand", + "null for a non-viewer seat; the absence is rendered as a count", + ), + ]; + + /// Round 3, mid-DARVO, mid-GROUND, scored. Built by hand rather than + /// played: at deal time two thirds of these fields are empty, and a + /// coverage test run against absent fields is the same lie in a + /// different costume. + fn fixture() -> GroundView { + let (p1, p2, p3) = (PlayerId(0), PlayerId(1), PlayerId(2)); + let card = |suit| SolutionCard { suit }; + let mut players = BTreeMap::new(); + players.insert( + p1, + PlayerView { + stress: 4, + freedom_ready: false, + freedom_gate_lifted: true, + darvo: DarvoStage::Attack, + protection: 2, + blame_from: vec![p2, p3], + hand: Some(vec![card(Suit::Clarify), card(Suit::Boundary)]), + hand_size: 2, + }, + ); + for (id, stress) in [(p2, 1u8), (p3, 0)] { + players.insert( + id, + PlayerView { + stress, + freedom_ready: true, + freedom_gate_lifted: false, + darvo: DarvoStage::Off, + protection: 0, + blame_from: vec![], + hand: None, + hand_size: 4, + }, + ); + } + + let mut problems = BTreeMap::new(); + problems.insert(1, ProblemView::FaceDown); + problems.insert( + 7, + ProblemView::FaceUp { + suit: Suit::Boundary, + value: 9, + denied: true, + claimed_by: Some(p3), + protected_this_round: true, + }, + ); + + GroundView { + viewer: Some(p1), + round: 3, + lead: p2, + step: RoundStep::Resolve, + mode: ScoringMode::BondedCoalitions, + players, + relations: BTreeMap::from([ + (Pair::new(p1, p2), Relation::Bond), + (Pair::new(p2, p3), Relation::Rivalry), + ]), + problems, + focus: BTreeMap::from([(p1, p3)]), + selections: BTreeMap::from([ + ( + p1, + SelectionView::Shown(Selection { + action: Action::Investigate, + target: Some(p2), + problem: Some(7), + }), + ), + (p2, SelectionView::Hidden), + ]), + ground_modes: BTreeMap::from([(p1, GroundMode::Ou)]), + ground_choices: BTreeMap::from([(p1, GroundChoice::CancelAttack { attacker: p2 })]), + support_responses: BTreeMap::from([(p2, SupportResponse::FlipToBond)]), + darvo_targets: BTreeMap::from([( + p1, + DarvoTarget { + problem: Some(7), + player: Some(p1), + }, + )]), + solution_deck_len: 11, + solution_discard: vec![card(Suit::Repair), card(Suit::Change)], + outcome: Some(OutcomeView { + total: 18, + threshold: 15, + group_success: true, + personal: BTreeMap::from([(p1, 6), (p2, 5), (p3, 7)]), + coalitions: vec![Coalition { + members: vec![p1, p2], + score: 11, + }], + mastery: Some(3), + winners: vec![p1, p2], + }), + } + } + + fn fixture_paths() -> Vec { + let json = serde_json::to_value(fixture()).expect("view serializes"); + let mut raw = Vec::new(); + paths(&json, "", &mut raw); + let mut all: Vec = raw.iter().map(|p| normalize(p)).collect(); + all.sort(); + all.dedup(); + all + } + + /// The gate: every field the projection carries is classified, and + /// every field claimed rendered really is. + #[test] + fn every_view_field_is_classified() { + let out = render(&fixture()); + let all = fixture_paths(); + + // EXPECT-VACUOUS control. A coverage test over an empty path set + // passes trivially, and that is precisely how this check would + // rot — a serde change, a flattened field, a walk that stops at + // the first map. `GroundView` has 17 fields and the fixture + // populates all of them; 30 leaves is a floor, not a count, so + // adding a field never fails this line for the wrong reason. + assert!( + all.len() >= 30, + "the walk found {} leaf path(s) — it is not walking the view", + all.len() + ); + + let rendered: Vec<&str> = RENDERED.iter().map(|(p, _)| *p).collect(); + let omitted: Vec<&str> = OMITTED.iter().map(|(p, _)| *p).collect(); + + let unclassified: Vec<&String> = all + .iter() + .filter(|p| !rendered.contains(&p.as_str()) && !omitted.contains(&p.as_str())) + .collect(); + assert!( + unclassified.is_empty(), + "new field(s) in GroundView are neither rendered nor declared omitted: {unclassified:?}\n\ + add each to RENDERED (with a token the inspector prints) or to OMITTED (with a reason)" + ); + + let stale: Vec<&str> = rendered + .iter() + .chain(omitted.iter()) + .filter(|p| !all.contains(&p.to_string())) + .copied() + .collect(); + assert!( + stale.is_empty(), + "classified path(s) no longer exist in GroundView: {stale:?}" + ); + + for (path, token) in RENDERED { + assert!( + out.contains(token), + "{path} is claimed rendered, but the output has no {token:?}\n--- output ---\n{out}" + ); + } + } + + /// The projection decides what is visible; the inspector must not + /// widen it. P1 is the viewer, so P2's and P3's hands are `None` and + /// only their sizes may appear. + #[test] + fn the_inspector_never_widens_the_projection() { + let out = render(&fixture()); + assert!(out.contains("hand [Clarify, Boundary]"), "{out}"); + assert_eq!( + out.matches("hand 4 card(s)").count(), + 2, + "both non-viewer seats show a count and nothing more\n{out}" + ); + // P2 selected face-down; the tag carries no Selection to leak, + // and the render must not invent one. + assert!(out.contains("P2 face-down"), "{out}"); + } + + /// A spectator sees no hand at all and is not told they are anyone. + #[test] + fn a_spectator_view_renders_without_a_seat() { + let mut view = fixture(); + view.viewer = None; + view.players.get_mut(&PlayerId(0)).unwrap().hand = None; + let out = render(&view); + assert!(!out.contains("(you)"), "{out}"); + assert!(!out.contains("hand ["), "{out}"); + } + + // ------------------------------------------------------- the walk + + /// A recorded game, produced the way a user produces one: play it + /// with bots and ask for a bundle. Fixtures written by hand would + /// test the reader against the writer's assumptions rather than + /// against what the writer actually writes. + fn recorded(tag: &str) -> (std::path::PathBuf, std::path::PathBuf, String) { + let dir = std::env::temp_dir().join(format!("cb-inspect-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("tmp"); + let config = crate::table::Config { + seed: 42, + players: 3, + human_seats: vec![], + bot: "greedy".into(), + replay_dir: Some(dir.clone()), + record: Some(dir.join("session.yaml")), + }; + let mut sink: Vec = Vec::new(); + let summary = + crate::table::play(&config, "".as_bytes(), &mut sink).expect("the bot game runs"); + ( + summary.bundle.expect("bundle"), + summary.recorded.expect("scenario"), + summary.end_state_hash, + ) + } + + /// The acceptance: an inspector that shows a state the replay never + /// reached is worse than no inspector. Both source kinds are walked, + /// because "it works for bundles" was the shape of the last three + /// half-checked claims in this repo. + #[test] + fn a_walk_reproduces_the_recorded_end_state() { + let (bundle, scenario, hash) = recorded("walk"); + + let mut out: Vec = Vec::new(); + let report = super::walk(&bundle, Eyes::Spectator, &mut out).expect("bundle walks"); + assert_eq!(report.end_state_hash, hash); + assert_eq!(report.expected_hash.as_deref(), Some(hash.as_str())); + assert!( + report.steps > 5, + "a 3-player game is more than {} steps", + report.steps + ); + + // The render must have run once per step plus once for the + // initial state — otherwise the walk "succeeded" having shown + // nothing, which is the harness-does-nothing shape. + let text = String::from_utf8(out).expect("utf8"); + assert_eq!( + text.matches(" problems:").count(), + report.steps + 1, + "one table per step plus the opening one\n{text}" + ); + + let mut out: Vec = Vec::new(); + let from_yaml = super::walk(&scenario, Eyes::Spectator, &mut out).expect("scenario walks"); + assert_eq!(from_yaml.end_state_hash, hash); + + let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); + } + + /// The control for the assertion above. A bundle whose recorded hash + /// has been altered must make the walk fail, or the hash comparison + /// is decoration. + #[test] + fn a_walk_that_does_not_reproduce_fails() { + let (bundle, _, hash) = recorded("tamper"); + let manifest = bundle.join("manifest.yaml"); + let text = std::fs::read_to_string(&manifest).expect("read manifest"); + std::fs::write( + &manifest, + text.replace( + &hash, + "0000000000000000000000000000000000000000000000000000000000000000", + ), + ) + .expect("write manifest"); + + let mut out: Vec = Vec::new(); + let err = + super::walk(&bundle, Eyes::Spectator, &mut out).expect_err("tampered bundle must fail"); + assert!(err.contains("did not reproduce"), "wrong reason: {err}"); + let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); + } + + /// `--as` is a projection, not a filter applied afterwards. Seat 2 + /// (P3) sees its own hand and counts for the rest. + /// + /// Seated deliberately at the *last* seat: the equivalent stage-0 + /// test was vacuous twice (CB-EV-0007) because it inspected a + /// position where the hidden thing had not yet been written. + #[test] + fn a_seat_walk_shows_that_seat_and_no_other() { + let (bundle, _, _) = recorded("eyes"); + + let mut out: Vec = Vec::new(); + super::walk(&bundle, Eyes::Seat(PlayerId(2)), &mut out).expect("walks"); + let seated = String::from_utf8(out).expect("utf8"); + + let mut out: Vec = Vec::new(); + super::walk(&bundle, Eyes::Spectator, &mut out).expect("walks"); + let spectator = String::from_utf8(out).expect("utf8"); + + // Exactly one seat shows cards, and it is P3. + let tables = seated.matches(" problems:").count(); + assert_eq!( + seated.matches("hand [").count(), + tables, + "P3 shows a hand in every table and nobody else does\n{seated}" + ); + for line in seated.lines().filter(|l| l.contains("hand [")) { + assert!(line.contains("P3 (you)"), "a hand leaked: {line}"); + } + // A spectator sees none at all — the same walk, one argument + // apart, so a render that ignored `Eyes` would fail here. + assert!(!spectator.contains("hand ["), "{spectator}"); + assert!(!spectator.contains("(you)"), "{spectator}"); + assert_ne!(seated, spectator); + + let _ = std::fs::remove_dir_all(bundle.parent().expect("tmp dir")); + } + + #[test] + fn eyes_parse_and_bad_ones_are_refused() { + assert_eq!(Eyes::parse("2").unwrap(), Eyes::Seat(PlayerId(2))); + assert_eq!(Eyes::parse("SPECTATOR").unwrap(), Eyes::Spectator); + assert!(Eyes::parse("P3").is_err()); + assert!(Eyes::parse("").is_err()); + } +} diff --git a/tools/cb-play/src/main.rs b/tools/cb-play/src/main.rs index eabf83f..e90066d 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -9,14 +9,17 @@ //! success — a session that ends without an outcome is a failure, and //! saying otherwise is how a stalled game passes for a played one. +mod inspect; mod table; use table::Config; const USAGE: &str = "\ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random] - [--replay DIR] [--all-bots] + [--replay DIR] [--all-bots] [--record FILE] + cb-play --inspect PATH [--as SEAT|spectator] +play: --seed N game seed (default 1); the same seed replays identically --players N 2..6 seats (default 3) --seat N a seat you play, 0-based, repeatable (default 0) @@ -24,10 +27,34 @@ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random] --bot KIND policy for every other seat: greedy (default) or random --replay DIR write a .cbreplay bundle of the finished game to DIR --record FILE write the finished game as a scenario YAML + +inspect a recorded game — a .cbreplay bundle directory or a scenario YAML: + --inspect PATH render the table after every recorded step + --as WHO whose projection to render: a 0-based seat, or + spectator (default). A seat sees only its own hand. + +The two modes take disjoint flags: inspecting reads a recording, it does +not deal one, so a seed or a seat count would be silently ignored. "; -fn parse_args(argv: &[String]) -> Result { +/// The two things this binary does. Separate rather than one `Config` +/// with optional halves: `--inspect --seed 9` is not a request anyone +/// can mean, and a flag that is accepted and ignored is worse than one +/// that is refused. +enum Mode { + Play(Config), + Inspect { + source: std::path::PathBuf, + eyes: inspect::Eyes, + }, +} + +fn parse_args(argv: &[String]) -> Result { let mut config = Config::default(); + let mut source: Option = None; + let mut eyes = inspect::Eyes::Spectator; + let mut play_flags: Vec = Vec::new(); + let mut inspect_flags: Vec = Vec::new(); let mut seats: Vec = Vec::new(); let mut all_bots = false; let mut i = 0; @@ -40,18 +67,21 @@ fn parse_args(argv: &[String]) -> Result { let flag = argv[i].as_str(); match flag { "--seed" => { + play_flags.push(flag.into()); config.seed = value(i, argv, flag)? .parse() .map_err(|e| format!("--seed: {e}"))?; i += 2; } "--players" => { + play_flags.push(flag.into()); config.players = value(i, argv, flag)? .parse() .map_err(|e| format!("--players: {e}"))?; i += 2; } "--seat" => { + play_flags.push(flag.into()); seats.push( value(i, argv, flag)? .parse() @@ -60,25 +90,54 @@ fn parse_args(argv: &[String]) -> Result { i += 2; } "--bot" => { + play_flags.push(flag.into()); config.bot = value(i, argv, flag)?; i += 2; } "--record" => { + play_flags.push(flag.into()); config.record = Some(value(i, argv, flag)?.into()); i += 2; } "--replay" => { + play_flags.push(flag.into()); config.replay_dir = Some(value(i, argv, flag)?.into()); i += 2; } "--all-bots" => { all_bots = true; + play_flags.push(flag.into()); i += 1; } + "--inspect" => { + source = Some(value(i, argv, flag)?.into()); + inspect_flags.push(flag.into()); + i += 2; + } + "--as" => { + eyes = inspect::Eyes::parse(&value(i, argv, flag)?)?; + inspect_flags.push(flag.into()); + i += 2; + } "-h" | "--help" => return Err(USAGE.into()), other => return Err(format!("unknown flag {other:?}\n\n{USAGE}")), } } + if let Some(source) = source { + if !play_flags.is_empty() { + return Err(format!( + "--inspect reads a recording; it cannot also {}\n\n{USAGE}", + play_flags.join(", ") + )); + } + return Ok(Mode::Inspect { source, eyes }); + } + if !inspect_flags.is_empty() { + return Err(format!( + "{} only means something with --inspect\n\n{USAGE}", + inspect_flags.join(", ") + )); + } if !(2..=6).contains(&config.players) { return Err(format!("GR-O01: {} players is outside 2–6", config.players)); } @@ -95,12 +154,12 @@ fn parse_args(argv: &[String]) -> Result { config.players )); } - Ok(config) + Ok(Mode::Play(config)) } fn main() { let argv: Vec = std::env::args().skip(1).collect(); - let config = match parse_args(&argv) { + let mode = match parse_args(&argv) { Ok(c) => c, Err(message) => { eprintln!("{message}"); @@ -108,6 +167,33 @@ fn main() { } }; + let config = match mode { + Mode::Play(c) => c, + Mode::Inspect { source, eyes } => { + let mut out = std::io::stdout().lock(); + match inspect::walk(&source, eyes, &mut out) { + Ok(walk) => { + println!( + " {}: {} step(s), {} rejected, hash {}{}", + walk.source, + walk.steps, + walk.rejected, + walk.end_state_hash, + match walk.expected_hash { + Some(_) => " — reproduces the recording", + None => " — the source pins no hash to check", + } + ); + return; + } + Err(message) => { + eprintln!("cb-play: {message}"); + std::process::exit(1); + } + } + } + }; + let stdin = std::io::stdin(); let stdout = std::io::stdout(); match table::play(&config, stdin.lock(), stdout.lock()) { @@ -264,21 +350,43 @@ mod tests { assert_eq!(summary.rounds, 5); } + /// `parse_args` in play mode. Panics on an inspect-mode result, so a + /// flag that quietly switched modes could not pass for a play flag. + fn play_args(list: &[&str]) -> Result { + match parse_args(&args(list))? { + Mode::Play(c) => Ok(c), + Mode::Inspect { .. } => panic!("{list:?} parsed as inspect, not play"), + } + } + #[test] fn flags_parse_and_bad_ones_are_refused() { - let c = parse_args(&args(&["--seed", "9", "--players", "4", "--seat", "2"])).unwrap(); + let c = play_args(&["--seed", "9", "--players", "4", "--seat", "2"]).unwrap(); assert_eq!((c.seed, c.players, c.human_seats.clone()), (9, 4, vec![2])); - assert_eq!(parse_args(&args(&[])).unwrap().human_seats, vec![0]); - assert!(parse_args(&args(&["--all-bots"])) - .unwrap() - .human_seats - .is_empty()); + assert_eq!(play_args(&[]).unwrap().human_seats, vec![0]); + assert!(play_args(&["--all-bots"]).unwrap().human_seats.is_empty()); // GR-O01's range, refused at the door rather than at setup. assert!(parse_args(&args(&["--players", "7"])).is_err()); assert!(parse_args(&args(&["--players", "1"])).is_err()); // A seat nobody occupies would silently never be prompted. assert!(parse_args(&args(&["--players", "3", "--seat", "3"])).is_err()); assert!(parse_args(&args(&["--seed"])).is_err()); + // The two modes take disjoint flags in both directions. A flag + // accepted and ignored is how a user believes they inspected + // seed 9 when they inspected whatever the recording holds. + assert!(parse_args(&args(&["--inspect", "x", "--seed", "9"])).is_err()); + assert!(parse_args(&args(&["--as", "2"])).is_err()); + assert!(matches!( + parse_args(&args(&["--inspect", "x", "--as", "2"])).unwrap(), + Mode::Inspect { eyes, .. } if eyes == inspect::Eyes::Seat(cb_kernel::PlayerId(2)) + )); + assert!(matches!( + parse_args(&args(&["--inspect", "x"])).unwrap(), + Mode::Inspect { + eyes: inspect::Eyes::Spectator, + .. + } + )); assert!(parse_args(&args(&["--nope"])).is_err()); } diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index a9a6fb2..66b4a39 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -16,8 +16,9 @@ use cb_game_runtime::{Project, Setup, Viewer}; use cb_kernel::{Actor, PlayerId}; use games_ground::bot::{BotError, Choice, GreedyPolicy, Policy, RandomPolicy}; use games_ground::record::to_step; -use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; use games_ground::{GroundCommand, GroundState}; + +use crate::inspect::{render, seat_name}; use std::io::{BufRead, Write}; pub struct Config { @@ -58,19 +59,11 @@ pub struct Summary { } // ------------------------------------------------------------- rendering - -fn suit_name(s: games_ground::Suit) -> &'static str { - match s { - games_ground::Suit::Clarify => "Clarify", - games_ground::Suit::Repair => "Repair", - games_ground::Suit::Boundary => "Boundary", - games_ground::Suit::Change => "Change", - } -} - -fn seat_name(p: PlayerId) -> String { - format!("P{}", p.0 + 1) -} +// +// The table itself is rendered by `inspect`. What stays here is the +// vocabulary a *chooser* needs: how a legal command is described in the +// menu. Rendering the state and naming a move are different jobs, and +// T02 needs the first without the second. fn describe(command: &GroundCommand) -> String { // Reuse the recorder's vocabulary rather than inventing a third one: @@ -90,109 +83,6 @@ fn describe(command: &GroundCommand) -> String { out } -fn render_player(id: PlayerId, p: &PlayerView, is_viewer: bool) -> String { - let hand = match &p.hand { - Some(cards) => { - let names: Vec<&str> = cards.iter().map(|c| suit_name(c.suit)).collect(); - format!("hand [{}]", names.join(", ")) - } - None => format!("hand {} card(s)", p.hand_size), - }; - format!( - " {}{} stress {} freedom {} darvo {:?} blame {} {hand}", - seat_name(id), - if is_viewer { " (you)" } else { " " }, - p.stress, - if p.freedom_ready { "READY" } else { "SPENT" }, - p.darvo, - p.blame_from.len(), - ) -} - -/// One seat's whole picture, from the projection and nothing else. -pub fn render(view: &GroundView) -> String { - let mut out = String::new(); - out.push_str(&format!( - "\nround {} step {:?} lead {} deck {}\n", - view.round, - view.step, - seat_name(view.lead), - view.solution_deck_len, - )); - for (id, p) in &view.players { - out.push_str(&render_player(*id, p, Some(*id) == view.viewer)); - out.push('\n'); - } - out.push_str(" problems:"); - for (priority, problem) in &view.problems { - match problem { - ProblemView::FaceDown => out.push_str(&format!(" [{priority}] face-down")), - ProblemView::FaceUp { - suit, - value, - denied, - claimed_by, - .. - } => { - out.push_str(&format!( - " [{priority}] {} {}{}{}", - suit_name(*suit), - value, - if *denied { " DENIED" } else { "" }, - match claimed_by { - Some(p) => format!(" claimed by {}", seat_name(*p)), - None => String::new(), - } - )); - } - } - } - out.push('\n'); - if !view.relations.is_empty() { - out.push_str(" relations:"); - for (pair, relation) in &view.relations { - out.push_str(&format!(" {pair} {relation:?}")); - } - out.push('\n'); - } - if !view.selections.is_empty() { - out.push_str(" selections:"); - for (id, sel) in &view.selections { - match sel { - SelectionView::Hidden => out.push_str(&format!(" {} face-down", seat_name(*id))), - SelectionView::Shown(s) => out.push_str(&format!( - " {} {:?}{}{}", - seat_name(*id), - s.action, - match s.target { - Some(t) => format!("→{}", seat_name(t)), - None => String::new(), - }, - match s.problem { - Some(p) => format!("→[{p}]"), - None => String::new(), - } - )), - } - } - out.push('\n'); - } - if let Some(o) = &view.outcome { - out.push_str(&format!( - " OUTCOME total {} / threshold {} group {} winners {:?}\n", - o.total, - o.threshold, - if o.group_success { - "SUCCESS" - } else { - "failure" - }, - o.winners.iter().map(|w| seat_name(*w)).collect::>(), - )); - } - out -} - // --------------------------------------------------------------- policies /// A seat driven from stdin. Implements the same `Policy` the bots do, so diff --git a/workplans/CB-WP-0011-inspectable-table.md b/workplans/CB-WP-0011-inspectable-table.md index 9e57a3d..4c997f4 100644 --- a/workplans/CB-WP-0011-inspectable-table.md +++ b/workplans/CB-WP-0011-inspectable-table.md @@ -2,7 +2,8 @@ id: CB-WP-0011 kind: product title: "Stage 1, first slice: an inspector that shows everything" -status: todo +status: done +state_hub_workstream_id: "1142442a-65f1-483a-800d-6ca8490c5f1a" --- # Purpose @@ -75,8 +76,9 @@ form. ```task id: CB-WP-0011-T01 -status: todo +status: done priority: high +state_hub_task_id: "b8cb4c20-9d06-47a4-890c-bf8390015439" ``` Extend `render` to cover every field of `GroundView`, and — the load- @@ -107,12 +109,41 @@ deal: most of the missing fields are empty at deal time, and a coverage test run against a state where the fields are absent is the same lie in a different costume. +**Done 2026-08-02.** The renderer moved to `tools/cb-play/src/inspect.rs` +and now covers **41 of the 42 leaf paths** in a populated `GroundView`. +The one omission is `players.*.hand` — `null` for a non-viewer seat, +where the absence is what `hand N card(s)` renders. + +**Measured, not counted** (T03): the old renderer produced 24 of the 41 +tokens. One of the 17 it missed, `outcome.winners.*`, it did in fact +show — in a different format (`["P1", "P2"]` rather than `winners P1, +P2`), so the token misses it. **16 fields were genuinely absent**, and +that is the number to quote. The commit message for this task says +"42 of 43, up from 24"; both figures were counted by hand before the +measurement and both were wrong. + +`every_view_field_is_classified` walks the serialized view for leaf +*paths* (not keys — `problem` occurs under a DARVO target, a GROUND +choice and a Selection, and a key-set walk would let one vouch for the +other two). Four controls, each run and each red for its stated reason: + +| control | result | +|---|---| +| a field deleted from `render` | `players.*.protection is claimed rendered, but the output has no "protect 2"` | +| a field present in neither list | fired **for real on the first run** — `players.*.hand` | +| the walk returns no paths (EXPECT-VACUOUS) | `the walk found 0 leaf path(s) — it is not walking the view` | +| a classified path that no longer exists | `classified path(s) no longer exist in GroundView: ["outcome.nonexistent"]` | + +The second is the one worth keeping: the gate caught a field I had missed +while writing the gate, before it had ever been committed. + ## Task: `cb-play --inspect` walks a recorded game ```task id: CB-WP-0011-T02 -status: todo +status: done priority: high +state_hub_task_id: "08f8a9c7-3dfe-4f72-b510-df9efa457370" ``` Stage 1 asks for a *debug inspector*. The inspector above renders one @@ -137,12 +168,42 @@ must not show anyone else's. The stage-0 test that checked this was vacuous twice before it held (CB-EV-0007); seat the assertion where the hidden thing is actually hidden. +**Done 2026-08-02.** `cb-play --inspect PATH [--as SEAT|spectator]` +walks a `.cbreplay` bundle or a scenario YAML and renders the table after +every step. + +The design decision worth recording: `replay::replay` was split, and its +bundle reader extracted as `replay::open`, so **the inspector walks +through the same reader the replay gate uses** — including the control +that makes the recorded seed load-bearing. A second reader would let the +inspector show states a replay never reached, and it would be a +duplicated fact in the one place where being wrong is silent. The hash is +then asserted anyway, because a structural argument that is never +executed is the class of claim this project keeps finding to be wrong. + +The two modes take **disjoint** flags in both directions +(`--inspect --seed 9` and a bare `--as 2` are both refused, exit 64): a +flag accepted and ignored is how a user comes to believe they inspected +seed 9 when they inspected whatever the recording holds. + +Three controls, each red for its stated reason: + +| control | result | +|---|---| +| the hash comparison removed | `a_walk_that_does_not_reproduce_fails` fails — the tampered bundle walks clean | +| `Eyes::Seat` resolved to `Viewer::Spectator` | `P3 shows a hand in every table and nobody else does` | +| the per-step render dropped | `one table per step plus the opening one` | + +The third is the one that matters: without it a walk that rendered +nothing at all would still have reported a matching hash and passed. + ## Task: evidence, and what the chaos roll cost ```task id: CB-WP-0011-T03 -status: todo +status: done priority: medium +state_hub_task_id: "db722865-1233-4918-952d-0bf4761199e4" ``` Write `evidence/CB-EV-0009-inspectable-table.md` covering: @@ -164,3 +225,12 @@ Write `evidence/CB-EV-0009-inspectable-table.md` covering: lands; the port half is untouched and INTENT's stage-1 line stays open. Marking a stage complete because part of it works is the failure mode stage 0 avoided by leaving the CLI player open for three passes. + +**Done 2026-08-02.** [CB-EV-0009](../evidence/CB-EV-0009-inspectable-table.md). +The headline: tier S did **not** produce a worse outcome, and the reason +is not that tier L was unnecessary — the roll forced a decomposition that +found a defect the survey would have walked past. Recorded as the CHAOS +gate's first `caught` entry, with the window left open. + +Also on record, against my own prior: I recommended tier L in the turn +before the roll, and the roll was right.