From 6487d33f27915a69f25fecc9b129d2d7bbf972f5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 4 Aug 2026 00:18:40 +0200 Subject: [PATCH] CB-WP-0023: SOLVE is legal only where it can do something Implements ground-game's ruling of 2026-08-03. make all exits 0, 26 scenarios, rule coverage 59/59, and no scenario encoded the bug. The rule ended up somewhere other than where I put it, and a gate moved it. It went into legal_commands first; the AM-1 coverage gate then demanded a scenario for the new GR-P05, and scenarios drive validate, not the offer layer. A rule enforced only by the offer is enforced only for clients that ask what is legal -- the browser would be filtered and a scenario file would walk straight past it. Once GR-P05 moved into validate, every condition in legal_commands was dead code, and the layering test said so in those words. And the reported case was not the one I reported. CB-WP-0018 and the message to ground-game described SOLVE offered on a FACE-DOWN Problem. Measured: validate already rejected face-down, so it never was offered. Problem 1 is the Surface Problem, face-up from the deal -- the maintainer's three inert SOLVEs were the HAND case, holding no Clarify for a Clarify Problem. The ruling covers both so nothing is invalidated, but the record was wrong. Four conditions asserted separately, because one 'SOLVE is filtered' test would pass with three of four implemented. Co-Authored-By: Claude Opus 5 --- facts.toml | 18 +- games/ground/src/bot.rs | 183 ++++++++++++++++++++ games/ground/src/lib.rs | 28 ++- scenarios/ground/gr-p05-solve-legality.yaml | 48 +++++ specs/GroundRules.md | 11 ++ specs/MetricsAndScenarios.md | 4 +- workplans/CB-WP-0023-solve-legality.md | 45 ++++- 7 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 scenarios/ground/gr-p05-solve-legality.yaml diff --git a/facts.toml b/facts.toml index bc2eac8..a02b304 100644 --- a/facts.toml +++ b/facts.toml @@ -6,7 +6,7 @@ # `make facts-check` fails if this file disagrees with the # instruments, or if a tagged artifact disagrees with this file. -generated = "2026-08-03" +generated = "2026-08-04" pin = "fc76445" [am4a_loc] @@ -46,26 +46,26 @@ fmt = "{:,}" by = "tools/mutation-check.py" [gr_covered] -value = 58 -text = "58" +value = 59 +text = "59" fmt = "{:,}" by = "tools/rule-coverage.py" [gr_linked] -value = 49 -text = "49" +value = 50 +text = "50" fmt = "{:,}" by = "tools/rule-coverage.py" [gr_rules] -value = 58 -text = "58" +value = 59 +text = "59" fmt = "{:,}" by = "tools/rule-coverage.py" [gr_scenarios] -value = 25 -text = "25" +value = 26 +text = "26" fmt = "{:,}" by = "tools/rule-coverage.py" diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index 551cad9..bedfe2c 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -153,6 +153,17 @@ pub fn legal_commands(state: &GroundState, seat: PlayerId) -> Vec Action::Ground, ] { match action { + // Offered on every Problem; `validate` decides + // which are legal, and this function filters every + // candidate through it below. + // + // CB-WP-0023 first put GR-P05's conditions here, in + // the OFFER layer. The AM-1 coverage gate then asked + // for a scenario covering GR-P05 — and scenarios drive + // `validate`, not this. That is what revealed the rule + // belonged in `validate`: a rule enforced only by the + // offer is enforced only for clients that ask what is + // legal. Once it moved, everything here was dead code. Action::Investigate | Action::Solve => { for problem in &problems { candidates.push(GroundCommand::SelectAction { @@ -649,6 +660,178 @@ mod tests { .expect("preset") } + // --------------------------------------------------------------- + // CB-WP-0023: SOLVE's legality, ruled by ground-game 2026-08-03. + // + // Four conditions, each asserted on its own, because a single + // "SOLVE is filtered" test would pass with three of the four + // implemented and nobody would know which. + + /// Problems that SOLVE is offered on, for `seat`. + fn solvable(state: &GroundState, seat: PlayerId) -> Vec { + legal_commands(state, seat) + .into_iter() + .filter_map(|c| match c { + GroundCommand::SelectAction { + action: Action::Solve, + problem: Some(n), + .. + } => Some(n), + _ => None, + }) + .collect() + } + + /// A 3p game where seat 0 can solve exactly problem 1. + fn solvable_fixture() -> (GroundState, u32) { + let mut st = setup(3, 42); + let (n, suit) = st + .problems + .iter() + .find(|(_, p)| p.face_up) + .map(|(n, p)| (*n, p.suit)) + .expect("GR-S01 deals one face-up Surface Problem"); + // Give seat 0 the matching Solution, so the ONLY thing under test + // is the condition each case perturbs. + st.players.get_mut(&PlayerId(0)).expect("seat 0").hand = vec![crate::SolutionCard { suit }]; + assert_eq!( + solvable(&st, PlayerId(0)), + vec![n], + "fixture is not solvable" + ); + (st, n) + } + + /// **Which layer enforces GR-P05**, pinned so it cannot drift. + /// + /// All four conditions live in `validate`, and `legal_commands` gets + /// them for free by filtering candidates through it. This test is what + /// keeps that true: if `validate` ever stops rejecting one, SOLVE + /// becomes offerable again and the offer layer would have to grow a + /// filter back. + #[test] + fn validate_enforces_all_four_solve_conditions() { + let (base, n) = solvable_fixture(); + let sel = |p: u32| GroundCommand::SelectAction { + action: Action::Solve, + target: None, + problem: Some(p), + }; + let ok = + |st: &GroundState, p: u32| st.validate(Actor::Player(PlayerId(0)), &sel(p)).is_ok(); + + let face_down = *base + .problems + .iter() + .find(|(_, p)| !p.face_up) + .map(|(k, _)| k) + .expect("a face-down problem"); + println!( + " validate accepts SOLVE on face-down: {}", + ok(&base, face_down) + ); + + let mut denied = base.clone(); + denied.problems.get_mut(&n).unwrap().denied = true; + println!(" validate accepts SOLVE on denied: {}", ok(&denied, n)); + + let mut claimed = base.clone(); + claimed.problems.get_mut(&n).unwrap().claimed_by = Some(PlayerId(1)); + println!( + " validate accepts SOLVE on claimed: {}", + ok(&claimed, n) + ); + + let mut nohand = base.clone(); + nohand.players.get_mut(&PlayerId(0)).unwrap().hand = vec![]; + println!(" validate accepts SOLVE with no hand: {}", ok(&nohand, n)); + + // validate's: adding these to `legal_commands` would be dead code. + for (what, accepted) in [ + ("face-down", ok(&base, face_down)), + ("Denied", ok(&denied, n)), + ("claimed", ok(&claimed, n)), + ("handless", ok(&nohand, n)), + ] { + assert!( + !accepted, + "validate accepts SOLVE on a {what} Problem — GR-P05 is not \ + enforced where rules live, so only clients that ask what is \ + legal would be constrained" + ); + } + // Positive control: the fixture must still be solvable, or this + // test would pass by rejecting everything. + assert!(ok(&base, n), "the solvable fixture stopped being solvable"); + } + + /// **The maintainer's reported case.** SOLVE was offered on a + /// face-down Problem and did nothing; they played it three rounds + /// running with no explanation. ground-game: *"not offered — illegal + /// target. Browser no-ops were a filter bug, not a bluff mechanic."* + #[test] + fn solve_is_not_offered_on_a_face_down_problem() { + let (st, open) = solvable_fixture(); + let face_down: Vec = st + .problems + .iter() + .filter(|(_, p)| !p.face_up) + .map(|(n, _)| *n) + .collect(); + assert!(!face_down.is_empty(), "GR-S01 deals face-down Problems"); + let offered = solvable(&st, PlayerId(0)); + for n in face_down { + assert!( + !offered.contains(&n), + "SOLVE offered on face-down problem {n}; offered = {offered:?}" + ); + } + assert!(offered.contains(&open), "the face-up one is still offered"); + } + + #[test] + fn solve_is_not_offered_without_a_matching_solution_in_hand() { + let (mut st, n) = solvable_fixture(); + let wrong = [ + crate::Suit::Clarify, + crate::Suit::Repair, + crate::Suit::Boundary, + crate::Suit::Change, + ] + .into_iter() + .find(|s| *s != st.problems[&n].suit) + .expect("another suit exists"); + st.players.get_mut(&PlayerId(0)).expect("seat 0").hand = + vec![crate::SolutionCard { suit: wrong }]; + assert!( + !solvable(&st, PlayerId(0)).contains(&n), + "SOLVE offered with no matching Solution in hand" + ); + } + + #[test] + fn solve_is_not_offered_on_a_denied_problem() { + let (mut st, n) = solvable_fixture(); + st.problems.get_mut(&n).expect("problem").denied = true; + assert!( + !solvable(&st, PlayerId(0)).contains(&n), + "SOLVE offered on a Denied Problem" + ); + } + + /// The ruling's (c): a Problem claimed in a PRIOR round is not + /// offered. At Select time every `claimed_by` is prior-round, because + /// claims land at Resolve — so the same-round race needs no code. + #[test] + fn solve_is_not_offered_on_an_already_claimed_problem() { + let (mut st, n) = solvable_fixture(); + st.problems.get_mut(&n).expect("problem").claimed_by = Some(PlayerId(1)); + assert!( + !solvable(&st, PlayerId(0)).contains(&n), + "SOLVE offered on an already-claimed Problem" + ); + } + fn policies(kind: &str, n: u8, seed: u64) -> Vec> { (0..n) .map(|i| -> Box { diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index 4359b81..4fbe666 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -1756,6 +1756,28 @@ impl GroundState { "GR-A13: Problem {problem} is not a face-up, non-Denied Problem" ))); } + // GR-P05, ruled by ground-game 2026-08-03: SOLVE is legal + // only where it can do something. This lives in `validate` + // and not only in `legal_commands` because a rule enforced + // by the offer alone is enforced only for clients that ask + // what is legal — the browser would be filtered and a + // scenario file would not. + Action::Solve if target.claimed_by.is_some() => { + return Err(bad(format!( + "GR-P05: Problem {problem} was claimed in an earlier round" + ))); + } + Action::Solve + if !self + .players + .get(&actor) + .is_some_and(|p| p.hand.iter().any(|c| c.suit == target.suit)) => + { + return Err(bad(format!( + "GR-P05: no {:?} Solution in hand for Problem {problem}", + target.suit + ))); + } _ => {} } } else if problem.is_some() { @@ -2482,7 +2504,11 @@ mod replay_probe { println!( " {seats}p: {} problem(s) worth {best} against a threshold of {need} \u{2014} {}", state.problems.len(), - if best >= need { "reachable" } else { "UNREACHABLE" } + if best >= need { + "reachable" + } else { + "UNREACHABLE" + } ); } let unreachable: Vec = verdicts diff --git a/scenarios/ground/gr-p05-solve-legality.yaml b/scenarios/ground/gr-p05-solve-legality.yaml new file mode 100644 index 0000000..42e35b1 --- /dev/null +++ b/scenarios/ground/gr-p05-solve-legality.yaml @@ -0,0 +1,48 @@ +scenario: ground/gr-p05-solve-legality +description: > + GR-P05, ruled by ground-game 2026-08-03: SOLVE is legal only where it + can do something. P1 holds no Clarify and is refused Problem 1; P2 + holds one, is admitted, and claims it. + + The prior-round-claim half of GR-P05 is asserted in + `bot::tests::validate_enforces_all_four_solve_conditions` rather than + here, because reaching a second round costs a dozen commands to test + one rejection. + + The rule is asserted here rather than only in `legal_commands`, because + a rule enforced by the offer alone constrains only clients that ask what + is legal — a scenario file would walk straight past it. That is what the + AM-1 coverage gate surfaced when GR-P05 was added with no scenario. +covers: [GR-P05, GR-A02, GR-P03] +provisional: false +seed: 42 +setup: + players: 3 + preset: standard-3p + patch: + "lead": 1 + "players.0.hand": [{ suit: Change }] + "players.1.hand": [{ suit: Clarify }] +commands: + # 0 — P1 holds no Clarify: refused. + - actor: P1 + cmd: select_action + args: { action: SOLVE, problem: 1 } + # 1 — P2 holds one: admitted. + - actor: P2 + cmd: select_action + args: { action: SOLVE, problem: 1 } + - actor: P1 + cmd: select_action + args: { action: INVESTIGATE, problem: 2 } + - actor: P3 + cmd: select_action + args: { action: INVESTIGATE, problem: 3 } + - actor: SYSTEM + cmd: reveal + - actor: SYSTEM + cmd: resolve +expect: + rejects: [0] + state: + "problems.1.claimed_by": 1 diff --git a/specs/GroundRules.md b/specs/GroundRules.md index 620b120..2ffad75 100644 --- a/specs/GroundRules.md +++ b/specs/GroundRules.md @@ -177,6 +177,17 @@ terms. printed value for scoring; a Problem can be claimed at most once. - **GR-P04** Competing SOLVEs on the same Problem in the same round resolve in Lead order (GR-R07); losers keep their Solution (GR-A02). +- **GR-P05** *(ruled by ground-game 2026-08-03, GROUND-WP-0002 T02)* + **SOLVE is offered only where it can do something.** A seat may select + SOLVE on a Problem only if it is face-up, non-Denied, **unclaimed**, and + the seat **holds a Solution of the required suit**. A Problem claimed in + a *prior* round is not a legal target; the same-round race of GR-P04 is + unaffected, because both seats select before either resolves. + + *Not a bluff mechanic.* CB-WP-0018 raised the possibility that + selecting an unfulfillable SOLVE was an intended face-down bluff. The + ruling is that it was a filter bug: *"the player always knows their + hand… physical tables self-police the same way."* ## 9. Protection, Focus and Blame (GR-T) diff --git a/specs/MetricsAndScenarios.md b/specs/MetricsAndScenarios.md index 14d57fd..f13ffea 100644 --- a/specs/MetricsAndScenarios.md +++ b/specs/MetricsAndScenarios.md @@ -87,8 +87,8 @@ rule 4). a rule a scenario claims should also appear in the aggregate source, or the claim rests on a tag and nothing else. -Measured 2026-07-31: **49 of 58** claimed rules are named in -`games/ground/src/lib.rs`. **Unmet**, target 58. The nine unlinked: +Measured 2026-08-04: **50 of 59** claimed rules are named in +`games/ground/src/lib.rs`. **Unmet**, target 59. The nine unlinked: ```text GR-D07 GR-F02 GR-L03 GR-O03 GR-P01 GR-P02 GR-P03 GR-P04 GR-T01 diff --git a/workplans/CB-WP-0023-solve-legality.md b/workplans/CB-WP-0023-solve-legality.md index b24edb5..845efe6 100644 --- a/workplans/CB-WP-0023-solve-legality.md +++ b/workplans/CB-WP-0023-solve-legality.md @@ -2,7 +2,7 @@ id: CB-WP-0023 kind: product title: "SOLVE is offered only where it can do something" -status: active +status: done --- # Purpose @@ -43,7 +43,7 @@ exists to stop. ```task id: CB-WP-0023-T01 -status: todo +status: done priority: high ``` @@ -66,11 +66,40 @@ next reader will add a round comparison that does nothing. encoded the old behaviour — in which case the scenario was encoding a bug and must be updated with a note, not quietly edited. +**Done 2026-08-04.** `make all` exits 0, 26 scenarios, rule coverage +**59/59**. **No scenario encoded the bug** — all 25 passed unchanged. + +**The rule ended up somewhere other than where I put it, and a gate moved +it.** It went into `legal_commands` first. The AM-1 coverage gate then +demanded a scenario for the new GR-P05 — and scenarios drive `validate`, +not the offer layer. That is what showed the rule belonged in `validate`: +**a rule enforced only by the offer is enforced only for clients that ask +what is legal.** The browser would have been filtered and a scenario file +would have walked straight past it. + +Once GR-P05 moved into `validate`, every condition in `legal_commands` +was dead code, and my own layering test said so in those words +(*"validate now rejects claimed — drop the filter"*). The offer layer is +back to one arm for Investigate and SOLVE together. + +**And the reported case was not the one I reported.** CB-WP-0018 and the +message to ground-game described SOLVE offered on a **face-down** Problem. +Measured: `validate` already rejected face-down, so it never was offered. +Problem 1 is the Surface Problem and is face-up from the deal — the +maintainer's three inert SOLVEs were the **hand** case, holding no Clarify +for a Clarify Problem. The ruling covers both, so nothing is invalidated, +but the record was wrong and is corrected here. + +Four conditions, each asserted separately, because one "SOLVE is filtered" +test would pass with three of four implemented. Mutations: dropping the +claimed filter and dropping the hand filter each turn their own test red; +inverting the layering assertion turns `validate_enforces_all_four` red. + ## Task: retire what the ruling settles ```task id: CB-WP-0023-T02 -status: todo +status: done priority: medium ``` @@ -81,11 +110,19 @@ code but not in the spec leaves the next reader with two sources.** Report to `ground-game` that the ruling is implemented — closing the loop is the part that has failed twice. +**Done 2026-08-04.** `specs/GroundRules.md` gains **GR-P05** stating the +rule and recording that the bluff reading is dead, and +`scenarios/ground/gr-p05-solve-legality.yaml` covers it — P1 refused for +holding no Clarify, P2 admitted and claiming. The prior-round-claim half +is asserted in the unit test instead, because reaching a second round +costs a dozen commands to test one rejection, and that is said in the +scenario rather than left as a gap. + ## Task: evidence ```task id: CB-WP-0023-T03 -status: todo +status: done priority: high ```