683 lines
30 KiB
Rust
683 lines
30 KiB
Rust
//! Validation modes and advisory-precondition checking (Chapter 6
|
|
//! §"Validation Modes", label `sec:semops:validation`).
|
|
//!
|
|
//! The spec distinguishes two precondition-checking modes:
|
|
//!
|
|
//! * **Authoring mode** — interactive edits. *All* preconditions are enforced:
|
|
//! invariant preconditions (which preserve graph invariants) and advisory
|
|
//! preconditions (user-intent constraints, range checks, style policy).
|
|
//! * **Replay mode** — replaying historical operations or applying remote
|
|
//! operations under reduction. Only invariant preconditions are enforced;
|
|
//! advisory preconditions MAY fail silently, since they represent the
|
|
//! authoring replica's local policy at the moment of authoring, not
|
|
//! invariants of the canonical state.
|
|
//!
|
|
//! ## Where each mode lives
|
|
//!
|
|
//! [`crate::OperationSet::reduce`] and [`crate::OperationSet::reduce_onto`]
|
|
//! **are** replay mode: the reducer enforces exactly the invariant
|
|
//! preconditions and nothing else, in every context. Authoring-mode
|
|
//! enforcement happens **before an envelope is minted** — the authoring layer
|
|
//! (epiphany-editor-core) runs [`advisory_violations`] against its current
|
|
//! materialized score and refuses to mint on any violation. Canonical
|
|
//! reduction behavior and canonical bytes are therefore **untouched** by the
|
|
//! mode machinery: an envelope that exists reduces identically whether its
|
|
//! author checked advisories or not (the spec's replay-parity requirement).
|
|
//!
|
|
//! ## The advisory inventory (core spec §6.10)
|
|
//!
|
|
//! The spec declares advisory preconditions for two of the implemented K0
|
|
//! operations (every other implemented kind's precondition bucket is entirely
|
|
//! invariant):
|
|
//!
|
|
//! * **InsertEvent** — (a) for pitched events, every pitch is within the
|
|
//! instrument's declared range, if any; (b) the event's duration does not
|
|
//! extend past a region boundary in a way that would require splitting.
|
|
//! * **CreateCrossCutting (Slur case)** — the slur does not span a region
|
|
//! boundary, unless explicitly permitted by region configuration.
|
|
//!
|
|
//! `ModifyEvent` carries the full replacement event value, so check (b)
|
|
//! applies to it identically (the replacement's span must not straddle the
|
|
//! region boundary any more than an inserted one may).
|
|
//!
|
|
//! The Phase-3 first tranche (`CreateStaff`, `SetTimeSignature`,
|
|
//! `SetTempoSegment`, `SetStaffLayout` — operation_catalog §CreateStaff,
|
|
//! §"Meter and Tempo Overwrites", §SetStaffLayout) declares **no advisory
|
|
//! preconditions**: every precondition those entries name (reference
|
|
//! resolution, mint freshness, resulting-map well-formedness, target
|
|
//! liveness) is invariant and enforced by the reducer in all modes, so this
|
|
//! module gains no new checks for them.
|
|
//!
|
|
//! ### Implemented here
|
|
//!
|
|
//! * 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, *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)
|
|
//!
|
|
//! * **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
|
|
//! sound-but-incomplete resolution discipline as
|
|
//! `epiphany_core::Region::overlaps_in_time`, which resolves only wall-clock
|
|
//! extents). A wall-clock or symbolic extent yields no musical bound and the
|
|
//! boundary check passes vacuously — the full tempo/measure resolution
|
|
//! machinery is deferred (P11-C5).
|
|
|
|
use epiphany_core::{
|
|
AnchorOffset, EventDuration, EventId, EventPosition, InstrumentId, MusicalPosition, Region,
|
|
RegionEdge, RegionId, RepeatStructureId, Score, SlurId, TimeAnchor,
|
|
};
|
|
|
|
use crate::payload::{CrossCuttingValue, OperationKind};
|
|
use crate::reduce::graph_voice_location;
|
|
|
|
/// The two precondition-checking modes of Chapter 6 §"Validation Modes"
|
|
/// (`sec:semops:validation`).
|
|
///
|
|
/// Invariant preconditions hold in **all** modes — the reducer
|
|
/// ([`crate::OperationSet::reduce`] / [`crate::OperationSet::reduce_onto`])
|
|
/// enforces them unconditionally, and is thereby exactly
|
|
/// [`ValidationMode::Replay`]. [`ValidationMode::Authoring`] additionally
|
|
/// requires [`advisory_violations`] to be empty *before an envelope is
|
|
/// minted*; it never alters reduction.
|
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
|
pub enum ValidationMode {
|
|
/// Interactive edits: invariant **and** advisory preconditions are
|
|
/// enforced. The advisory half is enforced pre-mint by the authoring
|
|
/// layer via [`advisory_violations`].
|
|
Authoring,
|
|
/// Replaying historical operations or applying remote operations under
|
|
/// reduction: only invariant preconditions are enforced; advisory
|
|
/// preconditions MAY fail silently.
|
|
Replay,
|
|
}
|
|
|
|
impl ValidationMode {
|
|
/// Whether this mode enforces advisory preconditions (only
|
|
/// [`ValidationMode::Authoring`] does).
|
|
#[inline]
|
|
pub fn enforces_advisory(self) -> bool {
|
|
matches!(self, ValidationMode::Authoring)
|
|
}
|
|
}
|
|
|
|
/// A failed advisory precondition (Chapter 6 §6.10, the "Advisory
|
|
/// preconditions (authoring mode only)" buckets).
|
|
///
|
|
/// **Deliberately non-canonical**: this type has no canonical encoding and no
|
|
/// discriminant table because it never enters effects, conflicts, or any other
|
|
/// canonical state — it exists only on the authoring side, *before* an
|
|
/// envelope is minted. An operation refused for an advisory violation leaves
|
|
/// no trace in the operation set; one that slipped past (a remote author's
|
|
/// different policy) reduces normally in replay mode.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum AdvisoryViolation {
|
|
/// An `InsertEvent`/`ModifyEvent` event's span starts inside its region's
|
|
/// musical extent but ends past the region's end bound — applying it would
|
|
/// require splitting the event across the boundary (core spec §6.10
|
|
/// InsertEvent, advisory bucket).
|
|
DurationCrossesRegionBoundary {
|
|
/// The offending event.
|
|
event: EventId,
|
|
/// The region whose end bound the event's span crosses.
|
|
region: RegionId,
|
|
},
|
|
/// A `CreateCrossCutting` slur's endpoint events lie in different regions
|
|
/// (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,
|
|
/// The region containing the start event.
|
|
start_region: RegionId,
|
|
/// 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,
|
|
},
|
|
/// A `CreateRepeatStructure` volta violates the Chapter-5 well-formedness
|
|
/// constraints (operation_catalog §"Repeat Structures", authoring
|
|
/// advisory): a volta's `endings` must be non-empty, 1-based, and strictly
|
|
/// ascending. Advisory only — reduction never enforces it.
|
|
VoltaEndingsIllFormed {
|
|
/// The offending repeat structure.
|
|
repeat: RepeatStructureId,
|
|
},
|
|
}
|
|
|
|
/// Checks `kind` against every implemented advisory precondition (the
|
|
/// authoring-mode-only bucket of Chapter 6 §6.10), evaluated against `score`
|
|
/// — the authoring replica's current materialized graph. Returns every
|
|
/// violation found (empty = the operation may be minted in authoring mode).
|
|
///
|
|
/// Replay mode never calls this: the reducer applies invariant preconditions
|
|
/// only, so an envelope violating an advisory check still reduces cleanly
|
|
/// (spec: advisory preconditions MAY fail silently in replay mode). The check
|
|
/// is deliberately *conservative*: anything it cannot resolve against the
|
|
/// graph (a missing voice, a non-musical placement, an unresolvable region
|
|
/// bound) passes vacuously and is left to the invariant preconditions under
|
|
/// reduction.
|
|
pub fn advisory_violations(kind: &OperationKind, score: &Score) -> Vec<AdvisoryViolation> {
|
|
let mut violations = Vec::new();
|
|
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) {
|
|
// 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,
|
|
end_region,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
OperationKind::CreateRepeatStructure(op) => {
|
|
// Volta well-formedness (Chapter 5, advisory): endings non-empty,
|
|
// 1-based, strictly ascending. One report per structure.
|
|
let ill_formed = op.repeat.voltas.iter().any(|volta| {
|
|
volta.endings.is_empty()
|
|
|| volta.endings.first().is_some_and(|first| *first < 1)
|
|
|| volta.endings.windows(2).any(|pair| pair[0] >= pair[1])
|
|
});
|
|
if ill_formed {
|
|
violations.push(AdvisoryViolation::VoltaEndingsIllFormed {
|
|
repeat: op.repeat.id,
|
|
});
|
|
}
|
|
}
|
|
// Every other implemented kind's spec precondition bucket is entirely
|
|
// invariant (see the module docs); nothing to check here.
|
|
_ => {}
|
|
}
|
|
violations
|
|
}
|
|
|
|
/// Reports a violation when `event`'s musical span straddles its region's
|
|
/// musical end bound (starts strictly before it, ends strictly past it — the
|
|
/// "would require splitting" shape). A non-musical placement, an unlocatable
|
|
/// voice, or an unresolvable bound passes vacuously.
|
|
fn check_event_span(
|
|
event: &epiphany_core::Event,
|
|
score: &Score,
|
|
violations: &mut Vec<AdvisoryViolation>,
|
|
) {
|
|
let (EventPosition::Musical(position), EventDuration::Musical(duration)) =
|
|
(event.position(), event.duration())
|
|
else {
|
|
return;
|
|
};
|
|
let Some((region_index, _, _)) = graph_voice_location(score, event.voice()) else {
|
|
return;
|
|
};
|
|
let region = &score.canvas.regions[region_index];
|
|
let Some(bound) = region_musical_end_bound(region) else {
|
|
return;
|
|
};
|
|
let end = position.clone() + duration.clone();
|
|
if position < &bound && end > bound {
|
|
violations.push(AdvisoryViolation::DurationCrossesRegionBoundary {
|
|
event: event.id(),
|
|
region: region.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
/// another region's edge) is not resolvable without the deferred tempo/measure
|
|
/// machinery and yields `None` (the advisory check then passes vacuously).
|
|
fn region_musical_end_bound(region: &Region) -> Option<MusicalPosition> {
|
|
match ®ion.time_extent.end {
|
|
TimeAnchor::Region {
|
|
id,
|
|
edge: RegionEdge::Start,
|
|
offset: AnchorOffset::Musical(length),
|
|
} if *id == region.id => Some(MusicalPosition(length.0.clone())),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// The region containing `event`, resolved through the event's voice. `None`
|
|
/// when the event or its voice is not in the graph (the invariant
|
|
/// preconditions own that case).
|
|
fn event_region(score: &Score, event: EventId) -> Option<RegionId> {
|
|
let ev = score.events.get(event)?;
|
|
let (region_index, _, _) = graph_voice_location(score, ev.voice())?;
|
|
Some(score.canvas.regions[region_index].id)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::payload::{CreateCrossCuttingOp, InsertEventOp, ModifyEventOp};
|
|
use crate::valuegen;
|
|
use epiphany_core::generators::valid_score;
|
|
use epiphany_core::{
|
|
EventId, MusicalDuration, MusicalPosition, PitchId, PitchRange, RationalTime, ReplicaId,
|
|
SlurId, VoiceId,
|
|
};
|
|
|
|
/// A fixture score whose (single) region declares a musical end bound of
|
|
/// `bound` whole units, plus the ids needed to aim operations at it.
|
|
fn bounded_score(bound: i32) -> (Score, RegionId, epiphany_core::StaffInstanceId, VoiceId) {
|
|
let mut score = valid_score(7);
|
|
let region = &mut score.canvas.regions[0];
|
|
let region_id = region.id;
|
|
region.time_extent.end = TimeAnchor::Region {
|
|
id: region_id,
|
|
edge: RegionEdge::Start,
|
|
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(bound))),
|
|
};
|
|
let instance = region.staff_instances()[0].id;
|
|
let voice = region.staff_instances()[0].voices[0].id;
|
|
(score, region_id, instance, voice)
|
|
}
|
|
|
|
fn insert_kind(
|
|
instance: epiphany_core::StaffInstanceId,
|
|
voice: VoiceId,
|
|
position: i32,
|
|
duration: i32,
|
|
) -> OperationKind {
|
|
OperationKind::InsertEvent(InsertEventOp {
|
|
staff_instance: instance,
|
|
event: valuegen::insert_event_value(
|
|
EventId::new(ReplicaId(50), 999),
|
|
voice,
|
|
MusicalPosition(RationalTime::from_int(position)),
|
|
MusicalDuration(RationalTime::from_int(duration)),
|
|
&[PitchId::new(ReplicaId(50), 998)],
|
|
),
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn insert_event_crossing_the_region_end_bound_is_an_advisory_violation() {
|
|
let (score, region, instance, voice) = bounded_score(12);
|
|
// Starts inside the extent (10 < 12), ends past it (14 > 12).
|
|
let kind = insert_kind(instance, voice, 10, 4);
|
|
let violations = advisory_violations(&kind, &score);
|
|
assert_eq!(
|
|
violations,
|
|
vec![AdvisoryViolation::DurationCrossesRegionBoundary {
|
|
event: EventId::new(ReplicaId(50), 999),
|
|
region,
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn insert_event_within_the_region_end_bound_passes() {
|
|
let (score, _, instance, voice) = bounded_score(12);
|
|
// Ends exactly at the bound: nothing to split, no violation.
|
|
let kind = insert_kind(instance, voice, 10, 2);
|
|
assert!(advisory_violations(&kind, &score).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn insert_event_against_an_unresolvable_extent_passes_vacuously() {
|
|
// The unmodified fixture's extent is wall-clock — no musical bound is
|
|
// resolvable, so the boundary check cannot fire (module docs, gap 3).
|
|
let score = valid_score(7);
|
|
let instance = score.canvas.regions[0].staff_instances()[0].id;
|
|
let voice = score.canvas.regions[0].staff_instances()[0].voices[0].id;
|
|
let kind = insert_kind(instance, voice, 10, 1_000);
|
|
assert!(advisory_violations(&kind, &score).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn modify_event_crossing_the_region_end_bound_is_an_advisory_violation() {
|
|
let (score, region, _, voice) = bounded_score(12);
|
|
// A replacement value for an existing event, moved to straddle the
|
|
// bound. (The advisory check reads the replacement's span; liveness of
|
|
// the target is the reducer's invariant precondition.)
|
|
let target = score.canvas.regions[0].staff_instances()[0].voices[0].events[0];
|
|
let kind = OperationKind::ModifyEvent(ModifyEventOp {
|
|
event: valuegen::insert_event_value(
|
|
target,
|
|
voice,
|
|
MusicalPosition(RationalTime::from_int(11)),
|
|
MusicalDuration(RationalTime::from_int(3)),
|
|
&[PitchId::new(ReplicaId(50), 998)],
|
|
),
|
|
});
|
|
let violations = advisory_violations(&kind, &score);
|
|
assert_eq!(
|
|
violations,
|
|
vec![AdvisoryViolation::DurationCrossesRegionBoundary {
|
|
event: target,
|
|
region,
|
|
}]
|
|
);
|
|
// The same replacement kept inside the bound passes.
|
|
let kind = OperationKind::ModifyEvent(ModifyEventOp {
|
|
event: valuegen::insert_event_value(
|
|
target,
|
|
voice,
|
|
MusicalPosition(RationalTime::from_int(11)),
|
|
MusicalDuration(RationalTime::from_int(1)),
|
|
&[PitchId::new(ReplicaId(50), 998)],
|
|
),
|
|
});
|
|
assert!(advisory_violations(&kind, &score).is_empty());
|
|
}
|
|
|
|
/// 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;
|
|
let end_region = other.canvas.regions[0].id;
|
|
let start_event = score.canvas.regions[0].staff_instances()[0].voices[0].events[0];
|
|
let end_event = other.canvas.regions[0].staff_instances()[0].voices[0].events[0];
|
|
score.canvas.regions.push(other.canvas.regions[0].clone());
|
|
for event in other.events.iter_canonical() {
|
|
score
|
|
.events
|
|
.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)),
|
|
});
|
|
assert_eq!(
|
|
advisory_violations(&cross, &score),
|
|
vec![AdvisoryViolation::SlurSpansRegionBoundary {
|
|
slur: slur_id,
|
|
start_region,
|
|
end_region,
|
|
}]
|
|
);
|
|
|
|
// A slur within one region passes.
|
|
let second = score.canvas.regions[0].staff_instances()[0].voices[0].events[1];
|
|
let within = OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
|
structure: CrossCuttingValue::Slur(valuegen::slur(slur_id, start_event, second)),
|
|
});
|
|
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
|
|
// in replay mode. An envelope carrying an advisory-violating insert
|
|
// reduces cleanly (Applied) through the ordinary reduction path, and
|
|
// the reduction's canonical bytes are a pure function of the operation
|
|
// set — consulting `advisory_violations` beforehand (as an authoring
|
|
// layer would) changes nothing.
|
|
use crate::causal::CausalContext;
|
|
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
|
use crate::support::AuthorId;
|
|
use crate::{OperationEnvelope, OperationPayload, OperationSet};
|
|
use epiphany_core::{OperationId, WallClockTime};
|
|
|
|
let (score, _, instance, voice) = bounded_score(12);
|
|
let kind = insert_kind(instance, voice, 10, 4);
|
|
assert!(
|
|
!advisory_violations(&kind, &score).is_empty(),
|
|
"the scenario must actually violate an advisory precondition"
|
|
);
|
|
|
|
let id = OperationId::new(ReplicaId(50), 0);
|
|
let env = OperationEnvelope {
|
|
id,
|
|
author: AuthorId(0),
|
|
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id),
|
|
causal_context: CausalContext::new(),
|
|
transaction: None,
|
|
payload: OperationPayload::Primitive(kind),
|
|
};
|
|
let mut set = OperationSet::new();
|
|
set.accept(env);
|
|
let first = set.reduce_onto(&score);
|
|
assert!(first.state.is_clean(), "replay applies the insert cleanly");
|
|
assert!(matches!(
|
|
first.state.effects.as_slice(),
|
|
[(applied, crate::OperationEffect::Applied)] if *applied == id
|
|
));
|
|
// Byte-identity across repeated reductions of the same set — the mode
|
|
// machinery has no channel into reduction.
|
|
let second = set.reduce_onto(&score);
|
|
assert_eq!(
|
|
first.state.canonical_bytes(),
|
|
second.state.canonical_bytes()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn validation_mode_advisory_enforcement_is_authoring_only() {
|
|
assert!(ValidationMode::Authoring.enforces_advisory());
|
|
assert!(!ValidationMode::Replay.enforces_advisory());
|
|
}
|
|
|
|
#[test]
|
|
fn ill_formed_volta_endings_are_an_advisory_violation() {
|
|
// operation_catalog §"Repeat Structures", authoring advisory: endings
|
|
// non-empty, 1-based, strictly ascending — advisory only.
|
|
let score = valid_score(7);
|
|
let e1 = EventId::new(ReplicaId(1), 1);
|
|
let e2 = EventId::new(ReplicaId(1), 2);
|
|
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1);
|
|
|
|
let well_formed = valuegen::volta_repeat(rid, e1, e2);
|
|
let kind = |repeat| {
|
|
OperationKind::CreateRepeatStructure(crate::payload::CreateRepeatStructureOp { repeat })
|
|
};
|
|
assert!(
|
|
advisory_violations(&kind(well_formed.clone()), &score).is_empty(),
|
|
"ascending 1-based endings pass"
|
|
);
|
|
|
|
for endings in [vec![], vec![0], vec![2, 2], vec![3, 1]] {
|
|
let mut repeat = well_formed.clone();
|
|
repeat.voltas[0].endings = endings.clone();
|
|
assert_eq!(
|
|
advisory_violations(&kind(repeat), &score),
|
|
vec![AdvisoryViolation::VoltaEndingsIllFormed { repeat: rid }],
|
|
"endings {endings:?} must report"
|
|
);
|
|
}
|
|
|
|
// A simple repeat carries no voltas: vacuous pass.
|
|
assert!(
|
|
advisory_violations(&kind(valuegen::repeat_structure(rid, e1, e2)), &score).is_empty()
|
|
);
|
|
}
|
|
}
|