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
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:
parent
82b9e7df31
commit
3045eb03f8
16 changed files with 859 additions and 94 deletions
8
Makefile
8
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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
210
tools/vendor-editions.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue