epiphany/crates/epiphany-core/src/invariants.rs

6399 lines
273 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The Chapter 5 graph invariants (Chapter 5 §"Graph Invariants").
//!
//! The spec enumerates a set of structural invariants every well-formed score
//! graph must satisfy. They are *property tests in CI, not runtime assertions
//! in release builds* (QUICKSTART, Agent B): this module is the checker the
//! property tests and generators (see [`crate::generators`]) drive. Each
//! enumerated invariant has exactly one check returning a typed
//! [`InvariantViolation`] witness identifying the smallest offending objects.
//!
//! **Count.** [`GraphInvariant::all`] is the single origin: its length *is* the
//! count, and no prose here restates it — a restated count goes stale silently
//! the next time the enumeration grows, which it has twice.
//!
//! The QUICKSTART's summary count disagrees with `core_spec.tex`'s own Chapter 5
//! enumeration; that discrepancy is recorded as a Pass 11 candidate in
//! `DECISIONS.md` (the spec is the contract) and is not reconciled here. Beyond
//! the spec body's original set, two rungs have appended: Genesis tranche G3b
//! (`spec/CONTRACT_GENESIS_G3B_MEASURE.md`) added measure-meter consistency
//! (see [`GraphInvariant::MeasureMeterConsistency`]), and P13-S16
//! (`spec/CONTRACT_P13S16_PROJECTION.md`) added staff-group membership
//! agreement (see [`GraphInvariant::StaffGroupMembershipAgreement`]).
//!
//! **Scope of structural decidability.** A few invariants depend on resolving
//! [`crate::TimeAnchor`]s to absolute time (region time-overlap, anchor-offset
//! agreement), which in general needs the full tempo/measure machinery that is
//! out of this crate's scope. Those checks are *sound but incomplete*: they
//! flag the cases this prototype can resolve (notably wall-clock-anchored
//! extents) and never raise a false positive. This is documented per check.
use std::cmp::Ordering;
use std::collections::{BTreeSet, HashMap, HashSet};
use crate::event::Event;
use crate::graph::{
derive_promoted_voice_id, CoordinateDiscipline, MeterChange, Region, RegionTimeModel, Score,
TieClass, TimeSignature, VoiceOrigin,
};
use crate::ids::{
EventId, MeasureId, PitchId, RegionId, ReplicaId, StaffGroupId, StaffId, StaffInstanceId,
TimeSignatureId, VoiceId,
};
use crate::pitch::{PitchSpaceId, SpellingDirective, SpellingScope};
use crate::time::{
AnchorOffset, ConcreteDuration, EventDuration, EventPosition, MeasurePosition, MusicalDuration,
MusicalPosition, OffsetKind, TimeAnchor, WallClockDuration,
};
/// The Chapter 5 graph invariants, numbered as in §"Graph Invariants".
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub enum GraphInvariant {
/// 1. Every arena event's `voice` points to a voice that lists it.
EventVoiceBacklink,
/// 2. Every event in a voice's list back-points to that voice.
VoiceEventBacklink,
/// 3. Events within a voice are sorted by position and do not overlap.
VoiceEventsSortedNonOverlap,
/// 4. Event coordinate variants agree with the region's time model.
EventCoordinateModel,
/// 5. Containment is a tree: each voice/instance has exactly one parent.
ContainmentTree,
/// 6. Each `StaffInstance.staff` resolves; no `StaffId` twice in a region.
StaffInstanceResolves,
/// 7. Region extents don't both overlap; `staff_extent` matches instances.
RegionExtents,
/// 8. Each measure belongs to exactly one staff instance.
MeasureSingleInstance,
/// 9. Each anchor's offset variant agrees with its target's time model.
AnchorOffsetModel,
/// 10. Every graph reference resolves to an extant object: cross-cutting
/// structures (incl. anchor targets, annotation layers, tuplet parents,
/// graphic objects) and event-internal references (indeterminate
/// alternatives, trajectory event-pitches, graphic objects, cue sources);
/// structural top-level references (a staff's declared instrument, a
/// staff's group, a staff group's members, a part's staves, a view's
/// active layers — genesis tranche G3a repairs this prose to name what
/// the check body already enforced); and meter/time-signature
/// references at every level a `MeterChange` can appear (a region's
/// time-model meter changes, a region's default metric grid, a
/// measure's declared time signature, a staff instance's local metric
/// grid).
CrossCuttingRefsResolve,
/// 11. Identifiers are unique within their kind (every id kind), with
/// reserved-namespace (`SYSTEM_DERIVED`) misuse, tombstone/live
/// collisions, and arena index/well-formedness integrity also enforced.
UniqueIdentifiers,
/// 12. Every embedded `PitchId` is unique in the pitch-identity index.
PitchIdUnique,
/// 13. Every `SpellingScope::Pitch` resolves to a live/tombstoned pitch.
SpellingScopeResolves,
/// 14. Every decomposition target resolves to a live/tombstoned event.
DecompositionTargetResolves,
/// 15. Live decomposition component durations sum to the event duration.
DecompositionSum,
/// 16. Tuplet member durations sum to the required total.
TupletSum,
/// 17. Tie pairings reference pitches of the endpoints; class rules hold.
TiePairing,
/// 18. Voice origin is consistent; promoted ids match the derivation.
VoiceOriginConsistent,
/// 19. Barline-group members stay within one region.
BarlineGroupSameRegion,
/// 20. Genesis tranche G3b (`spec/CONTRACT_GENESIS_G3B_MEASURE.md` pins
/// 6/6b/6c/9b): a measure's declared time signature AGREES with the
/// effective metric grid's active signature at its start, and
/// consecutive measure starts are separated by the governing
/// signature's `measure_duration()` (BOUNDARY consistency).
/// `time_signature: None` exempts only the agreement clause — the
/// inherited meter still governs boundary consistency. This check
/// does NOT duplicate invariant 10's signature-*resolution* check
/// (`CrossCuttingRefsResolve`): it only compares ALREADY-RESOLVING
/// signatures. It ABSTAINS — emits no violation — wherever pin 6's
/// comparable relation or pin 6b's musical delta cannot decide the
/// comparison (base-ingested data may predate the rule); this is
/// deliberate abstention, not a soundness gap (pin 7). A
/// pickup/anacrusis first measure has no predecessor for the
/// boundary clause, which is vacuous for it; the agreement clause
/// has no such exemption and still applies. Its successor is
/// measured against the governing signature's full
/// `measure_duration()` and can be refused and flagged (P13-S19,
/// deferred).
MeasureMeterConsistency,
/// 21. `Staff.group` and `StaffGroup.members` **agree in both directions**:
/// every live staff naming a group appears in that group's `members`,
/// and every staff a group lists names that group in its own `group`
/// field. `Staff.group` is the sole authority; `members` is the
/// projection maintained from it under reduction (P13-S16 pins 1, 2, 5).
///
/// Witnesses name **both ids and the direction**, because the two
/// directions fail for different reasons and are checked by two
/// separately dispatched methods (P13-S16 pin 6b) — a maintenance gap
/// leaves a staff absent from `members`, while a stale projection lists
/// a staff that no longer names the group.
StaffGroupMembershipAgreement,
}
impl GraphInvariant {
/// This invariant's position in `core_spec.tex` §"Graph Invariants",
/// numbered from 1. Deliberately count-free: the highest number is whatever
/// the last arm below returns, not a figure restated in prose.
pub fn number(self) -> u8 {
use GraphInvariant::*;
match self {
EventVoiceBacklink => 1,
VoiceEventBacklink => 2,
VoiceEventsSortedNonOverlap => 3,
EventCoordinateModel => 4,
ContainmentTree => 5,
StaffInstanceResolves => 6,
RegionExtents => 7,
MeasureSingleInstance => 8,
AnchorOffsetModel => 9,
CrossCuttingRefsResolve => 10,
UniqueIdentifiers => 11,
PitchIdUnique => 12,
SpellingScopeResolves => 13,
DecompositionTargetResolves => 14,
DecompositionSum => 15,
TupletSum => 16,
TiePairing => 17,
VoiceOriginConsistent => 18,
BarlineGroupSameRegion => 19,
MeasureMeterConsistency => 20,
StaffGroupMembershipAgreement => 21,
}
}
/// Every invariant, in enumeration order. The array's own length is the
/// authoritative count — see the module header's **Count** note.
pub fn all() -> [GraphInvariant; 21] {
use GraphInvariant::*;
[
EventVoiceBacklink,
VoiceEventBacklink,
VoiceEventsSortedNonOverlap,
EventCoordinateModel,
ContainmentTree,
StaffInstanceResolves,
RegionExtents,
MeasureSingleInstance,
AnchorOffsetModel,
CrossCuttingRefsResolve,
UniqueIdentifiers,
PitchIdUnique,
SpellingScopeResolves,
DecompositionTargetResolves,
DecompositionSum,
TupletSum,
TiePairing,
VoiceOriginConsistent,
BarlineGroupSameRegion,
MeasureMeterConsistency,
StaffGroupMembershipAgreement,
]
}
}
/// A violation of a graph invariant: which invariant, and a short witness
/// naming the smallest offending objects (Chapter 5; QUICKSTART: "minimizes
/// invariant violations to a small witness for debugging").
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct InvariantViolation {
pub invariant: GraphInvariant,
pub witness: String,
}
impl InvariantViolation {
fn new(invariant: GraphInvariant, witness: impl Into<String>) -> Self {
InvariantViolation {
invariant,
witness: witness.into(),
}
}
}
impl core::fmt::Display for InvariantViolation {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"invariant {} ({:?}) violated: {}",
self.invariant.number(),
self.invariant,
self.witness
)
}
}
/// A Chapter 5 well-formedness check this crate could not *decide* for a given
/// score — e.g., a region-overlap test whose extents use symbolic
/// [`crate::TimeAnchor`]s that need the full tempo/measure machinery (out of this
/// crate's scope) to place on a common timeline.
///
/// [`check_invariants`] stays *sound* — it never raises a false positive on an
/// undecidable check — but a sound-and-silent checker would treat "couldn't
/// decide" identically to "clean". [`deferred_checks`] makes the undecided cases
/// explicit and testable instead, so a stricter conformance profile can choose
/// to reject them rather than have them pass unseen.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct DeferredCheck {
/// The invariant whose decision was deferred.
pub invariant: GraphInvariant,
/// A human-readable witness explaining why it could not be decided.
pub reason: String,
}
impl core::fmt::Display for DeferredCheck {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"invariant {} ({:?}) deferred: {}",
self.invariant.number(),
self.invariant,
self.reason
)
}
}
/// The well-formedness checks [`check_invariants`] could not decide for `score`.
///
/// Currently this is region-overlap pairs that share a staff extent but whose
/// time overlap does not resolve to a common timeline (symbolic anchors needing
/// tempo/measure resolution). An empty result means every modelled invariant was
/// fully decided. The core checker reports these rather than silently accepting
/// them as valid; the caller decides how strict to be.
pub fn deferred_checks(score: &Score) -> Vec<DeferredCheck> {
let idx = GraphIndex::build(score);
let mut out = Vec::new();
idx.deferred_region_overlaps(&mut out);
out
}
/// Checks every Chapter 5 graph invariant over `score`, returning all
/// violations found (empty iff the graph is well-formed).
///
/// This is *sound but incomplete* for the few invariants that need absolute-time
/// resolution: it never raises a false positive, and the cases it could not
/// decide are reported separately by [`deferred_checks`] rather than silently
/// passed.
pub fn check_invariants(score: &Score) -> Vec<InvariantViolation> {
let idx = GraphIndex::build(score);
let mut v = Vec::new();
idx.check_event_voice_backlink(&mut v);
idx.check_voice_event_backlink(&mut v);
idx.check_voice_events_sorted_non_overlap(&mut v);
idx.check_event_coordinate_model(&mut v);
idx.check_containment_tree(&mut v);
idx.check_staff_instance_resolves(&mut v);
idx.check_region_extents(&mut v);
idx.check_measure_single_instance(&mut v);
idx.check_anchor_offset_model(&mut v);
idx.check_cross_cutting_refs(&mut v);
idx.check_tempo_maps(&mut v);
idx.check_aleatoric_models(&mut v);
idx.check_accidental_modification_compatibility(&mut v);
idx.check_unique_identifiers(&mut v);
idx.check_pitch_id_unique(&mut v);
idx.check_spelling_scope_resolves(&mut v);
idx.check_decomposition_target_resolves(&mut v);
idx.check_decomposition_sum(&mut v);
idx.check_tuplet_sum(&mut v);
idx.check_tie_pairing(&mut v);
idx.check_voice_origin_consistent(&mut v);
idx.check_barline_group_same_region(&mut v);
idx.check_measure_meter_consistency(&mut v);
// Invariant 21's two directions, dispatched separately (P13-S16 pin 6b).
// Each call site is independently deletable, which is what M6a and M6b
// delete; a single call handling both would leave the mutation with no way
// to fail one direction while the other still reports.
idx.check_staff_names_absent_group(&mut v);
idx.check_group_lists_unowned_staff(&mut v);
v
}
/// Cross-crate agreement-test oracle hook for the pin 6/6b comparable
/// relation and musical delta (G3b packet 2 architecture note: exposed so
/// `epiphany-testkit`'s cross-crate agreement test can drive the SAME
/// anchor pairs through this crate's independent invariant-20
/// implementation ([`GraphIndex::measure20_comparable_order`] /
/// [`GraphIndex::measure20_musical_delta`]) and through `epiphany-ops`'s
/// `Reducer` (which computes the identical normative relation privately,
/// over operational write chains rather than a materialized graph) and
/// assert they agree. This is the guard against maintaining one normative
/// relation in two places without sharing code: the dependency direction
/// (`epiphany-ops` depends on `epiphany-core`, never the reverse) forbids
/// `epiphany-core` from calling into `epiphany-ops`, so invariant 20 cannot
/// reuse the reducer's private methods, and there is no third crate either
/// could delegate to.
pub fn measure_anchor_relation(
score: &Score,
a: &TimeAnchor,
b: &TimeAnchor,
) -> (Option<Ordering>, Option<MusicalDuration>) {
let idx = GraphIndex::build(score);
(
idx.measure20_comparable_order(a, b),
idx.measure20_musical_delta(a, b),
)
}
/// Checks a single invariant (useful for targeted negative property tests).
pub fn check_invariant(score: &Score, which: GraphInvariant) -> Vec<InvariantViolation> {
check_invariants(score)
.into_iter()
.filter(|v| v.invariant == which)
.collect()
}
/// Pre-computed cross-references over a score, built once per check pass.
struct GraphIndex<'a> {
score: &'a Score,
/// Voice id -> the voice (also flags duplicate voice ids).
voice: HashMap<VoiceId, &'a crate::graph::Voice>,
/// Voice id -> its (region, instance) parent; absent if the voice id is
/// duplicated across instances (a containment violation).
voice_parent: HashMap<VoiceId, (RegionId, StaffInstanceId)>,
/// Region id -> coordinate discipline.
region_discipline: HashMap<RegionId, CoordinateDiscipline>,
/// Instance id -> its region (also flags duplicate instance ids).
instance_region: HashMap<StaffInstanceId, RegionId>,
/// Event id -> (voice, index) from the voices' ordered event lists.
event_voice_index: HashMap<EventId, (VoiceId, usize)>,
/// Event id -> the staff instance whose voice lists it.
event_instance: HashMap<EventId, StaffInstanceId>,
/// Event id -> the pitch ids it embeds.
event_pitches: HashMap<EventId, BTreeSet<PitchId>>,
/// Pitch id -> the embedded pitch.
pitch: HashMap<PitchId, &'a crate::pitch::Pitch>,
/// Live pitch ids in the arena.
live_pitches: BTreeSet<PitchId>,
/// Measure id -> the instance(s) listing it.
measure_instances: HashMap<MeasureId, Vec<StaffInstanceId>>,
/// Measure id -> its start anchor (for anchor resolution).
measure_start: HashMap<MeasureId, &'a TimeAnchor>,
/// Region id -> the region (for anchor resolution and reference checks).
region_by_id: HashMap<RegionId, &'a Region>,
/// Declared score-level staff ids.
declared_staves: BTreeSet<StaffId>,
/// Graphic-object ids stored across all regions' graphic content.
graphic_objects: BTreeSet<crate::ids::GraphicObjectId>,
/// Declared analysis-layer ids.
analysis_layers: BTreeSet<crate::ids::AnalysisLayerId>,
/// Tuplet ids (for `Tuplet::parent` resolution).
tuplet_ratios: HashMap<crate::ids::TupletId, crate::graph::TupletRatio>,
}
impl<'a> GraphIndex<'a> {
fn build(score: &'a Score) -> Self {
let mut voice = HashMap::new();
let mut voice_dup: HashSet<VoiceId> = HashSet::new();
let mut voice_parent = HashMap::new();
let mut region_discipline = HashMap::new();
let mut instance_region = HashMap::new();
let mut instance_dup: HashSet<StaffInstanceId> = HashSet::new();
let mut event_voice_index = HashMap::new();
let mut event_instance = HashMap::new();
let mut measure_instances: HashMap<MeasureId, Vec<StaffInstanceId>> = HashMap::new();
let mut measure_start = HashMap::new();
let mut region_by_id = HashMap::new();
for region in &score.canvas.regions {
region_discipline.insert(region.id, region.time_model.coordinate_discipline());
region_by_id.insert(region.id, region);
for si in region.staff_instances() {
if instance_region.insert(si.id, region.id).is_some() {
instance_dup.insert(si.id);
}
for m in &si.measures {
measure_instances.entry(m.id).or_default().push(si.id);
measure_start.entry(m.id).or_insert(&m.start);
}
for v in &si.voices {
if voice.insert(v.id, v).is_some() {
voice_dup.insert(v.id);
}
voice_parent.insert(v.id, (region.id, si.id));
for (ix, e) in v.events.iter().enumerate() {
event_voice_index.insert(*e, (v.id, ix));
event_instance.insert(*e, si.id);
}
}
}
}
// A duplicated voice/instance has no single parent: drop it so the
// containment check (5) owns the report rather than other checks
// silently picking one parent.
for v in &voice_dup {
voice_parent.remove(v);
}
let mut event_pitches: HashMap<EventId, BTreeSet<PitchId>> = HashMap::new();
let mut pitch = HashMap::new();
let mut live_pitches = BTreeSet::new();
let mut buf = Vec::new();
for e in score.events.iter() {
buf.clear();
e.collect_identified_pitches(&mut buf);
let set = event_pitches.entry(e.id()).or_default();
for ip in &buf {
set.insert(ip.id);
pitch.insert(ip.id, &ip.pitch);
live_pitches.insert(ip.id);
}
}
let declared_staves = score.staves.iter().map(|s| s.id).collect();
let graphic_objects = score
.canvas
.regions
.iter()
.flat_map(|r| r.content.graphic_objects().iter().map(|o| o.id))
.collect();
let analysis_layers = score.analysis_layers.iter().map(|l| l.id).collect();
let tuplet_ratios = score
.cross_cutting
.tuplets
.iter()
.map(|t| (t.id, t.ratio))
.collect();
GraphIndex {
score,
voice,
voice_parent,
region_discipline,
instance_region,
event_voice_index,
event_instance,
event_pitches,
pitch,
live_pitches,
measure_instances,
measure_start,
region_by_id,
declared_staves,
graphic_objects,
analysis_layers,
tuplet_ratios,
}
}
// --- Anchor resolution (shared by invariants 7, 9, 10). -----------------
/// Resolves a [`TimeAnchor`] to an absolute coordinate on a common timeline
/// where this prototype can — `WallClock` anchors directly, `Event` anchors
/// via the target event's region origin **plus** its region-relative
/// position, `Measure` *start* anchors and `Region` edges recursively —
/// applying the anchor's offset. Returns `None` when the target is missing,
/// the clocks disagree, or a coordinate cannot be placed without the
/// deferred tempo/measure-length machinery (`Measure` *end*, a musical event
/// position on a wall-clock-placed region, curved tempo, …). Depth-guarded
/// against cyclic region/measure references. Sound, never a false coordinate.
///
/// The result is an absolute **wall-clock** nanosecond coordinate. In this
/// model the only absolute leaf is a [`TimeAnchor::WallClock`]; musical
/// positions are region-relative and placing them on the canvas needs the
/// deferred musical→wall-clock tempo map, so any musical local position or
/// musical offset makes the anchor unresolvable (`None`) rather than wrong.
fn resolve_anchor(&self, anchor: &TimeAnchor, depth: u8) -> Option<i64> {
if depth == 0 {
return None;
}
match anchor {
TimeAnchor::WallClock { time } => Some(time.0),
TimeAnchor::Event { id, offset } => {
// Event positions are *region-relative* (Chapter 5
// §"Event Position and Duration"), so the absolute coordinate is
// the event's region origin plus its local position — resolvable
// only when the local position is wall-clock (a musical local
// position needs the deferred tempo map to place on the canvas).
let si = self.event_instance.get(id)?;
let region = self.region_by_id.get(self.instance_region.get(si)?)?;
let origin = self.resolve_anchor(&region.time_extent.start, depth - 1)?;
let local = match self.score.events.get(*id)?.position() {
EventPosition::WallClock(t) => t.0,
// A musical position is placed on the wall-clock timeline
// through the region's effective tempo map (its
// `local_tempo_map`, else the score map) — the conversion
// this prototype previously declined. When no tempo is
// defined, or the map is piecewise/curved beyond the stub,
// the conversion declines (`None`): sound but incomplete,
// never a false coordinate.
EventPosition::Musical(p) => {
let tm = region
.local_tempo_map
.as_ref()
.unwrap_or(&self.score.tempo_map);
tm.musical_to_wallclock(p).ok()?.0
}
};
// Checked: a pathological coordinate sum is reported as
// unresolvable, never a panic (release builds have overflow
// checks on).
apply_offset(origin.checked_add(local)?, offset)
}
TimeAnchor::Measure {
id,
position,
offset,
} => {
// Only the measure *start* is resolvable without the measure's
// length (which needs the deferred decomposition/tempo machinery).
if *position != crate::time::MeasurePosition::Start {
return None;
}
let start = self.measure_start.get(id)?;
let base = self.resolve_anchor(start, depth - 1)?;
apply_offset(base, offset)
}
TimeAnchor::Region { id, edge, offset } => {
let region = self.region_by_id.get(id)?;
let edge_anchor = match edge {
crate::time::RegionEdge::Start => &region.time_extent.start,
crate::time::RegionEdge::End => &region.time_extent.end,
};
let base = self.resolve_anchor(edge_anchor, depth - 1)?;
apply_offset(base, offset)
}
}
}
/// Whether the object a non-wall-clock anchor points at exists in the graph
/// (used by invariant 10 for cross-cutting anchor endpoints). Wall-clock
/// anchors reference no object, so they always resolve.
fn anchor_target_exists(&self, anchor: &TimeAnchor) -> bool {
match anchor {
TimeAnchor::WallClock { .. } => true,
TimeAnchor::Event { id, .. } => self.score.events.contains(*id),
TimeAnchor::Measure { id, .. } => self.measure_start.contains_key(id),
TimeAnchor::Region { id, .. } => self.region_by_id.contains_key(id),
}
}
// --- 1. Event -> voice backlink. ----------------------------------------
fn check_event_voice_backlink(&self, out: &mut Vec<InvariantViolation>) {
for e in self.score.events.iter() {
let vid = e.voice();
match self.voice.get(&vid) {
None => out.push(InvariantViolation::new(
GraphInvariant::EventVoiceBacklink,
format!(
"event {:?} names voice {:?}, which is not in the graph",
e.id(),
vid
),
)),
Some(v) if !v.events.contains(&e.id()) => out.push(InvariantViolation::new(
GraphInvariant::EventVoiceBacklink,
format!(
"event {:?} names voice {:?}, which does not list it",
e.id(),
vid
),
)),
_ => {}
}
}
}
// --- 2. Voice -> event backlink. ----------------------------------------
fn check_voice_event_backlink(&self, out: &mut Vec<InvariantViolation>) {
for (_r, _si, v) in self.score.voices() {
for e in &v.events {
match self.score.events.get(*e) {
None => out.push(InvariantViolation::new(
GraphInvariant::VoiceEventBacklink,
format!(
"voice {:?} lists event {:?}, absent from the arena",
v.id, e
),
)),
Some(ev) if ev.voice() != v.id => out.push(InvariantViolation::new(
GraphInvariant::VoiceEventBacklink,
format!(
"voice {:?} lists event {:?} whose voice is {:?}",
v.id,
e,
ev.voice()
),
)),
_ => {}
}
}
}
}
// --- 3. Events sorted and non-overlapping within a voice. ---------------
fn check_voice_events_sorted_non_overlap(&self, out: &mut Vec<InvariantViolation>) {
for (_r, _si, v) in self.score.voices() {
let mut prev: Option<(EventId, Endpoints)> = None;
for e in &v.events {
let Some(ev) = self.score.events.get(*e) else {
continue; // absence is invariant 2's report
};
let cur = Endpoints::of(ev);
if let Some((pe, pep)) = &prev {
if let (Some(p_end), Some(c_start)) = (pep.end_key(), cur.start_key()) {
if !p_end.le_same_clock(&c_start) {
// Either out of order (start < prev start) or
// overlapping (prev end > cur start).
out.push(InvariantViolation::new(
GraphInvariant::VoiceEventsSortedNonOverlap,
format!(
"in voice {:?}, event {:?} starts before event {:?} ends",
v.id, e, pe
),
));
}
}
}
prev = Some((*e, cur));
}
}
}
// --- 4. Coordinate variants agree with the region's time model. ---------
fn check_event_coordinate_model(&self, out: &mut Vec<InvariantViolation>) {
for e in self.score.events.iter() {
let Some((region, _)) = self.voice_parent.get(&e.voice()) else {
continue; // unparented voice: invariant 1/5 reports it
};
let Some(disc) = self.region_discipline.get(region) else {
continue;
};
if !coordinate_ok(e, *disc) {
out.push(InvariantViolation::new(
GraphInvariant::EventCoordinateModel,
format!(
"event {:?} coordinates {:?}/{:?} contradict region {:?} discipline {:?}",
e.id(),
e.position().kind(),
e.duration().concrete_kind(),
region,
disc
),
));
}
}
}
// --- 5. Containment is a tree. ------------------------------------------
fn check_containment_tree(&self, out: &mut Vec<InvariantViolation>) {
// Voice id appearing under more than one instance.
let mut voice_seen: HashMap<VoiceId, StaffInstanceId> = HashMap::new();
for (_r, si, v) in self.score.voices() {
if let Some(prev) = voice_seen.insert(v.id, si) {
if prev != si {
out.push(InvariantViolation::new(
GraphInvariant::ContainmentTree,
format!(
"voice {:?} appears in instances {:?} and {:?}",
v.id, prev, si
),
));
}
}
}
// Instance id appearing under more than one region.
let mut inst_seen: HashMap<StaffInstanceId, RegionId> = HashMap::new();
for r in &self.score.canvas.regions {
for si in r.staff_instances() {
if let Some(prev) = inst_seen.insert(si.id, r.id) {
if prev != r.id {
out.push(InvariantViolation::new(
GraphInvariant::ContainmentTree,
format!(
"staff instance {:?} appears in regions {:?} and {:?}",
si.id, prev, r.id
),
));
}
}
}
}
}
// --- 6. Instance.staff resolves; no StaffId twice in one region. --------
fn check_staff_instance_resolves(&self, out: &mut Vec<InvariantViolation>) {
for r in &self.score.canvas.regions {
let mut staff_in_region: HashSet<StaffId> = HashSet::new();
for si in r.staff_instances() {
if !self.declared_staves.contains(&si.staff) {
out.push(InvariantViolation::new(
GraphInvariant::StaffInstanceResolves,
format!(
"staff instance {:?} references undeclared staff {:?}",
si.id, si.staff
),
));
}
if !staff_in_region.insert(si.staff) {
out.push(InvariantViolation::new(
GraphInvariant::StaffInstanceResolves,
format!(
"staff {:?} is manifested by two instances in region {:?}",
si.staff, r.id
),
));
}
}
}
}
// --- 7. Region extents: staff_extent matches; no double overlap. --------
fn check_region_extents(&self, out: &mut Vec<InvariantViolation>) {
for r in &self.score.canvas.regions {
// staff_extent must list exactly the manifested staves, no dups.
let manifested: BTreeSet<StaffId> =
r.staff_instances().iter().map(|si| si.staff).collect();
let mut listed = BTreeSet::new();
for s in &r.staff_extent.staves {
if !listed.insert(*s) {
out.push(InvariantViolation::new(
GraphInvariant::RegionExtents,
format!("region {:?} staff_extent lists staff {:?} twice", r.id, s),
));
}
}
if listed != manifested {
out.push(InvariantViolation::new(
GraphInvariant::RegionExtents,
format!(
"region {:?} staff_extent {:?} != manifested staves {:?}",
r.id, listed, manifested
),
));
}
}
// No two regions overlap in both time and staff extent. Time overlap
// is decided on a common resolved timeline (wall-clock, event/region/
// measure-start anchors). Pairs whose extents cannot be resolved to a
// common clock are *not* silently passed: they are reported as undecided
// by `deferred_checks` (via `deferred_region_overlaps`) rather than
// treated as disjoint here. This check stays sound — it only flags a
// proven overlap.
let regions = &self.score.canvas.regions;
for i in 0..regions.len() {
for j in (i + 1)..regions.len() {
let (a, b) = (&regions[i], &regions[j]);
if !a.staff_extent_intersects(b) {
continue;
}
if self.regions_overlap_in_time(a, b) == Some(true) {
out.push(InvariantViolation::new(
GraphInvariant::RegionExtents,
format!(
"regions {:?} and {:?} overlap in both time and staff extent",
a.id, b.id
),
));
}
}
}
}
/// Region-overlap pairs that share a staff extent but whose time overlap is
/// undecidable here (symbolic anchors needing tempo/measure resolution).
/// Surfaced by [`deferred_checks`] so the undecided case is explicit, never
/// silently accepted as disjoint.
fn deferred_region_overlaps(&self, out: &mut Vec<DeferredCheck>) {
let regions = &self.score.canvas.regions;
for i in 0..regions.len() {
for j in (i + 1)..regions.len() {
let (a, b) = (&regions[i], &regions[j]);
if !a.staff_extent_intersects(b) {
continue;
}
if self.regions_overlap_in_time(a, b).is_none() {
out.push(DeferredCheck {
invariant: GraphInvariant::RegionExtents,
reason: format!(
"regions {:?} and {:?} share a staff extent but their time \
overlap is undecidable (symbolic anchors need tempo/measure \
resolution)",
a.id, b.id
),
});
}
}
}
}
/// `Some(true)`/`Some(false)` when both regions' extents resolve to absolute
/// wall-clock coordinates; `None` when they cannot be compared (deferred
/// tempo/measure machinery). Half-open: touching at a boundary is not an
/// overlap.
fn regions_overlap_in_time(&self, a: &Region, b: &Region) -> Option<bool> {
const MAX_DEPTH: u8 = 8;
let a0 = self.resolve_anchor(&a.time_extent.start, MAX_DEPTH)?;
let a1 = self.resolve_anchor(&a.time_extent.end, MAX_DEPTH)?;
let b0 = self.resolve_anchor(&b.time_extent.start, MAX_DEPTH)?;
let b1 = self.resolve_anchor(&b.time_extent.end, MAX_DEPTH)?;
Some(a0 < b1 && b0 < a1)
}
// --- 8. Each measure belongs to exactly one instance. -------------------
fn check_measure_single_instance(&self, out: &mut Vec<InvariantViolation>) {
for (mid, owners) in &self.measure_instances {
let distinct: BTreeSet<_> = owners.iter().copied().collect();
if distinct.len() > 1 {
out.push(InvariantViolation::new(
GraphInvariant::MeasureSingleInstance,
format!("measure {:?} belongs to instances {:?}", mid, distinct),
));
}
}
}
// --- 9. Anchor offset variant agrees with target's time model. ----------
fn check_anchor_offset_model(&self, out: &mut Vec<InvariantViolation>) {
for a in self.collect_anchors() {
if let Some(false) = self.offset_ok(a) {
out.push(InvariantViolation::new(
GraphInvariant::AnchorOffsetModel,
format!(
"anchor {:?} offset contradicts its target region's time model",
a
),
));
}
}
}
/// Every stored [`TimeAnchor`] reachable in the graph whose offset is
/// subject to invariant 9: region extents; metric-grid meter changes
/// (region default, instance-local, and the time model's own); measure
/// starts; clef and key changes; user system/page breaks; spanner
/// endpoints; and spelling-range scope endpoints.
fn collect_anchors(&self) -> Vec<&'a TimeAnchor> {
let mut anchors: Vec<&TimeAnchor> = Vec::new();
for r in &self.score.canvas.regions {
anchors.push(&r.time_extent.start);
anchors.push(&r.time_extent.end);
if let RegionTimeModel::Metric(m) = &r.time_model {
anchors.extend(m.meters.iter().map(|mc| &mc.anchor));
}
if let Some(c) = r.content.staff_based() {
if let Some(g) = &c.default_metric_grid {
anchors.extend(g.meter_sequence.iter().map(|mc| &mc.anchor));
}
anchors.extend(c.user_system_breaks.iter());
anchors.extend(c.user_page_breaks.iter());
}
for si in r.staff_instances() {
if let Some(g) = &si.local_metric_grid {
anchors.extend(g.meter_sequence.iter().map(|mc| &mc.anchor));
}
anchors.extend(si.measures.iter().map(|m| &m.start));
anchors.extend(si.clef_sequence.iter().map(|c| &c.anchor));
anchors.extend(si.key_sequence.iter().map(|k| &k.anchor));
}
}
let cc = &self.score.cross_cutting;
for sp in &cc.spanners {
anchors.push(&sp.start);
anchors.push(&sp.end);
}
for m in &cc.markers {
anchors.push(&m.anchor);
}
for rp in &cc.repeats {
// The shared site-set walk (start/end, kind jump targets, volta
// spans) — the same set reduction and the index consume.
anchors.extend(rp.anchor_sites());
}
for cs in &cc.chord_symbols {
anchors.push(&cs.anchor);
}
for an in &cc.analytical {
if let crate::graph::AnnotationAnchor::Range { start, end } = &an.anchor {
anchors.push(start);
anchors.push(end);
}
}
for cm in &cc.comments {
if let crate::graph::AnnotationAnchor::Range { start, end } = &cm.anchor {
anchors.push(start);
anchors.push(end);
}
}
for g in &cc.graphic_gestures {
if let crate::graph::GestureAnchoring::Range { start, end, .. } = &g.anchoring {
anchors.push(start);
anchors.push(end);
}
}
for a in &self.score.spelling_attachments {
if let SpellingScope::Range { start, end, .. } = &a.scope {
anchors.push(start);
anchors.push(end);
}
}
// Tempo-map segment boundaries are time anchors too (Chapter 3
// §"Tempo and the Tempo Map"): their offsets are subject to invariant 9.
for tm in self.tempo_maps() {
for seg in &tm.segments {
anchors.push(&seg.start);
if let Some(end) = &seg.end {
anchors.push(end);
}
}
}
anchors
}
/// Every tempo map in the score: the score-level map plus each region's
/// `local_tempo_map`.
fn tempo_maps(&self) -> impl Iterator<Item = &'a crate::tempo::TempoMap> {
std::iter::once(&self.score.tempo_map).chain(
self.score
.canvas
.regions
.iter()
.filter_map(|r| r.local_tempo_map.as_ref()),
)
}
/// `Some(true)`/`Some(false)` when the target region's discipline is
/// determinable; `None` when it is not (a sound-but-incomplete result).
fn offset_ok(&self, anchor: &TimeAnchor) -> Option<bool> {
use crate::graph::AleatoricAnchoringDiscipline as A;
let (offset, disc) = match anchor {
TimeAnchor::WallClock { .. } => return Some(true), // no offset
TimeAnchor::Region { id, offset, .. } => (offset, *self.region_discipline.get(id)?),
TimeAnchor::Event { id, offset } => {
let si = self.event_instance.get(id)?;
let region = self.instance_region.get(si)?;
let disc = *self.region_discipline.get(region)?;
// In an `EitherPerEvent` region the *event* fixes the clock, so
// an offset against it must match that event's coordinate kind —
// not merely "either" (Chapter 3 §"Aleatoric Time": an event's
// position and duration kinds must agree). This catches a
// musical offset on a wall-clock event.
if disc == CoordinateDiscipline::Aleatoric(A::EitherPerEvent) {
let event_kind = self.score.events.get(*id)?.position().kind();
return Some(offset_matches_kind(offset.kind(), event_kind));
}
(offset, disc)
}
TimeAnchor::Measure { id, offset, .. } => {
let owners = self.measure_instances.get(id)?;
let si = owners.first()?;
let region = self.instance_region.get(si)?;
(offset, *self.region_discipline.get(region)?)
}
};
Some(offset_matches(offset.kind(), disc))
}
// --- 10. Cross-cutting references resolve. ------------------------------
fn check_cross_cutting_refs(&self, out: &mut Vec<InvariantViolation>) {
let live_event = |e: &EventId| self.score.events.contains(*e);
let mut flag = |cond: bool, what: String| {
if !cond {
out.push(InvariantViolation::new(
GraphInvariant::CrossCuttingRefsResolve,
what,
));
}
};
for s in &self.score.cross_cutting.slurs {
flag(
live_event(&s.start_event),
format!("slur {:?} start_event dangling", s.id),
);
flag(
live_event(&s.end_event),
format!("slur {:?} end_event dangling", s.id),
);
}
for t in &self.score.cross_cutting.ties {
flag(
live_event(&t.start_event),
format!("tie {:?} start_event dangling", t.id),
);
flag(
live_event(&t.end_event),
format!("tie {:?} end_event dangling", t.id),
);
}
for b in &self.score.cross_cutting.beams {
for e in &b.events {
flag(
live_event(e),
format!("beam {:?} event {:?} dangling", b.id, e),
);
}
// Schema major 2: sub-beam member events are references too.
for sb in &b.sub_beams {
for e in &sb.events {
flag(
live_event(e),
format!("beam {:?} sub-beam event {:?} dangling", b.id, e),
);
}
}
}
for tp in &self.score.cross_cutting.tuplets {
for e in &tp.members {
flag(
live_event(e),
format!("tuplet {:?} member {:?} dangling", tp.id, e),
);
}
if let Some(parent) = tp.parent {
flag(
self.tuplet_ratios.contains_key(&parent),
format!("tuplet {:?} parent {:?} does not exist", tp.id, parent),
);
}
}
for sp in &self.score.cross_cutting.spanners {
for s in &sp.staves {
flag(
self.declared_staves.contains(s),
format!("spanner {:?} staff {:?} not declared", sp.id, s),
);
}
// The spanner's endpoint anchors are references too: their targeted
// event / measure / region must exist.
flag(
self.anchor_target_exists(&sp.start),
format!(
"spanner {:?} start anchor target {:?} dangling",
sp.id, sp.start
),
);
flag(
self.anchor_target_exists(&sp.end),
format!(
"spanner {:?} end anchor target {:?} dangling",
sp.id, sp.end
),
);
}
// The remaining reference-bearing cross-cutting structures.
let cc = &self.score.cross_cutting;
let annotation_anchor_ok = |a: &crate::graph::AnnotationAnchor| match a {
crate::graph::AnnotationAnchor::Event(e) => self.score.events.contains(*e),
crate::graph::AnnotationAnchor::Region(r) => self.region_by_id.contains_key(r),
crate::graph::AnnotationAnchor::Range { start, end } => {
self.anchor_target_exists(start) && self.anchor_target_exists(end)
}
};
for m in &cc.markers {
flag(
self.anchor_target_exists(&m.anchor),
format!("marker {:?} anchor target dangling", m.id),
);
}
for rp in &cc.repeats {
flag(
self.anchor_target_exists(&rp.start) && self.anchor_target_exists(&rp.end),
format!("repeat {:?} anchor target dangling", rp.id),
);
// Schema major 2: the kind's jump anchors and each volta's span.
let kind_ok = match &rp.kind {
crate::graph::RepeatKind::DaCapo { end_target } => {
self.anchor_target_exists(end_target)
}
crate::graph::RepeatKind::DalSegno { segno, end_target } => {
self.anchor_target_exists(segno) && self.anchor_target_exists(end_target)
}
crate::graph::RepeatKind::SimpleRepeat { .. } | crate::graph::RepeatKind::Volta => {
true
}
};
flag(
kind_ok,
format!("repeat {:?} kind anchor target dangling", rp.id),
);
for v in &rp.voltas {
flag(
self.anchor_target_exists(&v.start) && self.anchor_target_exists(&v.end),
format!("repeat {:?} volta anchor target dangling", rp.id),
);
}
}
for cs in &cc.chord_symbols {
flag(
self.anchor_target_exists(&cs.anchor),
format!("chord symbol {:?} anchor target dangling", cs.id),
);
}
for an in &cc.analytical {
flag(
annotation_anchor_ok(&an.anchor),
format!("analytical annotation {:?} anchor dangling", an.id),
);
if let Some(layer) = an.layer {
flag(
self.analysis_layers.contains(&layer),
format!(
"analytical annotation {:?} layer {:?} does not exist",
an.id, layer
),
);
}
}
for cm in &cc.comments {
flag(
annotation_anchor_ok(&cm.anchor),
format!("comment {:?} anchor dangling", cm.id),
);
}
for g in &cc.graphic_gestures {
for o in &g.objects {
flag(
self.graphic_objects.contains(o),
format!("gesture {:?} graphic object {:?} not stored", g.id, o),
);
}
match &g.anchoring {
crate::graph::GestureAnchoring::Events(es) => {
for e in es {
flag(
live_event(e),
format!("gesture {:?} event {:?} dangling", g.id, e),
);
}
}
crate::graph::GestureAnchoring::Range { start, end, staves } => {
flag(
self.anchor_target_exists(start) && self.anchor_target_exists(end),
format!("gesture {:?} range anchor dangling", g.id),
);
for s in staves {
flag(
self.declared_staves.contains(s),
format!("gesture {:?} staff {:?} not declared", g.id, s),
);
}
}
crate::graph::GestureAnchoring::Free => {}
}
}
for ly in &cc.lyrics {
for e in &ly.events {
flag(
live_event(e),
format!("lyric line {:?} event {:?} dangling", ly.id, e),
);
}
}
// Structural references: a staff's (and any per-instance override's)
// instrument must resolve to a declared `Instrument` (Chapter 5
// §"Top-Level Score Structure" / §"Instruments").
let instruments: BTreeSet<crate::ids::InstrumentId> =
self.score.instruments.iter().map(|i| i.id).collect();
for s in &self.score.staves {
flag(
instruments.contains(&s.instrument),
format!(
"staff {:?} instrument {:?} is not declared",
s.id, s.instrument
),
);
}
for (_r, si) in self.score.staff_instances() {
if let Some(instr) = si.instrument_override {
flag(
instruments.contains(&instr),
format!(
"staff instance {:?} instrument override {:?} is not declared",
si.id, instr
),
);
}
}
// Staff-group / part / view structural references must resolve to
// extant objects (Chapter 5 §"Top-Level Score Structure"): a staff's
// group, a group's members, a part's staves, a view's active layers.
let staff_groups: BTreeSet<crate::ids::StaffGroupId> =
self.score.staff_groups.iter().map(|g| g.id).collect();
for s in &self.score.staves {
if let Some(group) = s.group {
flag(
staff_groups.contains(&group),
format!("staff {:?} group {:?} is not declared", s.id, group),
);
}
}
for g in &self.score.staff_groups {
for m in &g.members {
flag(
self.declared_staves.contains(m),
format!(
"staff group {:?} member staff {:?} is not declared",
g.id, m
),
);
}
}
for p in &self.score.parts {
for s in &p.staves {
flag(
self.declared_staves.contains(s),
format!("part {:?} staff {:?} is not declared", p.id, s),
);
}
}
for v in &self.score.views {
for l in &v.active_layers {
flag(
self.analysis_layers.contains(l),
format!("view {:?} active layer {:?} is not declared", v.id, l),
);
}
}
// Time-signature references must resolve to a declared `TimeSignature`
// (Chapter 3 §"Time Signatures") — at every level a `MeterChange` can
// appear: per-measure, instance-local grids, the region-default grid,
// and the metric time model's own meter sequence.
let time_sigs: BTreeSet<crate::ids::TimeSignatureId> =
self.score.time_signatures.iter().map(|ts| ts.id).collect();
for r in &self.score.canvas.regions {
if let RegionTimeModel::Metric(m) = &r.time_model {
for mc in &m.meters {
flag(
time_sigs.contains(&mc.time_signature),
format!(
"region {:?} time-model meter change time signature {:?} is not declared",
r.id, mc.time_signature
),
);
}
}
if let Some(c) = r.content.staff_based() {
if let Some(g) = &c.default_metric_grid {
for mc in &g.meter_sequence {
flag(
time_sigs.contains(&mc.time_signature),
format!(
"region {:?} default-grid meter change time signature {:?} is not declared",
r.id, mc.time_signature
),
);
}
}
}
for si in r.staff_instances() {
for m in &si.measures {
if let Some(ts) = m.time_signature {
flag(
time_sigs.contains(&ts),
format!("measure {:?} time signature {:?} is not declared", m.id, ts),
);
}
}
if let Some(g) = &si.local_metric_grid {
for mc in &g.meter_sequence {
flag(
time_sigs.contains(&mc.time_signature),
format!(
"instance {:?} meter change time signature {:?} is not declared",
si.id, mc.time_signature
),
);
}
}
}
}
// Decomposition components' tuplet references must resolve (Chapter 3):
// a dangling `TupletId` would otherwise be silently treated as "no
// tuplet" (the component left unscaled), which can let an inconsistent
// decomposition slip past invariant 15.
for d in &self.score.decomposition_attachments {
for c in &d.components {
if let Some(t) = c.tuplet {
flag(
self.tuplet_ratios.contains_key(&t),
format!(
"decomposition of event {:?} references tuplet {:?}, which does not exist",
d.target, t
),
);
}
}
}
// Event-internal references must resolve too (Chapter 5: the graph's
// references resolve to extant objects). These are not cross-cutting
// structures but they bear graph references that can dangle.
for e in self.score.events.iter() {
match e {
Event::Indeterminate(ie) => {
for alt in &ie.hints.alternatives {
flag(
live_event(alt),
format!(
"indeterminate event {:?} alternative {:?} dangling",
ie.id, alt
),
);
}
}
Event::Trajectory(te) => {
for ep in [&te.start, &te.end] {
if let crate::event::TrajectoryEndpoint::EventPitch(pid) = ep {
flag(
self.live_pitches.contains(pid),
format!("trajectory {:?} endpoint pitch {:?} dangling", te.id, pid),
);
}
}
}
Event::Graphic(ge) => {
for o in &ge.graphics {
flag(
self.graphic_objects.contains(o),
format!("graphic event {:?} object {:?} not stored", ge.id, o),
);
}
}
Event::Cue(ce) => {
for src in &ce.source {
flag(
live_event(src),
format!("cue {:?} source {:?} dangling", ce.id, src),
);
}
}
_ => {}
}
}
}
// --- Tempo-map well-formedness (Chapter 3 §"Tempo and the Tempo Map"). --
//
// The spec enumerates no dedicated tempo graph invariant, but the tempo
// map's segment anchors are graph references and its segments carry
// structural requirements; both are surfaced here under invariant 10
// (reference resolution + graph integrity). Segment-anchor *offset*
// agreement is invariant 9's (the anchors are in `collect_anchors`).
fn check_tempo_maps(&self, out: &mut Vec<InvariantViolation>) {
use crate::tempo::TempoShape;
let mut flag = |cond: bool, what: String| {
if !cond {
out.push(InvariantViolation::new(
GraphInvariant::CrossCuttingRefsResolve,
what,
));
}
};
// Self-contained musical position of a segment boundary: a region-start
// anchor with a musical/zero offset (the natural region-local tempo
// anchoring). Other anchors need the score timeline; ordering/overlap is
// then skipped (sound but incomplete), never falsely flagged.
let seg_pos = |a: &TimeAnchor| -> Option<crate::time::RationalTime> {
match a {
TimeAnchor::Region {
edge: crate::time::RegionEdge::Start,
offset: AnchorOffset::Musical(d),
..
} => Some(d.rational().clone()),
TimeAnchor::Region {
edge: crate::time::RegionEdge::Start,
offset: AnchorOffset::Zero,
..
} => Some(crate::time::RationalTime::zero()),
_ => None,
}
};
for tm in self.tempo_maps() {
let mut prev_start: Option<crate::time::RationalTime> = None;
let mut prev_end: Option<crate::time::RationalTime> = None;
for seg in &tm.segments {
// Segment boundary anchor targets must resolve (invariant 10).
flag(
self.anchor_target_exists(&seg.start),
format!("tempo segment start anchor target {:?} dangling", seg.start),
);
if let Some(end) = &seg.end {
flag(
self.anchor_target_exists(end),
format!("tempo segment end anchor target {end:?} dangling"),
);
}
// Missing end_tempo / shape consistency (Chapter 3).
match seg.shape {
TempoShape::Constant => flag(
seg.end_tempo
.as_ref()
.is_none_or(|et| et == &seg.start_tempo),
"constant tempo segment has end_tempo != start_tempo".to_string(),
),
TempoShape::Linear | TempoShape::Exponential | TempoShape::Curve => flag(
seg.end_tempo.is_some(),
"non-constant tempo segment is missing its end_tempo".to_string(),
),
}
// Ordering and non-overlap, where resolvable.
let start = seg_pos(&seg.start);
if let (Some(ps), Some(s)) = (&prev_start, &start) {
flag(s >= ps, "tempo segments are out of start order".to_string());
}
if let (Some(pe), Some(s)) = (&prev_end, &start) {
flag(
s >= pe,
"tempo segments overlap in musical time".to_string(),
);
}
prev_end = seg.end.as_ref().and_then(seg_pos).or_else(|| start.clone());
prev_start = start;
}
}
}
// --- Aleatoric ordering / bounds well-formedness (Chapter 3 §"Aleatoric
// Time"). The ordering DAG and the per-event bounds map are graph
// references: they must name events that exist *in the region*, and each
// bound window must be ordered (`min <= max`). Dangling references go under
// invariant 10; a reversed window is a region-time-model defect (invariant
// 4). (The DAG's acyclicity is enforced at construction in `graph`.)
fn check_aleatoric_models(&self, out: &mut Vec<InvariantViolation>) {
for r in &self.score.canvas.regions {
let RegionTimeModel::Aleatoric(model) = &r.time_model else {
continue;
};
let in_region = |e: EventId| {
self.event_instance
.get(&e)
.and_then(|si| self.instance_region.get(si))
.map(|rid| *rid == r.id)
.unwrap_or(false)
};
for e in model.ordering.referenced_events() {
if !in_region(e) {
out.push(InvariantViolation::new(
GraphInvariant::CrossCuttingRefsResolve,
format!(
"aleatoric region {:?} ordering references event {:?}, absent from the region",
r.id, e
),
));
}
}
for (e, bounds) in &model.bounds {
if !in_region(*e) {
out.push(InvariantViolation::new(
GraphInvariant::CrossCuttingRefsResolve,
format!(
"aleatoric region {:?} bounds key event {:?} is absent from the region",
r.id, e
),
));
}
for tb in [bounds.start.as_ref(), bounds.end.as_ref()]
.into_iter()
.flatten()
{
if time_bounds_ordered(tb) == Some(false) {
out.push(InvariantViolation::new(
GraphInvariant::EventCoordinateModel,
format!(
"aleatoric region {:?} has a reversed (min > max) bound for event {:?}",
r.id, e
),
));
}
}
}
}
}
// --- Accidental modification / pitch-space compatibility (Chapter 4
// §"Accidental Registries", `req:tuning:accidental-modification-compatibility`,
// `core_spec.tex:3120`; Push 4b tranche 3a,
// `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`). Not one of the spec-enumerated
// Chapter 5 graph invariants (this is a Chapter 4
// requirement), so — like the tempo-map and aleatoric-model checks above
// — it is surfaced under an existing `GraphInvariant` tag rather than
// minting a new one. `CrossCuttingRefsResolve` is the closest fit: like
// those checks, this is "does this cross-cutting structure's content
// hold against the rest of the score", not a per-event/per-voice
// structural rule.
fn check_accidental_modification_compatibility(&self, out: &mut Vec<InvariantViolation>) {
// Every pitch space the score's tuning context concretely
// references: the default, plus any per-scope override's pitch
// space (`crate::tuning::TuningOverride::pitch_space`). This
// tranche has no built-in catalog linking an `AccidentalRegistryId`
// to the pitch space(s) that declare it as their
// `accidental_registry` (Push 4b tranche 1 built only
// `built_in_position_structure`'s id -> structure map, not a full
// `PitchSpace` catalog with that field populated) — so this is the
// referencing relation the score can actually attest to, honestly,
// rather than inventing catalog data (the `NOTEHEAD_ANCHORS`
// failure this project has already paid for twice).
let mut spaces: BTreeSet<&PitchSpaceId> = BTreeSet::new();
spaces.insert(&self.score.tuning_context.default_pitch_space);
for ov in &self.score.tuning_context.overrides {
if let Some(space) = &ov.pitch_space {
spaces.insert(space);
}
}
for ext in &self.score.tuning_context.accidental_extensions {
for def in ext.additions.iter().chain(ext.overrides.iter()) {
for space in &spaces {
if !crate::accidental::accidental_modification_compatible_with_space(
&def.modification,
space,
) {
out.push(InvariantViolation::new(
GraphInvariant::CrossCuttingRefsResolve,
format!(
"accidental {:?} (registry {:?}) modification {:?} is not \
expressible in pitch space {:?}'s interval algebra \
(req:tuning:accidental-modification-compatibility)",
def.id, ext.base, def.modification, space
),
));
}
}
}
}
}
// --- 11. Identifiers unique within their kind. --------------------------
fn check_unique_identifiers(&self, out: &mut Vec<InvariantViolation>) {
let mut regions = BTreeSet::new();
for r in &self.score.canvas.regions {
if !regions.insert(r.id) {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("region id {:?} is used twice", r.id),
));
}
}
let mut instances = BTreeSet::new();
let mut voices = BTreeSet::new();
let mut measures = BTreeSet::new();
for r in &self.score.canvas.regions {
for si in r.staff_instances() {
if !instances.insert(si.id) {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("staff-instance id {:?} is used twice", si.id),
));
}
for v in &si.voices {
if !voices.insert(v.id) {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("voice id {:?} is used twice", v.id),
));
}
}
for m in &si.measures {
if !measures.insert(m.id) {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("measure id {:?} is used twice", m.id),
));
}
}
}
}
let mut staves = BTreeSet::new();
for s in &self.score.staves {
if !staves.insert(s.id) {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("staff id {:?} is used twice", s.id),
));
}
}
// Cross-cutting structure ids, each unique within its kind.
let cc = &self.score.cross_cutting;
let mut dup = |used: bool, what: String| {
if used {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
what,
));
}
};
let mut slurs = BTreeSet::new();
for x in &cc.slurs {
dup(
!slurs.insert(x.id),
format!("slur id {:?} is used twice", x.id),
);
}
let mut ties = BTreeSet::new();
for x in &cc.ties {
dup(
!ties.insert(x.id),
format!("tie id {:?} is used twice", x.id),
);
}
let mut beams = BTreeSet::new();
for x in &cc.beams {
dup(
!beams.insert(x.id),
format!("beam id {:?} is used twice", x.id),
);
}
let mut spanners = BTreeSet::new();
for x in &cc.spanners {
dup(
!spanners.insert(x.id),
format!("spanner id {:?} is used twice", x.id),
);
}
let mut tuplets = BTreeSet::new();
for x in &cc.tuplets {
dup(
!tuplets.insert(x.id),
format!("tuplet id {:?} is used twice", x.id),
);
}
let mut groups = BTreeSet::new();
for r in &self.score.canvas.regions {
for g in r.content.barline_alignment_groups() {
dup(
!groups.insert(g.id),
format!("barline-group id {:?} is used twice", g.id),
);
}
}
let mut markers = BTreeSet::new();
for x in &cc.markers {
dup(
!markers.insert(x.id),
format!("marker id {:?} is used twice", x.id),
);
}
let mut repeats = BTreeSet::new();
for x in &cc.repeats {
dup(
!repeats.insert(x.id),
format!("repeat id {:?} is used twice", x.id),
);
}
let mut analytical = BTreeSet::new();
for x in &cc.analytical {
dup(
!analytical.insert(x.id),
format!("annotation id {:?} is used twice", x.id),
);
}
let mut comments = BTreeSet::new();
for x in &cc.comments {
dup(
!comments.insert(x.id),
format!("comment id {:?} is used twice", x.id),
);
}
let mut gestures = BTreeSet::new();
for x in &cc.graphic_gestures {
dup(
!gestures.insert(x.id),
format!("gesture id {:?} is used twice", x.id),
);
}
let mut lyrics = BTreeSet::new();
for x in &cc.lyrics {
dup(
!lyrics.insert(x.id),
format!("lyric-line id {:?} is used twice", x.id),
);
}
let mut chords = BTreeSet::new();
for x in &cc.chord_symbols {
dup(
!chords.insert(x.id),
format!("chord-symbol id {:?} is used twice", x.id),
);
}
let mut graphic_obj = BTreeSet::new();
for r in &self.score.canvas.regions {
for o in r.content.graphic_objects() {
dup(
!graphic_obj.insert(o.id),
format!("graphic-object id {:?} is used twice", o.id),
);
}
}
// Top-level object kinds.
let mut instruments = BTreeSet::new();
for x in &self.score.instruments {
dup(
!instruments.insert(x.id),
format!("instrument id {:?} is used twice", x.id),
);
}
let mut staff_groups = BTreeSet::new();
for x in &self.score.staff_groups {
dup(
!staff_groups.insert(x.id),
format!("staff-group id {:?} is used twice", x.id),
);
}
let mut parts = BTreeSet::new();
for x in &self.score.parts {
dup(
!parts.insert(x.id),
format!("part id {:?} is used twice", x.id),
);
}
let mut layers = BTreeSet::new();
for x in &self.score.analysis_layers {
dup(
!layers.insert(x.id),
format!("analysis-layer id {:?} is used twice", x.id),
);
}
let mut views = BTreeSet::new();
for x in &self.score.views {
dup(
!views.insert(x.id),
format!("view id {:?} is used twice", x.id),
);
}
let mut time_sig_ids = BTreeSet::new();
for x in &self.score.time_signatures {
dup(
!time_sig_ids.insert(x.id),
format!("time-signature id {:?} is used twice", x.id),
);
}
// Identifier stability: a live id must not also be tombstoned (the
// identifier would have been reused, which Chapter 5 §"Identifier
// Stability" forbids — "never reassigned, even after deletion").
for e in self.score.events.ids_canonical() {
if self.score.tombstoned_events.contains(&e) {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("event id {:?} is both live and tombstoned", e),
));
}
}
for p in &self.live_pitches {
if self.score.tombstoned_pitches.contains(p) {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("pitch id {:?} is both live and tombstoned", p),
));
}
}
// The SYSTEM_DERIVED replica namespace is reserved for
// deterministically-derived system identifiers. Only two object kinds
// legitimately use it: system-promoted voices (checked by invariant 18)
// and system-derived synthetic pitches (`MUSCSPCH`, Chapter 5). Every
// *other* kind in that namespace is misuse.
let mut sysmisuse = |used: bool, what: String| {
if used {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
what,
));
}
};
for r in &self.score.canvas.regions {
sysmisuse(
r.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!("region {:?} uses the reserved SYSTEM_DERIVED replica", r.id),
);
for si in r.staff_instances() {
sysmisuse(
si.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!(
"staff instance {:?} uses the reserved SYSTEM_DERIVED replica",
si.id
),
);
for m in &si.measures {
sysmisuse(
m.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!(
"measure {:?} uses the reserved SYSTEM_DERIVED replica",
m.id
),
);
}
}
}
for s in &self.score.staves {
sysmisuse(
s.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!("staff {:?} uses the reserved SYSTEM_DERIVED replica", s.id),
);
}
for e in self.score.events.ids_canonical() {
sysmisuse(
e.replica() == ReplicaId::SYSTEM_DERIVED,
format!("event {:?} uses the reserved SYSTEM_DERIVED replica", e),
);
}
// The authoring identity context itself must not be the reserved
// namespace (Chapter 5 §"System-Derived Identifier Namespace":
// user-authored replicas MUST NOT use SYSTEM_DERIVED), since every id it
// mints would otherwise land there.
sysmisuse(
self.score.identity.replica_id == ReplicaId::SYSTEM_DERIVED,
"score identity uses the reserved SYSTEM_DERIVED replica".to_string(),
);
// The remaining object kinds with *no* system-derived form: any use of
// the reserved namespace is misuse. (Embedded `PitchId`s are NOT checked
// here — a `MUSCSPCH` synthetic pitch legitimately lives in the
// namespace; voices are invariant 18's domain.)
for i in &self.score.instruments {
sysmisuse(
i.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!(
"instrument {:?} uses the reserved SYSTEM_DERIVED replica",
i.id
),
);
}
for g in &self.score.staff_groups {
sysmisuse(
g.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!(
"staff group {:?} uses the reserved SYSTEM_DERIVED replica",
g.id
),
);
}
for p in &self.score.parts {
sysmisuse(
p.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!("part {:?} uses the reserved SYSTEM_DERIVED replica", p.id),
);
}
for l in &self.score.analysis_layers {
sysmisuse(
l.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!(
"analysis layer {:?} uses the reserved SYSTEM_DERIVED replica",
l.id
),
);
}
for v in &self.score.views {
sysmisuse(
v.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!("view {:?} uses the reserved SYSTEM_DERIVED replica", v.id),
);
}
let cc = &self.score.cross_cutting;
for id_is_sys in cc
.slurs
.iter()
.map(|x| (x.id.replica(), "slur"))
.chain(cc.ties.iter().map(|x| (x.id.replica(), "tie")))
.chain(cc.beams.iter().map(|x| (x.id.replica(), "beam")))
.chain(cc.spanners.iter().map(|x| (x.id.replica(), "spanner")))
.chain(cc.tuplets.iter().map(|x| (x.id.replica(), "tuplet")))
.chain(cc.markers.iter().map(|x| (x.id.replica(), "marker")))
.chain(cc.repeats.iter().map(|x| (x.id.replica(), "repeat")))
.chain(cc.analytical.iter().map(|x| (x.id.replica(), "annotation")))
.chain(cc.comments.iter().map(|x| (x.id.replica(), "comment")))
.chain(
cc.graphic_gestures
.iter()
.map(|x| (x.id.replica(), "gesture")),
)
.chain(cc.lyrics.iter().map(|x| (x.id.replica(), "lyric")))
.chain(
cc.chord_symbols
.iter()
.map(|x| (x.id.replica(), "chord symbol")),
)
{
sysmisuse(
id_is_sys.0 == ReplicaId::SYSTEM_DERIVED,
format!(
"{} id uses the reserved SYSTEM_DERIVED replica",
id_is_sys.1
),
);
}
// Time-signature, barline-group, and graphic-object ids have no
// system-derived form, so any use of the reserved namespace is misuse.
for ts in &self.score.time_signatures {
sysmisuse(
ts.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!(
"time signature {:?} uses the reserved SYSTEM_DERIVED replica",
ts.id
),
);
}
for r in &self.score.canvas.regions {
for g in r.content.barline_alignment_groups() {
sysmisuse(
g.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!(
"barline group {:?} uses the reserved SYSTEM_DERIVED replica",
g.id
),
);
}
for o in r.content.graphic_objects() {
sysmisuse(
o.id.replica() == ReplicaId::SYSTEM_DERIVED,
format!(
"graphic object {:?} uses the reserved SYSTEM_DERIVED replica",
o.id
),
);
}
}
// A `SYSTEM_DERIVED` embedded pitch is legitimate only if it *proves* its
// namespace: its counter must equal the deterministic `MUSCSPCH`
// derivation of its own content (Chapter 5 §"System-Derived
// Identifiers"). An arbitrary counter in the reserved namespace is
// misuse — provenance-aware validation, not unconditional acceptance.
for pid in &self.live_pitches {
if pid.replica() == ReplicaId::SYSTEM_DERIVED {
if let Some(pitch) = self.pitch.get(pid) {
sysmisuse(
*pid != crate::pitch::derive_system_pitch_id(pitch),
format!(
"system-derived pitch {pid:?} is not the MUSCSPCH derivation of its content"
),
);
}
}
}
// Arena identity integrity: the index must agree with each event's own
// id, and no live pitched event may be empty. `insert` enforces both,
// but `get_mut` exposes the fields, so re-check here (catches an id or
// pitch-list mutated after insertion).
for id in self.score.events.index_inconsistencies() {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("arena index entry {id:?} disagrees with the stored event's id"),
));
}
for id in self.score.events.malformed_pitched_events() {
out.push(InvariantViolation::new(
GraphInvariant::UniqueIdentifiers,
format!("pitched event {id:?} has no pitches (malformed; Chapter 5)"),
));
}
}
// --- 12. Embedded PitchId uniqueness. -----------------------------------
fn check_pitch_id_unique(&self, out: &mut Vec<InvariantViolation>) {
let mut seen: BTreeSet<PitchId> = BTreeSet::new();
let mut buf = Vec::new();
for e in self.score.events.iter() {
buf.clear();
e.collect_identified_pitches(&mut buf);
for ip in &buf {
if !seen.insert(ip.id) {
out.push(InvariantViolation::new(
GraphInvariant::PitchIdUnique,
format!("pitch id {:?} appears more than once", ip.id),
));
}
}
}
}
// --- 13. SpellingScope::Pitch resolves to live or tombstoned. -----------
fn check_spelling_scope_resolves(&self, out: &mut Vec<InvariantViolation>) {
for a in &self.score.spelling_attachments {
if let SpellingScope::Pitch(pid) = &a.scope {
let live = self.live_pitches.contains(pid);
let tomb = self.score.tombstoned_pitches.contains(pid);
if !live && !tomb {
out.push(InvariantViolation::new(
GraphInvariant::SpellingScopeResolves,
format!(
"spelling attachment targets pitch {:?}, neither live nor tombstoned",
pid
),
));
}
}
// Explicit directives are only valid with a Pitch scope (Chapter 2);
// surfacing the malformed pairing here keeps the attachment honest.
if matches!(
(&a.scope, &a.directive),
(SpellingScope::Range { .. }, SpellingDirective::Explicit(_))
) {
out.push(InvariantViolation::new(
GraphInvariant::SpellingScopeResolves,
"explicit spelling on a range scope (only valid with a pitch scope)"
.to_string(),
));
}
// An explicit spelling's accidental stack must be well-formed: no
// repeated `AccidentalId` (Chapter 2 §"Accidental Stack Semantics").
// Wires `PitchSpelling::accidental_stack_is_well_formed` into
// validation rather than leaving it advisory.
if let SpellingDirective::Explicit(sp) = &a.directive {
if !sp.accidental_stack_is_well_formed() {
out.push(InvariantViolation::new(
GraphInvariant::SpellingScopeResolves,
format!(
"spelling attachment has a repeated accidental in its stack: {:?}",
sp.accidentals
),
));
}
}
// The attachment's analysis layer (if any) must resolve to a declared
// `AnalysisLayer` (Chapter 5 §"Analysis Layers and Views").
if let Some(layer) = a.layer {
if !self.analysis_layers.contains(&layer) {
out.push(InvariantViolation::new(
GraphInvariant::SpellingScopeResolves,
format!(
"spelling attachment layer {layer:?} is not a declared analysis layer"
),
));
}
}
}
}
// --- 14. Decomposition target resolves to live or tombstoned. -----------
fn check_decomposition_target_resolves(&self, out: &mut Vec<InvariantViolation>) {
for d in &self.score.decomposition_attachments {
let live = self.score.events.contains(d.target);
let tomb = self.score.tombstoned_events.contains(&d.target);
if !live && !tomb {
out.push(InvariantViolation::new(
GraphInvariant::DecompositionTargetResolves,
format!(
"decomposition targets event {:?}, neither live nor tombstoned",
d.target
),
));
}
}
}
// --- 15. Live decomposition component sum == event duration. ------------
fn check_decomposition_sum(&self, out: &mut Vec<InvariantViolation>) {
for d in &self.score.decomposition_attachments {
let Some(ev) = self.score.events.get(d.target) else {
continue; // tombstoned target: invariant 14 territory
};
let EventDuration::Musical(dur) = ev.duration() else {
continue; // only musical durations decompose into note values
};
// Sum each component's *sounding* duration: its notated value with
// dots, scaled by its tuplet's ratio when it is in one (Chapter 3).
let mut sum = crate::time::MusicalDuration::zero();
for c in &d.components {
let ratio = c.tuplet.and_then(|t| self.tuplet_ratios.get(&t).copied());
sum = sum + c.sounding_duration(ratio);
}
if &sum != dur {
out.push(InvariantViolation::new(
GraphInvariant::DecompositionSum,
format!(
"decomposition of event {:?} sums to {:?}, event duration is {:?}",
d.target, sum, dur
),
));
}
}
}
// --- 16. Tuplet member durations sum to required total. -----------------
fn check_tuplet_sum(&self, out: &mut Vec<InvariantViolation>) {
for t in &self.score.cross_cutting.tuplets {
// Degenerate ratios (a zero term or actual == notated) are rejected
// at construction by `TupletRatio::new` (Chapter 3 §"Tuplets",
// `req:time:tuplet-ratio-construction`), so they are never a
// representable graph state and are not re-checked here.
//
// Sum the members' musical durations; skip members that are absent
// (invariant 10) or non-musical (cannot contribute to a rational
// total).
let mut sum = crate::time::MusicalDuration::zero();
let mut measurable = !t.members.is_empty();
for e in &t.members {
match self.score.events.get(*e).map(|ev| ev.duration()) {
Some(EventDuration::Musical(d)) => sum = sum + d.clone(),
Some(_) => measurable = false,
None => measurable = false,
}
}
if measurable && sum != t.required_total {
out.push(InvariantViolation::new(
GraphInvariant::TupletSum,
format!(
"tuplet {:?} members sum to {:?}, required total is {:?}",
t.id, sum, t.required_total
),
));
}
// Ratio consistency (Chapter 3 §"Tuplet Consistency"): the
// actual:notated ratio MUST relate the members' *notation* to their
// sounding duration. For each member whose entire notational
// decomposition lies in this tuplet, scaling its notated duration by
// `notated/actual` MUST reproduce its sounding (event) duration —
// so a wrong ratio (e.g. 3:2 changed to 5:4) is caught. Members
// without an in-tuplet decomposition are skipped (sound but
// incomplete; the decomposition pre-pass is deferred).
if t.ratio.actual() != 0 && t.ratio.notated() != 0 {
let scale = crate::time::RationalTime::new(
t.ratio.notated() as i64,
t.ratio.actual() as i64,
)
.expect("nonzero ratio");
for &member in &t.members {
let comps: Vec<&crate::graph::NotatedComponent> = self
.score
.decomposition_attachments
.iter()
.filter(|d| d.target == member)
.flat_map(|d| d.components.iter())
.collect();
if comps.is_empty() || !comps.iter().all(|c| c.tuplet == Some(t.id)) {
continue;
}
let Some(EventDuration::Musical(sd)) =
self.score.events.get(member).map(|ev| ev.duration())
else {
continue;
};
let mut notated = crate::time::RationalTime::zero();
for c in &comps {
let nd = c.notated_duration();
notated = notated.add(nd.rational());
}
let sounding = notated.mul(&scale);
if &sounding != sd.rational() {
out.push(InvariantViolation::new(
GraphInvariant::TupletSum,
format!(
"tuplet {:?} ratio {}:{} is inconsistent with member {:?}'s notation \
(notated scaled to {:?}, sounding duration is {:?})",
t.id, t.ratio.actual(), t.ratio.notated(), member, sounding, sd
),
));
}
}
}
}
}
// --- 17. Tie pairing references and class rules. ------------------------
fn check_tie_pairing(&self, out: &mut Vec<InvariantViolation>) {
for t in &self.score.cross_cutting.ties {
let empty = BTreeSet::new();
let start_pitches = self.event_pitches.get(&t.start_event).unwrap_or(&empty);
let end_pitches = self.event_pitches.get(&t.end_event).unwrap_or(&empty);
let requires_enharmonic = matches!(
t.class,
TieClass::Standard | TieClass::Editorial | TieClass::CrossVoice
);
match &t.pitch_pairing {
Some(pairs) => {
// Explicit pairing: each entry must reference pitches of the
// respective events, and be enharmonic for the classes that
// require it.
for (sp, ep) in pairs {
if !start_pitches.contains(sp) {
out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!("tie {:?} pairs pitch {:?} not in start event", t.id, sp),
));
}
if !end_pitches.contains(ep) {
out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!("tie {:?} pairs pitch {:?} not in end event", t.id, ep),
));
}
if requires_enharmonic {
if let (Some(a), Some(b)) = (self.pitch.get(sp), self.pitch.get(ep)) {
if !a.enharmonic_equivalent(b) {
out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!(
"tie {:?} pairs non-enharmonic pitches {:?}/{:?}",
t.id, sp, ep
),
));
}
}
}
}
}
None => {
// Implicit pairing: "all pitches tied by enharmonic matching
// in pitch-id-ascending order" (Chapter 5 §"Ties"). Each
// start pitch (ascending) is greedily matched to the
// lowest-id not-yet-used enharmonically-equivalent end pitch
// — a deterministic matching that survives chord reordering,
// not a positional zip.
if start_pitches.len() != end_pitches.len() {
out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!(
"tie {:?} (implicit pairing): {} start vs {} end pitches",
t.id,
start_pitches.len(),
end_pitches.len()
),
));
}
let mut used: BTreeSet<PitchId> = BTreeSet::new();
for sp in start_pitches {
let sp_pitch = self.pitch.get(sp);
let matched = end_pitches.iter().find(|ep| {
if used.contains(*ep) {
return false;
}
match (sp_pitch, self.pitch.get(*ep)) {
(Some(a), Some(b)) => a.enharmonic_equivalent(b),
_ => false,
}
});
match matched {
Some(ep) => {
used.insert(*ep);
}
None => out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!(
"tie {:?} (implicit pairing): start pitch {:?} has no enharmonic end-event counterpart",
t.id, sp
),
)),
}
}
}
}
// Class-specific adjacency / voice / position rules.
self.check_tie_class_rules(t, out);
}
}
/// An event's start position as a comparable key (for tie ordering rules).
fn event_start_key(&self, eid: EventId) -> Option<TimeKey> {
Endpoints::of(self.score.events.get(eid)?).start_key()
}
fn check_tie_class_rules(&self, t: &crate::graph::Tie, out: &mut Vec<InvariantViolation>) {
let start = self.event_voice_index.get(&t.start_event);
let end = self.event_voice_index.get(&t.end_event);
match t.class {
TieClass::Standard => {
if let (Some((sv, si)), Some((ev, ei))) = (start, end) {
if sv != ev || *ei != si + 1 {
out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!(
"standard tie {:?} is not same-voice immediately adjacent",
t.id
),
));
}
}
}
TieClass::Editorial => {
if let (Some((sv, si)), Some((ev, ei))) = (start, end) {
if sv != ev || ei <= si {
out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!("editorial tie {:?} is not same-voice forward", t.id),
));
}
}
}
TieClass::CrossVoice => {
let sinst = self.event_instance.get(&t.start_event);
let einst = self.event_instance.get(&t.end_event);
if let (Some(a), Some(b)) = (sinst, einst) {
if a != b {
out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!("cross-voice tie {:?} crosses staff instances", t.id),
));
}
}
// The start's resolved position MUST be <= the end's
// (Chapter 5 §"Ties", CrossVoice). Comparable when both events
// share a clock; skipped otherwise (invariant 4's concern).
if let (Some(s), Some(e)) = (
self.event_start_key(t.start_event),
self.event_start_key(t.end_event),
) {
if !s.le_same_clock(&e) {
out.push(InvariantViolation::new(
GraphInvariant::TiePairing,
format!("cross-voice tie {:?} has start position after end", t.id),
));
}
}
}
TieClass::LaissezVibrer | TieClass::Registered(_) => {}
}
}
// --- 18. Voice origin consistency / promoted-id derivation. -------------
fn check_voice_origin_consistent(&self, out: &mut Vec<InvariantViolation>) {
for (_r, si, v) in self.score.voices() {
match &v.origin {
VoiceOrigin::SystemPromoted {
winning_operation,
losing_operation,
original_voice,
} => {
// A promoted voice's id MUST be the deterministic derivation
// (Chapter 5 §"System-Promoted Voices") from the complete
// provenance retained on the graph object.
let expected = derive_promoted_voice_id(
si,
*original_voice,
*winning_operation,
*losing_operation,
);
if v.id != expected {
out.push(InvariantViolation::new(
GraphInvariant::VoiceOriginConsistent,
format!(
"system-promoted voice {:?} != its derivation {:?}",
v.id, expected
),
));
}
}
VoiceOrigin::UserDeclared | VoiceOrigin::Imported { .. } => {
if v.id.replica() == ReplicaId::SYSTEM_DERIVED {
out.push(InvariantViolation::new(
GraphInvariant::VoiceOriginConsistent,
format!(
"user/imported voice {:?} uses the reserved SYSTEM_DERIVED replica",
v.id
),
));
}
}
}
}
}
// --- 19. Barline group members stay within one region. ------------------
fn check_barline_group_same_region(&self, out: &mut Vec<InvariantViolation>) {
for r in &self.score.canvas.regions {
let region_instances: BTreeSet<StaffInstanceId> =
r.staff_instances().iter().map(|si| si.id).collect();
let instance_measures: HashMap<StaffInstanceId, BTreeSet<MeasureId>> = r
.staff_instances()
.iter()
.map(|si| (si.id, si.measures.iter().map(|m| m.id).collect()))
.collect();
for g in r.content.barline_alignment_groups() {
for m in &g.members {
if !region_instances.contains(&m.staff_instance) {
out.push(InvariantViolation::new(
GraphInvariant::BarlineGroupSameRegion,
format!(
"barline group {:?} member instance {:?} is outside region {:?}",
g.id, m.staff_instance, r.id
),
));
continue;
}
if !instance_measures
.get(&m.staff_instance)
.map(|ms| ms.contains(&m.measure))
.unwrap_or(false)
{
out.push(InvariantViolation::new(
GraphInvariant::BarlineGroupSameRegion,
format!(
"barline group {:?} measure {:?} is not in instance {:?}",
g.id, m.measure, m.staff_instance
),
));
}
}
}
}
}
// --- 20. Measure-meter agreement and boundary consistency (Genesis
// tranche G3b, `spec/CONTRACT_GENESIS_G3B_MEASURE.md`). This mirrors
// `epiphany-ops::reduce::Reducer`'s pin 6/6b/6c relation as an
// INDEPENDENT implementation over the MATERIALIZED graph: there are no
// operational write chains to reconstruct here (the architecture note in
// the contract) — a `StaffInstance`'s effective grid is simply its own
// `local_metric_grid`, falling back WHOLE to the enclosing region's
// `default_metric_grid`, both already fully-resolved `MetricGrid`
// values once persisted, and c3's "vector index" is literally
// `StaffInstance.measures`' index, with no `minted_by` indirection.
// Divergence from the ops-crate implementation is guarded by
// `epiphany-testkit`'s cross-crate agreement test
// (`measure_anchor_relation` above), not by code sharing — the
// dependency direction (`epiphany-ops` -> `epiphany-core`, never the
// reverse) forbids this crate from calling into that one.
/// Pin 6: two `AnchorOffset`s are comparable iff they are the same
/// clock, or at least one is `Zero` — read as the additive identity of
/// whichever clock it is compared against. `Musical` against
/// `WallClock` is never comparable (the deferred wall-clock/musical
/// reconciliation).
fn measure20_offset_order(a: &AnchorOffset, b: &AnchorOffset) -> Option<Ordering> {
match (a, b) {
(AnchorOffset::Musical(x), AnchorOffset::Musical(y)) => Some(x.cmp(y)),
(AnchorOffset::WallClock(x), AnchorOffset::WallClock(y)) => Some(x.cmp(y)),
(AnchorOffset::Zero, AnchorOffset::Zero) => Some(Ordering::Equal),
(AnchorOffset::Zero, AnchorOffset::Musical(y)) => Some(MusicalDuration::zero().cmp(y)),
(AnchorOffset::Musical(x), AnchorOffset::Zero) => Some(x.cmp(&MusicalDuration::zero())),
(AnchorOffset::Zero, AnchorOffset::WallClock(y)) => Some(WallClockDuration(0).cmp(y)),
(AnchorOffset::WallClock(x), AnchorOffset::Zero) => Some(x.cmp(&WallClockDuration(0))),
(AnchorOffset::Musical(_), AnchorOffset::WallClock(_))
| (AnchorOffset::WallClock(_), AnchorOffset::Musical(_)) => None,
}
}
/// c3's "vector index" ordering between two DISTINCT measures, both
/// anchored via `Measure{pos: Start, off: Zero}` (contract pin 6, c3):
/// their relative position within the SAME `StaffInstance.measures`,
/// read directly off the materialized graph — no ledger indirection
/// needed (unlike `epiphany-ops`'s base-free branch, which has no
/// materialized graph to read at all).
fn measure20_vector_order(&self, a: MeasureId, b: MeasureId) -> Option<Ordering> {
for region in &self.score.canvas.regions {
for instance in region.staff_instances() {
let pos_a = instance.measures.iter().position(|m| m.id == a);
let pos_b = instance.measures.iter().position(|m| m.id == b);
if let (Some(pa), Some(pb)) = (pos_a, pos_b) {
return Some(pa.cmp(&pb));
}
}
}
None
}
/// Pin 6: the comparable relation over `TimeAnchor`s, EXACTLY the five
/// shapes c1-c5 (contract table). Everything else is NOT comparable,
/// and no other relation may be invented — pin 6's prohibition. The
/// boundary selector (`MeasurePosition`/`RegionEdge`) must be
/// IDENTICAL; it is never ordered.
fn measure20_comparable_order(&self, a: &TimeAnchor, b: &TimeAnchor) -> Option<Ordering> {
match (a, b) {
// c1: same Event id.
(
TimeAnchor::Event { id: ia, offset: oa },
TimeAnchor::Event { id: ib, offset: ob },
) if ia == ib => Self::measure20_offset_order(oa, ob),
// c2/c3: Measure anchors.
(
TimeAnchor::Measure {
id: ia,
position: pa,
offset: oa,
},
TimeAnchor::Measure {
id: ib,
position: pb,
offset: ob,
},
) => {
if pa != pb {
return None;
}
if ia == ib {
// c2: same id and same pos.
return Self::measure20_offset_order(oa, ob);
}
// c3: distinct ids, restricted to pos: Start, off: Zero.
if *pa != MeasurePosition::Start
|| !matches!(oa, AnchorOffset::Zero)
|| !matches!(ob, AnchorOffset::Zero)
{
return None;
}
self.measure20_vector_order(*ia, *ib)
}
// c4: same Region id and same edge.
(
TimeAnchor::Region {
id: ia,
edge: ea,
offset: oa,
},
TimeAnchor::Region {
id: ib,
edge: eb,
offset: ob,
},
) if ia == ib && ea == eb => Self::measure20_offset_order(oa, ob),
// c5: WallClock, no referent id.
(TimeAnchor::WallClock { time: ta }, TimeAnchor::WallClock { time: tb }) => {
Some(ta.cmp(tb))
}
// Never Event<->Measure, never Measure<->Region, never two
// Events with different ids, never Musical against WallClock,
// and never across differing pos/edge selectors.
_ => None,
}
}
/// Pin 6b: the musical delta `b - a`, computable ONLY in shape c1, c2,
/// or c4 with BOTH offsets in the `Musical` clock (or `Zero`,
/// normalized to `Musical(0)`), and only when the boundary selector is
/// identical. c3 supplies no delta at all (a vector index gives order,
/// never distance). `WallClock` deltas are never returned.
fn measure20_musical_delta(&self, a: &TimeAnchor, b: &TimeAnchor) -> Option<MusicalDuration> {
fn musical(o: &AnchorOffset) -> Option<MusicalDuration> {
match o {
AnchorOffset::Musical(d) => Some(d.clone()),
AnchorOffset::Zero => Some(MusicalDuration::zero()),
AnchorOffset::WallClock(_) => None,
}
}
match (a, b) {
(
TimeAnchor::Event { id: ia, offset: oa },
TimeAnchor::Event { id: ib, offset: ob },
) if ia == ib => Some(musical(ob)? - musical(oa)?),
(
TimeAnchor::Measure {
id: ia,
position: pa,
offset: oa,
},
TimeAnchor::Measure {
id: ib,
position: pb,
offset: ob,
},
) if ia == ib && pa == pb => Some(musical(ob)? - musical(oa)?),
(
TimeAnchor::Region {
id: ia,
edge: ea,
offset: oa,
},
TimeAnchor::Region {
id: ib,
edge: eb,
offset: ob,
},
) if ia == ib && ea == eb => Some(musical(ob)? - musical(oa)?),
_ => None,
}
}
/// Pin 6c steps 1-3: the unique maximum of `candidates` under
/// [`Self::measure20_comparable_order`] — shared by the effective-grid
/// oracle's governing meter change and (were it needed here) an
/// append-only "current last element" search.
fn measure20_unique_maximum<'x, T: Copy>(
&self,
candidates: impl IntoIterator<Item = (T, &'x TimeAnchor)>,
) -> Governing20<T> {
let items: Vec<(T, &TimeAnchor)> = candidates.into_iter().collect();
if items.is_empty() {
return Governing20::None;
}
let mut maximal: Vec<T> = Vec::new();
for (key, anchor) in &items {
let dominated = items.iter().any(|(_, other)| {
self.measure20_comparable_order(other, anchor) == Some(Ordering::Greater)
});
if !dominated {
maximal.push(*key);
}
}
if maximal.len() == 1 {
Governing20::Unique(maximal[0])
} else {
Governing20::Indeterminate
}
}
/// Pin 6c steps 0-3: the governing element among `candidates` relative
/// to `reference`. **Step 0 comes before any candidate set**: if ANY
/// candidate's anchor is incomparable to `reference`, the whole
/// selection is indeterminate — even when the not-after-filtered set
/// would have been empty (an incomparable change is unplaced, not
/// absent, and it might have governed).
fn measure20_governing_by_anchor<'x, T: Copy>(
&self,
reference: &TimeAnchor,
candidates: impl IntoIterator<Item = (T, &'x TimeAnchor)>,
) -> Governing20<T> {
let mut not_after: Vec<(T, &TimeAnchor)> = Vec::new();
for (key, anchor) in candidates {
match self.measure20_comparable_order(anchor, reference) {
None => return Governing20::Indeterminate,
Some(Ordering::Greater) => {}
Some(_) => not_after.push((key, anchor)),
}
}
self.measure20_unique_maximum(not_after)
}
/// Pin 6c: the effective grid's governing time signature at `start`,
/// from an already-resolved `sequence` (this crate's effective grid is
/// simply the instance's own local grid, or else the region default —
/// see [`Self::check_measure_meter_consistency`]).
fn measure20_governing_time_signature(
&self,
sequence: &[MeterChange],
start: &TimeAnchor,
) -> Governing20<TimeSignatureId> {
self.measure20_governing_by_anchor(
start,
sequence.iter().map(|c| (c.time_signature, &c.anchor)),
)
}
/// 20. Agreement and boundary consistency (contract pins 6/6b/6c/9b).
/// ABSTAINS (emits no violation) wherever the comparison or delta is
/// not computable — base-ingested data may predate the rule (pin 7);
/// this is deliberate abstention, not a soundness gap. Does NOT
/// duplicate invariant 10's signature-resolution check: only
/// ALREADY-RESOLVING signatures are compared (pin 9b, "a resolving
/// `Some(id)`"), whether that is the measure's own declared
/// signature or a grid entry's.
///
/// **The nine non-success paths** below the body's two `match`
/// ladders, enumerated and classified by
/// `spec/CONTRACT_P13S18_MATRIX.md` pins 12 (a diagnostic listing:
/// descriptive of existing behaviour, not normative, and it moves no
/// line of the executable body below). Exactly three are genuine
/// abstentions; the rest are inapplicable, vacuous, delegated
/// elsewhere, or a separately-filed deferral:
///
/// | Id | Clause | Path | Class |
/// |---|---|---|---|
/// | A1 | agreement | `m.time_signature` is `None` | inapplicable — no declared signature can disagree with anything |
/// | A2 | agreement | declared signature does not resolve | delegated to invariant 10's per-measure arm |
/// | A3 | agreement | `Governing20::None` | vacuous — no governing signature exists to disagree with |
/// | A4 | agreement | `Governing20::Indeterminate` | genuine abstention — the relation cannot place a candidate |
/// | B1 | boundary | first measure (`i == 0`) | the pickup/anacrusis deferral, filed as `P13-S19` |
/// | B2 | boundary | governing signature does not resolve | delegated to invariant 10's grid-level arms |
/// | B3 | boundary | `Governing20::None` | vacuous, same as A3 |
/// | B4 | boundary | `Governing20::Indeterminate` | genuine abstention, same as A4 |
/// | B5 | boundary | musical delta not computable | genuine abstention — order without distance |
///
/// A2 and B2 are `delegated`, not merely unenforced, only because
/// invariant 10 actually reports the same unresolving reference on
/// the same graph (`spec/CONTRACT_P13S18_MATRIX.md` pin 3) — see
/// `g3b_measure20_tests::m39_unresolvable_reference_is_invariant_
/// 10_only` (A2) and `matrix_b2_governing_signature_unresolving_
/// delegated` (B2) below, and M7/M8 in the contract's mutation
/// plan.
fn check_measure_meter_consistency(&self, out: &mut Vec<InvariantViolation>) {
let time_sigs: HashMap<TimeSignatureId, &TimeSignature> = self
.score
.time_signatures
.iter()
.map(|t| (t.id, t))
.collect();
for region in &self.score.canvas.regions {
let default_grid = region
.content
.staff_based()
.and_then(|c| c.default_metric_grid.as_ref());
for instance in region.staff_instances() {
let sequence: &[MeterChange] = instance
.local_metric_grid
.as_ref()
.map(|g| g.meter_sequence.as_slice())
.or_else(|| default_grid.map(|g| g.meter_sequence.as_slice()))
.unwrap_or(&[]);
let measures = &instance.measures;
for (i, m) in measures.iter().enumerate() {
// Agreement clause: ONLY a resolving `Some(id)` (pin
// 9b) — an unresolving reference is invariant 10's
// business, not this one's, and `None` avoids this
// clause entirely (but not the boundary clause below).
if let Some(sig) = m.time_signature {
if time_sigs.contains_key(&sig) {
match self.measure20_governing_time_signature(sequence, &m.start) {
Governing20::Unique(active) if active != sig => {
out.push(InvariantViolation::new(
GraphInvariant::MeasureMeterConsistency,
format!(
"measure {:?} declares time signature {:?} but the \
effective grid's active signature at its start is \
{:?}",
m.id, sig, active
),
));
}
Governing20::Unique(_) | Governing20::None => {}
// Indeterminate selection: abstain (pin 7).
Governing20::Indeterminate => {}
}
}
}
// Boundary clause: vacuous for the first measure (no
// predecessor — pickup/anacrusis deferral, P13-S19).
if i == 0 {
continue;
}
let prev = &measures[i - 1];
match self.measure20_governing_time_signature(sequence, &prev.start) {
Governing20::Unique(sig) => {
let Some(ts) = time_sigs.get(&sig) else {
// The grid's own entry doesn't resolve:
// invariant 10's business, not this one's —
// abstain.
continue;
};
match self.measure20_musical_delta(&prev.start, &m.start) {
Some(delta) if &delta == ts.measure_duration() => {}
Some(_) => {
out.push(InvariantViolation::new(
GraphInvariant::MeasureMeterConsistency,
format!(
"measure {:?} start is not exactly one \
measure_duration ({:?}, under governing \
signature {:?}) after predecessor measure \
{:?}'s start",
m.id,
ts.measure_duration(),
sig,
prev.id
),
));
}
// Delta not computable: abstain (pin 7).
None => {}
}
}
// No active signature governs the predecessor's
// start: vacuous, not a violation (pin 6c case 1).
Governing20::None => {}
// Indeterminate selection: abstain (pin 7).
Governing20::Indeterminate => {}
}
}
}
}
}
// --- Invariant 21, `StaffGroupMembershipAgreement` (P13-S16 pins 6, 6b).
// TWO separately dispatched methods, one per direction. The split is
// required, not stylistic: each direction must have an independently
// deletable call site so that deleting one leaves the other reporting
// (M6a/M6b). A single shared comparison would satisfy every behavioural
// assertion while making that observation impossible. Both emit the same
// `GraphInvariant`, so both directions collapse to one variant in any
// `kinds`-style set — which is why direction is asserted by name, never by
// counting. -----------------------------------------------------------
/// Direction **S→G**: every live staff naming a group appears in that
/// group's `members`. A maintenance gap — pin 2 failing to append — shows up
/// here.
///
/// A staff naming a group that does not exist is **not** flagged here:
/// dangling reference resolution belongs to the referential invariants, and
/// abstaining keeps this invariant's witnesses about *agreement* only.
fn check_staff_names_absent_group(&self, out: &mut Vec<InvariantViolation>) {
let members: HashMap<StaffGroupId, BTreeSet<StaffId>> = self
.score
.staff_groups
.iter()
.map(|group| (group.id, group.members.iter().copied().collect()))
.collect();
for staff in &self.score.staves {
let Some(group_id) = staff.group else {
continue;
};
if let Some(ids) = members.get(&group_id) {
if !ids.contains(&staff.id) {
out.push(InvariantViolation::new(
GraphInvariant::StaffGroupMembershipAgreement,
format!(
"S->G: staff {:?} names group {:?}, but that group's members omit it",
staff.id, group_id
),
));
}
}
}
}
/// Direction **G→S**: every staff a group lists names that group in its own
/// `group` field. A stale projection — a member left behind, or one pointing
/// at a different group — shows up here.
///
/// A member id with no live staff is **not** flagged here, for the same
/// reason as the S→G direction.
fn check_group_lists_unowned_staff(&self, out: &mut Vec<InvariantViolation>) {
let owner: HashMap<StaffId, Option<StaffGroupId>> = self
.score
.staves
.iter()
.map(|staff| (staff.id, staff.group))
.collect();
for group in &self.score.staff_groups {
for member in &group.members {
let Some(named) = owner.get(member) else {
continue;
};
if *named != Some(group.id) {
out.push(InvariantViolation::new(
GraphInvariant::StaffGroupMembershipAgreement,
format!(
"G->S: group {:?} lists staff {:?}, but that staff names {:?}",
group.id, member, named
),
));
}
}
}
}
}
/// Genesis tranche G3b (`spec/CONTRACT_GENESIS_G3B_MEASURE.md` pin 6c): the
/// outcome of finding the governing element of a partially-ordered set under
/// [`GraphIndex::measure20_comparable_order`] — an INDEPENDENT (core-only)
/// implementation of the SAME normative relation `epiphany-ops`'s `Reducer`
/// computes privately (see the architecture note on invariant 20's checker
/// methods, above).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Governing20<T> {
/// No candidate at all (an empty, or wholly-filtered-away, set) —
/// vacuous, not a violation.
None,
/// Exactly one candidate is the unique maximum.
Unique(T),
/// Either some candidate is incomparable to the reference point, or
/// multiple maxima are mutually incomparable to each other — the check
/// ABSTAINS rather than guessing.
Indeterminate,
}
/// Whether an aleatoric interval bound is ordered (`min <= max`): `Some(true)`
/// when ordered, `Some(false)` when reversed, `None` for [`TimeBounds::Unbounded`]
/// (Chapter 3 §"Aleatoric Time").
fn time_bounds_ordered(tb: &crate::time::TimeBounds) -> Option<bool> {
use crate::time::TimeBounds;
match tb {
TimeBounds::MusicalRange { min, max } => Some(min <= max),
TimeBounds::WallClockRange { min, max } => Some(min <= max),
TimeBounds::Unbounded => None,
}
}
/// Applies an [`AnchorOffset`] to a resolved absolute wall-clock coordinate
/// (nanoseconds), or `None` if the offset is musical — a musical offset on a
/// wall-clock coordinate needs the deferred tempo map to convert.
fn apply_offset(base: i64, offset: &AnchorOffset) -> Option<i64> {
match offset {
AnchorOffset::Zero => Some(base),
// Checked so a pathological offset is unresolvable, not a panic.
AnchorOffset::WallClock(d) => base.checked_add(d.0),
AnchorOffset::Musical(_) => None,
}
}
/// Whether an event's coordinate kinds satisfy a region's discipline
/// (invariant 4).
fn coordinate_ok(e: &Event, disc: CoordinateDiscipline) -> bool {
use crate::time::CoordinateKind::*;
let pos = e.position().kind();
let dur = e.duration();
match disc {
CoordinateDiscipline::Musical => {
matches!(pos, Musical) && matches!(dur, EventDuration::Musical(_))
}
CoordinateDiscipline::WallClock => {
matches!(pos, WallClock) && matches!(dur, EventDuration::WallClock(_))
}
CoordinateDiscipline::Aleatoric(a) => aleatoric_ok(e, a),
}
}
fn aleatoric_ok(e: &Event, a: crate::graph::AleatoricAnchoringDiscipline) -> bool {
use crate::graph::AleatoricAnchoringDiscipline as D;
use crate::time::CoordinateKind::{Musical, WallClock};
let pos = e.position().kind();
let dur_kind = e.duration().concrete_kind(); // None for indeterminate
let bounds_kind = match e.duration() {
EventDuration::Indeterminate(b) => duration_bounds_kind(b),
_ => BoundsKind::Concrete(dur_kind),
};
match a {
D::Musical => pos == Musical && bounds_kind.allows_only(Musical),
D::WallClock => pos == WallClock && bounds_kind.allows_only(WallClock),
D::EitherPerEvent => {
// Position and duration kinds must agree; bounds single-variant.
match (dur_kind, bounds_kind) {
(Some(k), _) => pos == k,
(None, BoundsKind::Single(k)) => pos == k,
(None, BoundsKind::Mixed) => false,
(None, BoundsKind::Empty) => true,
(None, BoundsKind::Concrete(_)) => true,
}
}
D::FreelyMixed => true,
}
}
enum BoundsKind {
Empty,
Single(crate::time::CoordinateKind),
Mixed,
Concrete(Option<crate::time::CoordinateKind>),
}
impl BoundsKind {
fn allows_only(&self, k: crate::time::CoordinateKind) -> bool {
match self {
BoundsKind::Empty => true,
BoundsKind::Single(x) | BoundsKind::Concrete(Some(x)) => *x == k,
BoundsKind::Concrete(None) => true,
BoundsKind::Mixed => false,
}
}
}
fn duration_bounds_kind(b: &crate::time::DurationBounds) -> BoundsKind {
let k = |c: &ConcreteDuration| c.kind();
match (&b.lower, &b.upper) {
(None, None) => BoundsKind::Empty,
(Some(l), None) => BoundsKind::Single(k(l)),
(None, Some(u)) => BoundsKind::Single(k(u)),
(Some(l), Some(u)) => {
if k(l) == k(u) {
BoundsKind::Single(k(l))
} else {
BoundsKind::Mixed
}
}
}
}
/// Whether an anchor offset kind matches a region's coordinate discipline
/// (invariant 9).
fn offset_matches(offset: OffsetKind, disc: CoordinateDiscipline) -> bool {
use crate::graph::AleatoricAnchoringDiscipline as A;
match offset {
OffsetKind::Zero => true,
OffsetKind::Musical => matches!(
disc,
CoordinateDiscipline::Musical
| CoordinateDiscipline::Aleatoric(A::Musical)
| CoordinateDiscipline::Aleatoric(A::EitherPerEvent)
| CoordinateDiscipline::Aleatoric(A::FreelyMixed)
),
OffsetKind::WallClock => matches!(
disc,
CoordinateDiscipline::WallClock
| CoordinateDiscipline::Aleatoric(A::WallClock)
| CoordinateDiscipline::Aleatoric(A::EitherPerEvent)
| CoordinateDiscipline::Aleatoric(A::FreelyMixed)
),
}
}
/// Whether an anchor offset kind matches a *specific* coordinate clock — used
/// for `EitherPerEvent` regions, where the targeted event (not the region)
/// fixes the clock (invariant 9; finding on `EitherPerEvent`).
fn offset_matches_kind(offset: OffsetKind, kind: crate::time::CoordinateKind) -> bool {
use crate::time::CoordinateKind::{Musical, WallClock};
match offset {
OffsetKind::Zero => true,
OffsetKind::Musical => kind == Musical,
OffsetKind::WallClock => kind == WallClock,
}
}
/// A comparable (start, end) interval for an event, within whichever clock its
/// position uses. Mismatched position/duration clocks yield `Unknown` (that is
/// invariant 4's concern, not 3's). Indeterminate durations collapse to a
/// point so they never spuriously trigger an overlap.
enum Endpoints {
Musical(MusicalPosition, MusicalPosition),
Wall(i64, i64),
Unknown,
}
#[derive(PartialEq)]
enum TimeKey {
Musical(MusicalPosition),
Wall(i64),
}
impl TimeKey {
/// `self <= other` when both are the same clock; `true` (don't flag) when
/// clocks differ (invariant 4 owns that mismatch).
fn le_same_clock(&self, other: &TimeKey) -> bool {
match (self, other) {
(TimeKey::Musical(a), TimeKey::Musical(b)) => a <= b,
(TimeKey::Wall(a), TimeKey::Wall(b)) => a <= b,
_ => true,
}
}
}
impl Endpoints {
fn of(e: &Event) -> Endpoints {
match e.position() {
EventPosition::Musical(p) => {
let end = match e.duration() {
EventDuration::Musical(d) => p.clone() + d.clone(),
EventDuration::Indeterminate(_) => p.clone(),
EventDuration::WallClock(_) => return Endpoints::Unknown,
};
Endpoints::Musical(p.clone(), end)
}
EventPosition::WallClock(t) => {
let end = match e.duration() {
// Overflow is unresolvable, not a saturated (wrong) endpoint
// that could mask an ordering violation — report Unknown.
EventDuration::WallClock(d) => match t.0.checked_add(d.0) {
Some(end) => end,
None => return Endpoints::Unknown,
},
EventDuration::Indeterminate(_) => t.0,
EventDuration::Musical(_) => return Endpoints::Unknown,
};
Endpoints::Wall(t.0, end)
}
}
}
fn start_key(&self) -> Option<TimeKey> {
match self {
Endpoints::Musical(s, _) => Some(TimeKey::Musical(s.clone())),
Endpoints::Wall(s, _) => Some(TimeKey::Wall(*s)),
Endpoints::Unknown => None,
}
}
fn end_key(&self) -> Option<TimeKey> {
match self {
Endpoints::Musical(_, e) => Some(TimeKey::Musical(e.clone())),
Endpoints::Wall(_, e) => Some(TimeKey::Wall(*e)),
Endpoints::Unknown => None,
}
}
}
#[cfg(test)]
mod review_fix_tests {
//! Targeted tests for the strengthened checks (overlap resolution, dangling
//! spanner anchors, tie None-pairing / cross-voice, promoted-id derivation,
//! tombstone collisions, comprehensive anchor offsets).
use super::*;
use crate::event::{Event, PitchedEvent, StemConfiguration};
use crate::generators::valid_score;
use crate::graph::{
derive_promoted_voice_id, Beam, MetricTimeModel, ProportionalTimeModel, Region,
RegionContent, RepeatKind, RepeatStructure, Spanner, StaffBasedContent, StaffExtent,
StaffInstance, SubBeam, Tie, TieClass, TimeExtent, Voice, VoiceOrigin, Volta,
};
use crate::ids::{BeamId, OperationId, RepeatStructureId, ReplicaId, SpannerId, TieId};
use crate::pitch::{
AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId,
PitchSpacePosition, ScalePosition, TuningReference,
};
use crate::time::{
AnchorOffset, EventDuration, EventPosition, MusicalDuration, MusicalPosition, RationalTime,
RegionEdge, WallClockDuration, WallClockTime,
};
fn fires(score: &Score, inv: GraphInvariant) -> bool {
!check_invariant(score, inv).is_empty()
}
fn cmn_ip(r: ReplicaId, c: u64, nominal: CmnNominal, oct: i8) -> IdentifiedPitch {
IdentifiedPitch {
id: crate::ids::PitchId::new(r, c),
pitch: Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("cmn-12"),
position: PitchSpacePosition::Cmn {
nominal,
alteration: 0,
octave: oct,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
},
}
}
fn wc(a: i64, b: i64) -> TimeExtent {
TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(a),
},
end: TimeAnchor::WallClock {
time: WallClockTime(b),
},
}
}
#[test]
fn inv7_detects_overlap_on_resolvable_wallclock_extents() {
let mut s = valid_score(1);
let staff = s.staves[0].id;
// A second region manifesting the same staff, overlapping in wall-clock
// time with the first region's [0, 1_000_000) extent.
let rid = s.identity.mint();
let inst = StaffInstance::new(s.identity.mint(), staff);
let region = Region {
id: rid,
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![inst],
..Default::default()
}),
time_extent: wc(500, 1500),
staff_extent: StaffExtent {
staves: vec![staff],
},
local_tempo_map: None,
permits_spanning_slurs: false,
};
s.canvas.regions.push(region);
assert!(fires(&s, GraphInvariant::RegionExtents));
// Disjoint-in-time (touching at the far end) must NOT fire.
s.canvas.regions.last_mut().unwrap().time_extent = wc(1_000_000, 2_000_000);
assert!(!fires(&s, GraphInvariant::RegionExtents));
}
#[test]
fn inv7_unresolvable_overlap_is_deferred_not_silently_valid() {
let mut s = valid_score(1);
let staff = s.staves[0].id;
let r0 = s.canvas.regions[0].id;
let rid = s.identity.mint();
let inst = StaffInstance::new(s.identity.mint(), staff);
// A second region on the same staff whose extent is anchored
// region-relative with a *musical* offset; with no tempo map it cannot
// resolve to wall-clock, so its overlap with region 0's wall-clock extent
// is undecidable.
let symbolic = TimeExtent {
start: TimeAnchor::Region {
id: r0,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
end: TimeAnchor::Region {
id: r0,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration::whole()),
},
};
s.canvas.regions.push(Region {
id: rid,
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![inst],
..Default::default()
}),
time_extent: symbolic,
staff_extent: StaffExtent {
staves: vec![staff],
},
local_tempo_map: None,
permits_spanning_slurs: false,
});
// Sound: the undecidable overlap is NOT raised as a (false-positive)
// violation — `check_invariants` stays clean...
assert!(
check_invariants(&s).is_empty(),
"unexpected violations: {:?}",
check_invariants(&s)
);
// ...but it is surfaced as a deferred check naming both regions, rather
// than silently treated as disjoint/valid.
let deferred = deferred_checks(&s);
assert_eq!(deferred.len(), 1, "{deferred:?}");
assert_eq!(deferred[0].invariant, GraphInvariant::RegionExtents);
assert!(deferred[0].reason.contains(&format!("{r0:?}")));
assert!(deferred[0].reason.contains(&format!("{rid:?}")));
// A wall-clock (resolvable), disjoint second region is *decided*, so it is
// neither a violation nor deferred.
s.canvas.regions.last_mut().unwrap().time_extent = wc(2_000_000, 3_000_000);
assert!(deferred_checks(&s).is_empty());
assert!(check_invariants(&s).is_empty());
}
#[test]
fn inv9_flags_offset_on_event_anchored_meter_change() {
// A proportional region whose own meter list is empty; place a metric
// region and give a spanner a wall-clock offset against it (already
// covered by the generator). Here exercise the *measure-start* path:
// a metric region, with a spanner anchored to one of its events
// carrying a musical offset is fine; a wall-clock offset is not.
let mut s = valid_score(2);
let rid = s.canvas.regions[0].id;
let staff = s.staves[0].id;
let spanner_ok = Spanner {
id: SpannerId::new(s.identity.replica_id, 1),
start: TimeAnchor::Region {
id: rid,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration::whole()),
},
end: TimeAnchor::WallClock {
time: WallClockTime(1),
},
staves: vec![staff],
kind: Default::default(),
style: Default::default(),
};
s.cross_cutting.spanners.push(spanner_ok);
assert!(!fires(&s, GraphInvariant::AnchorOffsetModel));
// Now a wall-clock offset against the metric region: invalid.
s.cross_cutting.spanners[0].start = TimeAnchor::Region {
id: rid,
edge: RegionEdge::Start,
offset: AnchorOffset::WallClock(WallClockDuration(1)),
};
assert!(fires(&s, GraphInvariant::AnchorOffsetModel));
}
#[test]
fn inv10_flags_dangling_spanner_anchor() {
let mut s = valid_score(3);
let staff = s.staves[0].id;
let ghost_event = crate::ids::EventId::new(s.identity.replica_id, 9_000_001);
s.cross_cutting.spanners.push(Spanner {
id: SpannerId::new(s.identity.replica_id, 1),
start: TimeAnchor::Event {
id: ghost_event,
offset: AnchorOffset::Zero,
},
end: TimeAnchor::WallClock {
time: WallClockTime(0),
},
staves: vec![staff],
kind: Default::default(),
style: Default::default(),
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn inv10_flags_dangling_sub_beam_event() {
// Schema major 2: sub-beam member events are references the invariant
// must resolve, exactly like the owning beam's events.
let mut s = valid_score(3);
let live: Vec<_> = s
.voices()
.flat_map(|(_, _, v)| v.events.clone())
.take(2)
.collect();
let ghost_event = crate::ids::EventId::new(s.identity.replica_id, 9_000_002);
s.cross_cutting.beams.push(Beam {
id: BeamId::new(s.identity.replica_id, 1),
events: live.clone(),
level: 1,
sub_beams: vec![SubBeam {
level: 2,
events: vec![ghost_event],
}],
geometry_override: None,
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn inv10_flags_dangling_repeat_kind_and_volta_anchors() {
// Schema major 2: a DalSegno's segno/end_target and each volta's span
// are anchors the invariant must resolve.
let mut s = valid_score(3);
let r = s.identity.replica_id;
let ghost = crate::ids::EventId::new(r, 9_000_003);
let dead_anchor = TimeAnchor::Event {
id: ghost,
offset: AnchorOffset::Zero,
};
let ok = TimeAnchor::WallClock {
time: WallClockTime(0),
};
s.cross_cutting.repeats.push(RepeatStructure {
id: RepeatStructureId::new(r, 1),
start: ok.clone(),
end: ok.clone(),
kind: RepeatKind::DalSegno {
segno: dead_anchor.clone(),
end_target: ok.clone(),
},
voltas: vec![],
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
let mut s2 = valid_score(3);
s2.cross_cutting.repeats.push(RepeatStructure {
id: RepeatStructureId::new(r, 2),
start: ok.clone(),
end: ok.clone(),
kind: RepeatKind::Volta,
voltas: vec![Volta {
endings: vec![1],
start: dead_anchor,
end: ok,
}],
});
assert!(fires(&s2, GraphInvariant::CrossCuttingRefsResolve));
}
/// Builds a single-voice score with two adjacent pitched chords and returns
/// (score, e0, e1) for tie tests.
fn two_chord_score(
seed: u64,
start_pitch: (CmnNominal, i8),
end_pitch: (CmnNominal, i8),
) -> (Score, crate::ids::EventId, crate::ids::EventId) {
let mut s = valid_score(seed);
let r = s.identity.replica_id;
let voice_id = s.canvas.regions[0].staff_instances()[0].voices[0].id;
// Replace the voice's events with exactly two adjacent pitched events.
let old: Vec<_> = s.canvas.regions[0].staff_instances()[0].voices[0]
.events
.clone();
for e in old {
s.events.remove(e);
}
let e0 = s.identity.mint();
let e1 = s.identity.mint();
let mk = |id, pos: i64, p: IdentifiedPitch| {
Event::Pitched(PitchedEvent {
id,
voice: voice_id,
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(pos, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
pitches: vec![p],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
})
};
s.events
.insert(mk(e0, 0, cmn_ip(r, 100, start_pitch.0, start_pitch.1)))
.unwrap();
s.events
.insert(mk(e1, 1, cmn_ip(r, 101, end_pitch.0, end_pitch.1)))
.unwrap();
let insts = s.canvas.regions[0].content.staff_instances_mut().unwrap();
insts[0].voices[0].events = vec![e0, e1];
(s, e0, e1)
}
#[test]
fn inv17_none_pairing_checks_enharmonic_implicit_pairs() {
// C4 -> D4 with implicit (None) pairing is not enharmonic -> fires.
let (mut s, e0, e1) = two_chord_score(10, (CmnNominal::C, 4), (CmnNominal::D, 4));
s.cross_cutting.ties.push(Tie {
id: TieId::new(s.identity.replica_id, 1),
start_event: e0,
end_event: e1,
pitch_pairing: None,
class: TieClass::Standard,
style: Default::default(),
});
assert!(fires(&s, GraphInvariant::TiePairing));
// C4 -> C4 with None pairing is a valid standard tie.
let (mut s2, e0, e1) = two_chord_score(11, (CmnNominal::C, 4), (CmnNominal::C, 4));
s2.cross_cutting.ties.push(Tie {
id: TieId::new(s2.identity.replica_id, 1),
start_event: e0,
end_event: e1,
pitch_pairing: None,
class: TieClass::Standard,
style: Default::default(),
});
assert!(!fires(&s2, GraphInvariant::TiePairing));
}
#[test]
fn inv11_flags_tombstone_live_collision_and_duplicate_cc_id() {
// Tombstone an event id that is still live -> identifier reuse.
let mut s = valid_score(20);
let live = s.events.ids_canonical()[0];
s.tombstoned_events.insert(live);
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
// Two slurs with the same id.
let mut s2 = valid_score(21);
let staff_event = s2.events.ids_canonical()[0];
let sid = crate::ids::SlurId::new(s2.identity.replica_id, 1);
for _ in 0..2 {
s2.cross_cutting.slurs.push(crate::graph::Slur {
id: sid,
start_event: staff_event,
end_event: staff_event,
kind: Default::default(),
curvature_override: None,
style: Default::default(),
});
}
assert!(fires(&s2, GraphInvariant::UniqueIdentifiers));
}
#[test]
fn inv11_flags_system_derived_misuse_on_region() {
let mut s = valid_score(22);
s.canvas.regions[0].id = crate::ids::RegionId::new(ReplicaId::SYSTEM_DERIVED, 1);
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
}
#[test]
fn inv18_flags_fabricated_promoted_voice_id_and_accepts_the_derivation() {
let mut s = valid_score(30);
let si = s.canvas.regions[0].staff_instances()[0].id;
let original = s.canvas.regions[0].staff_instances()[0].voices[0].id;
let winner = OperationId::new(s.identity.replica_id, 5);
let loser = OperationId::new(s.identity.replica_id, 6);
let correct = derive_promoted_voice_id(si, original, winner, loser);
// A SystemPromoted voice with the *correct* derived id is accepted.
let good = Voice {
id: correct,
events: vec![],
default_stem_direction: None,
is_primary: false,
origin: VoiceOrigin::SystemPromoted {
winning_operation: winner,
losing_operation: loser,
original_voice: original,
},
};
s.canvas.regions[0].content.staff_instances_mut().unwrap()[0]
.voices
.push(good);
assert!(!fires(&s, GraphInvariant::VoiceOriginConsistent));
// A fabricated id (even in the SYSTEM_DERIVED namespace) is rejected:
// it does not equal the deterministic derivation.
let fabricated = crate::ids::VoiceId::new(ReplicaId::SYSTEM_DERIVED, 0xDEAD);
s.canvas.regions[0].content.staff_instances_mut().unwrap()[0]
.voices
.push(Voice {
id: fabricated,
events: vec![],
default_stem_direction: None,
is_primary: false,
origin: VoiceOrigin::SystemPromoted {
winning_operation: winner,
losing_operation: loser,
original_voice: original,
},
});
assert!(fires(&s, GraphInvariant::VoiceOriginConsistent));
}
#[test]
fn inv10_flags_dangling_marker_lyric_and_gesture_refs() {
use crate::graph::{
AnnotationAnchor, ChordSymbol, Comment, GestureAnchoring, GraphicGesture, LyricLine,
Marker, RepeatStructure,
};
let r = valid_score(50).identity.replica_id;
let ghost_e = crate::ids::EventId::new(r, 9_100_001);
// Marker anchored to a non-existent region.
let mut s = valid_score(50);
s.cross_cutting.markers.push(Marker {
id: crate::ids::MarkerId::new(r, 1),
anchor: TimeAnchor::Region {
id: crate::ids::RegionId::new(r, 9_100_002),
edge: crate::time::RegionEdge::Start,
offset: AnchorOffset::Zero,
},
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Lyric line referencing a dangling event.
let mut s = valid_score(51);
s.cross_cutting.lyrics.push(LyricLine {
id: crate::ids::LyricLineId::new(r, 1),
events: vec![ghost_e],
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Graphic gesture anchored to a dangling event.
let mut s = valid_score(52);
s.cross_cutting.graphic_gestures.push(GraphicGesture {
id: crate::ids::GraphicGestureId::new(r, 1),
objects: vec![],
anchoring: GestureAnchoring::Events(vec![ghost_e]),
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Repeat / comment / chord symbol with a dangling event anchor.
let mut s = valid_score(53);
s.cross_cutting.repeats.push(RepeatStructure {
id: crate::ids::RepeatStructureId::new(r, 1),
start: TimeAnchor::Event {
id: ghost_e,
offset: AnchorOffset::Zero,
},
end: TimeAnchor::WallClock {
time: crate::time::WallClockTime(0),
},
kind: crate::graph::RepeatKind::migration_default(),
voltas: Vec::new(),
});
s.cross_cutting.comments.push(Comment {
id: crate::ids::CommentId::new(r, 1),
anchor: AnnotationAnchor::Event(ghost_e),
resolved: false,
});
s.cross_cutting.chord_symbols.push(ChordSymbol {
id: crate::ids::ChordSymbolId::new(r, 1),
anchor: TimeAnchor::Event {
id: ghost_e,
offset: AnchorOffset::Zero,
},
});
assert!(check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve).len() >= 3);
}
#[test]
fn proportional_region_resolver_round_trips() {
// Sanity: a proportional region's wall-clock extents resolve and a
// non-overlapping pair on a shared staff does not fire inv7.
let mut s = valid_score(40);
let staff = s.staves[0].id;
let inst = StaffInstance::new(s.identity.mint(), staff);
s.canvas.regions.push(Region {
id: s.identity.mint(),
time_model: RegionTimeModel::Proportional(ProportionalTimeModel {
duration: WallClockDuration(1000),
}),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![inst],
..Default::default()
}),
time_extent: wc(2_000_000, 3_000_000),
staff_extent: StaffExtent {
staves: vec![staff],
},
local_tempo_map: None,
permits_spanning_slurs: false,
});
assert!(check_invariants(&s).is_empty());
}
}
#[cfg(test)]
mod review_fix_tests_2 {
//! Tests for the second review pass: complete reference resolution
//! (annotation layer, tuplet parent, gesture/graphic-event objects,
//! event-internal refs), comprehensive id uniqueness + reserved-namespace
//! and arena-integrity checks, and the hardened identity context.
use super::*;
use crate::event::{
CueEvent, CueRendering, Event, GraphicEvent, IndeterminacyHints, IndeterminacyKind,
IndeterminateEvent, TrajectoryDisplay, TrajectoryEndpoint, TrajectoryEvent,
TrajectoryShape,
};
use crate::generators::valid_score;
use crate::graph::{
AnalysisLayer, AnalyticalAnnotation, AnnotationAnchor, GestureAnchoring, GraphicContent,
GraphicGesture, GraphicObject, Instrument, Marker, RegionContent, Tuplet, TupletRatio,
};
use crate::ids::{
AnalysisLayerId, AnalyticalAnnotationId, EventId, GraphicGestureId, GraphicObjectId,
IdentityContext, InstrumentId, MarkerId, PitchId, ReplicaId, TupletId, VoiceId,
};
use crate::time::{
EventDuration, EventPosition, MusicalDuration, MusicalPosition, RationalTime,
};
fn fires(s: &Score, inv: GraphInvariant) -> bool {
!check_invariant(s, inv).is_empty()
}
#[test]
fn inv10_resolves_annotation_layer_and_tuplet_parent() {
let mut s = valid_score(60);
let r = s.identity.replica_id;
// Annotation on a non-existent analysis layer.
s.cross_cutting.analytical.push(AnalyticalAnnotation {
id: AnalyticalAnnotationId::new(r, 1),
anchor: AnnotationAnchor::Region(s.canvas.regions[0].id),
layer: Some(AnalysisLayerId::new(r, 7_000_001)),
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Declaring the layer clears it.
s.analysis_layers.push(AnalysisLayer {
id: AnalysisLayerId::new(r, 7_000_001),
name: "x".into(),
});
assert!(!fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Tuplet with a non-existent parent.
let e = s.events.ids_canonical()[0];
s.cross_cutting.tuplets.push(Tuplet {
id: TupletId::new(r, 1),
ratio: TupletRatio::new(3, 2).expect("3:2 is a valid tuplet ratio"),
members: vec![e],
parent: Some(TupletId::new(r, 7_000_002)),
required_total: MusicalDuration(RationalTime::new(1, 4).unwrap()),
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn inv10_resolves_graphic_object_references() {
let mut s = valid_score(61);
let r = s.identity.replica_id;
let stored = GraphicObjectId::new(r, 1);
let missing = GraphicObjectId::new(r, 2);
// Store one graphic object in a free-graphic region. Free-graphic
// regions have no staff instances, so the staff extent is empty (and it
// is disjoint in time from region 0, so no overlap).
s.canvas.regions.push(crate::graph::Region {
id: crate::ids::RegionId::new(r, 9_003),
time_model: crate::graph::RegionTimeModel::Metric(Default::default()),
content: RegionContent::FreeGraphic(GraphicContent {
objects: vec![GraphicObject { id: stored }],
}),
time_extent: crate::graph::TimeExtent {
start: TimeAnchor::WallClock {
time: crate::time::WallClockTime(5_000_000),
},
end: TimeAnchor::WallClock {
time: crate::time::WallClockTime(6_000_000),
},
},
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 {
id: GraphicGestureId::new(r, 1),
objects: vec![stored],
anchoring: GestureAnchoring::Free,
});
assert!(!fires(&s, GraphInvariant::CrossCuttingRefsResolve));
s.cross_cutting.graphic_gestures[0].objects = vec![missing];
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
/// Adds a pitched event to voice 0 and returns its id (helper for ref tests).
fn add_event(s: &mut Score, ev: Event) -> EventId {
let id = ev.id();
let voice = s.canvas.regions[0].staff_instances()[0].voices[0].id;
let mut ev = ev;
ev.set_voice(voice);
s.events.insert(ev).unwrap();
s.canvas.regions[0].content.staff_instances_mut().unwrap()[0].voices[0]
.events
.push(id);
id
}
#[test]
fn inv10_resolves_event_internal_references() {
// Cue with a dangling source.
let mut s = valid_score(62);
let r = s.identity.replica_id;
let v = VoiceId::new(r, 0); // overwritten by add_event
let ghost = EventId::new(r, 8_000_001);
add_event(
&mut s,
Event::Cue(CueEvent {
id: EventId::new(r, 8_000_010),
voice: v,
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(100, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
source: vec![ghost],
rendering: CueRendering,
}),
);
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Indeterminate event with a dangling alternative.
let mut s = valid_score(63);
let r = s.identity.replica_id;
add_event(
&mut s,
Event::Indeterminate(IndeterminateEvent {
id: EventId::new(r, 8_000_020),
voice: VoiceId::new(r, 0),
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(100, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
indeterminacy: IndeterminacyKind::Choice,
hints: IndeterminacyHints {
alternatives: vec![EventId::new(r, 8_000_099)],
..Default::default()
},
}),
);
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Trajectory referencing a dangling event-pitch.
let mut s = valid_score(64);
let r = s.identity.replica_id;
add_event(
&mut s,
Event::Trajectory(TrajectoryEvent {
id: EventId::new(r, 8_000_030),
voice: VoiceId::new(r, 0),
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(100, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
start: TrajectoryEndpoint::EventPitch(PitchId::new(r, 8_000_098)),
end: TrajectoryEndpoint::EventPitch(PitchId::new(r, 8_000_097)),
shape: TrajectoryShape::Linear,
display: TrajectoryDisplay,
}),
);
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Graphic event referencing an unstored graphic object.
let mut s = valid_score(65);
let r = s.identity.replica_id;
add_event(
&mut s,
Event::Graphic(GraphicEvent {
id: EventId::new(r, 8_000_040),
voice: VoiceId::new(r, 0),
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(100, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
graphics: vec![GraphicObjectId::new(r, 8_000_096)],
playback_bindings: vec![],
}),
);
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn inv11_covers_more_id_kinds_and_identity_namespace() {
// Duplicate marker id.
let mut s = valid_score(70);
let r = s.identity.replica_id;
let mid = MarkerId::new(r, 1);
for _ in 0..2 {
s.cross_cutting.markers.push(Marker {
id: mid,
anchor: TimeAnchor::Region {
id: s.canvas.regions[0].id,
edge: crate::time::RegionEdge::Start,
offset: crate::time::AnchorOffset::Zero,
},
});
}
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
// Duplicate instrument id.
let mut s = valid_score(71);
let r = s.identity.replica_id;
let iid = InstrumentId::new(r, 1);
s.instruments.push(Instrument::new(iid, "a"));
s.instruments.push(Instrument::new(iid, "b"));
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
// Score identity in the reserved namespace.
let mut s = valid_score(72);
s.identity = IdentityContext {
replica_id: ReplicaId::SYSTEM_DERIVED,
next_counter: 0,
};
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
}
#[test]
fn inv11_catches_get_mut_corruption() {
// Clearing a pitched event's pitches via get_mut is caught (malformed).
let mut s = valid_score(80);
let e = s.events.ids_canonical()[0];
if let Some(Event::Pitched(p)) = s.events.get_mut(e) {
p.pitches.clear();
}
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
// Mutating an event's own id via get_mut desyncs the arena index.
let mut s = valid_score(81);
let e = s.events.ids_canonical()[0];
let r = s.identity.replica_id;
if let Some(Event::Pitched(p)) = s.events.get_mut(e) {
p.id = EventId::new(r, 9_999_999);
}
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
}
}
#[cfg(test)]
mod review_fix_tests_3 {
//! Tests for the third review pass: enharmonic implicit tie matching,
//! degenerate tuplet ratios, legitimate system-derived pitches vs reserved
//! misuse, `EitherPerEvent` offset clocks, and dangling instrument refs.
use super::*;
use crate::event::{Event, PitchedEvent, StemConfiguration};
use crate::generators::valid_score;
use crate::graph::{
AleatoricAnchoringDiscipline, AleatoricTimeModel, Instrument, Region, RegionContent,
RegionTimeModel, Spanner, StaffBasedContent, StaffExtent, StaffInstance, Tie, TieClass,
TimeExtent, TupletRatio, Voice,
};
use crate::ids::{
EventId, InstrumentId, PitchId, RegionId, ReplicaId, SpannerId, StaffInstanceId, TieId,
VoiceId,
};
use crate::pitch::{
AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId,
PitchSpacePosition, ScalePosition, TuningReference,
};
use crate::time::{
AnchorOffset, EventDuration, EventPosition, MusicalDuration, MusicalPosition, RationalTime,
WallClockDuration, WallClockTime,
};
fn fires(s: &Score, inv: GraphInvariant) -> bool {
!check_invariant(s, inv).is_empty()
}
fn pitch_at(r: ReplicaId, pid_counter: u64, nominal: CmnNominal, alt: i8) -> IdentifiedPitch {
IdentifiedPitch {
id: PitchId::new(r, pid_counter),
pitch: Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("cmn-12"),
position: PitchSpacePosition::Cmn {
nominal,
alteration: alt,
octave: 4,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
},
}
}
#[test]
fn inv17_implicit_pairing_uses_enharmonic_matching_not_zip() {
// Build a fresh single-voice score with two adjacent two-note chords.
// Start chord (by id order): C4@10, E4@11. End chord: E4@20, C4@21.
// A positional zip (C4↔E4, E4↔C4) would be non-enharmonic and rejected;
// deterministic enharmonic matching pairs C4↔C4 and E4↔E4 -> accepted.
let mut s = valid_score(90);
let r = s.identity.replica_id;
let voice = s.canvas.regions[0].staff_instances()[0].voices[0].id;
for e in s.canvas.regions[0].staff_instances()[0].voices[0]
.events
.clone()
{
s.events.remove(e);
}
let e0 = EventId::new(r, 1000);
let e1 = EventId::new(r, 1001);
let chord = |id, pos: i64, ps: Vec<IdentifiedPitch>| {
Event::Pitched(PitchedEvent {
id,
voice,
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(pos, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
pitches: ps,
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
})
};
s.events
.insert(chord(
e0,
0,
vec![
pitch_at(r, 10, CmnNominal::C, 0),
pitch_at(r, 11, CmnNominal::E, 0),
],
))
.unwrap();
s.events
.insert(chord(
e1,
1,
vec![
pitch_at(r, 20, CmnNominal::E, 0),
pitch_at(r, 21, CmnNominal::C, 0),
],
))
.unwrap();
s.canvas.regions[0].content.staff_instances_mut().unwrap()[0].voices[0].events =
vec![e0, e1];
s.cross_cutting.ties.push(Tie {
id: TieId::new(r, 1),
start_event: e0,
end_event: e1,
pitch_pairing: None,
class: TieClass::Standard,
style: Default::default(),
});
assert!(
!fires(&s, GraphInvariant::TiePairing),
"reordered chord should match"
);
// Replace the end chord's C4 with a G4 (no counterpart for the start C4).
if let Some(Event::Pitched(p)) = s.events.get_mut(e1) {
p.pitches[1] = pitch_at(r, 21, CmnNominal::G, 0);
}
assert!(
fires(&s, GraphInvariant::TiePairing),
"missing counterpart should fire"
);
}
#[test]
fn degenerate_tuplet_ratio_is_rejected_at_construction() {
// Pass 11, item 3.5 / Tuplet honesty: a degenerate ratio is rejected by
// `TupletRatio::new` at construction, so it can never enter the graph
// and is no longer a runtime invariant. A zero term or `actual ==
// notated` is refused; a well-formed ratio is accepted.
assert!(TupletRatio::new(0, 2).is_none(), "n:0-form rejected");
assert!(TupletRatio::new(2, 0).is_none(), "0:n-form rejected");
assert!(TupletRatio::new(0, 0).is_none(), "0:0 rejected");
assert!(
TupletRatio::new(4, 4).is_none(),
"actual == notated rejected"
);
let ok = TupletRatio::new(3, 2).expect("3:2 is valid");
assert_eq!((ok.actual(), ok.notated()), (3, 2));
}
#[test]
fn inv11_accepts_proven_system_pitch_but_flags_arbitrary_one() {
// A MUSCSPCH synthetic pitch is legitimate only when its counter is the
// deterministic content derivation of its own pitch.
let mut s = valid_score(92);
let e = s.events.ids_canonical()[0];
if let Some(Event::Pitched(p)) = s.events.get_mut(e) {
let derived = crate::derive_system_pitch_id(&p.pitches[0].pitch);
p.pitches[0].id = derived;
}
assert!(
!fires(&s, GraphInvariant::UniqueIdentifiers),
"a proven MUSCSPCH derivation is legitimate"
);
// An *arbitrary* counter in the reserved namespace is misuse: it does
// not prove the MUSCSPCH derivation.
let mut s = valid_score(92);
let e = s.events.ids_canonical()[0];
if let Some(Event::Pitched(p)) = s.events.get_mut(e) {
p.pitches[0].id = PitchId::new(ReplicaId::SYSTEM_DERIVED, 123);
}
assert!(
fires(&s, GraphInvariant::UniqueIdentifiers),
"an arbitrary system-derived pitch counter is misuse"
);
// An instrument in the reserved namespace is misuse.
let mut s = valid_score(93);
s.instruments.push(Instrument {
id: InstrumentId::new(ReplicaId::SYSTEM_DERIVED, 1),
name: "x".into(),
range: None,
abbreviation: None,
sound_config: Default::default(),
transposition: None,
default_clef: crate::graph::Clef::treble(),
default_staff_lines: Default::default(),
unpitched_members: Vec::new(),
});
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
}
#[test]
fn inv10_flags_dangling_staff_instrument() {
let mut s = valid_score(94);
let r = s.identity.replica_id;
// Repoint the staff at an undeclared instrument.
s.staves[0].instrument = InstrumentId::new(r, 7_777_001);
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn inv9_either_per_event_offset_matches_event_clock() {
// Aleatoric EitherPerEvent region with a wall-clock event; a spanner
// anchored to that event with a musical offset is wrong, with a
// wall-clock offset is fine.
let mut s = valid_score(95);
let r = s.identity.replica_id;
let staff = s.staves[0].id;
let evid = EventId::new(r, 5000);
let region_id = RegionId::new(r, 5001);
let inst_id = StaffInstanceId::new(r, 5002);
let voice_id = VoiceId::new(r, 5003);
let mut voice = Voice::user(voice_id);
voice.events.push(evid);
let mut inst = StaffInstance::new(inst_id, staff);
// Distinct staff to avoid a same-staff overlap with region 0.
let staff2 = crate::ids::StaffId::new(r, 5009);
s.staves.push(crate::graph::Staff {
id: staff2,
name: "w".into(),
abbreviation: None,
instrument: s.staves[0].instrument,
default_staff_lines: Default::default(),
group: None,
default_clef: crate::graph::Clef::treble(),
});
inst.staff = staff2;
inst.voices.push(voice);
s.events
.insert(Event::Pitched(PitchedEvent {
id: evid,
voice: voice_id,
position: EventPosition::WallClock(WallClockTime(10)),
duration: EventDuration::WallClock(WallClockDuration(5)),
pitches: vec![pitch_at(r, 5100, CmnNominal::C, 0)],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
}))
.unwrap();
s.canvas.regions.push(Region {
id: region_id,
time_model: RegionTimeModel::Aleatoric(AleatoricTimeModel {
anchoring: AleatoricAnchoringDiscipline::EitherPerEvent,
ordering: Default::default(),
bounds: Default::default(),
duration_hint: WallClockDuration(1000),
}),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![inst],
..Default::default()
}),
time_extent: TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(2_000_000),
},
end: TimeAnchor::WallClock {
time: WallClockTime(3_000_000),
},
},
staff_extent: StaffExtent {
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 {
id: SpannerId::new(r, 1),
start: TimeAnchor::Event {
id: evid,
offset: AnchorOffset::Musical(MusicalDuration::whole()),
},
end: TimeAnchor::WallClock {
time: WallClockTime(0),
},
staves: vec![staff2],
kind: Default::default(),
style: Default::default(),
});
assert!(fires(&s, GraphInvariant::AnchorOffsetModel));
// A wall-clock offset matches the event's clock -> ok.
s.cross_cutting.spanners[0].start = TimeAnchor::Event {
id: evid,
offset: AnchorOffset::WallClock(WallClockDuration(1)),
};
assert!(!fires(&s, GraphInvariant::AnchorOffsetModel));
}
#[test]
fn inv10_flags_unresolved_time_signature_reference() {
use crate::generators::valid_score_rich;
use crate::graph::{TimeSignature, TimeSignatureDisplay};
use crate::ids::TimeSignatureId;
let mut s = valid_score_rich(96);
let r = s.identity.replica_id;
let ts_id = TimeSignatureId::new(r, 4_4);
// Point region A's measure at an undeclared time signature.
s.canvas.regions[0].content.staff_instances_mut().unwrap()[0].measures[0].time_signature =
Some(ts_id);
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Declaring a valid 4/4 time signature resolves it.
s.time_signatures.push(
TimeSignature::new(
ts_id,
TimeSignatureDisplay::Standard {
numerator: 4,
denominator: crate::graph::PowerOfTwo::new(4).unwrap(),
},
MusicalDuration::whole(),
vec![crate::graph::BeatGroup {
duration: MusicalDuration::whole(),
subdivision: None,
accent: 0,
}],
)
.unwrap(),
);
assert!(!fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
}
#[cfg(test)]
mod review_fix_tests_4 {
//! Tests for this review pass: aleatoric ordering/bounds validation (F3),
//! tempo-map segment invariants (F4), metric overlap via tempo conversion
//! (F5), inv-11 time-signature/barline/graphic namespace + uniqueness (F6),
//! inv-10 staff-group/part/view/meter reference resolution (F8), dangling
//! decomposition tuplet refs (F9), and tuplet ratio consistency (F10).
use super::*;
use crate::generators::{valid_score, valid_score_rich};
use crate::graph::{
EventOrderingDAG, MetricTimeModel, Region, RegionContent, RegionTimeModel,
StaffBasedContent, StaffExtent, StaffInstance, TupletRatio,
};
use crate::ids::{EventId, ReplicaId};
use crate::tempo::{Tempo, TempoMap, TempoSegment, TempoShape};
use crate::time::{
AnchorOffset, EventBounds, MusicalDuration, MusicalPosition, RationalTime, RegionEdge,
TimeAnchor, TimeBounds, WallClockTime,
};
fn fires(s: &Score, inv: GraphInvariant) -> bool {
!check_invariant(s, inv).is_empty()
}
fn aleatoric_region_events(s: &Score) -> (usize, Vec<EventId>) {
// valid_score_rich's region C (index 2) is aleatoric (musical).
let idx = s
.canvas
.regions
.iter()
.position(|r| matches!(r.time_model, RegionTimeModel::Aleatoric(_)))
.unwrap();
let evs = s.canvas.regions[idx].staff_instances()[0].voices[0]
.events
.clone();
(idx, evs)
}
#[test]
fn f3_aleatoric_dag_referencing_absent_event_fires() {
let mut s = valid_score_rich(200);
let (idx, evs) = aleatoric_region_events(&s);
let ghost = EventId::new(s.identity.replica_id, 9_300_001);
let mut edges = std::collections::BTreeMap::new();
edges.insert(evs[0], vec![ghost]); // real -> ghost (acyclic)
if let RegionTimeModel::Aleatoric(m) = &mut s.canvas.regions[idx].time_model {
m.ordering = EventOrderingDAG::try_new(edges).unwrap();
}
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn f3_aleatoric_bounds_key_absent_and_reversed_window_fire() {
// A bounds key naming a non-region event.
let mut s = valid_score_rich(201);
let (idx, _evs) = aleatoric_region_events(&s);
let ghost = EventId::new(s.identity.replica_id, 9_300_002);
if let RegionTimeModel::Aleatoric(m) = &mut s.canvas.regions[idx].time_model {
m.bounds.insert(ghost, EventBounds::default());
}
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// A reversed (min > max) window on a real region event.
let mut s = valid_score_rich(202);
let (idx, evs) = aleatoric_region_events(&s);
if let RegionTimeModel::Aleatoric(m) = &mut s.canvas.regions[idx].time_model {
m.bounds.insert(
evs[0],
EventBounds {
start: Some(TimeBounds::MusicalRange {
min: MusicalPosition(RationalTime::new(1, 2).unwrap()),
max: MusicalPosition::origin(),
}),
end: None,
},
);
}
assert!(fires(&s, GraphInvariant::EventCoordinateModel));
}
fn region_seg_anchor(rid: crate::ids::RegionId, whole_notes: RationalTime) -> TimeAnchor {
TimeAnchor::Region {
id: rid,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(whole_notes)),
}
}
#[test]
fn f4_tempo_segment_structural_defects_fire() {
let base = valid_score(210);
let rid = base.canvas.regions[0].id;
// Non-constant segment missing its end_tempo.
let mut s = base.clone();
s.tempo_map = TempoMap {
initial: None,
segments: vec![TempoSegment {
start: region_seg_anchor(rid, RationalTime::zero()),
end: Some(region_seg_anchor(rid, RationalTime::from_int(1))),
start_tempo: Tempo::quarter(60.0).unwrap(),
end_tempo: None,
shape: TempoShape::Linear,
}],
};
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Constant segment whose end_tempo disagrees with start_tempo.
let mut s = base.clone();
s.tempo_map = TempoMap {
initial: None,
segments: vec![TempoSegment {
start: region_seg_anchor(rid, RationalTime::zero()),
end: None,
start_tempo: Tempo::quarter(60.0).unwrap(),
end_tempo: Some(Tempo::quarter(120.0).unwrap()),
shape: TempoShape::Constant,
}],
};
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Out-of-order segments (start 2 then start 1).
let mut s = base.clone();
let seg = |from, to| TempoSegment {
start: region_seg_anchor(rid, RationalTime::from_int(from)),
end: Some(region_seg_anchor(rid, RationalTime::from_int(to))),
start_tempo: Tempo::quarter(60.0).unwrap(),
end_tempo: None,
shape: TempoShape::Constant,
};
s.tempo_map = TempoMap {
initial: None,
segments: vec![seg(2, 3), seg(1, 2)],
};
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Segment anchored to a non-existent region (dangling anchor target).
let mut s = base.clone();
let ghost_region = crate::ids::RegionId::new(s.identity.replica_id, 9_400_001);
s.tempo_map = TempoMap {
initial: None,
segments: vec![TempoSegment {
start: region_seg_anchor(ghost_region, RationalTime::zero()),
end: None,
start_tempo: Tempo::quarter(60.0).unwrap(),
end_tempo: None,
shape: TempoShape::Constant,
}],
};
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn f4_tempo_segment_offset_kind_is_checked_by_invariant_9() {
// valid_score's region 0 is metric: a tempo segment anchored to it with
// a wall-clock offset contradicts the time model (invariant 9).
let mut s = valid_score(211);
let rid = s.canvas.regions[0].id;
s.tempo_map = TempoMap {
initial: None,
segments: vec![TempoSegment {
start: TimeAnchor::Region {
id: rid,
edge: RegionEdge::Start,
offset: AnchorOffset::WallClock(crate::time::WallClockDuration(1)),
},
end: None,
start_tempo: Tempo::quarter(60.0).unwrap(),
end_tempo: None,
shape: TempoShape::Constant,
}],
};
assert!(fires(&s, GraphInvariant::AnchorOffsetModel));
}
#[test]
fn f5_overlapping_metric_regions_caught_via_tempo_conversion() {
// Region 0 (metric, wall-clock extent, musical events) grounds the
// events. Two regions on a fresh staff Y anchor their extents to those
// musical events; with a tempo map they resolve and overlap is caught.
let mut s = valid_score(212);
let r = s.identity.replica_id;
let ground_events = s.canvas.regions[0].staff_instances()[0].voices[0]
.events
.clone();
let (e0, e1) = (ground_events[0], ground_events[1]);
// A fresh staff Y (declared) with its instrument.
let staff_y = s.identity.mint();
let instr = s.identity.mint();
s.instruments
.push(crate::graph::Instrument::new(instr, "y"));
s.staves.push(crate::graph::Staff {
id: staff_y,
name: "Y".into(),
abbreviation: None,
instrument: instr,
default_staff_lines: Default::default(),
group: None,
default_clef: crate::graph::Clef::treble(),
});
let mk_region = |s: &mut Score, start: TimeAnchor, end: TimeAnchor| Region {
id: s.identity.mint(),
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![StaffInstance::new(s.identity.mint(), staff_y)],
..Default::default()
}),
time_extent: crate::graph::TimeExtent { start, end },
staff_extent: StaffExtent {
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(
&mut s,
TimeAnchor::Event {
id: e0,
offset: AnchorOffset::Zero,
},
TimeAnchor::Event {
id: e1,
offset: AnchorOffset::Zero,
},
);
// R2 wall-clock [2e8, 8e8] overlaps R1 on staff Y.
let r2 = mk_region(
&mut s,
TimeAnchor::WallClock {
time: WallClockTime(200_000_000),
},
TimeAnchor::WallClock {
time: WallClockTime(800_000_000),
},
);
s.canvas.regions.push(r1);
s.canvas.regions.push(r2);
let _ = r;
// Without a tempo, the musical-event extent cannot be placed: sound but
// incomplete — overlap is NOT (falsely) reported.
s.tempo_map = TempoMap::default();
assert!(!fires(&s, GraphInvariant::RegionExtents));
// With a constant tempo, e0/e1 resolve and the overlap is caught.
s.tempo_map = TempoMap::constant(Tempo::quarter(120.0).unwrap());
assert!(fires(&s, GraphInvariant::RegionExtents));
}
#[test]
fn f6_time_signature_uniqueness_and_namespaces() {
use crate::graph::{BeatGroup, TimeSignature, TimeSignatureDisplay};
use crate::ids::TimeSignatureId;
let mk_ts = |id| {
TimeSignature::new(
id,
TimeSignatureDisplay::Standard {
numerator: 4,
denominator: crate::graph::PowerOfTwo::new(4).unwrap(),
},
MusicalDuration::whole(),
vec![BeatGroup {
duration: MusicalDuration::whole(),
subdivision: None,
accent: 0,
}],
)
.unwrap()
};
// Duplicate TimeSignatureId.
let mut s = valid_score(220);
let id = TimeSignatureId::new(s.identity.replica_id, 1);
s.time_signatures.push(mk_ts(id));
s.time_signatures.push(mk_ts(id));
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
// A TimeSignatureId in the reserved namespace.
let mut s = valid_score(221);
s.time_signatures
.push(mk_ts(TimeSignatureId::new(ReplicaId::SYSTEM_DERIVED, 1)));
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
// A barline group / graphic object in the reserved namespace.
let mut s = valid_score(222);
if let RegionContent::StaffBased(c) = &mut s.canvas.regions[0].content {
c.barline_alignment_groups
.push(crate::graph::BarlineAlignmentGroup {
id: crate::ids::BarlineAlignmentGroupId::new(ReplicaId::SYSTEM_DERIVED, 1),
members: vec![],
});
}
assert!(fires(&s, GraphInvariant::UniqueIdentifiers));
}
#[test]
fn f8_structural_reference_resolution() {
// Staff.group dangling.
let mut s = valid_score(230);
s.staves[0].group = Some(crate::ids::StaffGroupId::new(s.identity.replica_id, 9_001));
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// StaffGroup.members dangling.
let mut s = valid_score(231);
let r = s.identity.replica_id;
s.staff_groups.push(crate::graph::StaffGroup {
id: crate::ids::StaffGroupId::new(r, 1),
name: None,
kind: crate::graph::StaffGroupKind::Bracket,
members: vec![crate::ids::StaffId::new(r, 9_002)],
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// PartDefinition.staves dangling.
let mut s = valid_score(232);
let r = s.identity.replica_id;
s.parts.push(crate::graph::PartDefinition {
id: crate::ids::PartDefinitionId::new(r, 1),
name: "p".into(),
staves: vec![crate::ids::StaffId::new(r, 9_003)],
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// ViewDefinition.active_layers dangling.
let mut s = valid_score(233);
let r = s.identity.replica_id;
s.views.push(crate::graph::ViewDefinition {
id: crate::ids::ViewId::new(r, 1),
name: "v".into(),
active_layers: vec![crate::ids::AnalysisLayerId::new(r, 9_004)],
});
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
// Region-default-grid meter change referencing an undeclared time sig.
let mut s = valid_score(234);
let r = s.identity.replica_id;
let ts = crate::ids::TimeSignatureId::new(r, 9_005);
if let RegionContent::StaffBased(c) = &mut s.canvas.regions[0].content {
c.default_metric_grid = Some(crate::graph::MetricGrid {
meter_sequence: vec![crate::graph::MeterChange {
anchor: TimeAnchor::WallClock {
time: WallClockTime(0),
},
time_signature: ts,
}],
});
}
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn f9_dangling_decomposition_tuplet_reference_fires() {
// The rich score's decomposition references a real tuplet; repoint it at
// a non-existent tuplet id.
let mut s = valid_score_rich(240);
let ghost = crate::ids::TupletId::new(s.identity.replica_id, 9_500_001);
s.decomposition_attachments[0].components[0].tuplet = Some(ghost);
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
}
#[test]
fn f10_tuplet_ratio_inconsistent_with_member_notation_fires() {
// The rich score's eighth-in-3:2 triplet member is consistent.
let s = valid_score_rich(241);
assert!(!fires(&s, GraphInvariant::TupletSum));
// Changing the ratio to 5:4 (member notation unchanged) now has a
// validation effect: the notated eighth no longer scales to 1/12.
let mut s = valid_score_rich(241);
s.cross_cutting.tuplets[0].ratio =
TupletRatio::new(5, 4).expect("5:4 is a valid tuplet ratio");
assert!(fires(&s, GraphInvariant::TupletSum));
}
}
#[cfg(test)]
mod accidental_compatibility_tests {
//! `check_accidental_modification_compatibility` (Push 4b tranche 3a,
//! `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`, proof-of-life item 3): "a
//! `CmnChromatic` accidental in a `cmn-12` (`DiatonicOverChromatic`)
//! space passes; the same accidental in an `edo-31` (`Chromatic`) space
//! is rejected with the violation."
use super::*;
use crate::accidental::{fixture_extensions, PitchSpaceModification};
use crate::generators::valid_score;
use crate::pitch::PitchSpaceId;
fn fires(s: &Score, inv: GraphInvariant) -> bool {
!check_invariant(s, inv).is_empty()
}
#[test]
fn cmn_chromatic_accidental_in_cmn_12_does_not_fire() {
let mut s = valid_score(300);
s.tuning_context.default_pitch_space = PitchSpaceId::new("cmn-12");
s.tuning_context
.accidental_extensions
.push(fixture_extensions(
"cmn-accidentals",
PitchSpaceModification::CmnChromatic(1),
));
let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve);
assert!(
violations
.iter()
.all(|v| !v.witness.contains("accidental-modification-compatibility")),
"a CmnChromatic accidental in cmn-12 must not violate the compatibility \
invariant, got: {violations:?}"
);
}
#[test]
fn cmn_chromatic_accidental_in_edo_31_fires() {
// A test that only checked the accept case above would miss this
// reject — the contract's own warning.
let mut s = valid_score(300);
s.tuning_context.default_pitch_space = PitchSpaceId::new("edo-31");
s.tuning_context
.accidental_extensions
.push(fixture_extensions(
"cmn-accidentals",
PitchSpaceModification::CmnChromatic(1),
));
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve);
assert!(
violations
.iter()
.any(|v| v.witness.contains("accidental-modification-compatibility")),
"expected an accidental-modification-compatibility violation, got: {violations:?}"
);
}
#[test]
fn a_score_with_no_accidental_extensions_never_fires_this_check() {
// Every generator-produced score has an empty `accidental_extensions`
// (this tranche adds no data to any generator), so the new check must
// be silent across the existing test/property-test corpus.
let s = valid_score(301);
assert!(s.tuning_context.accidental_extensions.is_empty());
let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve);
assert!(violations
.iter()
.all(|v| !v.witness.contains("accidental-modification-compatibility")));
}
}
/// Genesis tranche G3a (`spec/CONTRACT_GENESIS_G3A_ENTITIES.md` pin 6): the
/// invariant-10 doc-comment reconciliation guard.
#[cfg(test)]
mod g3a_tests {
const SOURCE: &str = include_str!("invariants.rs");
/// The production portion of this file only, ending right before the
/// first `#[cfg(test)]` module. Every needle this module searches for is
/// itself written, as a string literal, inside a test module — so
/// searching the whole file risks the exact self-matching trap
/// `spec/CONTRACT_GENESIS_G3A_ENTITIES.md` §4 warns about (G2b hit it
/// twice: once via a needle matching the guard's own source, once via an
/// assertion message). Restricting the haystack structurally removes
/// that risk rather than relying on needle length alone.
fn production_source() -> &'static str {
SOURCE
.split_once("#[cfg(test)]")
.map(|(before, _)| before)
.expect("this file contains at least one #[cfg(test)] module")
}
/// (t12) Invariant 10's doc comment names the four reference classes its
/// body checks (pin 6). Grep-assert the repaired prose is present
/// **within the invariant-10 doc block only** — slice the source from
/// the `/// 10.` line to the `CrossCuttingRefsResolve,` line and search
/// *that*, since searching the whole file would pass on the
/// implementation body, which contains the same identifiers the doc
/// comment is supposed to gain.
///
/// **Mutation:** revert the doc comment to its pre-G3a text (naming only
/// cross-cutting structures and event-internal references); must fail.
#[test]
fn t12_invariant_10_doc_comment_names_the_four_reference_classes() {
let source = production_source();
let start = source
.find(" /// 10. Every graph reference resolves")
.expect("invariant 10's doc comment is present");
let end = source[start..]
.find("CrossCuttingRefsResolve,")
.map(|offset| start + offset)
.expect("the CrossCuttingRefsResolve variant follows its doc comment");
let doc_block = &source[start..end];
for needle in [
"staff's group",
"group's members",
"part's staves",
"active layers",
] {
assert!(
doc_block.contains(needle),
"invariant 10's doc comment must name `{needle}`; block was:\n{doc_block}"
);
}
}
}
/// Genesis tranche G3b packet 2 (`spec/CONTRACT_GENESIS_G3B_MEASURE.md`):
/// mutations M34-M39 for invariant 20 (M40 lives in `g3b_dispatch_tests`
/// below, since it targets `check_invariants`' dispatch rather than the
/// check body).
#[cfg(test)]
mod g3b_measure20_tests {
use super::*;
use crate::event::Rest;
use crate::graph::{
BeatGroup, Measure, MeterChange, MetricGrid, MetricTimeModel, PowerOfTwo, Region,
RegionContent, RegionTimeModel, StaffBasedContent, StaffExtent, StaffInstance, TimeExtent,
TimeSignature, TimeSignatureDisplay,
};
use crate::ids::{
IdentityContext, MeasureId, RegionId, ReplicaId, StaffId, StaffInstanceId, TimeSignatureId,
};
use crate::time::{
AnchorOffset, MusicalDuration, RationalTime, RegionEdge, WallClockDuration, WallClockTime,
};
fn fires(score: &Score, inv: GraphInvariant) -> bool {
!check_invariant(score, inv).is_empty()
}
/// A time signature with `measure_duration` one whole note.
fn sig(replica: ReplicaId, n: u64) -> (TimeSignatureId, TimeSignature) {
let id = TimeSignatureId::new(replica, n);
let measure_duration = MusicalDuration::whole();
let ts = TimeSignature::new(
id,
TimeSignatureDisplay::Standard {
numerator: 4,
denominator: PowerOfTwo::new(4).unwrap(),
},
measure_duration.clone(),
vec![BeatGroup {
duration: measure_duration,
subdivision: None,
accent: 0,
}],
)
.unwrap();
(id, ts)
}
/// A minimal metric-region score: one staff instance carrying
/// `measures`, and (if `active` is `Some`) a region-default grid naming
/// it, active from the region's own start.
fn score_with(
active: Option<TimeSignatureId>,
declared: Vec<TimeSignature>,
measures: Vec<Measure>,
) -> (Score, RegionId) {
let replica = ReplicaId(7);
let mut idc = IdentityContext::new(replica);
let region_id: RegionId = idc.mint();
let staff_id: StaffId = idc.mint();
let instance_id: StaffInstanceId = idc.mint();
let mut instance = StaffInstance::new(instance_id, staff_id);
instance.measures = measures;
let region_start = TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
};
let default_metric_grid = active.map(|active_sig| MetricGrid {
meter_sequence: vec![MeterChange {
anchor: region_start,
time_signature: active_sig,
}],
});
let region = Region {
id: region_id,
time_model: RegionTimeModel::Metric(MetricTimeModel::default()),
content: RegionContent::StaffBased(StaffBasedContent {
staff_instances: vec![instance],
default_metric_grid,
..Default::default()
}),
time_extent: TimeExtent {
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
end: TimeAnchor::WallClock {
time: WallClockTime(1_000_000),
},
},
staff_extent: StaffExtent {
staves: vec![staff_id],
},
local_tempo_map: None,
permits_spanning_slurs: false,
};
let mut score = Score::empty(idc.clone());
score.identity = idc;
score.time_signatures = declared;
score.canvas.regions = vec![region];
(score, region_id)
}
/// A measure anchored at `whole_notes` whole notes after `region`'s
/// start (c4-comparable to a grid entry built the same way by
/// [`score_with`], since both use `TimeAnchor::Region{edge: Start, ..}`).
fn measure_at(
id: MeasureId,
region: RegionId,
whole_notes: i32,
declared: Option<TimeSignatureId>,
) -> Measure {
Measure {
id,
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: if whole_notes == 0 {
AnchorOffset::Zero
} else {
AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(whole_notes)))
},
},
time_signature: declared,
explicit_number: None,
number_visibility: Default::default(),
}
}
/// Discovers the region id `score_with` will mint (its `IdentityContext`
/// is deterministic given no prior mints), so a test can build
/// [`Measure`]s referencing it before constructing the real score.
fn probe_region_id() -> RegionId {
let (_, region) = score_with(None, vec![], vec![]);
region
}
/// Inserts a minimal LIVE `Rest` event into `score` and returns its id
/// (`spec/CONTRACT_P13S18_MATRIX.md` pin 4, S6/S7): `Measure.start` and
/// `MeterChange.anchor` are unrestricted `TimeAnchor`s, so an
/// `Event`-anchored measure is a legal graph, not a hypothetical one --
/// the event this mints and inserts is genuinely live in `score.events`,
/// not a dangling/ghost id (contrast `inv10_flags_dangling_spanner_
/// anchor`'s `ghost_event`, in `review_fix_tests` above, which is the
/// deliberately-NOT-live case).
fn insert_live_event(score: &mut Score) -> crate::ids::EventId {
let event_id: crate::ids::EventId = score.identity.mint();
let voice_id: crate::ids::VoiceId = score.identity.mint();
score
.events
.insert(Event::Rest(Rest {
id: event_id,
voice: voice_id,
position: EventPosition::Musical(MusicalPosition(RationalTime::from_int(0))),
duration: EventDuration::Musical(MusicalDuration::whole()),
vertical_position: None,
visible: true,
}))
.unwrap();
event_id
}
#[test]
fn agreement_and_boundary_hold_together() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, Some(active));
let m1 = measure_at(MeasureId::new(replica, 11), region, 1, Some(active));
let (score, _) = score_with(Some(active), vec![ts_active], vec![m0, m1]);
assert!(
!fires(&score, GraphInvariant::MeasureMeterConsistency),
"agreeing, correctly-spaced measures must not violate invariant 20"
);
}
/// M34: removing the agreement clause must let this go undetected.
#[test]
fn m34_agreement_flags_disagreement() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (declared, ts_declared) = sig(replica, 2);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, Some(declared));
let (score, _) = score_with(Some(active), vec![ts_active, ts_declared], vec![m0]);
assert!(
fires(&score, GraphInvariant::MeasureMeterConsistency),
"a measure declaring a signature that disagrees with the effective grid's \
active signature must violate invariant 20"
);
}
/// M35: this IS the pickup/anacrusis demonstration (spec/CONTRACT_P13S19_
/// PARTIAL.md pin 1). `m0` is a first measure occupying only half a bar
/// -- a pickup -- and by itself is flagged by neither clause: it has no
/// predecessor, so the boundary clause is vacuous, and it avoids the
/// agreement clause here by declaring `None` (not by any first-measure
/// exemption -- there is none). `m1` is its successor, comparable and
/// correctly ordered, but only HALF a `measure_duration` away -- the
/// pickup's own actual length, not the governing signature's full bar.
/// Removing the boundary clause must let THAT go undetected. Both
/// measures avoid the agreement clause (`None`) so only boundary can
/// fire.
#[test]
fn m35_pickup_successor_boundary_flags_wrong_distance() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, None);
// The pickup by itself: a first measure has no predecessor, so the
// boundary clause is vacuous for it, and `None` separately avoids
// the agreement clause -- neither is an exemption granted TO the
// agreement clause itself (pin 1).
let (lone, _) = score_with(Some(active), vec![ts_active.clone()], vec![m0.clone()]);
assert!(
!fires(&lone, GraphInvariant::MeasureMeterConsistency),
"the pickup by itself, first measure, no predecessor, must not be flagged"
);
// Half a whole note later — not a full measure_duration away.
let m1 = Measure {
id: MeasureId::new(replica, 11),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::new(1, 2).unwrap())),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let (score, _) = score_with(Some(active), vec![ts_active], vec![m0, m1]);
assert!(
fires(&score, GraphInvariant::MeasureMeterConsistency),
"a measure at the wrong distance from its predecessor must violate invariant 20 \
even though neither declares a time signature"
);
}
/// M36: `None` avoids only agreement, not boundary consistency.
#[test]
fn m36_none_still_bound_by_boundary() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, Some(active));
// Wrong distance, and `None` (so agreement can never be the cause).
let m1 = Measure {
id: MeasureId::new(replica, 11),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::new(3, 2).unwrap())),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let (score, _) = score_with(Some(active), vec![ts_active], vec![m0, m1]);
assert!(
fires(&score, GraphInvariant::MeasureMeterConsistency),
"None exempts only agreement -- a None measure at the wrong distance must still \
violate invariant 20's boundary clause"
);
}
/// M37: an incomparable case (cross-clock offsets) must ABSTAIN, not
/// flag.
#[test]
fn m37_incomparable_abstains() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let region = probe_region_id();
// The grid's own entry is anchored at the region's start with a
// WallClock offset — cross-clock against a Musical-offset measure
// start sharing the same id/edge, so it is incomparable (pin 6).
let grid = MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::WallClock(WallClockDuration(0)),
},
time_signature: active,
}],
};
let m0 = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration::whole()),
},
time_signature: Some(active),
explicit_number: None,
number_visibility: Default::default(),
};
let (mut score, _) = score_with(None, vec![ts_active], vec![]);
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(grid);
content.staff_instances[0].measures.push(m0);
}
assert!(
!fires(&score, GraphInvariant::MeasureMeterConsistency),
"an incomparable grid entry must make the check ABSTAIN, not flag -- an \
incomparable change is unplaced, not absent, and might have governed"
);
}
/// M38: a pickup (partial) first measure declaring no time signature
/// must never be flagged BY THE BOUNDARY CLAUSE -- it has no
/// predecessor, so that clause is vacuous for it. This is narrower than
/// "never flagged" in general: the agreement clause is not
/// predecessor-dependent and would apply to this same measure if it
/// declared a disagreeing signature instead of `None` (spec/CONTRACT_
/// P13S19_PARTIAL.md pin 1) -- this fixture's `None` avoids agreement
/// separately, not as a consequence of being a first measure.
///
/// **On the mutation's failure mode:** the M38 mutation (removing the
/// `if i == 0 { continue; }` guard in `check_measure_meter_consistency`)
/// is observed as an `attempt to subtract with overflow` PANIC, not a
/// wrong-flag assertion failure. This is expected and still a valid red
/// signal, not a weak one: that `i == 0` guard is simultaneously the
/// pickup-measure boundary-clause exemption AND the only thing standing
/// between `i - 1` and a `usize` underflow, so any mutation that removes
/// or weakens it crashes before it could ever produce a wrong (but
/// well-formed) verdict to assert against.
#[test]
fn m38_pickup_first_measure_boundary_clause_not_flagged() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let region = probe_region_id();
// A single, lone first measure at a nonzero, arbitrary offset --
// there is no predecessor to be "the wrong distance" from.
let m0 = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::new(1, 3).unwrap())),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let (score, _) = score_with(Some(active), vec![ts_active], vec![m0]);
assert!(
!fires(&score, GraphInvariant::MeasureMeterConsistency),
"a lone first (pickup) measure declaring no time signature must not be flagged \
by invariant 20's boundary clause -- vacuous, no predecessor (its agreement \
clause is separately avoided by `None`, not exempted by being first)"
);
}
/// M39: an unresolvable measure time-signature reference is invariant
/// 10's business, not invariant 20's -- invariant 20 must NOT duplicate
/// the resolution check.
#[test]
fn m39_unresolvable_reference_is_invariant_10_only() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let undeclared = TimeSignatureId::new(replica, 999);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, Some(undeclared));
let (score, _) = score_with(Some(active), vec![ts_active], vec![m0]);
assert!(
fires(&score, GraphInvariant::CrossCuttingRefsResolve),
"an undeclared measure time-signature reference must violate invariant 10"
);
assert!(
!fires(&score, GraphInvariant::MeasureMeterConsistency),
"invariant 20 must NOT duplicate invariant 10's resolution check -- an \
unresolving reference is only invariant 10's violation"
);
}
// -------------------------------------------------------------------
// spec/CONTRACT_P13S18_MATRIX.md: the outcome matrix (9 shapes x 2
// clauses = 18 cells) plus the four non-shape-driven paths (A1, A3, B2,
// B3) that pin 4's shapes do not themselves exercise. A2 and B1 reuse
// `m39_unresolvable_reference_is_invariant_10_only` and
// `m38_pickup_first_measure_boundary_clause_not_flagged` above
// respectively; S3 reuses `m34_agreement_flags_disagreement` and
// `m35_pickup_successor_boundary_flags_wrong_distance` above (Region
// same id, same edge, Musical offsets is exactly their shape). Every
// other cell gets a dedicated fixture below.
// -------------------------------------------------------------------
/// Matrix cell A1: `m.time_signature` is `None` -- inapplicable, not
/// concealed disagreement. A single (first) measure keeps the boundary
/// clause vacuous (B1) so only A1 is live here. M4 flips this measure to
/// a resolving, DISAGREEING `Some(id)` and must turn this from silent to
/// a violation.
#[test]
fn matrix_a1_none_time_signature_inapplicable() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, None);
let (score, _) = score_with(Some(active), vec![ts_active], vec![m0]);
assert!(
!fires(&score, GraphInvariant::MeasureMeterConsistency),
"A1: a measure with no declared time signature must not be \
flagged by the agreement clause"
);
}
/// Matrix cell A3: `Governing20::None` -- vacuous, not a violation,
/// because the region-default grid is empty (pin 6c case 1) so there is
/// no candidate at all. A single (first) measure keeps the boundary
/// clause vacuous (B1) so only A3 is live here. A bare `fires` boolean
/// cannot distinguish "vacuous" from any of the other eight paths, so
/// this uses a paired positive control: fixture 1 is the claimed
/// abstention (`m0` declares a DISAGREEING, resolving signature against
/// an EMPTY grid); fixture 2 changes ONLY the grid -- from empty to a
/// real entry naming the very signature `m0` disagrees with -- and must
/// decide and flag. The flip from fixture 1 to fixture 2 is exactly
/// M10's edit, now inside the test.
#[test]
fn matrix_a3_vacuous_agreement() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, Some(wrong));
// Fixture 1: no grid at all, so there is no candidate for the
// governing search to find regardless of what m0 declares.
let (empty_score, _) = score_with(
None,
vec![ts_active.clone(), ts_wrong.clone()],
vec![m0.clone()],
);
let empty_violations =
check_invariant(&empty_score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
empty_violations.len(),
0,
"A3: with no grid entries at all, the agreement clause must \
abstain as vacuous -- got {empty_violations:?}"
);
// Fixture 2 (positive control): ONLY the grid changes, from empty
// to a real, disagreeing entry.
let (populated_score, _) = score_with(Some(active), vec![ts_active, ts_wrong], vec![m0]);
let populated_violations =
check_invariant(&populated_score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
populated_violations.len(),
1,
"A3 positive control: a non-empty, disagreeing grid must decide \
and flag -- got {populated_violations:?}"
);
assert!(
populated_violations[0]
.witness
.contains("declares time signature"),
"A3 positive control: the violation must be the agreement \
clause's -- witness was {:?}",
populated_violations[0].witness
);
}
/// Matrix cell B2 (delegated -- verified, not asserted, pin 3): the
/// SAME graph that makes invariant 20's boundary clause abstain (B2,
/// the grid's OWN governing signature does not resolve) must
/// independently violate invariant 10's grid-level resolution arm.
/// `m39_unresolvable_reference_is_invariant_10_only` (above) is the twin
/// observation for A2 (the MEASURE's own declared signature failing to
/// resolve); this is the grid-entry case, and M8 shows the SAME
/// condition go unreported by the whole suite once invariant 10's
/// grid-level arm is deleted.
#[test]
fn matrix_b2_governing_signature_unresolving_delegated() {
let replica = ReplicaId(7);
let undeclared = TimeSignatureId::new(replica, 999);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, None);
let m1 = measure_at(MeasureId::new(replica, 11), region, 1, None);
// The INSTANCE-LOCAL grid's own entry (not the region default --
// M8 targets invariant 10's instance-local-grid arm specifically)
// names a signature that is never declared -- unresolvable by
// either invariant's own logic, but reported ONLY by invariant 10.
let (mut score, _) = score_with(None, vec![], vec![m0, m1]);
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.staff_instances[0].local_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
time_signature: undeclared,
}],
});
}
assert!(
fires(&score, GraphInvariant::CrossCuttingRefsResolve),
"the instance-local grid's undeclared time-signature reference \
must violate invariant 10's instance-local-grid arm"
);
assert!(
!fires(&score, GraphInvariant::MeasureMeterConsistency),
"B2: invariant 20's boundary clause must abstain rather than \
duplicate invariant 10's resolution check -- the grid's own \
governing signature does not resolve"
);
}
/// Matrix cell B3: `Governing20::None` at the boundary clause -- vacuous,
/// same as A3, because the region-default grid is empty (pin 6c case
/// 1). A wrong boundary distance (2 whole notes, not 1) is baked in
/// deliberately so the abstention is load-bearing. As with A3, a bare
/// boolean cannot distinguish "vacuous" from the other eight paths, so
/// this uses a paired positive control: fixture 1 is the claimed
/// abstention (empty grid, wrong distance already staged); fixture 2
/// changes ONLY the grid -- from empty to a real, comparable entry --
/// and the SAME wrong distance must now be flagged. The flip from
/// fixture 1 to fixture 2 is M10's edit, now inside the test.
#[test]
fn matrix_b3_vacuous_boundary() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let region = probe_region_id();
let m0 = measure_at(MeasureId::new(replica, 10), region, 0, None);
let m1 = measure_at(MeasureId::new(replica, 11), region, 2, None);
// Fixture 1: no grid at all.
let (empty_score, _) =
score_with(None, vec![ts_active.clone()], vec![m0.clone(), m1.clone()]);
let empty_violations =
check_invariant(&empty_score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
empty_violations.len(),
0,
"B3: with no grid entries at all, the boundary clause must \
abstain as vacuous -- got {empty_violations:?}"
);
// Fixture 2 (positive control): ONLY the grid changes, from empty
// to a real, comparable entry -- the SAME wrong distance (2 whole
// notes, not 1) is untouched.
let (populated_score, _) = score_with(Some(active), vec![ts_active], vec![m0, m1]);
let populated_violations =
check_invariant(&populated_score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
populated_violations.len(),
1,
"B3 positive control: a non-empty, comparable grid must decide \
and flag the wrong distance -- got {populated_violations:?}"
);
assert!(
populated_violations[0]
.witness
.contains("is not exactly one"),
"B3 positive control: the violation must be the boundary \
clause's -- witness was {:?}",
populated_violations[0].witness
);
}
/// Matrix row S1 (`WallClock` measures, `WallClock`-anchored meter
/// changes): agreement DECIDES (`D`) and boundary abstains as B5 --
/// pin 5's split. `m0`'s agreement clause and `m1`'s boundary clause
/// share the EXACT SAME governing search
/// (`measure20_governing_time_signature(sequence, &m0.start)`), so
/// `m0`'s violation firing is itself the proof that search decided
/// `Unique`, not `Indeterminate` -- ruling out B4 and pinning the
/// boundary abstention to B5 (the `WallClock` delta, structurally never
/// computable) rather than an incomparable governing search. `m1` sits
/// at a deliberately wrong `WallClock` distance from `m0`, so a live
/// boundary clause would flag it; only ONE violation (agreement, on
/// `m0`) is observed.
#[test]
fn matrix_s1_wallclock_measures_wallclock_meter_changes() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let m0 = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let m1 = Measure {
id: MeasureId::new(replica, 11),
start: TimeAnchor::WallClock {
time: WallClockTime(999_999_999),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let (mut score, _) = score_with(None, vec![ts_active, ts_wrong], vec![]);
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::WallClock {
time: WallClockTime(0),
},
time_signature: active,
}],
});
content.staff_instances[0].measures = vec![m0, m1];
}
let violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
violations.len(),
1,
"S1: expected exactly one violation (m0's agreement) -- got {violations:?}"
);
assert!(
violations[0].witness.contains("declares time signature"),
"S1: the sole violation must be the agreement clause's, not the \
boundary clause's -- witness was {:?}",
violations[0].witness
);
}
/// Matrix row S2, agreement cell (`WallClock` measures, `Region`-
/// anchored meter changes): `comparable_order` has no arm for
/// `WallClock`<->`Region` at all (falls to the catch-all `_ => None`)
/// -- structurally incomparable regardless of any offset or timestamp.
/// `x` (a single, `WallClock`-anchored measure) declares a resolving,
/// disagreeing signature and is not flagged: A4. Paired positive
/// control: fixture 2 changes ONLY `x.start`, from `WallClock` to the
/// grid's own `Region` shape, and the SAME disagreement must decide and
/// flag -- M1 is fixture 1's `x.start` edit.
#[test]
fn matrix_s2_agreement_a4() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let x_wallclock = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
// Fixture 1: `x` is WallClock-anchored -- incomparable to the
// Region-anchored grid.
let (mut score, region) = score_with(
Some(active),
vec![ts_active.clone(), ts_wrong.clone()],
vec![],
);
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.staff_instances[0].measures = vec![x_wallclock];
}
let wallclock_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
wallclock_violations.len(),
0,
"S2 agreement: a WallClock-anchored measure against a \
Region-anchored grid must abstain (A4) -- got \
{wallclock_violations:?}"
);
// Fixture 2 (positive control): ONLY `x.start` changes, to the
// grid's own Region{Start, Zero} shape.
let x_region = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let (mut score2, _) = score_with(Some(active), vec![ts_active, ts_wrong], vec![]);
if let RegionContent::StaffBased(content) = &mut score2.canvas.regions[0].content {
content.staff_instances[0].measures = vec![x_region];
}
let region_violations = check_invariant(&score2, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
region_violations.len(),
1,
"S2 agreement positive control: a Region-anchored measure \
matching the grid must decide and flag -- got \
{region_violations:?}"
);
assert!(
region_violations[0]
.witness
.contains("declares time signature"),
"S2 agreement positive control: the violation must be the \
agreement clause's -- witness was {:?}",
region_violations[0].witness
);
}
/// Matrix row S2, boundary cell: `x` (index 1, `WallClock`-anchored,
/// carrying a resolving disagreeing signature so it is structurally
/// eligible for A4 too -- see `matrix_s2_agreement_a4`) is preceded by
/// `prev` (index 0, also `WallClock`-anchored): B4. A `WallClock`
/// measure's boundary clause can NEVER decide-and-flag (`measure20_
/// musical_delta` has no `WallClock` arm at all, pin 5/S1), so the
/// positive control cannot be "x's boundary now flags" -- instead it
/// changes ONLY `prev.start` to the grid's `Region` shape and observes
/// `prev`'s OWN agreement clause, which reuses the IDENTICAL governing
/// search `x`'s boundary clause performs on `prev.start`. That the
/// identical search decides once `prev.start` alone is comparable is
/// the proof that the silence in fixture 1 was genuine indeterminacy
/// (B4), not vacuity (B3) or non-resolution (B2) -- and `x`'s own
/// boundary clause is asserted silent in BOTH fixtures, confirming the
/// change didn't leak into a delta becoming computable. M2 is fixture
/// 1's `prev.start` edit.
#[test]
fn matrix_s2_boundary_b4() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let prev_wallclock = Measure {
id: MeasureId::new(replica, 9),
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let x = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::WallClock {
time: WallClockTime(500),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
// Fixture 1: `prev` is WallClock-anchored -- incomparable to the
// Region-anchored grid.
let (mut score, region) = score_with(
Some(active),
vec![ts_active.clone(), ts_wrong.clone()],
vec![],
);
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.staff_instances[0].measures = vec![prev_wallclock, x.clone()];
}
let wallclock_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
wallclock_violations.len(),
0,
"S2 boundary: with `prev` WallClock-anchored against a \
Region-anchored grid, both `prev`'s agreement and `x`'s \
boundary must abstain (A4/B4) -- got {wallclock_violations:?}"
);
// Fixture 2 (positive control): ONLY `prev.start` changes, to the
// grid's own Region{Start, Zero} shape. `x` is untouched.
let prev_region = Measure {
id: MeasureId::new(replica, 9),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let (mut score2, _) = score_with(Some(active), vec![ts_active, ts_wrong], vec![]);
if let RegionContent::StaffBased(content) = &mut score2.canvas.regions[0].content {
content.staff_instances[0].measures = vec![prev_region, x];
}
let region_violations = check_invariant(&score2, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
region_violations.len(),
1,
"S2 boundary positive control: with `prev` now comparable to \
the grid, `prev`'s OWN agreement clause (the identical \
governing search `x`'s boundary performs) must decide and \
flag -- got {region_violations:?}"
);
assert!(
region_violations[0]
.witness
.contains("declares time signature"),
"S2 boundary positive control: the violation must be `prev`'s \
agreement clause's, not a new boundary violation on `x` -- a \
WallClock delta is structurally never computable (pin 5), so \
`x`'s boundary stays silent even though `prev`'s governing \
search now decides -- witness was {:?}",
region_violations[0].witness
);
}
/// Matrix row S4: `Measure` **same id**, `pos: End` on both sides,
/// `Musical` offsets (c2) -- the shape that falsifies "any `Measure`
/// end anchor is incomparable": same id AND same `pos` decides via c2's
/// offset comparison, exactly like same-id `Start`. Both clauses DECIDE
/// (`D`/`D`), deliberately wrong so the decision is observed as a
/// violation rather than a pass that could have come from anywhere.
#[test]
fn matrix_s4_measure_same_id_end_end_decides() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let shared_end = MeasureId::new(replica, 500);
let m0 = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Measure {
id: shared_end,
position: MeasurePosition::End,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let m1 = Measure {
id: MeasureId::new(replica, 11),
start: TimeAnchor::Measure {
id: shared_end,
position: MeasurePosition::End,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(2))),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let (mut score, _) = score_with(None, vec![ts_active, ts_wrong], vec![]);
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Measure {
id: shared_end,
position: MeasurePosition::End,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: active,
}],
});
content.staff_instances[0].measures = vec![m0, m1];
}
let violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
violations.len(),
2,
"S4: same-id End<->End must decide BOTH clauses (D/D) -- got \
{violations:?}"
);
assert!(
violations
.iter()
.any(|v| v.witness.contains("declares time signature")),
"S4: expected an agreement violation on m0 -- got {violations:?}"
);
assert!(
violations
.iter()
.any(|v| v.witness.contains("is not exactly one")),
"S4: expected a boundary violation on m1 -- got {violations:?}"
);
}
/// Matrix row S5 (`Measure` **distinct** ids, `Start`, `Zero` -- c3):
/// the contrast to S4. c3's vector-index ordering DOES decide
/// (agreement: `D`) but supplies no distance at all (boundary: B5) --
/// pin 10's second deficiency. Three measures in one instance:
/// `filler` (index 0, purely an anchor target), `prev` (index 1) and
/// `m` (index 2, under test). The grid's sole entry is anchored to
/// `filler`'s id -- DISTINCT from both `prev`'s and `m`'s own
/// self-referencing ids -- so every governing search below goes
/// through c3's vector order, never c2. `m` declares a disagreeing
/// signature (agreement fires); the boundary delta between `prev` and
/// `m` is structurally impossible (distinct ids), so it must abstain
/// regardless of distance -- no wrong distance is even staged here.
#[test]
fn matrix_s5_measure_distinct_ids_start_zero() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let filler_id = MeasureId::new(replica, 20);
let prev_id = MeasureId::new(replica, 21);
let m_id = MeasureId::new(replica, 22);
let filler = Measure {
id: filler_id,
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let prev = Measure {
id: prev_id,
start: TimeAnchor::Measure {
id: prev_id,
position: MeasurePosition::Start,
offset: AnchorOffset::Zero,
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let m = Measure {
id: m_id,
start: TimeAnchor::Measure {
id: m_id,
position: MeasurePosition::Start,
offset: AnchorOffset::Zero,
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let (mut score, _) = score_with(None, vec![ts_active, ts_wrong], vec![]);
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Measure {
id: filler_id,
position: MeasurePosition::Start,
offset: AnchorOffset::Zero,
},
time_signature: active,
}],
});
content.staff_instances[0].measures = vec![filler, prev, m];
}
let violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
violations.len(),
1,
"S5: expected exactly one violation (m's agreement, decided via \
c3's vector order) -- got {violations:?}"
);
assert!(
violations[0].witness.contains("declares time signature"),
"S5: the sole violation must be the agreement clause's -- \
boundary (B5) has no distance to report even though its \
governing search also decided via c3 -- witness was {:?}",
violations[0].witness
);
}
/// Matrix row S6 (`Event` **same id**, a LIVE event, `Musical` offsets
/// -- c1): `Measure.start` and `MeterChange.anchor` are unrestricted
/// `TimeAnchor`s, so an `Event`-anchored measure is a legal (if
/// unusual) graph, not a hypothetical one -- the event inserted here is
/// genuinely live in `score.events`. Both clauses DECIDE (`D`/`D`),
/// deliberately wrong.
#[test]
fn matrix_s6_event_same_id_live_event_decides() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let (mut score, _) = score_with(None, vec![ts_active, ts_wrong], vec![]);
let live = insert_live_event(&mut score);
let m0 = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Event {
id: live,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let m1 = Measure {
id: MeasureId::new(replica, 11),
start: TimeAnchor::Event {
id: live,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(2))),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Event {
id: live,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: active,
}],
});
content.staff_instances[0].measures = vec![m0, m1];
}
let violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
violations.len(),
2,
"S6: same-id live-Event anchors must decide BOTH clauses (D/D) \
-- got {violations:?}"
);
assert!(
violations
.iter()
.any(|v| v.witness.contains("declares time signature")),
"S6: expected an agreement violation on m0 -- got {violations:?}"
);
assert!(
violations
.iter()
.any(|v| v.witness.contains("is not exactly one")),
"S6: expected a boundary violation on m1 -- got {violations:?}"
);
}
/// Matrix row S7, agreement cell (`Event`, DISTINCT ids, otherwise
/// identical to S6): isolates the distinct-`Event` fall-through --
/// unlike `Measure`'s c3, there is no vector-index fallback for
/// `Event`s, so distinct ids are UNCONDITIONALLY incomparable. `x` (a
/// single, live-`Event`-anchored measure referencing `event_a`)
/// declares a resolving, disagreeing signature against a grid entry
/// referencing the DISTINCT `event_b`: A4. Paired positive control:
/// fixture 2 changes ONLY the grid's referent, from `event_b` to
/// `event_a` (matching `x`), and the SAME disagreement must decide and
/// flag -- exactly the case the retained P11-C5 citation at
/// `CONTRACT_GENESIS_G3B_MEASURE.md:223` exists for. (M1/M2, the
/// contract's ratified A4/B4 mutation pair, target S8 below; this cell
/// has no separately-numbered mutation, matching A3/A2's ratified
/// scope.)
#[test]
fn matrix_s7_agreement_a4() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let (mut score, _) = score_with(None, vec![ts_active.clone(), ts_wrong.clone()], vec![]);
let event_a = insert_live_event(&mut score);
let event_b = insert_live_event(&mut score);
let x = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Event {
id: event_a,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(2))),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
// Fixture 1: the grid references `event_b`, distinct from `x`'s
// own `event_a`.
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Event {
id: event_b,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: active,
}],
});
content.staff_instances[0].measures = vec![x];
}
let distinct_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
distinct_violations.len(),
0,
"S7 agreement: a distinct-Event-id grid entry must abstain \
(A4) -- got {distinct_violations:?}"
);
// Fixture 2 (positive control): ONLY the grid's referent changes,
// from `event_b` to `event_a`, matching `x`.
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Event {
id: event_a,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: active,
}],
});
}
let matching_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
matching_violations.len(),
1,
"S7 agreement positive control: a matching-Event-id grid entry \
must decide and flag -- got {matching_violations:?}"
);
assert!(
matching_violations[0]
.witness
.contains("declares time signature"),
"S7 agreement positive control: the violation must be the \
agreement clause's -- witness was {:?}",
matching_violations[0].witness
);
}
/// Matrix row S7, boundary cell: `x` (index 1, `Event`-anchored to
/// `event_a`, carrying a resolving disagreeing signature so it is
/// structurally eligible for A4 too -- see `matrix_s7_agreement_a4`) is
/// preceded by `prev` (index 0, ALSO `Event`-anchored to `event_a` --
/// `prev` and `x` already share a referent; only the grid's `event_b`
/// is the outlier): B4. Unlike S2's structurally-forced `WallClock`
/// delta (never computable, pin 5), `measure20_musical_delta`'s
/// `Event` arm decides fine once ids match, so the paired positive
/// control here changes ONLY the grid's referent (`event_b` ->
/// `event_a`, matching what `prev`, and `x`, already share) and THREE
/// clauses decide and flag together: `prev`'s own agreement, `x`'s own
/// agreement (it too references `event_a` and disagrees), and `x`'s
/// boundary -- a stronger, triply-confirmed observation than S2's
/// single witness, for the same underlying governing-search reason.
#[test]
fn matrix_s7_boundary_b4() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let (mut score, _) = score_with(None, vec![ts_active.clone(), ts_wrong.clone()], vec![]);
let event_a = insert_live_event(&mut score);
let event_b = insert_live_event(&mut score);
let prev = Measure {
id: MeasureId::new(replica, 9),
start: TimeAnchor::Event {
id: event_a,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let x = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Event {
id: event_a,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(2))),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
// Fixture 1: the grid references `event_b`, distinct from `prev`'s
// own `event_a`.
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Event {
id: event_b,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: active,
}],
});
content.staff_instances[0].measures = vec![prev.clone(), x.clone()];
}
let distinct_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
distinct_violations.len(),
0,
"S7 boundary: with `prev` referencing a distinct Event id from \
the grid, both `prev`'s agreement and `x`'s boundary must \
abstain (A4/B4) -- got {distinct_violations:?}"
);
// Fixture 2 (positive control): ONLY the grid's referent changes,
// from `event_b` to `event_a`, matching `prev` (and `x`, since both
// still reference `event_a`).
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Event {
id: event_a,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: active,
}],
});
}
let matching_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
matching_violations.len(),
3,
"S7 boundary positive control: with the grid now matching \
`prev`, and `x` (same event id), THREE clauses all newly \
comparable at once decide and flag: `prev`'s own agreement, \
`x`'s own agreement (it too shares `event_a` and disagrees), \
and `x`'s boundary (now a computable Event delta, unlike S2's \
structurally-impossible WallClock case) -- got \
{matching_violations:?}"
);
assert_eq!(
matching_violations
.iter()
.filter(|v| v.witness.contains("declares time signature"))
.count(),
2,
"S7 boundary positive control: expected agreement violations \
on BOTH prev and x -- got {matching_violations:?}"
);
assert!(
matching_violations
.iter()
.any(|v| v.witness.contains("is not exactly one")),
"S7 boundary positive control: expected x's boundary violation \
-- got {matching_violations:?}"
);
}
/// Matrix row S8, agreement cell (matching referent, DIFFERING
/// `pos`/`edge` selector): the `ia == ib && ea == eb` conjunction
/// (Region) requires an IDENTICAL selector, never merely an identical
/// id. `x` (a single, `Region`-anchored measure at `End`) declares a
/// resolving, disagreeing signature against a grid entry at `Start`,
/// same `Region` id: A4. Paired positive control: fixture 2 changes
/// ONLY `x`'s own edge, from `End` to `Start` (matching the grid), and
/// the SAME disagreement must decide and flag. M1 is fixture 1's
/// `x`-edge edit.
#[test]
fn matrix_s8_agreement_a4() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let (mut score, region) =
score_with(None, vec![ts_active.clone(), ts_wrong.clone()], vec![]);
let x_end = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::End,
offset: AnchorOffset::Zero,
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
time_signature: active,
}],
});
content.staff_instances[0].measures = vec![x_end];
}
let end_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
end_violations.len(),
0,
"S8 agreement: an End-anchored measure against a Start-anchored \
grid entry (same Region id) must abstain (A4) -- got \
{end_violations:?}"
);
// Fixture 2 (positive control): ONLY `x`'s own edge changes, from
// `End` to `Start`, matching the grid.
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.staff_instances[0].measures[0].start = TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
};
}
let start_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
start_violations.len(),
1,
"S8 agreement positive control: a Start-anchored measure \
matching the grid must decide and flag -- got \
{start_violations:?}"
);
assert!(
start_violations[0]
.witness
.contains("declares time signature"),
"S8 agreement positive control: the violation must be the \
agreement clause's -- witness was {:?}",
start_violations[0].witness
);
}
/// Matrix row S8, boundary cell: `x` (index 1, `Region`-anchored at
/// `End`, carrying a resolving disagreeing signature so it is
/// structurally eligible for A4 too -- see `matrix_s8_agreement_a4`) is
/// preceded by `prev` (index 0, also `Region`-anchored at `End`): B4.
/// The positive control moves the GRID entry's edge to `End`, not
/// `prev`'s. Both levers restore the governing search, but only this one
/// leaves `prev` and `x` c4-comparable to each other, so
/// `measure20_musical_delta`'s Region arm (`ia == ib && ea == eb`) still
/// yields a delta and **`x`'s own boundary clause fires**. Moving `prev`
/// instead would break that match and leave the boundary silent for a
/// second reason, signing this cell by inference from a shared call
/// rather than by observing it. **S2 has no such lever** -- a `WallClock`
/// delta is never computable (pin 5) -- so its control legitimately
/// observes `prev`'s agreement, and that exception is S2's alone.
/// M2 is fixture 1's grid-edge edit.
#[test]
fn matrix_s8_boundary_b4() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let (mut score, region) =
score_with(None, vec![ts_active.clone(), ts_wrong.clone()], vec![]);
let prev_end = Measure {
id: MeasureId::new(replica, 9),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::End,
offset: AnchorOffset::Zero,
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let x = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::End,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(2))),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
time_signature: active,
}],
});
content.staff_instances[0].measures = vec![prev_end, x];
}
let end_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
end_violations.len(),
0,
"S8 boundary: with `prev` End-anchored against a Start-anchored \
grid entry, both `prev`'s agreement and `x`'s boundary must \
abstain (A4/B4) -- got {end_violations:?}"
);
// Fixture 2 (positive control): ONLY the grid entry's edge changes,
// from `Start` to `End`, matching both measures. Neither measure
// moves -- which is the point: `prev` and `x` stay Region{End} and
// therefore stay c4-comparable to EACH OTHER, so the delta survives
// and `x`'s boundary clause itself decides. Moving `prev` instead
// would restore the governing search while breaking `prev`<->`x`,
// leaving the boundary silent for a second reason and signing this
// cell by inference rather than observation. (S2 has no such option:
// a WallClock delta is structurally never computable, pin 5, so its
// control legitimately observes `prev`'s agreement instead. That
// exception is S2's alone.)
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content
.default_metric_grid
.as_mut()
.expect("the grid was just installed")
.meter_sequence[0]
.anchor = TimeAnchor::Region {
id: region,
edge: RegionEdge::End,
offset: AnchorOffset::Zero,
};
}
let matching_violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
matching_violations.len(),
3,
"S8 boundary positive control: with the grid now End-anchored, \
three clauses newly decide at once -- `prev`'s agreement, \
`x`'s agreement, and `x`'s BOUNDARY (a computable c4 delta, \
since both measures are still Region{{End}}) -- got \
{matching_violations:?}"
);
assert_eq!(
matching_violations
.iter()
.filter(|v| v.witness.contains("declares time signature"))
.count(),
2,
"S8 boundary positive control: expected agreement violations on \
BOTH prev and x -- got {matching_violations:?}"
);
assert!(
matching_violations
.iter()
.any(|v| v.witness.contains("is not exactly one")),
"S8 boundary positive control: expected `x`'s OWN boundary \
violation, which is what attributes fixture 1's silence to B4 \
rather than to a downstream delta failure -- got \
{matching_violations:?}"
);
}
/// Matrix row S9 (pin 7's behavioural lock): one instance, heterogeneous
/// measure anchors. THREE measures, deliberately distinct roles so the
/// pin-7 contrast and the row's own A4/B4 cell pair don't get
/// conflated into one false "same measure" claim (the mistake defect 2
/// found in this row's first draft):
///
/// - `q` (index 0, `Region`-anchored, matching the grid) is pin 7's
/// REQUIRED contrast -- "another measure ... reaches a decision" --
/// and decides (deliberately wrong, so it flags).
/// - `r` (index 1, `WallClock`-anchored, `None` declared) is purely
/// `p`'s predecessor; it carries no claim of its own.
/// - `p` (index 2, `WallClock`-anchored like `r`, resolving disagreeing
/// signature) is the row's A4/B4 EXHIBIT: its own agreement clause
/// abstains (A4, `p.start` incomparable to the grid) AND its own
/// boundary clause abstains (B4, `r.start` -- `p`'s predecessor --
/// ALSO incomparable to the grid), on ONE measure, per defect 2.
///
/// `q` cannot double as `p`'s predecessor: if it did, `p`'s boundary
/// governing search would use `q.start`, which IS comparable to the
/// grid (that's why `q` decides) -- and `p`'s boundary would then
/// decide too, not abstain. Splitting the roles across THREE measures
/// is what makes both claims true at once. Exactly ONE violation --
/// naming `q`, never `p` or `r` -- is pin 7's behavioural proof; a
/// prose claim that "one incomparable change disables the whole
/// instance" would predict zero. M6 gives `q` the SAME `WallClock`
/// shape as `r`/`p` and only then does its agreement clause stop
/// deciding.
#[test]
fn matrix_s9_heterogeneous_measure_anchors() {
let replica = ReplicaId(7);
let (active, ts_active) = sig(replica, 1);
let (wrong, ts_wrong) = sig(replica, 2);
let (mut score, region) = score_with(Some(active), vec![ts_active, ts_wrong], vec![]);
let q = Measure {
id: MeasureId::new(replica, 9),
start: TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration::zero()),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
let q_id = q.id;
let r = Measure {
id: MeasureId::new(replica, 10),
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let p = Measure {
id: MeasureId::new(replica, 11),
start: TimeAnchor::WallClock {
time: WallClockTime(500),
},
time_signature: Some(wrong),
explicit_number: None,
number_visibility: Default::default(),
};
if let RegionContent::StaffBased(content) = &mut score.canvas.regions[0].content {
content.staff_instances[0].measures = vec![q, r, p];
}
let violations = check_invariant(&score, GraphInvariant::MeasureMeterConsistency);
assert_eq!(
violations.len(),
1,
"S9: exactly one measure's agreement clause (q's) must decide \
in this instance -- got {violations:?}"
);
assert!(
violations[0].witness.contains(&format!("{q_id:?}")),
"S9: the sole violation must name q, proving p's abstention \
(A4) and r's presence did not disable the whole instance -- \
witness was {:?}",
violations[0].witness
);
}
}
/// Genesis tranche G3b packet 2: M40, asserted BEHAVIOURALLY against
/// `check_invariants`' dispatch (`spec/CONTRACT_GENESIS_G3B_MEASURE.md` pin
/// 11) -- an `all().len()` count passes even with the dispatch arm deleted, so
/// this row must instead show a score violating ONLY invariant 20 is
/// actually flagged by the top-level `check_invariants` entry point.
#[cfg(test)]
mod g3b_dispatch_tests {
use super::*;
use crate::graph::{
BeatGroup, Measure, MeterChange, MetricGrid, PowerOfTwo, RegionContent, TimeSignature,
TimeSignatureDisplay,
};
use crate::ids::{MeasureId, TimeSignatureId};
use crate::time::{AnchorOffset, MusicalDuration, RationalTime, RegionEdge};
/// M40: deleting invariant 20's arm from `check_invariants`' dispatch
/// must be observed here -- an `all().len()` count alone would not notice.
#[test]
fn m40_check_invariants_dispatches_invariant_20() {
assert_eq!(GraphInvariant::all().len(), 21);
let mut s = crate::generators::valid_score(4242);
let replica = s.identity.replica_id;
// Corrupt ONLY invariant 20: two measures, both `None` (so
// agreement never fires), at the wrong boundary distance from each
// other.
let region_id = s.canvas.regions[0].id;
let sig_id = TimeSignatureId::new(replica, 900);
let measure_duration = MusicalDuration::whole();
let ts_val = TimeSignature::new(
sig_id,
TimeSignatureDisplay::Standard {
numerator: 4,
denominator: PowerOfTwo::new(4).unwrap(),
},
measure_duration.clone(),
vec![BeatGroup {
duration: measure_duration,
subdivision: None,
accent: 0,
}],
)
.unwrap();
s.time_signatures.push(ts_val);
let m0 = Measure {
id: MeasureId::new(replica, 501),
start: TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
let m1 = Measure {
id: MeasureId::new(replica, 502),
start: TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(RationalTime::new(1, 3).unwrap())),
},
time_signature: None,
explicit_number: None,
number_visibility: Default::default(),
};
if let RegionContent::StaffBased(content) = &mut s.canvas.regions[0].content {
content.default_metric_grid = Some(MetricGrid {
meter_sequence: vec![MeterChange {
anchor: TimeAnchor::Region {
id: region_id,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
time_signature: sig_id,
}],
});
content.staff_instances[0].measures = vec![m0, m1];
}
assert!(
!check_invariant(&s, GraphInvariant::MeasureMeterConsistency).is_empty(),
"the score must violate invariant 20 directly"
);
assert!(
check_invariants(&s)
.iter()
.any(|v| v.invariant == GraphInvariant::MeasureMeterConsistency),
"check_invariants (the top-level dispatch) must surface the invariant-20 \
violation -- this is the behavioural assertion M40 needs, since \
an all().len() count alone passes even with the dispatch arm deleted"
);
}
}
/// P13-S16 pins 6/6a/6b: invariant 21's **two directions**, each surfaced
/// through `check_invariants`' dispatch and each isolated to its own direction.
///
/// Both directions carry the same `GraphInvariant`, so **no `all()`-driven test
/// can tell them apart** — every such test is satisfied by either one. These two
/// are the only permanent guarantee that both are dispatched, which is what pin
/// 6b's two independently removable methods exist for.
///
/// **Each fixture violates its own direction ONLY**, asserted as an exact set.
/// A fixture disagreeing both ways would be reported after either arm was
/// deleted, so it would sign neither.
#[cfg(test)]
mod s16_agreement_dispatch_tests {
use super::*;
use crate::graph::{StaffGroup, StaffGroupKind};
use crate::ids::StaffGroupId;
/// (m41) **Breaks S→G**: a staff whose `group` names a group whose `members`
/// omit it — the shape a pin-2 maintenance gap produces.
///
/// **Holds G→S**: `members` is empty, so that direction has nothing to
/// disagree about. **Holds every other invariant**, which is what the exact
/// cardinality assertion proves and what `any()` could never touch.
///
/// **Mutation (M6a):** delete the `check_staff_names_absent_group` call from
/// `check_invariants`. This fixture then goes **unreported** — the assertion
/// prints `0` against `1` with an empty vector, which is the observation M6a
/// owes, quoted rather than inferred. `m41b` must still pass.
#[test]
fn m41_check_invariants_dispatches_invariant_21_staff_names_absent_group() {
let mut s = crate::generators::valid_score(4243);
let replica = s.identity.replica_id;
let group_id = StaffGroupId::new(replica, 21_001);
s.staff_groups.push(StaffGroup {
id: group_id,
name: None,
kind: StaffGroupKind::Bracket,
members: Vec::new(),
});
let staff_id = s.staves[0].id;
s.staves[0].group = Some(group_id);
// Bound before ANY assertion: the opposite-direction fact this fixture
// depends on, then the violations. Nothing is asserted until the
// cardinality check, which is the one M6a trips.
let group_members: Option<Vec<StaffId>> = s
.staff_groups
.iter()
.find(|group| group.id == group_id)
.map(|group| group.members.clone());
let violations = check_invariants(&s);
assert_eq!(
violations.len(),
1,
"expected exactly the invariant-21 S->G violation and nothing else, \
got {violations:?}"
);
assert_eq!(
violations[0].invariant,
GraphInvariant::StaffGroupMembershipAgreement,
"the single violation must be invariant 21, got {violations:?}"
);
assert!(
violations[0].witness.starts_with("S->G:"),
"the witness must name the S->G direction so this test cannot pass \
on m41b's fixture, got {violations:?}"
);
assert!(
violations[0].witness.contains(&format!("{staff_id:?}"))
&& violations[0].witness.contains(&format!("{group_id:?}")),
"the witness must name both the staff and the group id, got \
{violations:?}"
);
// The G->S direction holds, asserted directly rather than left to follow
// from the cardinality above: the group must genuinely list nobody, and
// no G->S witness may be present.
assert_eq!(
group_members.as_deref(),
Some(&[][..]),
"fixture: the group must list nobody, so only S->G disagrees"
);
assert!(
!violations.iter().any(|v| v.witness.starts_with("G->S:")),
"the G->S direction must hold on this fixture, got {violations:?}"
);
}
/// (m41b) **Breaks G→S**: a group listing a staff whose own `group` is not
/// that group — the shape a stale projection produces.
///
/// **Holds S→G**: the listed staff's `group` is `None`, so it names no group
/// and that direction abstains. **Holds every other invariant** — note the
/// listed staff is genuinely declared, so this is a *disagreement*, not a
/// dangling reference (invariant 21 abstains on those; invariant 10 owns
/// them).
///
/// **Mutation (M6b):** delete the `check_group_lists_unowned_staff` call from
/// `check_invariants`. This fixture then goes unreported, printing `0`
/// against `1`. **`m41b` is the ONLY test M6b breaks** — `m41`, the generator
/// direction test and all four `all()` consumers use S→G fixtures, so
/// without this test the G→S arm could be deleted and the suite would stay
/// green.
#[test]
fn m41b_check_invariants_dispatches_invariant_21_group_lists_unowned_staff() {
let mut s = crate::generators::valid_score(4244);
let replica = s.identity.replica_id;
let group_id = StaffGroupId::new(replica, 21_002);
let staff_id = s.staves[0].id;
s.staff_groups.push(StaffGroup {
id: group_id,
name: None,
kind: StaffGroupKind::Bracket,
members: vec![staff_id],
});
// Bound before ANY assertion: the opposite-direction fact (`valid_score`
// leaves every staff's `group` as `None`, which is what keeps S->G
// abstaining), then the violations. This was asserted *before* the
// binding in the first draft, which put a check ahead of the cardinality
// assertion M6b trips — if the precondition ever broke, M6b's evidence
// would be replaced by a fixture complaint.
let listed_staff_group = s.staves[0].group;
let violations = check_invariants(&s);
assert_eq!(
violations.len(),
1,
"expected exactly the invariant-21 G->S violation and nothing else, \
got {violations:?}"
);
assert_eq!(
violations[0].invariant,
GraphInvariant::StaffGroupMembershipAgreement,
"the single violation must be invariant 21, got {violations:?}"
);
assert!(
violations[0].witness.starts_with("G->S:"),
"the witness must name the G->S direction so this test cannot pass \
on m41's fixture, got {violations:?}"
);
assert!(
violations[0].witness.contains(&format!("{staff_id:?}"))
&& violations[0].witness.contains(&format!("{group_id:?}")),
"the witness must name both the staff and the group id, got \
{violations:?}"
);
// The S->G direction holds, asserted directly: the listed staff must name
// no group at all, and no S->G witness may be present. Without the first
// of these the fixture could silently become a both-directions one, which
// would be reported after EITHER arm was deleted and so would sign
// neither.
assert_eq!(
listed_staff_group, None,
"fixture: the listed staff must name no group, so only G->S disagrees"
);
assert!(
!violations.iter().any(|v| v.witness.starts_with("S->G:")),
"the S->G direction must hold on this fixture, got {violations:?}"
);
}
}