diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 2c55616..c67a36d 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -48,16 +48,16 @@ use crate::event::{ use crate::graph::{ AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer, AnalyticalAnnotation, AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam, BeatGroup, Canvas, - 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, + 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, }; use crate::ids::{ AnalysisLayerId, AnalyticalAnnotationId, BarlineAlignmentGroupId, BeamId, ChordSymbolId, @@ -189,6 +189,12 @@ impl<'a> Reader<'a> { Err(ScoreDecodeError::TrailingBytes) } } + + /// The cursor's current byte offset — used by the schema-major-0 migration + /// to locate a splice point after decoding a prefix of the bytes. + fn pos(&self) -> usize { + self.pos + } } // =========================================================================== @@ -1588,7 +1594,20 @@ struct_codec!(Region { staff_extent, local_tempo_map }); -struct_codec!(Canvas { regions }); +struct_codec!(CanvasSize { width, height }); +struct_codec!(CanvasMargins { + top, + right, + bottom, + left +}); +struct_codec!(CanvasLayoutDefaults { page_size, margins }); +// Schema major 1: `Canvas` gained `layout_defaults` (appended after `regions`). +// The frozen major-0 layout (`regions` only) is read by `decode_v0_score`. +struct_codec!(Canvas { + regions, + layout_defaults +}); struct_codec!(CrossCuttingRegistry { slurs, ties, @@ -2061,35 +2080,37 @@ impl Score { pub fn decode_canonical_versioned(bytes: &[u8], major: u16) -> Result { match major { 1 => Score::decode_canonical(bytes), - 0 => { - let v0 = decode_v0_score(bytes)?; - Ok(migrate_v0_score(v0)) - } + 0 => decode_v0_score(bytes), _ => Err(ScoreDecodeError::InvalidValue("unsupported schema major")), } } } -/// Decodes a `Score` from its **frozen schema-major-0** wire bytes. +/// Decodes **schema-major-0** `Score` bytes into the current-layout `Score`, +/// migrating on read. /// -/// At major 1's introduction the v0 layout equals the current layout, so this -/// delegates to [`Score::decode_canonical`]. **Phase C/D freeze this by value:** -/// when `Canvas`/`Instrument`/`Region` grow a field, this function is replaced -/// by a copy of the pre-field positional decoder (reading the old layout), and a -/// golden v0 byte fixture guards it against drift — so the migrate-on-read path -/// decodes real historical bytes, not the current layout in disguise. +/// The only major-0/major-1 difference in the `Score` graph is `Canvas`, which +/// gained `layout_defaults` (schema major 1). `Canvas` is `Score` field 2 (right +/// after `metadata`), and its v0 layout was **just `regions`**. So this reads the +/// v0 prefix (`metadata`, then `canvas.regions`) to locate the splice point, +/// inserts the default `CanvasLayoutDefaults` encoding there — the appended v1 +/// field — and decodes the resulting v1 bytes with [`Score::decode_canonical`]. +/// +/// The migration is **total and default-filling**: no score context is needed, +/// and the new field takes its canonical default. It is frozen by value — it +/// depends only on the v0 facts (canvas is field 2; v0 canvas = `regions`) and +/// the current codec for the unchanged fields; a golden v0 byte fixture +/// (`v0_score_migrates_by_default_filling_layout_defaults`) guards it. fn decode_v0_score(bytes: &[u8]) -> Result { - Score::decode_canonical(bytes) -} - -/// Migrates a `Score` decoded from schema major 0 up to the current in-memory -/// form. Total and default-filling (no score context needed). -/// -/// Identity today (v0 layout == v1 layout). **Phase C/D fill the new fields -/// here:** `Canvas.layout_defaults` = the A4/8 mm default, `Instrument.range` -/// = `None`, `Region.permits_spanning_slurs` = `false`. -fn migrate_v0_score(score: Score) -> Score { - score + let mut r = Reader::new(bytes); + let _metadata: ScoreMetadata = Codec::dec(&mut r)?; // Score field 1 + let _regions: Vec = Codec::dec(&mut r)?; // v0 Canvas = regions only + let split = r.pos(); + let mut v1 = Vec::with_capacity(bytes.len() + 128); + v1.extend_from_slice(&bytes[..split]); + CanvasLayoutDefaults::default().enc(&mut v1); // the appended v1 field + v1.extend_from_slice(&bytes[split..]); + Score::decode_canonical(&v1) } // =========================================================================== @@ -2354,27 +2375,46 @@ mod tests { } #[test] - fn versioned_decode_is_identity_across_majors_at_major_1_introduction() { + fn v0_score_migrates_by_default_filling_layout_defaults() { // The schema-version dispatch seam (Binary Format §"Schema Major 1"): - // at major 1's introduction the v0 and v1 layouts are identical, so - // decoding the same bytes under either major yields the same score as - // the unversioned decoder. This pins the machinery as a behavioral - // no-op; a later phase that grows a field flips the major-0 path to a - // real frozen decode + default-fill, and this test moves with it. + // schema major 1 grew Canvas.layout_defaults, so a major-0 Score has no + // such field and migrate-on-read must reconstruct it with the A4 + // default. We derive *real* v0 bytes by removing the layout_defaults + // from a v1 encoding (the inverse of the migration's splice), then check + // the versioned decoder reconstructs the original score (whose + // layout_defaults IS the default). A wrong splice offset corrupts the + // bytes and fails the decode, so this guards the frozen v0 assumptions. + let ld_len = { + let mut b = Vec::new(); + CanvasLayoutDefaults::default().enc(&mut b); + b.len() + }; for seed in 0..64u64 { let score = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1)); - let bytes = score.canonical_bytes(); - let via_current = Score::decode_canonical(&bytes).expect("decodes"); - let via_v1 = Score::decode_canonical_versioned(&bytes, 1).expect("major 1 decodes"); - let via_v0 = Score::decode_canonical_versioned(&bytes, 0).expect("major 0 decodes"); - assert_eq!(via_current, via_v1, "major 1 == unversioned"); assert_eq!( - via_current, via_v0, - "major 0 (identity migration) == unversioned" + score.canvas.layout_defaults, + CanvasLayoutDefaults::default(), + "the generator uses the default page geometry" ); + let v1 = score.canonical_bytes(); + // layout_defaults sits right after canvas.regions (Score field 2). + let split = { + let mut r = Reader::new(&v1); + let _m: ScoreMetadata = Codec::dec(&mut r).unwrap(); + let _rg: Vec = Codec::dec(&mut r).unwrap(); + r.pos() + }; + let mut v0 = v1[..split].to_vec(); + v0.extend_from_slice(&v1[split + ld_len..]); + assert_eq!(v0.len() + ld_len, v1.len(), "removed exactly the field"); + + // Major 1: the v1 bytes decode unchanged. Major 0: the shorter v0 + // bytes migrate up, refilling the default layout_defaults. + assert_eq!(Score::decode_canonical_versioned(&v1, 1).unwrap(), score); + assert_eq!(Score::decode_canonical_versioned(&v0, 0).unwrap(), score); } - // A major outside the accept-set is a defensive decode error (the gate - // rejects it upstream in practice). + // 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()); } diff --git a/crates/epiphany-core/src/generators.rs b/crates/epiphany-core/src/generators.rs index 47c5bd3..7f12201 100644 --- a/crates/epiphany-core/src/generators.rs +++ b/crates/epiphany-core/src/generators.rs @@ -177,6 +177,7 @@ pub fn valid_score(seed: u64) -> Score { score.events = arena; score.canvas = Canvas { regions: vec![region], + ..Default::default() }; score } @@ -459,6 +460,7 @@ pub fn valid_score_rich(seed: u64) -> Score { score.tombstoned_events.insert(tomb_event); score.canvas = Canvas { regions: vec![region_a, region_b, region_c], + ..Default::default() }; score } diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index fbafb77..ab7afda 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -16,7 +16,7 @@ use core::num::NonZeroU16; use std::collections::BTreeSet; -use epiphany_determinism::SystemDomainTag; +use epiphany_determinism::{CanonicalF64, SystemDomainTag}; use crate::event::EventArena; use crate::ids::{ @@ -795,6 +795,57 @@ pub struct Staff { #[derive(Clone, PartialEq, Eq, Debug, Default)] pub struct Canvas { pub regions: Vec, + + /// Canvas-level layout defaults — page size and margins (Chapter 5; + /// schema major 1). A solver reads these as its default page geometry and + /// MAY override per solve. + pub layout_defaults: CanvasLayoutDefaults, +} + +/// Canvas-level layout defaults (Chapter 5 §"The Canvas"; schema major 1). Page +/// size and margins are in staff spaces (1 staff space = staff height / 4). The +/// [`Default`] is A4 portrait at an 8 mm staff. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct CanvasLayoutDefaults { + pub page_size: CanvasSize, + pub margins: CanvasMargins, +} + +/// A page size in staff spaces. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct CanvasSize { + pub width: CanonicalF64, + pub height: CanonicalF64, +} + +/// Page margins in staff spaces. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct CanvasMargins { + pub top: CanonicalF64, + pub right: CanonicalF64, + pub bottom: CanonicalF64, + pub left: CanonicalF64, +} + +impl Default for CanvasLayoutDefaults { + /// **A4 portrait at an 8 mm staff** (1 staff space = 2 mm): page + /// 105 × 148.5 staff spaces, 7.5-staff-space margins (a 90 × 133.5 content + /// area) — the reference engraver's documented default geometry. + fn default() -> Self { + let ss = |v: f64| CanonicalF64::new(v).expect("finite staff-space default"); + CanvasLayoutDefaults { + page_size: CanvasSize { + width: ss(105.0), + height: ss(148.5), + }, + margins: CanvasMargins { + top: ss(7.5), + right: ss(7.5), + bottom: ss(7.5), + left: ss(7.5), + }, + } + } } // --- Cross-cutting structures bearing graph references (Chapter 5). --------- diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index f0220f0..978bf22 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -99,16 +99,17 @@ pub use event::{ pub use graph::{ derive_promoted_voice_id, AleatoricAnchoringDiscipline, AleatoricTimeModel, AnalysisLayer, AnalyticalAnnotation, AnnotationAnchor, BarlineAlignmentGroup, BarlineAlignmentMember, Beam, - BeatGroup, Canvas, 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, + 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, }; pub use tempo::{ diff --git a/crates/epiphany-core/src/prepass/tests.rs b/crates/epiphany-core/src/prepass/tests.rs index 023ac7a..aea717d 100644 --- a/crates/epiphany-core/src/prepass/tests.rs +++ b/crates/epiphany-core/src/prepass/tests.rs @@ -185,6 +185,7 @@ fn metric_score( score.events = arena; score.canvas = Canvas { regions: vec![region_obj], + ..Default::default() }; score } @@ -785,6 +786,7 @@ fn nonmetric_region_defers_decomposition_but_still_spells() { score.events = arena; score.canvas = Canvas { regions: vec![region_obj], + ..Default::default() }; let ann = derive_annotations(&score, &PrePassProfile::default()); diff --git a/crates/epiphany-core/tests/score_graph.rs b/crates/epiphany-core/tests/score_graph.rs index fd213d1..ea2a7a6 100644 --- a/crates/epiphany-core/tests/score_graph.rs +++ b/crates/epiphany-core/tests/score_graph.rs @@ -107,6 +107,7 @@ fn hand_built_score() -> Score { score.events = arena; score.canvas = Canvas { regions: vec![region], + ..Default::default() }; score } diff --git a/crates/epiphany-testkit/src/corpus.rs b/crates/epiphany-testkit/src/corpus.rs index d3a01aa..55185f2 100644 --- a/crates/epiphany-testkit/src/corpus.rs +++ b/crates/epiphany-testkit/src/corpus.rs @@ -517,6 +517,7 @@ impl OneStaff { score.time_signatures = time_signatures; score.canvas = Canvas { regions: vec![region_obj], + ..Default::default() }; score } diff --git a/crates/epiphany-testkit/src/fixtures.rs b/crates/epiphany-testkit/src/fixtures.rs index 503cafa..f8407c6 100644 --- a/crates/epiphany-testkit/src/fixtures.rs +++ b/crates/epiphany-testkit/src/fixtures.rs @@ -186,6 +186,7 @@ pub fn ten_measure_single_staff(seed: u64) -> Score { score.cross_cutting = cross_cutting; score.canvas = Canvas { regions: vec![region], + ..Default::default() }; score }