diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index c67a36d..76ece5e 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -68,11 +68,11 @@ use crate::ids::{ }; use crate::pitch::{ AccidentalId, AcousticPitch, AcousticRealization, CmnNominal, ForeignFormatId, IdentifiedPitch, - NominalRegistryId, Pitch, PitchSpaceId, PitchSpacePosition, PitchSpelling, PositionRegistryId, - ReferencePitch, ScalePosition, SpellingAttachment, SpellingDirective, SpellingNominal, - SpellingPrecedence, SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope, - SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId, - TuningReference, TuningSystemId, VoiceSelector, + NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition, PitchSpelling, + PositionRegistryId, ReferencePitch, ScalePosition, SpellingAttachment, SpellingDirective, + SpellingNominal, SpellingPrecedence, SpellingRenderHints, SpellingRule, SpellingRuleSetId, + SpellingScope, SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, + TieClassRegistryId, TuningReference, TuningSystemId, VoiceSelector, }; use crate::tempo::{Tempo, TempoMap, TempoSegment, TempoShape}; use crate::time::{ @@ -189,12 +189,6 @@ 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 - } } // =========================================================================== @@ -950,6 +944,8 @@ struct_codec!(AcousticPitch { tuning, realization }); +// Schema major 1: `Instrument.range` embeds this (appended after `name`). +struct_codec!(PitchRange { lowest, highest }); struct_codec!(Pitch { scale_position, acoustic @@ -1432,7 +1428,9 @@ struct_codec!(ScoreMetadata { composer, copyright }); -struct_codec!(Instrument { id, name }); +// 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 }); struct_codec!(GraphicObject { id }); struct_codec!(GraphicContent { objects }); @@ -1586,13 +1584,19 @@ struct_codec!(StaffBasedContent { user_system_breaks, user_page_breaks }); +// Schema major 1: `Region` gained `permits_spanning_slurs` (appended after +// `local_tempo_map`). The frozen major-0 layout (the six fields before it) is +// read by `dec_region_v0`. `Region` is also a `CanonicalValue` embedded in the +// canonical `CreateRegion` op payload, so this byte change is canonical — the +// op-block migration for it is a separate change (schema-major track, D2). struct_codec!(Region { id, time_model, content, time_extent, staff_extent, - local_tempo_map + local_tempo_map, + permits_spanning_slurs }); struct_codec!(CanvasSize { width, height }); struct_codec!(CanvasMargins { @@ -2089,28 +2093,165 @@ impl Score { /// Decodes **schema-major-0** `Score` bytes into the current-layout `Score`, /// migrating on read. /// -/// 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`]. +/// 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: /// +/// * `Canvas` (field 2) — v0 was `regions` only; v1 appended `layout_defaults`. +/// [`dec_canvas_v0`] reads the region vector and default-fills the defaults. +/// * `Instrument` (inside field 3, `instruments: Vec`) — v0 was +/// `{ id, name }`; v1 appended `range`. [`dec_instruments_v0`] default-fills +/// `range: None`. +/// * `Region` (inside `Canvas.regions`) — v0 was the six fields before +/// `permits_spanning_slurs`; v1 appended it. [`dec_region_v0`] default-fills +/// `false`. +/// +/// A byte-splice sufficed while only `Canvas` (a single top-level field) had +/// changed, but two of the three grown structs are nested inside `Vec`s, so +/// there is no single splice point — the walk must reconstruct each element. /// 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. +/// and every new field takes its canonical default. It is frozen by value — it +/// depends only on the v0 field lists above and the current codec for unchanged +/// fields; the `v0_score_migrates_*` golden tests guard it (they synthesize real +/// v0 bytes via a mirror v0 encoder and check the migration reconstructs the +/// original score with the new fields at their defaults). fn decode_v0_score(bytes: &[u8]) -> Result { 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) + // 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)?; + let canvas = dec_canvas_v0(&mut r)?; + let instruments = dec_instruments_v0(&mut r)?; + let staves = Codec::dec(&mut r)?; + let staff_groups = Codec::dec(&mut r)?; + let parts = Codec::dec(&mut r)?; + let cross_cutting = Codec::dec(&mut r)?; + let time_signatures = Codec::dec(&mut r)?; + let tuning_context = 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()?; + Ok(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, + }) +} + +/// Frozen v0 decoder for `Canvas`: the v0 layout was **just `regions`** (a +/// `Vec` in v0 element form), with no `layout_defaults`. Reads the +/// region vector via [`dec_region_v0`] and default-fills the new field. +fn dec_canvas_v0(r: &mut Reader<'_>) -> Result { + let n = r.count()?; + let mut regions = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + regions.push(dec_region_v0(r)?); + } + Ok(Canvas { + regions, + layout_defaults: CanvasLayoutDefaults::default(), + }) +} + +/// Frozen v0 decoder for a `Region` element: the six fields before +/// `permits_spanning_slurs`, which is default-filled `false`. (The `Vec` +/// framing is the caller's; this reads one element.) +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 time_extent = Codec::dec(r)?; + let staff_extent = Codec::dec(r)?; + let local_tempo_map = Codec::dec(r)?; + Ok(Region { + id, + time_model, + content, + time_extent, + staff_extent, + local_tempo_map, + permits_spanning_slurs: false, + }) +} + +/// Frozen v0 encoder for a `Region` element — the inverse of [`dec_region_v0`]. +/// Emits the six fields before `permits_spanning_slurs`; the flag is **not** +/// written, so the bytes are byte-identical to schema major 0. +fn enc_region_v0(reg: &Region, out: &mut Vec) { + reg.id.enc(out); + reg.time_model.enc(out); + reg.content.enc(out); + reg.time_extent.enc(out); + reg.staff_extent.enc(out); + reg.local_tempo_map.enc(out); + // v0: permits_spanning_slurs is not carried. +} + +impl Region { + /// The **frozen schema-major-0** canonical bytes of this region: the six + /// fields before `permits_spanning_slurs`, which is *not* carried. + /// + /// The canonical `CreateRegion` operation payload embeds a region, and its + /// op-envelope block is stamped at schema major 0. To keep that block + /// byte-identical to schema major 0 — so a major-0 reader parses it and no + /// op-block migration is owed — the operation payload uses **this** surface, + /// not the full [`canonical_bytes`](CanonicalValue::canonical_bytes) (which + /// now carries `permits_spanning_slurs`, schema major 1). A region minted by + /// a `CreateRegion` therefore reduces with `permits_spanning_slurs = false` + /// regardless of the value passed — the only value any producer sets today. + /// + /// The op payload moves to the schema-major-1 encoding, and the op block to + /// major 1 with migrate-on-read, when the op-block schema-major machinery + /// lands (schema-major track, D2); the crate-private `dec_region_v0` is that + /// migration's frozen decoder. + pub fn canonical_bytes_v0(&self) -> Vec { + let mut out = Vec::new(); + enc_region_v0(self, &mut out); + out + } +} + +/// Frozen v0 decoder for `Score.instruments`: v0 `Instrument` was `{ id, name }` +/// (no `range`), which is default-filled `None`. Mirrors the `Vec` framing (a +/// `u32` count then each element). +fn dec_instruments_v0(r: &mut Reader<'_>) -> Result> { + let n = r.count()?; + let mut instruments = Vec::with_capacity(n.min(1024)); + for _ in 0..n { + let id = Codec::dec(r)?; + let name = Codec::dec(r)?; + instruments.push(Instrument { + id, + name, + range: None, + }); + } + Ok(instruments) } // =========================================================================== @@ -2374,16 +2515,113 @@ mod tests { } } + /// A frozen **v0 encoder** — the byte-exact inverse of the [`decode_v0_score`] + /// field walk, used only by the migration tests to synthesize *genuine* v0 + /// bytes (a score whose three schema-major-1 fields are absent). It mirrors + /// the v0 layout exactly: `Canvas` without `layout_defaults`, `Instrument` + /// without `range`, `Region` without `permits_spanning_slurs`; every other + /// field through the current `Codec`. A decoder/encoder that agreed on a + /// *wrong* layout would still round-trip, so the callers also anchor the v0 + /// byte length against the production v1 encoder (which this does not touch). + fn encode_v0_score(s: &Score) -> Vec { + // Reuses the production frozen v0 region encoder (super::enc_region_v0). + let mut out = Vec::new(); + s.metadata.enc(&mut out); + // Canvas v0: `regions` only (no `layout_defaults`). + put_len(&mut out, s.canvas.regions.len()); + for reg in &s.canvas.regions { + enc_region_v0(reg, &mut out); + } + // Instruments v0: `{ id, name }` only (no `range`). + put_len(&mut out, s.instruments.len()); + for inst in &s.instruments { + inst.id.enc(&mut out); + inst.name.enc(&mut out); + } + // Fields 4..19 are unchanged between v0 and v1. + s.staves.enc(&mut out); + s.staff_groups.enc(&mut out); + s.parts.enc(&mut out); + s.cross_cutting.enc(&mut out); + s.time_signatures.enc(&mut out); + 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 + } + + /// A C-in-`cmn-12` pitch at the given octave, for a non-default + /// [`PitchRange`] (the generators never populate `Instrument.range`). + fn cmn_c(octave: i8) -> Pitch { + Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("cmn-12"), + position: PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave, + }, + }, + acoustic: AcousticPitch { + tuning: crate::pitch::TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + } + } + #[test] - fn v0_score_migrates_by_default_filling_layout_defaults() { - // The schema-version dispatch seam (Binary Format §"Schema Major 1"): - // 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. + fn v1_round_trips_non_default_values_for_every_new_field() { + // The three schema-major-1 fields must survive a v1 round-trip at + // *non-default* values — not just vacuously when they equal the default + // (which is all the generators produce). This is what makes them real + // wire content rather than always-default padding. + let mut score = valid_score(5); + assert!(!score.instruments.is_empty() && !score.canvas.regions.is_empty()); + + let ss = |v: f64| CanonicalF64::new(v).expect("finite"); + let custom = CanvasLayoutDefaults { + // US Letter (216 × 279.4 mm) in staff spaces, distinct margins. + page_size: CanvasSize { + width: ss(216.0), + height: ss(279.4), + }, + margins: CanvasMargins { + top: ss(9.0), + right: ss(10.0), + bottom: ss(11.0), + left: ss(12.0), + }, + }; + assert_ne!(custom, CanvasLayoutDefaults::default()); + score.canvas.layout_defaults = custom; + score.canvas.regions[0].permits_spanning_slurs = true; + score.instruments[0].range = Some(PitchRange { + lowest: cmn_c(2), + highest: cmn_c(6), + }); + + let bytes = score.canonical_bytes(); + assert_eq!(Score::decode_canonical(&bytes).unwrap(), score); + assert_eq!(Score::decode_canonical_versioned(&bytes, 1).unwrap(), score); + } + + #[test] + fn v0_score_migrates_default_filling_all_three_new_fields() { + // Schema major 1 grew three fields — Canvas.layout_defaults, + // Instrument.range, Region.permits_spanning_slurs — so a major-0 score + // has none of them, and migrate-on-read must reconstruct each with its + // canonical default. We synthesize *genuine* v0 bytes via a mirror v0 + // encoder (the byte-exact inverse of decode_v0_score) and check the + // versioned decoder rebuilds the original score, whose three fields ARE + // the defaults. let ld_len = { let mut b = Vec::new(); CanvasLayoutDefaults::default().enc(&mut b); @@ -2391,33 +2629,69 @@ mod tests { }; for seed in 0..64u64 { let score = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1)); + // Precondition: the generator produces the v0-equivalent defaults, so + // the original IS what a v0→v1 migration should reconstruct. assert_eq!( score.canvas.layout_defaults, - CanvasLayoutDefaults::default(), - "the generator uses the default page geometry" + CanvasLayoutDefaults::default() ); - 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"); + assert!(score.instruments.iter().all(|i| i.range.is_none())); + assert!(score + .canvas + .regions + .iter() + .all(|r| !r.permits_spanning_slurs)); - // Major 1: the v1 bytes decode unchanged. Major 0: the shorter v0 - // bytes migrate up, refilling the default layout_defaults. + let v1 = score.canonical_bytes(); + 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, + // plus one byte each for every instrument's range=None (Option tag) + // and every region's permits_spanning_slurs=false (bool). + let expected_removed = ld_len + score.instruments.len() + score.canvas.regions.len(); + assert_eq!( + v1.len() - v0.len(), + expected_removed, + "v0 omits exactly the three new fields' default bytes" + ); + + // Major 0: the shorter v0 bytes migrate up, default-filling all three. + 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. assert_eq!(Score::decode_canonical_versioned(&v1, 1).unwrap(), score); - assert_eq!(Score::decode_canonical_versioned(&v0, 0).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()); } + #[test] + fn v0_regions_inside_canvas_decode_after_region_grew() { + // The nested-Vec case the struct decoder exists for: multiple v0 Region + // elements inside Canvas.regions must each decode and default-fill + // permits_spanning_slurs. A wrong per-element size would desync the Vec + // after the first element (every later region would misparse), so a + // multi-region canvas is the discriminating fixture — valid_score_rich + // carries three regions. + let score = valid_score_rich(9); + assert!( + score.canvas.regions.len() >= 2, + "need a multi-region canvas to exercise the Vec walk" + ); + let v0 = encode_v0_score(&score); + let migrated = Score::decode_canonical_versioned(&v0, 0).unwrap(); + assert_eq!(migrated, score); + assert_eq!(migrated.canvas.regions.len(), score.canvas.regions.len()); + assert!(migrated + .canvas + .regions + .iter() + .all(|r| !r.permits_spanning_slurs)); + } + #[test] fn distinct_scores_serialize_differently() { assert_ne!( diff --git a/crates/epiphany-core/src/generators.rs b/crates/epiphany-core/src/generators.rs index 7f12201..a101c71 100644 --- a/crates/epiphany-core/src/generators.rs +++ b/crates/epiphany-core/src/generators.rs @@ -117,6 +117,7 @@ pub fn valid_score(seed: u64) -> Score { instruments.push(Instrument { id: instrument, name: String::from("instrument"), + range: None, }); staves.push(Staff { id: staff_id, @@ -168,6 +169,7 @@ pub fn valid_score(seed: u64) -> Score { staves: staff_extent, }, local_tempo_map: None, + permits_spanning_slurs: false, }; let mut score = Score::empty(idc.clone()); @@ -222,6 +224,7 @@ pub fn valid_score_rich(seed: u64) -> Score { instruments.push(Instrument { id: instrument, name: String::from("instrument"), + range: None, }); staves.push(Staff { id, @@ -351,6 +354,7 @@ pub fn valid_score_rich(seed: u64) -> Score { staves: vec![staff_a], }, local_tempo_map: None, + permits_spanning_slurs: false, }; // --- Proportional region on staff B: wall-clock events. ------------------ @@ -392,6 +396,7 @@ pub fn valid_score_rich(seed: u64) -> Score { staves: vec![staff_b], }, local_tempo_map: None, + permits_spanning_slurs: false, }; // --- Aleatoric region on staff C (musical discipline). ------------------- @@ -436,6 +441,7 @@ pub fn valid_score_rich(seed: u64) -> Score { staves: vec![staff_c], }, local_tempo_map: None, + permits_spanning_slurs: false, }; // --- Tombstones + a spelling attachment resolving to a tombstoned pitch. - diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index ab7afda..196a179 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -26,7 +26,7 @@ use crate::ids::{ TupletId, ViewId, VoiceId, }; use crate::pitch::{ - ForeignFormatId, PitchSpaceId, ReferencePitch, SpellingAttachment, TuningSystemId, + ForeignFormatId, PitchRange, PitchSpaceId, ReferencePitch, SpellingAttachment, TuningSystemId, }; use crate::time::{MeasurePosition, MusicalDuration, TimeAnchor, WallClockDuration}; @@ -749,6 +749,14 @@ pub struct Region { pub time_extent: TimeExtent, pub staff_extent: StaffExtent, pub local_tempo_map: Option, + + /// Whether slurs may cross this region's boundary (schema major 1). `false` + /// is the default and today's behavior — the CreateCrossCutting(Slur) + /// advisory precondition reports a slur whose endpoints lie in different + /// regions unless **both** endpoint regions set this flag (a boundary is + /// permeable only when neither side forbids it; core spec §6.10 + /// CreateCrossCutting, Slur bucket). Advisory: it never alters reduction. + pub permits_spanning_slurs: bool, } impl Region { @@ -1189,11 +1197,18 @@ pub struct ScoreMetadata { } /// An abstract instrument definition (Chapter 5 §"Instruments"). Baseline: the -/// identity and name; sound configuration and ranges are the audio engine's. +/// identity and name; sound configuration is the audio engine's. #[derive(Clone, PartialEq, Eq, Debug)] pub struct Instrument { pub id: InstrumentId, pub name: String, + + /// The instrument's declared playable pitch range, if any (schema major 1). + /// `None` is "no declared range" — the InsertEvent pitch-in-range advisory + /// precondition is then a vacuous pass (core spec §6.10, "if any"). A + /// spanning-frame candidate outside the range trips the advisory check in + /// authoring mode only (see [`PitchRange::contains`]). + pub range: Option, } /// The kind of a staff grouping (Chapter 5 §"Top-Level Score Structure"). @@ -1385,6 +1400,7 @@ mod tests { time_extent: ext, staff_extent: StaffExtent { staves }, local_tempo_map: None, + permits_spanning_slurs: false, } } diff --git a/crates/epiphany-core/src/invariants.rs b/crates/epiphany-core/src/invariants.rs index e9ea58a..4ff5003 100644 --- a/crates/epiphany-core/src/invariants.rs +++ b/crates/epiphany-core/src/invariants.rs @@ -2542,6 +2542,7 @@ mod review_fix_tests { staves: vec![staff], }, local_tempo_map: None, + permits_spanning_slurs: false, }; s.canvas.regions.push(region); assert!(fires(&s, GraphInvariant::RegionExtents)); @@ -2585,6 +2586,7 @@ mod review_fix_tests { staves: vec![staff], }, local_tempo_map: None, + permits_spanning_slurs: false, }); // Sound: the undecidable overlap is NOT raised as a (false-positive) @@ -2892,6 +2894,7 @@ mod review_fix_tests { staves: vec![staff], }, local_tempo_map: None, + permits_spanning_slurs: false, }); assert!(check_invariants(&s).is_empty()); } @@ -2981,6 +2984,7 @@ mod review_fix_tests_2 { }, staff_extent: crate::graph::StaffExtent { staves: vec![] }, local_tempo_map: None, + permits_spanning_slurs: false, }); // A gesture referencing a stored object resolves; a missing one fires. s.cross_cutting.graphic_gestures.push(GraphicGesture { @@ -3113,10 +3117,12 @@ mod review_fix_tests_2 { s.instruments.push(Instrument { id: iid, name: "a".into(), + range: None, }); s.instruments.push(Instrument { id: iid, name: "b".into(), + range: None, }); assert!(fires(&s, GraphInvariant::UniqueIdentifiers)); @@ -3326,6 +3332,7 @@ mod review_fix_tests_3 { s.instruments.push(Instrument { id: InstrumentId::new(ReplicaId::SYSTEM_DERIVED, 1), name: "x".into(), + range: None, }); assert!(fires(&s, GraphInvariant::UniqueIdentifiers)); } @@ -3404,6 +3411,7 @@ mod review_fix_tests_3 { staves: vec![staff2], }, local_tempo_map: None, + permits_spanning_slurs: false, }); // Musical offset against a wall-clock event -> invariant 9 fires. s.cross_cutting.spanners.push(Spanner { @@ -3652,6 +3660,7 @@ mod review_fix_tests_4 { s.instruments.push(crate::graph::Instrument { id: instr, name: "y".into(), + range: None, }); s.staves.push(crate::graph::Staff { id: staff_y, @@ -3673,6 +3682,7 @@ mod review_fix_tests_4 { staves: vec![staff_y], }, local_tempo_map: None, + permits_spanning_slurs: false, }; // R1 spans event e0 (musical 0) .. e1 (musical 1/4): wall-clock [0, 5e8]. let r1 = mk_region( diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index 978bf22..9af6dd5 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -76,11 +76,12 @@ pub use time::{ pub use pitch::{ canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId, AcousticPitch, AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId, - IdentifiedPitch, NominalRegistryId, Pitch, PitchSpaceId, PitchSpacePosition, PitchSpelling, - PositionRegistryId, ReferencePitch, ScalePosition, SpellingAlgorithmId, SpellingAttachment, - SpellingContext, SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingRenderHints, - SpellingRule, SpellingRuleSetId, SpellingScope, SpellingSource, SpellingSourceKind, - StaffGroupKindRegistryId, TieClassRegistryId, TuningReference, TuningSystemId, VoiceSelector, + IdentifiedPitch, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition, + PitchSpelling, PositionRegistryId, ReferencePitch, ScalePosition, SpellingAlgorithmId, + SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal, SpellingPrecedence, + SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope, SpellingSource, + SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId, TuningReference, + TuningSystemId, VoiceSelector, }; pub use prepass::{ diff --git a/crates/epiphany-core/src/pitch.rs b/crates/epiphany-core/src/pitch.rs index d8c6bca..e830741 100644 --- a/crates/epiphany-core/src/pitch.rs +++ b/crates/epiphany-core/src/pitch.rs @@ -441,6 +441,48 @@ impl Pitch { } } +/// A closed pitch range, `lowest..=highest` (Chapter 2; the type +/// `core_spec` references for [`Instrument`](crate::Instrument)'s declared +/// range). Both endpoints are full [`Pitch`] values, so a range is expressed in +/// a specific pitch space; membership is only decidable when a candidate shares +/// a comparison frame with the endpoints (the same "sound but incomplete" +/// discipline as [`Pitch::enharmonic_equivalent`]). Derived `Eq` is structural. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct PitchRange { + /// The lowest sounding pitch admitted (inclusive). + pub lowest: Pitch, + /// The highest sounding pitch admitted (inclusive). + pub highest: Pitch, +} + +impl PitchRange { + /// Whether `pitch` lies within `lowest..=highest`, decided by absolute + /// 12-TET semitone ([`Pitch::twelve_tet_semitone`]). Returns `None` — the + /// indeterminate case its advisory caller treats as a pass — when: + /// + /// * the three pitches do not all share a [`PitchSpaceId`] frame (absolute + /// semitones across frames use different zero references and are not + /// comparable); + /// * any pitch's semitone is not determinable without a tuning resolver; or + /// * the range is **malformed** in the comparable frame — `lowest` sorts + /// strictly above `highest`. A range is well-formed only when `lowest` + /// does not sort above `highest` (core spec §"Instrument"); a reversed + /// range is undecidable, not "empty", so it must not reject every pitch. + pub fn contains(&self, pitch: &Pitch) -> Option { + let frame = &self.lowest.scale_position.space; + if &self.highest.scale_position.space != frame || &pitch.scale_position.space != frame { + return None; + } + let lo = self.lowest.twelve_tet_semitone()?; + let hi = self.highest.twelve_tet_semitone()?; + if lo > hi { + return None; // malformed (reversed) range: undecidable, not empty + } + let p = pitch.twelve_tet_semitone()?; + Some(lo <= p && p <= hi) + } +} + /// Canonical, deterministic bytes for a [`Pitch`]'s intrinsic content (scale /// position plus acoustic realization), used to derive a content-addressed /// system pitch identifier. Strings are length-prefixed and already NFC (the @@ -815,6 +857,47 @@ mod tests { assert_eq!(CmnNominal::B.chromatic(), 11); } + #[test] + fn pitch_range_contains_is_advisory_and_frame_aware() { + let range = PitchRange { + lowest: cmn(CmnNominal::C, 0, 2), + highest: cmn(CmnNominal::C, 0, 6), + }; + // Interior and inclusive endpoints are in range. + assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 4)), Some(true)); + assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 2)), Some(true)); + assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 6)), Some(true)); + // Below and above are out of range. + assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 1)), Some(false)); + assert_eq!(range.contains(&cmn(CmnNominal::C, 0, 7)), Some(false)); + + // A malformed (reversed) range is *undecidable*, not "everything out of + // range" — it must not reject every comparable pitch. + let reversed = PitchRange { + lowest: cmn(CmnNominal::C, 0, 6), + highest: cmn(CmnNominal::C, 0, 2), + }; + assert_eq!(reversed.contains(&cmn(CmnNominal::C, 0, 4)), None); + + // A candidate in a different pitch-space frame is undecidable (absolute + // semitones across frames are not comparable). + let other_frame = Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("cmn-19"), + position: PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave: 4, + }, + }, + acoustic: AcousticPitch { + tuning: TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + }; + assert_eq!(range.contains(&other_frame), None); + } + #[test] fn enharmonic_equivalence_is_twelve_tet_pitch_class() { // C-sharp4 and D-flat4 are enharmonic but not structurally equal nor diff --git a/crates/epiphany-core/src/prepass/tests.rs b/crates/epiphany-core/src/prepass/tests.rs index aea717d..d1a6d4a 100644 --- a/crates/epiphany-core/src/prepass/tests.rs +++ b/crates/epiphany-core/src/prepass/tests.rs @@ -165,6 +165,7 @@ fn metric_score( staves: vec![staff], }, local_tempo_map: None, + permits_spanning_slurs: false, }; let mut score = Score::empty(idc.clone()); @@ -180,6 +181,7 @@ fn metric_score( score.instruments = vec![Instrument { id: instrument, name: String::from("Test"), + range: None, }]; score.cross_cutting.tuplets = tuplets; score.events = arena; @@ -768,6 +770,7 @@ fn nonmetric_region_defers_decomposition_but_still_spells() { staves: vec![staff], }, local_tempo_map: None, + permits_spanning_slurs: false, }; let mut score = Score::empty(idc.clone()); score.identity = idc; @@ -782,6 +785,7 @@ fn nonmetric_region_defers_decomposition_but_still_spells() { score.instruments = vec![Instrument { id: instrument, name: "T".into(), + range: None, }]; score.events = arena; score.canvas = Canvas { diff --git a/crates/epiphany-core/tests/score_graph.rs b/crates/epiphany-core/tests/score_graph.rs index ea2a7a6..7f1fca8 100644 --- a/crates/epiphany-core/tests/score_graph.rs +++ b/crates/epiphany-core/tests/score_graph.rs @@ -88,6 +88,7 @@ fn hand_built_score() -> Score { staves: vec![staff_id], }, local_tempo_map: None, + permits_spanning_slurs: false, }; let mut score = Score::empty(idc.clone()); @@ -95,6 +96,7 @@ fn hand_built_score() -> Score { score.instruments = vec![Instrument { id: instrument, name: "Flute".into(), + range: None, }]; score.staves = vec![Staff { id: staff_id, diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index 3da7d60..b171a85 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -1004,9 +1004,11 @@ impl CanonicalEncode for ModifyCrossCuttingOp { // deletes are empty-only delete-wins tombstones (the container must have no live // children — the caller deletes contents first). See `DECISIONS.md`. -/// Mint an empty region into the canvas (Chapter 6 §6.10 InsertRegion). Carries -/// the full [`Region`] value (v1); the reduction preconditions it carries no -/// staff instances (an empty container). +/// Mint an empty region into the canvas (Chapter 6 §6.10 InsertRegion). Holds +/// the full [`Region`] value; the reduction preconditions it carries no staff +/// instances (an empty container). Its canonical payload embeds the region's +/// **schema-major-0** form (no `permits_spanning_slurs`) so the op-envelope +/// block stays byte-v0 — see [`CreateRegionOp::encode_canonical`]. #[derive(Clone, PartialEq, Eq, Debug)] pub struct CreateRegionOp { pub region: Region, @@ -1021,7 +1023,15 @@ impl CreateRegionOp { impl CanonicalEncode for CreateRegionOp { fn encode_canonical(&self, out: &mut Vec) { - push_lp_bytes(out, &self.region.canonical_bytes()); + // The op-envelope block is stamped schema major 0, so this payload stays + // byte-identical to schema major 0: it embeds the region's **v0** + // canonical form (no `permits_spanning_slurs`), not the schema-major-1 + // `canonical_bytes`. A region minted here therefore reduces with the + // flag `false` — the only value any producer sets today. The op payload + // moves to the v1 encoding, and the block to major 1 with + // migrate-on-read, when the op-block schema-major machinery lands + // (schema-major track, D2). See `Region::canonical_bytes_v0`. + push_lp_bytes(out, &self.region.canonical_bytes_v0()); } } @@ -1324,7 +1334,7 @@ impl CanonicalEncode for SetStaffLayoutOp { #[cfg(test)] mod tests { use super::*; - use epiphany_core::{ReplicaId, SlurId}; + use epiphany_core::{RegionId, ReplicaId, SlurId}; #[test] fn operation_kind_wire_discriminants_are_golden() { @@ -1603,6 +1613,31 @@ mod tests { assert_eq!(&bytes[16..], &[0xAB; 32]); } + #[test] + fn create_region_payload_is_byte_v0_and_omits_the_spanning_flag() { + // The op-envelope block is stamped schema major 0, so the CreateRegion + // payload must stay byte-identical to schema major 0 — it must NOT carry + // Region.permits_spanning_slurs (schema major 1). Encoding a region with + // the flag set produces the same bytes as with it clear, and equals the + // region's frozen v0 canonical form, length-prefixed. + let rid = RegionId::new(ReplicaId(9), 3); + let mut permit = crate::valuegen::region(rid); + permit.permits_spanning_slurs = true; + let forbid = crate::valuegen::region(rid); // valuegen defaults the flag false + + let enc = |region: Region| { + let mut out = Vec::new(); + CreateRegionOp { region }.encode_canonical(&mut out); + out + }; + // The flag is not carried: both encode identically. + assert_eq!(enc(permit.clone()), enc(forbid.clone())); + // And the payload is exactly the region's v0 canonical form, LP-framed. + let mut expected = Vec::new(); + push_lp_bytes(&mut expected, &forbid.canonical_bytes_v0()); + assert_eq!(enc(forbid), expected); + } + #[test] fn transaction_category_discriminants_are_golden() { // RATIFIED by Pass 11 (item 2.4, req:semops:transaction-category): the diff --git a/crates/epiphany-ops/src/validate.rs b/crates/epiphany-ops/src/validate.rs index 7055bf1..9eaf882 100644 --- a/crates/epiphany-ops/src/validate.rs +++ b/crates/epiphany-ops/src/validate.rs @@ -53,22 +53,25 @@ //! * InsertEvent / ModifyEvent duration-not-crossing-region-boundary //! ([`AdvisoryViolation::DurationCrossesRegionBoundary`]), for regions whose //! musical end bound is resolvable (see below). +//! * InsertEvent / ModifyEvent pitch-within-instrument-range +//! ([`AdvisoryViolation::PitchOutsideInstrumentRange`]): each chord pitch is +//! checked against the event's instrument's declared +//! [`range`](epiphany_core::Instrument::range), resolved through +//! voice → staff instance → staff (honoring an instance `instrument_override`). +//! An instrument with no declared range is the "if any" vacuous pass, and a +//! pitch whose frame is not comparable with the range's (the +//! [`PitchRange::contains`](epiphany_core::PitchRange::contains) indeterminate +//! case) passes too — sound but incomplete. //! * CreateCrossCutting(Slur) not-spanning-a-region-boundary //! ([`AdvisoryViolation::SlurSpansRegionBoundary`]): the slur's endpoint -//! events resolve to different regions. +//! events resolve to different regions, *unless* both endpoint regions set +//! [`permits_spanning_slurs`](epiphany_core::Region::permits_spanning_slurs) +//! — a boundary is permeable only when neither side forbids it (the +//! conservative reading of "explicitly permitted by region configuration", +//! pending spec ratification of which region governs a cross-region slur). //! //! ### Documented gaps (blocked on the truncated data model) //! -//! * **InsertEvent pitch-within-instrument-range**: `epiphany_core::Instrument` -//! carries only `{ id, name }` — it has no declared range field. The -//! data-model completion is staged to the Binary Format companion; until the -//! field exists there is nothing to check against ("if any" in the spec text -//! makes the absent-range case a vacuous pass, which is exactly what this -//! module does by omission). -//! * **Slur spanning "explicitly permitted by region configuration"**: -//! `epiphany_core::Region` has no such configuration flag. The check treats -//! spanning as never permitted; when the flag lands, it suppresses the -//! violation. //! * **Region musical end bound**: a region's `TimeExtent` is a pair of //! `TimeAnchor`s. The bound is resolvable in musical time only when the end //! anchor is region-start-anchored with a `Musical` offset (the same @@ -79,8 +82,8 @@ //! machinery is deferred (P11-C5). use epiphany_core::{ - AnchorOffset, EventDuration, EventId, EventPosition, MusicalPosition, Region, RegionEdge, - RegionId, Score, SlurId, TimeAnchor, + AnchorOffset, EventDuration, EventId, EventPosition, InstrumentId, MusicalPosition, Region, + RegionEdge, RegionId, Score, SlurId, TimeAnchor, }; use crate::payload::{CrossCuttingValue, OperationKind}; @@ -138,9 +141,9 @@ pub enum AdvisoryViolation { region: RegionId, }, /// A `CreateCrossCutting` slur's endpoint events lie in different regions - /// (core spec §6.10 CreateCrossCutting, Slur advisory bucket). Region - /// configuration cannot yet permit spanning (see the module docs' gap - /// list), so a cross-region slur always reports. + /// (core spec §6.10 CreateCrossCutting, Slur advisory bucket), and the two + /// regions do not both set + /// [`permits_spanning_slurs`](epiphany_core::Region::permits_spanning_slurs). SlurSpansRegionBoundary { /// The offending slur. slur: SlurId, @@ -149,6 +152,17 @@ pub enum AdvisoryViolation { /// The (different) region containing the end event. end_region: RegionId, }, + /// An `InsertEvent`/`ModifyEvent` pitched event carries a chord pitch outside + /// its instrument's declared range (core spec §6.10 InsertEvent, advisory + /// bucket). Only a pitch definitively out of range in a comparable frame + /// reports; an instrument with no declared range, or a pitch whose frame is + /// not comparable with the range's, is a vacuous pass. + PitchOutsideInstrumentRange { + /// The offending event. + event: EventId, + /// The instrument whose declared range the event's pitch exceeds. + instrument: InstrumentId, + }, } /// Checks `kind` against every implemented advisory precondition (the @@ -168,16 +182,24 @@ pub fn advisory_violations(kind: &OperationKind, score: &Score) -> Vec { check_event_span(&op.event, score, &mut violations); + check_pitch_range(&op.event, score, &mut violations); } OperationKind::ModifyEvent(op) => { check_event_span(&op.event, score, &mut violations); + check_pitch_range(&op.event, score, &mut violations); } OperationKind::CreateCrossCutting(op) => { if let CrossCuttingValue::Slur(slur) = &op.structure { let start = event_region(score, slur.start_event); let end = event_region(score, slur.end_event); if let (Some(start_region), Some(end_region)) = (start, end) { - if start_region != end_region { + // A cross-region slur reports unless BOTH regions permit + // spanning — the boundary is permeable only when neither + // side forbids it (see the module docs). + if start_region != end_region + && !(region_permits_spanning(score, start_region) + && region_permits_spanning(score, end_region)) + { violations.push(AdvisoryViolation::SlurSpansRegionBoundary { slur: slur.id, start_region, @@ -224,6 +246,63 @@ fn check_event_span( } } +/// Reports a violation when any chord pitch of `event` is definitively outside +/// its instrument's declared range. The instrument is resolved through the +/// event's voice → staff instance → staff (honoring an instance +/// `instrument_override`); an unlocatable voice/staff/instrument, an instrument +/// with no declared range, or a pitch whose frame is not comparable with the +/// range's ([`epiphany_core::PitchRange::contains`] returns `None`) all pass +/// vacuously — the same sound-but-incomplete stance as the span check. +fn check_pitch_range( + event: &epiphany_core::Event, + score: &Score, + violations: &mut Vec, +) { + let Some((region_index, instance_index, _)) = graph_voice_location(score, event.voice()) else { + return; + }; + let instance = &score.canvas.regions[region_index].staff_instances()[instance_index]; + // Effective instrument: the instance override, else the staff's instrument. + let instrument_id = match instance.instrument_override { + Some(id) => id, + None => match score.staves.iter().find(|s| s.id == instance.staff) { + Some(staff) => staff.instrument, + None => return, + }, + }; + let Some(instrument) = score.instruments.iter().find(|i| i.id == instrument_id) else { + return; + }; + // "If any": an instrument with no declared range vacuously passes. + let Some(range) = &instrument.range else { + return; + }; + let mut pitches = Vec::new(); + event.collect_identified_pitches(&mut pitches); + // One violation per event suffices; a single out-of-range chord pitch (in a + // comparable frame) is enough to report. + if pitches + .iter() + .any(|ip| range.contains(&ip.pitch) == Some(false)) + { + violations.push(AdvisoryViolation::PitchOutsideInstrumentRange { + event: event.id(), + instrument: instrument_id, + }); + } +} + +/// Whether the region with `id` sets `permits_spanning_slurs` (a linear find; +/// `false` when the id is not a live region, which the invariant preconditions +/// own). +fn region_permits_spanning(score: &Score, id: RegionId) -> bool { + score + .canvas + .regions + .iter() + .any(|r| r.id == id && r.permits_spanning_slurs) +} + /// The region's end bound as a region-local musical position, when its /// `TimeExtent`'s end anchor expresses one: anchored to this region's own /// start edge with a `Musical` offset. Any other shape (wall-clock, symbolic, @@ -256,8 +335,8 @@ mod tests { use crate::valuegen; use epiphany_core::generators::valid_score; use epiphany_core::{ - EventId, MusicalDuration, MusicalPosition, PitchId, RationalTime, ReplicaId, SlurId, - VoiceId, + EventId, MusicalDuration, MusicalPosition, PitchId, PitchRange, RationalTime, ReplicaId, + SlurId, VoiceId, }; /// A fixture score whose (single) region declares a musical end bound of @@ -365,10 +444,10 @@ mod tests { assert!(advisory_violations(&kind, &score).is_empty()); } - #[test] - fn slur_spanning_two_regions_is_an_advisory_violation() { - // Two single-region fixture scores merged: distinct regions, each with - // its own events. + /// Two single-region fixture scores merged into one score with two distinct + /// regions (index 0 = start, index 1 = end), returning the ids for a + /// cross-region slur. + fn two_region_score() -> (Score, RegionId, RegionId, EventId, EventId) { let mut score = valid_score(7); let other = valid_score(8); let start_region = score.canvas.regions[0].id; @@ -382,7 +461,12 @@ mod tests { .insert(event.clone()) .expect("distinct seeds mint distinct event ids"); } + (score, start_region, end_region, start_event, end_event) + } + #[test] + fn slur_spanning_two_regions_is_an_advisory_violation() { + let (score, start_region, end_region, start_event, end_event) = two_region_score(); let slur_id = SlurId::new(ReplicaId(50), 1); let cross = OperationKind::CreateCrossCutting(CreateCrossCuttingOp { structure: CrossCuttingValue::Slur(valuegen::slur(slur_id, start_event, end_event)), @@ -404,6 +488,89 @@ mod tests { assert!(advisory_violations(&within, &score).is_empty()); } + #[test] + fn slur_spanning_two_permitting_regions_is_suppressed() { + // When BOTH endpoint regions permit spanning slurs, the boundary is + // permeable and the advisory violation is suppressed. + let (mut score, _start, _end, start_event, end_event) = two_region_score(); + score.canvas.regions[0].permits_spanning_slurs = true; + score.canvas.regions[1].permits_spanning_slurs = true; + let slur_id = SlurId::new(ReplicaId(50), 1); + let cross = OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingValue::Slur(valuegen::slur(slur_id, start_event, end_event)), + }); + assert!(advisory_violations(&cross, &score).is_empty()); + } + + #[test] + fn slur_spanning_still_reports_when_only_one_region_permits() { + // AND semantics: one side opting in is not enough — the region that does + // not permit spanning still forbids the boundary crossing. + let (mut score, start_region, end_region, start_event, end_event) = two_region_score(); + score.canvas.regions[0].permits_spanning_slurs = true; // start only + let slur_id = SlurId::new(ReplicaId(50), 1); + let cross = OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingValue::Slur(valuegen::slur(slur_id, start_event, end_event)), + }); + assert_eq!( + advisory_violations(&cross, &score), + vec![AdvisoryViolation::SlurSpansRegionBoundary { + slur: slur_id, + start_region, + end_region, + }] + ); + } + + #[test] + fn insert_event_with_pitch_outside_instrument_range_is_an_advisory_violation() { + let (mut score, _region, instance, voice) = bounded_score(12); + // Declare a range strictly above the fixture's C4 event pitch (C5..C6) + // on every instrument, so whichever the voice resolves to carries it. + let range = PitchRange { + lowest: valuegen::pitch_value_nth(35), // C5 + highest: valuegen::pitch_value_nth(42), // C6 + }; + for instrument in &mut score.instruments { + instrument.range = Some(range.clone()); + } + // In-extent (span 0..1, bound 12) so only the pitch check can fire. + let kind = insert_kind(instance, voice, 0, 1); + let violations = advisory_violations(&kind, &score); + assert!( + matches!( + violations.as_slice(), + [AdvisoryViolation::PitchOutsideInstrumentRange { event, .. }] + if *event == EventId::new(ReplicaId(50), 999) + ), + "expected a single pitch-range violation, got {violations:?}" + ); + } + + #[test] + fn insert_event_within_instrument_range_passes() { + let (mut score, _region, instance, voice) = bounded_score(12); + // C2..C6 brackets the C4 event pitch. + let range = PitchRange { + lowest: valuegen::pitch_value_nth(14), // C2 + highest: valuegen::pitch_value_nth(42), // C6 + }; + for instrument in &mut score.instruments { + instrument.range = Some(range.clone()); + } + let kind = insert_kind(instance, voice, 0, 1); + assert!(advisory_violations(&kind, &score).is_empty()); + } + + #[test] + fn insert_event_with_no_declared_instrument_range_passes_vacuously() { + // The fixture's instruments declare no range — the "if any" vacuous pass. + let (score, _region, instance, voice) = bounded_score(12); + assert!(score.instruments.iter().all(|i| i.range.is_none())); + let kind = insert_kind(instance, voice, 0, 1); + assert!(advisory_violations(&kind, &score).is_empty()); + } + #[test] fn replay_reduction_ignores_advisory_violations_and_is_unchanged_by_the_mode_machinery() { // Spec §"Validation Modes": advisory preconditions MAY fail silently diff --git a/crates/epiphany-ops/src/valuegen.rs b/crates/epiphany-ops/src/valuegen.rs index 1f4f435..898b933 100644 --- a/crates/epiphany-ops/src/valuegen.rs +++ b/crates/epiphany-ops/src/valuegen.rs @@ -265,6 +265,7 @@ pub fn region(id: RegionId) -> Region { }, staff_extent: StaffExtent { staves: Vec::new() }, local_tempo_map: None, + permits_spanning_slurs: false, } } diff --git a/crates/epiphany-testkit/src/corpus.rs b/crates/epiphany-testkit/src/corpus.rs index 55185f2..57184d7 100644 --- a/crates/epiphany-testkit/src/corpus.rs +++ b/crates/epiphany-testkit/src/corpus.rs @@ -495,6 +495,7 @@ impl OneStaff { staves: vec![staff], }, local_tempo_map: None, + permits_spanning_slurs: false, }; let mut score = Score::empty(idc.clone()); @@ -510,6 +511,7 @@ impl OneStaff { score.instruments = vec![Instrument { id: instrument, name: String::from("F-corpus"), + range: None, }]; score.events = arena; score.cross_cutting = cross_cutting; diff --git a/crates/epiphany-testkit/src/fixtures.rs b/crates/epiphany-testkit/src/fixtures.rs index f8407c6..9d8d06c 100644 --- a/crates/epiphany-testkit/src/fixtures.rs +++ b/crates/epiphany-testkit/src/fixtures.rs @@ -166,6 +166,7 @@ pub fn ten_measure_single_staff(seed: u64) -> Score { staves: vec![staff_id], }, local_tempo_map: None, + permits_spanning_slurs: false, }; let mut score = Score::empty(idc.clone()); @@ -173,6 +174,7 @@ pub fn ten_measure_single_staff(seed: u64) -> Score { score.instruments = vec![Instrument { id: instrument, name: String::from("Flute"), + range: None, }]; score.staves = vec![Staff { id: staff_id,