Push 4b tranche 3b-i: the score wire opens schema major 3

ScoreTuningContext gains `smufl` and `overrides` on the canonical wire.
`accidental_extensions` is deliberately STAGED to a later major: under
req:binfmt:frozen-layout a field addition is a major, so freezing a large
subtree whose consumer (the engraver) does not exist yet buys nothing and
risks a major-4 to fix a mis-shaped field. The three shapes 3a ratified
(Cents/CanonicalF64, AnchorPoint, SmuflVersion) stay reversible until
something exercises them.

Scope discovery: no operation payload embeds the tuning context, anywhere.
So the wire reached is the acceleration full-Score snapshot ONLY — the
canonical operation layer is untouched, no payload is born at v3, no frozen
v2 op-payload decoder is needed, and OperationEnvelopeBlock's accept-set
stays at 2 while Snapshot rises to 3. Major 3 is the first data-model bump
under which a chunk role's max does not move in lockstep. The canonical
base embeds no tuning context and stays major 0, byte-identical.

Migration: the live codec becomes v3; the 3-field form is frozen as
enc/dec_tuning_context_v2 and consumed by decode_v2_score (new, with its
byte-exact inverse encode_v2_score) and by the v0/v1 decoders — AND by
encode_v0_score/encode_v1_score, which the contract missed and which would
have silently corrupted the frozen forms through the strict-canonicality
re-encode check.

The frozen bytes are now pinned by a golden, because nothing else pinned
them. The cross-implementation decode corpus covers only the operation and
bundle surfaces — there is no epiphany-core vectors module — so every
existing test round-trips enc against dec and stays green under a
SELF-CONSISTENT reordering: swapping smufl and overrides in both halves
passed the entire workspace suite and 8/8 conformance, silently moving a
permanently frozen layout. schema_major_3_tuning_context_wire_bytes_are_frozen
asserts the exact encoding of the default (48 B) and a loaded (82 B)
context; it kills that swap. The S12 normalization is visible in the
literal: 1.4 stores minor_centi 40 (0x28), 1.12 stores 12.

Gate: fmt clean, clippy 0, 1282 passed / 0 failed, doc 0, conformance 8/8,
requirement labels 6/6 at 212/282/282 (unchanged — no new req: labels).
Mutations verified independently: breaking the v0 reroute kills 6 tests;
the wire-order swap kills the new golden.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-23 17:35:55 -04:00
parent 456792debd
commit 68b08adb5e
9 changed files with 1031 additions and 143 deletions

View File

@ -53,8 +53,10 @@ pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0;
/// `OperationEnvelopeBlock` admits major 2 (schema major 2 fills the
/// cross-cutting/staff/metadata bodies its payloads embed; major 1 embedded a
/// v1 `CreateRegion`; the reader treats the block bytes opaquely, so it
/// parses a higher-major block without decoding the payload). `Snapshot`
/// admits major 2 for the acceleration full-`Score` form (decoded through
/// parses a higher-major block without decoding the payload). 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. `Snapshot`
/// admits major 3 for the acceleration full-`Score` form (decoded through
/// the core versioned seam); the canonical BASE carried under the same kind
/// must stay major 0, enforced per role. Every other role stays at
/// [`SUPPORTED_SCHEMA_MAJOR`] until its own versioned path lands — the
@ -67,11 +69,11 @@ pub fn max_supported_major(kind: ChunkKind) -> u16 {
ChunkKind::OperationEnvelopeBlock => 2,
// The payload-polymorphic Snapshot role: the acceleration
// full-`Score` form is decoded through the core versioned seam
// (`Score::decode_canonical_versioned`, majors {0,1,2}). The
// (`Score::decode_canonical_versioned`, majors {0,1,2,3}). The
// *canonical base* must stay major 0 regardless — that is enforced
// per ROLE (`mis_stamped_canonical_base`, consulted at open and
// commit), not by this per-kind bound.
ChunkKind::Snapshot => 2,
ChunkKind::Snapshot => 3,
_ => SUPPORTED_SCHEMA_MAJOR,
}
}
@ -1310,17 +1312,20 @@ mod tests {
// CreateRegion), and schema major 2 to major 2 (a block bearing a v2
// cross-cutting/staff/metadata value); every other role stays exact-0
// until its own versioned path lands, and the manifest stays major 0
// forever.
// forever. Schema major 3 (Push 4b tranche 3b-i, §"Schema Major 3")
// raises only the snapshot role — no operation payload embeds the
// tuning context, so the op-block role's admission is untouched.
assert_eq!(SchemaVersion::V1.major, 1);
assert_eq!(SchemaVersion::V2.major, 2);
// The op-block role admits [0, 2].
assert_eq!(SchemaVersion::V3.major, 3);
// The op-block role admits [0, 2] — unchanged by schema major 3.
assert_eq!(max_supported_major(ChunkKind::OperationEnvelopeBlock), 2);
// The snapshot role admits the major-2 acceleration form (decoded
// The snapshot role admits the major-3 acceleration form (decoded
// through the core versioned seam); the canonical BASE stays major 0
// per role (`mis_stamped_canonical_base`). The remaining roles stay
// at the generic baseline: the layout cache and the operation index.
assert_eq!(SUPPORTED_SCHEMA_MAJOR, 0);
assert_eq!(max_supported_major(ChunkKind::Snapshot), 2);
assert_eq!(max_supported_major(ChunkKind::Snapshot), 3);
assert_eq!(max_supported_major(ChunkKind::LayoutCache), 0);
assert_eq!(max_supported_major(ChunkKind::OperationIndex), 0);
// The manifest gate is exact to the manifest's own major (0), independent

View File

@ -188,6 +188,13 @@ impl SchemaVersion {
/// operation-envelope block bearing a v2 value.
pub const V2: SchemaVersion = SchemaVersion { major: 2, minor: 0 };
/// Schema major 3 — the third data-model expansion major (Binary Format
/// companion §"Schema Major 3", Push 4b tranche 3b-i): `ScoreTuningContext`
/// gains `smufl` and `overrides` on the wire. Stamped on the acceleration
/// full-`Score` snapshot only — no operation payload embeds the tuning
/// context, so `OperationEnvelopeBlock`'s accept-set is untouched.
pub const V3: SchemaVersion = SchemaVersion { major: 3, minor: 0 };
/// Constructs a schema version.
#[inline]
pub const fn new(major: u16, minor: u16) -> Self {
@ -195,16 +202,18 @@ impl SchemaVersion {
}
/// The current schema version at a given major: [`Self::V0`] for major 0,
/// [`Self::V1`] for major 1, [`Self::V2`] for major 2, and `{major, 0}`
/// for any higher (future) major. A writer maps a chunk's derived schema
/// major to a version this way — e.g. an operation-envelope block stamps
/// the max over its operations' `schema_major()`.
/// [`Self::V1`] for major 1, [`Self::V2`] for major 2, [`Self::V3`] for
/// major 3, and `{major, 0}` for any higher (future) major. A writer maps
/// a chunk's derived schema major to a version this way — e.g. an
/// operation-envelope block stamps the max over its operations'
/// `schema_major()`.
#[inline]
pub const fn for_major(major: u16) -> Self {
match major {
0 => SchemaVersion::V0,
1 => SchemaVersion::V1,
2 => SchemaVersion::V2,
3 => SchemaVersion::V3,
m => SchemaVersion { major: m, minor: 0 },
}
}

View File

@ -1175,3 +1175,133 @@ warnings, after one intra-doc link — `` [`:3160`] `` — was de-linked to a
plain parenthetical citation), `conformance_suite` (8/8), and
`requirement_labels` (6 passed, counts unchanged at 212/282/282) all pass.
No `.tex` file was touched and no requirement was added.
## Push 4b tranche 3b-i: schema major 3 opens — `smufl` and `overrides` reach the wire
`spec/CONTRACT_PUSH4B_3BI_WIRE.md`. The user split the original 3b sketch in
two and staged it down: 3b-i (this) freezes only `smufl` and `overrides` onto
the score wire; `accidental_extensions` stays in memory (no consumer yet —
the engraver, out of `epiphany-core`); the `SmuflVersion` unification with
`epiphany-layout-ir` and the `GlyphCatalogIdentity` move are **3b-ii**, a
separate dispatch, untouched here. This is the first **irreversible** byte
layout of Push 4b: schema major 3 is now open, and every layout below is
frozen forever under `req:binfmt:frozen-layout`.
**The frozen wire**, exactly as the contract specifies:
`ScoreTuningContext(v3) = (default_pitch_space, default_tuning_system,
reference)` (the untouched v0..v2 prefix) `⌢ smufl ⌢ overrides`. Four new
leaf `Codec` impls, all hand-written (not `struct_codec!`/
`cstyle_enum_codec!`, since those macros also generate a `TextValue` impl,
and none of these four types has one — text projection is a separate
surface this tranche does not touch, so the macro would either fail to
compile, missing `TextValue for u16`/`TuningScope`, or silently open a new
projection surface nobody asked for):
* `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, end, voices }
(`TimeAnchor`/`VoiceSelector` already encode).
* `TuningOverride = scope(TuningScope) ⌢ pitch_space(Option<PitchSpaceId>) ⌢
tuning_system(Option<TuningSystemId>) ⌢ reference(Option<ReferencePitch>)`.
**The reroute — the highest-risk edit, and it reaches further than the
contract's own two named call sites.** The contract named
`decode_v0_score`/`decode_v1_score` as the must-not-miss reroute onto a new
frozen `dec_tuning_context_v2` (the pre-v3, 3-field form — "v2" because it is
the form majors 0/1/2 all share, exactly the naming convention
`dec_ccr_v1`/`dec_metadata_v1` already use for "the frozen form as of the
prior major"). Auditing the two named decoders surfaced two more sites the
contract's prose did not call out but the same bug applies to: **their
byte-exact-inverse encoders**, `encode_v0_score` and `encode_v1_score`, both
of which called `s.tuning_context.enc(&mut out)` — the *live* codec. Once the
live codec became 5-field, both would have silently started emitting 5-field
tuning-context bytes inside a nominally-frozen v0/v1 form, which
`decode_v0_score`/`decode_v1_score`'s own strict-canonicality re-encode check
(`encode_v0_score(&score) != bytes`) would then reject on *every* input —
not a subtle bug, a total breakage of the v0/v1 migration paths, caught only
because the goldens exercise real synthesized v0/v1 bytes rather than
hand-written literals. Fixed by routing both through a new
`enc_tuning_context_v2`, symmetric with `dec_tuning_context_v2`. Also added
(named by the contract): `encode_v2_score`/`decode_v2_score`, the newly-frozen
schema-major-2 score form — the live walk for the other 18 fields (unchanged
between major 2 and 3) plus `enc_tuning_context_v2`/`dec_tuning_context_v2`
for `tuning_context`. `decode_canonical_versioned` now dispatches
`3 => decode_canonical, 2 => decode_v2_score, 1 => decode_v1_score, 0 =>
decode_v0_score`.
**Consequences the contract didn't spell out, found by re-running every
existing test after the bump:**
* Two pre-existing tests asserted `decode_canonical_versioned(bytes, 2)` where
`2` meant "the current major" (`v1_round_trips_non_default_values_for_every_new_field`,
`current_major_round_trips_non_default_values_for_every_major_2_field`) —
both bumped to `3`.
* `v0_score_migrates_default_filling_all_three_new_fields` asserted major `3`
was *unsupported* (`decode_canonical_versioned(.., 3).is_err()`) — true
before this tranche, false after (3 is now current). Bumped the probe to
`4`, the new first-unsupported major.
* `v1_score_migrates_default_filling_the_major_2_fields`'s exact byte-count
size anchor (`current.len() - v1.len() == expected_removed`) silently grew
by a flat 12 bytes — `smufl`'s 8 (two bare `u16` pairs) plus `overrides`'
empty-count 4 — present in `current` (now v3) but absent from `v1` (frozen
pre-v3). Added `+ 12` to `expected_removed`, documented why.
* Added the contract's asked-for `v2_score_migrates_default_filling_smufl_and_overrides`,
mirroring the v0/v1 migration goldens: synthesizes real v2 bytes via
`encode_v2_score`, asserts the flat 12-byte size anchor, and checks the v2
bytes migrate to the same score `decode_canonical` reaches.
**The two off-the-wire tests fold into one staging-boundary test**, per the
contract: `score_tuning_context_overrides_do_not_reach_the_wire` (tranche 2)
is deleted outright (fully superseded); `..._accidental_extensions_smufl_and_overrides_do_not_reach_the_wire`
(tranche 3a) is renamed to
`score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not`
and inverted: the same three-field-loaded fixture now asserts `smufl`/`overrides`
survive `enc`→`dec` equal, while `accidental_extensions` still decodes empty.
Mutation-verified (weakened the test to also expect `accidental_extensions`
survival — it failed, confirming the drop assertion is real, not vacuous).
**`bundle.rs:1356`'s `UnsupportedCanonicalChunkMajor { schema_major: 3 }`
case — inspected, not guessed, per the contract's own instruction.** It is
`committing_an_unsupported_major_op_root_makes_the_live_bundle_read_only`,
which stages an `OperationEnvelopeBlock` (not a canonical base) at schema
major 3 to prove "beyond the op-block accept-set." Since no operation
payload embeds the tuning context, `max_supported_major(OperationEnvelopeBlock)`
stays at 2 this tranche (verified: a full search of `epiphany-ops` for
`ScoreTuningContext`/`TuningOverride`/`tuning_context`/
`SmuflVersionRequirement` finds nothing) — so major 3 is *still* beyond that
role's accept-set. **Left at 3, unchanged**; bumping to 4 would have been
wrong (it would stop testing the boundary this test actually exercises). The
sibling canonical-base test (`a_canonical_base_stamped_above_major_0_opens_read_only`,
`schema_major: 1`) is a different test entirely and was never in scope.
**Version/accept-set**: `SchemaVersion::V3 = {3, 0}` (`epiphany-bundle/src/ids.rs`);
`max_supported_major(Snapshot) = 3`, `max_supported_major(OperationEnvelopeBlock) = 2`
(unchanged — the first data-model major where a chunk role's max does not
move in lockstep with the others); `testkit::roundtrip::assert_score_serialization_stable`
flips both `for_major(2)` sites to `for_major(3)`.
**Spec**: new `spec/binary_format.tex` §"Schema Major 3" (mirrors §Schema
Major 2's structure: where the fields reach, cross-major reader behavior,
changed/new layouts, the v2→v3 migration table), the chunk-level-gate section
updated to state the accept-set is per-role as of this bump (Snapshot 3,
OperationEnvelopeBlock 2, everything else 0), a revision-history entry
(0.10.0), and the title-page version line. **No `req:` label added**
requirement counts stay 212/282/282 (asserted unchanged by
`requirement_labels`, which passed 6/6).
**Full gate green** after the bump: `cargo fmt --all --check`; `cargo clippy
--workspace --all-targets` (0 warnings); `cargo test --workspace` (1271
passed, 0 failed); `RUSTDOCFLAGS="-D warnings" cargo doc --workspace
--no-deps` (0 warnings); `conformance_suite` (8/8); `requirement_labels`
(6/6, counts unchanged at 212/282/282 — there is no `--example
requirement_labels`; it is the integration test at
`epiphany-testkit/tests/requirement_labels.rs`, run via `cargo test -p
epiphany-testkit --test requirement_labels`). Mutation-verified the reroute
itself: reverting all three `dec_tuning_context_v2` call sites back to the
live `Codec::dec` killed six tests at once (`v0_score_migrates_*`,
`v1_score_migrates_*`, `v2_score_migrates_*`, `v0_regions_inside_canvas_decode_after_region_grew`,
`v0_decode_is_strictly_canonical_over_the_v0_wire_form`,
`v1_round_trips_non_default_values_for_every_new_field`) — confirming the
reroute is load-bearing across the whole frozen-decoder family, not just the
two sites the contract named.

View File

@ -39,6 +39,7 @@ use std::collections::{BTreeMap, BTreeSet};
use epiphany_determinism::{CanonicalDecode, CanonicalEncode, CanonicalF64, ContentHash};
use crate::accidental::{SmuflVersion, SmuflVersionRequirement};
use crate::event::{
ArticulationMark, CueEvent, CueRendering, DynamicMark, Event, EventArena, GraceKind,
GraphicEvent, IndeterminacyHints, IndeterminacyKind, IndeterminateEvent, OrnamentMark,
@ -84,6 +85,7 @@ use crate::time::{
MeasurePosition, MusicalDuration, MusicalPosition, RationalTime, RegionEdge, TimeAnchor,
TimeBounds, WallClockDuration, WallClockTime,
};
use crate::tuning::{TuningOverride, TuningScope};
// ===========================================================================
// Errors and the reader cursor.
@ -1832,42 +1834,150 @@ struct_codec!(BeatGroup {
subdivision,
accent
});
// `ScoreTuningContext` has gained three in-memory-only fields beyond the
// three wire fields — `overrides` (Push 4b tranche 2,
// `spec/CONTRACT_PUSH4B_RESOLVER.md`), then `accidental_extensions` and
// `smufl` (Push 4b tranche 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`) — none
// of which may reach the wire: schema major 3 has not been opened.
// `struct_codec!` cannot express that — its generated `dec` ends in a struct
// literal naming every field it was given, so an in-memory-only field either
// goes on the wire (freezing a type before it has a consumer) or the macro
// cannot build the value at all. Hand-written instead: encode/decode exactly
// the three wire fields, in their original order, and default the other
// three on decode (`overrides: Vec::new()`, `accidental_extensions:
// Vec::new()`, `smufl: SmuflVersionRequirement::default()`). A round-trip
// test just below (`score_tuning_context_overrides_do_not_reach_the_wire`)
// proves a non-empty `overrides` encodes to the same bytes as an empty one; a
// second (`score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire`)
// extends the same proof to all three fields at once. The matching
// text-projection proofs are `textvalue_graph.rs`'s
// ===========================================================================
// accidental.rs / tuning.rs — the schema-major-3 leaf layouts staged onto the
// wire this tranche (Push 4b tranche 3b-i, `spec/CONTRACT_PUSH4B_3BI_WIRE.md`).
// Only these four types gain a `Codec`; `accidental_extensions` and its whole
// subtree stay in-memory-only (no `Codec` exists, or may exist, for them —
// `accidental.rs`'s own module doc). Hand-written rather than
// `struct_codec!`/`cstyle_enum_codec!`: `struct_codec!` also generates a
// `TextValue` impl, and these four types have none (text projection is a
// separate surface this tranche does not touch — see the staging-boundary
// tests below and `textvalue_graph.rs`), so the macro would either fail to
// compile (no `TextValue` for `u16`/`TuningScope`) or silently open a new
// text-projection surface neither asked for nor specified. Every layout here
// is now FROZEN (`req:binfmt:frozen-layout`) — get the field order right.
impl Codec for SmuflVersion {
fn enc(&self, out: &mut Vec<u8>) {
self.major.enc(out);
self.minor_centi.enc(out);
}
fn dec(r: &mut Reader<'_>) -> Result<Self> {
let major = u16::dec(r)?;
let minor_centi = u16::dec(r)?;
Ok(SmuflVersion { major, minor_centi })
}
}
impl Codec for SmuflVersionRequirement {
fn enc(&self, out: &mut Vec<u8>) {
self.minimum.enc(out);
self.authored_against.enc(out);
}
fn dec(r: &mut Reader<'_>) -> Result<Self> {
let minimum = SmuflVersion::dec(r)?;
let authored_against = SmuflVersion::dec(r)?;
Ok(SmuflVersionRequirement {
minimum,
authored_against,
})
}
}
impl Codec for TuningScope {
fn enc(&self, out: &mut Vec<u8>) {
match self {
TuningScope::Voice(id) => {
out.push(0);
id.enc(out);
}
TuningScope::Staff(id) => {
out.push(1);
id.enc(out);
}
TuningScope::Region(id) => {
out.push(2);
id.enc(out);
}
TuningScope::Range { start, end, voices } => {
out.push(3);
start.enc(out);
end.enc(out);
voices.enc(out);
}
}
}
fn dec(r: &mut Reader<'_>) -> Result<Self> {
match r.u8()? {
0 => Ok(TuningScope::Voice(Codec::dec(r)?)),
1 => Ok(TuningScope::Staff(Codec::dec(r)?)),
2 => Ok(TuningScope::Region(Codec::dec(r)?)),
3 => Ok(TuningScope::Range {
start: Codec::dec(r)?,
end: Codec::dec(r)?,
voices: Codec::dec(r)?,
}),
tag => Err(ScoreDecodeError::InvalidTag {
kind: "TuningScope",
tag,
}),
}
}
}
impl Codec for TuningOverride {
fn enc(&self, out: &mut Vec<u8>) {
self.scope.enc(out);
self.pitch_space.enc(out);
self.tuning_system.enc(out);
self.reference.enc(out);
}
fn dec(r: &mut Reader<'_>) -> Result<Self> {
let scope = Codec::dec(r)?;
let pitch_space = Codec::dec(r)?;
let tuning_system = Codec::dec(r)?;
let reference = Codec::dec(r)?;
Ok(TuningOverride {
scope,
pitch_space,
tuning_system,
reference,
})
}
}
// `ScoreTuningContext` is schema major 3 (Push 4b tranche 3b-i,
// `spec/CONTRACT_PUSH4B_3BI_WIRE.md`): `smufl` and `overrides` join the
// major-0 prefix (`default_pitch_space`, `default_tuning_system`,
// `reference`) on the wire, in that order — append-after-existing, per the
// frozen-layout rule. `accidental_extensions` stays in-memory-only (staged to
// a later major, appended after `overrides` when it lands); `struct_codec!`
// still cannot express that one holdout, so this impl stays hand-written.
// `dec` default-fills only `accidental_extensions: Vec::new()`.
//
// The pre-v3 form (3 fields; majors 0/1/2 all share it) is frozen separately
// as `enc_tuning_context_v2`/`dec_tuning_context_v2` below, consumed by the
// frozen `decode_v0_score`/`decode_v1_score`/`decode_v2_score` (and their
// byte-exact-inverse `encode_v*_score` siblings) so those frozen forms never
// drift when this live impl changes. A round-trip test just below
// (`score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not`)
// proves `smufl`/`overrides` now survive `enc`→`dec` while
// `accidental_extensions` still does not. The matching text-projection
// proofs are unchanged by this tranche — `textvalue_graph.rs`'s
// `score_tuning_context_round_trips_and_overrides_do_not_project` and
// `score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project`.
// `score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project`
// (text projection is a separate surface; see this tranche's contract).
impl Codec for ScoreTuningContext {
fn enc(&self, out: &mut Vec<u8>) {
self.default_pitch_space.enc(out);
self.default_tuning_system.enc(out);
self.reference.enc(out);
self.smufl.enc(out);
self.overrides.enc(out);
}
fn dec(r: &mut Reader<'_>) -> Result<Self> {
let default_pitch_space = Codec::dec(r)?;
let default_tuning_system = Codec::dec(r)?;
let reference = Codec::dec(r)?;
let smufl = Codec::dec(r)?;
let overrides = Codec::dec(r)?;
Ok(ScoreTuningContext {
default_pitch_space,
default_tuning_system,
reference,
accidental_extensions: Vec::new(),
smufl: crate::accidental::SmuflVersionRequirement::default(),
overrides: Vec::new(),
smufl,
overrides,
})
}
}
@ -2450,7 +2560,7 @@ impl Score {
/// Decodes the exact inverse of [`Score::canonical_bytes`], validating every
/// tag, length, primitive, and type invariant. Trailing bytes are rejected.
///
/// This is the **current (schema major 2)** layout. To decode bytes whose
/// This is the **current (schema major 3)** layout. To decode bytes whose
/// schema major is not known to be current, use
/// [`Score::decode_canonical_versioned`].
///
@ -2478,20 +2588,22 @@ impl Score {
}
/// The **schema-version dispatch seam** (Binary Format companion
/// §"Schema Major 1" / §"Schema Major 2"): decodes a full-`Score` snapshot
/// whose bytes were written under the given schema `major`, migrating a
/// lower-major encoding up to the current in-memory form on read. Major 2
/// is the current layout ([`Score::decode_canonical`]); majors 1 and 0 are
/// decoded through their frozen wire forms (`decode_v1_score`,
/// §"Schema Major 1" / §"Schema Major 2" / §"Schema Major 3"): decodes a
/// full-`Score` snapshot whose bytes were written under the given schema
/// `major`, migrating a lower-major encoding up to the current in-memory
/// form on read. Major 3 is the current layout
/// ([`Score::decode_canonical`]); majors 2, 1, and 0 are decoded through
/// their frozen wire forms (`decode_v2_score`, `decode_v1_score`,
/// `decode_v0_score`), each a total default-filling migration — the
/// composed v0→v1→v2 translation happens in the one v0 read.
/// composed v0→v1→v2→v3 translation happens in the one v0 read.
///
/// The caller (the bundle read path) only reaches this after the chunk gate
/// has admitted the major into its accept-set, so a major outside
/// `{0, 1, 2}` is a defensive error, not an expected path.
/// `{0, 1, 2, 3}` is a defensive error, not an expected path.
pub fn decode_canonical_versioned(bytes: &[u8], major: u16) -> Result<Score> {
match major {
2 => Score::decode_canonical(bytes),
3 => Score::decode_canonical(bytes),
2 => decode_v2_score(bytes),
1 => decode_v1_score(bytes),
0 => decode_v0_score(bytes),
_ => Err(ScoreDecodeError::InvalidValue("unsupported schema major")),
@ -2505,8 +2617,11 @@ impl Score {
/// This is the **frozen v0 wire form, decoded by value** — a hand-written walk
/// of the 19 `Score` fields in declaration order, using the current [`Codec`]
/// for every field whose layout is unchanged, the frozen **v1** sub-decoders
/// (v0 == v1) for the types schema major 2 filled, and a frozen v0
/// sub-decoder for the three that grew a field in schema major 1:
/// (v0 == v1) for the types schema major 2 filled, the frozen **v2**
/// sub-decoder ([`dec_tuning_context_v2`]) for `tuning_context` (v0 == v1 ==
/// v2 — schema major 3 is the first bump to touch it, Push 4b tranche 3b-i),
/// and a frozen v0 sub-decoder for the three that grew a field in schema
/// major 1:
///
/// * `Canvas` (field 2) — v0 was `regions` only; v1 appended `layout_defaults`.
/// [`dec_canvas_v0`] reads the region vector and default-fills the defaults.
@ -2525,14 +2640,20 @@ impl Score {
/// depends only on the v0 field lists above and the current codec for unchanged
/// fields; the `v0_score_migrates_*` golden tests guard it (they synthesize real
/// v0 bytes via a mirror v0 encoder and check the migration reconstructs the
/// original score with the new fields at their defaults).
/// original score with the new fields at their defaults). **Must** read
/// `tuning_context` via [`dec_tuning_context_v2`], never via the live
/// [`Codec`] — the live codec now reads/writes the 5-field schema-major-3
/// form, and reading v0/v1/v2 bytes through it would desynchronize the
/// cursor for every field after `tuning_context`.
fn decode_v0_score(bytes: &[u8]) -> Result<Score> {
let mut r = Reader::new(bytes);
// The 19 Score fields in declaration order (codec.rs `struct_codec!(Score)`).
// `canvas` (2) and `instruments` (3) carry v0-specific sub-forms; the
// schema-major-2 fills route `metadata`, `staves`, `cross_cutting`, and
// (transitively, inside regions) the staff instances through the frozen
// v1 sub-decoders — v0 == v1 for every type major 2 changed.
// v1 sub-decoders — v0 == v1 for every type major 2 changed. `tuning_context`
// routes through the frozen v2 sub-decoder — v0 == v1 == v2 for it, only
// major 3 changes it.
let metadata = dec_metadata_v1(&mut r)?;
let canvas = dec_canvas_v0(&mut r)?;
let instruments = dec_instruments_v0(&mut r)?;
@ -2541,7 +2662,7 @@ fn decode_v0_score(bytes: &[u8]) -> Result<Score> {
let parts = Codec::dec(&mut r)?;
let cross_cutting = dec_ccr_v1(&mut r)?;
let time_signatures = Codec::dec(&mut r)?;
let tuning_context = Codec::dec(&mut r)?;
let tuning_context = dec_tuning_context_v2(&mut r)?;
let tempo_map = Codec::dec(&mut r)?;
let events = Codec::dec(&mut r)?;
let spelling_attachments = Codec::dec(&mut r)?;
@ -2592,7 +2713,9 @@ fn decode_v0_score(bytes: &[u8]) -> Result<Score> {
/// The **frozen schema-major-0** encoding of a score — the byte-exact inverse of
/// [`decode_v0_score`]'s field walk: `Canvas` without `layout_defaults`,
/// `Instrument` without `range`, `Region` without `permits_spanning_slurs`;
/// `Instrument` without `range`, `Region` without `permits_spanning_slurs`,
/// `tuning_context` through the frozen 3-field [`enc_tuning_context_v2`] (not
/// the live [`Codec`], which now writes the 5-field schema-major-3 form);
/// every other field through the current [`Codec`]. Used by `decode_v0_score`
/// to enforce strict v0 canonicality (and by the migration tests to synthesize
/// genuine v0 bytes). A migrated score's schema-major-1 fields hold their
@ -2629,7 +2752,7 @@ pub(crate) fn encode_v0_score(s: &Score) -> Vec<u8> {
s.parts.enc(&mut out);
enc_ccr_v1(&s.cross_cutting, &mut out);
s.time_signatures.enc(&mut out);
s.tuning_context.enc(&mut out);
enc_tuning_context_v2(&s.tuning_context, &mut out);
s.tempo_map.enc(&mut out);
s.events.enc(&mut out);
s.spelling_attachments.enc(&mut out);
@ -3070,10 +3193,12 @@ fn dec_instruments_v1(r: &mut Reader<'_>) -> Result<Vec<Instrument>> {
}
/// The **frozen schema-major-1** encoding of a score — the byte-exact inverse
/// of [`decode_v1_score`]'s field walk. Used by `decode_v1_score` to enforce
/// strict v1 canonicality, and by migration tests and the decode fuzzer to
/// synthesize genuine v1 bytes. A migrated score's schema-major-2 fields hold
/// their defaults, so this simply omits them.
/// of [`decode_v1_score`]'s field walk (`tuning_context` through the frozen
/// [`enc_tuning_context_v2`], not the live [`Codec`] — see
/// [`encode_v0_score`]'s note). Used by `decode_v1_score` to enforce strict v1
/// canonicality, and by migration tests and the decode fuzzer to synthesize
/// genuine v1 bytes. A migrated score's schema-major-2 fields hold their
/// defaults, so this simply omits them.
pub(crate) fn encode_v1_score(s: &Score) -> Vec<u8> {
let mut out = Vec::new();
enc_metadata_v1(&s.metadata, &mut out);
@ -3084,7 +3209,7 @@ pub(crate) fn encode_v1_score(s: &Score) -> Vec<u8> {
s.parts.enc(&mut out);
enc_ccr_v1(&s.cross_cutting, &mut out);
s.time_signatures.enc(&mut out);
s.tuning_context.enc(&mut out);
enc_tuning_context_v2(&s.tuning_context, &mut out);
s.tempo_map.enc(&mut out);
s.events.enc(&mut out);
s.spelling_attachments.enc(&mut out);
@ -3113,7 +3238,7 @@ fn decode_v1_score(bytes: &[u8]) -> Result<Score> {
let parts = Codec::dec(&mut r)?;
let cross_cutting = dec_ccr_v1(&mut r)?;
let time_signatures = Codec::dec(&mut r)?;
let tuning_context = Codec::dec(&mut r)?;
let tuning_context = dec_tuning_context_v2(&mut r)?;
let tempo_map = Codec::dec(&mut r)?;
let events = Codec::dec(&mut r)?;
let spelling_attachments = Codec::dec(&mut r)?;
@ -3154,6 +3279,129 @@ fn decode_v1_score(bytes: &[u8]) -> Result<Score> {
Ok(score)
}
/// The **frozen schema-major-2** encoding of [`ScoreTuningContext`] — the
/// 3-field pre-major-3 form (`default_pitch_space`, `default_tuning_system`,
/// `reference`) that majors 0, 1, and 2 all share (schema major 3, Push 4b
/// tranche 3b-i, `spec/CONTRACT_PUSH4B_3BI_WIRE.md`, is the first bump to
/// change this type — it appends `smufl` and `overrides`). Used by
/// `encode_v0_score`, `encode_v1_score`, and [`encode_v2_score`] — every
/// frozen pre-major-3 score form — so none of them silently drift onto the
/// live (now 5-field) [`Codec`].
fn enc_tuning_context_v2(ctx: &ScoreTuningContext, out: &mut Vec<u8>) {
ctx.default_pitch_space.enc(out);
ctx.default_tuning_system.enc(out);
ctx.reference.enc(out);
}
/// The exact inverse of [`enc_tuning_context_v2`]: reads the 3-field
/// pre-major-3 form and default-fills the fields major 3 (and earlier
/// tranches) added — `accidental_extensions: Vec::new()`,
/// `smufl: SmuflVersionRequirement::default()`, `overrides: Vec::new()`.
/// Used by `decode_v0_score`, `decode_v1_score`, and [`decode_v2_score`].
fn dec_tuning_context_v2(r: &mut Reader<'_>) -> Result<ScoreTuningContext> {
let default_pitch_space = Codec::dec(r)?;
let default_tuning_system = Codec::dec(r)?;
let reference = Codec::dec(r)?;
Ok(ScoreTuningContext {
default_pitch_space,
default_tuning_system,
reference,
accidental_extensions: Vec::new(),
smufl: SmuflVersionRequirement::default(),
overrides: Vec::new(),
})
}
/// The **frozen schema-major-2** encoding of a score — the byte-exact inverse
/// of [`decode_v2_score`]'s field walk. Schema major 3 (Push 4b tranche 3b-i)
/// changes only `tuning_context` (Section "the frozen major-3 wire layout");
/// every other `Score` field is byte-identical to the live [`Codec`], so this
/// walk uses the live codec directly for all 18 other fields and
/// [`enc_tuning_context_v2`] for `tuning_context`. Used by [`decode_v2_score`]
/// to enforce strict v2 canonicality, and by migration tests to synthesize
/// genuine v2 bytes.
pub(crate) fn encode_v2_score(s: &Score) -> Vec<u8> {
let mut out = Vec::new();
s.metadata.enc(&mut out);
s.canvas.enc(&mut out);
s.instruments.enc(&mut out);
s.staves.enc(&mut out);
s.staff_groups.enc(&mut out);
s.parts.enc(&mut out);
s.cross_cutting.enc(&mut out);
s.time_signatures.enc(&mut out);
enc_tuning_context_v2(&s.tuning_context, &mut out);
s.tempo_map.enc(&mut out);
s.events.enc(&mut out);
s.spelling_attachments.enc(&mut out);
s.decomposition_attachments.enc(&mut out);
s.spelling_precedence.enc(&mut out);
s.analysis_layers.enc(&mut out);
s.views.enc(&mut out);
s.identity.enc(&mut out);
s.tombstoned_pitches.enc(&mut out);
s.tombstoned_events.enc(&mut out);
out
}
/// Decodes **schema-major-2** `Score` bytes into the current-layout `Score`,
/// migrating on read (total, default-filling — Binary Format §"Schema Major
/// 3"'s migration table: `tuning_context` default-fills `smufl` and
/// `overrides`; every other field is unchanged since major 2).
/// Strictly canonical on the v2 wire form, like its v0/v1 siblings: re-encodes
/// through [`encode_v2_score`] and rejects any input that is not already its
/// canonical v2 encoding.
fn decode_v2_score(bytes: &[u8]) -> Result<Score> {
let mut r = Reader::new(bytes);
let metadata = Codec::dec(&mut r)?;
let canvas = Codec::dec(&mut r)?;
let instruments = Codec::dec(&mut r)?;
let staves = Codec::dec(&mut r)?;
let staff_groups = Codec::dec(&mut r)?;
let parts = Codec::dec(&mut r)?;
let cross_cutting = Codec::dec(&mut r)?;
let time_signatures = Codec::dec(&mut r)?;
let tuning_context = dec_tuning_context_v2(&mut r)?;
let tempo_map = Codec::dec(&mut r)?;
let events = Codec::dec(&mut r)?;
let spelling_attachments = Codec::dec(&mut r)?;
let decomposition_attachments = Codec::dec(&mut r)?;
let spelling_precedence = Codec::dec(&mut r)?;
let analysis_layers = Codec::dec(&mut r)?;
let views = Codec::dec(&mut r)?;
let identity = Codec::dec(&mut r)?;
let tombstoned_pitches = Codec::dec(&mut r)?;
let tombstoned_events = Codec::dec(&mut r)?;
r.finish()?;
let score = Score {
metadata,
canvas,
instruments,
staves,
staff_groups,
parts,
cross_cutting,
time_signatures,
tuning_context,
tempo_map,
events,
spelling_attachments,
decomposition_attachments,
spelling_precedence,
analysis_layers,
views,
identity,
tombstoned_pitches,
tombstoned_events,
};
if encode_v2_score(&score) != bytes {
return Err(ScoreDecodeError::InvalidValue(
"non-canonical v2 Score encoding",
));
}
Ok(score)
}
// ===========================================================================
// Public per-value canonical codec (the K↔J seam).
// ===========================================================================
@ -3416,49 +3664,18 @@ mod tests {
}
#[test]
fn score_tuning_context_overrides_do_not_reach_the_wire() {
use crate::graph::ScoreTuningContext;
use crate::ids::{ReplicaId, VoiceId};
use crate::pitch::TuningSystemId;
use crate::tuning::{TuningOverride, TuningScope};
let without_overrides = ScoreTuningContext::default();
let mut with_overrides = ScoreTuningContext::default();
with_overrides.overrides.push(TuningOverride {
scope: TuningScope::Voice(VoiceId::new(ReplicaId(1), 1)),
pitch_space: None,
tuning_system: Some(TuningSystemId::new("tet-19")),
reference: None,
});
// Sanity: the two in-memory values actually differ, so a
// byte-identity assertion below is not vacuous.
assert_ne!(
with_overrides, without_overrides,
"the fixture must actually carry a non-empty override in memory"
);
let mut bytes_with = Vec::new();
with_overrides.enc(&mut bytes_with);
let mut bytes_without = Vec::new();
without_overrides.enc(&mut bytes_without);
assert_eq!(
bytes_with, bytes_without,
"overrides must not reach canonical bytes (Push 4b tranche 2, Ruling C)"
);
// Decoding either stream reconstructs `overrides` as empty — the
// field never round-trips, by construction.
let decoded = ScoreTuningContext::dec(&mut Reader::new(&bytes_with)).expect("decodes");
assert_eq!(decoded, without_overrides);
assert!(decoded.overrides.is_empty());
}
#[test]
fn score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire() {
// The direct analogue of `score_tuning_context_overrides_do_not_reach_the_wire`
// above, extended to all three in-memory-only fields (Push 4b tranche
// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`): `accidental_extensions`
// and `smufl` join `overrides` off the wire.
fn score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not() {
// Schema major 3 (Push 4b tranche 3b-i, `spec/CONTRACT_PUSH4B_3BI_WIRE.md`)
// moves `smufl` and `overrides` onto the wire — the direct
// **inversion** of the pre-major-3 proof this test replaces (formerly
// two tests: `score_tuning_context_overrides_do_not_reach_the_wire`,
// Push 4b tranche 2; and
// `score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire`,
// tranche 3a). `accidental_extensions` stays in memory only (staged to
// a later major, appended after `overrides` when it lands), so this
// is now a **staging-boundary** test: it proves the line falls
// exactly between `overrides` and `accidental_extensions`, not that
// nothing reaches the wire.
use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement};
use crate::graph::ScoreTuningContext;
use crate::ids::{ReplicaId, VoiceId};
@ -3484,7 +3701,7 @@ mod tests {
reference: None,
});
// Sanity: the fixture actually differs in memory in all three
// fields, so the byte-identity assertion below is not vacuous.
// fields, so neither assertion below is vacuous.
assert_ne!(loaded, bare);
assert!(!loaded.accidental_extensions.is_empty());
assert_ne!(loaded.smufl, SmuflVersionRequirement::default());
@ -3494,17 +3711,129 @@ mod tests {
loaded.enc(&mut bytes_loaded);
let mut bytes_bare = Vec::new();
bare.enc(&mut bytes_bare);
assert_eq!(
// `smufl` and `overrides` differ in memory, so — unlike before major
// 3 — the two encodings must now differ too.
assert_ne!(
bytes_loaded, bytes_bare,
"accidental_extensions, smufl, and overrides must not reach canonical bytes \
(Push 4b tranche 3a)"
"smufl and overrides must reach canonical bytes as of schema major 3"
);
let decoded = ScoreTuningContext::dec(&mut Reader::new(&bytes_loaded)).expect("decodes");
assert_eq!(decoded, bare);
// smufl and overrides round-trip exactly...
assert_eq!(decoded.smufl, loaded.smufl);
assert_eq!(decoded.overrides, loaded.overrides);
// ...but accidental_extensions is still dropped: never on the wire.
assert!(decoded.accidental_extensions.is_empty());
assert_eq!(decoded.smufl, SmuflVersionRequirement::default());
assert!(decoded.overrides.is_empty());
assert_eq!(
decoded,
ScoreTuningContext {
accidental_extensions: Vec::new(),
..loaded.clone()
}
);
}
/// Pins the **exact frozen bytes** of the schema-major-3
/// `ScoreTuningContext` wire form (Push 4b tranche 3b-i).
///
/// Why a byte literal and not just a round-trip: the cross-implementation
/// decode corpus (`spec/vectors/decode_vectors.txt`,
/// `req:binfmt:decode-vectors`) covers only the **operation** and
/// **bundle** surfaces — there is no `epiphany-core` vectors module, so
/// the core score wire has no vector pinning it. Every other test here
/// round-trips `enc` against `dec`, which stays green under a
/// *self-consistent* reordering of the fields: swapping `smufl` and
/// `overrides` in both halves passes the entire workspace suite. That
/// would silently move a layout `req:binfmt:frozen-layout` freezes
/// permanently, and a second implementation would disagree. This golden
/// is what makes such a swap fail.
///
/// If this test fails, the wire format changed. That is a schema-major
/// event — do not "fix" it by re-copying the bytes.
#[test]
fn schema_major_3_tuning_context_wire_bytes_are_frozen() {
use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement};
use crate::graph::ScoreTuningContext;
use crate::ids::{ReplicaId, VoiceId};
use crate::pitch::TuningSystemId;
use crate::tuning::{TuningOverride, TuningScope};
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
// --- the default context: what every score with untouched tuning
// encodes to, so this literal is the one that appears in practice.
//
// 06000000 636d6e2d3132 default_pitch_space = "cmn-12"
// 06000000 7465742d3132 default_tuning_system = "tet-12"
// 00050004080000000000000000807b40 reference (frozen since major 0)
// 01002800 smufl.minimum = {major 1, minor_centi 40} = 1.4
// 01002800 smufl.authored_against = 1.4
// 00000000 overrides = u32 count 0
//
// Note the S12 normalization is visible on the wire: 1.4 stores
// `minor_centi` 40 (0x28), NOT a literal minor 4 — that is what makes
// 1.12 < 1.3 order correctly.
let mut bare = Vec::new();
ScoreTuningContext::default().enc(&mut bare);
assert_eq!(
hex(&bare),
"06000000636d6e2d3132060000007465742d3132000500040800000000000000 0080 7b40010028000100280000000000"
.replace(' ', ""),
"the default major-3 tuning-context wire form moved"
);
assert_eq!(bare.len(), 48);
// --- a fully loaded context: non-default smufl, one override, and a
// populated `accidental_extensions` that MUST contribute zero bytes
// (staged to a later major).
//
// ...prefix and reference as above...
// 01000c00 smufl.minimum = {1, 12} = 1.12
// 01001200 smufl.authored_against = {1, 18} = 1.18
// 01000000 overrides = u32 count 1
// <34 bytes> the TuningOverride body: TuningScope::Voice
// (discriminant 0) + VoiceId, then the three Options
// (pitch_space None, tuning_system Some("tet-19"),
// reference None).
let mut loaded = ScoreTuningContext {
smufl: SmuflVersionRequirement {
minimum: SmuflVersion::from_decimal(1, "12").unwrap(),
authored_against: SmuflVersion::from_decimal(1, "18").unwrap(),
},
..ScoreTuningContext::default()
};
loaded.overrides.push(TuningOverride {
scope: TuningScope::Voice(VoiceId::new(ReplicaId(1), 7)),
pitch_space: None,
tuning_system: Some(TuningSystemId::new("tet-19")),
reference: None,
});
loaded
.accidental_extensions
.push(crate::accidental::fixture_extensions(
"heji",
PitchSpaceModification::CmnChromatic(1),
));
assert!(!loaded.accidental_extensions.is_empty());
let mut got = Vec::new();
loaded.enc(&mut got);
assert_eq!(
hex(&got),
"06000000636d6e2d3132060000007465742d31320005000408000000000000000080 7b4001000c000100120001000000001000000000000000000000010000000000000007 0001060000007465742d313900"
.replace(' ', ""),
"the loaded major-3 tuning-context wire form moved"
);
assert_eq!(got.len(), 82);
// The golden is bidirectional: the frozen bytes decode back to the
// value, with `accidental_extensions` dropped (it never encoded).
let back = ScoreTuningContext::dec(&mut Reader::new(&got)).expect("golden decodes");
assert_eq!(back.smufl, loaded.smufl);
assert_eq!(back.overrides, loaded.overrides);
assert!(back.accidental_extensions.is_empty());
}
#[test]
@ -3571,10 +3900,10 @@ mod tests {
let bytes = score.canonical_bytes();
assert_eq!(Score::decode_canonical(&bytes).unwrap(), score);
assert_eq!(Score::decode_canonical_versioned(&bytes, 2).unwrap(), score);
assert_eq!(Score::decode_canonical_versioned(&bytes, 3).unwrap(), score);
// The non-default major-1 values are representable at the frozen v1
// wire form too: the v1 encoding migrates back to the same score
// (its major-2 fields are all defaults here).
// (its major-2 and major-3 fields are all defaults here).
let v1 = encode_v1_score(&score);
assert_eq!(Score::decode_canonical_versioned(&v1, 1).unwrap(), score);
}
@ -3623,22 +3952,23 @@ mod tests {
);
// Major 0: the shorter v0 bytes migrate up, default-filling all three
// (and, composed, every schema-major-2 default).
// (and, composed, every schema-major-2 default, and — composed
// further — the schema-major-3 `smufl`/`overrides` defaults).
let migrated = Score::decode_canonical_versioned(&v0, 0).unwrap();
assert_eq!(migrated, score);
// The migrated score re-encodes to the production (major-2) bytes.
// The migrated score re-encodes to the production (major-3) bytes.
assert_eq!(migrated.canonical_bytes(), current);
// Major 1: the frozen v1 bytes migrate to the same score.
assert_eq!(Score::decode_canonical_versioned(&v1, 1).unwrap(), score);
// Major 2: the current bytes decode unchanged.
// Major 3: the current bytes decode unchanged.
assert_eq!(
Score::decode_canonical_versioned(&current, 2).unwrap(),
Score::decode_canonical_versioned(&current, 3).unwrap(),
score
);
}
// A major outside {0, 1, 2} is a defensive decode error (the gate
// A major outside {0, 1, 2, 3} is a defensive decode error (the gate
// rejects it upstream in practice).
assert!(Score::decode_canonical_versioned(&valid_score(1).canonical_bytes(), 3).is_err());
assert!(Score::decode_canonical_versioned(&valid_score(1).canonical_bytes(), 4).is_err());
}
#[test]
@ -3671,7 +4001,12 @@ mod tests {
// (abbreviation 1 + sound count 4 + transposition 1 + clef 3 +
// full config 15 + members count 4); an overridden staff-instance
// config 14; metadata 23 (three presence bytes + two i64
// timestamps + additional count 4).
// timestamps + additional count 4); plus a flat 12
// (schema-major-3 `tuning_context.smufl`/`overrides`: `smufl` is
// two bare `SmuflVersion`s = 4 x u16 = 8, `overrides` is an empty
// Vec = a bare u32 count = 4 — present at every score regardless
// of content, since `v1` is frozen at the pre-major-3 3-field
// `tuning_context` while `current` is now major 3).
let expected_removed = c.slurs.len() * 4
+ c.ties.len() * 2
+ c.beams.len() * 5
@ -3680,11 +4015,13 @@ mod tests {
+ score.staves.len() * 17
+ score.instruments.len() * 28
+ override_sites * 14
+ 23;
+ 23
+ 12;
assert_eq!(
current.len() - v1.len(),
expected_removed,
"v1 omits exactly the appended major-2 default bytes"
"v1 omits exactly the appended major-2 default bytes (plus the flat \
major-3 tuning-context default bytes, present regardless of content)"
);
let migrated = Score::decode_canonical_versioned(&v1, 1).unwrap();
@ -3693,6 +4030,43 @@ mod tests {
}
}
#[test]
fn v2_score_migrates_default_filling_smufl_and_overrides() {
// Schema major 3 (Push 4b tranche 3b-i, `spec/CONTRACT_PUSH4B_3BI_WIRE.md`)
// appends `tuning_context.smufl` and `.overrides`; a major-2 score
// carries neither, so migrate-on-read must reconstruct both at their
// canonical defaults. `accidental_extensions` is unaffected — it was
// never on the wire, at any major, so there is nothing for a v2->v3
// migration to default-fill for it. Genuine v2 bytes come from the
// mirror v2 encoder; the size anchor pins that v2 omits exactly the
// flat 12 default bytes (`smufl` 8 + `overrides` count 4), the same
// constant every score pays regardless of its other content, since
// the generator leaves `tuning_context` at its default.
for seed in 0..64u64 {
let score = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(5));
// Precondition: the generator leaves tuning_context's smufl/overrides
// at their defaults, so a v2->v3 migration reconstructing the
// default IS reconstructing the original.
assert_eq!(
score.tuning_context.smufl,
crate::accidental::SmuflVersionRequirement::default()
);
assert!(score.tuning_context.overrides.is_empty());
let current = score.canonical_bytes();
let v2 = encode_v2_score(&score);
assert_eq!(
current.len() - v2.len(),
12,
"v2 omits exactly the flat smufl(8)+overrides-count(4) default bytes"
);
let migrated = Score::decode_canonical_versioned(&v2, 2).unwrap();
assert_eq!(migrated, score);
assert_eq!(migrated.canonical_bytes(), current);
}
}
#[test]
fn current_major_round_trips_non_default_values_for_every_major_2_field() {
// One score carrying a non-default value for EVERY schema-major-2
@ -3875,7 +4249,7 @@ mod tests {
let bytes = score.canonical_bytes();
assert_eq!(Score::decode_canonical(&bytes).unwrap(), score);
assert_eq!(Score::decode_canonical_versioned(&bytes, 2).unwrap(), score);
assert_eq!(Score::decode_canonical_versioned(&bytes, 3).unwrap(), score);
// The per-value seam carries the filled bodies too.
let slur = &score.cross_cutting.slurs[0];
assert_eq!(

View File

@ -240,8 +240,8 @@ pub fn run_decode_fuzz(iters: u64, seed: u64) {
// The current-layout decoder: must not panic; an Ok must round-trip.
check_score(Score::decode_canonical(&bytes), &bytes);
// The schema-version dispatch seam. Major 2 is the current layout;
// majors 1 and 0 run the frozen migrations; an arbitrary major
// The schema-version dispatch seam. Major 3 is the current layout;
// majors 2, 1, and 0 run the frozen migrations; an arbitrary major
// exercises the defensive out-of-accept-set path. Each migration
// default-fills the appended fields, so it does not round-trip to the
// *current* form — but each is strictly canonical over its OWN wire

View File

@ -1643,22 +1643,26 @@ pub struct ViewDefinition {
/// the default pitch space, tuning system, and reference pitch every score must
/// declare; per-scope overrides land here (Push 4b tranche 2); accidental
/// registry extensions and the SMuFL version requirement land here too (Push
/// 4b tranche 3a) — all three in memory only.
/// 4b tranche 3a). `smufl` and `overrides` reach the wire as of tranche 3b-i;
/// `accidental_extensions` stays in memory only.
///
/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`; tranche
/// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`).** The canonical encoding stays
/// **exactly** `default_pitch_space`, `default_tuning_system`, `reference`, in
/// that order — schema major 3 has not been opened, so `overrides`,
/// `accidental_extensions`, and `smufl` are *not* on the wire this tranche.
/// See the hand-written `impl Codec` in `codec.rs` and `impl TextValue` in
/// `textvalue_graph.rs` (replacing the `struct_codec!` this type used to use,
/// which named every field it was given in its generated decoder and so
/// cannot compile against an in-memory-only one). Where the three in-memory
/// fields sit in *this* Rust struct is free — the manual codec fixes the wire
/// order independently of field declaration order — but they are declared
/// here in the specification's eventual major-3 field order
/// (`accidental_extensions`, `smufl`, `overrides`) for readability. Putting
/// all three on the wire in that order is tranche 3b's job, not this one's.
/// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`; tranche 3b-i,
/// `spec/CONTRACT_PUSH4B_3BI_WIRE.md`, schema major 3).** The canonical
/// encoding is `default_pitch_space` ⌢ `default_tuning_system` ⌢ `reference`
/// (the frozen major-0..2 prefix) ⌢ `smufl` ⌢ `overrides` — append-after-existing,
/// per the frozen-layout rule. `accidental_extensions` is **not** on the wire:
/// it is staged to a later major, which will append it *after* `overrides`
/// when it lands (its own consumer, the engraver, does not exist yet). See
/// the hand-written `impl Codec` in `codec.rs` and `impl TextValue` in
/// `textvalue_graph.rs` (the manual codec predates this bump and stays manual
/// now for the one remaining in-memory field, `accidental_extensions`, since
/// `struct_codec!`'s generated decoder cannot build a value from fewer fields
/// than it declares). Where `accidental_extensions` sits in *this* Rust
/// struct is free — the manual codec fixes the wire order independently of
/// field declaration order — but it is declared here in the specification's
/// eventual field order (`accidental_extensions`, `smufl`, `overrides`) for
/// readability.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ScoreTuningContext {
pub default_pitch_space: PitchSpaceId,

View File

@ -337,7 +337,7 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u
);
// serialize: stage the score as a properly-roled ACCELERATION snapshot
// (Binary Format §Schema Major 2): a `ChunkKind::Snapshot` stamped with
// (Binary Format §Schema Major 3): a `ChunkKind::Snapshot` stamped with
// the current schema major and referenced from the manifest's
// `acceleration_snapshots` — NOT the canonical base, which is the
// MaterializedState's role and stays major 0. (The `SnapshotId` here is a
@ -349,7 +349,7 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
let snapshot = StagedChunk {
kind: ChunkKind::Snapshot,
schema_version: SchemaVersion::for_major(2),
schema_version: SchemaVersion::for_major(3),
payload: canonical.clone(),
};
let frontier = frontier.to_vec();
@ -388,7 +388,7 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u
.acceleration_snapshots
.first()
.expect("an acceleration snapshot");
assert_eq!(accel.root.schema_version, SchemaVersion::for_major(2));
assert_eq!(accel.root.schema_version, SchemaVersion::for_major(3));
let loaded = reopened
.read_chunk(&accel.root)
.expect("read snapshot chunk back");

View File

@ -0,0 +1,223 @@
# 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 (~13141374): 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.

View File

@ -240,7 +240,7 @@
{\Large\scshape\color{epiphanyslate}Binary Format}\\[6pt]
{\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt]
{\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt]
{\normalsize\color{epiphanyink}Version 0.9.0 --- The decode vector corpus is ratified (the cross-implementation decoder test is no longer deferred)}\\[4pt]
{\normalsize\color{epiphanyink}Version 0.10.0 --- Schema major 3: the tuning context's SMuFL requirement and per-scope overrides reach the wire}\\[4pt]
{\small\color{epiphanyslate}Normative for the byte layouts it defines}
\vfill
\end{titlepage}
@ -2297,9 +2297,10 @@ governed by the Binary Format companion specification, which defines the
wire encoding for each schema version''}). This chapter defines the wire
rules for schema evolution. Chapters~\ref{ch:values}--\ref{ch:barriers}
specify the schema-major-0 layouts; Section~\ref{sec:evolution:major1}
specifies the schema-major-1 delta and the migration between them, and
specifies the schema-major-1 delta and the migration between them,
Section~\ref{sec:evolution:major2} the schema-major-2 delta and its
migration from major~1.
migration from major~1, and Section~\ref{sec:evolution:major3} the
schema-major-3 delta and its migration from major~2.
\section{The Chunk-Level Gate}
\label{sec:evolution:gate}
@ -2313,11 +2314,19 @@ Every chunk declares a \texttt{SchemaVersion} (major, minor) in its
$[\textsc{min}, \textsc{max}]$ of majors and rejects any chunk whose major
falls \emph{outside} $[\textsc{min}, \textsc{max}]$ --- too new (above
\textsc{max}) or, once \textsc{min} rises past a retired major, too old
(Section~\ref{sec:evolution:major1}). Three majors are defined:
\tablenums{0}, \tablenums{1} (Section~\ref{sec:evolution:major1}), and
\tablenums{2} (Section~\ref{sec:evolution:major2}); the reference
implementation's accept-set is $\{0, 1, 2\}$ ($\textsc{min} = 0$,
$\textsc{max} = 2$).
(Section~\ref{sec:evolution:major1}). Four majors are defined:
\tablenums{0}, \tablenums{1} (Section~\ref{sec:evolution:major1}),
\tablenums{2} (Section~\ref{sec:evolution:major2}), and \tablenums{3}
(Section~\ref{sec:evolution:major3}). The accept-set is \textbf{per chunk
role}, not one shared bound (Section~\ref{sec:evolution:major1}'s ``raised
per chunk role'' rule, sharpened by major~3, the first bump under which the
roles' maxima genuinely diverge): the \texttt{Snapshot} role's max is
\tablenums{3}; the \texttt{OperationEnvelopeBlock} role's max stays
\tablenums{2} (schema major~3 embeds no operation payload, so this role
does not rise); every other role's max is \tablenums{0}. The reference
implementation's overall envelope is $\{0, 1, 2, 3\}$ ($\textsc{min} = 0$,
$\textsc{max} = 3$), realized as these differing per-role maxima rather
than a single shared bound.
\item \textbf{Minor} = additive. v0 readers verify the major only; the
minor is a \emph{record}, not a gate --- but it is a mandatory record:
a writer \MUST{} raise the chunk schema minor when it emits any
@ -2884,6 +2893,121 @@ rendering exactly: a migrated document draws byte-identically.
\end{tabular}
\end{center}
\section{Schema Major 3}
\label{sec:evolution:major3}
Schema major~3 is the third data-model expansion major (Push 4b tranche
3b-i, \sectionsc{Score Tuning Context}): \texttt{ScoreTuningContext} gains
\texttt{smufl} (the score's declared SMuFL version requirement) and
\texttt{overrides} (per-scope tuning overrides) on the canonical wire.
\texttt{accidental\_extensions} --- the third field the core specification
added alongside these two --- is deliberately \textbf{staged} to a later
major: its consumer, the engraver, does not exist yet, and freezing a wire
layout ahead of a real consumer is exactly what the frozen-layout rule
(Requirement~\ref{req:binfmt:frozen-layout}) warns against. When
\texttt{accidental\_extensions} does land, it appends \emph{after}
\texttt{overrides}, per the append-only rule this chapter has followed since
major~1.
\subsection{Where the changed fields reach}
Only one payload type changes, and it changes at exactly one site:
\begin{itemize}
\item \textbf{Snapshot-only:} \texttt{ScoreTuningContext} reaches the wire
\emph{only} through the acceleration full-\texttt{Score} snapshot. No
operation payload embeds the tuning context (core Chapter~6's operation
vocabulary carries no \texttt{ScoreTuningContext} / \texttt{TuningOverride}
/ \texttt{SmuflVersionRequirement} field anywhere), so the
\textbf{canonical operation layer is untouched}: no op payload is ``born
at v3'', and no frozen v2 op-payload decoder is needed. The
\texttt{OperationEnvelopeBlock} role's accept-set therefore stays at
major~2 (Section~\ref{sec:evolution:major2}) --- schema major~3 is the
first data-model bump under which a chunk role's max does \emph{not}
move in lockstep with the others.
\item \textbf{The canonical base is unchanged.} The
\texttt{MaterializedState} embeds no tuning context, so it remains
major~0, byte-identical across the bump --- the same keystone as
majors~1 and~2, and a conformance test \SHOULD{} assert it again.
\item The manifest stays major~0 (nothing here touches it).
\end{itemize}
\subsection{Cross-major reader behaviour}
The major-1/major-2 rules extend unchanged: the acceleration snapshot is
non-canonical, so a reader \MAY{} discard and regenerate a foreign-major one,
or migrate it on read (the reference implementation migrates). Canonical
chunks parse or the bundle opens read-only. Migration \emph{composes}: a
major-3 reader migrates a major-0 snapshot
v0${\to}$v1${\to}$v2${\to}$v3 in one read, each step total and
default-filling. Because no operation payload changes at this major, a
major-2-only reader's op-block admission is exactly as before this bump ---
only the \texttt{Snapshot} role's accept-set widens, to \tablenums{3}
(Section~\ref{sec:evolution:gate}).
\subsection{Changed value layout}
As in majors~1 and~2, the wire form appends new fields \emph{after} the
existing ones, independent of the core specification's presentational field
order (Section~\ref{sec:evolution:major1}'s note applies unchanged):
\begin{itemize}
\item \texttt{ScoreTuningContext} $=$ \texttt{default\_pitch\_space} \cat{}
\texttt{default\_tuning\_system} \cat{} \texttt{reference} (the frozen
major-0/1/2 prefix, unchanged since major~0) \cat{}
\textbf{\texttt{smufl}} \cat{} \textbf{\texttt{overrides}}.
\texttt{accidental\_extensions} is \textbf{not} on the wire this major
(see above): a decoder default-fills it, exactly as it always has, and a
future major appends it after \texttt{overrides} when it lands.
\end{itemize}
\subsection{New leaf-type layouts}
\begin{itemize}
\item \texttt{SmuflVersion} $=$ \texttt{major} (\texttt{u16} LE, bare) \cat{}
\texttt{minor\_centi} (\texttt{u16} LE, bare) --- the fractional SMuFL
version digits normalized to hundredths (1.12 $\to$ \tablenums{12}, 1.4
$\to$ \tablenums{40}, 1.3 $\to$ \tablenums{30}) so an integer comparison
on the pair agrees with SMuFL's real release order (1.12 shipped before
1.3, though \tablenums{3} literally sorts before \tablenums{12}).
\item \texttt{SmuflVersionRequirement} $=$ \texttt{minimum}
(\texttt{SmuflVersion}) \cat{} \texttt{authored\_against}
(\texttt{SmuflVersion}) --- $8$ bytes total, neither field framed as a
length-prefixed leaf (both are fixed-shape bare-integer pairs).
\item \texttt{TuningScope}: one discriminant byte \cat{} payload ---
\tablenums{0} Voice \cat{} \texttt{VoiceId}; \tablenums{1} Staff \cat{}
\texttt{StaffId}; \tablenums{2} Region \cat{} \texttt{RegionId};
\tablenums{3} Range \cat{} \texttt{start} (\texttt{TimeAnchor}) \cat{}
\texttt{end} (\texttt{TimeAnchor}) \cat{} \texttt{voices}
(\texttt{VoiceSelector}), fields in declaration order.
\item \texttt{TuningOverride} $=$ \texttt{scope} (\texttt{TuningScope})
\cat{} \texttt{pitch\_space} (\texttt{Option<PitchSpaceId>}) \cat{}
\texttt{tuning\_system} (\texttt{Option<TuningSystemId>}) \cat{}
\texttt{reference} (\texttt{Option<ReferencePitch>}) --- one presence
byte per \texttt{Option}, the inner catalog-id/pitch leaves under this
chapter's regime~(a) framing (a catalog id is a length-prefixed string;
\texttt{ReferencePitch} carries its own established layout).
\end{itemize}
\subsection{Migration from major 2}
Total and default-filling, needing no score context; composes after the
major-1 and major-2 migrations for major-1/major-0 input. The flat $12$
default bytes this migration appends (\texttt{smufl} $= 8$,
\texttt{overrides}' empty count $= 4$) are the same for every score,
independent of its other content, since neither field depends on anything
already on the wire.
\begin{center}
\begin{tabular}{p{1.9in} p{3.4in}}
\toprule
\textbf{major-2 form} & \textbf{major-3 form} \\
\midrule
\texttt{ScoreTuningContext} & append \texttt{smufl} $=$ $1.4$/$1.4$ (both
\texttt{minimum} and \texttt{authored\_against}, the version this
reference implementation already targets), \texttt{overrides} $=$ empty
\\
canonical-base \texttt{MaterializedState} & unchanged, byte-identical,
stays major~0 \\
\bottomrule
\end{tabular}
\end{center}
% ===========================================================================
\chapter{Non-Canonical Pinned Encodings}
\label{ch:noncanon}
@ -3296,6 +3420,25 @@ only}: implementations need not agree on an error taxonomy.
the verdict. Accepting a \texttt{reject} vector and normalizing it is
\emph{accepting} it. The wire-format fuzzer remains an implementation
deliverable. No wire layout changed. \\
\today & Schema evolution / Graph value layouts & 0.10.0 --- Defines
\textbf{schema major~3} (Section~\ref{sec:evolution:major3}, Push~4b
tranche 3b-i): \texttt{ScoreTuningContext} gains \texttt{smufl}
(\texttt{SmuflVersionRequirement}) and \texttt{overrides}
($\mathrm{seq}$(\texttt{TuningOverride})) on the canonical wire, with
layouts for the four new leaf types (\texttt{SmuflVersion},
\texttt{SmuflVersionRequirement}, \texttt{TuningScope},
\texttt{TuningOverride}) and the total default-filling v2${\to}$v3
migration table. \texttt{accidental\_extensions} is deliberately staged to
a later major (no consumer yet) and stays off the wire. Reaches only the
acceleration full-\texttt{Score} snapshot --- no operation payload embeds
the tuning context, so the canonical operation layer and its accept-set
are untouched (the first data-model major under which a chunk role's max
does not move in lockstep with the others); the canonical base and
manifest stay major~0, byte-identical. Accept-set: \texttt{Snapshot}
widens to \tablenums{3}, \texttt{OperationEnvelopeBlock} stays at
\tablenums{2}. Semantics: core specification Chapter~4, \sectionsc{Score
Tuning Context} / \sectionsc{SMuFL Versioning}; the staging ruling itself
is Push~4b's own (\texttt{spec/CONTRACT\_PUSH4B\_3BI\_WIRE.md}). \\
\bottomrule
\end{longtable}