CB-WP-0048 T02/T03/T04: Configuration replaces Variant in the state
Some checks failed
ci / check (push) Has been cancelled

The three-armed enum could not express a configuration carrying two
modules. It does now: --variant h1 expands to problem_stress.flat_any_open
AND attack_relief.self_soothe_ge4, and scoped_plus_attack_soothe plays.

Legacy names alias forever via serde(alias="variant") plus a
scalar-or-map deserialiser. serde(default) alone would have been a silent
migration bug -- every H1/H2 recording would have come back as baseline.
All 26 scenarios pass unchanged; none pins a state hash.

Three things shipped broken first, all caught by gates rather than by
reading:

1. `profile` was in the state hash. with_config(select("ground-darvo-r0"))
   sets profile=Some("baseline") where setup alone leaves None, so two
   states at THE SAME POINT IN ASPECT SPACE hashed differently and a
   replay bundle stopped reproducing its own initial state. Now
   serde(skip): a hash covers what determines play. This was the open
   judgement from T01 and it did not survive contact with the replay path.

2. A YAML parse inside the event loop. rules() -> resolve() -> catalog()
   re-parsed catalog.yaml per rule check; AM-6 fell to 9,345 events/s
   against a 100,000 target. OnceLock, and aspect validation moved to
   where a configuration is BUILT.

   Then I nearly optimised a phantom: 470k still looked like a 3x
   regression against the "~1.7M on bnt-lap001" reference in the gate's
   own message. Making rules() free measured 491k -- this machine's
   ceiling. Before optimising against a reference, measure the ceiling
   with the suspect code removed.

3. The refusal did not fire on the path a player takes.
   `--module problem_deal.pressure_deck` played a full baseline game and
   reported success, because with_config is a builder and fell back to
   the printed rules -- the silent no-op ADR-0022 exists to refuse. Every
   unit test of resolve() passed. The helper was tested and the driver
   was not, which is CB-WP-0033's finding verbatim. The new test asserts
   refusal BY NAME and that no game was played.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-09 00:54:34 +02:00
parent 704b99975b
commit 48a7bce04d
12 changed files with 538 additions and 67 deletions

View file

@ -110,7 +110,7 @@ could not express.
```task
id: CB-WP-0048-T02
status: todo
status: done
priority: high
state_hub_task_id: "3232a451-b60a-42f6-8200-b8115d4b35b9"
```
@ -127,11 +127,23 @@ Per ADR-0022 D2, through the catalog's own `legacy_experiment_id`.
- **the expansion comes from the catalog**, not a table in our source: a
second copy of ground-game's mapping is F25's shape.
**Done 2026-08-08.** `GroundState.variant: Variant` became
`config: Configuration`, with `#[serde(alias = "variant")]` plus a
scalar-or-map deserialiser so a recording written before this loads by
its old name and old spelling. **`serde(default)` alone would have been a
silent migration bug**: every recording played under H1 or H2 would have
come back as the baseline.
All 26 scenarios pass unchanged — none pins a state hash. `--variant h2`,
`--profile h2` and `--module problem_stress.scoped` all resolve to the
same point in aspect space, and `--variant h1` expands to **two** modules
on two aspects, which is the decomposition schema 2 exists for.
## Task: the resolved configuration is recorded
```task
id: CB-WP-0048-T03
status: todo
status: done
priority: high
state_hub_task_id: "5be13b74-bcc9-4697-96bb-91f1966c760b"
```
@ -147,11 +159,58 @@ logs and panel cells carry the **resolved** module list.
- **a bare field write cannot leave a module inert** — the H2 defect had
three call sites and this is the generalisation of it.
**Done 2026-08-08**, and the interesting part is what shipped broken
first.
### `profile` was in the hash, and it should not have been
The replay walk caught it within the hour: `with_config(select("ground-darvo-r0"))`
sets `profile = Some("baseline")` while `setup` alone leaves it `None`,
so **two states at the same point in aspect space hashed differently** and
a bundle stopped reproducing its own initial state. `profile` is now
`#[serde(skip)]`. A state hash should cover what determines play;
provenance of what was *requested* belongs in the recording's manifest.
This was the open judgement flagged when T01 landed. It did not survive
contact with the replay path, which is the right way for it to have been
settled.
### A YAML parse inside the event loop
`rules()` called `resolve()` called `catalog()`, which re-parsed
`catalog.yaml` **per rule check**. AM-6 throughput fell to **9,345
events/s** against a 100,000 target — a spec gate, caught the same run.
The catalog is an `include_str!` constant, so parsing it more than once
was never doing anything but work; it is a `OnceLock` now, and the aspect
validation moved out of `resolve` to where a configuration is *built*.
**And then I nearly optimised a phantom.** 470k events/s still looked
like a 3x regression against the *"~1.7M on bnt-lap001"* reference in the
gate's own message. Making `rules()` free and re-measuring gave **491k**
— this machine's ceiling. The reference is another machine, and the only
real regression was the one already fixed. The control is cheap and worth
naming: **before optimising against a reference, measure the ceiling with
the suspect code removed.**
### The refusal did not fire on the path a player takes
`--module problem_deal.pressure_deck` **played a full baseline game and
reported success.** `with_config` is a builder, cannot return an error,
and fell back to the printed rules — the silent no-op ADR-0022 exists to
refuse. Every unit test of `resolve()` passed throughout.
**The helper was tested and the driver was not**, which is exactly
CB-WP-0033's finding: *"the unit test proves the helper; only the
integration test proves the driver, and the driver was where the data
loss lived."* The driver resolves before dealing now, and the new test
asserts both that it refuses **by name** and that **no game was played**
a refusal that still deals is the same no-op wearing an error message.
## Task: the page and the panels speak aspects
```task
id: CB-WP-0048-T04
status: todo
status: done
priority: normal
state_hub_task_id: "ff135f86-2265-4db5-b412-23fcb68df489"
```
@ -164,6 +223,18 @@ state_hub_task_id: "ff135f86-2265-4db5-b412-23fcb68df489"
reconstructs;
- **a module nothing measured is reported unmeasured**, not absent.
**Done 2026-08-08.** The page names every live module —
`attack_relief.self_soothe_ge4 + problem_stress.scoped` where `h2` could
only ever have said one thing — and names the baseline by id when none
are live, because "no modules" and "we could not read the catalog" must
not look the same on the line a player uses to tell rules sets apart.
The four aspects sitting at their defaults are **declared omitted** from
the player page with a reason: an aspect at its default *is* the printed
game, and naming all four on every page is the wall of rules a player
stops reading. `cb-play inspect` prints all four, because a maintainer
diffing two states is asking a different question (ADR-0022 D3).
## Not done here
- **No policy reads the configuration.** F27 already records that the