epiphany/spec/CONTRACT_GENESIS_G2B_TUNING.md

362 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Contract: Genesis G2b — `SetTuningContext`, and the accept-set raise it pays for
Repo root `/home/jeans/Repos/active/epiphany`. Governed by
`spec/RULING_GENESIS_PERSISTENCE.md`, `spec/PLAN_GENESIS_OPS.md` §4, and
`spec/PASS13_CANDIDATES.md` (P13-S13, which **closes** here).
**One operation, one kind, one tag, one accept-set raise.** G2 was split
precisely so this surface could be isolated: minimal stamping is a pure
function of each payload's value, and `ScoreTuningContext` is the **only**
genesis payload born at schema major 3. The raise is charged here, not
amortised across nine surfaces as the ruling's framing first implied.
**Prerequisite, already met:** G-minor landed at `ff9bd0f`. This contract
appends kinds/tags **34**, which is why it sequences after that sweep — running
it first would have shipped 34 with the very defect G-minor retired.
**Parallel safety.** The editor track owns `crates/epiphany-editor-gui/**`,
`crates/epiphany-render-svg/**`, `crates/epiphany-glyphs/**`,
`crates/epiphany-testkit/benches/editor_pipeline.rs`, `spec/PLAN_EDITOR_APP.md`,
every `spec/CONTRACT_EDITOR_*.md`, `spec/DRAFT_T4_FIXTURE_RECIPE.md`,
`spec/ANALYSIS_*.md`, `crates/epiphany-editor-gui/goldens/*.png`, and the entire
`spikes/` tree. **All out of scope. Stage only the files this contract names.**
**One-time authorization, not generalising:** this packet **will** need to edit
`crates/epiphany-editor-core/src/barriers.rs` (`subjects_of`) and
`crates/epiphany-layout-ir/src/barrier.rs`. Rust exhaustiveness forces the
first, and the second holds a "one past the vocabulary" literal. **Budget both
up front** — this is the G1 lesson, where an unbudgeted crossing blocked the
entire gate because `epiphany-testkit` depends on `epiphany-editor-core`.
---
## 1. The pin, RESOLVED — subset, not normalization
**This was the open pin. It is resolved here, in the contract, not left to the
implementation.**
### The hazard
`ScoreTuningContext` has six fields, but its `Codec` **deliberately encodes
five** (`core/src/codec.rs:1939`): `default_pitch_space`,
`default_tuning_system`, `reference`, `smufl`, `overrides`.
`accidental_extensions` is staged out of schema major 3 and `dec` reconstructs
it as `Vec::new()` (`graph.rs:1676` documents it "**In memory only**").
A naïve full-value `SetTuningContextOp(ScoreTuningContext)` therefore diverges:
* **Live session** — `OperationSet::accept` stores the envelope as a **value**
(`opset.rs`, `OperationSlot::Single(env)`), so reduction applies
`accidental_extensions` intact.
* **Same document reloaded** — the envelope round-trips through bytes, the
field decodes as empty, and reduction applies an empty one.
Same document, two graph states, depending only on whether you just authored it.
**`canonical_value!` structurally cannot catch this.** Its generated
`decode_canonical` does decode → `finish()` → re-encode → reject-on-mismatch,
comparing **bytes** and never the originating value. A field that never reached
the bytes is invisible to it. This is not a gap to plug; it is what that macro
is.
### The ruling: **subset**
`SetTuningContextOp` carries a **new payload type holding exactly the five
wire-bearing fields**, in the codec's existing order. Reduction writes those
five onto `score.tuning_context` and **leaves `accidental_extensions`
untouched**, preserving whatever the graph already held.
Four reasons, in order of weight:
1. **The wire bytes are identical either way, so this costs no layout design.**
`ScoreTuningContext::enc` already encodes exactly those five fields in
exactly that order. The subset type's canonical encoding is byte-for-byte
the current one. This is a **type-level narrowing, not a new wire form**
`canonical_value!` still applies and the G1/G2a payload template is unchanged.
2. **It makes the divergence unrepresentable.** Normalization would make
correctness depend on remembering to clear a field at every construction
site, enforced by nothing the compiler or the codec can see — and this track
has now been burned twice by exactly that shape (four stale literal sites at
Push 4a, six found during G2a). A field that does not exist cannot be set
wrongly.
3. **Preserving is semantically right; clearing is not.** The operation carries
no information about `accidental_extensions`. Normalization would make
"set the tuning context" silently *erase* a live in-memory registry
extension — an effect the author never asked for and the wire cannot even
record.
4. **The forward path is identical.** When a later schema major lands
`accidental_extensions` on the wire, the subset type gains the field: a
normal major payload change, the same cost normalization would have paid.
**Rejected: normalization** (clear the field at construction) — fails 2 and 3.
**Rejected: reject-on-non-empty** (`well_formed` refuses a populated field) —
turns an in-memory-only field into an authoring error for callers who never
opted into persistence, and still needs the check maintained by hand.
**Evidence this is low-risk today:** nothing in production populates
`accidental_extensions` — every non-`Vec::new()` reference in the tree is a
test (`codec.rs:3725`, `textvalue_graph.rs:1092`). The **text projection drops
it too** (`textvalue_graph.rs:1123`), so it already survives no persistence
surface at all. The subset type ratifies the status quo rather than changing it.
**RATIFIED 2026-07-28:** the subset design, and the type's name and path —
**`epiphany_core::TuningContextSettings`**. It lives in `epiphany-core`
alongside the type it narrows, and is publicly exported at that exact path.
---
## 2. Design pins
### Pin 1 — kinds and tags are 34, in both spaces
`OperationKind::SetTuningContext` = **34** (`payload.rs`, the hand-written
`discriminant()` match at :275 — **the exact site Push 4a got wrong**) and
`OperationKindTag::SetTuningContext` = **34** inside
`operation_kind_tag_vocabulary!`. They coincide numerically here; that is a
coincidence, not an alignment. **Two rows, never one.**
### Pin 2 — `schema_major()` gains a real arm, unlike G2a
G2a's pin 3 said `schema_major()` gains **no** arm, because both setters were
major 0 and fell through the catch-all. **G2b is the opposite.**
`SetTuningContext` must return **3**, unconditionally — `ScoreTuningContext`'s
wire form is born at major 3 and its appends are mandatory, not `Option`-hidden.
This is the single fact the whole rung is charged for.
### Pin 3 — the accept-set raise, and the prose that must move with it
`max_supported_major(ChunkKind::OperationEnvelopeBlock)` goes **2 → 3**
(`bundle/src/bundle.rs:69`).
**The doc comment above it does not merely document the cap — it asserts a
claim this rung falsifies.** `bundle.rs:56-58` currently reads: *"Schema major 3
(Push 4b tranche 3b-i) does **not** raise this role: no operation payload embeds
the tuning context, so no op block is ever born at v3."* After G2b that sentence
is **false**. Rewrite it; do not leave it beside a changed number. A stale
rationale next to a correct constant is how `binary_format.tex:2373` happened.
### Pin 4 — G-minor interaction: kind 34 needs an epoch
G-minor's `introduced_minor()` is **exhaustive with no wildcard arm**, so
kind/tag 34 **cannot compile** without an epoch. That is the control working as
designed — do not defeat it.
**Epoch 10, for the genesis G2b event — RATIFIED 2026-07-28** and already
recorded in the authoritative ladder (`PLAN_GMINOR_SCHEMA_MINOR.md` §4).
**Transcribe it; do not edit that plan.** This is the first exercise of the
ladder's own growth path, and it stayed monotonic and prefix-closed.
### Pin 5 — undo restores the **seeded base**, and the never-authored /
authored-to-default distinction must stay **unobservable**
**An earlier draft of this contract had this backwards.** It required undo to
distinguish "never authored" from "authored to the default value".
`spec/PLAN_GENESIS_OPS.md` §5 trap 5 **withdrew** that on 2026-07-28, and
`SetMetadata` is the disproof: `Score::empty` seeds `metadata` exactly as it
seeds `tuning_context` (`graph.rs:1749`, `:1757`), and base ingest then runs
`metadata_chain.seed(score.metadata.clone())` (`reduce.rs:1385`) precisely so a
value-restoring undo of the *first* operational write restores the
pre-operational state.
**The rule:**
* From-empty reduces **onto** `Score::empty` (via `reduce_operation_set_onto`),
so **the seed runs**. Undoing the first write yields
`Restore(Some(Predecessor::Base(seeded)))`.
* **Undo restores the seeded base settings — default or non-default alike.**
Restoring the seeded value is correct in *both* cases, so the distinction is
unobservable, **and this contract must keep it so.** Writing code that can
tell them apart is a defect, not a safeguard.
* **`Predecessor::Base` vs `::Write` earns its keep only for the canonical
bookkeeping families** (`spellings`, `breaks`, `page_breaks`,
`reduce.rs:707`), where a base predecessor returns a **map key to absence**.
`ScoreTuningContext` is an **always-valued** `Score` field, like `metadata`:
**there is no absent state to return to.**
* **Copy `set_metadata` structurally**, as all three G2 setters do. Do not
invent a chain shape for this field.
The chain's value type is the **subset type**, not `ScoreTuningContext`:
`accidental_extensions` never participates in undo, because no operation ever
writes it and undo must leave it exactly as it found it.
### Pin 6 — P13-S13 closes here
Mark it resolved in `spec/PASS13_CANDIDATES.md`, citing this rung. Its closure
argument is the `metadata` precedent: the op log is canonical, and the canonical
base embeds no graph values for **any** field.
### Pin 7 — the four-document append ritual applies in full
**This is the G2a lesson and it is not optional.** An operation-vocabulary
append is a documented event in **four** specification documents:
* `binary_format.tex` — payload-layout table, tag table, **and** the §2373
minor-additive bullet (now G-minor-repaired; kind 34 extends its history),
version, Revision History row;
* `operation_catalog.tex` — a `\section`, version, changelog paragraph;
* `core_spec.tex` — the **normative** `OperationKind` *and* `OperationKindTag`
listings, plus any spelled-out payload count;
* `text_projection.tex` — a kind production and **`COMPANION_VERSION`
0.10.0 → 0.11.0**.
**The signature to grep for: a spelled-out count sitting next to an
enumeration that disagrees with it.**
### Pin 8 — the hand-maintained sites, budgeted up front
Five non-compiler-checked sites (`layout_stub`'s is now derived):
1. `ops/src/payload.rs``OperationKind::discriminant()`;
2. `layout-ir/src/barrier.rs` — the "one past the vocabulary" literal;
3. `testkit/tests/text_projection_grammar.rs` — a kind **count**;
4. `ops/src/textproj_kind.rs` — a kind **count**;
5. `testkit/src/generators.rs` — an `rng.below(N)` bound.
Plus `editor-core/src/barriers.rs`'s `subjects_of`, which the compiler forces.
**Prefer deriving over extending** wherever the site allows it.
### Pin 9 — explicit non-goal: G2b authorizes **no** pruning or compaction
**G2b does not authorize pruning or compaction of the canonical operation
log**, and nothing in this packet may implement, enable, or prepare either.
`spec/PLAN_GENESIS_OPS.md` §3 requires this stated outright, and the reason is
that G2b **sharpens** the standing prohibition rather than relaxing it. Before
this rung, pruning was a performance concern: it would have discarded
re-derivable state. **The moment G2b lands, pruning would discard *authored
genesis state*** — the tuning context exists canonically only in the op log,
because the canonical base embeds no graph values for any field.
The prohibition remains blocked on **disposition C** (the canonical base
carrying graph values). There is no `fn prune` in the tree today, which is
exactly why the prohibition is free now and would be catastrophic later. **If
the implementation finds itself wanting a prune or compaction step for any
reason, that is a finding to report, not a feature to add.**
### Pin 10 — P13-S15 stays open
The golden lock ends at 29 and **this rung must not extend it**. Adding 34 while
3033 remain unlocked would make the table look maintained while the gap
persists. S15 lands as its own rung with its own mutation evidence.
---
## 3. Touch table
**Derived from G2a's actual footprint** (`git show 7df5ca1 --name-only`, thirty
files) plus G1's one extra, then extended for what G2b does that neither did.
It is a **floor, not a ceiling** — a touch outside it is a finding to report,
but a file here that turns out not to need changing is fine, said plainly.
### Core — the new type
| # | File | Change |
|---|---|---|
| 1 | `core/src/graph.rs` | **new `TuningContextSettings`** (the five wire-bearing fields, in codec order); doc it as the authored subset of `ScoreTuningContext` and say why `accidental_extensions` is absent |
| 2 | `core/src/lib.rs` | **public export** `epiphany_core::TuningContextSettings` (the ratified path) |
| 3 | `core/src/codec.rs` | its `Codec`**byte-identical to `ScoreTuningContext`'s existing five-field walk**; add the assertion that the two encodings agree |
| 4 | `core/DECISIONS.md` | the subset ruling and its rationale |
### Ops — the operation
| # | File | Change |
|---|---|---|
| 5 | `ops/src/payload.rs` | `SetTuningContext` **kind 34** in the enum, the hand-written `discriminant()` match, `schema_major()`**3** (pin 2), `tag()`, tag **34** inside `operation_kind_tag_vocabulary!`, and **`introduced_minor()` → epoch 10 in both spaces** (pin 4) |
| 6 | `ops/src/reduce.rs` | the `WriteChain<TuningContextSettings>` — decl, snapshot, init, **seed**, setter, undo verdict, restoration apply, working-snapshot save/restore. **Copy `set_metadata` structurally** (pin 5) |
| 7 | `ops/src/envdecode.rs` | envelope decode arm |
| 8 | `ops/src/migrate.rs` | migration arm |
| 9 | `ops/src/v0.rs` | v0-catalog arm |
| 10 | `ops/src/valuegen.rs` | a generator for the payload |
| 11 | `ops/src/vectors.rs` | **decode vectors pinned to literal bytes, not round-trip** (plan trap 4: a self-consistent reorder passed 1283 tests and 8/8) |
| 12 | `ops/src/fuzz.rs` | fuzz arm |
| 13 | `ops/src/textproj_kind.rs` | the kind **count** — hand-maintained (pin 8) |
| 14 | `ops/src/lib.rs` | public exports |
| 15 | `ops/DECISIONS.md` | the rung's decisions |
### Bundle — the accept-set raise (**new to G2b; G2a touched no bundle file**)
| # | File | Change |
|---|---|---|
| 16 | `bundle/src/bundle.rs` | `max_supported_major(OperationEnvelopeBlock)` **2 → 3** (:69) **and the rewritten rationale at :53-58** whose current text this rung falsifies (pin 3) |
| 17 | `bundle/DECISIONS.md` | why the raise is charged to this surface alone |
### Boundary crossings — budgeted up front (pin 8)
| # | File | Change |
|---|---|---|
| 18 | `editor-core/src/barriers.rs` | `subjects_of` arm — **compiler-forced**; without it the workspace does not build and the gate cannot run at all |
| 19 | `layout-ir/src/barrier.rs` | the "one past the vocabulary" literal → 35 |
| 20 | `testkit/src/generators.rs` | the `rng.below(N)` bound |
| 21 | `testkit/src/layout_stub.rs` | confirm the derived `PAYLOAD_FREE` path still holds — **prefer deriving over extending** |
| 22 | `testkit/tests/text_projection_grammar.rs` | the kind **count** |
### Text projection
| # | File | Change |
|---|---|---|
| 23 | `textproj/src/parse.rs` | the kind production |
| 24 | `textproj/src/project.rs` | the projection arm (G1 touched this; G2a did not — **check, don't assume**) |
| 25 | `textproj/src/vectors.rs` | vector coverage for the new kind |
| 26 | `textproj/src/lib.rs` | **`COMPANION_VERSION` 0.10.0 → 0.11.0** |
### Normative documents — the four-document ritual (pin 7)
| # | File | Change |
|---|---|---|
| 27 | `spec/binary_format.tex` | payload-layout row, tag row, the **accept-set/major-3 text**, the §2373 minor-additive history (kind 34 / epoch 10), version, Revision History row |
| 28 | `spec/operation_catalog.tex` | a `\section{SetTuningContext}`, version, changelog |
| 29 | `spec/core_spec.tex` | the **normative `OperationKind` *and* `OperationKindTag` listings**, plus any spelled-out payload count |
| 30 | `spec/text_projection.tex` | kind production, companion 0.11.0, changelog |
| 3134 | `spec/binary_format.pdf`, `operation_catalog.pdf`, `core_spec.pdf`, `text_projection.pdf` | **all four regenerate and are committed** — every `.tex` source changes here |
### Vectors and tracking
| # | File | Change |
|---|---|---|
| 35 | `spec/vectors/decode_vectors.txt` | regenerate |
| 36 | `spec/vectors/textproj_document_vectors.txt` | regenerate |
| 37 | `spec/PASS13_CANDIDATES.md` | **close P13-S13**, citing this rung and the `metadata` precedent (pin 6) |
| 38 | `spec/PLAN_GENESIS_OPS.md` | mark G2b complete; ladder advances to G3 |
**Already done by the ratification, do NOT redo:** epoch 10 is recorded in
`spec/PLAN_GMINOR_SCHEMA_MINOR.md` §4. Transcribe it into code; do not edit that
plan.
## 4. Tests — each with the mutation that must kill it
| # | Test | Required mutation |
|---|---|---|
| t1 | Kind and tag are both 34, and the discriminant byte leads the canonical encoding | Move either to 35; must fail |
| t2 | `schema_major()` returns **3** for `SetTuningContext` | Return 0 (the pre-G2b catch-all behaviour); must fail |
| t3 | A block containing one `SetTuningContext` stamps major **3** | Make `schema_major` ignore the new arm; must fail |
| t4 | That block is **admitted** by the accept-set | Revert the cap to 2; must fail |
| t5 | **The subset pin holds**: an op authored live and the same op reloaded from bytes produce **identical** graph states, with a pre-existing non-empty `accidental_extensions` **preserved across both** | Make the payload carry the full `ScoreTuningContext`; must fail — this is the pin's whole reason for existing |
| t6 | Undo restores the previous five-field value | Restore the default instead of the predecessor; must fail |
| t7 | Undo of the **first** authoring restores the **seeded base** settings, and does so identically whether the seed was the default or a non-default value — with `accidental_extensions` untouched throughout | Skip `seed()` on the tuning chain during base ingest, so the first undo yields `NotWritten` instead of `Restore(Base)`; must fail. **Assert the two cases are indistinguishable**, not that they differ |
| t8 | Kind 34 carries **epoch 10** and a block containing it stamps minor 10 | Assign epoch 9; must fail |
| t9 | The from-empty spine still reaches a note, now with a tuning context authored | Replace the dispatch arm with `OperationKind::SetTuningContext(_) => OperationEffect::Applied` (`reduce.rs`), bypassing tuning reduction while keeping the match exhaustive; must fail on the **authored tuning-context** assertions while the spine ops stay applied and the note stays reachable. **Corrected 2026-07-29:** this row previously said "mutation is t2's", which the sweep disproved — t9 stayed green under t2's `schema_major` change, because the spine reaches a note regardless of stamping. A shared mutation that does not kill the test signs nothing |
| t10 | The accept-set prose no longer claims no payload embeds the tuning context | Grep-assert the stale sentence is absent; restore it to see the test fail |
---
## 5. Gate
`cargo fmt --check`; `cargo clippy --workspace --all-targets` **0 warnings**;
`cargo test --workspace` green with the count; conformance suites with counts
including `[7f]`; decode vectors regenerated with the count; text-projection
vectors regenerated; **all four PDFs at 0 undefined references**, committing
`binary_format.pdf`, `operation_catalog.pdf`, `core_spec.pdf` and
`text_projection.pdf` (**all four `.tex` sources change here**, unlike G-minor);
and `git status --short` **before and after**, whose only differences may be
the §3 touch table — the editor track's dirt must appear unchanged
in both.
## 6. Report
The gate outputs; every mutation with the failure actually observed; any touch
outside this contract; and **any place where this contract's own assumptions
turned out to be wrong**. Required, not politeness: G1 shipped five normative
falsehoods because its contract said "no `binary_format.tex`", and G2a's
"derived by enumerating every `SetMetadata` site" was false with the evidence
already in a grep that had been read.