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
|
/// `OperationEnvelopeBlock` admits major 2 (schema major 2 fills the
|
||||||
/// cross-cutting/staff/metadata bodies its payloads embed; major 1 embedded a
|
/// cross-cutting/staff/metadata bodies its payloads embed; major 1 embedded a
|
||||||
/// v1 `CreateRegion`; the reader treats the block bytes opaquely, so it
|
/// v1 `CreateRegion`; the reader treats the block bytes opaquely, so it
|
||||||
/// parses a higher-major block without decoding the payload). `Snapshot`
|
/// parses a higher-major block without decoding the payload). Schema major 3
|
||||||
/// admits major 2 for the acceleration full-`Score` form (decoded through
|
/// (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
|
/// the core versioned seam); the canonical BASE carried under the same kind
|
||||||
/// must stay major 0, enforced per role. Every other role stays at
|
/// must stay major 0, enforced per role. Every other role stays at
|
||||||
/// [`SUPPORTED_SCHEMA_MAJOR`] until its own versioned path lands — the
|
/// [`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,
|
ChunkKind::OperationEnvelopeBlock => 2,
|
||||||
// The payload-polymorphic Snapshot role: the acceleration
|
// The payload-polymorphic Snapshot role: the acceleration
|
||||||
// full-`Score` form is decoded through the core versioned seam
|
// 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
|
// *canonical base* must stay major 0 regardless — that is enforced
|
||||||
// per ROLE (`mis_stamped_canonical_base`, consulted at open and
|
// per ROLE (`mis_stamped_canonical_base`, consulted at open and
|
||||||
// commit), not by this per-kind bound.
|
// commit), not by this per-kind bound.
|
||||||
ChunkKind::Snapshot => 2,
|
ChunkKind::Snapshot => 3,
|
||||||
_ => SUPPORTED_SCHEMA_MAJOR,
|
_ => SUPPORTED_SCHEMA_MAJOR,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1310,17 +1312,20 @@ mod tests {
|
||||||
// CreateRegion), and schema major 2 to major 2 (a block bearing a v2
|
// CreateRegion), and schema major 2 to major 2 (a block bearing a v2
|
||||||
// cross-cutting/staff/metadata value); every other role stays exact-0
|
// cross-cutting/staff/metadata value); every other role stays exact-0
|
||||||
// until its own versioned path lands, and the manifest stays major 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::V1.major, 1);
|
||||||
assert_eq!(SchemaVersion::V2.major, 2);
|
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);
|
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
|
// through the core versioned seam); the canonical BASE stays major 0
|
||||||
// per role (`mis_stamped_canonical_base`). The remaining roles stay
|
// per role (`mis_stamped_canonical_base`). The remaining roles stay
|
||||||
// at the generic baseline: the layout cache and the operation index.
|
// at the generic baseline: the layout cache and the operation index.
|
||||||
assert_eq!(SUPPORTED_SCHEMA_MAJOR, 0);
|
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::LayoutCache), 0);
|
||||||
assert_eq!(max_supported_major(ChunkKind::OperationIndex), 0);
|
assert_eq!(max_supported_major(ChunkKind::OperationIndex), 0);
|
||||||
// The manifest gate is exact to the manifest's own major (0), independent
|
// 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.
|
/// operation-envelope block bearing a v2 value.
|
||||||
pub const V2: SchemaVersion = SchemaVersion { major: 2, minor: 0 };
|
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.
|
/// Constructs a schema version.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub const fn new(major: u16, minor: u16) -> Self {
|
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,
|
/// 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}`
|
/// [`Self::V1`] for major 1, [`Self::V2`] for major 2, [`Self::V3`] for
|
||||||
/// for any higher (future) major. A writer maps a chunk's derived schema
|
/// major 3, and `{major, 0}` for any higher (future) major. A writer maps
|
||||||
/// major to a version this way — e.g. an operation-envelope block stamps
|
/// a chunk's derived schema major to a version this way — e.g. an
|
||||||
/// the max over its operations' `schema_major()`.
|
/// operation-envelope block stamps the max over its operations'
|
||||||
|
/// `schema_major()`.
|
||||||
#[inline]
|
#[inline]
|
||||||
pub const fn for_major(major: u16) -> Self {
|
pub const fn for_major(major: u16) -> Self {
|
||||||
match major {
|
match major {
|
||||||
0 => SchemaVersion::V0,
|
0 => SchemaVersion::V0,
|
||||||
1 => SchemaVersion::V1,
|
1 => SchemaVersion::V1,
|
||||||
2 => SchemaVersion::V2,
|
2 => SchemaVersion::V2,
|
||||||
|
3 => SchemaVersion::V3,
|
||||||
m => SchemaVersion { major: m, minor: 0 },
|
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
|
plain parenthetical citation), `conformance_suite` (8/8), and
|
||||||
`requirement_labels` (6 passed, counts unchanged at 212/282/282) all pass.
|
`requirement_labels` (6 passed, counts unchanged at 212/282/282) all pass.
|
||||||
No `.tex` file was touched and no requirement was added.
|
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 epiphany_determinism::{CanonicalDecode, CanonicalEncode, CanonicalF64, ContentHash};
|
||||||
|
|
||||||
|
use crate::accidental::{SmuflVersion, SmuflVersionRequirement};
|
||||||
use crate::event::{
|
use crate::event::{
|
||||||
ArticulationMark, CueEvent, CueRendering, DynamicMark, Event, EventArena, GraceKind,
|
ArticulationMark, CueEvent, CueRendering, DynamicMark, Event, EventArena, GraceKind,
|
||||||
GraphicEvent, IndeterminacyHints, IndeterminacyKind, IndeterminateEvent, OrnamentMark,
|
GraphicEvent, IndeterminacyHints, IndeterminacyKind, IndeterminateEvent, OrnamentMark,
|
||||||
|
|
@ -84,6 +85,7 @@ use crate::time::{
|
||||||
MeasurePosition, MusicalDuration, MusicalPosition, RationalTime, RegionEdge, TimeAnchor,
|
MeasurePosition, MusicalDuration, MusicalPosition, RationalTime, RegionEdge, TimeAnchor,
|
||||||
TimeBounds, WallClockDuration, WallClockTime,
|
TimeBounds, WallClockDuration, WallClockTime,
|
||||||
};
|
};
|
||||||
|
use crate::tuning::{TuningOverride, TuningScope};
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// Errors and the reader cursor.
|
// Errors and the reader cursor.
|
||||||
|
|
@ -1832,42 +1834,150 @@ struct_codec!(BeatGroup {
|
||||||
subdivision,
|
subdivision,
|
||||||
accent
|
accent
|
||||||
});
|
});
|
||||||
// `ScoreTuningContext` has gained three in-memory-only fields beyond the
|
// ===========================================================================
|
||||||
// three wire fields — `overrides` (Push 4b tranche 2,
|
// accidental.rs / tuning.rs — the schema-major-3 leaf layouts staged onto the
|
||||||
// `spec/CONTRACT_PUSH4B_RESOLVER.md`), then `accidental_extensions` and
|
// wire this tranche (Push 4b tranche 3b-i, `spec/CONTRACT_PUSH4B_3BI_WIRE.md`).
|
||||||
// `smufl` (Push 4b tranche 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`) — none
|
// Only these four types gain a `Codec`; `accidental_extensions` and its whole
|
||||||
// of which may reach the wire: schema major 3 has not been opened.
|
// subtree stay in-memory-only (no `Codec` exists, or may exist, for them —
|
||||||
// `struct_codec!` cannot express that — its generated `dec` ends in a struct
|
// `accidental.rs`'s own module doc). Hand-written rather than
|
||||||
// literal naming every field it was given, so an in-memory-only field either
|
// `struct_codec!`/`cstyle_enum_codec!`: `struct_codec!` also generates a
|
||||||
// goes on the wire (freezing a type before it has a consumer) or the macro
|
// `TextValue` impl, and these four types have none (text projection is a
|
||||||
// cannot build the value at all. Hand-written instead: encode/decode exactly
|
// separate surface this tranche does not touch — see the staging-boundary
|
||||||
// the three wire fields, in their original order, and default the other
|
// tests below and `textvalue_graph.rs`), so the macro would either fail to
|
||||||
// three on decode (`overrides: Vec::new()`, `accidental_extensions:
|
// compile (no `TextValue` for `u16`/`TuningScope`) or silently open a new
|
||||||
// Vec::new()`, `smufl: SmuflVersionRequirement::default()`). A round-trip
|
// text-projection surface neither asked for nor specified. Every layout here
|
||||||
// test just below (`score_tuning_context_overrides_do_not_reach_the_wire`)
|
// is now FROZEN (`req:binfmt:frozen-layout`) — get the field order right.
|
||||||
// proves a non-empty `overrides` encodes to the same bytes as an empty one; a
|
impl Codec for SmuflVersion {
|
||||||
// second (`score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire`)
|
fn enc(&self, out: &mut Vec<u8>) {
|
||||||
// extends the same proof to all three fields at once. The matching
|
self.major.enc(out);
|
||||||
// text-projection proofs are `textvalue_graph.rs`'s
|
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_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 {
|
impl Codec for ScoreTuningContext {
|
||||||
fn enc(&self, out: &mut Vec<u8>) {
|
fn enc(&self, out: &mut Vec<u8>) {
|
||||||
self.default_pitch_space.enc(out);
|
self.default_pitch_space.enc(out);
|
||||||
self.default_tuning_system.enc(out);
|
self.default_tuning_system.enc(out);
|
||||||
self.reference.enc(out);
|
self.reference.enc(out);
|
||||||
|
self.smufl.enc(out);
|
||||||
|
self.overrides.enc(out);
|
||||||
}
|
}
|
||||||
fn dec(r: &mut Reader<'_>) -> Result<Self> {
|
fn dec(r: &mut Reader<'_>) -> Result<Self> {
|
||||||
let default_pitch_space = Codec::dec(r)?;
|
let default_pitch_space = Codec::dec(r)?;
|
||||||
let default_tuning_system = Codec::dec(r)?;
|
let default_tuning_system = Codec::dec(r)?;
|
||||||
let reference = Codec::dec(r)?;
|
let reference = Codec::dec(r)?;
|
||||||
|
let smufl = Codec::dec(r)?;
|
||||||
|
let overrides = Codec::dec(r)?;
|
||||||
Ok(ScoreTuningContext {
|
Ok(ScoreTuningContext {
|
||||||
default_pitch_space,
|
default_pitch_space,
|
||||||
default_tuning_system,
|
default_tuning_system,
|
||||||
reference,
|
reference,
|
||||||
accidental_extensions: Vec::new(),
|
accidental_extensions: Vec::new(),
|
||||||
smufl: crate::accidental::SmuflVersionRequirement::default(),
|
smufl,
|
||||||
overrides: Vec::new(),
|
overrides,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2450,7 +2560,7 @@ impl Score {
|
||||||
/// Decodes the exact inverse of [`Score::canonical_bytes`], validating every
|
/// Decodes the exact inverse of [`Score::canonical_bytes`], validating every
|
||||||
/// tag, length, primitive, and type invariant. Trailing bytes are rejected.
|
/// 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
|
/// schema major is not known to be current, use
|
||||||
/// [`Score::decode_canonical_versioned`].
|
/// [`Score::decode_canonical_versioned`].
|
||||||
///
|
///
|
||||||
|
|
@ -2478,20 +2588,22 @@ impl Score {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The **schema-version dispatch seam** (Binary Format companion
|
/// The **schema-version dispatch seam** (Binary Format companion
|
||||||
/// §"Schema Major 1" / §"Schema Major 2"): decodes a full-`Score` snapshot
|
/// §"Schema Major 1" / §"Schema Major 2" / §"Schema Major 3"): decodes a
|
||||||
/// whose bytes were written under the given schema `major`, migrating a
|
/// full-`Score` snapshot whose bytes were written under the given schema
|
||||||
/// lower-major encoding up to the current in-memory form on read. Major 2
|
/// `major`, migrating a lower-major encoding up to the current in-memory
|
||||||
/// is the current layout ([`Score::decode_canonical`]); majors 1 and 0 are
|
/// form on read. Major 3 is the current layout
|
||||||
/// decoded through their frozen wire forms (`decode_v1_score`,
|
/// ([`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
|
/// `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
|
/// 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
|
/// 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> {
|
pub fn decode_canonical_versioned(bytes: &[u8], major: u16) -> Result<Score> {
|
||||||
match major {
|
match major {
|
||||||
2 => Score::decode_canonical(bytes),
|
3 => Score::decode_canonical(bytes),
|
||||||
|
2 => decode_v2_score(bytes),
|
||||||
1 => decode_v1_score(bytes),
|
1 => decode_v1_score(bytes),
|
||||||
0 => decode_v0_score(bytes),
|
0 => decode_v0_score(bytes),
|
||||||
_ => Err(ScoreDecodeError::InvalidValue("unsupported schema major")),
|
_ => 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
|
/// 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`]
|
/// of the 19 `Score` fields in declaration order, using the current [`Codec`]
|
||||||
/// for every field whose layout is unchanged, the frozen **v1** sub-decoders
|
/// 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
|
/// (v0 == v1) for the types schema major 2 filled, the frozen **v2**
|
||||||
/// sub-decoder for the three that grew a field in schema major 1:
|
/// 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`.
|
/// * `Canvas` (field 2) — v0 was `regions` only; v1 appended `layout_defaults`.
|
||||||
/// [`dec_canvas_v0`] reads the region vector and default-fills the 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
|
/// 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
|
/// 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
|
/// 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> {
|
fn decode_v0_score(bytes: &[u8]) -> Result<Score> {
|
||||||
let mut r = Reader::new(bytes);
|
let mut r = Reader::new(bytes);
|
||||||
// The 19 Score fields in declaration order (codec.rs `struct_codec!(Score)`).
|
// The 19 Score fields in declaration order (codec.rs `struct_codec!(Score)`).
|
||||||
// `canvas` (2) and `instruments` (3) carry v0-specific sub-forms; the
|
// `canvas` (2) and `instruments` (3) carry v0-specific sub-forms; the
|
||||||
// schema-major-2 fills route `metadata`, `staves`, `cross_cutting`, and
|
// schema-major-2 fills route `metadata`, `staves`, `cross_cutting`, and
|
||||||
// (transitively, inside regions) the staff instances through the frozen
|
// (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 metadata = dec_metadata_v1(&mut r)?;
|
||||||
let canvas = dec_canvas_v0(&mut r)?;
|
let canvas = dec_canvas_v0(&mut r)?;
|
||||||
let instruments = dec_instruments_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 parts = Codec::dec(&mut r)?;
|
||||||
let cross_cutting = dec_ccr_v1(&mut r)?;
|
let cross_cutting = dec_ccr_v1(&mut r)?;
|
||||||
let time_signatures = Codec::dec(&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 tempo_map = Codec::dec(&mut r)?;
|
||||||
let events = Codec::dec(&mut r)?;
|
let events = Codec::dec(&mut r)?;
|
||||||
let spelling_attachments = 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
|
/// The **frozen schema-major-0** encoding of a score — the byte-exact inverse of
|
||||||
/// [`decode_v0_score`]'s field walk: `Canvas` without `layout_defaults`,
|
/// [`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`
|
/// every other field through the current [`Codec`]. Used by `decode_v0_score`
|
||||||
/// to enforce strict v0 canonicality (and by the migration tests to synthesize
|
/// 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
|
/// 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);
|
s.parts.enc(&mut out);
|
||||||
enc_ccr_v1(&s.cross_cutting, &mut out);
|
enc_ccr_v1(&s.cross_cutting, &mut out);
|
||||||
s.time_signatures.enc(&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.tempo_map.enc(&mut out);
|
||||||
s.events.enc(&mut out);
|
s.events.enc(&mut out);
|
||||||
s.spelling_attachments.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
|
/// 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
|
/// of [`decode_v1_score`]'s field walk (`tuning_context` through the frozen
|
||||||
/// strict v1 canonicality, and by migration tests and the decode fuzzer to
|
/// [`enc_tuning_context_v2`], not the live [`Codec`] — see
|
||||||
/// synthesize genuine v1 bytes. A migrated score's schema-major-2 fields hold
|
/// [`encode_v0_score`]'s note). Used by `decode_v1_score` to enforce strict v1
|
||||||
/// their defaults, so this simply omits them.
|
/// 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> {
|
pub(crate) fn encode_v1_score(s: &Score) -> Vec<u8> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
enc_metadata_v1(&s.metadata, &mut out);
|
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);
|
s.parts.enc(&mut out);
|
||||||
enc_ccr_v1(&s.cross_cutting, &mut out);
|
enc_ccr_v1(&s.cross_cutting, &mut out);
|
||||||
s.time_signatures.enc(&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.tempo_map.enc(&mut out);
|
||||||
s.events.enc(&mut out);
|
s.events.enc(&mut out);
|
||||||
s.spelling_attachments.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 parts = Codec::dec(&mut r)?;
|
||||||
let cross_cutting = dec_ccr_v1(&mut r)?;
|
let cross_cutting = dec_ccr_v1(&mut r)?;
|
||||||
let time_signatures = Codec::dec(&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 tempo_map = Codec::dec(&mut r)?;
|
||||||
let events = Codec::dec(&mut r)?;
|
let events = Codec::dec(&mut r)?;
|
||||||
let spelling_attachments = 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)
|
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).
|
// Public per-value canonical codec (the K↔J seam).
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
@ -3416,49 +3664,18 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn score_tuning_context_overrides_do_not_reach_the_wire() {
|
fn score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not() {
|
||||||
use crate::graph::ScoreTuningContext;
|
// Schema major 3 (Push 4b tranche 3b-i, `spec/CONTRACT_PUSH4B_3BI_WIRE.md`)
|
||||||
use crate::ids::{ReplicaId, VoiceId};
|
// moves `smufl` and `overrides` onto the wire — the direct
|
||||||
use crate::pitch::TuningSystemId;
|
// **inversion** of the pre-major-3 proof this test replaces (formerly
|
||||||
use crate::tuning::{TuningOverride, TuningScope};
|
// two tests: `score_tuning_context_overrides_do_not_reach_the_wire`,
|
||||||
|
// Push 4b tranche 2; and
|
||||||
let without_overrides = ScoreTuningContext::default();
|
// `score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire`,
|
||||||
let mut with_overrides = ScoreTuningContext::default();
|
// tranche 3a). `accidental_extensions` stays in memory only (staged to
|
||||||
with_overrides.overrides.push(TuningOverride {
|
// a later major, appended after `overrides` when it lands), so this
|
||||||
scope: TuningScope::Voice(VoiceId::new(ReplicaId(1), 1)),
|
// is now a **staging-boundary** test: it proves the line falls
|
||||||
pitch_space: None,
|
// exactly between `overrides` and `accidental_extensions`, not that
|
||||||
tuning_system: Some(TuningSystemId::new("tet-19")),
|
// nothing reaches the wire.
|
||||||
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.
|
|
||||||
use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement};
|
use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement};
|
||||||
use crate::graph::ScoreTuningContext;
|
use crate::graph::ScoreTuningContext;
|
||||||
use crate::ids::{ReplicaId, VoiceId};
|
use crate::ids::{ReplicaId, VoiceId};
|
||||||
|
|
@ -3484,7 +3701,7 @@ mod tests {
|
||||||
reference: None,
|
reference: None,
|
||||||
});
|
});
|
||||||
// Sanity: the fixture actually differs in memory in all three
|
// 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_ne!(loaded, bare);
|
||||||
assert!(!loaded.accidental_extensions.is_empty());
|
assert!(!loaded.accidental_extensions.is_empty());
|
||||||
assert_ne!(loaded.smufl, SmuflVersionRequirement::default());
|
assert_ne!(loaded.smufl, SmuflVersionRequirement::default());
|
||||||
|
|
@ -3494,17 +3711,129 @@ mod tests {
|
||||||
loaded.enc(&mut bytes_loaded);
|
loaded.enc(&mut bytes_loaded);
|
||||||
let mut bytes_bare = Vec::new();
|
let mut bytes_bare = Vec::new();
|
||||||
bare.enc(&mut bytes_bare);
|
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,
|
bytes_loaded, bytes_bare,
|
||||||
"accidental_extensions, smufl, and overrides must not reach canonical bytes \
|
"smufl and overrides must reach canonical bytes as of schema major 3"
|
||||||
(Push 4b tranche 3a)"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let decoded = ScoreTuningContext::dec(&mut Reader::new(&bytes_loaded)).expect("decodes");
|
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!(decoded.accidental_extensions.is_empty());
|
||||||
assert_eq!(decoded.smufl, SmuflVersionRequirement::default());
|
assert_eq!(
|
||||||
assert!(decoded.overrides.is_empty());
|
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]
|
#[test]
|
||||||
|
|
@ -3571,10 +3900,10 @@ mod tests {
|
||||||
|
|
||||||
let bytes = score.canonical_bytes();
|
let bytes = score.canonical_bytes();
|
||||||
assert_eq!(Score::decode_canonical(&bytes).unwrap(), score);
|
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
|
// The non-default major-1 values are representable at the frozen v1
|
||||||
// wire form too: the v1 encoding migrates back to the same score
|
// 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);
|
let v1 = encode_v1_score(&score);
|
||||||
assert_eq!(Score::decode_canonical_versioned(&v1, 1).unwrap(), 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
|
// 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();
|
let migrated = Score::decode_canonical_versioned(&v0, 0).unwrap();
|
||||||
assert_eq!(migrated, score);
|
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);
|
assert_eq!(migrated.canonical_bytes(), current);
|
||||||
// Major 1: the frozen v1 bytes migrate to the same score.
|
// Major 1: the frozen v1 bytes migrate to the same score.
|
||||||
assert_eq!(Score::decode_canonical_versioned(&v1, 1).unwrap(), 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!(
|
assert_eq!(
|
||||||
Score::decode_canonical_versioned(¤t, 2).unwrap(),
|
Score::decode_canonical_versioned(¤t, 3).unwrap(),
|
||||||
score
|
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).
|
// 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]
|
#[test]
|
||||||
|
|
@ -3671,7 +4001,12 @@ mod tests {
|
||||||
// (abbreviation 1 + sound count 4 + transposition 1 + clef 3 +
|
// (abbreviation 1 + sound count 4 + transposition 1 + clef 3 +
|
||||||
// full config 15 + members count 4); an overridden staff-instance
|
// full config 15 + members count 4); an overridden staff-instance
|
||||||
// config 14; metadata 23 (three presence bytes + two i64
|
// 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
|
let expected_removed = c.slurs.len() * 4
|
||||||
+ c.ties.len() * 2
|
+ c.ties.len() * 2
|
||||||
+ c.beams.len() * 5
|
+ c.beams.len() * 5
|
||||||
|
|
@ -3680,11 +4015,13 @@ mod tests {
|
||||||
+ score.staves.len() * 17
|
+ score.staves.len() * 17
|
||||||
+ score.instruments.len() * 28
|
+ score.instruments.len() * 28
|
||||||
+ override_sites * 14
|
+ override_sites * 14
|
||||||
+ 23;
|
+ 23
|
||||||
|
+ 12;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
current.len() - v1.len(),
|
current.len() - v1.len(),
|
||||||
expected_removed,
|
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();
|
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]
|
#[test]
|
||||||
fn current_major_round_trips_non_default_values_for_every_major_2_field() {
|
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
|
// One score carrying a non-default value for EVERY schema-major-2
|
||||||
|
|
@ -3875,7 +4249,7 @@ mod tests {
|
||||||
|
|
||||||
let bytes = score.canonical_bytes();
|
let bytes = score.canonical_bytes();
|
||||||
assert_eq!(Score::decode_canonical(&bytes).unwrap(), score);
|
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.
|
// The per-value seam carries the filled bodies too.
|
||||||
let slur = &score.cross_cutting.slurs[0];
|
let slur = &score.cross_cutting.slurs[0];
|
||||||
assert_eq!(
|
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.
|
// The current-layout decoder: must not panic; an Ok must round-trip.
|
||||||
check_score(Score::decode_canonical(&bytes), &bytes);
|
check_score(Score::decode_canonical(&bytes), &bytes);
|
||||||
|
|
||||||
// The schema-version dispatch seam. Major 2 is the current layout;
|
// The schema-version dispatch seam. Major 3 is the current layout;
|
||||||
// majors 1 and 0 run the frozen migrations; an arbitrary major
|
// majors 2, 1, and 0 run the frozen migrations; an arbitrary major
|
||||||
// exercises the defensive out-of-accept-set path. Each migration
|
// exercises the defensive out-of-accept-set path. Each migration
|
||||||
// default-fills the appended fields, so it does not round-trip to the
|
// default-fills the appended fields, so it does not round-trip to the
|
||||||
// *current* form — but each is strictly canonical over its OWN wire
|
// *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
|
/// the default pitch space, tuning system, and reference pitch every score must
|
||||||
/// declare; per-scope overrides land here (Push 4b tranche 2); accidental
|
/// declare; per-scope overrides land here (Push 4b tranche 2); accidental
|
||||||
/// registry extensions and the SMuFL version requirement land here too (Push
|
/// 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
|
/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`; tranche
|
||||||
/// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`).** The canonical encoding stays
|
/// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`; tranche 3b-i,
|
||||||
/// **exactly** `default_pitch_space`, `default_tuning_system`, `reference`, in
|
/// `spec/CONTRACT_PUSH4B_3BI_WIRE.md`, schema major 3).** The canonical
|
||||||
/// that order — schema major 3 has not been opened, so `overrides`,
|
/// encoding is `default_pitch_space` ⌢ `default_tuning_system` ⌢ `reference`
|
||||||
/// `accidental_extensions`, and `smufl` are *not* on the wire this tranche.
|
/// (the frozen major-0..2 prefix) ⌢ `smufl` ⌢ `overrides` — append-after-existing,
|
||||||
/// See the hand-written `impl Codec` in `codec.rs` and `impl TextValue` in
|
/// per the frozen-layout rule. `accidental_extensions` is **not** on the wire:
|
||||||
/// `textvalue_graph.rs` (replacing the `struct_codec!` this type used to use,
|
/// it is staged to a later major, which will append it *after* `overrides`
|
||||||
/// which named every field it was given in its generated decoder and so
|
/// when it lands (its own consumer, the engraver, does not exist yet). See
|
||||||
/// cannot compile against an in-memory-only one). Where the three in-memory
|
/// the hand-written `impl Codec` in `codec.rs` and `impl TextValue` in
|
||||||
/// fields sit in *this* Rust struct is free — the manual codec fixes the wire
|
/// `textvalue_graph.rs` (the manual codec predates this bump and stays manual
|
||||||
/// order independently of field declaration order — but they are declared
|
/// now for the one remaining in-memory field, `accidental_extensions`, since
|
||||||
/// here in the specification's eventual major-3 field order
|
/// `struct_codec!`'s generated decoder cannot build a value from fewer fields
|
||||||
/// (`accidental_extensions`, `smufl`, `overrides`) for readability. Putting
|
/// than it declares). Where `accidental_extensions` sits in *this* Rust
|
||||||
/// all three on the wire in that order is tranche 3b's job, not this one's.
|
/// 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)]
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
pub struct ScoreTuningContext {
|
pub struct ScoreTuningContext {
|
||||||
pub default_pitch_space: PitchSpaceId,
|
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
|
// 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
|
// the current schema major and referenced from the manifest's
|
||||||
// `acceleration_snapshots` — NOT the canonical base, which is the
|
// `acceleration_snapshots` — NOT the canonical base, which is the
|
||||||
// MaterializedState's role and stays major 0. (The `SnapshotId` here is a
|
// 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");
|
Bundle::create(MemStore::new(), uuid, Manifest::empty(doc)).expect("create bundle");
|
||||||
let snapshot = StagedChunk {
|
let snapshot = StagedChunk {
|
||||||
kind: ChunkKind::Snapshot,
|
kind: ChunkKind::Snapshot,
|
||||||
schema_version: SchemaVersion::for_major(2),
|
schema_version: SchemaVersion::for_major(3),
|
||||||
payload: canonical.clone(),
|
payload: canonical.clone(),
|
||||||
};
|
};
|
||||||
let frontier = frontier.to_vec();
|
let frontier = frontier.to_vec();
|
||||||
|
|
@ -388,7 +388,7 @@ pub fn assert_score_serialization_stable(score: &Score, frontier: &[u8], seed: u
|
||||||
.acceleration_snapshots
|
.acceleration_snapshots
|
||||||
.first()
|
.first()
|
||||||
.expect("an acceleration snapshot");
|
.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
|
let loaded = reopened
|
||||||
.read_chunk(&accel.root)
|
.read_chunk(&accel.root)
|
||||||
.expect("read snapshot chunk back");
|
.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\scshape\color{epiphanyslate}Binary Format}\\[6pt]
|
||||||
{\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt]
|
{\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt]
|
||||||
{\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt]
|
{\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}
|
{\small\color{epiphanyslate}Normative for the byte layouts it defines}
|
||||||
\vfill
|
\vfill
|
||||||
\end{titlepage}
|
\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
|
wire encoding for each schema version''}). This chapter defines the wire
|
||||||
rules for schema evolution. Chapters~\ref{ch:values}--\ref{ch:barriers}
|
rules for schema evolution. Chapters~\ref{ch:values}--\ref{ch:barriers}
|
||||||
specify the schema-major-0 layouts; Section~\ref{sec:evolution:major1}
|
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
|
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}
|
\section{The Chunk-Level Gate}
|
||||||
\label{sec:evolution: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
|
$[\textsc{min}, \textsc{max}]$ of majors and rejects any chunk whose major
|
||||||
falls \emph{outside} $[\textsc{min}, \textsc{max}]$ --- too new (above
|
falls \emph{outside} $[\textsc{min}, \textsc{max}]$ --- too new (above
|
||||||
\textsc{max}) or, once \textsc{min} rises past a retired major, too old
|
\textsc{max}) or, once \textsc{min} rises past a retired major, too old
|
||||||
(Section~\ref{sec:evolution:major1}). Three majors are defined:
|
(Section~\ref{sec:evolution:major1}). Four majors are defined:
|
||||||
\tablenums{0}, \tablenums{1} (Section~\ref{sec:evolution:major1}), and
|
\tablenums{0}, \tablenums{1} (Section~\ref{sec:evolution:major1}),
|
||||||
\tablenums{2} (Section~\ref{sec:evolution:major2}); the reference
|
\tablenums{2} (Section~\ref{sec:evolution:major2}), and \tablenums{3}
|
||||||
implementation's accept-set is $\{0, 1, 2\}$ ($\textsc{min} = 0$,
|
(Section~\ref{sec:evolution:major3}). The accept-set is \textbf{per chunk
|
||||||
$\textsc{max} = 2$).
|
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
|
\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:
|
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
|
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{tabular}
|
||||||
\end{center}
|
\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}
|
\chapter{Non-Canonical Pinned Encodings}
|
||||||
\label{ch:noncanon}
|
\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
|
the verdict. Accepting a \texttt{reject} vector and normalizing it is
|
||||||
\emph{accepting} it. The wire-format fuzzer remains an implementation
|
\emph{accepting} it. The wire-format fuzzer remains an implementation
|
||||||
deliverable. No wire layout changed. \\
|
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
|
\bottomrule
|
||||||
\end{longtable}
|
\end{longtable}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue