clay-borg/workplans/CB-WP-0048-a-configuration-not-a-variant.md
tegwick 8ae68195b8
Some checks are pending
ci / check (push) Waiting to run
CB-WP-0049 T04: a seat that attends to the modules in play
ModuleAwarePolicy reads the RESOLVED Rules -- one policy, not one per
module. H2AwarePolicy would have been the blob schema 2 exists to
retire, rebuilt a layer up; reading Rules means a two-module
configuration gets both terms and a future module is one arm here rather
than a new policy per combination.

Two terms, both reading the table: prefer the Problem whose Stress falls
on me (problem_stress.scoped), and treat ATTACK as a Stress tool at the
gate (attack_relief.self_soothe_ge4). stress_scope and the owner marker
are public regardless of the card's face -- the H2 rules place the
marker ON the card -- so it is blind by construction.

THE CONTROL I CLAIMED WAS REAL WAS VACUOUS, AND MUTATION SAID SO.
"Under the baseline the two must be identical" swept fresh deals across
36 combinations, and forcing the scope term to fire regardless of
configuration left it GREEN. At a fresh deal only the Surface Problem is
face up, so exactly one SOLVE is legal and no ranking term can move the
argmax. The control could not distinguish the property from its
negation.

Both tests now use a BUILT position -- two face-up, unclaimed, equally
valuable Problems differing only in scope -- under H2 for divergence and
under the baseline for the control. The mutation goes red there.

That is also a finding about the module: the scope term is INERT at
round-one positions, so anything measuring H2 with round-one-heavy play
is measuring a mechanism that has not started. Recorded, not tuned away.

Also: CB-WP-0048 was `active` with every task closed; loop-lint's
lifecycle rule caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 01:25:22 +02:00

11 KiB
Raw Blame History

id kind title status state_hub_workstream_id
CB-WP-0048 product A configuration, not a variant done 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, adopting ground-game catalog schema 2 per CB-RES-0010.

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

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

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

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

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

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.