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:
parent
456792debd
commit
68b08adb5e
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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(¤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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 (~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.
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue