Schema major 1 Phase D1: Instrument.range + Region flag (snapshot side)
The full-Score snapshot side of the three schema-major-1 data-model fields,
on a struct-level frozen v0 decoder. Snapshot-only: the CreateRegion op
payload stays byte-v0 (D2 owns the op-block migration + read-only).
Data model (epiphany-core):
- PitchRange { lowest, highest: Pitch } in pitch.rs; contains() is frame-guarded
(decides only within a shared PitchSpaceId) and treats a reversed range as
undecidable (None), not "everything out of range".
- Instrument.range: Option<PitchRange>; Region.permits_spanning_slurs: bool.
Codec:
- struct_codec! for PitchRange; Instrument -> {id,name,range}; Region appends
permits_spanning_slurs.
- Replaced the Phase-C byte-splice with a struct-level decode_v0_score: a
hand-written 19-field Score walk using dec_canvas_v0/dec_region_v0/
dec_instruments_v0 for the two changed fields (nested in Vecs) and the current
Codec for the other 17. Removed the now-unused Reader::pos().
- Region.canonical_bytes_v0() (+ enc_region_v0) is the frozen v0 op-payload
surface: CreateRegionOp embeds it so the op-envelope block stays byte-v0.
Advisory preconditions (epiphany-ops/validate.rs):
- PitchOutsideInstrumentRange: pitch-in-range via voice->instance->staff->
instrument (honoring instrument_override); "if any"/indeterminate-frame pass.
- Slur-spanning suppressed only when BOTH endpoint regions permit (AND
semantics; documented pending spec ratification of which region governs).
Tests: the three frozen-decoder fixtures (non-default v1 round-trip; a mirror
v0 encoder synthesizing genuine v0 bytes that migrate default-filling all three
fields, anchored by an independent byte-length check; the nested-Vec multi-
region case); the byte-v0 CreateRegion payload; the frame-aware/reversed-range
contains(); and 5 advisory tests. ~29 construction sites updated. Full gate
green (workspace tests, clippy -D warnings, fmt, rustdoc -D warnings).
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:
parent
e199fabe3f
commit
03758bbae0
|
|
@ -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<Instrument>`) — 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<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)
|
||||
// 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<Region>` 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<Canvas> {
|
||||
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<Region> {
|
||||
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<u8>) {
|
||||
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<u8> {
|
||||
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<Vec<Instrument>> {
|
||||
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<u8> {
|
||||
// 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<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");
|
||||
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!(
|
||||
|
|
|
|||
|
|
@ -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. -
|
||||
|
|
|
|||
|
|
@ -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<crate::tempo::TempoMap>,
|
||||
|
||||
/// 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<PitchRange>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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<bool> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<u8>) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<AdvisoryV
|
|||
match kind {
|
||||
OperationKind::InsertEvent(op) => {
|
||||
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<AdvisoryViolation>,
|
||||
) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -265,6 +265,7 @@ pub fn region(id: RegionId) -> Region {
|
|||
},
|
||||
staff_extent: StaffExtent { staves: Vec::new() },
|
||||
local_tempo_map: None,
|
||||
permits_spanning_slurs: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue