diff --git a/WORK-RECORDS.md b/WORK-RECORDS.md index 44608b8..302b682 100644 --- a/WORK-RECORDS.md +++ b/WORK-RECORDS.md @@ -14,11 +14,10 @@ | 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 | done | — | workplans/CB-WP-0007-session-shape.md | +| workplan | CB-WP-0007 | in_progress | — | 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 | done | — | workplans/CB-WP-0010-consolidation.md | -| workplan | CB-WP-0011 | todo | — | workplans/CB-WP-0011-inspectable-table.md | +| workplan | CB-WP-0010 | proposed | — | workplans/CB-WP-0010-consolidation.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 | @@ -73,8 +72,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 | cancel | — | workplans/CB-WP-0007-session-shape.md | -| task | CB-WP-0007-T06 | 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-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 | @@ -83,9 +82,6 @@ | 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 | 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 | +| 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 | diff --git a/crates/cb-game-runtime/src/replay.rs b/crates/cb-game-runtime/src/replay.rs index 00ab0e5..a42679c 100644 --- a/crates/cb-game-runtime/src/replay.rs +++ b/crates/cb-game-runtime/src/replay.rs @@ -133,18 +133,12 @@ pub fn write_bundle( Ok(bundle) } -/// Open a bundle for re-execution: the manifest, the restored initial -/// state, and the recorded steps in order. +/// Re-execute a bundle and require it to reproduce the recorded hash. /// -/// 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> +/// 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, { @@ -171,33 +165,17 @@ where &std::fs::read(bundle.join(INITIAL)).map_err(|e| err("read initial", e))?, ) .map_err(|e| err("parse initial", e))?; - let state: G = serde_json::from_value(initial).map_err(|e| err("restore initial", e))?; + let mut 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 step in &steps { - let (actor, command) = G::parse_command(step)?; + 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)?; 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 deleted file mode 100644 index 9f6237b..0000000 --- a/evidence/CB-EV-0009-inspectable-table.md +++ /dev/null @@ -1,191 +0,0 @@ -# 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 29b43be..111de34 100644 --- a/gates.toml +++ b/gates.toml @@ -120,9 +120,7 @@ target = "" checks = "d4 on each tier declaration, 12-declaration calibration window" added = "2026-07-30" review_by = "2026-09-30" -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)", -] +caught = [] 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 deleted file mode 100644 index 6b2466d..0000000 --- a/tools/cb-play/src/inspect.rs +++ /dev/null @@ -1,878 +0,0 @@ -//! 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 e90066d..eabf83f 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -9,17 +9,14 @@ //! 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] [--record FILE] - cb-play --inspect PATH [--as SEAT|spectator] + [--replay DIR] [--all-bots] -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) @@ -27,34 +24,10 @@ play: --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. "; -/// 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 { +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; @@ -67,21 +40,18 @@ 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() @@ -90,54 +60,25 @@ 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)); } @@ -154,12 +95,12 @@ fn parse_args(argv: &[String]) -> Result { config.players )); } - Ok(Mode::Play(config)) + Ok(config) } fn main() { let argv: Vec = std::env::args().skip(1).collect(); - let mode = match parse_args(&argv) { + let config = match parse_args(&argv) { Ok(c) => c, Err(message) => { eprintln!("{message}"); @@ -167,33 +108,6 @@ 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()) { @@ -350,43 +264,21 @@ 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 = play_args(&["--seed", "9", "--players", "4", "--seat", "2"]).unwrap(); + let c = parse_args(&args(&["--seed", "9", "--players", "4", "--seat", "2"])).unwrap(); assert_eq!((c.seed, c.players, c.human_seats.clone()), (9, 4, vec![2])); - assert_eq!(play_args(&[]).unwrap().human_seats, vec![0]); - assert!(play_args(&["--all-bots"]).unwrap().human_seats.is_empty()); + assert_eq!(parse_args(&args(&[])).unwrap().human_seats, vec![0]); + assert!(parse_args(&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 66b4a39..a9a6fb2 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -16,9 +16,8 @@ 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 { @@ -59,11 +58,19 @@ pub struct Summary { } // ------------------------------------------------------------- rendering -// -// 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 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) +} fn describe(command: &GroundCommand) -> String { // Reuse the recorder's vocabulary rather than inventing a third one: @@ -83,6 +90,109 @@ 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 4c997f4..9e57a3d 100644 --- a/workplans/CB-WP-0011-inspectable-table.md +++ b/workplans/CB-WP-0011-inspectable-table.md @@ -2,8 +2,7 @@ id: CB-WP-0011 kind: product title: "Stage 1, first slice: an inspector that shows everything" -status: done -state_hub_workstream_id: "1142442a-65f1-483a-800d-6ca8490c5f1a" +status: todo --- # Purpose @@ -76,9 +75,8 @@ form. ```task id: CB-WP-0011-T01 -status: done +status: todo priority: high -state_hub_task_id: "b8cb4c20-9d06-47a4-890c-bf8390015439" ``` Extend `render` to cover every field of `GroundView`, and — the load- @@ -109,41 +107,12 @@ 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: done +status: todo priority: high -state_hub_task_id: "08f8a9c7-3dfe-4f72-b510-df9efa457370" ``` Stage 1 asks for a *debug inspector*. The inspector above renders one @@ -168,42 +137,12 @@ 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: done +status: todo priority: medium -state_hub_task_id: "db722865-1233-4918-952d-0bf4761199e4" ``` Write `evidence/CB-EV-0009-inspectable-table.md` covering: @@ -225,12 +164,3 @@ 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.