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>
254 lines
11 KiB
Markdown
254 lines
11 KiB
Markdown
---
|
||
id: CB-WP-0048
|
||
kind: product
|
||
title: "A configuration, not a variant"
|
||
status: active
|
||
state_hub_workstream_id: "44ceb8d8-e6e2-46d6-a694-fd275804b771"
|
||
---
|
||
|
||
# Purpose
|
||
|
||
```
|
||
structural tier M (changes the state and therefore the recording, and
|
||
replaces the selector every consumer names)
|
||
declared tier M
|
||
```
|
||
|
||
Implements [ADR-0022](../decisions/ADR-0022-a-configuration-is-a-point-in-aspect-space.md),
|
||
adopting ground-game catalog **schema 2** per
|
||
[CB-RES-0010](../research/CB-RES-0010-game-aspects-and-design-space.md).
|
||
|
||
## The gap, stated exactly
|
||
|
||
`scoped_plus_attack_soothe` is a profile in the vendored catalog **today**.
|
||
clay-borg cannot name it — not "has not implemented", *cannot express*.
|
||
`Variant` has three arms; H1 bundles flat pressure with self-soothe and
|
||
cannot be taken apart, H2 gives scoping alone, and their product has no
|
||
arm. Every future combination has the same problem.
|
||
|
||
The kernel is not the obstacle: **four sites branch on the variant and
|
||
each belongs to exactly one aspect** (ADR-0022 Context). The selector is
|
||
the blob.
|
||
|
||
## Task: the mirror is held by the gate
|
||
|
||
```task
|
||
id: CB-WP-0048-T00
|
||
status: done
|
||
priority: high
|
||
state_hub_task_id: "fe1a39f4-d123-4c83-be64-35901603edcd"
|
||
```
|
||
|
||
**Done 2026-08-08.** The schema-2 mirror (`catalog.yaml`, `ASPECTS.md`,
|
||
`CATALOG.md`, `modules/`) arrived vendored with **no recorded digests** —
|
||
19 files — and `make edition-check` was red on the working tree.
|
||
|
||
- Digests are now **generated by walking `editions/`**, not typed. Two
|
||
reviews (CB-REV-0002 #8, CB-REV-0003 #8) already found hand-written
|
||
lists that made their own controls vacuous; a mirror that grows a
|
||
*directory* is precisely the case a maintained list loses.
|
||
- `editions/PROVENANCE-catalog.md` was a file **inside the mirrored tree
|
||
that upstream does not have**, which the gate correctly refused as
|
||
unexplained. Folded into `editions/ground-darvo-r0/PROVENANCE.md` —
|
||
provenance *about* the mirror is ours, so it does not live inside the
|
||
thing it describes.
|
||
|
||
**Noted, not fixed:** `vendored_files()` matches only `.csv` inside the
|
||
edition directory, so a non-CSV added *there* is invisible to both the
|
||
digest and the freshness check. The sibling walk has no such hole. The
|
||
exposure is one file today (`PROVENANCE.md`, ours) — but this is F26's
|
||
shape again and should be closed on its own terms.
|
||
|
||
## Task: `Configuration` and `Rules`
|
||
|
||
```task
|
||
id: CB-WP-0048-T01
|
||
status: done
|
||
priority: high
|
||
state_hub_task_id: "4b8bfc25-0e4d-4fdc-9dad-5cdb66798207"
|
||
```
|
||
|
||
Per ADR-0022 D1. Identity as data, behaviour exhaustive, `resolve()`
|
||
between them.
|
||
|
||
**Done 2026-08-08.** `catalog.rs` reads schema 2 (aspects, modules,
|
||
profiles) and **refuses a schema it does not understand by name** — a
|
||
reader that silently accepted schema 1 would answer questions about
|
||
aspects over a file that has none. `config.rs` holds `Configuration`
|
||
(identity), `Rules` (behaviour) and `resolve()`.
|
||
|
||
Nothing about the aspect list, the defaults, or which modules are
|
||
proposed is written in our source; all of it is read. The tests locate
|
||
the proposed module **by searching the catalog for `status: proposed`**,
|
||
so if ground-game implements it upstream the test looks elsewhere rather
|
||
than going stale.
|
||
|
||
| mutation | what went red |
|
||
|---|---|
|
||
| the two errors become one sentence | *"a module the catalog HAS was reported as unknown"* |
|
||
| one-per-aspect check removed | *"two modules on one aspect must be refused"* |
|
||
| identity refuses what it cannot run | *"selecting a catalog module must NAME a configuration"* |
|
||
| legacy lookup stops reading the catalog | *"h2 and profile h2 are not the same point in aspect space"* |
|
||
|
||
`scoped_plus_attack_soothe` now resolves — `problem_stress.scoped` ×
|
||
`attack_relief.self_soothe_ge4`, the combination the three-armed enum
|
||
could not express.
|
||
|
||
**Controls:**
|
||
- **a proposed module is refused BY NAME**, distinguishably from a typo —
|
||
the two-error requirement is the reason this shape was chosen, so it is
|
||
the first test;
|
||
- **two modules on one aspect are refused** (GAME↔MODEL validation);
|
||
- **an aspect the catalog has and the kernel does not know still parses**,
|
||
because a configuration must be nameable before it is runnable;
|
||
- **`Rules` has no catch-all arm**, so a new module cannot fall through
|
||
silently (CB-WP-0034's exhaustiveness, which caught two commands before
|
||
any test ran);
|
||
- mutation-proven, each control against its own defect.
|
||
|
||
## Task: legacy ids alias forever
|
||
|
||
```task
|
||
id: CB-WP-0048-T02
|
||
status: done
|
||
priority: high
|
||
state_hub_task_id: "3232a451-b60a-42f6-8200-b8115d4b35b9"
|
||
```
|
||
|
||
Per ADR-0022 D2, through the catalog's own `legacy_experiment_id`.
|
||
|
||
**Controls:**
|
||
- **all 26 recordings replay byte-identically**, which is the whole
|
||
constraint — `make sim` is the authority;
|
||
- **`--variant h2` and `--profile h2` produce the same resolved
|
||
configuration**, asserted on the resolved object rather than on
|
||
behaviour, so the equivalence is exact and not a coincidence of
|
||
outcomes;
|
||
- **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: done
|
||
priority: high
|
||
state_hub_task_id: "5be13b74-bcc9-4697-96bb-91f1966c760b"
|
||
```
|
||
|
||
Per ADR-0022 D3 and CB-RES-0010 §5.3: `cb-play` sessions, replays, trial
|
||
logs and panel cells carry the **resolved** module list.
|
||
|
||
**Controls:**
|
||
- **the recorded configuration is the applied one**, proven the way
|
||
CB-WP-0046 proved the trial-log stamp — from the state, not the flag;
|
||
- **the trial-log marker carries the configuration**, closing the gap
|
||
CB-WP-0047 left open (variant stamped, scenario and mode not);
|
||
- **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: done
|
||
priority: normal
|
||
state_hub_task_id: "ff135f86-2265-4db5-b412-23fcb68df489"
|
||
```
|
||
|
||
**Controls:**
|
||
- **the table names every live module**, not a legacy id — the CB-WP-0044
|
||
family: a rules change the page cannot name is reported as "no changes";
|
||
- **panels facet by aspect** (CB-RES-0010 §5.4), so "hold all aspects
|
||
fixed, vary one" is what the harness does rather than what a reader
|
||
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
|
||
three scoring modes produce identical play because `GreedyPolicy` never
|
||
consults `state.mode`; the same will be true of every aspect. Selection
|
||
without aspect-aware policies gives configurations we can run and cannot
|
||
evaluate — CB-RES-0010 §5.4's last bullet is F27 restated. **This is the
|
||
next pass, and it is a prerequisite for measuring any new module**, not
|
||
a nicety.
|
||
- **`end_condition` and `problem_deal` get no kernel path here.** Both
|
||
their non-default modules are `status: proposed`; T01 makes them
|
||
nameable and refusable, which is what ADR-0022 asks for and all it asks
|
||
for.
|
||
- **`specs/Taxonomy.md` does not yet distinguish aspect from stratum.**
|
||
ADR-0022 D0 states the distinction; the taxonomy is where it belongs,
|
||
and without it the two vocabularies will be crossed in exactly the way
|
||
the taxonomy exists to prevent.
|