From 794400c4c76234a02807dbc92fa87033e85057a5 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 16:30:07 -0400 Subject: [PATCH] Schema major 2 Phase B: snapshot side + honest stamps (data-model fills) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nine type bodies fill to the ratified Ch5 shapes and the wire moves to the Binary Format §Schema Major 2 layouts, review-hardened (high- effort review; 8 findings, 7 fixed + 1 deferral sharpened). epiphany-core: - 19 new leaf types in graph.rs (SlurKind/CurveDirection/ CurvatureOverride/SpanStyle/LineStyle/SpaceUnit/SubBeam/ BeamGeometryOverride/SpannerKind+5 payload types/RepeatKind/Volta/ StaffBracketKind/MetadataEntry/MetadataValue/Timestamp/ SoundConfiguration/TranspositionInterval/UnpitchedMember) with the ratified discriminants (cstyle_enum_codec! reused; tagged unions hand-written); nine struct fills appended in wire order. - The frozen wire forms generalized to a SHARED sub-codec layer (enc_/dec_*_v1, v0==v1 for every type major 2 changed; vec framing through enc_/dec_vec_v1) used by the new decode_v1_score/ encode_v1_score AND the rerouted v0 pair — major 2 touched types the v0 walk had treated as unchanged (metadata, staves, cross_cutting, staff instances transitively). Strict-canonical guards on every versioned path; decode_canonical_versioned dispatches {0,1,2} with composed default-fill migration. - Invariants extended to the new reference-bearing fields (REVIEW FIX): Beam.sub_beams events, RepeatKind DaCapo/DalSegno anchors, Volta spans now covered by CrossCuttingRefsResolve + the anchor model walk, with negative tests. - Tests: v1 migration size-anchor (v1 omits exactly the appended default bytes — the frozen encoder cannot drift), a non-default round-trip covering every new field and every SpannerKind/RepeatKind wire arm, fuzzer corpus gains genuine-v1 forms + the major-2 seam with enforced must-decode-Ok on unmutated frozen forms. epiphany-ops + epiphany-bundle (Phase C's semantic core, landed here deliberately — the live codec flip makes CrossCutting/Staff/Metadata payload bytes v2 immediately; shimming nine transitively-embedded types the major-1 D1 way was throwaway): - Minimal-stamping OperationKind::schema_major per the ratified rule (CrossCutting/CreateStaff/SetMetadata always 2; CreateRegion 2 iff a carried instance bears Some(staff_lines_override) else 1; CreateStaffInstance/SetStaffLayout 2 iff Some else 0), unit-locked. - Bundle op-block accept-set [0,2]; SchemaVersion::V2; beyond-accept-set tests moved to major 3; testkit V2 stamp-derivation test. - the_canonical_base_is_byte_identical_across_data_model_majors: pinned blake3 of a seeded reduction — the companion's SHOULD that the canonical base never moves across data-model majors. - The op-payload migrate-on-read deferral restated precisely in DECISIONS (no consumer byte-reconstructs op payloads today; the first one must bring per-type frozen payload decoders). Zero golden churn (fixtures deliberately carry v2 defaults). Instrument::new consolidates the sweep's default fills. Full gate: fmt, clippy -D warnings, rustdoc -D warnings, 30 workspace suites, conformance scale 1 (8/8). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd --- crates/epiphany-bundle/DECISIONS.md | 10 + crates/epiphany-bundle/src/bundle.rs | 25 +- crates/epiphany-bundle/src/ids.rs | 17 +- crates/epiphany-core/DECISIONS.md | 33 + crates/epiphany-core/src/codec.rs | 1095 +++++++++++++++++- crates/epiphany-core/src/fuzz.rs | 99 +- crates/epiphany-core/src/generators.rs | 24 +- crates/epiphany-core/src/graph.rs | 339 +++++- crates/epiphany-core/src/invariants.rs | 159 ++- crates/epiphany-core/src/lib.rs | 25 +- crates/epiphany-core/src/prepass/tests.rs | 14 +- crates/epiphany-core/tests/score_graph.rs | 13 +- crates/epiphany-layout-ir/src/logical.rs | 2 + crates/epiphany-ops/DECISIONS.md | 32 + crates/epiphany-ops/src/migrate.rs | 2 + crates/epiphany-ops/src/payload.rs | 49 +- crates/epiphany-ops/src/reduce.rs | 104 ++ crates/epiphany-ops/src/valuegen.rs | 13 + crates/epiphany-ops/tests/graph_reduction.rs | 16 +- crates/epiphany-testkit/src/corpus.rs | 26 +- crates/epiphany-testkit/src/fixtures.rs | 23 +- crates/epiphany-testkit/src/roundtrip.rs | 55 +- 22 files changed, 1975 insertions(+), 200 deletions(-) diff --git a/crates/epiphany-bundle/DECISIONS.md b/crates/epiphany-bundle/DECISIONS.md index 586aec3..934018d 100644 --- a/crates/epiphany-bundle/DECISIONS.md +++ b/crates/epiphany-bundle/DECISIONS.md @@ -401,3 +401,13 @@ do not include the field. This crate places `retention_policy` inside `ProfileConstraints`. The spec should show the field explicitly (and confirm whether a bundle declaring multiple profiles resolves retention from the first declared profile, as this crate does). + +## Schema major 2: op-block accept-set raised to [0, 2] + +`max_supported_major(OperationEnvelopeBlock)` → 2 (same commit as the core +fills + ops stamps, so stamps never lag bytes); every other role stays at +major 0 — including the payload-polymorphic Snapshot: nothing stages an +acceleration full-`Score` snapshot yet, so its role gate waits for a real +producer (the core-side seam `decode_canonical_versioned` already handles +{0,1,2}). `SchemaVersion::V2` added; beyond-accept-set tests moved to +major 3. diff --git a/crates/epiphany-bundle/src/bundle.rs b/crates/epiphany-bundle/src/bundle.rs index 8a3f6a0..6697faf 100644 --- a/crates/epiphany-bundle/src/bundle.rs +++ b/crates/epiphany-bundle/src/bundle.rs @@ -50,9 +50,11 @@ pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0; /// bound of its per-role accept-set `[0, max]` (Binary Format companion /// §"Schema Major 1", "The accept-set gate"). /// -/// `OperationEnvelopeBlock` admits major 1 (its v1 form embeds a v1 -/// `CreateRegion`; the reader treats the block bytes opaquely, so it parses a -/// v1 block without decoding the payload). Every other role stays at +/// `OperationEnvelopeBlock` admits major 2 (schema major 2 fills the +/// cross-cutting/staff/metadata bodies its payloads embed; major 1 embedded a +/// v1 `CreateRegion`; the reader treats the block bytes opaquely, so it +/// parses a higher-major block without decoding the payload). Every other +/// role stays at /// [`SUPPORTED_SCHEMA_MAJOR`] until its own versioned path lands — including the /// payload-polymorphic `Snapshot` (the acceleration form's migrate-on-read is /// core-side; the canonical base stays major 0) and the manifest (carried @@ -61,7 +63,7 @@ pub const SUPPORTED_SCHEMA_MAJOR: u16 = 0; /// (a major-0-only reader meeting a v1 op block), not a hard reject. pub fn max_supported_major(kind: ChunkKind) -> u16 { match kind { - ChunkKind::OperationEnvelopeBlock => 1, + ChunkKind::OperationEnvelopeBlock => 2, _ => SUPPORTED_SCHEMA_MAJOR, } } @@ -1264,11 +1266,14 @@ mod tests { // Admission of major 1 is raised **per chunk role**, never as a blanket // accept-set (Binary Format companion §"Schema Major 1"). D2 raised the // operation-envelope-block role to major 1 (a block bearing a v1 - // CreateRegion); every other role stays exact-0 until its own versioned - // path lands, and the manifest stays major 0 forever. + // CreateRegion), and schema major 2 to major 2 (a block bearing a v2 + // cross-cutting/staff/metadata value); every other role stays exact-0 + // until its own versioned path lands, and the manifest stays major 0 + // forever. assert_eq!(SchemaVersion::V1.major, 1); - // The op-block role admits [0, 1]. - assert_eq!(max_supported_major(ChunkKind::OperationEnvelopeBlock), 1); + assert_eq!(SchemaVersion::V2.major, 2); + // The op-block role admits [0, 2]. + assert_eq!(max_supported_major(ChunkKind::OperationEnvelopeBlock), 2); // Every other role stays at the generic baseline (major 0): the // payload-polymorphic Snapshot (its migrate-on-read is core-side), the // layout cache, and the operation index. @@ -1290,7 +1295,7 @@ mod tests { let mut bundle = fresh_bundle(); let block = StagedChunk::operation_block_versioned( crate::block::encode_block(&[vec![1u8, 2, 3]]), - SchemaVersion::new(2, 0), + SchemaVersion::new(3, 0), ); bundle .commit(&[block], |ctx| { @@ -1306,7 +1311,7 @@ mod tests { ); assert!(bundle.anomalies().iter().any(|a| matches!( a, - IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 2 } + IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 3 } ))); // A further commit against the now-read-only bundle is refused. let more = StagedChunk::operation_block_versioned( diff --git a/crates/epiphany-bundle/src/ids.rs b/crates/epiphany-bundle/src/ids.rs index 86c2005..14f6896 100644 --- a/crates/epiphany-bundle/src/ids.rs +++ b/crates/epiphany-bundle/src/ids.rs @@ -180,6 +180,14 @@ impl SchemaVersion { /// and operation blocks without a changed payload stay at [`Self::V0`]. pub const V1: SchemaVersion = SchemaVersion { major: 1, minor: 0 }; + /// Schema major 2 — the second data-model expansion major (Binary Format + /// companion §"Schema Major 2"): the cross-cutting bodies, repeats/voltas, + /// staff/instrument/metadata fills. Stamped (minimally — the lowest major + /// whose layouts decode the bytes) on chunks whose payload carries a v2 + /// layout: the acceleration full-`Score` snapshot and any + /// operation-envelope block bearing a v2 value. + pub const V2: SchemaVersion = SchemaVersion { major: 2, minor: 0 }; + /// Constructs a schema version. #[inline] pub const fn new(major: u16, minor: u16) -> Self { @@ -187,15 +195,16 @@ impl SchemaVersion { } /// The current schema version at a given major: [`Self::V0`] for major 0, - /// [`Self::V1`] for major 1, and `{major, 0}` for any higher (future) major. - /// A writer maps a chunk's derived schema major to a version this way — e.g. - /// an operation-envelope block stamps the max over its operations' - /// `schema_major()`. + /// [`Self::V1`] for major 1, [`Self::V2`] for major 2, and `{major, 0}` + /// for any higher (future) major. A writer maps a chunk's derived schema + /// major to a version this way — e.g. an operation-envelope block stamps + /// the max over its operations' `schema_major()`. #[inline] pub const fn for_major(major: u16) -> Self { match major { 0 => SchemaVersion::V0, 1 => SchemaVersion::V1, + 2 => SchemaVersion::V2, m => SchemaVersion { major: m, minor: 0 }, } } diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 7c8c344..e32f174 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -457,3 +457,36 @@ rejected up front); every production caller uses the default profile and `.expect`s; the stale test is rewritten as `unknown_algorithm_ids_error`. CONFORMANCE.md's long-standing "errors" claim is now true rather than aspirational. + +## Schema major 2, Phase B: the snapshot side (data-model fills + frozen v1) + +The nine type bodies fill to the ratified Ch5 shapes (Binary Format §Schema +Major 2): Slur/Tie/Beam/Spanner (kind/curvature/sub-beams/geometry/style — +one shared `SpanStyle`), RepeatStructure (kind/voltas), Staff (default_clef), +StaffLineConfiguration (spacing/style/bracket), Instrument (six fields), +ScoreMetadata (six fields incl. the strictly-authored timestamps). All new +leaf types live in `graph.rs` with the ratified wire discriminants in +`codec.rs` (`tag_only_codec!` for the tag-only enums). + +**The frozen-form architecture generalized:** major 2's fills reach types the +v0 walk had treated as "unchanged" (`metadata`, `staves`, `cross_cutting`, +and — transitively through `Region.content` — the staff instances), so the +frozen wire forms are now a *shared sub-codec layer*: `enc_/dec_*_v1` +functions (v0 == v1 for every type major 2 changed) used by BOTH +`decode_v1_score`/`encode_v1_score` (new) and the v0 pair (updated to route +through them). Each versioned decoder stays strictly canonical over its own +wire form (re-encode-and-compare, the fuzzer-P1 discipline) and the fuzzer +corpus gained genuine-v1 forms + the major-2 seam. The +`v1_score_migrates_default_filling_the_major_2_fields` size anchor pins that +v1 omits exactly the appended default bytes (so the frozen encoder cannot +silently drift), and +`current_major_round_trips_non_default_values_for_every_major_2_field` +exercises every new field (and every payload-carrying SpannerKind variant) +as real wire content. + +**Deliberate scope choices:** generator fixtures were NOT given non-default +v2 content — that would churn the render goldens and reference-suite metrics +for zero coverage the codec tests don't already provide; ops-level coverage +arrives when Phase D's valuegen builders emit v2 values. `Score::empty` keeps +its signature (Timestamp(0) is the ratified unset convention; a +creation-time builder waits for a producer, e.g. import). diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index d2b3327..1e44476 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -47,17 +47,21 @@ use crate::event::{ }; use crate::graph::{ AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer, AnalyticalAnnotation, - AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam, BeatGroup, Canvas, - CanvasLayoutDefaults, CanvasMargins, CanvasSize, ChordSymbol, Clef, ClefChange, ClefShape, - Comment, CrossCuttingRegistry, DecompositionAttachment, DecompositionSource, EventOrderingDAG, - GestureAnchoring, GraphicContent, GraphicGesture, GraphicObject, Instrument, KeySignature, - KeySignatureChange, LyricLine, Marker, Measure, MeasureNumberVisibility, MeterChange, - MetricGrid, MetricTimeModel, NotatedComponent, NoteValue, PartDefinition, PowerOfTwo, - ProportionalTimeModel, Region, RegionContent, RegionTimeModel, RepeatStructure, Score, - ScoreMetadata, ScoreTuningContext, Slur, Spanner, Staff, StaffBasedContent, StaffExtent, - StaffGroup, StaffGroupKind, StaffInstance, StaffLineConfiguration, StemDirection, - TempoMapReference, Tie, TieClass, TimeExtent, TimeSignature, TimeSignatureDisplay, Tuplet, - TupletRatio, ViewDefinition, Voice, VoiceOrigin, + AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam, BeamGeometryOverride, + BeatGroup, BracketKind, Canvas, CanvasLayoutDefaults, CanvasMargins, CanvasSize, ChordSymbol, + Clef, ClefChange, ClefShape, Comment, CrossCuttingRegistry, CurvatureOverride, CurveDirection, + DecompositionAttachment, DecompositionSource, EventOrderingDAG, GestureAnchoring, + GraphicContent, GraphicGesture, GraphicObject, HairpinDirection, Instrument, KeySignature, + KeySignatureChange, LineStyle, LyricLine, Marker, Measure, MeasureNumberVisibility, + MetadataEntry, MetadataValue, MeterChange, MetricGrid, MetricTimeModel, NotatedComponent, + NoteValue, OctaveOffset, PartDefinition, PedalKind, PowerOfTwo, ProportionalTimeModel, Region, + RegionContent, RegionTimeModel, RepeatKind, RepeatStructure, Score, ScoreMetadata, + ScoreTuningContext, Slur, SlurKind, SoundConfiguration, SpaceUnit, SpanStyle, Spanner, + SpannerKind, Staff, StaffBasedContent, StaffBracketKind, StaffExtent, StaffGroup, + StaffGroupKind, StaffInstance, StaffLineConfiguration, StemDirection, SubBeam, + TempoMapReference, TextLineDefinition, Tie, TieClass, TimeExtent, TimeSignature, + TimeSignatureDisplay, Timestamp, TranspositionInterval, Tuplet, TupletRatio, UnpitchedMember, + ViewDefinition, Voice, VoiceOrigin, Volta, }; use crate::ids::{ AnalysisLayerId, AnalyticalAnnotationId, BarlineAlignmentGroupId, BeamId, ChordSymbolId, @@ -314,6 +318,15 @@ impl Codec for i32 { } } +impl Codec for i64 { + fn enc(&self, out: &mut Vec) { + out.extend_from_slice(&self.to_le_bytes()); + } + fn dec(r: &mut Reader<'_>) -> Result { + Ok(r.u64()? as i64) + } +} + impl Codec for u128 { fn enc(&self, out: &mut Vec) { out.extend_from_slice(&self.to_le_bytes()); @@ -1437,26 +1450,258 @@ cstyle_enum_codec!(NoteValue { 4 => Sixteenth, 5 => ThirtySecond, 6 => SixtyFourth, }); +// --- Schema-major-2 leaf types (Binary Format §Schema Major 2). ------------- +// Every layout below is the ratified v2 wire form: tag-only enums are one +// discriminant byte; `SpaceUnit` rides its `CanonicalF64` leaf (framed 12); +// `Timestamp` is one bare i64 LE; fixed-width primitives inside structs are +// bare; identifiers keep the leaf framing. + +impl Codec for Timestamp { + fn enc(&self, out: &mut Vec) { + self.0.enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + Ok(Timestamp(i64::dec(r)?)) + } +} + +impl Codec for SpaceUnit { + fn enc(&self, out: &mut Vec) { + self.0.enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + Ok(SpaceUnit(Codec::dec(r)?)) + } +} + +impl Codec for SoundConfiguration { + fn enc(&self, out: &mut Vec) { + self.0.enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + Ok(SoundConfiguration(Codec::dec(r)?)) + } +} + +impl Codec for OctaveOffset { + fn enc(&self, out: &mut Vec) { + self.0.enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + Ok(OctaveOffset(i8::dec(r)?)) + } +} + +cstyle_enum_codec!(LineStyle { 0 => Solid, 1 => Dashed, 2 => Dotted }); +cstyle_enum_codec!(SlurKind { + 0 => Legato, + 1 => Phrase, + 2 => Articulation, + 3 => Editorial, +}); +cstyle_enum_codec!(CurveDirection { 0 => Above, 1 => Below }); +cstyle_enum_codec!(HairpinDirection { + 0 => Crescendo, + 1 => Diminuendo, +}); +cstyle_enum_codec!(PedalKind { + 0 => Sustain, + 1 => Sostenuto, + 2 => UnaCorda, +}); +cstyle_enum_codec!(BracketKind { 0 => Square }); +cstyle_enum_codec!(StaffBracketKind { 0 => Brace, 1 => Bracket }); + +impl Codec for SpannerKind { + fn enc(&self, out: &mut Vec) { + match self { + SpannerKind::Generic => out.push(0), + SpannerKind::Hairpin(d) => { + out.push(1); + d.enc(out); + } + SpannerKind::OctaveLine(o) => { + out.push(2); + o.enc(out); + } + SpannerKind::PedalLine(p) => { + out.push(3); + p.enc(out); + } + SpannerKind::TrillExtension => out.push(4), + SpannerKind::Glissando => out.push(5), + SpannerKind::Portamento => out.push(6), + SpannerKind::TextLine(t) => { + out.push(7); + t.enc(out); + } + SpannerKind::Bracket(b) => { + out.push(8); + b.enc(out); + } + } + } + fn dec(r: &mut Reader<'_>) -> Result { + match r.u8()? { + 0 => Ok(SpannerKind::Generic), + 1 => Ok(SpannerKind::Hairpin(Codec::dec(r)?)), + 2 => Ok(SpannerKind::OctaveLine(Codec::dec(r)?)), + 3 => Ok(SpannerKind::PedalLine(Codec::dec(r)?)), + 4 => Ok(SpannerKind::TrillExtension), + 5 => Ok(SpannerKind::Glissando), + 6 => Ok(SpannerKind::Portamento), + 7 => Ok(SpannerKind::TextLine(Codec::dec(r)?)), + 8 => Ok(SpannerKind::Bracket(Codec::dec(r)?)), + tag => Err(ScoreDecodeError::InvalidTag { + kind: "SpannerKind", + tag, + }), + } + } +} + +impl Codec for RepeatKind { + fn enc(&self, out: &mut Vec) { + match self { + RepeatKind::SimpleRepeat { count } => { + out.push(0); + count.enc(out); + } + RepeatKind::DaCapo { end_target } => { + out.push(1); + end_target.enc(out); + } + RepeatKind::DalSegno { segno, end_target } => { + out.push(2); + segno.enc(out); + end_target.enc(out); + } + RepeatKind::Volta => out.push(3), + } + } + fn dec(r: &mut Reader<'_>) -> Result { + match r.u8()? { + 0 => Ok(RepeatKind::SimpleRepeat { + count: Codec::dec(r)?, + }), + 1 => Ok(RepeatKind::DaCapo { + end_target: Codec::dec(r)?, + }), + 2 => Ok(RepeatKind::DalSegno { + segno: Codec::dec(r)?, + end_target: Codec::dec(r)?, + }), + 3 => Ok(RepeatKind::Volta), + tag => Err(ScoreDecodeError::InvalidTag { + kind: "RepeatKind", + tag, + }), + } + } +} + +impl Codec for MetadataValue { + fn enc(&self, out: &mut Vec) { + match self { + MetadataValue::Text(s) => { + out.push(0); + s.enc(out); + } + MetadataValue::Integer(i) => { + out.push(1); + i.enc(out); + } + MetadataValue::Flag(b) => { + out.push(2); + b.enc(out); + } + } + } + fn dec(r: &mut Reader<'_>) -> Result { + match r.u8()? { + 0 => Ok(MetadataValue::Text(Codec::dec(r)?)), + 1 => Ok(MetadataValue::Integer(Codec::dec(r)?)), + 2 => Ok(MetadataValue::Flag(Codec::dec(r)?)), + tag => Err(ScoreDecodeError::InvalidTag { + kind: "MetadataValue", + tag, + }), + } + } +} + +struct_codec!(CurvatureOverride { direction, height }); +struct_codec!(SpanStyle { line, thickness }); +struct_codec!(SubBeam { level, events }); +struct_codec!(BeamGeometryOverride { slope, offset }); +struct_codec!(TextLineDefinition { text }); +struct_codec!(Volta { + endings, + start, + end +}); +struct_codec!(MetadataEntry { key, value }); +struct_codec!(TranspositionInterval { + diatonic_steps, + chromatic_steps +}); +struct_codec!(UnpitchedMember { + member, + name, + staff_position +}); + +// Schema major 2: `ScoreMetadata` appended six fields after the major-0/1 +// order. The frozen prior layout (`title`, `composer`, `copyright`) is read +// by `dec_metadata_v1`. struct_codec!(ScoreMetadata { title, composer, - copyright + copyright, + subtitle, + lyricist, + arranger, + creation_timestamp, + modification_timestamp, + additional }); -// Schema major 1: `Instrument` gained `range` (appended after `name`). The +// Schema major 1: `Instrument` gained `range` (appended after `name`); the // frozen major-0 layout (`id`, `name`) is read by `dec_instruments_v0`. -struct_codec!(Instrument { id, name, range }); -struct_codec!(StaffLineConfiguration { line_count }); +// Schema major 2 appended six more fields after `range`; the frozen major-1 +// layout (`id`, `name`, `range`) is read by `dec_instruments_v1`. +struct_codec!(Instrument { + id, + name, + range, + abbreviation, + sound_config, + transposition, + default_clef, + default_staff_lines, + unpitched_members +}); +// Schema major 2: three fields appended after `line_count`; the frozen prior +// layout (`line_count` only) is read by `dec_staff_lines_v1`. +struct_codec!(StaffLineConfiguration { + line_count, + line_spacing, + line_style, + bracket +}); struct_codec!(GraphicObject { id }); struct_codec!(GraphicContent { objects }); struct_codec!(TimeExtent { start, end }); struct_codec!(StaffExtent { staves }); +// Schema major 2: `default_clef` appended last; the frozen prior layout is +// read by `dec_staff_v1`. struct_codec!(Staff { id, name, abbreviation, instrument, default_staff_lines, - group + group, + default_clef }); struct_codec!(PartDefinition { id, name, staves }); struct_codec!(AnalysisLayer { id, name }); @@ -1510,12 +1755,23 @@ struct_codec!(ScoreTuningContext { default_tuning_system, reference }); +// Schema major 2: the cross-cutting bodies filled (appended fields); the +// frozen prior layouts are read by the `dec_*_v1` sub-decoders. struct_codec!(Slur { id, start_event, - end_event + end_event, + kind, + curvature_override, + style +}); +struct_codec!(Beam { + id, + events, + level, + sub_beams, + geometry_override }); -struct_codec!(Beam { id, events, level }); // TupletRatio is not a plain struct_codec!: it has private fields and a checked // constructor that rejects degenerate ratios, so decode must validate too // (a malformed bundle cannot inject a degenerate ratio). @@ -1543,10 +1799,18 @@ struct_codec!(Spanner { id, start, end, - staves + staves, + kind, + style }); struct_codec!(Marker { id, anchor }); -struct_codec!(RepeatStructure { id, start, end }); +struct_codec!(RepeatStructure { + id, + start, + end, + kind, + voltas +}); struct_codec!(Comment { id, anchor, @@ -1839,7 +2103,8 @@ struct_codec!(Tie { start_event, end_event, pitch_pairing, - class + class, + style }); impl Codec for AnnotationAnchor { @@ -2097,24 +2362,21 @@ impl Score { } /// The **schema-version dispatch seam** (Binary Format companion - /// §"Schema Major 1"): decodes a full-`Score` snapshot whose bytes were - /// written under the given schema `major`, migrating a major-0 encoding up - /// to the current in-memory form on read. Major 1 is the current layout - /// ([`Score::decode_canonical`]); major 0 is decoded through the frozen v0 - /// wire form (`decode_v0_score`) and then migrated (`migrate_v0_score`). - /// - /// At schema major 1's introduction the v0 and v1 layouts are **identical**, - /// so the v0 path is the identity today — the machinery is a behavioral - /// no-op. When a later phase grows a field on `Canvas`, `Instrument`, or - /// `Region`, the frozen pre-field decoder is added here (reading the old - /// layout) and `migrate_v0_score` default-fills the new field. + /// §"Schema Major 1" / §"Schema Major 2"): decodes a full-`Score` snapshot + /// whose bytes were written under the given schema `major`, migrating a + /// lower-major encoding up to the current in-memory form on read. Major 2 + /// is the current layout ([`Score::decode_canonical`]); majors 1 and 0 are + /// decoded through their frozen wire forms (`decode_v1_score`, + /// `decode_v0_score`), each a total default-filling migration — the + /// composed v0→v1→v2 translation happens in the one v0 read. /// /// The caller (the bundle read path) only reaches this after the chunk gate - /// has admitted the major into its accept-set, so a major outside `{0, 1}` - /// is a defensive error, not an expected path. + /// has admitted the major into its accept-set, so a major outside + /// `{0, 1, 2}` is a defensive error, not an expected path. pub fn decode_canonical_versioned(bytes: &[u8], major: u16) -> Result { match major { - 1 => Score::decode_canonical(bytes), + 2 => Score::decode_canonical(bytes), + 1 => decode_v1_score(bytes), 0 => decode_v0_score(bytes), _ => Err(ScoreDecodeError::InvalidValue("unsupported schema major")), } @@ -2126,8 +2388,9 @@ impl Score { /// /// This is the **frozen v0 wire form, decoded by value** — a hand-written walk /// of the 19 `Score` fields in declaration order, using the current [`Codec`] -/// for every field whose layout is unchanged and a frozen v0 sub-decoder for the -/// three that grew a field in schema major 1: +/// for every field whose layout is unchanged, the frozen **v1** sub-decoders +/// (v0 == v1) for the types schema major 2 filled, and a frozen v0 +/// sub-decoder for the three that grew a field in schema major 1: /// /// * `Canvas` (field 2) — v0 was `regions` only; v1 appended `layout_defaults`. /// [`dec_canvas_v0`] reads the region vector and default-fills the defaults. @@ -2149,15 +2412,18 @@ impl Score { /// original score with the new fields at their defaults). fn decode_v0_score(bytes: &[u8]) -> Result { let mut r = Reader::new(bytes); - // The 19 Score fields in declaration order (codec.rs `struct_codec!(Score)`); - // only `canvas` (2) and `instruments` (3) differ from the current layout. - let metadata = Codec::dec(&mut r)?; + // The 19 Score fields in declaration order (codec.rs `struct_codec!(Score)`). + // `canvas` (2) and `instruments` (3) carry v0-specific sub-forms; the + // schema-major-2 fills route `metadata`, `staves`, `cross_cutting`, and + // (transitively, inside regions) the staff instances through the frozen + // v1 sub-decoders — v0 == v1 for every type major 2 changed. + let metadata = dec_metadata_v1(&mut r)?; let canvas = dec_canvas_v0(&mut r)?; let instruments = dec_instruments_v0(&mut r)?; - let staves = Codec::dec(&mut r)?; + let staves = dec_staves_v1(&mut r)?; let staff_groups = Codec::dec(&mut r)?; let parts = Codec::dec(&mut r)?; - let cross_cutting = Codec::dec(&mut r)?; + let cross_cutting = dec_ccr_v1(&mut r)?; let time_signatures = Codec::dec(&mut r)?; let tuning_context = Codec::dec(&mut r)?; let tempo_map = Codec::dec(&mut r)?; @@ -2219,14 +2485,16 @@ pub(crate) fn encode_v0_score(s: &Score) -> Vec { fn enc_region_v0(reg: &Region, out: &mut Vec) { reg.id.enc(out); reg.time_model.enc(out); - reg.content.enc(out); + // Content through the frozen v1 (== v0) sub-form: its staff + // instances embed the pre-major-2 StaffLineConfiguration. + enc_content_v1(®.content, out); reg.time_extent.enc(out); reg.staff_extent.enc(out); reg.local_tempo_map.enc(out); // v0: no permits_spanning_slurs. } let mut out = Vec::new(); - s.metadata.enc(&mut out); + enc_metadata_v1(&s.metadata, &mut out); // Canvas v0: `regions` only (no `layout_defaults`). put_len(&mut out, s.canvas.regions.len()); for reg in &s.canvas.regions { @@ -2238,11 +2506,12 @@ pub(crate) fn encode_v0_score(s: &Score) -> Vec { inst.id.enc(&mut out); inst.name.enc(&mut out); } - // Fields 4..19 are unchanged between v0 and v1. - s.staves.enc(&mut out); + // Fields 4..19: the schema-major-2-changed ones go through the frozen + // v1 (== v0) sub-encoders; the rest are unchanged since v0. + enc_staves_v1(&s.staves, &mut out); s.staff_groups.enc(&mut out); s.parts.enc(&mut out); - s.cross_cutting.enc(&mut out); + enc_ccr_v1(&s.cross_cutting, &mut out); s.time_signatures.enc(&mut out); s.tuning_context.enc(&mut out); s.tempo_map.enc(&mut out); @@ -2279,7 +2548,7 @@ fn dec_canvas_v0(r: &mut Reader<'_>) -> Result { fn dec_region_v0(r: &mut Reader<'_>) -> Result { let id = Codec::dec(r)?; let time_model = Codec::dec(r)?; - let content = Codec::dec(r)?; + let content = dec_content_v1(r)?; let time_extent = Codec::dec(r)?; let staff_extent = Codec::dec(r)?; let local_tempo_map = Codec::dec(r)?; @@ -2307,11 +2576,468 @@ fn dec_instruments_v0(r: &mut Reader<'_>) -> Result> { id, name, range: None, + abbreviation: None, + sound_config: SoundConfiguration::default(), + transposition: None, + default_clef: Clef::treble(), + default_staff_lines: StaffLineConfiguration::default(), + unpitched_members: Vec::new(), }); } Ok(instruments) } +// =========================================================================== +// Frozen schema-major-1 wire form (Binary Format §Schema Major 2). +// =========================================================================== +// +// Schema major 2 filled nine type bodies (Slur/Tie/Beam/Spanner, +// RepeatStructure, Staff, StaffLineConfiguration, Instrument, ScoreMetadata). +// The v1 (= v0, for all of these — major 1 touched none of them) layouts are +// frozen here as `enc_*_v1`/`dec_*_v1` sub-codecs, shared by +// [`decode_v1_score`]/[`encode_v1_score`] and the v0 pair (whose walk routes +// the transitively-changed fields through these). Each `dec_*_v1` +// default-fills the appended v2 fields per the companion's total migration +// table. The chain is transitive where an Option/Vec hides the embedding: +// Region → RegionContent → StaffBasedContent → StaffInstance → +// `staff_lines_override`. + +fn enc_vec_v1(items: &[T], out: &mut Vec, enc: impl Fn(&T, &mut Vec)) { + put_len(out, items.len()); + for item in items { + enc(item, out); + } +} + +fn dec_vec_v1(r: &mut Reader<'_>, dec: impl Fn(&mut Reader<'_>) -> Result) -> Result> { + let n = r.count()?; + let mut items = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + items.push(dec(r)?); + } + Ok(items) +} + +fn enc_metadata_v1(m: &ScoreMetadata, out: &mut Vec) { + m.title.enc(out); + m.composer.enc(out); + m.copyright.enc(out); +} + +fn dec_metadata_v1(r: &mut Reader<'_>) -> Result { + Ok(ScoreMetadata { + title: Codec::dec(r)?, + composer: Codec::dec(r)?, + copyright: Codec::dec(r)?, + ..Default::default() + }) +} + +fn enc_staff_lines_v1(c: &StaffLineConfiguration, out: &mut Vec) { + c.line_count.enc(out); +} + +fn dec_staff_lines_v1(r: &mut Reader<'_>) -> Result { + Ok(StaffLineConfiguration { + line_count: Codec::dec(r)?, + ..Default::default() + }) +} + +fn enc_staff_v1(s: &Staff, out: &mut Vec) { + s.id.enc(out); + s.name.enc(out); + s.abbreviation.enc(out); + s.instrument.enc(out); + enc_staff_lines_v1(&s.default_staff_lines, out); + s.group.enc(out); +} + +fn dec_staff_v1(r: &mut Reader<'_>) -> Result { + Ok(Staff { + id: Codec::dec(r)?, + name: Codec::dec(r)?, + abbreviation: Codec::dec(r)?, + instrument: Codec::dec(r)?, + default_staff_lines: dec_staff_lines_v1(r)?, + group: Codec::dec(r)?, + default_clef: Clef::treble(), + }) +} + +fn enc_staves_v1(staves: &[Staff], out: &mut Vec) { + enc_vec_v1(staves, out, enc_staff_v1); +} + +fn dec_staves_v1(r: &mut Reader<'_>) -> Result> { + dec_vec_v1(r, dec_staff_v1) +} + +fn enc_slur_v1(s: &Slur, out: &mut Vec) { + s.id.enc(out); + s.start_event.enc(out); + s.end_event.enc(out); +} + +fn dec_slur_v1(r: &mut Reader<'_>) -> Result { + Ok(Slur { + id: Codec::dec(r)?, + start_event: Codec::dec(r)?, + end_event: Codec::dec(r)?, + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::default(), + }) +} + +fn enc_tie_v1(t: &Tie, out: &mut Vec) { + t.id.enc(out); + t.start_event.enc(out); + t.end_event.enc(out); + t.pitch_pairing.enc(out); + t.class.enc(out); +} + +fn dec_tie_v1(r: &mut Reader<'_>) -> Result { + Ok(Tie { + id: Codec::dec(r)?, + start_event: Codec::dec(r)?, + end_event: Codec::dec(r)?, + pitch_pairing: Codec::dec(r)?, + class: Codec::dec(r)?, + style: SpanStyle::default(), + }) +} + +fn enc_beam_v1(b: &Beam, out: &mut Vec) { + b.id.enc(out); + b.events.enc(out); + b.level.enc(out); +} + +fn dec_beam_v1(r: &mut Reader<'_>) -> Result { + Ok(Beam { + id: Codec::dec(r)?, + events: Codec::dec(r)?, + level: Codec::dec(r)?, + sub_beams: Vec::new(), + geometry_override: None, + }) +} + +fn enc_spanner_v1(s: &Spanner, out: &mut Vec) { + s.id.enc(out); + s.start.enc(out); + s.end.enc(out); + s.staves.enc(out); +} + +fn dec_spanner_v1(r: &mut Reader<'_>) -> Result { + Ok(Spanner { + id: Codec::dec(r)?, + start: Codec::dec(r)?, + end: Codec::dec(r)?, + staves: Codec::dec(r)?, + kind: SpannerKind::Generic, + style: SpanStyle::default(), + }) +} + +fn enc_repeat_v1(rep: &RepeatStructure, out: &mut Vec) { + rep.id.enc(out); + rep.start.enc(out); + rep.end.enc(out); +} + +fn dec_repeat_v1(r: &mut Reader<'_>) -> Result { + Ok(RepeatStructure { + id: Codec::dec(r)?, + start: Codec::dec(r)?, + end: Codec::dec(r)?, + kind: RepeatKind::migration_default(), + voltas: Vec::new(), + }) +} + +fn enc_ccr_v1(c: &CrossCuttingRegistry, out: &mut Vec) { + enc_vec_v1(&c.slurs, out, enc_slur_v1); + enc_vec_v1(&c.ties, out, enc_tie_v1); + enc_vec_v1(&c.beams, out, enc_beam_v1); + c.tuplets.enc(out); + enc_vec_v1(&c.spanners, out, enc_spanner_v1); + c.markers.enc(out); + enc_vec_v1(&c.repeats, out, enc_repeat_v1); + c.analytical.enc(out); + c.comments.enc(out); + c.graphic_gestures.enc(out); + c.lyrics.enc(out); + c.chord_symbols.enc(out); +} + +fn dec_ccr_v1(r: &mut Reader<'_>) -> Result { + Ok(CrossCuttingRegistry { + slurs: dec_vec_v1(r, dec_slur_v1)?, + ties: dec_vec_v1(r, dec_tie_v1)?, + beams: dec_vec_v1(r, dec_beam_v1)?, + tuplets: Codec::dec(r)?, + spanners: dec_vec_v1(r, dec_spanner_v1)?, + markers: Codec::dec(r)?, + repeats: dec_vec_v1(r, dec_repeat_v1)?, + analytical: Codec::dec(r)?, + comments: Codec::dec(r)?, + graphic_gestures: Codec::dec(r)?, + lyrics: Codec::dec(r)?, + chord_symbols: Codec::dec(r)?, + }) +} + +fn enc_staff_instance_v1(si: &StaffInstance, out: &mut Vec) { + si.id.enc(out); + si.staff.enc(out); + si.voices.enc(out); + si.clef_sequence.enc(out); + si.key_sequence.enc(out); + si.local_metric_grid.enc(out); + si.measures.enc(out); + si.instrument_override.enc(out); + match &si.staff_lines_override { + None => out.push(0), + Some(c) => { + out.push(1); + enc_staff_lines_v1(c, out); + } + } + si.visible.enc(out); +} + +fn dec_staff_instance_v1(r: &mut Reader<'_>) -> Result { + Ok(StaffInstance { + id: Codec::dec(r)?, + staff: Codec::dec(r)?, + voices: Codec::dec(r)?, + clef_sequence: Codec::dec(r)?, + key_sequence: Codec::dec(r)?, + local_metric_grid: Codec::dec(r)?, + measures: Codec::dec(r)?, + instrument_override: Codec::dec(r)?, + staff_lines_override: match r.u8()? { + 0 => None, + 1 => Some(dec_staff_lines_v1(r)?), + tag => { + return Err(ScoreDecodeError::InvalidTag { + kind: "Option", + tag, + }) + } + }, + visible: Codec::dec(r)?, + }) +} + +fn enc_sbc_v1(sbc: &StaffBasedContent, out: &mut Vec) { + enc_vec_v1(&sbc.staff_instances, out, enc_staff_instance_v1); + sbc.default_metric_grid.enc(out); + sbc.barline_alignment_groups.enc(out); + sbc.user_system_breaks.enc(out); + sbc.user_page_breaks.enc(out); +} + +fn dec_sbc_v1(r: &mut Reader<'_>) -> Result { + Ok(StaffBasedContent { + staff_instances: dec_vec_v1(r, dec_staff_instance_v1)?, + default_metric_grid: Codec::dec(r)?, + barline_alignment_groups: Codec::dec(r)?, + user_system_breaks: Codec::dec(r)?, + user_page_breaks: Codec::dec(r)?, + }) +} + +fn enc_content_v1(content: &RegionContent, out: &mut Vec) { + match content { + RegionContent::StaffBased(s) => { + out.push(0); + enc_sbc_v1(s, out); + } + RegionContent::FreeGraphic(g) => { + out.push(1); + g.enc(out); + } + RegionContent::Hybrid { + staves, + overlay, + overlay_below_staves, + } => { + out.push(2); + enc_sbc_v1(staves, out); + overlay.enc(out); + overlay_below_staves.enc(out); + } + } +} + +fn dec_content_v1(r: &mut Reader<'_>) -> Result { + match r.u8()? { + 0 => Ok(RegionContent::StaffBased(dec_sbc_v1(r)?)), + 1 => Ok(RegionContent::FreeGraphic(Codec::dec(r)?)), + 2 => Ok(RegionContent::Hybrid { + staves: dec_sbc_v1(r)?, + overlay: Codec::dec(r)?, + overlay_below_staves: Codec::dec(r)?, + }), + tag => Err(ScoreDecodeError::InvalidTag { + kind: "RegionContent", + tag, + }), + } +} + +fn enc_region_v1(reg: &Region, out: &mut Vec) { + reg.id.enc(out); + reg.time_model.enc(out); + enc_content_v1(®.content, out); + reg.time_extent.enc(out); + reg.staff_extent.enc(out); + reg.local_tempo_map.enc(out); + reg.permits_spanning_slurs.enc(out); +} + +fn dec_region_v1(r: &mut Reader<'_>) -> Result { + Ok(Region { + id: Codec::dec(r)?, + time_model: Codec::dec(r)?, + content: dec_content_v1(r)?, + time_extent: Codec::dec(r)?, + staff_extent: Codec::dec(r)?, + local_tempo_map: Codec::dec(r)?, + permits_spanning_slurs: Codec::dec(r)?, + }) +} + +fn enc_canvas_v1(c: &Canvas, out: &mut Vec) { + enc_vec_v1(&c.regions, out, enc_region_v1); + c.layout_defaults.enc(out); +} + +fn dec_canvas_v1(r: &mut Reader<'_>) -> Result { + Ok(Canvas { + regions: dec_vec_v1(r, dec_region_v1)?, + layout_defaults: Codec::dec(r)?, + }) +} + +fn enc_instruments_v1(instruments: &[Instrument], out: &mut Vec) { + put_len(out, instruments.len()); + for inst in instruments { + inst.id.enc(out); + inst.name.enc(out); + inst.range.enc(out); + } +} + +fn dec_instruments_v1(r: &mut Reader<'_>) -> Result> { + let n = r.count()?; + let mut instruments = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + instruments.push(Instrument { + id: Codec::dec(r)?, + name: Codec::dec(r)?, + range: Codec::dec(r)?, + abbreviation: None, + sound_config: SoundConfiguration::default(), + transposition: None, + default_clef: Clef::treble(), + default_staff_lines: StaffLineConfiguration::default(), + unpitched_members: Vec::new(), + }); + } + Ok(instruments) +} + +/// The **frozen schema-major-1** encoding of a score — the byte-exact inverse +/// of [`decode_v1_score`]'s field walk. Used by `decode_v1_score` to enforce +/// strict v1 canonicality, and by migration tests and the decode fuzzer to +/// synthesize genuine v1 bytes. A migrated score's schema-major-2 fields hold +/// their defaults, so this simply omits them. +pub(crate) fn encode_v1_score(s: &Score) -> Vec { + let mut out = Vec::new(); + enc_metadata_v1(&s.metadata, &mut out); + enc_canvas_v1(&s.canvas, &mut out); + enc_instruments_v1(&s.instruments, &mut out); + enc_staves_v1(&s.staves, &mut out); + s.staff_groups.enc(&mut out); + s.parts.enc(&mut out); + enc_ccr_v1(&s.cross_cutting, &mut out); + s.time_signatures.enc(&mut out); + s.tuning_context.enc(&mut out); + 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-1** `Score` bytes into the current-layout `Score`, +/// migrating on read (total, default-filling — Binary Format §Schema +/// Major 2's migration table). Strictly canonical on the v1 wire form, like +/// its v0 sibling: re-encodes through [`encode_v1_score`] and rejects any +/// input that is not already its canonical v1 encoding. +fn decode_v1_score(bytes: &[u8]) -> Result { + let mut r = Reader::new(bytes); + let metadata = dec_metadata_v1(&mut r)?; + let canvas = dec_canvas_v1(&mut r)?; + let instruments = dec_instruments_v1(&mut r)?; + let staves = dec_staves_v1(&mut r)?; + let staff_groups = Codec::dec(&mut r)?; + let parts = Codec::dec(&mut r)?; + let cross_cutting = dec_ccr_v1(&mut r)?; + let time_signatures = Codec::dec(&mut r)?; + let tuning_context = Codec::dec(&mut r)?; + let 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_v1_score(&score) != bytes { + return Err(ScoreDecodeError::InvalidValue( + "non-canonical v1 Score encoding", + )); + } + Ok(score) +} + // =========================================================================== // Public per-value canonical codec (the K↔J seam). // =========================================================================== @@ -2634,7 +3360,12 @@ mod tests { let bytes = score.canonical_bytes(); assert_eq!(Score::decode_canonical(&bytes).unwrap(), score); - assert_eq!(Score::decode_canonical_versioned(&bytes, 1).unwrap(), score); + assert_eq!(Score::decode_canonical_versioned(&bytes, 2).unwrap(), score); + // The non-default major-1 values are representable at the frozen v1 + // wire form too: the v1 encoding migrates back to the same score + // (its major-2 fields are all defaults here). + let v1 = encode_v1_score(&score); + assert_eq!(Score::decode_canonical_versioned(&v1, 1).unwrap(), score); } #[test] @@ -2666,7 +3397,8 @@ mod tests { .iter() .all(|r| !r.permits_spanning_slurs)); - let v1 = score.canonical_bytes(); + let current = score.canonical_bytes(); + let v1 = encode_v1_score(&score); let v0 = encode_v0_score(&score); // Size anchor (independent of the v0 encoder's field order): v0 is // exactly v1 minus the appended default bytes — layout_defaults once, @@ -2679,17 +3411,266 @@ mod tests { "v0 omits exactly the three new fields' default bytes" ); - // 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). let migrated = Score::decode_canonical_versioned(&v0, 0).unwrap(); assert_eq!(migrated, score); - // The migrated score re-encodes to the production v1 bytes. - assert_eq!(migrated.canonical_bytes(), v1); - // Major 1: the v1 bytes decode unchanged. + // The migrated score re-encodes to the production (major-2) bytes. + assert_eq!(migrated.canonical_bytes(), current); + // Major 1: the frozen v1 bytes migrate to the same score. assert_eq!(Score::decode_canonical_versioned(&v1, 1).unwrap(), score); + // Major 2: the current bytes decode unchanged. + assert_eq!( + Score::decode_canonical_versioned(¤t, 2).unwrap(), + score + ); } - // A major outside {0, 1} is a defensive decode error (the gate rejects - // it upstream in practice). - assert!(Score::decode_canonical_versioned(&valid_score(1).canonical_bytes(), 2).is_err()); + // A major outside {0, 1, 2} is a defensive decode error (the gate + // rejects it upstream in practice). + assert!(Score::decode_canonical_versioned(&valid_score(1).canonical_bytes(), 3).is_err()); + } + + #[test] + fn v1_score_migrates_default_filling_the_major_2_fields() { + // Schema major 2 filled nine type bodies. A major-1 score carries none + // of the appended fields; migrate-on-read reconstructs each at its + // canonical default (Binary Format §Schema Major 2 migration table). + // Genuine v1 bytes come from the mirror v1 encoder; the size anchor + // pins that v1 omits exactly the appended default bytes, so the frozen + // encoder cannot silently drift from the pre-major-2 wire form. + for seed in 0..64u64 { + let score = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(3)); + let current = score.canonical_bytes(); + let v1 = encode_v1_score(&score); + + let c = &score.cross_cutting; + let override_sites: usize = score + .canvas + .regions + .iter() + .flat_map(|r| r.content.staff_instances()) + .filter(|si| si.staff_lines_override.is_some()) + .count(); + // Appended default bytes per component: slur 4 (kind 1 + + // curvature presence 1 + style line 1 + thickness presence 1); + // tie 2 (style); beam 5 (sub_beams count 4 + geometry presence 1); + // spanner 3 (kind tag 1 + style 2); repeat 9 (SimpleRepeat tag 1 + + // count 4 + voltas count 4); staff 17 (clef 3 + config appends + // 14 = spacing 12 + style 1 + bracket 1); instrument 28 + // (abbreviation 1 + sound count 4 + transposition 1 + clef 3 + + // full config 15 + members count 4); an overridden staff-instance + // config 14; metadata 23 (three presence bytes + two i64 + // timestamps + additional count 4). + let expected_removed = c.slurs.len() * 4 + + c.ties.len() * 2 + + c.beams.len() * 5 + + c.spanners.len() * 3 + + c.repeats.len() * 9 + + score.staves.len() * 17 + + score.instruments.len() * 28 + + override_sites * 14 + + 23; + assert_eq!( + current.len() - v1.len(), + expected_removed, + "v1 omits exactly the appended major-2 default bytes" + ); + + let migrated = Score::decode_canonical_versioned(&v1, 1).unwrap(); + assert_eq!(migrated, score); + assert_eq!(migrated.canonical_bytes(), current); + } + } + + #[test] + fn current_major_round_trips_non_default_values_for_every_major_2_field() { + // One score carrying a non-default value for EVERY schema-major-2 + // field (and every payload-carrying SpannerKind variant), so the live + // codec arms are exercised as real wire content, not always-default + // padding. Byte round-trip only — the standalone cross-cutting values + // reference fixture events for hygiene but invariants are not the + // subject here. + use crate::ids::{BeamId, RepeatStructureId, SlurId, SpannerId, TieId}; + let mut score = valid_score(11); + let events: Vec = score + .voices() + .flat_map(|(_, _, v)| v.events.clone()) + .collect(); + assert!(events.len() >= 2); + let ss = |v: f64| SpaceUnit(CanonicalF64::new(v).expect("finite")); + let anchor = |n: i64| crate::time::TimeAnchor::WallClock { + time: crate::time::WallClockTime(n), + }; + + score.cross_cutting.slurs.push(Slur { + id: SlurId::new(ReplicaId(9), 1), + start_event: events[0], + end_event: events[1], + kind: SlurKind::Phrase, + curvature_override: Some(CurvatureOverride { + direction: Some(CurveDirection::Below), + height: Some(ss(2.5)), + }), + style: SpanStyle { + line: LineStyle::Dashed, + thickness: Some(ss(0.25)), + }, + }); + score.cross_cutting.ties.push(Tie { + id: TieId::new(ReplicaId(9), 2), + start_event: events[0], + end_event: events[1], + pitch_pairing: None, + class: TieClass::Editorial, + style: SpanStyle { + line: LineStyle::Dotted, + thickness: None, + }, + }); + score.cross_cutting.beams.push(Beam { + id: BeamId::new(ReplicaId(9), 3), + events: events.clone(), + level: 1, + sub_beams: vec![SubBeam { + level: 2, + events: vec![events[0]], + }], + geometry_override: Some(BeamGeometryOverride { + slope: Some(CanonicalF64::new(0.5).expect("finite")), + offset: Some(ss(-1.0)), + }), + }); + for (n, kind) in [ + SpannerKind::Hairpin(HairpinDirection::Crescendo), + SpannerKind::OctaveLine(OctaveOffset(-1)), + SpannerKind::PedalLine(PedalKind::Sostenuto), + SpannerKind::TextLine(TextLineDefinition { + text: String::from("rit."), + }), + SpannerKind::Bracket(BracketKind::Square), + SpannerKind::TrillExtension, + ] + .into_iter() + .enumerate() + { + score.cross_cutting.spanners.push(Spanner { + id: SpannerId::new(ReplicaId(9), 10 + n as u64), + start: anchor(0), + end: anchor(1000), + staves: score.staves.iter().map(|st| st.id).collect(), + kind, + style: SpanStyle { + line: LineStyle::Dashed, + thickness: Some(ss(0.1)), + }, + }); + } + score.cross_cutting.repeats.push(RepeatStructure { + id: RepeatStructureId::new(ReplicaId(9), 20), + start: anchor(0), + end: anchor(500), + kind: RepeatKind::DalSegno { + segno: anchor(10), + end_target: anchor(400), + }, + voltas: vec![Volta { + endings: vec![1, 2], + start: anchor(300), + end: anchor(500), + }], + }); + // Every RepeatKind wire arm as real content: DaCapo (tag 1) and the + // payload-less Volta (tag 3); SimpleRepeat is the migration default + // exercised everywhere else. + score.cross_cutting.repeats.push(RepeatStructure { + id: RepeatStructureId::new(ReplicaId(9), 21), + start: anchor(500), + end: anchor(700), + kind: RepeatKind::DaCapo { + end_target: anchor(600), + }, + voltas: vec![], + }); + score.cross_cutting.repeats.push(RepeatStructure { + id: RepeatStructureId::new(ReplicaId(9), 22), + start: anchor(700), + end: anchor(900), + kind: RepeatKind::Volta, + voltas: vec![Volta { + endings: vec![3], + start: anchor(800), + end: anchor(900), + }], + }); + + score.staves[0].default_clef = Clef::bass(); + score.staves[0].default_staff_lines = StaffLineConfiguration { + line_count: 1, + line_spacing: ss(0.6), + line_style: LineStyle::Dotted, + bracket: Some(StaffBracketKind::Brace), + }; + let si = score.canvas.regions[0] + .content + .staff_instances_mut() + .expect("staff-based region") + .first_mut() + .expect("an instance"); + si.staff_lines_override = Some(StaffLineConfiguration { + line_count: 4, + line_spacing: ss(0.8), + line_style: LineStyle::Dashed, + bracket: Some(StaffBracketKind::Bracket), + }); + + score.instruments[0].abbreviation = Some(String::from("Vln.")); + score.instruments[0].sound_config = SoundConfiguration(vec![1, 2, 3]); + score.instruments[0].transposition = Some(TranspositionInterval { + diatonic_steps: -1, + chromatic_steps: -2, + }); + score.instruments[0].default_clef = Clef::alto(); + score.instruments[0].default_staff_lines = StaffLineConfiguration { + line_count: 5, + line_spacing: ss(1.2), + line_style: LineStyle::Solid, + bracket: None, + }; + score.instruments[0].unpitched_members = vec![UnpitchedMember { + member: UnpitchedMemberId(3), + name: String::from("snare"), + staff_position: StaffPosition(2), + }]; + + score.metadata.subtitle = Some(String::from("a subtitle")); + score.metadata.lyricist = Some(String::from("a lyricist")); + score.metadata.arranger = Some(String::from("an arranger")); + score.metadata.creation_timestamp = Timestamp(1_700_000_000_000_000_000); + score.metadata.modification_timestamp = Timestamp(1_700_000_100_000_000_000); + score.metadata.additional = vec![ + MetadataEntry { + key: String::from("opus"), + value: MetadataValue::Integer(27), + }, + MetadataEntry { + key: String::from("dedication"), + value: MetadataValue::Text(String::from("f\u{fc}r Elise")), + }, + MetadataEntry { + key: String::from("urtext"), + value: MetadataValue::Flag(true), + }, + ]; + + let bytes = score.canonical_bytes(); + assert_eq!(Score::decode_canonical(&bytes).unwrap(), score); + assert_eq!(Score::decode_canonical_versioned(&bytes, 2).unwrap(), score); + // The per-value seam carries the filled bodies too. + let slur = &score.cross_cutting.slurs[0]; + assert_eq!( + Slur::decode_canonical(&slur.canonical_bytes()).unwrap(), + *slur + ); } #[test] diff --git a/crates/epiphany-core/src/fuzz.rs b/crates/epiphany-core/src/fuzz.rs index 8a12c2d..b71504b 100644 --- a/crates/epiphany-core/src/fuzz.rs +++ b/crates/epiphany-core/src/fuzz.rs @@ -46,14 +46,20 @@ struct Corpus { regions: Vec>, /// Valid **frozen v0** whole-`Score` encodings — genuine major-0 wire bytes /// (via [`crate::codec::encode_v0_score`]), to exercise the strict v0 - /// migration path with real v0 inputs rather than only mutated v1 bytes. + /// migration path with real v0 inputs rather than only mutated current + /// bytes. v0_scores: Vec>, + /// Valid **frozen v1** whole-`Score` encodings (via + /// [`crate::codec::encode_v1_score`]) — the schema-major-2 migration's + /// input form. + v1_scores: Vec>, } fn build_corpus(rng: &mut SplitMix64) -> Corpus { let mut scores = Vec::new(); let mut regions = Vec::new(); let mut v0_scores = Vec::new(); + let mut v1_scores = Vec::new(); for i in 0..12u64 { let seed = rng.next_u64(); let score = if i % 2 == 0 { @@ -65,12 +71,14 @@ fn build_corpus(rng: &mut SplitMix64) -> Corpus { regions.push(region.canonical_bytes()); } v0_scores.push(crate::codec::encode_v0_score(&score)); + v1_scores.push(crate::codec::encode_v1_score(&score)); scores.push(score.canonical_bytes()); } Corpus { scores, regions, v0_scores, + v1_scores, } } @@ -184,15 +192,22 @@ pub fn run_decode_fuzz(iters: u64, seed: u64) { // The current-layout decoder: must not panic; an Ok must round-trip. check_score(Score::decode_canonical(&bytes), &bytes); - // The schema-version dispatch seam. Major 1 is the current layout; major - // 0 runs the frozen `decode_v0_score` migration; an arbitrary major - // exercises the defensive out-of-accept-set path. - let _ = Score::decode_canonical_versioned(&bytes, 1); - // The v0 migration default-fills the schema-major-1 fields, so it does - // not round-trip to the *v1* form — but it is strictly canonical over the - // **v0 wire form**: an accepted input re-encodes to itself via the frozen - // v0 encoder. This proves non-canonical rejection on the v0 path, not + // The schema-version dispatch seam. Major 2 is the current layout; + // majors 1 and 0 run the frozen migrations; an arbitrary major + // exercises the defensive out-of-accept-set path. Each migration + // default-fills the appended fields, so it does not round-trip to the + // *current* form — but each is strictly canonical over its OWN wire + // form: an accepted input re-encodes to itself via the frozen encoder. + // This proves non-canonical rejection on every versioned path, not // just the absence of a panic. + let _ = Score::decode_canonical_versioned(&bytes, 2); + if let Ok(v1_score) = Score::decode_canonical_versioned(&bytes, 1) { + assert_eq!( + crate::codec::encode_v1_score(&v1_score), + bytes, + "the v1 migration accepted a non-canonical v1 byte string" + ); + } if let Ok(v0_score) = Score::decode_canonical_versioned(&bytes, 0) { assert_eq!( crate::codec::encode_v0_score(&v0_score), @@ -230,31 +245,53 @@ pub fn run_decode_fuzz(iters: u64, seed: u64) { } } - // Every ~4th iteration, feed a *genuine* v0-form encoding (mutated) to - // the frozen major-0 migration, so its strict v0 canonicality is hit - // with real v0 bytes — an accepted input must re-encode to itself in the - // v0 wire form, and an unmutated v0 encoding must always be accepted. + // Every ~4th iteration, feed a *genuine* frozen-form encoding + // (mutated) to its migration path — both the v0 and v1 wire forms — + // so each strict canonicality guard is hit with real bytes of its own + // major. An UNMUTATED frozen encoding MUST decode Ok (enforced, not + // just commented); an accepted input must re-encode to itself. if rng.next_u64() % 4 == 0 { - let mut v0 = - corpus.v0_scores[(rng.next_u64() as usize) % corpus.v0_scores.len()].clone(); - match rng.next_u64() % 4 { - 0 => {} // unmutated: must decode Ok and round-trip the v0 form. - 1 => { - let k = 1 + (rng.next_u64() % 4) as usize; - substitute(&mut rng, &mut v0, k); + type Reenc = fn(&Score) -> Vec; + let forms: [(&[Vec], u16, Reenc, &str); 2] = [ + ( + &corpus.v0_scores, + 0, + crate::codec::encode_v0_score as Reenc, + "v0", + ), + ( + &corpus.v1_scores, + 1, + crate::codec::encode_v1_score as Reenc, + "v1", + ), + ]; + for (pool, major, reenc, label) in forms { + let mut bytes = pool[(rng.next_u64() as usize) % pool.len()].clone(); + let mutation = rng.next_u64() % 4; + match mutation { + 0 => {} // unmutated: must decode Ok (asserted below). + 1 => { + let k = 1 + (rng.next_u64() % 4) as usize; + substitute(&mut rng, &mut bytes, k); + } + 2 => { + let t = (rng.next_u64() as usize) % (bytes.len() + 1); + bytes.truncate(t); + } + _ => corrupt_length_prefix(&mut rng, &mut bytes), } - 2 => { - let t = (rng.next_u64() as usize) % (v0.len() + 1); - v0.truncate(t); + match Score::decode_canonical_versioned(&bytes, major) { + Ok(score) => assert_eq!( + reenc(&score), + bytes, + "the {label} migration accepted a non-canonical {label} byte string" + ), + Err(_) => assert_ne!( + mutation, 0, + "an unmutated genuine {label} encoding must decode Ok" + ), } - _ => corrupt_length_prefix(&mut rng, &mut v0), - } - if let Ok(score) = Score::decode_canonical_versioned(&v0, 0) { - assert_eq!( - crate::codec::encode_v0_score(&score), - v0, - "the v0 migration accepted a non-canonical v0 byte string" - ); } } } diff --git a/crates/epiphany-core/src/generators.rs b/crates/epiphany-core/src/generators.rs index a101c71..0a5b134 100644 --- a/crates/epiphany-core/src/generators.rs +++ b/crates/epiphany-core/src/generators.rs @@ -114,11 +114,7 @@ pub fn valid_score(seed: u64) -> Score { let staff_id: StaffId = idc.mint(); let instrument: InstrumentId = idc.mint(); // Declare the instrument so the staff's reference resolves (invariant 10). - instruments.push(Instrument { - id: instrument, - name: String::from("instrument"), - range: None, - }); + instruments.push(Instrument::new(instrument, String::from("instrument"))); staves.push(Staff { id: staff_id, name: String::from("staff"), @@ -126,6 +122,7 @@ pub fn valid_score(seed: u64) -> Score { instrument, default_staff_lines: StaffLineConfiguration::default(), group: None, + default_clef: crate::graph::Clef::treble(), }); staff_extent.push(staff_id); @@ -221,11 +218,7 @@ pub fn valid_score_rich(seed: u64) -> Score { -> StaffId { let id: StaffId = idc.mint(); let instrument: InstrumentId = idc.mint(); - instruments.push(Instrument { - id: instrument, - name: String::from("instrument"), - range: None, - }); + instruments.push(Instrument::new(instrument, String::from("instrument"))); staves.push(Staff { id, name: String::from("staff"), @@ -233,6 +226,7 @@ pub fn valid_score_rich(seed: u64) -> Score { instrument, default_staff_lines: StaffLineConfiguration::default(), group: None, + default_clef: crate::graph::Clef::treble(), }); id }; @@ -301,6 +295,7 @@ pub fn valid_score_rich(seed: u64) -> Score { end_event: triplet_members[1], pitch_pairing: None, class: TieClass::Standard, + style: Default::default(), }); // The first triplet member is an eighth in a 3:2 triplet, so its sounding // duration is 1/8 × 2/3 = 1/12 — matching the event's duration (invariant 15). @@ -325,6 +320,8 @@ pub fn valid_score_rich(seed: u64) -> Score { time: WallClockTime(10), }, staves: vec![staff_a], + kind: Default::default(), + style: Default::default(), }); cross_cutting.markers.push(Marker { id: idc.mint::(), @@ -587,6 +584,7 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score { instrument: s.identity.mint(), default_staff_lines: StaffLineConfiguration::default(), group: None, + default_clef: crate::graph::Clef::treble(), }); s.canvas.regions[0].staff_extent.staves.push(staff2); s.canvas.regions[0] @@ -610,6 +608,8 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score { time: WallClockTime(10), }, staves: vec![staff], + kind: Default::default(), + style: Default::default(), }); } CrossCuttingRefsResolve => { @@ -619,6 +619,9 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score { id: SlurId::new(replica, 1), start_event: ghost, end_event: ghost, + kind: Default::default(), + curvature_override: None, + style: Default::default(), }); } UniqueIdentifiers => { @@ -700,6 +703,7 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score { end_event: e1, pitch_pairing: Some(vec![(ghost, end_pid)]), class: TieClass::Editorial, + style: Default::default(), }); } VoiceOriginConsistent => { diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index 196a179..1433330 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -43,14 +43,33 @@ pub enum StemDirection { #[derive(Clone, PartialEq, Eq, Debug)] pub struct StaffLineConfiguration { pub line_count: u8, + /// Schema major 2 (appended; migration default 1.0). + pub line_spacing: SpaceUnit, + /// Schema major 2 (appended; migration default `Solid`). + pub line_style: LineStyle, + /// Schema major 2 (appended; migration default `None`). A per-staff + /// bracket adornment, distinct from StaffGroup-level bracketing. + pub bracket: Option, } impl Default for StaffLineConfiguration { fn default() -> Self { - StaffLineConfiguration { line_count: 5 } + StaffLineConfiguration { + line_count: 5, + line_spacing: SpaceUnit::normal(), + line_style: LineStyle::Solid, + bracket: None, + } } } +/// A per-staff bracket adornment (Chapter 5; schema major 2). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum StaffBracketKind { + Brace, + Bracket, +} + /// The SMuFL clef family a [`Clef`] draws from. The reference pitch each family /// fixes (G4 / F3 / middle C4) is what pins the staff-position mapping. #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] @@ -797,6 +816,9 @@ pub struct Staff { pub instrument: InstrumentId, pub default_staff_lines: StaffLineConfiguration, pub group: Option, + /// Default clef for new instances of this staff (schema major 2, + /// appended last per the wire rule; migration default treble). + pub default_clef: Clef, } /// The spatial root of the score (Chapter 5 §"The Canvas"). @@ -864,12 +886,110 @@ impl Default for CanvasLayoutDefaults { // graphic gestures, lyrics, chord symbols) extends this with the same // reference-resolution discipline. +/// A dimension in staff spaces (Chapter 5; schema major 2). Staff line +/// spacing is relative to the global staff space: 1.0 is a normal-size +/// staff, smaller values yield cue/ossia staves. The wire form is the +/// wrapped [`CanonicalF64`]'s (the newtype adds no bytes). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct SpaceUnit(pub CanonicalF64); + +impl SpaceUnit { + /// The normal-size staff spacing, 1.0 — the v1→v2 migration default + /// for `StaffLineConfiguration.line_spacing`. + pub fn normal() -> Self { + SpaceUnit(CanonicalF64::new(1.0).expect("1.0 is finite")) + } +} + +/// A line drawing style (Chapter 5; schema major 2). Shared by staff +/// lines and [`SpanStyle`]. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)] +pub enum LineStyle { + #[default] + Solid, + Dashed, + Dotted, +} + +/// The class of a slur (Chapter 5 §"Slurs"; schema major 2). The +/// v1→v2 migration default is `Legato`. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)] +pub enum SlurKind { + /// An ordinary legato slur. + #[default] + Legato, + /// A phrase mark (typically longer, over sub-phrases). + Phrase, + /// An articulation slur (e.g., over a two-note sigh figure). + Articulation, + /// An editorial slur (rendered distinctly, e.g., dashed). + Editorial, +} + +/// Which side of the notes a curve arcs toward (Chapter 5; schema +/// major 2). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum CurveDirection { + Above, + Below, +} + +/// An authored curvature override (Chapter 5 §"Slurs"; schema major 2). +/// The engraver computes default curvature; each present field +/// overrides that component. Consumed by the Standard engraving tier; +/// stored and preserved at every tier. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CurvatureOverride { + pub direction: Option, + /// Arc height at the apex. + pub height: Option, +} + +/// The visual style of a spanning mark (Chapter 5; schema major 2): +/// one shared record for `Slur.style`, `Tie.style`, and +/// `Spanner.style`. Defaults: solid, engraver-chosen thickness. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct SpanStyle { + pub line: LineStyle, + /// Line thickness; `None` = the engraver's default. + pub thickness: Option, +} + /// A slur / phrase mark over a span of events (Chapter 5 §"Slurs"). #[derive(Clone, PartialEq, Eq, Debug)] pub struct Slur { pub id: SlurId, pub start_event: crate::ids::EventId, pub end_event: crate::ids::EventId, + /// Schema major 2 (appended; migration default `Legato`). + pub kind: SlurKind, + /// Schema major 2 (appended; migration default `None`). + pub curvature_override: Option, + /// Schema major 2 (appended; migration default `SpanStyle::default()`). + pub style: SpanStyle, +} + +/// A beam segment at a deeper subdivision level (Chapter 5 §"Beams"; +/// schema major 2): a contiguous subset of the owning beam's events +/// beamed together at `level` (strictly deeper than the owner's +/// primary level). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SubBeam { + pub level: u8, + pub events: Vec, +} + +/// An authored beam-geometry override (Chapter 5 §"Beams"; schema +/// major 2). Each present field overrides the engraver's computed +/// geometry. Consumed by the Standard engraving tier; stored and +/// preserved at every tier. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct BeamGeometryOverride { + /// Beam slope: staff spaces of rise per staff space of run + /// (dimensionless, hence not a [`SpaceUnit`]). + pub slope: Option, + /// Vertical displacement from the default placement (positive = up). + pub offset: Option, } /// A beam over a sequence of events (Chapter 5 §"Beams"). @@ -878,6 +998,65 @@ pub struct Beam { pub id: BeamId, pub events: Vec, pub level: u8, + /// Schema major 2 (appended; migration default empty). + pub sub_beams: Vec, + /// Schema major 2 (appended; migration default `None`). + pub geometry_override: Option, +} + +/// Hairpin orientation (Chapter 5 §"Spanners"; schema major 2). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum HairpinDirection { + Crescendo, + Diminuendo, +} + +/// Octave-line displacement in signed octaves (Chapter 5 §"Spanners"; +/// schema major 2): +1 = 8va, -1 = 8vb, +2 = 15ma, -2 = 15mb. Zero is +/// representable but degenerate; the authoring advisory layer flags +/// it, reduction does not. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct OctaveOffset(pub i8); + +/// Pedal-line kind (Chapter 5 §"Spanners"; schema major 2). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum PedalKind { + Sustain, + Sostenuto, + UnaCorda, +} + +/// A text line's content (Chapter 5 §"Spanners"; schema major 2); the +/// dash pattern comes from the spanner's style. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct TextLineDefinition { + pub text: String, +} + +/// A bracket spanner's shape (Chapter 5 §"Spanners"; schema major 2). +/// Growth is by appended variant. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum BracketKind { + Square, +} + +/// The kind of a spanner (Chapter 5 §"Spanners"; schema major 2). +/// `Generic` is deliberately first: it is the v1→v2 migration default +/// — a v1 spanner carried no kind, and `Generic` (a plain line or +/// bracket) is the honest translation of that absence. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub enum SpannerKind { + /// An unclassified spanning mark: renders as a plain line/bracket. + #[default] + Generic, + Hairpin(HairpinDirection), + OctaveLine(OctaveOffset), + PedalLine(PedalKind), + TrillExtension, + Glissando, + Portamento, + TextLine(TextLineDefinition), + Bracket(BracketKind), } /// A generic spanning mark anchored by time (Chapter 5 §"Spanners"). @@ -888,6 +1067,11 @@ pub struct Spanner { pub end: TimeAnchor, /// Which staves this spanner attaches to. pub staves: Vec, + /// Schema major 2 (appended after `staves` per the wire rule; + /// migration default `Generic`). + pub kind: SpannerKind, + /// Schema major 2 (appended; migration default `SpanStyle::default()`). + pub style: SpanStyle, } /// The class of a tie, fixing its validation profile (Chapter 5 §"Ties"). @@ -916,6 +1100,8 @@ pub struct Tie { /// pitches by enharmonic matching in pitch-id-ascending order. pub pitch_pairing: Option>, pub class: TieClass, + /// Schema major 2 (appended; migration default `SpanStyle::default()`). + pub style: SpanStyle, } /// The actual:notated ratio of a tuplet (Chapter 3 §"Tuplets"). Built only @@ -988,6 +1174,46 @@ pub struct Marker { pub anchor: TimeAnchor, } +/// The kind of a repeat structure (Chapter 5 §"Repeat Structures"; +/// schema major 2). The v1→v2 migration default is +/// `SimpleRepeat { count: 2 }`: a v1 repeat *meant* a repeat, and +/// playing the span twice is the conventional semantics of an +/// unadorned repeat sign. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum RepeatKind { + SimpleRepeat { + count: u32, + }, + DaCapo { + end_target: TimeAnchor, + }, + DalSegno { + segno: TimeAnchor, + end_target: TimeAnchor, + }, + Volta, +} + +impl RepeatKind { + /// The v1→v2 migration default (Binary Format §Schema Major 2). + pub const fn migration_default() -> Self { + RepeatKind::SimpleRepeat { count: 2 } + } +} + +/// One volta bracket (Chapter 5 §"Repeat Structures"; schema major 2): +/// the passes it applies on and the time span it covers. The `endings` +/// constraints (non-empty, 1-based, strictly ascending) are advisory — +/// decoders and reduction accept violations, the authoring validation +/// layer flags them. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct Volta { + /// The pass numbers this ending plays on (1-based), ascending. + pub endings: Vec, + pub start: TimeAnchor, + pub end: TimeAnchor, +} + /// A repeat structure: simple repeat, da capo, dal segno, volta (Chapter 5 /// §"Repeat Structures"). Spanned by two time anchors. #[derive(Clone, PartialEq, Eq, Debug)] @@ -995,6 +1221,11 @@ pub struct RepeatStructure { pub id: RepeatStructureId, pub start: TimeAnchor, pub end: TimeAnchor, + /// Schema major 2 (appended; migration default + /// [`RepeatKind::migration_default`]). + pub kind: RepeatKind, + /// Schema major 2 (appended; migration default empty). + pub voltas: Vec, } /// An analytical annotation (Roman numeral, form label, …) (Chapter 5 @@ -1187,6 +1418,35 @@ pub struct DecompositionAttachment { // later companions; this baseline models the identity- and reference-bearing // skeleton and leaves the rest as documented placeholders. +/// A calendar timestamp (Chapter 5 §"Score Metadata"; schema major 2): +/// nanoseconds since the Unix epoch, UTC, no zone. Distinct from +/// [`crate::WallClockTime`], which is *performance* time within a +/// score. Zero is the "unset" convention. Strictly authored +/// (`req:graph:metadata-timestamps`): nothing writes these implicitly. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)] +pub struct Timestamp(pub i64); + +/// The value of an additional metadata entry (Chapter 5 §"Score +/// Metadata"; schema major 2). Closed small union; growth by appended +/// variant. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum MetadataValue { + Text(String), + Integer(i64), + Flag(bool), +} + +/// One additional metadata entry (Chapter 5 §"Score Metadata"; schema +/// major 2). `ScoreMetadata.additional` is an ordered authored *list*, +/// not a map: order is preserved verbatim and duplicate keys are +/// permitted (foreign formats carry repeated keys); map-seeking +/// consumers take the first entry per key. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct MetadataEntry { + pub key: String, + pub value: MetadataValue, +} + /// Bibliographic and authorship metadata (Chapter 5 §"Score Metadata"). The /// structure is deliberately small. #[derive(Clone, PartialEq, Eq, Debug, Default)] @@ -1194,6 +1454,51 @@ pub struct ScoreMetadata { pub title: Option, pub composer: Option, pub copyright: Option, + /// Schema major 2 (appended; migration default `None`). + pub subtitle: Option, + /// Schema major 2 (appended; migration default `None`). + pub lyricist: Option, + /// Schema major 2 (appended; migration default `None`). + pub arranger: Option, + /// Schema major 2 (appended; migration default unset/zero). + pub creation_timestamp: Timestamp, + /// Schema major 2 (appended; migration default unset/zero). + pub modification_timestamp: Timestamp, + /// Schema major 2 (appended; migration default empty). + pub additional: Vec, +} + +/// Opaque sound configuration (Chapter 5 §"Instruments"; schema +/// major 2). The audio engine specification owns the structure; the +/// core stores the bytes verbatim and never interprets them. +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct SoundConfiguration(pub Vec); + +/// A written-versus-sounding transposition (Chapter 5 §"Instruments"; +/// schema major 2). Structural only: the diatonic and chromatic step +/// counts of the interval (e.g., B-flat clarinet = -1 diatonic, +/// -2 chromatic). Semantically ADVISORY until the Chapter 4 tuning +/// catalog pins interval algebra (the P12-K2 discipline): nothing in +/// the core resolves it to frequencies or respells through it yet. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct TranspositionInterval { + pub diatonic_steps: i32, + pub chromatic_steps: i32, +} + +/// One playable member of an unpitched instrument (Chapter 5 +/// §"Instruments"; schema major 2). `member` is an instrument-scoped +/// small value (not a 128-bit object id); resolution from +/// `UnpitchedEvent.instrument_member` is by first match in list order, +/// and a no-match is tolerated (every pre-major-2 instrument has an +/// empty member list while its events carry member values) — the +/// event's own `staff_position` governs placement either way; the +/// member's is the authoring default copied onto new events. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct UnpitchedMember { + pub member: crate::event::UnpitchedMemberId, + pub name: String, + pub staff_position: crate::event::StaffPosition, } /// An abstract instrument definition (Chapter 5 §"Instruments"). Baseline: the @@ -1209,6 +1514,38 @@ pub struct Instrument { /// spanning-frame candidate outside the range trips the advisory check in /// authoring mode only (see [`PitchRange::contains`]). pub range: Option, + + /// Schema major 2 (appended after the major-1 order per the wire + /// rule; migration default `None`). + pub abbreviation: Option, + /// Schema major 2 (appended; migration default empty). + pub sound_config: SoundConfiguration, + /// Schema major 2 (appended; migration default `None`). + pub transposition: Option, + /// Schema major 2 (appended; migration default treble). + pub default_clef: Clef, + /// Schema major 2 (appended; migration default the 5-line default). + pub default_staff_lines: StaffLineConfiguration, + /// Schema major 2 (appended; migration default empty). + pub unpitched_members: Vec, +} + +impl Instrument { + /// A minimal instrument: identity and name, every other field at its + /// canonical default (the schema-major-2 migration defaults). + pub fn new(id: InstrumentId, name: impl Into) -> Self { + Instrument { + id, + name: name.into(), + range: None, + abbreviation: None, + sound_config: SoundConfiguration::default(), + transposition: None, + default_clef: Clef::treble(), + default_staff_lines: StaffLineConfiguration::default(), + unpitched_members: Vec::new(), + } + } } /// The kind of a staff grouping (Chapter 5 §"Top-Level Score Structure"). diff --git a/crates/epiphany-core/src/invariants.rs b/crates/epiphany-core/src/invariants.rs index 4ff5003..ab4a3e5 100644 --- a/crates/epiphany-core/src/invariants.rs +++ b/crates/epiphany-core/src/invariants.rs @@ -808,6 +808,20 @@ impl<'a> GraphIndex<'a> { for rp in &cc.repeats { anchors.push(&rp.start); anchors.push(&rp.end); + // Schema major 2: the kind and volta anchors are anchors too. + match &rp.kind { + crate::graph::RepeatKind::DaCapo { end_target } => anchors.push(end_target), + crate::graph::RepeatKind::DalSegno { segno, end_target } => { + anchors.push(segno); + anchors.push(end_target); + } + crate::graph::RepeatKind::SimpleRepeat { .. } | crate::graph::RepeatKind::Volta => { + } + } + for v in &rp.voltas { + anchors.push(&v.start); + anchors.push(&v.end); + } } for cs in &cc.chord_symbols { anchors.push(&cs.anchor); @@ -931,6 +945,15 @@ impl<'a> GraphIndex<'a> { format!("beam {:?} event {:?} dangling", b.id, e), ); } + // Schema major 2: sub-beam member events are references too. + for sb in &b.sub_beams { + for e in &sb.events { + flag( + live_event(e), + format!("beam {:?} sub-beam event {:?} dangling", b.id, e), + ); + } + } } for tp in &self.score.cross_cutting.tuplets { for e in &tp.members { @@ -990,6 +1013,28 @@ impl<'a> GraphIndex<'a> { self.anchor_target_exists(&rp.start) && self.anchor_target_exists(&rp.end), format!("repeat {:?} anchor target dangling", rp.id), ); + // Schema major 2: the kind's jump anchors and each volta's span. + let kind_ok = match &rp.kind { + crate::graph::RepeatKind::DaCapo { end_target } => { + self.anchor_target_exists(end_target) + } + crate::graph::RepeatKind::DalSegno { segno, end_target } => { + self.anchor_target_exists(segno) && self.anchor_target_exists(end_target) + } + crate::graph::RepeatKind::SimpleRepeat { .. } | crate::graph::RepeatKind::Volta => { + true + } + }; + flag( + kind_ok, + format!("repeat {:?} kind anchor target dangling", rp.id), + ); + for v in &rp.voltas { + flag( + self.anchor_target_exists(&v.start) && self.anchor_target_exists(&v.end), + format!("repeat {:?} volta anchor target dangling", rp.id), + ); + } } for cs in &cc.chord_symbols { flag( @@ -2473,11 +2518,11 @@ mod review_fix_tests { use crate::event::{Event, PitchedEvent, StemConfiguration}; use crate::generators::valid_score; use crate::graph::{ - derive_promoted_voice_id, MetricTimeModel, ProportionalTimeModel, Region, RegionContent, - Spanner, StaffBasedContent, StaffExtent, StaffInstance, Tie, TieClass, TimeExtent, Voice, - VoiceOrigin, + derive_promoted_voice_id, Beam, MetricTimeModel, ProportionalTimeModel, Region, + RegionContent, RepeatKind, RepeatStructure, Spanner, StaffBasedContent, StaffExtent, + StaffInstance, SubBeam, Tie, TieClass, TimeExtent, Voice, VoiceOrigin, Volta, }; - use crate::ids::{OperationId, ReplicaId, SpannerId, TieId}; + use crate::ids::{BeamId, OperationId, RepeatStructureId, ReplicaId, SpannerId, TieId}; use crate::pitch::{ AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId, PitchSpacePosition, ScalePosition, TuningReference, @@ -2632,6 +2677,8 @@ mod review_fix_tests { time: WallClockTime(1), }, staves: vec![staff], + kind: Default::default(), + style: Default::default(), }; s.cross_cutting.spanners.push(spanner_ok); assert!(!fires(&s, GraphInvariant::AnchorOffsetModel)); @@ -2659,10 +2706,77 @@ mod review_fix_tests { time: WallClockTime(0), }, staves: vec![staff], + kind: Default::default(), + style: Default::default(), }); assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve)); } + #[test] + fn inv10_flags_dangling_sub_beam_event() { + // Schema major 2: sub-beam member events are references the invariant + // must resolve, exactly like the owning beam's events. + let mut s = valid_score(3); + let live: Vec<_> = s + .voices() + .flat_map(|(_, _, v)| v.events.clone()) + .take(2) + .collect(); + let ghost_event = crate::ids::EventId::new(s.identity.replica_id, 9_000_002); + s.cross_cutting.beams.push(Beam { + id: BeamId::new(s.identity.replica_id, 1), + events: live.clone(), + level: 1, + sub_beams: vec![SubBeam { + level: 2, + events: vec![ghost_event], + }], + geometry_override: None, + }); + assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve)); + } + + #[test] + fn inv10_flags_dangling_repeat_kind_and_volta_anchors() { + // Schema major 2: a DalSegno's segno/end_target and each volta's span + // are anchors the invariant must resolve. + let mut s = valid_score(3); + let r = s.identity.replica_id; + let ghost = crate::ids::EventId::new(r, 9_000_003); + let dead_anchor = TimeAnchor::Event { + id: ghost, + offset: AnchorOffset::Zero, + }; + let ok = TimeAnchor::WallClock { + time: WallClockTime(0), + }; + s.cross_cutting.repeats.push(RepeatStructure { + id: RepeatStructureId::new(r, 1), + start: ok.clone(), + end: ok.clone(), + kind: RepeatKind::DalSegno { + segno: dead_anchor.clone(), + end_target: ok.clone(), + }, + voltas: vec![], + }); + assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve)); + + let mut s2 = valid_score(3); + s2.cross_cutting.repeats.push(RepeatStructure { + id: RepeatStructureId::new(r, 2), + start: ok.clone(), + end: ok.clone(), + kind: RepeatKind::Volta, + voltas: vec![Volta { + endings: vec![1], + start: dead_anchor, + end: ok, + }], + }); + assert!(fires(&s2, GraphInvariant::CrossCuttingRefsResolve)); + } + /// Builds a single-voice score with two adjacent pitched chords and returns /// (score, e0, e1) for tie tests. fn two_chord_score( @@ -2719,6 +2833,7 @@ mod review_fix_tests { end_event: e1, pitch_pairing: None, class: TieClass::Standard, + style: Default::default(), }); assert!(fires(&s, GraphInvariant::TiePairing)); @@ -2730,6 +2845,7 @@ mod review_fix_tests { end_event: e1, pitch_pairing: None, class: TieClass::Standard, + style: Default::default(), }); assert!(!fires(&s2, GraphInvariant::TiePairing)); } @@ -2751,6 +2867,9 @@ mod review_fix_tests { id: sid, start_event: staff_event, end_event: staff_event, + kind: Default::default(), + curvature_override: None, + style: Default::default(), }); } assert!(fires(&s2, GraphInvariant::UniqueIdentifiers)); @@ -2857,6 +2976,8 @@ mod review_fix_tests { end: TimeAnchor::WallClock { time: crate::time::WallClockTime(0), }, + kind: crate::graph::RepeatKind::migration_default(), + voltas: Vec::new(), }); s.cross_cutting.comments.push(Comment { id: crate::ids::CommentId::new(r, 1), @@ -3114,16 +3235,8 @@ mod review_fix_tests_2 { let mut s = valid_score(71); let r = s.identity.replica_id; let iid = InstrumentId::new(r, 1); - s.instruments.push(Instrument { - id: iid, - name: "a".into(), - range: None, - }); - s.instruments.push(Instrument { - id: iid, - name: "b".into(), - range: None, - }); + s.instruments.push(Instrument::new(iid, "a")); + s.instruments.push(Instrument::new(iid, "b")); assert!(fires(&s, GraphInvariant::UniqueIdentifiers)); // Score identity in the reserved namespace. @@ -3267,6 +3380,7 @@ mod review_fix_tests_3 { end_event: e1, pitch_pairing: None, class: TieClass::Standard, + style: Default::default(), }); assert!( !fires(&s, GraphInvariant::TiePairing), @@ -3333,6 +3447,12 @@ mod review_fix_tests_3 { id: InstrumentId::new(ReplicaId::SYSTEM_DERIVED, 1), name: "x".into(), range: None, + abbreviation: None, + sound_config: Default::default(), + transposition: None, + default_clef: crate::graph::Clef::treble(), + default_staff_lines: Default::default(), + unpitched_members: Vec::new(), }); assert!(fires(&s, GraphInvariant::UniqueIdentifiers)); } @@ -3370,6 +3490,7 @@ mod review_fix_tests_3 { instrument: s.staves[0].instrument, default_staff_lines: Default::default(), group: None, + default_clef: crate::graph::Clef::treble(), }); inst.staff = staff2; inst.voices.push(voice); @@ -3424,6 +3545,8 @@ mod review_fix_tests_3 { time: WallClockTime(0), }, staves: vec![staff2], + kind: Default::default(), + style: Default::default(), }); assert!(fires(&s, GraphInvariant::AnchorOffsetModel)); // A wall-clock offset matches the event's clock -> ok. @@ -3657,11 +3780,8 @@ mod review_fix_tests_4 { // A fresh staff Y (declared) with its instrument. let staff_y = s.identity.mint(); let instr = s.identity.mint(); - s.instruments.push(crate::graph::Instrument { - id: instr, - name: "y".into(), - range: None, - }); + s.instruments + .push(crate::graph::Instrument::new(instr, "y")); s.staves.push(crate::graph::Staff { id: staff_y, name: "Y".into(), @@ -3669,6 +3789,7 @@ mod review_fix_tests_4 { instrument: instr, default_staff_lines: Default::default(), group: None, + default_clef: crate::graph::Clef::treble(), }); let mk_region = |s: &mut Score, start: TimeAnchor, end: TimeAnchor| Region { id: s.identity.mint(), diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index a3dea70..5cf047f 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -102,17 +102,20 @@ pub use event::{ pub use graph::{ derive_promoted_voice_id, AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer, AnalyticalAnnotation, AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam, - BeatGroup, Canvas, CanvasLayoutDefaults, CanvasMargins, CanvasSize, ChordSymbol, Clef, - ClefChange, ClefShape, Comment, CoordinateDiscipline, CrossCuttingRegistry, - DecompositionAttachment, DecompositionSource, EventOrderingDAG, GestureAnchoring, - GraphicContent, GraphicGesture, GraphicObject, Instrument, KeySignature, KeySignatureChange, - LyricLine, Marker, Measure, MeasureNumberVisibility, MeterChange, MetricGrid, MetricTimeModel, - NotatedComponent, NoteValue, PartDefinition, PowerOfTwo, ProportionalTimeModel, Region, - RegionContent, RegionTimeModel, RepeatStructure, Score, ScoreMetadata, ScoreTuningContext, - Slur, Spanner, Staff, StaffBasedContent, StaffExtent, StaffGroup, StaffGroupKind, - StaffInstance, StaffLineConfiguration, StemDirection, TempoMapReference, Tie, TieClass, - TimeExtent, TimeSignature, TimeSignatureDisplay, Tuplet, TupletRatio, ViewDefinition, Voice, - VoiceOrigin, + BeamGeometryOverride, BeatGroup, BracketKind, Canvas, CanvasLayoutDefaults, CanvasMargins, + CanvasSize, ChordSymbol, Clef, ClefChange, ClefShape, Comment, CoordinateDiscipline, + CrossCuttingRegistry, CurvatureOverride, CurveDirection, DecompositionAttachment, + DecompositionSource, EventOrderingDAG, GestureAnchoring, GraphicContent, GraphicGesture, + GraphicObject, HairpinDirection, Instrument, KeySignature, KeySignatureChange, LineStyle, + LyricLine, Marker, Measure, MeasureNumberVisibility, MetadataEntry, MetadataValue, MeterChange, + MetricGrid, MetricTimeModel, NotatedComponent, NoteValue, OctaveOffset, PartDefinition, + PedalKind, PowerOfTwo, ProportionalTimeModel, Region, RegionContent, RegionTimeModel, + RepeatKind, RepeatStructure, Score, ScoreMetadata, ScoreTuningContext, Slur, SlurKind, + SoundConfiguration, SpaceUnit, SpanStyle, Spanner, SpannerKind, Staff, StaffBasedContent, + StaffBracketKind, StaffExtent, StaffGroup, StaffGroupKind, StaffInstance, + StaffLineConfiguration, StemDirection, SubBeam, TempoMapReference, TextLineDefinition, Tie, + TieClass, TimeExtent, TimeSignature, TimeSignatureDisplay, Timestamp, TranspositionInterval, + Tuplet, TupletRatio, UnpitchedMember, ViewDefinition, Voice, VoiceOrigin, Volta, }; pub use tempo::{ diff --git a/crates/epiphany-core/src/prepass/tests.rs b/crates/epiphany-core/src/prepass/tests.rs index b91c53d..ae6e7cb 100644 --- a/crates/epiphany-core/src/prepass/tests.rs +++ b/crates/epiphany-core/src/prepass/tests.rs @@ -177,12 +177,9 @@ fn metric_score( instrument, default_staff_lines: StaffLineConfiguration::default(), group: None, + default_clef: crate::graph::Clef::treble(), }]; - score.instruments = vec![Instrument { - id: instrument, - name: String::from("Test"), - range: None, - }]; + score.instruments = vec![Instrument::new(instrument, String::from("Test"))]; score.cross_cutting.tuplets = tuplets; score.events = arena; score.canvas = Canvas { @@ -785,12 +782,9 @@ fn nonmetric_region_defers_decomposition_but_still_spells() { instrument, default_staff_lines: StaffLineConfiguration::default(), group: None, + default_clef: crate::graph::Clef::treble(), }]; - score.instruments = vec![Instrument { - id: instrument, - name: "T".into(), - range: None, - }]; + score.instruments = vec![Instrument::new(instrument, "T")]; score.events = arena; score.canvas = Canvas { regions: vec![region_obj], diff --git a/crates/epiphany-core/tests/score_graph.rs b/crates/epiphany-core/tests/score_graph.rs index 7f1fca8..a8a0947 100644 --- a/crates/epiphany-core/tests/score_graph.rs +++ b/crates/epiphany-core/tests/score_graph.rs @@ -7,9 +7,9 @@ use epiphany_core::{ check_invariants, derive_promoted_voice_id, generators, AcousticPitch, AcousticRealization, Canvas, CmnNominal, Event, EventArena, EventDuration, EventPosition, GraphInvariant, - IdentifiedPitch, IdentityContext, Instrument, InstrumentId, MusicalDuration, MusicalPosition, - OperationId, Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent, RationalTime, - Region, RegionContent, RegionTimeModel, ReplicaId, ScalePosition, Score, StaffBasedContent, + IdentifiedPitch, IdentityContext, InstrumentId, MusicalDuration, MusicalPosition, OperationId, + Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent, RationalTime, Region, + RegionContent, RegionTimeModel, ReplicaId, ScalePosition, Score, StaffBasedContent, StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, TimeAnchor, TimeExtent, TuningReference, Voice, WallClockTime, }; @@ -93,11 +93,7 @@ fn hand_built_score() -> Score { let mut score = Score::empty(idc.clone()); score.identity = idc; - score.instruments = vec![Instrument { - id: instrument, - name: "Flute".into(), - range: None, - }]; + score.instruments = vec![epiphany_core::Instrument::new(instrument, "Flute")]; score.staves = vec![Staff { id: staff_id, name: "Flute 1".into(), @@ -105,6 +101,7 @@ fn hand_built_score() -> Score { instrument, default_staff_lines: StaffLineConfiguration::default(), group: None, + default_clef: epiphany_core::Clef::treble(), }]; score.events = arena; score.canvas = Canvas { diff --git a/crates/epiphany-layout-ir/src/logical.rs b/crates/epiphany-layout-ir/src/logical.rs index d87b857..d95db76 100644 --- a/crates/epiphany-layout-ir/src/logical.rs +++ b/crates/epiphany-layout-ir/src/logical.rs @@ -1446,6 +1446,8 @@ mod tests { offset: AnchorOffset::Zero, }, staves: vec![first_staff, second_staff], + kind: Default::default(), + style: Default::default(), }); let logical = to_logical(&score); diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index 7b4d605..ca4b7c0 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -975,3 +975,35 @@ same split previously read `TargetMissing` vs a silent `Applied` rewrite) and is a ModifyEvent-introduction question — whether a replacement value may introduce never-minted pitch ids at all — batched for Pass 13, not improvised here. + +## Schema major 2: minimal stamping (landed with core Phase B, deliberately) + +The live codec change makes CrossCutting/Staff/Metadata payload bytes v2 +immediately, so the honest stamps land in the SAME commit rather than a +later phase (the major-1 D1/D2 byte-shim approach would have needed v1 +shims for nine transitively-embedded types, all throwaway). +`OperationKind::schema_major` implements the ratified **minimal-stamping** +rule (Binary Format §Schema Major 2): Create/ModifyCrossCutting, CreateStaff, +SetMetadata → always 2 (mandatory appends); CreateRegion → 2 iff a carried +staff instance bears Some(staff_lines_override) else 1; CreateStaffInstance/ +SetStaffLayout → 2 iff Some(override) else 0 (None encodes byte-identically +to the prior major). Locked by `schema_majors_follow_the_minimal_stamping_rule`. +`the_canonical_base_is_byte_identical_across_data_model_majors` golden-locks +a seeded reduction's MaterializedState bytes — the companion's SHOULD that +the canonical base never moves across data-model majors. + +**Review sharpening — the op-payload migrate-on-read deferral, stated +precisely:** with the live codecs at v2, op-payload BYTES from a pre-major-2 +build (a persisted bundle whose CrossCutting/Staff/Metadata blocks were +stamped major 0 under the old per-kind rule) have no in-build decoder — the +frozen v1 layer covers whole-`Score` snapshots only, while binary_format's +migration table ratifies "a v0/v1 op block migrates on read". This is +ACCEPTABLE TODAY because (a) no production corpus exists (local repo, test +bundles only), and (b) no code path decodes op-envelope bytes back to values +(the bundle treats block bytes opaquely; reduction runs on in-memory +envelopes; the opindex reads only the leading id). The moment a consumer +byte-reconstructs op payloads (bundle replay of foreign documents, the P2 +ops-decoder fuzzer, MusicXML round-trip tooling), it MUST bring the +op-payload migrate-on-read primitive with it — per-type frozen v1 payload +decoders keyed by the block's stamped major. Tracked as the standing Phase-C +remainder in the Push-2 plan. diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index afd8d05..e80eb6c 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -494,6 +494,8 @@ mod tests { offset: epiphany_core::AnchorOffset::Zero, }, staves: vec![StaffId::new(ReplicaId(3), 0)], + kind: Default::default(), + style: Default::default(), }; let e = env(primitive(OperationKind::CreateCrossCutting( CreateCrossCuttingOp { diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index e97df90..5966400 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -80,9 +80,8 @@ impl OperationPayload { } /// The binary-format schema major this payload's canonical encoding requires - /// ([`OperationKind::schema_major`]). Only a primitive `CreateRegion` is - /// major 1; the meta-operations embed no schema-major-1 value, so they are - /// major 0. + /// ([`OperationKind::schema_major`]). The meta-operations embed no + /// data-model value, so they are major 0. pub fn schema_major(&self) -> u16 { match self { OperationPayload::Primitive(kind) => kind.schema_major(), @@ -187,16 +186,46 @@ pub enum OperationKind { impl OperationKind { /// The binary-format schema major this kind's canonical payload encodes at - /// (Binary Format companion §"Schema Major 1"). `CreateRegion` embeds a - /// [`Region`], which grew `permits_spanning_slurs` at schema major 1, so its - /// payload is major 1; every other kind's payload is unchanged from major 0. + /// (Binary Format companion §"Schema Major 1" / §"Schema Major 2"). /// - /// An op-envelope block's schema major is the maximum over the operations it - /// carries: a block bearing any `CreateRegion` is stamped major 1, and a - /// major-0-only reader opens such a bundle read-only. + /// An op-envelope block's schema major is the maximum over the operations + /// it carries. **Minimal stamping** (Binary Format §Schema Major 2): the + /// stamp is the *lowest* major whose layouts decode the payload's bytes — + /// a pure function of the value, so identical content stamps (and hashes) + /// identically. The kinds whose v2 fills are mandatory appended fields + /// are always major 2; the kinds whose only v2 embedding hides behind an + /// `Option` (encoding byte-identically to the prior major when `None`) + /// are value-dependent. pub fn schema_major(&self) -> u16 { match self { - OperationKind::CreateRegion(_) => 1, + // Mandatory v2 appends: CrossCuttingValue (Slur/Tie/Beam/Spanner + // bodies), Staff (default_clef + filled line config), and + // ScoreMetadata (six appended fields). + OperationKind::CreateCrossCutting(_) + | OperationKind::ModifyCrossCutting(_) + | OperationKind::CreateStaff(_) + | OperationKind::SetMetadata(_) => 2, + // Value-dependent: the embedded StaffLineConfiguration rides an + // Option; None encodes byte-identically to the prior major. + OperationKind::CreateRegion(op) => { + if op + .region + .content + .staff_instances() + .iter() + .any(|si| si.staff_lines_override.is_some()) + { + 2 + } else { + 1 + } + } + OperationKind::CreateStaffInstance(op) + if op.instance.staff_lines_override.is_some() => + { + 2 + } + OperationKind::SetStaffLayout(op) if op.staff_lines_override.is_some() => 2, _ => 0, } } diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 754211d..80c4000 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -7848,6 +7848,110 @@ mod tests { ); } + #[test] + fn schema_majors_follow_the_minimal_stamping_rule() { + // Binary Format §Schema Major 2, "Minimal stamping": the stamp is the + // lowest major whose layouts decode the payload's bytes — a pure + // function of the value. Mandatory-append kinds are always 2; the + // Option-hidden embeddings are value-dependent; everything else keeps + // its prior major. + use crate::payload::{ + CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CrossCuttingValue, + SetStaffLayoutOp, + }; + use epiphany_core::{RegionId, SlurId, StaffInstanceId}; + + let slur = crate::valuegen::slur( + SlurId::new(ReplicaId(9), 1), + EventId::new(ReplicaId(9), 1), + EventId::new(ReplicaId(9), 2), + ); + assert_eq!( + OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingValue::Slur(slur), + }) + .schema_major(), + 2, + "mandatory v2 appends stamp 2" + ); + + let region = crate::valuegen::region(RegionId::new(ReplicaId(9), 3)); + assert_eq!( + OperationKind::CreateRegion(CreateRegionOp { + region: region.clone() + }) + .schema_major(), + 1, + "an empty-instance CreateRegion keeps its v1 stamp" + ); + + let iid = StaffInstanceId::new(ReplicaId(9), 4); + let mut instance = crate::valuegen::staff_instance(iid, StaffId::new(ReplicaId(9), 5)); + assert_eq!( + OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region: region.id, + instance: instance.clone(), + }) + .schema_major(), + 0, + "a None-override instance encodes byte-identically at v0" + ); + instance.staff_lines_override = Some(epiphany_core::StaffLineConfiguration::default()); + assert_eq!( + OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region: region.id, + instance, + }) + .schema_major(), + 2, + "a Some-override instance bears the v2 StaffLineConfiguration" + ); + + assert_eq!( + OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: iid, + instrument_override: None, + staff_lines_override: None, + visible: true, + }) + .schema_major(), + 0 + ); + assert_eq!( + OperationKind::SetStaffLayout(SetStaffLayoutOp { + staff_instance: iid, + instrument_override: None, + staff_lines_override: Some(epiphany_core::StaffLineConfiguration::default()), + visible: true, + }) + .schema_major(), + 2 + ); + } + + #[test] + fn the_canonical_base_is_byte_identical_across_data_model_majors() { + // Binary Format §Schema Major 1 / §Schema Major 2: the canonical-base + // MaterializedState embeds none of the data-model-major values, so its + // bytes MUST NOT move across those bumps. This golden-locks a seeded + // reduction's canonical bytes; if it fails after a data-model change, + // a filled type has leaked into the canonical base — which the majors + // promise not to do. (A deliberate change to the base's own vocabulary + // — an appended discriminant the seeded corpus emits — re-pins this + // consciously.) + let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0xBA5E); + let envelopes = crate::fuzz::gen_envelope_set(&mut rng, 200); + let mut set = OperationSet::new(); + set.accept_all(envelopes); + let bytes = set.reduce().canonical_bytes(); + let digest = epiphany_determinism::blake3_256(&bytes); + let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!( + hex, + "65ad7ce56c6e8f37fbbbdab7dca8654507b3c952b0895673b453944623e42070" + ); + } + #[test] fn transpose_skips_system_derived_targets_p12_k3() { // Review finding on P12-K3: Transpose must not rewrite a diff --git a/crates/epiphany-ops/src/valuegen.rs b/crates/epiphany-ops/src/valuegen.rs index 898b933..1c7c911 100644 --- a/crates/epiphany-ops/src/valuegen.rs +++ b/crates/epiphany-ops/src/valuegen.rs @@ -157,6 +157,9 @@ pub fn slur(id: SlurId, start: EventId, end: EventId) -> Slur { id, start_event: start, end_event: end, + kind: Default::default(), + curvature_override: None, + style: Default::default(), } } @@ -168,6 +171,7 @@ pub fn tie(id: TieId, start: EventId, end: EventId) -> Tie { end_event: end, pitch_pairing: None, class: TieClass::LaissezVibrer, + style: Default::default(), } } @@ -177,6 +181,8 @@ pub fn beam(id: BeamId, events: Vec) -> Beam { id, events, level: 1, + sub_beams: Vec::new(), + geometry_override: None, } } @@ -278,6 +284,12 @@ pub fn score_metadata(nth: u8) -> epiphany_core::ScoreMetadata { title: Some(format!("title-{nth}")), composer: Some("composer".to_string()), copyright: None, + subtitle: None, + lyricist: None, + arranger: None, + creation_timestamp: Default::default(), + modification_timestamp: Default::default(), + additional: Vec::new(), } } @@ -300,6 +312,7 @@ pub fn staff(id: StaffId, instrument: epiphany_core::InstrumentId) -> epiphany_c instrument, default_staff_lines: epiphany_core::StaffLineConfiguration::default(), group: None, + default_clef: epiphany_core::Clef::treble(), } } diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index d52d150..cacc6ff 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -726,6 +726,8 @@ fn spanner_over(id: epiphany_core::SpannerId, a: EventId, b: EventId) -> epiphan offset: AnchorOffset::Zero, }, staves: Vec::new(), + kind: Default::default(), + style: Default::default(), } } @@ -3124,7 +3126,12 @@ fn set_staff_layout_is_an_advisory_lww_with_tombstone_noop() { prim(OperationKind::SetStaffLayout(SetStaffLayoutOp { staff_instance: instance, instrument_override: None, - staff_lines_override: Some(epiphany_core::StaffLineConfiguration { line_count: 1 }), + staff_lines_override: Some(epiphany_core::StaffLineConfiguration { + line_count: 1, + line_spacing: epiphany_core::SpaceUnit::normal(), + line_style: Default::default(), + bracket: None, + }), visible: false, })), ); @@ -3148,7 +3155,12 @@ fn set_staff_layout_is_an_advisory_lww_with_tombstone_noop() { assert_eq!(materialized.instrument_override, None); assert_eq!( materialized.staff_lines_override, - Some(epiphany_core::StaffLineConfiguration { line_count: 1 }) + Some(epiphany_core::StaffLineConfiguration { + line_count: 1, + line_spacing: epiphany_core::SpaceUnit::normal(), + line_style: Default::default(), + bracket: None, + }) ); assert!(!materialized.visible); assert!(check_invariants(&result.score).is_empty()); diff --git a/crates/epiphany-testkit/src/corpus.rs b/crates/epiphany-testkit/src/corpus.rs index 5c064b4..c8ed2f6 100644 --- a/crates/epiphany-testkit/src/corpus.rs +++ b/crates/epiphany-testkit/src/corpus.rs @@ -28,14 +28,14 @@ use epiphany_core::{ check_invariants, derive_annotations, AleatoricAnchoringDiscipline, AleatoricTimeModel, Canvas, CmnNominal, CueEvent, CueRendering, DerivedAnnotations, Event, EventArena, EventDuration, EventPosition, GraceKind, GraphicEvent, IdentifiedPitch, IdentityContext, IndeterminacyHints, - IndeterminacyKind, IndeterminateEvent, Instrument, MetricTimeModel, MusicalDuration, - MusicalPosition, PitchSpelling, PowerOfTwo, PrePassProfile, ProportionalTimeModel, Region, - RegionContent, RegionTimeModel, Score, SpellingAttachment, SpellingDirective, SpellingNominal, - SpellingScope, SpellingSource, Staff, StaffBasedContent, StaffExtent, StaffInstance, - StaffLineConfiguration, StaffPosition, StemConfiguration, TaxonomyReport, TimeAnchor, - TimeExtent, TimeSignature, TimeSignatureDisplay, TrajectoryDisplay, TrajectoryEndpoint, - TrajectoryEvent, TrajectoryShape, Tuplet, TupletRatio, UnpitchedEvent, UnpitchedMemberId, - Voice, WallClockDuration, WallClockTime, + IndeterminacyKind, IndeterminateEvent, MetricTimeModel, MusicalDuration, MusicalPosition, + PitchSpelling, PowerOfTwo, PrePassProfile, ProportionalTimeModel, Region, RegionContent, + RegionTimeModel, Score, SpellingAttachment, SpellingDirective, SpellingNominal, SpellingScope, + SpellingSource, Staff, StaffBasedContent, StaffExtent, StaffInstance, StaffLineConfiguration, + StaffPosition, StemConfiguration, TaxonomyReport, TimeAnchor, TimeExtent, TimeSignature, + TimeSignatureDisplay, TrajectoryDisplay, TrajectoryEndpoint, TrajectoryEvent, TrajectoryShape, + Tuplet, TupletRatio, UnpitchedEvent, UnpitchedMemberId, Voice, WallClockDuration, + WallClockTime, }; use epiphany_core::{ AccidentalId, AcousticPitch, AcousticRealization, BeatGroup, EventId, InstrumentId, Pitch, @@ -507,12 +507,12 @@ impl OneStaff { instrument, default_staff_lines: StaffLineConfiguration::default(), group: None, + default_clef: epiphany_core::Clef::treble(), }]; - score.instruments = vec![Instrument { - id: instrument, - name: String::from("F-corpus"), - range: None, - }]; + score.instruments = vec![epiphany_core::Instrument::new( + instrument, + String::from("F-corpus"), + )]; score.events = arena; score.cross_cutting = cross_cutting; score.spelling_attachments = spelling_attachments; diff --git a/crates/epiphany-testkit/src/fixtures.rs b/crates/epiphany-testkit/src/fixtures.rs index 9d8d06c..4570c42 100644 --- a/crates/epiphany-testkit/src/fixtures.rs +++ b/crates/epiphany-testkit/src/fixtures.rs @@ -12,11 +12,11 @@ use epiphany_core::{ AcousticPitch, AcousticRealization, AnchorOffset, Canvas, ChordSymbol, CmnNominal, CrossCuttingRegistry, Event, EventArena, EventDuration, EventPosition, IdentifiedPitch, - IdentityContext, Instrument, Marker, Measure, MetricTimeModel, MusicalDuration, - MusicalPosition, Pitch, PitchSpaceId, PitchSpacePosition, RationalTime, RegionContent, - RegionEdge, RegionTimeModel, ScalePosition, Score, Spanner, Staff, StaffBasedContent, - StaffExtent, StaffInstance, StaffLineConfiguration, StemConfiguration, Tie, TieClass, - TimeAnchor, TimeExtent, TuningReference, Voice, WallClockTime, + IdentityContext, Marker, Measure, MetricTimeModel, MusicalDuration, MusicalPosition, Pitch, + PitchSpaceId, PitchSpacePosition, RationalTime, RegionContent, RegionEdge, RegionTimeModel, + ScalePosition, Score, Spanner, Staff, StaffBasedContent, StaffExtent, StaffInstance, + StaffLineConfiguration, StemConfiguration, Tie, TieClass, TimeAnchor, TimeExtent, + TuningReference, Voice, WallClockTime, }; use epiphany_core::{ ChordSymbolId, EventId, InstrumentId, MarkerId, MeasureId, PitchId, RegionId, ReplicaId, @@ -115,6 +115,7 @@ pub fn ten_measure_single_staff(seed: u64) -> Score { end_event: events[1], pitch_pairing: None, class: TieClass::Standard, + style: Default::default(), }); cross_cutting.spanners.push(Spanner { id: idc.mint::(), @@ -127,6 +128,8 @@ pub fn ten_measure_single_staff(seed: u64) -> Score { time: WallClockTime(10), }, staves: vec![staff_id], + kind: Default::default(), + style: Default::default(), }); cross_cutting.markers.push(Marker { id: idc.mint::(), @@ -171,11 +174,10 @@ pub fn ten_measure_single_staff(seed: u64) -> Score { let mut score = Score::empty(idc.clone()); score.identity = idc; - score.instruments = vec![Instrument { - id: instrument, - name: String::from("Flute"), - range: None, - }]; + score.instruments = vec![epiphany_core::Instrument::new( + instrument, + String::from("Flute"), + )]; score.staves = vec![Staff { id: staff_id, name: String::from("Flute"), @@ -183,6 +185,7 @@ pub fn ten_measure_single_staff(seed: u64) -> Score { instrument, default_staff_lines: StaffLineConfiguration::default(), group: None, + default_clef: epiphany_core::Clef::treble(), }]; score.events = arena; score.cross_cutting = cross_cutting; diff --git a/crates/epiphany-testkit/src/roundtrip.rs b/crates/epiphany-testkit/src/roundtrip.rs index a5fd9bc..8ebc5a2 100644 --- a/crates/epiphany-testkit/src/roundtrip.rs +++ b/crates/epiphany-testkit/src/roundtrip.rs @@ -626,15 +626,62 @@ mod tests { assert_eq!(blocks, vec![env.to_canonical_bytes()]); } + #[test] + fn cross_cutting_op_block_is_stamped_major_2_and_reopens_read_write() { + // Schema major 2 (minimal stamping): a CreateCrossCutting payload's + // v2 fills are mandatory appended fields, so the kind is always + // major 2; the writer derives the block stamp from its operations. + use epiphany_core::{OperationId, ReplicaId, SlurId, WallClockTime}; + use epiphany_ops::{ + AuthorId, CausalContext, CreateCrossCuttingOp, CrossCuttingValue, HybridLogicalClock, + OperationKind, OperationPayload, OperationStamp, + }; + let slur = epiphany_ops::valuegen::slur( + SlurId::new(ReplicaId(9), 5), + epiphany_core::EventId::new(ReplicaId(9), 100), + epiphany_core::EventId::new(ReplicaId(9), 101), + ); + let id = OperationId::new(ReplicaId(9), 2); + let env = OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(2), 0), id), + causal_context: CausalContext::new(), + transaction: None, + payload: OperationPayload::Primitive(OperationKind::CreateCrossCutting( + CreateCrossCuttingOp { + structure: CrossCuttingValue::Slur(slur), + }, + )), + }; + assert_eq!( + env.schema_major(), + 2, + "CreateCrossCutting encodes at schema major 2" + ); + let block = crate::bundle_harness::stage_operation_block(std::slice::from_ref(&env)); + let reopened = reopen_with_op_block(0xD2_0003, block); + // Major 2 is within the op-block accept-set [0,2]: read-write. + assert_eq!( + reopened.manifest().operation_roots[0].schema_version, + SchemaVersion::V2 + ); + assert!(!reopened.is_read_only()); + let blocks = reopened + .read_operation_block(&reopened.manifest().operation_roots[0]) + .expect("major-2 op block is admitted by the accept-set"); + assert_eq!(blocks, vec![env.to_canonical_bytes()]); + } + #[test] fn op_block_beyond_the_accept_set_opens_read_only() { use epiphany_bundle::IntegrityAnomaly; - // A newer writer's op block, stamped schema major 2 — beyond the reader's - // op-block accept-set [0,1]. The bundle opens read-only preservation (the + // A newer writer's op block, stamped schema major 3 — beyond the reader's + // op-block accept-set [0,2]. The bundle opens read-only preservation (the // canonical base and manifest still read) rather than hard-rejecting. let block = StagedChunk::operation_block_versioned( encode_block(&[vec![1u8, 2, 3, 4]]), - SchemaVersion::new(2, 0), + SchemaVersion::new(3, 0), ); let reopened = reopen_with_op_block(0xD2_0002, block); assert!( @@ -643,7 +690,7 @@ mod tests { ); assert!(reopened.anomalies().iter().any(|a| matches!( a, - IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 2 } + IntegrityAnomaly::UnsupportedCanonicalChunkMajor { schema_major: 3 } ))); }