From 68b08adb5e1d431cb054ea1ba302a436a08599c3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 17:35:55 -0400 Subject: [PATCH] Push 4b tranche 3b-i: the score wire opens schema major 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/epiphany-bundle/src/bundle.rs | 21 +- crates/epiphany-bundle/src/ids.rs | 17 +- crates/epiphany-core/DECISIONS.md | 130 +++++ crates/epiphany-core/src/codec.rs | 582 +++++++++++++++++++---- crates/epiphany-core/src/fuzz.rs | 4 +- crates/epiphany-core/src/graph.rs | 32 +- crates/epiphany-testkit/src/roundtrip.rs | 6 +- spec/CONTRACT_PUSH4B_3BI_WIRE.md | 223 +++++++++ spec/binary_format.tex | 159 ++++++- 9 files changed, 1031 insertions(+), 143 deletions(-) create mode 100644 spec/CONTRACT_PUSH4B_3BI_WIRE.md diff --git a/crates/epiphany-bundle/src/bundle.rs b/crates/epiphany-bundle/src/bundle.rs index 37a880a..8ee84a3 100644 --- a/crates/epiphany-bundle/src/bundle.rs +++ b/crates/epiphany-bundle/src/bundle.rs @@ -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 diff --git a/crates/epiphany-bundle/src/ids.rs b/crates/epiphany-bundle/src/ids.rs index 14f6896..5178f9f 100644 --- a/crates/epiphany-bundle/src/ids.rs +++ b/crates/epiphany-bundle/src/ids.rs @@ -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 }, } } diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index e69ad15..c7526b3 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -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) ⌢ + tuning_system(Option) ⌢ reference(Option)`. + +**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. diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 9d3ca06..c423f38 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -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) { + self.major.enc(out); + self.minor_centi.enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + 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) { + self.minimum.enc(out); + self.authored_against.enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + 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) { + 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 { + 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) { + self.scope.enc(out); + self.pitch_space.enc(out); + self.tuning_system.enc(out); + self.reference.enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + 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) { 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 { 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 { 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 { 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 { 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 { /// 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 { 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> { } /// 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 { 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 { 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 { 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 { 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) { + 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 { + 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 { + 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 { + 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(¤t, 2).unwrap(), + Score::decode_canonical_versioned(¤t, 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!( diff --git a/crates/epiphany-core/src/fuzz.rs b/crates/epiphany-core/src/fuzz.rs index a514d0a..87a0048 100644 --- a/crates/epiphany-core/src/fuzz.rs +++ b/crates/epiphany-core/src/fuzz.rs @@ -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 diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index 7a96575..1f1d012 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -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, diff --git a/crates/epiphany-testkit/src/roundtrip.rs b/crates/epiphany-testkit/src/roundtrip.rs index 6791aa3..56c724c 100644 --- a/crates/epiphany-testkit/src/roundtrip.rs +++ b/crates/epiphany-testkit/src/roundtrip.rs @@ -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"); diff --git a/spec/CONTRACT_PUSH4B_3BI_WIRE.md b/spec/CONTRACT_PUSH4B_3BI_WIRE.md new file mode 100644 index 0000000..6e494ca --- /dev/null +++ b/spec/CONTRACT_PUSH4B_3BI_WIRE.md @@ -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, 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) + ⌢ `tuning_system` (Option) ⌢ `reference` (Option). + (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` = 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`: the live walk for all 18 + other fields; `enc_tuning_context_v2` for `tuning_context`. + - `fn decode_v2_score(bytes) -> Result`: 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. diff --git a/spec/binary_format.tex b/spec/binary_format.tex index 783be3b..9bf8708 100644 --- a/spec/binary_format.tex +++ b/spec/binary_format.tex @@ -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}) \cat{} + \texttt{tuning\_system} (\texttt{Option}) \cat{} + \texttt{reference} (\texttt{Option}) --- 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}