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 \
|
--record trials/$(TRIAL_NAME).yaml \
|
||||||
--trial trials/$(TRIAL_NAME).md $(ARGS)
|
--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
|
## fmt + clippy (deny warnings) + HashMap deny-lint
|
||||||
check:
|
check:
|
||||||
$(IN_REPO) $(CARGO) fmt --all --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 |
|
| `scenario_fiction` | Thematic conflict | Scenarios.csv | content, not rules module |
|
||||||
| `safety_teaching_frame` | How DARVO is framed | Rules_Text safety; INTENT | product |
|
| `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 |
|
| `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) |
|
| `problem_stress` | `none` | `flat_any_open` (H1-A, reject), `scoped` (H2, keep) |
|
||||||
| `attack_relief` | `none` | `self_soothe_ge4` (H1-B) |
|
| `attack_relief` | `none` | `self_soothe_ge4` (H1-B) |
|
||||||
| `end_condition` | `fixed_rounds_5` | `hybrid_clear_collapse` (**proposed**) |
|
| `end_condition` | `fixed_rounds_5` | `hybrid_clear_collapse` (**ready-for-implement**) |
|
||||||
| `problem_deal` | `fixed_setup` | `pressure_deck` (**proposed**) |
|
| `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).
|
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
|
path: editions/modules/end_condition/hybrid_clear_collapse
|
||||||
is_default: false
|
is_default: false
|
||||||
selectable: true
|
selectable: true
|
||||||
status: proposed
|
status: ready-for-implement
|
||||||
rules_delta: editions/modules/end_condition/hybrid_clear_collapse/rules_delta.yaml
|
rules_delta: editions/modules/end_condition/hybrid_clear_collapse/rules_delta.yaml
|
||||||
hypothesis_ref: history/260808-deal-end-sequences-design.md
|
hypothesis_ref: history/260808-deal-end-sequences-design.md
|
||||||
summary: >
|
summary: >
|
||||||
End on board clear, group collapse, or round ceiling (draft rules).
|
End on board clear, group collapse, or round-5 ceiling (v0 frozen).
|
||||||
utility_estimate: Unimplemented — draft only.
|
utility_estimate: Unimplemented in kernel — package ready for clay-borg.
|
||||||
decision: none
|
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 ---
|
# --- problem_deal ---
|
||||||
- module_id: problem_deal.fixed_setup
|
- module_id: problem_deal.fixed_setup
|
||||||
|
|
@ -177,16 +179,16 @@ modules:
|
||||||
path: editions/modules/problem_deal/pressure_deck
|
path: editions/modules/problem_deal/pressure_deck
|
||||||
is_default: false
|
is_default: false
|
||||||
selectable: true
|
selectable: true
|
||||||
status: proposed
|
status: ready-for-implement
|
||||||
rules_delta: editions/modules/problem_deal/pressure_deck/rules_delta.yaml
|
rules_delta: editions/modules/problem_deal/pressure_deck/rules_delta.yaml
|
||||||
hypothesis_ref: history/260808-deal-end-sequences-design.md
|
hypothesis_ref: history/260808-deal-end-sequences-design.md
|
||||||
summary: >
|
summary: >
|
||||||
Small start set + mid-game draws from Pressure deck (draft).
|
Reduced starters + Pressure deck draws; drawn cards 0 points (v0 frozen).
|
||||||
utility_estimate: Unimplemented — draft only.
|
utility_estimate: Unimplemented in kernel — package ready for clay-borg.
|
||||||
decision: none
|
decision: none
|
||||||
clay_borg_notes: >
|
clay_borg_notes: >
|
||||||
Kernel pending. Recommended compose with problem_stress.scoped.
|
v0 frozen in MODULE.md. Drawn point_value 0; thresholds unchanged.
|
||||||
v0 draft uses 0-point drawn cards.
|
Compose with problem_stress.scoped via profile scoped_plus_pressure_deck.
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Profiles — named compositions (convenience; not a second rules source)
|
# 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.
|
reported it unverified and it was a real gap.
|
||||||
|
|
||||||
```
|
```
|
||||||
sha256 84d43ac110615a678276c318a64a26d9996a9f3f2edbb1b72c38c3e12cf68149 ../ASPECTS.md
|
sha256 536ad836991804e8d96eff20ae549654ecb82e28cdd884914730b70e3c387ee3 ../ASPECTS.md
|
||||||
sha256 684abb9856e81d456eb214efc7bcfc643e06200451daae1653c528a8b93f5c66 ../CATALOG.md
|
sha256 5b5f1b73a6fa0f7a8d9ef2061670efc1c309d69774dea9e0deeb3fbce0a399b9 ../CATALOG.md
|
||||||
sha256 297c36c67f19a3ccdab152c928f94ff39c861c7ce836c9dfc10a773a7fe51ee2 ../catalog.yaml
|
sha256 2adedb8c28570c33e126484c472d8b097067f50358f2ac523f1b3c976e66947a ../catalog.yaml
|
||||||
sha256 7b1cc0149122b855e827bc930576ed165bf7dd8d62707e845a9e514ce3521f8e ../experiments/h1-problem-stress/Actions.csv
|
sha256 7b1cc0149122b855e827bc930576ed165bf7dd8d62707e845a9e514ce3521f8e ../experiments/h1-problem-stress/Actions.csv
|
||||||
sha256 62785f5e7e245c60171624d15de2f40187a44fec54f93c7d9705cf52584b1078 ../experiments/h1-problem-stress/Rules_Text.csv
|
sha256 62785f5e7e245c60171624d15de2f40187a44fec54f93c7d9705cf52584b1078 ../experiments/h1-problem-stress/Rules_Text.csv
|
||||||
sha256 49897a68056643a8cfccff32c9e4a9811018b4e4689b90a85124a487f2318369 ../experiments/h1-problem-stress/VARIANT.md
|
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 0eec2ef761c5f6548085156248f182fa077ac5aee7c657bd6bfa6ba22d53446b ../modules/attack_relief/self_soothe_ge4/MODULE.md
|
||||||
sha256 91463363a482219109bb6d5033019135b5b10505f55f6272b912c7a51cd90173 ../modules/attack_relief/self_soothe_ge4/rules_delta.yaml
|
sha256 91463363a482219109bb6d5033019135b5b10505f55f6272b912c7a51cd90173 ../modules/attack_relief/self_soothe_ge4/rules_delta.yaml
|
||||||
sha256 a02aa5ee860aca71d3e7c19975dd5eb1c66d7c8a0f3d3ceca8dd9914b85d6c7e ../modules/end_condition/fixed_rounds_5/MODULE.md
|
sha256 a02aa5ee860aca71d3e7c19975dd5eb1c66d7c8a0f3d3ceca8dd9914b85d6c7e ../modules/end_condition/fixed_rounds_5/MODULE.md
|
||||||
sha256 6d7d1f5b1d629b61dc35299606393cdac969379bb6207b5c1e741ddbbf50c06e ../modules/end_condition/hybrid_clear_collapse/MODULE.md
|
sha256 b1706aa3a10bace4fac5854eaa73848bba331cc9720da4b162e09db415631a50 ../modules/end_condition/hybrid_clear_collapse/MODULE.md
|
||||||
sha256 8dda4def373cd4aaaefdf764c915ad54ae7a1b4d885ee2cd623e81f752fdb907 ../modules/end_condition/hybrid_clear_collapse/rules_delta.yaml
|
sha256 66eca80a673583499da2cfef01439ebf511401b5af9fad6a9ee790b0006e98b8 ../modules/end_condition/hybrid_clear_collapse/rules_delta.yaml
|
||||||
sha256 5b809680afdf9282a457f8d7f70b111bf18590f4867622e332f49e1ee4a23b08 ../modules/problem_deal/fixed_setup/MODULE.md
|
sha256 5b809680afdf9282a457f8d7f70b111bf18590f4867622e332f49e1ee4a23b08 ../modules/problem_deal/fixed_setup/MODULE.md
|
||||||
sha256 d8ddf74127aa6adf32be6a9e30dac4e6b9ff14da1a9e0bb74d2faf22794e8a57 ../modules/problem_deal/pressure_deck/MODULE.md
|
sha256 ca656820336e37b72fdd2409c15f54102c4f7daf9ab0a0b94fc1f30119b1c727 ../modules/problem_deal/pressure_deck/MODULE.md
|
||||||
sha256 c05addd208e9d2c287a8da8a2112e24a6b30aa163b19fe763362eae59598ce30 ../modules/problem_deal/pressure_deck/rules_delta.yaml
|
sha256 1845ee0d3ec48ff6df31fca6f6acb1cad8b362c65ca7e7d93dfd61e0426fcdbd ../modules/problem_deal/pressure_deck/rules_delta.yaml
|
||||||
sha256 a5e66fd60abef1cf965b0b20892548c65f4005c45f4d44c025e4756eff579897 ../modules/problem_stress/flat_any_open/MODULE.md
|
sha256 a5e66fd60abef1cf965b0b20892548c65f4005c45f4d44c025e4756eff579897 ../modules/problem_stress/flat_any_open/MODULE.md
|
||||||
sha256 95fa5d4ad811407eda1cf31d99d5dd984e9d1d9af8c284e10df598874d0f06e3 ../modules/problem_stress/flat_any_open/rules_delta.yaml
|
sha256 95fa5d4ad811407eda1cf31d99d5dd984e9d1d9af8c284e10df598874d0f06e3 ../modules/problem_stress/flat_any_open/rules_delta.yaml
|
||||||
sha256 1e9cb6e2dac046a9f48beccb0ddc09c41608306711320159ecdce3572562e454 ../modules/problem_stress/none/MODULE.md
|
sha256 1e9cb6e2dac046a9f48beccb0ddc09c41608306711320159ecdce3572562e454 ../modules/problem_stress/none/MODULE.md
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,62 @@
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **aspect** | `end_condition` |
|
| **aspect** | `end_condition` |
|
||||||
| **role** | End on **board clear**, **group collapse**, or **max rounds** (default max 5) |
|
| **module_id** | `end_condition.hybrid_clear_collapse` |
|
||||||
| **status** | proposed — not yet implemented in kernel |
|
| **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).
|
## Frozen v0 rules
|
||||||
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.
|
|
||||||
|
|
||||||
**Compose with** any problem_stress / deal / attack_relief.
|
### END-CLEAR
|
||||||
**Kernel:** not shipped — package `rules_delta.yaml` when implemented.
|
|
||||||
|
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
|
module_id: end_condition.hybrid_clear_collapse
|
||||||
aspect: end_condition
|
aspect: end_condition
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
base: ground-darvo-r0
|
base: ground-darvo-r0
|
||||||
status: proposed
|
status: ready-for-implement
|
||||||
implementation: pending
|
implementation: pending_kernel
|
||||||
|
|
||||||
deltas:
|
deltas:
|
||||||
- id: END-CLEAR
|
- id: END-CLEAR
|
||||||
|
|
@ -15,7 +16,8 @@ deltas:
|
||||||
effect:
|
effect:
|
||||||
end_game: true
|
end_game: true
|
||||||
scoring: mode_as_usual
|
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
|
- id: END-COLLAPSE
|
||||||
name: early_end_group_collapse
|
name: early_end_group_collapse
|
||||||
|
|
@ -30,7 +32,7 @@ deltas:
|
||||||
personal_winners: none
|
personal_winners: none
|
||||||
|
|
||||||
- id: END-CEILING
|
- id: END-CEILING
|
||||||
name: max_rounds_unchanged
|
name: max_rounds_five
|
||||||
phase: after_round
|
phase: after_round
|
||||||
when:
|
when:
|
||||||
round_completed: 5
|
round_completed: 5
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,75 @@
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **aspect** | `problem_deal` |
|
| **aspect** | `problem_deal` |
|
||||||
| **role** | Start with a small set; draw additional scoped Problems mid-game from a Pressure deck |
|
| **module_id** | `problem_deal.pressure_deck` |
|
||||||
| **status** | proposed — not yet implemented |
|
| **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).
|
## Frozen v0 rules
|
||||||
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.
|
|
||||||
|
|
||||||
**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
|
module_id: problem_deal.pressure_deck
|
||||||
aspect: problem_deal
|
aspect: problem_deal
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
base: ground-darvo-r0
|
base: ground-darvo-r0
|
||||||
status: proposed
|
status: ready-for-implement
|
||||||
implementation: pending
|
implementation: pending_kernel
|
||||||
|
|
||||||
deltas:
|
deltas:
|
||||||
- id: DEAL-START
|
- id: DEAL-START
|
||||||
name: reduced_initial_deal
|
name: reduced_initial_deal
|
||||||
phase: setup
|
phase: setup
|
||||||
effect:
|
effect:
|
||||||
initial_in_play: [surface, hidden_priority_1]
|
seats_2_3_initial: [surface, hidden_priority_1]
|
||||||
# optional: also priority_2 at 4p+
|
seats_4_6_initial: [surface, hidden_priority_1, hidden_priority_2]
|
||||||
remainder_to: pressure_deck
|
remainder_to: pressure_deck
|
||||||
pressure_deck_shuffle: deterministic_seeded
|
pressure_deck_shuffle: deterministic_seeded
|
||||||
|
|
||||||
|
|
@ -21,14 +22,24 @@ deltas:
|
||||||
phase: round_end
|
phase: round_end
|
||||||
when:
|
when:
|
||||||
pressure_deck_nonempty: true
|
pressure_deck_nonempty: true
|
||||||
unclaimed_count_lt: open_cap # open_cap := seats (proposed)
|
unclaimed_count_lt: open_cap
|
||||||
effect:
|
effect:
|
||||||
|
open_cap: seat_count
|
||||||
draw_problems: 1
|
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
|
- id: DEAL-POINTS
|
||||||
name: drawn_card_scoring
|
name: drawn_card_scoring
|
||||||
phase: data
|
phase: data
|
||||||
effect:
|
effect:
|
||||||
drawn_point_value: 0 # v0: stress-only; revisit
|
drawn_point_value: 0
|
||||||
starter_threshold_unchanged: true
|
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.
|
//! and the column is expected to match, which is itself a control.
|
||||||
|
|
||||||
use cb_game_runtime::{ScenarioGame, Setup};
|
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};
|
use games_ground::{GroundState, ScoringMode};
|
||||||
|
|
||||||
/// Games per cell. Named once so the banner and the assertion cannot
|
/// Games per cell. Named once so the banner and the assertion cannot
|
||||||
|
|
@ -41,7 +41,17 @@ struct Cell {
|
||||||
setup_fails: u32,
|
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 {
|
let mut c = Cell {
|
||||||
games: 0,
|
games: 0,
|
||||||
played: 0,
|
played: 0,
|
||||||
|
|
@ -74,7 +84,10 @@ fn sweep(scenario: &str, mode: ScoringMode, players: u8) -> Cell {
|
||||||
// scenario it did not play.
|
// scenario it did not play.
|
||||||
assert_eq!(st.scenario, scenario, "{preset} dealt {}", st.scenario);
|
assert_eq!(st.scenario, scenario, "{preset} dealt {}", st.scenario);
|
||||||
let mut ps: Vec<Box<dyn Policy>> = (0..players)
|
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();
|
.collect();
|
||||||
let g = match play(st, &mut ps) {
|
let g = match play(st, &mut ps) {
|
||||||
Ok(g) => g,
|
Ok(g) => g,
|
||||||
|
|
@ -131,16 +144,23 @@ fn main() {
|
||||||
for players in [2u8, 4, 6] {
|
for players in [2u8, 4, 6] {
|
||||||
println!("{players} players");
|
println!("{players} players");
|
||||||
println!(
|
println!(
|
||||||
" {:<32} {:<18} {:>5} {:>7} {:>6} {:>9}",
|
" {:<32} {:<18} {:>5} {:>5} {:>7} {:>7} {:>6} {:>9}",
|
||||||
"scenario", "mode", "won", "win/g", "pts", "threshold"
|
"scenario", "mode", "grdy", "obj", "win/g g", "win/g o", "pts", "threshold"
|
||||||
);
|
);
|
||||||
for s in &scenarios {
|
for s in &scenarios {
|
||||||
for (label, mode) in &modes {
|
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!(
|
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),
|
format!("{} {}", s.id, s.title),
|
||||||
|
g.group_success,
|
||||||
c.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.winners) / f64::from(c.games),
|
||||||
f64::from(c.total) / f64::from(c.games),
|
f64::from(c.total) / f64::from(c.games),
|
||||||
c.threshold,
|
c.threshold,
|
||||||
|
|
@ -150,6 +170,11 @@ fn main() {
|
||||||
println!();
|
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!("SCN_01 and SCN_02 are the same board — identical suits and");
|
||||||
println!("values at every priority. Their rows are expected to MATCH,");
|
println!("values at every priority. Their rows are expected to MATCH,");
|
||||||
println!("and a divergence means one of the two decks was edited.");
|
println!("and a divergence means one of the two decks was edited.");
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Action, DarvoTarget, GroundChoice, GroundCommand, GroundMode, GroundState, Relation, RoundStep,
|
Action, DarvoTarget, GroundChoice, GroundCommand, GroundMode, GroundState, Relation, RoundStep,
|
||||||
Selection, SupportResponse,
|
ScoringMode, Selection, SupportResponse,
|
||||||
};
|
};
|
||||||
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
use cb_kernel::{Actor, Aggregate, ChaChaRng, KernelRng, PlayerId, Rejection, Seed};
|
||||||
|
|
||||||
|
|
@ -1100,6 +1100,155 @@ mod tests {
|
||||||
// than removed, and carries why the numbers changed.
|
// 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]).
|
/// **A policy is bound by what its seat can see** ([ADR-0023]).
|
||||||
///
|
///
|
||||||
/// `Policy::choose` takes the whole `GroundState`, which carries every
|
/// `Policy::choose` takes the whole `GroundState`, which carries every
|
||||||
|
|
@ -1272,6 +1421,135 @@ mod blindness_tests {
|
||||||
assert!(err.contains("peeker"), "{err}");
|
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.
|
/// And the shipped policies are blind.
|
||||||
#[test]
|
#[test]
|
||||||
fn every_shipped_policy_is_blind_to_what_its_seat_cannot_see() {
|
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 {
|
mod tests {
|
||||||
use super::*;
|
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
|
/// **A proposed module is refused BY NAME, distinguishably from a
|
||||||
/// typo** (ADR-0022 D1).
|
/// typo** (ADR-0022 D1).
|
||||||
///
|
///
|
||||||
|
|
@ -292,23 +315,16 @@ mod tests {
|
||||||
/// statement about the edition, and the wrong-subject family again.
|
/// statement about the edition, and the wrong-subject family again.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_proposed_module_and_a_typo_are_different_errors() {
|
fn a_proposed_module_and_a_typo_are_different_errors() {
|
||||||
let cat = crate::catalog::catalog().expect("catalog.yaml");
|
let Some((aspect, module)) = a_module_we_cannot_run() else {
|
||||||
// A module the catalog has, with a rules_delta, that no kernel
|
// The kernel implements every module the catalog has. Nothing
|
||||||
// path implements. Found in the catalog, not hardcoded: if
|
// to distinguish, and nothing broken.
|
||||||
// ground-game implements it upstream this test looks elsewhere
|
return;
|
||||||
// rather than going stale.
|
};
|
||||||
let proposed = cat
|
|
||||||
.modules
|
|
||||||
.iter()
|
|
||||||
.find(|m| m.status == "proposed")
|
|
||||||
.expect("the catalog has at least one proposed module");
|
|
||||||
|
|
||||||
let mut c = Configuration::default();
|
let mut c = Configuration::default();
|
||||||
c.modules
|
c.modules.insert(aspect.clone(), module.clone());
|
||||||
.insert(proposed.aspect.clone(), proposed.module_id.clone());
|
|
||||||
let refused = c.resolve().expect_err("a proposed module must be refused");
|
let refused = c.resolve().expect_err("a proposed module must be refused");
|
||||||
assert!(
|
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}"
|
"a proposed module was not refused by name: {refused}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|
@ -318,10 +334,7 @@ mod tests {
|
||||||
|
|
||||||
// And a real typo says the other thing.
|
// And a real typo says the other thing.
|
||||||
let mut typo = Configuration::default();
|
let mut typo = Configuration::default();
|
||||||
typo.modules.insert(
|
typo.modules.insert(aspect, format!("{module}_zzz"));
|
||||||
proposed.aspect.clone(),
|
|
||||||
format!("{}_zzz", proposed.module_id),
|
|
||||||
);
|
|
||||||
let unknown = typo.resolve().expect_err("a typo must be refused");
|
let unknown = typo.resolve().expect_err("a typo must be refused");
|
||||||
assert!(
|
assert!(
|
||||||
unknown.contains("is not a module the catalog has"),
|
unknown.contains("is not a module the catalog has"),
|
||||||
|
|
@ -358,21 +371,12 @@ mod tests {
|
||||||
/// failure (ADR-0022 D0).
|
/// failure (ADR-0022 D0).
|
||||||
#[test]
|
#[test]
|
||||||
fn a_module_with_no_kernel_path_still_names_a_configuration() {
|
fn a_module_with_no_kernel_path_still_names_a_configuration() {
|
||||||
let cat = crate::catalog::catalog().expect("catalog.yaml");
|
let Some((aspect, module)) = a_module_we_cannot_run() else {
|
||||||
let proposed = cat
|
return;
|
||||||
.modules
|
};
|
||||||
.iter()
|
let c = Configuration::from_modules("ground-darvo-r0", std::slice::from_ref(&module))
|
||||||
.find(|m| m.status == "proposed")
|
.expect("selecting a catalog module must NAME a configuration");
|
||||||
.expect("a proposed module");
|
assert_eq!(c.module_on(&aspect), Some(module.as_str()));
|
||||||
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())
|
|
||||||
);
|
|
||||||
// It round-trips, so a recording can carry it.
|
// It round-trips, so a recording can carry it.
|
||||||
let s = serde_yaml::to_string(&c).unwrap();
|
let s = serde_yaml::to_string(&c).unwrap();
|
||||||
let back: Configuration = serde_yaml::from_str(&s).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.
|
/// 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.
|
// GR-E01/P03: a claimed Problem counts its printed value.
|
||||||
let total: u32 = self
|
let total: u32 = self
|
||||||
.problems
|
.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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 -->
|
<!-- 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
|
score. `unplayed` rather than `inert`: the modes score correctly, they
|
||||||
have simply never faced a seat that wanted to win alone.
|
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
|
- **F28 — SHARED GROUND's mastery rating counts cards where the mode
|
||||||
card counts points.** *"All claimed Problem cards form one shared score.
|
card counts points.** *"All claimed Problem cards form one shared score.
|
||||||
… For a mastery rating, subtract 1 for each Blame token still in play
|
… 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
|
```task
|
||||||
id: CB-WP-0049-T02
|
id: CB-WP-0049-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "7e3611bf-4cfb-412c-8e33-695778c7980b"
|
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
|
- **the objective is blind** (T01's control applies to anything that
|
||||||
reaches a policy).
|
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: a seat that plays it, and the F27 re-measurement
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: CB-WP-0049-T03
|
id: CB-WP-0049-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "a842c0a3-9374-4f08-8e2b-1571b8f241ef"
|
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
|
- **greedy's numbers are unchanged**, so the new policy is an addition
|
||||||
and not a silent edit of the published baseline.
|
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: module-aware evaluation
|
||||||
|
|
||||||
```task
|
```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";
|
control that separates "attends to the module" from "plays differently";
|
||||||
- **blind** (T01).
|
- **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
|
## Not done here
|
||||||
|
|
||||||
- **The trait still takes `&GroundState`.** ADR-0023 D2 chose the
|
- **The trait still takes `&GroundState`.** ADR-0023 D2 chose the
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue