224 lines
12 KiB
Markdown
224 lines
12 KiB
Markdown
# CONTRACT — Push 4b tranche 3b-i: the score wire (schema major 3)
|
||
|
||
**Status:** dispatch-ready. Ratified by the user 2026-07-23: (1) **split** 3b into
|
||
3b-i (this — the core score wire) then 3b-ii (the layout-ir `SmuflVersion`
|
||
unification + `GlyphCatalogIdentity` move); (2) **stage** — freeze `smufl` and
|
||
`overrides` on the major-3 wire now, **hold `accidental_extensions` in memory**
|
||
until its engrave consumer exists (a later major bump appends it).
|
||
|
||
This tranche opens **schema major 3** and is **irreversible**: every byte layout
|
||
it defines is frozen forever under `req:binfmt:frozen-layout`. 3b-ii is a
|
||
separate dispatch and MUST NOT be started here.
|
||
|
||
---
|
||
|
||
## What this tranche does, in one sentence
|
||
|
||
`ScoreTuningContext` grows two wire fields — `smufl` and `overrides` — moving them
|
||
from in-memory-only (tranches 2/3a) onto the canonical score wire; the reader
|
||
gains a v2→v3 migration; nothing else on the wire changes.
|
||
|
||
## The scope is much smaller than the original 3b sketch — two findings
|
||
|
||
1. **No operation embeds the tuning context.** A full-workspace search
|
||
(`ScoreTuningContext`/`TuningOverride`/`tuning_context`/`SmuflVersionRequirement`
|
||
across `epiphany-ops`) finds nothing. The tuning context reaches the wire
|
||
**only** through the full-`Score` acceleration snapshot. Therefore:
|
||
- The **operation-block minimal-stamping machinery is untouched.** No op
|
||
payload is "born at v3"; no frozen v2 op-payload decoder is needed.
|
||
- `max_supported_major(ChunkKind::OperationEnvelopeBlock)` **stays 2.** Only
|
||
`ChunkKind::Snapshot` rises to 3.
|
||
- The **canonical base** (`MaterializedState`) embeds no tuning context, so it
|
||
stays major 0, byte-identical — the same keystone as majors 1 and 2.
|
||
2. **Staging removed the accidental subtree.** Only `smufl` +
|
||
`overrides` are frozen. `accidental_extensions` and its whole subtree
|
||
(`ScoreAccidentalExtensions`, `PitchSpaceModification`, `AccidentalEngraving`,
|
||
`AnchorPoint`, …) stay in-memory-only, exactly as they are today. **Do not
|
||
write a `Codec` for any of them.**
|
||
|
||
## The permanent decision: the frozen major-3 wire layout
|
||
|
||
`ScoreTuningContext` on the wire, **in this exact order** (append-after-existing
|
||
per the frozen-layout rule; the Rust struct's *declaration* order is decoupled
|
||
and stays as-is — the hand codec fixes wire order independently, per the note at
|
||
`graph.rs:1655`):
|
||
|
||
```
|
||
ScoreTuningContext(v3) =
|
||
default_pitch_space ⌢ default_tuning_system ⌢ reference (the frozen v0..v2 prefix)
|
||
⌢ smufl (NEW — SmuflVersionRequirement)
|
||
⌢ overrides (NEW — Vec<TuningOverride>, u32 count)
|
||
```
|
||
|
||
`accidental_extensions` is **NOT** on the wire. When it lands at a future major it
|
||
appends **after** `overrides`. Update the doc comment at `graph.rs:1655-1661` to
|
||
say this (currently it claims the "eventual major-3 field order" is
|
||
`(accidental_extensions, smufl, overrides)` all together — now false: major 3 is
|
||
`smufl ⌢ overrides`, accidental_extensions is staged to a later major and appends
|
||
last).
|
||
|
||
### New leaf wire layouts (four `Codec` impls, all over types whose members
|
||
already encode — no transitive new codecs):
|
||
|
||
- `SmuflVersion` = `major` (u16 LE) ⌢ `minor_centi` (u16 LE).
|
||
- `SmuflVersionRequirement` = `minimum` (SmuflVersion) ⌢ `authored_against` (SmuflVersion).
|
||
- `TuningScope` = one discriminant byte ⌢ body:
|
||
- `0` `Voice(VoiceId)`; `1` `Staff(StaffId)`; `2` `Region(RegionId)`;
|
||
- `3` `Range { start: TimeAnchor, end: TimeAnchor, voices: VoiceSelector }`
|
||
(fields in declaration order; `TimeAnchor` @ codec.rs:748, `VoiceSelector`
|
||
@ codec.rs:1152 already encode).
|
||
- `TuningOverride` = `scope` (TuningScope) ⌢ `pitch_space` (Option<PitchSpaceId>)
|
||
⌢ `tuning_system` (Option<TuningSystemId>) ⌢ `reference` (Option<ReferencePitch>).
|
||
(One presence byte per Option; the inner id/pitch types already encode.)
|
||
|
||
Match the codebase's conventions: LE integers, one discriminant byte per union,
|
||
`u32` counts/length prefixes, one presence byte per `Option`. Prefer
|
||
`struct_codec!` / the enum-codec macro where the shape allows; hand-write only
|
||
`TuningScope` if the macro can't express the `Range` struct variant. Every one of
|
||
these is **permanent** — get the field order right.
|
||
|
||
## The migration (v2 → v3)
|
||
|
||
Only `ScoreTuningContext` changes v2→v3; every other `Score` field is byte-identical.
|
||
|
||
1. **Update the live `impl Codec for ScoreTuningContext`** (codec.rs:1854): `enc`
|
||
writes the 5 wire fields (3 existing ⌢ `smufl` ⌢ `overrides`); `dec` reads all
|
||
5 and default-fills **only** `accidental_extensions: Vec::new()`. This is now
|
||
the **v3** form. Rewrite the block comment above it (codec.rs:1838-1853) to
|
||
describe the staged wire (smufl+overrides on; accidental_extensions off).
|
||
|
||
2. **Freeze the current 3-field form** as a named sub-codec:
|
||
- `fn dec_tuning_context_v2(r) -> Result<ScoreTuningContext>` = today's exact
|
||
3-field read, default-filling all three in-memory fields
|
||
(`accidental_extensions`, `smufl`, `overrides`).
|
||
- `fn enc_tuning_context_v2(ctx, out)` = writes exactly the 3 fields.
|
||
|
||
3. **Reroute the frozen v0/v1 score decoders** — the must-not-miss edit.
|
||
`decode_v0_score:2544` and `decode_v1_score` currently read `tuning_context`
|
||
via `Codec::dec` (the *live* codec). Now that the live codec is v3 (5 fields),
|
||
both MUST read it via **`dec_tuning_context_v2`** instead — v0/v1/v2 bytes all
|
||
carry the 3-field form. The existing `v0/v1_score_migrates_*` goldens
|
||
(codec.rs:3583, 3645) will fail loudly if this is missed.
|
||
|
||
4. **Add the v2 frozen score pair**, mirroring `encode_v1_score`/`decode_v1_score`
|
||
(codec.rs:3077/3106):
|
||
- `pub(crate) fn encode_v2_score(s) -> Vec<u8>`: the live walk for all 18
|
||
other fields; `enc_tuning_context_v2` for `tuning_context`.
|
||
- `fn decode_v2_score(bytes) -> Result<Score>`: the live walk for all 18 other
|
||
fields; `dec_tuning_context_v2` for `tuning_context`. Strict-canonical:
|
||
re-encode via `encode_v2_score` and reject on mismatch (exactly as
|
||
`decode_v1_score` does at :3149).
|
||
|
||
5. **`decode_canonical_versioned`** (codec.rs:2492): `3 => Score::decode_canonical`
|
||
(the live v3), `2 => decode_v2_score`, `1 => decode_v1_score`,
|
||
`0 => decode_v0_score`. Update its doc to name major 3 as current.
|
||
|
||
6. **New migration golden** `v2_score_migrates_default_filling_smufl_and_overrides`
|
||
(mirror :3583/:3645): synthesize v2 bytes via `encode_v2_score`, decode via
|
||
`decode_v2_score`, assert the score reconstructs with `smufl` at default and
|
||
`overrides` empty. (accidental_extensions is unaffected — it was never on the
|
||
wire.)
|
||
|
||
## Version + bundle + accept-set
|
||
|
||
- `SchemaVersion::V3 = { major: 3, minor: 0 }` in `epiphany-bundle/src/ids.rs`
|
||
(mirror V2 @ :189, with a doc line).
|
||
- `max_supported_major` (bundle.rs:65): **`Snapshot => 3`**;
|
||
**`OperationEnvelopeBlock` stays `2`** (no op embeds a v3 value).
|
||
- `assert_score_serialization_stable` (testkit roundtrip.rs:352, :391, :403): the
|
||
acceleration snapshot stamps the **current** major — flip `for_major(2)` →
|
||
`for_major(3)` at both the stamp and the assert; the read-back seam keys off the
|
||
stamped major and needs no change beyond that.
|
||
- bundle.rs tests (~1314–1374): add `assert_eq!(SchemaVersion::V3.major, 3)`;
|
||
`max_supported_major(Snapshot) == 3` (was 2), `OperationEnvelopeBlock == 2`
|
||
(unchanged). The `UnsupportedCanonicalChunkMajor { schema_major: 3 }` case
|
||
(bundle.rs:1356) tests a **canonical base** stamped at an unsupported major —
|
||
the base is always major 0, so major 3 is still unsupported *for the base role*.
|
||
**Read what that test constructs** before touching it: if it asserts "a
|
||
canonical base at major 3 is rejected", it stays valid as-is (base ≠ snapshot);
|
||
if it was standing in for "beyond the accept-set", bump its value to 4. Do not
|
||
guess — inspect and preserve its intent.
|
||
|
||
## Test inversions — partial, and that is the point
|
||
|
||
The off-the-wire test `score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire`
|
||
(codec.rs:3457) must become a **staging-boundary** test:
|
||
- `smufl` and `overrides` now **round-trip** through `enc`→`dec` (set them
|
||
non-default, encode, decode, assert they survive equal).
|
||
- `accidental_extensions` is still **dropped** (set it non-empty, encode, decode,
|
||
assert it comes back empty). This proves the staging line exactly.
|
||
Rename it to reflect the new meaning (e.g.
|
||
`score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not`).
|
||
The sibling `score_tuning_context_overrides_do_not_reach_the_wire`, if it still
|
||
exists separately, folds into the above or inverts to prove round-trip.
|
||
|
||
**Text projection is a separate surface — do NOT change it here.** The
|
||
`textvalue_graph.rs` analogues (`..._do_not_project`) stay as-is: all three
|
||
fields still do not project to text. (Binary-wire persistence without
|
||
text-projection parity is a known asymmetry I am flagging to the user as a
|
||
possible follow-up, not fixing in this binary-wire tranche.)
|
||
|
||
## Spec — binary_format.tex §"Schema Major 3"
|
||
|
||
Add a `\section{Schema Major 3}` mirroring §Schema Major 2's structure
|
||
(spec/binary_format.tex:2574):
|
||
- **What it adds:** `ScoreTuningContext` gains `smufl` and `overrides` on the
|
||
wire (Chapter 4 tuning context reaching the canonical form); state plainly that
|
||
`accidental_extensions` is **staged** to a later major.
|
||
- **Where the changed fields reach:** the acceleration full-`Score` snapshot
|
||
**only**. No operation payload embeds the tuning context, so the canonical
|
||
operation layer is untouched and the canonical base stays major 0,
|
||
byte-identical (assert-again keystone). The snapshot role's accept-set max
|
||
rises to 3; the op-block role stays 2.
|
||
- **Cross-major reader behaviour:** composes — a major-3 reader migrates
|
||
v0→v1→v2→v3 in one read, each step total and default-filling.
|
||
- **Changed value layouts:** `ScoreTuningContext = (v0..v2 prefix) ⌢ smufl ⌢
|
||
overrides`, and the four new leaf layouts (`SmuflVersion`,
|
||
`SmuflVersionRequirement`, `TuningScope`, `TuningOverride`) exactly as frozen
|
||
above.
|
||
- Update the accept-set section (§2474) if it enumerates per-role maxima.
|
||
|
||
This is a wire-layout ratification, not new normative behaviour: **do not add
|
||
`req:` labels.** The requirement counts (212 / 282 / 282) MUST be unchanged — the
|
||
gate asserts this.
|
||
|
||
## Do NOT touch
|
||
|
||
- **`accidental_extensions` and the entire accidental subtree** — stays
|
||
in-memory-only. No `Codec`, no wire bytes.
|
||
- **`epiphany-layout-ir`** — the `SmuflVersion` unification and
|
||
`GlyphCatalogIdentity` move are **tranche 3b-ii**, a separate dispatch. Leave
|
||
layout-ir's own `SmuflVersion { major, minor }` and `encode_catalog` exactly as
|
||
they are. (The two `SmuflVersion` homonyms remain a deliberate bounded homonym
|
||
until 3b-ii, as `accidental.rs:251` already documents.)
|
||
- **Text projection** (`textvalue_graph.rs`, `epiphany-textproj`) — unchanged.
|
||
- **The operation layer** (`epiphany-ops`) — unchanged (no op embeds the context).
|
||
- **`spec/PLAN_EDITOR_APP.md`, `spec/CONTRACT_EDITOR_T1A_GOLDENS.md`** — untracked
|
||
parallel work; MUST NOT be touched or staged. Stage only `epiphany-core/`,
|
||
`epiphany-bundle/`, `epiphany-testkit/`, and `spec/binary_format.tex` +
|
||
`spec/CONTRACT_PUSH4B_3BI_WIRE.md`.
|
||
|
||
## The gate (all must pass; report exact numbers)
|
||
|
||
- `cargo fmt --all --check`
|
||
- `cargo clippy --workspace --all-targets` → 0 warnings
|
||
- `cargo test --workspace` → 0 failed
|
||
- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` → 0
|
||
- `cargo run -q -p epiphany-testkit --example conformance_suite` → 8/8
|
||
- `cargo run -q -p epiphany-testkit --example requirement_labels` → 6/6, counts
|
||
**212 / 282 / 282** (UNCHANGED — a changed count means a stray `req:` label slipped in)
|
||
|
||
## What I (the reviewer) will verify independently before committing — build to survive it
|
||
|
||
- Encode a `ScoreTuningContext` with non-default `smufl` and a non-empty
|
||
`overrides`, round-trip through `Score::canonical_bytes()` / `decode_canonical`,
|
||
and confirm both survive **and** `accidental_extensions` is dropped.
|
||
- Synthesize real v2 bytes via `encode_v2_score`, decode via
|
||
`decode_canonical_versioned(.., 2)`, confirm default-fill; do the same for v1/v0
|
||
to prove the reroute of the frozen decoders holds (mutation: break the reroute,
|
||
watch a `*_migrates_*` golden fail).
|
||
- Confirm `OperationEnvelopeBlock` max is still 2 and the canonical base is still
|
||
major 0 byte-identical across the bump.
|
||
- Mutation-verify the staging-boundary test: weaken it to also accept
|
||
`accidental_extensions` surviving, confirm it then fails.
|