Schema major 1 Phase C: Canvas.layout_defaults + real migrate-on-read

The first real data-model field of the schema-major-1 bump, and the point
where the dispatch seam flips from the Phase-B identity to a genuine
migration. Zero golden churn: CanvasLayoutDefaults::default() equals the
engraver's existing A4/8mm default, so no resolved geometry moves.

- Data model (core/graph.rs): Canvas gains `layout_defaults:
  CanvasLayoutDefaults`, with new core geometry primitives CanvasSize /
  CanvasMargins (staff-space CanonicalF64, A4/8mm Default -- core has no
  geometry types of its own and must not depend on layout-ir). Exported from
  the crate root; ~7 Canvas construction sites updated with
  `..Default::default()`.
- Codec (core/codec.rs): struct_codec! for the three new types; Canvas v1
  encodes `regions` then `layout_defaults`.
- The frozen migrate-on-read (decode_v0_score): a byte-level splice. v0 Score
  bytes are the v1 layout minus Canvas.layout_defaults; Canvas is Score field 2
  and its v0 layout was just `regions`. Read the v0 prefix (metadata,
  canvas.regions) to find the split, insert the default CanvasLayoutDefaults
  encoding, then decode the resulting v1 bytes. Total and default-filling (no
  score context), frozen by value. A `pos()` accessor was added to Reader for
  the splice.
- Test (v0_score_migrates_by_default_filling_layout_defaults): derives REAL v0
  bytes by stripping the field from a v1 encoding, then migrates them back and
  checks the original score is reconstructed with the default refilled. A wrong
  splice offset corrupts the bytes and fails the decode, so it guards the
  frozen v0 assumptions.

Deferred: routing the migrate through a bundle acceleration_snapshots slot (a
Phase-B Finding-3 item). The migrate logic is proven by the core unit test and
the bundle read path by the canonical-base roundtrip; combining them hits
cross-crate friction (constructing v0 bytes needs core-internal Reader, which
testkit can't reach and core-below-bundle can't stage) for marginal coverage.
The forward scenario (v1 reader migrating a v0 bundle's major-0 acceleration
snapshot) needs no new bundle code -- the v0 snapshot passes the exact gate.

863 workspace tests pass; clippy -D warnings, fmt --check, rustdoc -D warnings
all clean; no render/snapshot goldens changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
Levi Neuwirth 2026-07-05 20:37:36 -04:00
parent 9f688cc592
commit e199fabe3f
8 changed files with 158 additions and 59 deletions

View File

@ -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<Score> {
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> {
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<Region> = 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<Region> = 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());
}

View File

@ -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
}

View File

@ -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<Region>,
/// 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). ---------

View File

@ -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::{

View File

@ -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());

View File

@ -107,6 +107,7 @@ fn hand_built_score() -> Score {
score.events = arena;
score.canvas = Canvas {
regions: vec![region],
..Default::default()
};
score
}

View File

@ -517,6 +517,7 @@ impl OneStaff {
score.time_signatures = time_signatures;
score.canvas = Canvas {
regions: vec![region_obj],
..Default::default()
};
score
}

View File

@ -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
}