From 3045eb03f8721bc3341e6f5fe5163ee587bf04f3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 8 Aug 2026 23:31:11 +0200 Subject: [PATCH] CB-WP-0049 T02/T03: a seat that plays its objective, and F27 splits in two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objective() reads GroundState::score (now public) rather than restating what winning is; a copy in the bot would disagree with the kernel the first time ground-game rules on F28. Working out WHERE the modes can differ was most of the task and it bounds the result: SOLVE always claims for the actor, so own-score and group-score want the same SOLVE nearly everywhere. That is a fact about GROUND's action set, not a shortcoming of the bot. Two real divergences, both readable off the table: SUPPORT regulates someone else (worth less against a rival, worth MORE under coalitions where a Bond merges them into my side), and SOLVE's value is the card's value, which greedy ignores entirely. THE RESULT — F27 splits in two: group success UNCHANGED in 34 of 36 cells who wins MOVES: BONDED COALITIONS at 4p goes 2.04 -> 2.98, 2.12 -> 3.29, 2.05 -> 3.01 winning seats per game So "the competitive modes are scoring lenses over cooperative play" was too strong and is withdrawn. The sharper claim: GROUND's scoring modes change WHO WINS, not WHETHER THE GROUP SUCCEEDS. And the effect is seat-band dependent -- 2p none, 4p largest, 6p none under coalitions; two relation slots capping network growth is a candidate explanation and is untested. The panel now prints BOTH policies side by side. That was a correction mid-task: the first version printed only the new one and I compared it against a figure remembered from CB-WP-0047 -- a comparison against a board nobody re-ran. Control that makes the numbers mean anything: under SHARED GROUND the two policies agree at all but <=2 decision points across 12 boards, so a moving column is mode-awareness and not simply a different bot. Also: two T01 tests keyed on `status: proposed`, which ground-game renamed to `ready-for-implement` mid-session. They now find the module by asking resolve() -- the structural property is ours and does not move when another repo edits its vocabulary. Also: `make vendor` replaces three hand re-vendors with a tool that regenerates digests by walking editions/, and reports one-sided files rather than resolving them. Co-Authored-By: Claude Opus 5 --- Makefile | 8 + editions/ASPECTS.md | 2 +- editions/CATALOG.md | 4 +- editions/catalog.yaml | 20 +- editions/ground-darvo-r0/PROVENANCE.md | 14 +- .../hybrid_clear_collapse/MODULE.md | 64 +++- .../hybrid_clear_collapse/rules_delta.yaml | 12 +- .../problem_deal/pressure_deck/MODULE.md | 77 ++++- .../pressure_deck/rules_delta.yaml | 29 +- games/ground/examples/scenario-panel.rs | 39 ++- games/ground/src/bot.rs | 280 +++++++++++++++++- games/ground/src/config.rs | 70 ++--- games/ground/src/lib.rs | 10 +- specs/FindingRegister.md | 31 +- tools/vendor-editions.py | 210 +++++++++++++ ...049-a-seat-that-plays-its-own-objective.md | 83 +++++- 16 files changed, 859 insertions(+), 94 deletions(-) create mode 100644 tools/vendor-editions.py diff --git a/Makefile b/Makefile index 54150bc..03a0243 100644 --- a/Makefile +++ b/Makefile @@ -88,6 +88,14 @@ ground: --record trials/$(TRIAL_NAME).yaml \ --trial trials/$(TRIAL_NAME).md $(ARGS) +## re-vendor the edition mirror from ground-game and record its digests +## The mirror goes stale whenever ground-game is edited. This syncs it +## and REGENERATES the digest block by walking editions/ — never by +## hand, which is what drifts. It reports files only one side has +## rather than resolving them: that is a decision, not a sync. +vendor: + @$(PY) $(TOOLS)/vendor-editions.py + ## fmt + clippy (deny warnings) + HashMap deny-lint check: $(IN_REPO) $(CARGO) fmt --all --check diff --git a/editions/ASPECTS.md b/editions/ASPECTS.md index b848813..b449014 100644 --- a/editions/ASPECTS.md +++ b/editions/ASPECTS.md @@ -86,7 +86,7 @@ Aspects marked **product** may never need a kernel module. |-----------|----------|--------------|----------| | `scenario_fiction` | Thematic conflict | Scenarios.csv | content, not rules module | | `safety_teaching_frame` | How DARVO is framed | Rules_Text safety; INTENT | product | -| `difficulty_accessibility` | Learning vs mastery | WP-0005; H2 scope mix as dial | future modules | +| `difficulty_accessibility` | Learning vs mastery | **WP-0005 finished:** Standard thresholds only; dial via `problem_stress` / `problem_deal` modules, not Learning/Mastery threshold cards | future if needed | | `medium` | Print vs digital | Edition CSV vs clay-borg | product | --- diff --git a/editions/CATALOG.md b/editions/CATALOG.md index 8962bbb..a32ce58 100644 --- a/editions/CATALOG.md +++ b/editions/CATALOG.md @@ -47,8 +47,8 @@ |--------|---------|----------------| | `problem_stress` | `none` | `flat_any_open` (H1-A, reject), `scoped` (H2, keep) | | `attack_relief` | `none` | `self_soothe_ge4` (H1-B) | -| `end_condition` | `fixed_rounds_5` | `hybrid_clear_collapse` (**proposed**) | -| `problem_deal` | `fixed_setup` | `pressure_deck` (**proposed**) | +| `end_condition` | `fixed_rounds_5` | `hybrid_clear_collapse` (**ready-for-implement**) | +| `problem_deal` | `fixed_setup` | `pressure_deck` (**ready-for-implement**) | Many other aspects exist but are **fixed in r0** until a competing design appears (see ASPECTS.md § full map). diff --git a/editions/catalog.yaml b/editions/catalog.yaml index 56cb732..3e6cb48 100644 --- a/editions/catalog.yaml +++ b/editions/catalog.yaml @@ -152,14 +152,16 @@ modules: path: editions/modules/end_condition/hybrid_clear_collapse is_default: false selectable: true - status: proposed + status: ready-for-implement rules_delta: editions/modules/end_condition/hybrid_clear_collapse/rules_delta.yaml hypothesis_ref: history/260808-deal-end-sequences-design.md summary: > - End on board clear, group collapse, or round ceiling (draft rules). - utility_estimate: Unimplemented — draft only. + End on board clear, group collapse, or round-5 ceiling (v0 frozen). + utility_estimate: Unimplemented in kernel — package ready for clay-borg. decision: none - clay_borg_notes: Kernel pending; do not claim measured until implemented. + clay_borg_notes: > + v0 frozen in MODULE.md. Implement alone then profile scoped_plus_hybrid_end. + allow_proposed/ready-for-implement: refuse measure-as-green until kernel ships. # --- problem_deal --- - module_id: problem_deal.fixed_setup @@ -177,16 +179,16 @@ modules: path: editions/modules/problem_deal/pressure_deck is_default: false selectable: true - status: proposed + status: ready-for-implement rules_delta: editions/modules/problem_deal/pressure_deck/rules_delta.yaml hypothesis_ref: history/260808-deal-end-sequences-design.md summary: > - Small start set + mid-game draws from Pressure deck (draft). - utility_estimate: Unimplemented — draft only. + Reduced starters + Pressure deck draws; drawn cards 0 points (v0 frozen). + utility_estimate: Unimplemented in kernel — package ready for clay-borg. decision: none clay_borg_notes: > - Kernel pending. Recommended compose with problem_stress.scoped. - v0 draft uses 0-point drawn cards. + v0 frozen in MODULE.md. Drawn point_value 0; thresholds unchanged. + Compose with problem_stress.scoped via profile scoped_plus_pressure_deck. # --------------------------------------------------------------------------- # Profiles — named compositions (convenience; not a second rules source) diff --git a/editions/ground-darvo-r0/PROVENANCE.md b/editions/ground-darvo-r0/PROVENANCE.md index 011041d..3d3fd87 100644 --- a/editions/ground-darvo-r0/PROVENANCE.md +++ b/editions/ground-darvo-r0/PROVENANCE.md @@ -58,9 +58,9 @@ the adversarial review ([CB-REV-0001](../../reviews/CB-REV-0001-h1.md)) reported it unverified and it was a real gap. ``` -sha256 84d43ac110615a678276c318a64a26d9996a9f3f2edbb1b72c38c3e12cf68149 ../ASPECTS.md -sha256 684abb9856e81d456eb214efc7bcfc643e06200451daae1653c528a8b93f5c66 ../CATALOG.md -sha256 297c36c67f19a3ccdab152c928f94ff39c861c7ce836c9dfc10a773a7fe51ee2 ../catalog.yaml +sha256 536ad836991804e8d96eff20ae549654ecb82e28cdd884914730b70e3c387ee3 ../ASPECTS.md +sha256 5b5f1b73a6fa0f7a8d9ef2061670efc1c309d69774dea9e0deeb3fbce0a399b9 ../CATALOG.md +sha256 2adedb8c28570c33e126484c472d8b097067f50358f2ac523f1b3c976e66947a ../catalog.yaml sha256 7b1cc0149122b855e827bc930576ed165bf7dd8d62707e845a9e514ce3521f8e ../experiments/h1-problem-stress/Actions.csv sha256 62785f5e7e245c60171624d15de2f40187a44fec54f93c7d9705cf52584b1078 ../experiments/h1-problem-stress/Rules_Text.csv sha256 49897a68056643a8cfccff32c9e4a9811018b4e4689b90a85124a487f2318369 ../experiments/h1-problem-stress/VARIANT.md @@ -75,11 +75,11 @@ sha256 54678e312332ab999826710ae30565c0bba4da76b781ba4f391ace0c4341e484 ../mod sha256 0eec2ef761c5f6548085156248f182fa077ac5aee7c657bd6bfa6ba22d53446b ../modules/attack_relief/self_soothe_ge4/MODULE.md sha256 91463363a482219109bb6d5033019135b5b10505f55f6272b912c7a51cd90173 ../modules/attack_relief/self_soothe_ge4/rules_delta.yaml sha256 a02aa5ee860aca71d3e7c19975dd5eb1c66d7c8a0f3d3ceca8dd9914b85d6c7e ../modules/end_condition/fixed_rounds_5/MODULE.md -sha256 6d7d1f5b1d629b61dc35299606393cdac969379bb6207b5c1e741ddbbf50c06e ../modules/end_condition/hybrid_clear_collapse/MODULE.md -sha256 8dda4def373cd4aaaefdf764c915ad54ae7a1b4d885ee2cd623e81f752fdb907 ../modules/end_condition/hybrid_clear_collapse/rules_delta.yaml +sha256 b1706aa3a10bace4fac5854eaa73848bba331cc9720da4b162e09db415631a50 ../modules/end_condition/hybrid_clear_collapse/MODULE.md +sha256 66eca80a673583499da2cfef01439ebf511401b5af9fad6a9ee790b0006e98b8 ../modules/end_condition/hybrid_clear_collapse/rules_delta.yaml sha256 5b809680afdf9282a457f8d7f70b111bf18590f4867622e332f49e1ee4a23b08 ../modules/problem_deal/fixed_setup/MODULE.md -sha256 d8ddf74127aa6adf32be6a9e30dac4e6b9ff14da1a9e0bb74d2faf22794e8a57 ../modules/problem_deal/pressure_deck/MODULE.md -sha256 c05addd208e9d2c287a8da8a2112e24a6b30aa163b19fe763362eae59598ce30 ../modules/problem_deal/pressure_deck/rules_delta.yaml +sha256 ca656820336e37b72fdd2409c15f54102c4f7daf9ab0a0b94fc1f30119b1c727 ../modules/problem_deal/pressure_deck/MODULE.md +sha256 1845ee0d3ec48ff6df31fca6f6acb1cad8b362c65ca7e7d93dfd61e0426fcdbd ../modules/problem_deal/pressure_deck/rules_delta.yaml sha256 a5e66fd60abef1cf965b0b20892548c65f4005c45f4d44c025e4756eff579897 ../modules/problem_stress/flat_any_open/MODULE.md sha256 95fa5d4ad811407eda1cf31d99d5dd984e9d1d9af8c284e10df598874d0f06e3 ../modules/problem_stress/flat_any_open/rules_delta.yaml sha256 1e9cb6e2dac046a9f48beccb0ddc09c41608306711320159ecdce3572562e454 ../modules/problem_stress/none/MODULE.md diff --git a/editions/modules/end_condition/hybrid_clear_collapse/MODULE.md b/editions/modules/end_condition/hybrid_clear_collapse/MODULE.md index 566c1bd..2624208 100644 --- a/editions/modules/end_condition/hybrid_clear_collapse/MODULE.md +++ b/editions/modules/end_condition/hybrid_clear_collapse/MODULE.md @@ -3,14 +3,62 @@ | | | |---|---| | **aspect** | `end_condition` | -| **role** | End on **board clear**, **group collapse**, or **max rounds** (default max 5) | -| **status** | proposed — not yet implemented in kernel | +| **module_id** | `end_condition.hybrid_clear_collapse` | +| **role** | End on **board clear**, **group collapse**, or **max rounds** | +| **status** | **ready-for-implement** (draft frozen for v0 kernel) | +| **rules_delta** | [`rules_delta.yaml`](rules_delta.yaml) | +| **design** | [history/260808-deal-end-sequences-design.md](../../../history/260808-deal-end-sequences-design.md) | -## Draft rules (not frozen) +Independent of `problem_stress` / `problem_deal` / `attack_relief`. Measure +**alone** first, then profile `scoped_plus_hybrid_end`. -1. **Clear:** after Solve, if no unclaimed Problems remain → end game; apply mode scoring (threshold auto-success if claimed value ≥ threshold). -2. **Collapse:** if no seat has Stress ≤ 3 **or** majority of seats are in active DARVO → end as group failure (SHARED) / no personal winners (semi/coalition). -3. **Ceiling:** if Round Max (5) completes without (1) or (2) → current threshold scoring. +## Frozen v0 rules -**Compose with** any problem_stress / deal / attack_relief. -**Kernel:** not shipped — package `rules_delta.yaml` when implemented. +### END-CLEAR + +After the **Solve** step: if **zero** unclaimed Problems remain in play → +**end the game immediately**. + +- Apply mode scoring as usual. +- **Threshold:** if total claimed value ≥ scenario threshold for seat band → + group success (SHARED mastery / semi-coalition personal winners as normal). +- If claimed value < threshold but board is empty (should be rare) → group + **failure** (nothing left to claim). + +### END-COLLAPSE + +After Round End stress clamp (and problem-stress ticks if any module active), +before Lead rotation: + +**Collapse** if **either**: + +1. **No seat has Stress ≤ 3**, or +2. **Majority** of seats are in an **active DARVO sequence** (stage ≠ OFF). + +Effect: + +- End the game. +- **Group success = false** (SHARED fails). +- Semi / coalition: **no personal / coalition winners** (table broke). + +### END-CEILING + +If Round **5** completes without CLEAR or COLLAPSE → end with current +threshold + mode scoring (identical to baseline fixed-rounds behaviour). + +Round max is **5** in v0 (same product length as r0). Changing max is a +different module later if needed. + +## Non-goals (v0) + +- Soft collapse (one seat at 5 only). +- Extending rounds when board not clear. +- Mode-specific collapse exceptions. + +## Success criteria (when clay-borg implements) + +1. Alone on baseline content: some games end before round 5 (clear or collapse). +2. Mean game length ≤ 5 rounds; no hangs. +3. With `problem_stress.scoped`: collapse rate non-zero for unregulated policies; + clear rate non-zero for greedy SHARED at 3–4p. +4. Does not recreate H1-style 0% greedy wins solely via collapse spam. diff --git a/editions/modules/end_condition/hybrid_clear_collapse/rules_delta.yaml b/editions/modules/end_condition/hybrid_clear_collapse/rules_delta.yaml index 25e521d..1682848 100644 --- a/editions/modules/end_condition/hybrid_clear_collapse/rules_delta.yaml +++ b/editions/modules/end_condition/hybrid_clear_collapse/rules_delta.yaml @@ -1,10 +1,11 @@ -# module: end_condition.hybrid_clear_collapse — PROPOSED +# module: end_condition.hybrid_clear_collapse +# status: ready-for-implement (v0 rules frozen in MODULE.md) module_id: end_condition.hybrid_clear_collapse aspect: end_condition schema_version: 1 base: ground-darvo-r0 -status: proposed -implementation: pending +status: ready-for-implement +implementation: pending_kernel deltas: - id: END-CLEAR @@ -15,7 +16,8 @@ deltas: effect: end_game: true scoring: mode_as_usual - note: threshold treated as met if claimed_value >= threshold + group_success_if_claimed_ge_threshold: true + group_fail_if_claimed_lt_threshold: true - id: END-COLLAPSE name: early_end_group_collapse @@ -30,7 +32,7 @@ deltas: personal_winners: none - id: END-CEILING - name: max_rounds_unchanged + name: max_rounds_five phase: after_round when: round_completed: 5 diff --git a/editions/modules/problem_deal/pressure_deck/MODULE.md b/editions/modules/problem_deal/pressure_deck/MODULE.md index 2987bd7..a0e07e2 100644 --- a/editions/modules/problem_deal/pressure_deck/MODULE.md +++ b/editions/modules/problem_deal/pressure_deck/MODULE.md @@ -3,14 +3,75 @@ | | | |---|---| | **aspect** | `problem_deal` | -| **role** | Start with a small set; draw additional scoped Problems mid-game from a Pressure deck | -| **status** | proposed — not yet implemented | +| **module_id** | `problem_deal.pressure_deck` | +| **role** | Small start set + mid-game **Pressure deck** draws | +| **status** | **ready-for-implement** (draft frozen for v0 kernel) | +| **rules_delta** | [`rules_delta.yaml`](rules_delta.yaml) | +| **design** | [history/260808-deal-end-sequences-design.md](../../../history/260808-deal-end-sequences-design.md) | -## Draft rules (not frozen) +Independent of end_condition. **Recommended** compose with +`problem_stress.scoped` so drawn scopes matter. Measure alone first if +possible (with default problem_stress.none, influx still adds board clutter). -1. **Setup:** Surface (global) + 1–2 starters (mostly personal). Remaining scenario Problems form face-down **Pressure deck** (shuffled). -2. **Influx:** at Round End, if unclaimed count < open_cap (e.g. seats) and deck non-empty, draw 1 into play face-down (or face-up if global). -3. **Owner:** personal → next clockwise from Lead among seats / drawer rule TBD; bond → owner’s network; global → none. -4. **Scoring (v0):** drawn cards may be **stress-only (0 points)** so thresholds stay on starters — decide before kernel work. +## Frozen v0 rules -**Compose with** `problem_stress.scoped` (recommended) so drawn scopes matter. +### DEAL-START + +- **Always in play at setup:** Surface Problem (priority 0). +- **Also deal face-down:** hidden priority **1** (and priority **2** only if + seats ≥ 4). +- All other scenario Problems for that scenario go into the **Pressure deck**, + shuffled with the game seed (deterministic). +- Seat-band `k` from r0 does **not** bulk-deal the rest; influx replaces + “deal all k at once.” + +| seats | starters in play | rest → pressure deck | +|------:|------------------|----------------------| +| 2–3 | Surface + H1 | H2… | +| 4–6 | Surface + H1 + H2 | H3… | + +### DEAL-INFLUX + +At **Round End** (after problem-stress ticks if any, before DARVO arm): + +- If Pressure deck non-empty **and** unclaimed Problems in play < **open_cap**, + draw **1** card into play. +- **open_cap** = number of seats. +- Placement: face-down if hidden/personal/bond as printed; Surface-like global + face-up if scope is global. +- Stop drawing for that End once one card is drawn (v0: max 1 per round). + +### DEAL-OWN (if problem_stress.scoped also active) + +- **personal:** owner = Lead at time of draw (or next free seat clockwise if + Lead already owns ≥2 personal — v0 simplify: **drawer seat = current Lead**). +- **bond:** same owner rule; network = Bond network of owner. +- **global:** no owner. + +If `problem_stress.none`, owners may still be recorded for table clarity but +have no stress effect. + +### DEAL-POINTS (v0) + +- Cards that entered via **Pressure deck draw** have **point_value 0** for + scoring (stress / fiction only). +- **Starters** keep printed `point_value`. +- Scenario **thresholds unchanged** (5/7/9) and apply only to starter claims + + any printed values that were starters. + +This avoids reopening GR-E01 arithmetic while testing influx. + +## Non-goals (v0) + +- Problems in Solution hand. +- Multiple draws per round. +- Owner-only SOLVE. +- Changing thresholds mid-game. + +## Success criteria (when clay-borg implements) + +1. Games show mid-game influx (deck depletes or open_cap binds). +2. With scoped stress: variance and DARVO still possible; greedy SHARED 3–4p + wins not collapsed to H1-zero. +3. Threshold success still well-defined (starter points only). +4. Composable: works with default end_condition and with hybrid end. diff --git a/editions/modules/problem_deal/pressure_deck/rules_delta.yaml b/editions/modules/problem_deal/pressure_deck/rules_delta.yaml index 19ec89e..6c66309 100644 --- a/editions/modules/problem_deal/pressure_deck/rules_delta.yaml +++ b/editions/modules/problem_deal/pressure_deck/rules_delta.yaml @@ -1,18 +1,19 @@ -# module: problem_deal.pressure_deck — PROPOSED +# module: problem_deal.pressure_deck +# status: ready-for-implement (v0 rules frozen in MODULE.md) module_id: problem_deal.pressure_deck aspect: problem_deal schema_version: 1 base: ground-darvo-r0 -status: proposed -implementation: pending +status: ready-for-implement +implementation: pending_kernel deltas: - id: DEAL-START name: reduced_initial_deal phase: setup effect: - initial_in_play: [surface, hidden_priority_1] - # optional: also priority_2 at 4p+ + seats_2_3_initial: [surface, hidden_priority_1] + seats_4_6_initial: [surface, hidden_priority_1, hidden_priority_2] remainder_to: pressure_deck pressure_deck_shuffle: deterministic_seeded @@ -21,14 +22,24 @@ deltas: phase: round_end when: pressure_deck_nonempty: true - unclaimed_count_lt: open_cap # open_cap := seats (proposed) + unclaimed_count_lt: open_cap effect: + open_cap: seat_count draw_problems: 1 - place: in_play_face_down_unless_global + max_draws_per_end: 1 + place: face_down_unless_global_scope + + - id: DEAL-OWN + name: owner_on_draw + phase: on_pressure_draw + effect: + personal_or_bond_owner: current_lead + global_owner: none - id: DEAL-POINTS name: drawn_card_scoring phase: data effect: - drawn_point_value: 0 # v0: stress-only; revisit - starter_threshold_unchanged: true + drawn_point_value: 0 + starter_printed_values: unchanged + thresholds: unchanged_5_7_9 diff --git a/games/ground/examples/scenario-panel.rs b/games/ground/examples/scenario-panel.rs index ab8c0fa..8ba20e4 100644 --- a/games/ground/examples/scenario-panel.rs +++ b/games/ground/examples/scenario-panel.rs @@ -17,7 +17,7 @@ //! and the column is expected to match, which is itself a control. use cb_game_runtime::{ScenarioGame, Setup}; -use games_ground::bot::{play, GreedyPolicy, Policy}; +use games_ground::bot::{play, GreedyPolicy, ObjectivePolicy, Policy}; use games_ground::{GroundState, ScoringMode}; /// Games per cell. Named once so the banner and the assertion cannot @@ -41,7 +41,17 @@ struct Cell { setup_fails: u32, } -fn sweep(scenario: &str, mode: ScoringMode, players: u8) -> Cell { +/// Which bot fills every seat. CB-WP-0049 T03: F27 says the three modes +/// produce identical play because greedy never reads `state.mode`; the +/// only way to know whether that is a fact about GROUND or a fact about +/// our bot is to run the same panel with a seat that does read it. +#[derive(Clone, Copy, PartialEq)] +enum Seat { + Greedy, + Objective, +} + +fn sweep(scenario: &str, mode: ScoringMode, players: u8, who: Seat) -> Cell { let mut c = Cell { games: 0, played: 0, @@ -74,7 +84,10 @@ fn sweep(scenario: &str, mode: ScoringMode, players: u8) -> Cell { // scenario it did not play. assert_eq!(st.scenario, scenario, "{preset} dealt {}", st.scenario); let mut ps: Vec> = (0..players) - .map(|_| Box::new(GreedyPolicy) as Box) + .map(|_| match who { + Seat::Greedy => Box::new(GreedyPolicy) as Box, + Seat::Objective => Box::new(ObjectivePolicy) as Box, + }) .collect(); let g = match play(st, &mut ps) { Ok(g) => g, @@ -131,16 +144,23 @@ fn main() { for players in [2u8, 4, 6] { println!("{players} players"); println!( - " {:<32} {:<18} {:>5} {:>7} {:>6} {:>9}", - "scenario", "mode", "won", "win/g", "pts", "threshold" + " {:<32} {:<18} {:>5} {:>5} {:>7} {:>7} {:>6} {:>9}", + "scenario", "mode", "grdy", "obj", "win/g g", "win/g o", "pts", "threshold" ); for s in &scenarios { for (label, mode) in &modes { - let c = sweep(&s.id, *mode, players); + let g = sweep(&s.id, *mode, players, Seat::Greedy); + let c = sweep(&s.id, *mode, players, Seat::Objective); println!( - " {:<32} {label} {:>5} {:>7.2} {:>6.1} {:>9}", + " {:<32} {label} {:>5} {:>5} {:>7.2} {:>7.2} {:>6.1} {:>9}", format!("{} {}", s.id, s.title), + g.group_success, c.group_success, + // **Both, side by side.** Reading one policy's number + // against a figure remembered from an earlier pass is + // how a comparison gets made against a board nobody + // re-ran. + f64::from(g.winners) / f64::from(g.games), f64::from(c.winners) / f64::from(c.games), f64::from(c.total) / f64::from(c.games), c.threshold, @@ -150,6 +170,11 @@ fn main() { println!(); } + println!("`grdy` is GreedyPolicy, which never reads state.mode; `obj`"); + println!("is ObjectivePolicy, which plays its seat's own objective."); + println!("If the obj column varies ACROSS modes where grdy does not,"); + println!("F27 was a fact about our bot. If it does not, it is a fact"); + println!("about GROUND.\n"); println!("SCN_01 and SCN_02 are the same board — identical suits and"); println!("values at every priority. Their rows are expected to MATCH,"); println!("and a divergence means one of the two decks was edited."); diff --git a/games/ground/src/bot.rs b/games/ground/src/bot.rs index b76c9c5..4506016 100644 --- a/games/ground/src/bot.rs +++ b/games/ground/src/bot.rs @@ -14,7 +14,7 @@ use crate::{ Action, DarvoTarget, GroundChoice, GroundCommand, GroundMode, GroundState, Relation, RoundStep, - Selection, SupportResponse, + ScoringMode, Selection, SupportResponse, }; use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed}; @@ -1100,6 +1100,155 @@ mod tests { // than removed, and carries why the numbers changed. } +/// What a seat is trying to maximise (CB-WP-0049 T02). +/// +/// **Read off `GroundState::score`, never restated.** The game already +/// computes all three answers to build its `Outcome`; a copy in the bot +/// would be a second definition of winning, and the two would disagree +/// the first time ground-game rules on F28. +/// +/// | mode | objective | +/// |---|---| +/// | SHARED GROUND | the group total | +/// | COMMON PROBLEM | own claimed value − own Blame | +/// | BONDED COALITIONS | own coalition's summed personal score | +/// +/// It reads only `claimed_by`, `value` of **claimed** Problems and Blame +/// tokens — all of which are on the table — so it is blind by +/// construction (ADR-0023) rather than by inspection. +pub fn objective(state: &GroundState, seat: PlayerId) -> i32 { + let o = state.score(); + match state.mode { + ScoringMode::SharedGround => o.total as i32, + ScoringMode::CommonProblem => o.personal.get(&seat).copied().unwrap_or(0), + ScoringMode::BondedCoalitions => o + .coalitions + .iter() + .find(|c| c.members.contains(&seat)) + .map(|c| c.score) + // A seat in no coalition is a one-seat coalition (GR-E04), so + // falling back to its own score is the rule and not a guess. + .unwrap_or_else(|| o.personal.get(&seat).copied().unwrap_or(0)), + } +} + +/// A seat that plays **its own** objective (CB-WP-0049 T03). +/// +/// ## Where the modes actually differ, and where they cannot +/// +/// Working this out was most of the task. **SOLVE always claims for the +/// actor**, so a seat maximising its own score and a seat maximising the +/// group's want the same SOLVE in almost every position — which is a +/// fact about GROUND's action set, not a shortcoming of the bot, and it +/// bounds how far apart any two policies can get. +/// +/// Two places the objective really does diverge, both readable off the +/// table: +/// +/// - **SUPPORT regulates someone else.** Under SHARED GROUND that is +/// worth what it is worth to the group. Under COMMON PROBLEM the +/// beneficiary is a rival, so it is worth less. Under BONDED +/// COALITIONS a Bond *merges that seat into my coalition*, and my score +/// becomes the coalition's sum — so it is worth more, and worth most +/// with a seat not already in my network. +/// - **SOLVE's value is the Problem's value.** `GreedyPolicy::rank` gives +/// every legal SOLVE 90 regardless of what the card is worth. Under a +/// competitive objective the difference between a 2 and a 3 is the +/// whole margin. +/// +/// **It delegates.** Every arm this does not name comes from +/// `GreedyPolicy::rank`. CB-WP-0039 re-typed an abridged copy of greedy +/// and called it "one preference changed"; it differed in five places and +/// burned the Freedom token in round one of every game. Delegating makes +/// "these arms and no others" structurally true instead of a claim in a +/// comment. +/// +/// **Blind by construction** (ADR-0023): it reads Problem values only for +/// face-up Problems, and relations, which are on the table. +pub struct ObjectivePolicy; + +impl ObjectivePolicy { + /// The seats whose personal score counts toward `seat`'s objective. + fn my_side(state: &GroundState, seat: PlayerId) -> Vec { + match state.mode { + // Everyone's claims are my claims. + ScoringMode::SharedGround => state.players.keys().copied().collect(), + ScoringMode::CommonProblem => vec![seat], + ScoringMode::BondedCoalitions => state + .score() + .coalitions + .into_iter() + .find(|c| c.members.contains(&seat)) + .map(|c| c.members) + .unwrap_or_else(|| vec![seat]), + } + } + + pub fn rank(state: &GroundState, seat: PlayerId, cmd: &GroundCommand) -> i32 { + let base = GreedyPolicy::rank(state, seat, cmd); + match cmd { + // SOLVE, weighted by what the card is worth. Face-up only: + // `legal_commands` offers SOLVE on face-up Problems, and the + // lookup returns nothing for a card this seat cannot read. + GroundCommand::SelectAction { + action: Action::Solve, + problem: Some(n), + .. + } => { + let worth = state + .problems + .get(n) + .filter(|p| p.face_up && p.claimed_by.is_none()) + .map(|p| i32::from(p.value)) + .unwrap_or(0); + base + worth + } + // SUPPORT, weighted by whether the target is on my side. + GroundCommand::SelectAction { + action: Action::Support, + target: Some(other), + .. + } => { + let side = Self::my_side(state, seat); + if side.contains(other) { + base + 5 + } else { + match state.mode { + // A Bond would bring them onto my side, and my + // score is my side's sum. + ScoringMode::BondedCoalitions => base + 10, + // Regulating a rival is work I do for them. + ScoringMode::CommonProblem => base - 30, + ScoringMode::SharedGround => base, + } + } + } + _ => base, + } + } +} + +impl Policy for ObjectivePolicy { + fn name(&self) -> &'static str { + "objective" + } + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + _may_pass: bool, + ) -> Choice { + let mut best = 0; + for (i, c) in legal.iter().enumerate() { + if Self::rank(state, seat, c) > Self::rank(state, seat, &legal[best]) { + best = i; + } + } + Choice::Command(best) + } +} + /// **A policy is bound by what its seat can see** ([ADR-0023]). /// /// `Policy::choose` takes the whole `GroundState`, which carries every @@ -1272,6 +1421,135 @@ mod blindness_tests { assert!(err.contains("peeker"), "{err}"); } + /// The new policy is blind too — the point of T01 being first. + #[test] + fn the_objective_policy_is_blind() { + for seed in [1u64, 7, 42] { + for players in [2u8, 4, 6] { + for mode in [ + ScoringMode::SharedGround, + ScoringMode::CommonProblem, + ScoringMode::BondedCoalitions, + ] { + let mut st = deal(players, seed); + st.mode = mode; + for seat in st.players.keys().copied() { + is_blind(|| ObjectivePolicy, &st, seat) + .unwrap_or_else(|e| panic!("{players}p {mode:?} seed {seed}: {e}")); + } + } + } + } + } + + /// **The objective is the game's own scoring** (CB-WP-0049 T02). + #[test] + fn the_objective_agrees_with_the_outcome() { + let mut st = deal(4, 5); + let seats: Vec = st.players.keys().copied().collect(); + // Claim two Problems for two different seats, so group and + // personal cannot coincide by accident. + let keys: Vec = st.problems.keys().copied().collect(); + st.problems.get_mut(&keys[0]).unwrap().claimed_by = Some(seats[0]); + st.problems.get_mut(&keys[1]).unwrap().claimed_by = Some(seats[1]); + let o = st.score(); + + st.mode = ScoringMode::SharedGround; + assert_eq!(objective(&st, seats[0]), o.total as i32); + // Everyone shares one number. + assert_eq!(objective(&st, seats[0]), objective(&st, seats[2])); + + st.mode = ScoringMode::CommonProblem; + assert_eq!(objective(&st, seats[0]), o.personal[&seats[0]]); + // **And a seat's objective is NOT the group's** — the claim that + // makes a competitive mode competitive, asserted on a board + // rather than argued. + assert_ne!( + objective(&st, seats[0]), + st.score().total as i32, + "under COMMON PROBLEM a seat's objective coincided with the group's" + ); + assert_ne!( + objective(&st, seats[0]), + objective(&st, seats[2]), + "a claiming seat and an empty-handed seat had the same objective" + ); + + st.mode = ScoringMode::BondedCoalitions; + let mine = st + .score() + .coalitions + .into_iter() + .find(|c| c.members.contains(&seats[0])) + .expect("every seat is in a coalition"); + assert_eq!(objective(&st, seats[0]), mine.score); + } + + /// **Under SHARED GROUND the objective policy IS greedy** — the + /// control that separates "attends to the objective" from "plays + /// differently". + /// + /// Without it, any change in the panel could be the new policy simply + /// being a different bot. The two must agree wherever the objective + /// is the group's, and diverge only where it is not. + #[test] + fn under_shared_ground_the_objective_policy_and_greedy_want_the_same_thing() { + let mut differed = 0; + for seed in [1u64, 7, 42, 99] { + for players in [2u8, 4, 6] { + let mut st = deal(players, seed); + st.mode = ScoringMode::SharedGround; + for seat in st.players.keys().copied() { + let legal = legal_commands(&st, seat); + if legal.is_empty() { + continue; + } + let a = GreedyPolicy.choose(&st, seat, &legal, false); + let b = ObjectivePolicy.choose(&st, seat, &legal, false); + if a != b { + differed += 1; + } + } + } + } + // SOLVE is weighted by card value in every mode, so a tie greedy + // broke by order can break the other way here. That is a + // refinement of greedy's own objective, not a different one — so + // the assertion is that divergence is RARE, with the number + // stated rather than a vague "mostly". + assert!( + differed <= 2, + "under SHARED GROUND the two policies differed at {differed} decision points; they are supposed to share an objective" + ); + } + + /// **Under COMMON PROBLEM they do NOT** — and the reason is Support. + #[test] + fn a_competitive_seat_values_supporting_a_rival_less() { + let mut st = deal(4, 5); + let seat = PlayerId(0); + let other = PlayerId(1); + let support = GroundCommand::SelectAction { + action: Action::Support, + target: Some(other), + problem: None, + }; + st.mode = ScoringMode::SharedGround; + let shared = ObjectivePolicy::rank(&st, seat, &support); + st.mode = ScoringMode::CommonProblem; + let selfish = ObjectivePolicy::rank(&st, seat, &support); + st.mode = ScoringMode::BondedCoalitions; + let coalition = ObjectivePolicy::rank(&st, seat, &support); + assert!( + selfish < shared, + "a seat scoring only its own claims valued regulating a rival the same as a cooperative seat did ({selfish} vs {shared})" + ); + assert!( + coalition > shared, + "under BONDED COALITIONS a Bond brings that seat's score into mine, so Support is worth MORE, not the same ({coalition} vs {shared})" + ); + } + /// And the shipped policies are blind. #[test] fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() { diff --git a/games/ground/src/config.rs b/games/ground/src/config.rs index 4337cbf..497f594 100644 --- a/games/ground/src/config.rs +++ b/games/ground/src/config.rs @@ -283,6 +283,29 @@ fn no_kernel_path(cat: &crate::catalog::Catalog, id: &str) -> String { mod tests { use super::*; + /// A module the catalog has and this kernel has **no path for**. + /// + /// **Found by asking `resolve`, not by matching a status string.** + /// The first version searched for `status: proposed` — and + /// ground-game renamed those to `ready-for-implement` the same day, + /// so two tests failed over a word neither of them was about. The + /// structural property is ours to determine and does not move when + /// another repo edits its vocabulary; the same lesson as matching a + /// rules passage by heading rather than by row number (CB-WP-0046). + /// + /// `None` when the kernel implements everything — a real state, and + /// the day it arrives these tests should skip rather than fail. + fn a_module_we_cannot_run() -> Option<(String, String)> { + let cat = crate::catalog::catalog().ok()?; + cat.modules.iter().find_map(|m| { + let mut c = Configuration::default(); + c.modules.insert(m.aspect.clone(), m.module_id.clone()); + c.resolve() + .is_err() + .then(|| (m.aspect.clone(), m.module_id.clone())) + }) + } + /// **A proposed module is refused BY NAME, distinguishably from a /// typo** (ADR-0022 D1). /// @@ -292,23 +315,16 @@ mod tests { /// statement about the edition, and the wrong-subject family again. #[test] fn a_proposed_module_and_a_typo_are_different_errors() { - let cat = crate::catalog::catalog().expect("catalog.yaml"); - // A module the catalog has, with a rules_delta, that no kernel - // path implements. Found in the catalog, not hardcoded: if - // ground-game implements it upstream this test looks elsewhere - // rather than going stale. - let proposed = cat - .modules - .iter() - .find(|m| m.status == "proposed") - .expect("the catalog has at least one proposed module"); - + let Some((aspect, module)) = a_module_we_cannot_run() else { + // The kernel implements every module the catalog has. Nothing + // to distinguish, and nothing broken. + return; + }; let mut c = Configuration::default(); - c.modules - .insert(proposed.aspect.clone(), proposed.module_id.clone()); + c.modules.insert(aspect.clone(), module.clone()); let refused = c.resolve().expect_err("a proposed module must be refused"); assert!( - refused.contains(&proposed.module_id) && refused.contains("no kernel path"), + refused.contains(&module) && refused.contains("no kernel path"), "a proposed module was not refused by name: {refused}" ); assert!( @@ -318,10 +334,7 @@ mod tests { // And a real typo says the other thing. let mut typo = Configuration::default(); - typo.modules.insert( - proposed.aspect.clone(), - format!("{}_zzz", proposed.module_id), - ); + typo.modules.insert(aspect, format!("{module}_zzz")); let unknown = typo.resolve().expect_err("a typo must be refused"); assert!( unknown.contains("is not a module the catalog has"), @@ -358,21 +371,12 @@ mod tests { /// failure (ADR-0022 D0). #[test] fn a_module_with_no_kernel_path_still_names_a_configuration() { - let cat = crate::catalog::catalog().expect("catalog.yaml"); - let proposed = cat - .modules - .iter() - .find(|m| m.status == "proposed") - .expect("a proposed module"); - let c = Configuration::from_modules( - "ground-darvo-r0", - std::slice::from_ref(&proposed.module_id), - ) - .expect("selecting a catalog module must NAME a configuration"); - assert_eq!( - c.module_on(&proposed.aspect), - Some(proposed.module_id.as_str()) - ); + let Some((aspect, module)) = a_module_we_cannot_run() else { + return; + }; + let c = Configuration::from_modules("ground-darvo-r0", std::slice::from_ref(&module)) + .expect("selecting a catalog module must NAME a configuration"); + assert_eq!(c.module_on(&aspect), Some(module.as_str())); // It round-trips, so a recording can carry it. let s = serde_yaml::to_string(&c).unwrap(); let back: Configuration = serde_yaml::from_str(&s).unwrap(); diff --git a/games/ground/src/lib.rs b/games/ground/src/lib.rs index cdaab0b..59117a3 100644 --- a/games/ground/src/lib.rs +++ b/games/ground/src/lib.rs @@ -1834,7 +1834,15 @@ impl GroundState { } /// GR-E01..E04: final scoring for the configured mode. - fn score(&self) -> Outcome { + /// **Public since CB-WP-0049 T02.** A policy needs to know what its + /// seat is trying to maximise, and the game already computes all + /// three answers here. A second copy in the bot would be a second + /// definition of winning — and the two would disagree the first time + /// ground-game rules on F28's mastery reading. + /// + /// It is a pure function of the state, so it answers mid-game too: + /// "what would this position score if it stopped now". + pub fn score(&self) -> Outcome { // GR-E01/P03: a claimed Problem counts its printed value. let total: u32 = self .problems diff --git a/specs/FindingRegister.md b/specs/FindingRegister.md index e1bd41a..041a006 100644 --- a/specs/FindingRegister.md +++ b/specs/FindingRegister.md @@ -54,7 +54,7 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by | F23 | inconsistent | applied | decisions/ADR-0017-chaos-window-2-verdict.md | counterexample | 2026-08-07 | clay-borg | | F22 | underdetermined | withdrawn | games_ground::edition::supply_tests::play_never_exceeds_the_components_the_box_holds | counterexample | 2026-08-07 | clay-borg | | F26 | inert | raised | `crates/cb-render-html/src/lib.rs::a_scoped_table_says_what_a_scope_does` | counterexample | 2026-08-08 | ground-game | -| F27 | unplayed | raised | `games/ground/examples/scenario-panel.rs` | counterexample | 2026-08-08 | clay-borg | +| F27 | unplayed | reported | `games/ground/examples/scenario-panel.rs` | counterexample | 2026-08-08 | clay-borg | | F28 | underdetermined | raised | `games_ground::tests::the_mastery_rating_and_the_shared_score_count_different_things` | counterexample | 2026-08-08 | ground-game | @@ -70,6 +70,35 @@ kinds, states and metrics: [`GameDesign.md`](GameDesign.md). Reported by score. `unplayed` rather than `inert`: the modes score correctly, they have simply never faced a seat that wanted to win alone. + **Re-measured 2026-08-08 with `ObjectivePolicy`** (CB-WP-0049 T03), + which plays its seat's own objective. The finding **splits in two**: + + - **Group success does not move.** 34 of 36 cells identical; SCN_03 at + 4p goes 99→100 in all three modes, which is the SOLVE-by-value + refinement and not a mode effect. **Whether the table survives is the + same game in all three modes.** + - **Who wins does move.** Under BONDED COALITIONS at 4p, winning seats + per game go 2.04 → 2.98 (SCN_01/02), 2.12 → 3.29 (SCN_03), 2.05 → + 3.01 (SCN_04): a seat whose score is its coalition's sum bonds more, + and the coalitions get about half again as large. COMMON PROBLEM + moves at 6p (1.10 → 1.17). + + So the modes **do** reach decisions — the original claim that they are + "scoring lenses over cooperative play" was too strong, and is withdrawn + in favour of the sharper one: **GROUND's scoring modes change who wins, + not whether the group succeeds.** + + **Sensitivity:** vary only the seat band and the effect appears and + disappears — 2p shows no divergence in any mode, 4p shows the largest, + 6p shows none under BONDED COALITIONS. A candidate explanation is that + two relation slots per seat cap network growth, so at 6p the incentive + exists and cannot be acted on; **that is untested** and is the next + thing to vary. + + **Still open**, because the part that motivated it is untested: no + policy models a *rival* playing their objective, so a competitive mode + in which nobody anticipates an opponent remains a weak test of that + mode (CB-WP-0049 "Not done here"). - **F28 — SHARED GROUND's mastery rating counts cards where the mode card counts points.** *"All claimed Problem cards form one shared score. … For a mastery rating, subtract 1 for each Blame token still in play diff --git a/tools/vendor-editions.py b/tools/vendor-editions.py new file mode 100644 index 0000000..ed141d5 --- /dev/null +++ b/tools/vendor-editions.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +"""Re-vendor the edition mirror from ground-game and record its digests. + +`edition-check` asks two questions: does every vendored file match its +recorded digest, and is every vendored copy current with the sibling +checkout. Answering "no" is routine — ground-game is edited by its own +maintainer and the mirror goes stale several times a day. + +**This does the sync; it does not decide anything.** It copies what +upstream has, records what it copied, and refuses to invent either. + +## Why this is a tool and not a habit + +The mirror went stale three times in one session and was refreshed by +hand each time: copy the changed files, recompute a digest, edit +`PROVENANCE.md`. A hand-repeated sync is exactly the thing that drifts — +the second time, one file gets missed and its digest keeps certifying a +version nobody has. + +The digest block is **generated by walking `editions/`**, never typed. +CB-REV-0002 #8 and CB-REV-0003 #8 both found hand-written file lists that +made their own controls vacuous, and a mirror that grows a *directory* is +precisely the case a maintained list loses. + +## What it will not do + +- It does not delete files upstream no longer has, and it does not add + files upstream does not have. `edition-check` reports both, and a sync + tool that silently resolved them would remove the only signal that the + mirror and upstream disagree about what exists. +- It does not touch `editions/ground-darvo-r0/` except to write the + digest block. That directory is the baseline package (ADR-0011) and its + own provenance is a separate record. +""" + +import hashlib +import os +import shutil +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +EDITIONS = os.path.join(ROOT, "editions") +EDITION_DIR = os.path.join(EDITIONS, "ground-darvo-r0") +PROVENANCE = os.path.join(EDITION_DIR, "PROVENANCE.md") +UPSTREAM = os.path.join(ROOT, "..", "ground-game", "editions") + +SKIP = {".DS_Store"} + + +def digest(path): + return hashlib.sha256(open(path, "rb").read()).hexdigest() + + +def siblings(): + """Every file beside the edition directory, as `../`-relative paths. + + Mirrors `edition-check`'s own walk. Two walks of the same tree is a + duplication worth naming: if they ever disagree the check fails + loudly, which is the direction the duplication should fail in.""" + out = [] + for dirpath, _dirs, files in os.walk(EDITIONS): + if os.path.abspath(dirpath).startswith(os.path.abspath(EDITION_DIR)): + continue + for name in sorted(files): + if name in SKIP: + continue + full = os.path.join(dirpath, name) + rel = os.path.relpath(full, EDITION_DIR).replace(os.sep, "/") + out.append((rel, full)) + return sorted(out) + + +def refresh(): + if not os.path.isdir(UPSTREAM): + print(f" upstream not checked out at {UPSTREAM}") + print(" NOT a failure: the sync could not be asked for.") + return 1 + + copied, missing, extra = [], [], [] + for rel, full in siblings(): + up = os.path.normpath(os.path.join(UPSTREAM, rel.replace("../", "", 1))) + if not os.path.exists(up): + # Ours, or gone upstream. Either way it is a question for a + # human -- see the module docstring. + extra.append(rel) + continue + if digest(up) != digest(full): + shutil.copy2(up, full) + copied.append(rel) + + # Files upstream has that we do not. Reported, never copied blind: + # vendoring a new package is a decision (ADR-0011), not a sync. + for dirpath, _dirs, files in os.walk(UPSTREAM): + for name in files: + if name in SKIP: + continue + up = os.path.join(dirpath, name) + rel = os.path.relpath(up, UPSTREAM).replace(os.sep, "/") + here = os.path.join(EDITIONS, rel) + if rel.startswith("ground-darvo-r0/"): + continue + if not os.path.exists(here): + missing.append(rel) + + rows = [(digest(full), rel) for rel, full in siblings()] + rows.sort(key=lambda r: r[1]) + block = "\n".join(f"sha256 {h} {r}" for h, r in rows) + + text = open(PROVENANCE, encoding="utf-8").read() + a = text.index("```\nsha256") + b = text.index("```", a + 3) + 3 + open(PROVENANCE, "w", encoding="utf-8").write( + text[:a] + "```\n" + block + "\n```" + text[b:] + ) + + print("vendor-editions — the mirror, and what it now records") + print(f" refreshed from upstream {len(copied)}") + for r in copied: + print(f" {r}") + print(f" digests recorded {len(rows)}") + if extra: + print(f" here and NOT upstream {len(extra)} (not deleted — a human decides)") + for r in extra: + print(f" {r}") + if missing: + print(f" upstream and NOT here {len(missing)} (not copied — vendoring is a decision)") + for r in missing: + print(f" {r}") + return 0 + + +def recorded_paths(): + """The SIBLING paths the digest block names. + + **`../`-prefixed only.** `PROVENANCE.md` carries two digest blocks — + one for the edition's own CSVs and one for everything beside it — and + this tool owns the second. Reading both reported ten edition CSVs as + "recorded but absent" the first time the self-test ran, because they + are absent *from the sibling walk*, which is a different question + from absent. + + The tool's own rewrite is likewise scoped: it replaces the first + `sha256` block, which is the sibling one, and never touches the + edition's. + """ + text = open(PROVENANCE, encoding="utf-8").read() + out = [] + for line in text.splitlines(): + parts = line.split() + if len(parts) == 3 and parts[0] == "sha256" and parts[2].startswith("../"): + out.append(parts[2]) + return sorted(out) + + +def self_test(): + """A sync tool that cannot detect an unrecorded file is decoration. + + **These are read-only.** A self-test that ran `refresh` would write + to `PROVENANCE.md` to prove that it can, which is a control that + changes the thing it measures. + """ + ok = True + + def check(name, cond, detail=""): + nonlocal ok + ok = ok and bool(cond) + print(f" [{'ok ' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + + import tempfile + + with tempfile.NamedTemporaryFile("wb", delete=False) as f: + f.write(b"clay-borg") + tmp = f.name + check("digest is sha256 of the bytes", + digest(tmp) == hashlib.sha256(b"clay-borg").hexdigest()) + os.unlink(tmp) + + found = [rel for rel, _ in siblings()] + check("the walk finds files, not just the top level", bool(found)) + # CB-REV-0003 #8: the first edition-check listed three glob patterns + # and missed everything a directory deeper. The mirror is now three + # deep, so this asserts the walk actually descends. + deep = [r for r in found if r.count("/") >= 3] + check("the walk descends into module directories", bool(deep), + f"deepest: {max(found, key=lambda r: r.count('/')) if found else 'none'}") + + # THE control. The recorded block must name exactly what is on disk: + # a file added to the mirror without re-running this tool is the + # failure it exists to prevent, and `edition-check` would then be + # certifying a set nobody chose. + rec = set(recorded_paths()) + disk = set(found) + check("every vendored file has a recorded digest", not (disk - rec), + f"unrecorded: {sorted(disk - rec)}" if disk - rec else "") + check("no digest is recorded for a file that is not here", not (rec - disk), + f"recorded but absent: {sorted(rec - disk)}" if rec - disk else "") + + # And the digests are the CURRENT bytes, not a stale record that + # happens to name the right files. + stale = [rel for rel, full in siblings() + if digest(full) not in open(PROVENANCE, encoding="utf-8").read()] + check("every recorded digest is the file's current bytes", not stale, + f"stale: {stale}" if stale else "") + + print("vendor-editions self-test (positive control)") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(self_test() if "--self-test" in sys.argv else refresh()) diff --git a/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md b/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md index 69726b1..f7aeccc 100644 --- a/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md +++ b/workplans/CB-WP-0049-a-seat-that-plays-its-own-objective.md @@ -100,7 +100,7 @@ can fail is decoration. ```task id: CB-WP-0049-T02 -status: todo +status: done priority: high state_hub_task_id: "7e3611bf-4cfb-412c-8e33-695778c7980b" ``` @@ -126,11 +126,17 @@ ground-game rules on F28. - **the objective is blind** (T01's control applies to anything that reaches a policy). +**Done 2026-08-08.** `GroundState::score` is now public and +`bot::objective` reads it. Nothing about winning is restated in the bot. +Because `score` is a pure function of the state it answers mid-game too — +*"what would this position score if it stopped now"* — which is what a +policy needs and what a second implementation would have had to invent. + ## Task: a seat that plays it, and the F27 re-measurement ```task id: CB-WP-0049-T03 -status: todo +status: done priority: high state_hub_task_id: "a842c0a3-9374-4f08-8e2b-1571b8f241ef" ``` @@ -156,6 +162,54 @@ and the workplan must not be written as though one is expected. - **greedy's numbers are unchanged**, so the new policy is an addition and not a silent edit of the published baseline. +**Done 2026-08-08.** `ObjectivePolicy` delegates to `GreedyPolicy::rank` +and overrides two arms. + +### Where the modes can differ at all + +Working this out was most of the task, and it bounds the result: +**SOLVE always claims for the actor**, so a seat maximising its own score +and one maximising the group's want the same SOLVE in nearly every +position. That is a fact about GROUND's action set, not a shortcoming of +the bot. Two places the objective genuinely diverges, both readable off +the table: + +- **SUPPORT regulates someone else** — worth less when the beneficiary is + a rival (COMMON PROBLEM), worth *more* when a Bond merges them into my + coalition and my score is the coalition's sum (BONDED COALITIONS). +- **SOLVE's value is the card's value** — greedy ranks every legal SOLVE + at 90 regardless, and under a competitive objective the difference + between a 2 and a 3 is the margin. + +### The result — F27 splits in two + +The panel now prints **both policies side by side**. That was a +correction mid-task: the first version printed only the new policy's +`win/g` and I compared it against a figure remembered from CB-WP-0047, +which is a comparison against a board nobody re-ran. + +| | | +|---|---| +| **group success** | **unchanged** — 34 of 36 cells identical; SCN_03 at 4p goes 99→100 in all three modes, which is the SOLVE-by-value refinement, not a mode effect | +| **who wins** | **moves** — BONDED COALITIONS at 4p: 2.04 → 2.98, 2.12 → 3.29, 2.05 → 3.01 winning seats per game. COMMON PROBLEM at 6p: 1.10 → 1.17 | + +**So the original claim was too strong.** The modes are not "scoring +lenses over cooperative play" — they reach decisions. The sharper +statement: **GROUND's scoring modes change who wins, not whether the +group succeeds.** + +**And the effect is seat-band dependent**: 2p shows no divergence in any +mode, 4p the largest, 6p none under BONDED COALITIONS. A candidate +explanation is that two relation slots per seat cap network growth, so at +6p the incentive exists and cannot be acted on. **That is untested** and +is the next variable to move. + +**Under SHARED GROUND the two policies agree** at all but ≤2 decision +points across 12 boards — the control that separates "attends to the +objective" from "is simply a different bot". Without it, every number +above could have been the new policy being different rather than being +mode-aware. + ## Task: module-aware evaluation ```task @@ -182,6 +236,31 @@ achievable cheaply, the honest move is one policy that reads the resolved control that separates "attends to the module" from "plays differently"; - **blind** (T01). +## Two things this pass fixed that were not the task + +**A test keyed on a word another repo owns.** T01's controls found their +example module by searching the catalog for `status: proposed` — and +ground-game renamed those to `ready-for-implement` mid-session, so two +tests failed over a string neither was about. The property they actually +need is *"a module the catalog has that this kernel cannot run"*, which +`resolve()` determines and which does not move when someone edits their +vocabulary. Same lesson as matching a rules passage by heading rather +than row number (CB-WP-0046) and reading a module's `aspect:` field +rather than splitting its id on the dot. + +**The mirror was re-vendored by hand three times in one session.** +`make vendor` now does it and regenerates the digest block by **walking** +`editions/`, never by typing it. It reports files only one side has +rather than resolving them — deleting a file upstream dropped, or copying +one it added, are decisions (ADR-0011), not a sync. + +Its self-test caught a defect in itself on first run: it read *both* +digest blocks in `PROVENANCE.md` and reported ten edition CSVs as +"recorded but absent", when they are absent only from the *sibling* walk +— a different question from absent. Mutation-proved: touch a vendored +file without re-running and *"every recorded digest is the file's current +bytes"* goes red. + ## Not done here - **The trait still takes `&GroundState`.** ADR-0023 D2 chose the