CB-WP-0049 T02/T03: a seat that plays its objective, and F27 splits in two
Some checks failed
ci / check (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-08 23:31:11 +02:00
parent 82b9e7df31
commit 3045eb03f8
16 changed files with 859 additions and 94 deletions

View file

@ -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

View file

@ -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 |
---

View file

@ -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).

View file

@ -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)

View file

@ -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

View file

@ -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 &lt; 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 34p.
4. Does not recreate H1-style 0% greedy wins solely via collapse spam.

View file

@ -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

View file

@ -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) + 12 starters (mostly personal). Remaining scenario Problems form face-down **Pressure deck** (shuffled).
2. **Influx:** at Round End, if unclaimed count &lt; 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 → owners 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 |
|------:|------------------|----------------------|
| 23 | Surface + H1 | H2… |
| 46 | 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 &lt; **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 34p
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.

View file

@ -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

View file

@ -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<Box<dyn Policy>> = (0..players)
.map(|_| Box::new(GreedyPolicy) as Box<dyn Policy>)
.map(|_| match who {
Seat::Greedy => Box::new(GreedyPolicy) as Box<dyn Policy>,
Seat::Objective => Box::new(ObjectivePolicy) as Box<dyn Policy>,
})
.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.");

View file

@ -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<PlayerId> {
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<PlayerId> = st.players.keys().copied().collect();
// Claim two Problems for two different seats, so group and
// personal cannot coincide by accident.
let keys: Vec<u32> = 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() {

View file

@ -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();

View file

@ -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

View file

@ -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 |
<!-- design-register:end -->
@ -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

210
tools/vendor-editions.py Normal file
View file

@ -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())

View file

@ -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