diff --git a/crates/epiphany-core/src/generators.rs b/crates/epiphany-core/src/generators.rs index 0a5b134..7599792 100644 --- a/crates/epiphany-core/src/generators.rs +++ b/crates/epiphany-core/src/generators.rs @@ -730,6 +730,59 @@ pub fn violating_score(inv: GraphInvariant, seed: u64) -> Score { }); } } + MeasureMeterConsistency => { + // A measure whose declared time signature disagrees with the + // effective grid's active signature at its start (Genesis + // tranche G3b, contract pins 6/6c/9b's agreement clause). Both + // anchored at the region's start (`TimeAnchor::Region`) so the + // comparison is c4-comparable and determinate. + use crate::graph::{BeatGroup, MeterChange, MetricGrid, PowerOfTwo, TimeSignature}; + let sig_active: crate::ids::TimeSignatureId = s.identity.mint(); + let sig_declared: crate::ids::TimeSignatureId = s.identity.mint(); + let measure_duration = MusicalDuration::whole(); + let beat_groups = vec![BeatGroup { + duration: measure_duration.clone(), + subdivision: None, + accent: 0, + }]; + let display = crate::graph::TimeSignatureDisplay::Standard { + numerator: 4, + denominator: PowerOfTwo::new(4).unwrap(), + }; + s.time_signatures.push( + TimeSignature::new( + sig_active, + display.clone(), + measure_duration.clone(), + beat_groups.clone(), + ) + .unwrap(), + ); + s.time_signatures.push( + TimeSignature::new(sig_declared, display, measure_duration, beat_groups).unwrap(), + ); + let region_id = s.canvas.regions[0].id; + let region_start = TimeAnchor::Region { + id: region_id, + edge: RegionEdge::Start, + offset: AnchorOffset::Zero, + }; + if let RegionContent::StaffBased(content) = &mut s.canvas.regions[0].content { + content.default_metric_grid = Some(MetricGrid { + meter_sequence: vec![MeterChange { + anchor: region_start.clone(), + time_signature: sig_active, + }], + }); + content.staff_instances[0].measures.push(Measure { + id: MeasureId::new(replica, 5_000_000), + start: region_start, + time_signature: Some(sig_declared), + explicit_number: Some(1), + number_visibility: Default::default(), + }); + } + } } s } diff --git a/crates/epiphany-core/src/invariants.rs b/crates/epiphany-core/src/invariants.rs index 75eb524..abe4683 100644 --- a/crates/epiphany-core/src/invariants.rs +++ b/crates/epiphany-core/src/invariants.rs @@ -8,9 +8,13 @@ //! [`InvariantViolation`] witness identifying the smallest offending objects. //! //! **Count.** The QUICKSTART says "the 18 graph invariants enumerated in -//! Chapter 5"; the spec body actually enumerates **19** items (1–19 in -//! §"Graph Invariants"). We implement all 19 and record the discrepancy as a -//! Pass 11 candidate in `DECISIONS.md` (the spec is the contract). +//! Chapter 5"; the spec body (pre-G3b) enumerates **19** items (1–19 in +//! §"Graph Invariants"). We implement all 19 of those and record the +//! discrepancy as a Pass 11 candidate in `DECISIONS.md` (the spec is the +//! contract). Genesis tranche G3b +//! (`spec/CONTRACT_GENESIS_G3B_MEASURE.md`) adds a 20th, measure-meter +//! consistency, ahead of `core_spec.tex`'s own update (a later packet in the +//! same tranche) — see [`GraphInvariant::MeasureMeterConsistency`]. //! //! **Scope of structural decidability.** A few invariants depend on resolving //! [`crate::TimeAnchor`]s to absolute time (region time-overlap, anchor-offset @@ -19,20 +23,22 @@ //! 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, Region, RegionTimeModel, Score, TieClass, - VoiceOrigin, + derive_promoted_voice_id, CoordinateDiscipline, MeterChange, Region, RegionTimeModel, Score, + TieClass, TimeSignature, VoiceOrigin, }; use crate::ids::{ - EventId, MeasureId, PitchId, RegionId, ReplicaId, StaffId, StaffInstanceId, VoiceId, + EventId, MeasureId, PitchId, RegionId, ReplicaId, StaffId, StaffInstanceId, TimeSignatureId, + VoiceId, }; use crate::pitch::{PitchSpaceId, SpellingDirective, SpellingScope}; use crate::time::{ - AnchorOffset, ConcreteDuration, EventDuration, EventPosition, MusicalPosition, OffsetKind, - TimeAnchor, + AnchorOffset, ConcreteDuration, EventDuration, EventPosition, MeasurePosition, MusicalDuration, + MusicalPosition, OffsetKind, TimeAnchor, WallClockDuration, }; /// The Chapter 5 graph invariants, numbered as in §"Graph Invariants". @@ -89,6 +95,22 @@ pub enum GraphInvariant { 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 and is never + /// flagged (P13-S19, deferred). + MeasureMeterConsistency, } impl GraphInvariant { @@ -115,11 +137,12 @@ impl GraphInvariant { TiePairing => 17, VoiceOriginConsistent => 18, BarlineGroupSameRegion => 19, + MeasureMeterConsistency => 20, } } - /// All 19 invariants in enumeration order. - pub fn all() -> [GraphInvariant; 19] { + /// All 20 invariants in enumeration order. + pub fn all() -> [GraphInvariant; 20] { use GraphInvariant::*; [ EventVoiceBacklink, @@ -141,6 +164,7 @@ impl GraphInvariant { TiePairing, VoiceOriginConsistent, BarlineGroupSameRegion, + MeasureMeterConsistency, ] } } @@ -251,9 +275,36 @@ pub fn check_invariants(score: &Score) -> Vec { 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); 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, Option) { + 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 { check_invariants(score) @@ -2343,6 +2394,354 @@ impl<'a> GraphIndex<'a> { } } } + + // --- 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 { + 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 { + 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 { + 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 { + fn musical(o: &AnchorOffset) -> Option { + 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, + ) -> Governing20 { + let items: Vec<(T, &TimeAnchor)> = candidates.into_iter().collect(); + if items.is_empty() { + return Governing20::None; + } + let mut maximal: Vec = 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, + ) -> Governing20 { + 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 { + 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. + fn check_measure_meter_consistency(&self, out: &mut Vec) { + let time_sigs: HashMap = 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 => {} + } + } + } + } + } +} + +/// 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 { + /// 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)` @@ -4139,3 +4538,418 @@ mod g3a_tests { } } } + +/// 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::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, + declared: Vec, + measures: Vec, + ) -> (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, + ) -> 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 + } + + #[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: removing the boundary clause must let this go undetected. Both + /// measures avoid the agreement clause (`None`) so only boundary can + /// fire. + #[test] + fn m35_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); + // 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 must never be flagged -- it + /// has no predecessor, so the boundary clause is vacuous for it. + /// + /// **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 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_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 must never be flagged by invariant 20" + ); + } + + /// 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" + ); + } +} + +/// Genesis tranche G3b packet 2: M40, asserted BEHAVIOURALLY against +/// `check_invariants`' dispatch (`spec/CONTRACT_GENESIS_G3B_MEASURE.md` pin +/// 11) -- `all().len() == 20` 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 -- `all().len() == 20` alone would not notice. + #[test] + fn m40_check_invariants_dispatches_invariant_20() { + assert_eq!(GraphInvariant::all().len(), 20); + 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 \ + all().len() == 20 alone passes even with the dispatch arm deleted" + ); + } +} diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index bacf46d..c04aea0 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -181,6 +181,6 @@ pub use codec::{CanonicalValue, ScoreDecodeError}; pub use indexes::ScoreIndexes; pub use invariants::{ - check_invariant, check_invariants, deferred_checks, DeferredCheck, GraphInvariant, - InvariantViolation, + check_invariant, check_invariants, deferred_checks, measure_anchor_relation, DeferredCheck, + GraphInvariant, InvariantViolation, }; diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 192419f..628bfb4 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -138,7 +138,8 @@ pub use payload::{ TransactionDescriptor, TransposeIntervalOp, TransposeOp, TupletCompensation, }; pub use reduce::{ - canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason, + canonical_reduction_order, measure_anchor_relation_for_agreement_test, GraphMaterialization, + MaterializedState, ObjectState, PendingReason, }; pub use slot::OperationSlot; pub use stamp::{HybridLogicalClock, OperationStamp, StampTuple}; diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 31ed49c..3c37b64 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -685,6 +685,34 @@ pub fn reduce_operation_set_onto(op_set: &OperationSet, base: &Score) -> GraphMa } } +/// Genesis tranche G3b packet 2 (`spec/CONTRACT_GENESIS_G3B_MEASURE.md`): +/// exposes this crate's pin 6/6b comparable relation and musical delta — +/// [`Reducer::anchors_comparable_order`] / [`Reducer::anchor_musical_delta`], +/// which stay private to this crate — so `epiphany-testkit`'s cross-crate +/// agreement test can drive the SAME anchor pairs through this +/// implementation and through `epiphany-core`'s independent invariant-20 +/// implementation of the same normative relation +/// (`epiphany_core::invariants::measure_anchor_relation`) and assert they +/// agree. This is the guard against maintaining one normative relation in +/// two places without sharing code: `epiphany-core` cannot depend on this +/// crate (the dependency direction is fixed the other way), so invariant 20 +/// cannot reuse `Reducer`'s methods, and there is no third crate either +/// could delegate to. `graph` seeds the reducer graph-aware, so c3 (the +/// vector-index shape) resolves exactly as production reduction resolves +/// it. +pub fn measure_anchor_relation_for_agreement_test( + graph: &Score, + a: &TimeAnchor, + b: &TimeAnchor, +) -> (Option, Option) { + let op_set = OperationSet::new(); + let reducer = Reducer::new_onto(&op_set, graph); + ( + reducer.anchors_comparable_order(a, b), + reducer.anchor_musical_delta(a, b), + ) +} + /// One write in a per-key canonical-order write chain (operation_catalog /// §UndoTransaction, "Value restoration"): the writer, the transaction it was a /// member of (if any), and the value it wrote. @@ -3149,6 +3177,16 @@ impl<'a> Reducer<'a> { } } } + // Contract pin 9c.1: the RESULTING grid must not break invariant + // 20's agreement or boundary clause for any live measure the change + // reaches. Checked before any write (there is no mint in this + // operation to leak, but the check precedes the write regardless, + // for uniformity with `set_time_signature`). + if let Some(effect) = + self.check_grid_preserves_invariant20(op.region, Some(&op.grid), &BTreeMap::new(), None) + { + return effect; + } let prev = self .metric_grid_chain .get(&op.region) @@ -4971,6 +5009,295 @@ impl<'a> Reducer<'a> { .collect() } + /// All LIVE measures of `instance`, in append order — the graph's own + /// `Vec` order graph-aware, or (base-free) sorted by + /// [`Self::measure_vector_order`]'s minted-stamp-tuple comparison, which + /// IS the append order for a mint-only, append-only family (contract pin + /// 9c). Shared by `CreateMeasure`'s "current last measure" walk's + /// sibling checks and by the preservation checks below, which must + /// evaluate every EXISTING live measure of an instance, not merely the + /// last one — a whole-grid or per-key rewrite can break agreement or + /// boundary consistency for ANY of them. + fn measures_of_instance_in_order(&self, instance: StaffInstanceId) -> Vec { + let mut list: Vec<(MeasureId, Measure)> = self + .measure_values + .iter() + .filter(|(id, (parent, _))| { + *parent == instance + && matches!( + self.objects.get(&TypedObjectId::Measure(**id)), + Some(ObjectState::Live) + ) + }) + .map(|(id, (_, m))| (*id, m.clone())) + .collect(); + list.sort_by(|a, b| { + self.measure_vector_order(a.0, b.0) + .unwrap_or(Ordering::Equal) + }); + list.into_iter().map(|(_, m)| m).collect() + } + + /// Contract pin 9c: mirrors invariant 20's own two-clause definition + /// (agreement, boundary) over `sequence` — the RESULTING effective grid + /// a prospective write or an undo restoration set would install — for + /// every live measure of `instance`, in append order. Shared by both + /// grid setters' forward-path preservation checks (pin 9c.1) and by undo + /// restoration-safety evaluation (pin 9c.3, both undo policies), so a + /// single implementation — not three near-duplicates — defines "does + /// this grid preserve invariant 20". `None` when clean. + /// `prospective_signature` lets a caller whose write mints a BRAND-NEW + /// `TimeSignature` (as part of the SAME operation) supply its value for + /// this check to consult — the check MUST run before the mint (pin + /// 9c.2 / M45), so `self.time_signature_values` does not yet carry it. + /// `None` for callers with nothing new to add (`SetMetricGrid`, whose + /// own precondition already requires every referenced signature to + /// pre-exist; undo restoration, which only ever reinstalls + /// already-known signatures). + fn invariant20_violation( + &self, + instance: StaffInstanceId, + sequence: &[MeterChange], + prospective_signature: Option<&TimeSignature>, + ) -> Option { + let duration_of = |sig: TimeSignatureId| -> Option { + if let Some(p) = prospective_signature { + if p.id == sig { + return Some(p.measure_duration().clone()); + } + } + self.time_signature_values + .get(&sig) + .map(|ts| ts.measure_duration().clone()) + }; + let resolves = |sig: TimeSignatureId| -> bool { + matches!( + self.objects.get(&TypedObjectId::TimeSignature(sig)), + Some(ObjectState::Live) + ) || prospective_signature.is_some_and(|p| p.id == sig) + }; + let measures = self.measures_of_instance_in_order(instance); + for (i, m) in measures.iter().enumerate() { + // Agreement clause: ONLY a resolving `Some(id)` (pin 9b) — an + // unresolving reference is invariant 10's business, and `None` + // avoids only this clause, not the boundary clause below. + if let Some(sig) = m.time_signature { + if resolves(sig) { + match self.governing_time_signature(sequence, &m.start) { + GoverningElement::Unique(active) if active != sig => { + return Some(PreconditionFailureReason::MeasureMeterMismatch); + } + GoverningElement::Unique(_) | GoverningElement::None => {} + GoverningElement::Indeterminate => { + return Some(PreconditionFailureReason::MeasureOrderUnverifiable); + } + } + } + } + // 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.governing_time_signature(sequence, &prev.start) { + GoverningElement::Unique(sig) => { + let expected = duration_of(sig); + match (self.anchor_musical_delta(&prev.start, &m.start), expected) { + (Some(delta), Some(expected)) if delta == expected => {} + (Some(_), Some(_)) => { + return Some(PreconditionFailureReason::MeasureMeterMismatch); + } + _ => return Some(PreconditionFailureReason::MeasureOrderUnverifiable), + } + } + GoverningElement::None => {} + GoverningElement::Indeterminate => { + return Some(PreconditionFailureReason::MeasureOrderUnverifiable); + } + } + } + None + } + + /// Contract pin 9c.1: refuses `SetMetricGrid`/`SetTimeSignature` when + /// the RESULTING grid (`grid_override` for a whole-grid write, or + /// `meter_change_overrides` for a per-key write) would violate + /// invariant 20's agreement or boundary clause for any live measure of + /// any staff instance of `region` the change reaches. An instance + /// carrying its own local override is unaffected: `effective_grid` + /// ignores these override params once a local override governs, so + /// checking it here is harmless (it just re-derives the same clean + /// local grid). MUST be called before any mint (pin 9c.2 / M45). + fn check_grid_preserves_invariant20( + &self, + region: RegionId, + grid_override: Option<&Option>, + meter_change_overrides: &BTreeMap>, + prospective_signature: Option<&TimeSignature>, + ) -> Option { + let instances = self + .region_instances + .get(®ion) + .cloned() + .unwrap_or_default(); + for instance in instances { + let sequence = self.effective_grid( + instance, + Some(region), + grid_override, + meter_change_overrides, + ); + if let Some(reason) = + self.invariant20_violation(instance, &sequence, prospective_signature) + { + return Some(OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { reason }, + }); + } + } + None + } + + /// Contract pin 9c.3: whether the effective grid of `region`, with + /// `whole`/`per_key`'s prospective overrides (if any) applied, would + /// leave invariant 20 clean for every live measure of every instance of + /// that region — the single-region primitive both the `StrictInverse` + /// aggregate check and `BestEffort`'s canonical-order greedy fold over. + fn region_grid_clean( + &self, + region: RegionId, + whole: &BTreeMap>, + per_key: &BTreeMap>>, + ) -> bool { + let grid_override = whole.get(®ion); + let empty = BTreeMap::new(); + let meter_change_overrides = per_key.get(®ion).unwrap_or(&empty); + let instances = self + .region_instances + .get(®ion) + .cloned() + .unwrap_or_default(); + for instance in instances { + let sequence = self.effective_grid( + instance, + Some(region), + grid_override, + meter_change_overrides, + ); + if self + .invariant20_violation(instance, &sequence, None) + .is_some() + { + return false; + } + } + true + } + + /// Contract pin 9c.3, `StrictInverse`/`Cascade`: whether the WHOLE + /// restoration set, applied together, would violate invariant 20 for + /// any region it touches — evaluated in AGGREGATE (never + /// per-restoration): individually-unsafe restorations can be jointly + /// safe, and individually-safe ones can be jointly unsafe. + fn aggregate_restorations_violate_invariant20( + &self, + restorations: &[ValueRestoration], + ) -> bool { + let mut whole: BTreeMap> = BTreeMap::new(); + let mut per_key: BTreeMap>> = + BTreeMap::new(); + for r in restorations { + match r { + ValueRestoration::MetricGrid { region, value } => { + whole.insert(*region, value.clone()); + } + ValueRestoration::MeterChange { + region, + position, + value, + } => { + per_key + .entry(*region) + .or_default() + .insert(position.clone(), value.clone()); + } + _ => {} + } + } + let mut regions: BTreeSet = whole.keys().copied().collect(); + regions.extend(per_key.keys().copied()); + regions + .into_iter() + .any(|region| !self.region_grid_clean(region, &whole, &per_key)) + } + + /// Contract pin 9c.3, `BestEffort`: the canonical-order greedy — walks + /// `restorations` in `collect_restorations`' existing (canonical, + /// BTreeMap-keyed) order, admitting each grid/meter-change restoration + /// whose addition keeps the ACCUMULATED prospective state + /// invariant-20-clean, skipping the rest. Non-grid restorations (event, + /// pitch, cross-cutting, …) are unaffected by invariant 20 and are + /// always admitted. "Maximal" means maximal under this deterministic + /// rule, not set-theoretically maximum: the greedy never backtracks to + /// try a different admission order, and the true maximum subset is not + /// uniquely defined. + fn select_invariant20_safe_restorations( + &self, + restorations: Vec, + ) -> Vec { + let mut admitted_whole: BTreeMap> = BTreeMap::new(); + let mut admitted_per_key: BTreeMap< + RegionId, + BTreeMap>, + > = BTreeMap::new(); + let mut out = Vec::new(); + for restoration in restorations { + match &restoration { + ValueRestoration::MetricGrid { region, value } => { + let saved = admitted_whole.insert(*region, value.clone()); + if self.region_grid_clean(*region, &admitted_whole, &admitted_per_key) { + out.push(restoration); + } else { + match saved { + Some(prev) => { + admitted_whole.insert(*region, prev); + } + None => { + admitted_whole.remove(region); + } + } + } + } + ValueRestoration::MeterChange { + region, + position, + value, + } => { + let entry = admitted_per_key.entry(*region).or_default(); + let saved = entry.insert(position.clone(), value.clone()); + if self.region_grid_clean(*region, &admitted_whole, &admitted_per_key) { + out.push(restoration); + } else { + let entry = admitted_per_key + .get_mut(region) + .expect("just inserted above"); + match saved { + Some(prev) => { + entry.insert(position.clone(), prev); + } + None => { + entry.remove(position); + } + } + } + } + _ => out.push(restoration), + } + } + out + } + fn graph_create_measure(&mut self, instance: StaffInstanceId, measure: &Measure) { let Some(score) = self.graph.as_mut() else { return; @@ -5231,17 +5558,34 @@ impl<'a> Reducer<'a> { if let Some(effect) = self.layout_region_slot(op.region) { return effect; } - if let Some(signature) = &op.time_signature { - if let Err(effect) = self.mint_time_signature(env, signature) { - return effect; - } - } + // Contract pin 9c.2/M45: EVERY invariant check must precede any + // mint. `written`'s signature id is a field read off `op` itself — + // no mint needed to know it — so the prospective invariant-20 + // preservation check (pin 9c.1) can and must run BEFORE + // `mint_time_signature` below. A refusal here leaves no residue: no + // fresh `TimeSignature` was ever minted for it to leak from + // `objects`, `time_signature_values`, or the graph. let key = (op.region, op.resolved_position()); let written: Option = op.time_signature.as_ref().map(|signature| MeterChange { anchor: op.anchor.clone(), time_signature: signature.id, }); + let mut prospective_overrides = BTreeMap::new(); + prospective_overrides.insert(key.1.clone(), written.clone()); + if let Some(effect) = self.check_grid_preserves_invariant20( + op.region, + None, + &prospective_overrides, + op.time_signature.as_ref(), + ) { + return effect; + } + if let Some(signature) = &op.time_signature { + if let Err(effect) = self.mint_time_signature(env, signature) { + return effect; + } + } let prev = self .meter_change_chain .get(&key) @@ -6089,6 +6433,25 @@ impl<'a> Reducer<'a> { self.conflicts.insert(conflict); return OperationEffect::Conflicted { conflict: cid }; } + // Contract pin 9c.3, `StrictInverse`/`Cascade`: the WHOLE + // restoration set, evaluated TOGETHER — never + // per-restoration — must leave invariant 20 clean. A + // restoration reinstating a prior grid or meter change + // breaks agreement/boundary exactly as a forward write + // does. + if self.aggregate_restorations_violate_invariant20(&restorations) { + let conflict = ConflictRecord::new( + ConflictKind::TransactionConflict { + transaction: op.target, + failed_members: vec![env.id], + }, + vec![env.id], + vec![], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + return OperationEffect::Conflicted { conflict: cid }; + } let repairs = self.tombstone_undo_targets(env, &targets); self.apply_restorations(env, restorations); if repairs.is_empty() { @@ -6107,7 +6470,13 @@ impl<'a> Reducer<'a> { .copied() .collect(); let repairs = self.tombstone_undo_targets(env, &tombstonable); - self.apply_restorations(env, restorations); + // Contract pin 9c.3, `BestEffort`: the canonical-order + // greedy applies the MAXIMAL safe subset of grid/meter- + // change restorations (never a naive per-restoration + // filter evaluated independently of the others already + // admitted) — see `select_invariant20_safe_restorations`. + let safe_restorations = self.select_invariant20_safe_restorations(restorations); + self.apply_restorations(env, safe_restorations); if repairs.is_empty() { OperationEffect::Applied } else { @@ -19243,4 +19612,1007 @@ mod tests { (pin 6c case 1 / pin 7)" ); } + + // ============================================================================= + // Genesis tranche G3b packet 2 (`spec/CONTRACT_GENESIS_G3B_MEASURE.md`): + // preservation (pin 9c, M41-M47). + // ============================================================================= + + #[cfg(test)] + mod g3b_preservation_tests { + use super::*; + + /// A minimal G3b preservation fixture (contract pin 9c): the full + /// prerequisite chain, one active `TimeSignature` installed at `pos0` + /// via `SetTimeSignature`, and two measures one whole note apart. + /// `declared` controls whether the measures declare `Some(active)` + /// (isolates the agreement clause — the boundary clause holds + /// trivially, since a same-duration replacement never breaks it) or + /// `None` (isolates the boundary clause, which `None` does not + /// exempt). Contiguous counters from 0, `physical` stepped in + /// lockstep, `seen_r1(prev)` chained, per the fixture discipline. + struct G3bPreservationFixture { + labeled: Vec<(&'static str, OperationEnvelope)>, + region: RegionId, + /// The id of the `SetTimeSignature`-installed active signature at + /// `pos0` — not read by every test, but part of the fixture's + /// documented shape. + #[allow(dead_code)] + active: TimeSignatureId, + next_counter: u64, + next_causal: CausalContext, + } + + impl G3bPreservationFixture { + /// Appends one more envelope, chaining the counter/causal-context + /// bookkeeping automatically. + fn push(&mut self, label: &'static str, kind: OperationKind) -> OperationEnvelope { + let counter = self.next_counter; + let env = prim_env(1, counter, counter as i64, self.next_causal.clone(), kind); + self.labeled.push((label, env.clone())); + self.next_counter += 1; + self.next_causal = seen_r1(counter); + env + } + + /// Runs every envelope pushed so far, asserting every one EXCEPT + /// the last `unchecked_tail` labeled envelopes is exactly + /// `Applied` (the fixture discipline), with no conflicts/anomalies + /// and no dropped (pending) op. Returns the materialization so the + /// caller can inspect the tail envelopes' own effects. + fn run(&self, unchecked_tail: usize) -> GraphMaterialization { + let mut set = OperationSet::new(); + let envelopes: Vec = + self.labeled.iter().map(|(_, e)| e.clone()).collect(); + let accepted_count = envelopes.len(); + set.accept_all(envelopes); + let identity = IdentityContext::new(ReplicaId(1)); + let out = reduce_operation_set_onto(&set, &Score::empty(identity)); + let clean_len = self.labeled.len() - unchecked_tail; + g3b_assert_fixture_ran_cleanly(&out, &self.labeled[..clean_len], accepted_count); + out + } + + /// Appends one more envelope as a MEMBER of transaction `tx`, + /// chaining the counter/causal-context bookkeeping exactly like + /// [`Self::push`]. + fn push_tx( + &mut self, + label: &'static str, + tx: TransactionId, + kind: OperationKind, + ) -> OperationEnvelope { + let counter = self.next_counter; + let env = tx_member( + 1, + counter, + counter as i64, + self.next_causal.clone(), + tx, + kind, + ); + self.labeled.push((label, env.clone())); + self.next_counter += 1; + self.next_causal = seen_r1(counter); + env + } + + /// Like [`Self::run`], but WITHOUT the global "no conflicts, no + /// anomalies" assertion `g3b_assert_fixture_ran_cleanly` makes — + /// for a fixture whose own TAIL envelope (the undo under test) is + /// expected to conflict. Every envelope EXCEPT the last + /// `unchecked_tail` is still asserted `Applied`, and every + /// accepted envelope is still asserted to have produced an + /// effect (no silently-dropped/pending op), so a setup failure + /// still fails loudly — only the conflict-registry assertion is + /// left to the caller, scoped to whatever it actually expects. + fn run_allowing_tail_conflict(&self, unchecked_tail: usize) -> GraphMaterialization { + let mut set = OperationSet::new(); + let envelopes: Vec = + self.labeled.iter().map(|(_, e)| e.clone()).collect(); + let accepted_count = envelopes.len(); + set.accept_all(envelopes); + let identity = IdentityContext::new(ReplicaId(1)); + let out = reduce_operation_set_onto(&set, &Score::empty(identity)); + let clean_len = self.labeled.len() - unchecked_tail; + for (label, env) in &self.labeled[..clean_len] { + assert_eq!( + g3b_effect_of(&out.state, env.id), + Some(OperationEffect::Applied), + "setup op `{label}` (id {:?}) must be Applied", + env.id + ); + } + assert_eq!( + out.state.effects.len(), + accepted_count, + "every accepted envelope must produce an effect entry — a dropped \ + (pending) op fails loudly here instead of silently vanishing" + ); + assert!( + out.state.anomalies.is_empty(), + "no anomalies: {:?}", + out.state.anomalies + ); + out + } + } + + fn g3b_preservation_fixture(declared: bool) -> G3bPreservationFixture { + let region = RegionId::new(ReplicaId(1), 1); + let instance = StaffInstanceId::new(ReplicaId(1), 2); + let staff = StaffId::new(ReplicaId(1), 3); + let instrument = InstrumentId::new(ReplicaId(1), 4); + let active = TimeSignatureId::new(ReplicaId(1), 10); + let pos0 = g3b_region_anchor(region, 0); + let pos1 = g3b_region_anchor(region, 1); + + let mut f = G3bPreservationFixture { + labeled: Vec::new(), + region, + active, + next_counter: 0, + next_causal: CausalContext::new(), + }; + f.push( + "CreateInstrument", + OperationKind::CreateInstrument(CreateInstrumentOp { + instrument: crate::valuegen::instrument(instrument), + }), + ); + f.push( + "CreateStaff", + OperationKind::CreateStaff(CreateStaffOp { + staff: crate::valuegen::staff(staff, instrument), + }), + ); + f.push( + "CreateRegion", + OperationKind::CreateRegion(CreateRegionOp { + region: crate::valuegen::region(region), + }), + ); + f.push( + "CreateStaffInstance", + OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region, + instance: crate::valuegen::staff_instance(instance, staff), + }), + ); + f.push( + "SetTimeSignature (install active at pos0)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: pos0.clone(), + time_signature: Some(crate::valuegen::time_signature(active, 4)), + }), + ); + let sig = if declared { Some(active) } else { None }; + f.push( + "CreateMeasure m0", + OperationKind::CreateMeasure(CreateMeasureOp { + instance, + measure: Measure { + id: MeasureId::new(ReplicaId(1), 100), + start: pos0, + time_signature: sig, + explicit_number: None, + number_visibility: Default::default(), + }, + }), + ); + f.push( + "CreateMeasure m1", + OperationKind::CreateMeasure(CreateMeasureOp { + instance, + measure: Measure { + id: MeasureId::new(ReplicaId(1), 101), + start: pos1, + time_signature: sig, + explicit_number: None, + number_visibility: Default::default(), + }, + }), + ); + f + } + + fn measure_meter_mismatch_noop() -> OperationEffect { + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::MeasureMeterMismatch, + }, + } + } + + /// M41: `SetMetricGrid`'s agreement precondition. `declared = true` + /// baseline: m0/m1 both declare `Some(active)`. A whole-grid write + /// naming a DIFFERENT (but SAME-duration) signature at `pos0` disagrees + /// with m0's declared signature while leaving boundary distance intact + /// (same duration) — isolating the agreement clause. + #[test] + fn m41_set_metric_grid_preserves_agreement() { + let mut f = g3b_preservation_fixture(true); + let replacement = TimeSignatureId::new(ReplicaId(1), 20); + let throwaway = g3b_region_anchor(f.region, 1000); + f.push( + "mint replacement (throwaway)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region: f.region, + anchor: throwaway.clone(), + time_signature: Some(crate::valuegen::time_signature(replacement, 4)), + }), + ); + f.push( + "clear throwaway", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region: f.region, + anchor: throwaway, + time_signature: None, + }), + ); + let disruptive = f.push( + "SetMetricGrid (disagreeing whole grid)", + OperationKind::SetMetricGrid(SetMetricGridOp { + region: f.region, + grid: Some(MetricGrid { + meter_sequence: vec![MeterChange { + anchor: g3b_region_anchor(f.region, 0), + time_signature: replacement, + }], + }), + }), + ); + let out = f.run(1); + assert_eq!( + g3b_effect_of(&out.state, disruptive.id), + Some(measure_meter_mismatch_noop()), + "a whole-grid write that disagrees with an existing measure's declared \ + signature must refuse with MeasureMeterMismatch" + ); + } + + /// M42: `SetMetricGrid`'s boundary precondition. `declared = false` + /// baseline (agreement can never fire): a whole-grid write installing a + /// DIFFERENT-DURATION signature at `pos0` makes m0-to-m1's one-whole-note + /// distance wrong under the new governing duration. + #[test] + fn m42_set_metric_grid_preserves_boundary() { + let mut f = g3b_preservation_fixture(false); + let short = TimeSignatureId::new(ReplicaId(1), 21); + let throwaway = g3b_region_anchor(f.region, 1000); + f.push( + "mint short-duration replacement (throwaway)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region: f.region, + anchor: throwaway.clone(), + // numerator 2 => measure_duration 2/4 = half a whole note. + time_signature: Some(crate::valuegen::time_signature(short, 2)), + }), + ); + f.push( + "clear throwaway", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region: f.region, + anchor: throwaway, + time_signature: None, + }), + ); + let disruptive = f.push( + "SetMetricGrid (shorter-duration whole grid)", + OperationKind::SetMetricGrid(SetMetricGridOp { + region: f.region, + grid: Some(MetricGrid { + meter_sequence: vec![MeterChange { + anchor: g3b_region_anchor(f.region, 0), + time_signature: short, + }], + }), + }), + ); + let out = f.run(1); + assert_eq!( + g3b_effect_of(&out.state, disruptive.id), + Some(measure_meter_mismatch_noop()), + "a whole-grid write whose new governing duration no longer matches an \ + existing measure boundary's distance must refuse with MeasureMeterMismatch" + ); + } + + /// M43: `SetTimeSignature`'s agreement precondition. Same shape as M41, + /// but the disruptive write is the per-key `SetTimeSignature` itself + /// (self-minting its replacement — no separate pre-mint needed). + #[test] + fn m43_set_time_signature_preserves_agreement() { + let mut f = g3b_preservation_fixture(true); + let replacement = TimeSignatureId::new(ReplicaId(1), 22); + let disruptive = f.push( + "SetTimeSignature (disagreeing replacement at pos0)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region: f.region, + anchor: g3b_region_anchor(f.region, 0), + time_signature: Some(crate::valuegen::time_signature(replacement, 4)), + }), + ); + let out = f.run(1); + assert_eq!( + g3b_effect_of(&out.state, disruptive.id), + Some(measure_meter_mismatch_noop()), + "a per-key write that disagrees with an existing measure's declared \ + signature must refuse with MeasureMeterMismatch" + ); + } + + /// M44: `SetTimeSignature`'s boundary precondition. Same shape as M42, + /// but the disruptive write is the per-key `SetTimeSignature` itself. + #[test] + fn m44_set_time_signature_preserves_boundary() { + let mut f = g3b_preservation_fixture(false); + let short = TimeSignatureId::new(ReplicaId(1), 23); + let disruptive = f.push( + "SetTimeSignature (shorter-duration replacement at pos0)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region: f.region, + anchor: g3b_region_anchor(f.region, 0), + time_signature: Some(crate::valuegen::time_signature(short, 2)), + }), + ); + let out = f.run(1); + assert_eq!( + g3b_effect_of(&out.state, disruptive.id), + Some(measure_meter_mismatch_noop()), + "a per-key write whose new governing duration no longer matches an \ + existing measure boundary's distance must refuse with MeasureMeterMismatch" + ); + } + + /// M45: every invariant-20 check in `set_time_signature` MUST precede + /// `mint_time_signature` — a refusal must leave NO RESIDUE: no entry in + /// `objects`, none in the graph's `time_signatures`. (The third channel, + /// the carried-value map `time_signature_values`, is private to + /// `Reducer` and not observable after `run()` consumes it; per this + /// codebase's ratified value-map discipline — every reader gates on + /// `self.objects` first — an entry there with no `objects` counterpart + /// is structurally inert, never read by any future decision.) + #[test] + fn m45_refused_set_time_signature_leaves_no_residue() { + let mut f = g3b_preservation_fixture(true); + let fresh = TimeSignatureId::new(ReplicaId(1), 999); + let disruptive = f.push( + "SetTimeSignature (disagreeing FRESH signature at pos0)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region: f.region, + anchor: g3b_region_anchor(f.region, 0), + time_signature: Some(crate::valuegen::time_signature(fresh, 4)), + }), + ); + let out = f.run(1); + assert_eq!( + g3b_effect_of(&out.state, disruptive.id), + Some(measure_meter_mismatch_noop()), + "the disruptive write must be refused" + ); + assert!( + !out.state + .objects + .contains_key(&TypedObjectId::TimeSignature(fresh)), + "a refused SetTimeSignature must leave no residue in `objects` -- the fresh \ + signature must never have been minted" + ); + assert!( + !out.score.time_signatures.iter().any(|t| t.id == fresh), + "a refused SetTimeSignature must leave no residue in the graph -- the fresh \ + signature must never have been pushed onto `score.time_signatures`" + ); + } + + /// M46: aggregate restoration safety under `StrictInverse`/`Cascade`, + /// evaluated on the WHOLE restoration set together — never + /// per-restoration. Direct unit tests on `Reducer::aggregate_ + /// restorations_violate_invariant20` (bypassing the full undo/ + /// transaction machinery, whose OWN forward-path preservation checks + /// -- M41-M44 -- would otherwise refuse a transaction that transiently + /// disagrees mid-transaction, a different, already-covered concern). + /// Two `MeterChange` restorations at DIFFERENT resolved positions, + /// each comparable to the reference (`m0.start`, a `Zero`-offset + /// anchor -- comparable to any clock) but built so the two directions + /// below hold. + #[test] + fn m46_aggregate_restoration_safety_both_directions() { + let region = RegionId::new(ReplicaId(1), 1); + let instance = StaffInstanceId::new(ReplicaId(1), 2); + let m0 = MeasureId::new(ReplicaId(1), 100); + let m1 = MeasureId::new(ReplicaId(1), 101); + let sig1 = TimeSignatureId::new(ReplicaId(1), 10); + let sig2 = TimeSignatureId::new(ReplicaId(1), 11); + let reference_start = TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Zero, + }; + // m1 one whole note after m0 -- the boundary distance every + // scenario below checks against. + let after_start = TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(1))), + }; + + // ---- Direction 1: individually safe, jointly UNSAFE (must + // conflict). `sig1` (duration 1, correct) is anchored via a + // Musical-clock Region anchor at position bucket 0; `sig2` + // (duration 1, ALSO correct in isolation) via a WallClock-clock + // Region anchor at position bucket 1. Both are individually + // comparable to the Zero-offset reference (Zero is the additive + // identity of whichever clock it is compared against, pin 6), but + // mutually incomparable to EACH OTHER (Musical vs WallClock, pin + // 6) -- so admitting only one is an unambiguous unique maximum, + // while admitting BOTH makes selection indeterminate (pin 6c step + // 3), which `invariant20_violation` treats as unsafe. + { + let op_set = OperationSet::new(); + let mut r = Reducer::new(&op_set); + r.region_instances.insert(region, [instance].into()); + r.objects + .insert(TypedObjectId::StaffInstance(instance), ObjectState::Live); + for (id, start) in [(m0, reference_start.clone()), (m1, after_start.clone())] { + r.objects + .insert(TypedObjectId::Measure(id), ObjectState::Live); + r.measure_values.insert( + id, + ( + instance, + Measure { + id, + start, + time_signature: None, + explicit_number: None, + number_visibility: Default::default(), + }, + ), + ); + } + for sig in [sig1, sig2] { + r.objects + .insert(TypedObjectId::TimeSignature(sig), ObjectState::Live); + r.time_signature_values + .insert(sig, crate::valuegen::time_signature(sig, 4)); + } + let pos0 = MusicalPosition(RationalTime::from_int(0)); + let pos1 = MusicalPosition(RationalTime::from_int(1)); + let restorations = vec![ + ValueRestoration::MeterChange { + region, + position: pos0, + value: Some(MeterChange { + anchor: TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration( + RationalTime::from_int(0), + )), + }, + time_signature: sig1, + }), + }, + ValueRestoration::MeterChange { + region, + position: pos1, + value: Some(MeterChange { + anchor: TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::WallClock(WallClockDuration(0)), + }, + time_signature: sig2, + }), + }, + ]; + assert!( + r.aggregate_restorations_violate_invariant20(&restorations), + "M46 direction 1: each restoration alone is safe, but the pairing is \ + mutually incomparable (Musical vs WallClock) -- the WHOLE set, \ + evaluated jointly, must be flagged unsafe (Indeterminate governing \ + selection, pin 6c step 3), never accepted by a per-restoration \ + evaluation" + ); + } + + // ---- Direction 2: individually UNSAFE, jointly safe (must + // apply). Each position's REAL current chain content is a + // wrong-duration signature; each restoration ALONE leaves the + // OTHER position's bad real content as the sole (and thus + // governing) candidate -- unsafe. Restoring BOTH (to explicit + // absence) empties the grid entirely, which is VACUOUS (pin 6c + // case 1), not a violation. + { + let op_set = OperationSet::new(); + let mut r = Reducer::new(&op_set); + r.region_instances.insert(region, [instance].into()); + r.objects + .insert(TypedObjectId::StaffInstance(instance), ObjectState::Live); + for (id, start) in [(m0, reference_start.clone()), (m1, after_start.clone())] { + r.objects + .insert(TypedObjectId::Measure(id), ObjectState::Live); + r.measure_values.insert( + id, + ( + instance, + Measure { + id, + start, + time_signature: None, + explicit_number: None, + number_visibility: Default::default(), + }, + ), + ); + } + r.objects + .insert(TypedObjectId::TimeSignature(sig1), ObjectState::Live); + r.time_signature_values + .insert(sig1, crate::valuegen::time_signature(sig1, 2)); + + let pos0 = MusicalPosition(RationalTime::from_int(0)); + let pos1 = MusicalPosition(RationalTime::from_int(1)); + let bad_musical = TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(0))), + }; + let bad_wallclock = TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::WallClock(WallClockDuration(0)), + }; + // The REAL (current, pre-undo) chain content at both keys: + // wrong duration (1/2, not 1), each individually the sole + // (unambiguous) governor if the OTHER position is restored to + // absence instead. + let mut chain0: WriteChain> = WriteChain::new(); + chain0.record( + OperationId::new(ReplicaId(1), 1), + None, + Some(MeterChange { + anchor: bad_musical, + time_signature: sig1, + }), + ); + r.meter_change_chain.insert((region, pos0.clone()), chain0); + let mut chain1: WriteChain> = WriteChain::new(); + chain1.record( + OperationId::new(ReplicaId(1), 2), + None, + Some(MeterChange { + anchor: bad_wallclock, + time_signature: sig1, + }), + ); + r.meter_change_chain.insert((region, pos1.clone()), chain1); + + let restorations = vec![ + ValueRestoration::MeterChange { + region, + position: pos0, + value: None, + }, + ValueRestoration::MeterChange { + region, + position: pos1, + value: None, + }, + ]; + assert!( + !r.aggregate_restorations_violate_invariant20(&restorations), + "M46 direction 2: each restoration ALONE leaves the OTHER position's \ + wrong-duration REAL content governing (individually unsafe), but \ + removing BOTH empties the grid -- vacuous, not a violation -- so the \ + WHOLE set, evaluated jointly, must be SAFE" + ); + } + } + + /// M47: `BestEffort`'s canonical-order greedy applies the safe subset. + /// Reuses M46 direction 1's individually-safe/jointly-unsafe pair: in + /// canonical (BTreeMap key) order, position 0's restoration is + /// considered first and admitted (alone, it is clean); position 1's is + /// considered next and REJECTED, because admitting it on top of the + /// already-admitted position-0 restoration makes selection + /// indeterminate. A naive per-restoration filter (each restoration + /// checked independently, never against what has already been + /// admitted) would admit BOTH. + #[test] + fn m47_best_effort_applies_only_the_documented_safe_subset() { + let region = RegionId::new(ReplicaId(1), 1); + let instance = StaffInstanceId::new(ReplicaId(1), 2); + let m0 = MeasureId::new(ReplicaId(1), 100); + let m1 = MeasureId::new(ReplicaId(1), 101); + let sig1 = TimeSignatureId::new(ReplicaId(1), 10); + let sig2 = TimeSignatureId::new(ReplicaId(1), 11); + let reference_start = TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Zero, + }; + let after_start = TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(1))), + }; + + let op_set = OperationSet::new(); + let mut r = Reducer::new(&op_set); + r.region_instances.insert(region, [instance].into()); + r.objects + .insert(TypedObjectId::StaffInstance(instance), ObjectState::Live); + for (id, start) in [(m0, reference_start), (m1, after_start)] { + r.objects + .insert(TypedObjectId::Measure(id), ObjectState::Live); + r.measure_values.insert( + id, + ( + instance, + Measure { + id, + start, + time_signature: None, + explicit_number: None, + number_visibility: Default::default(), + }, + ), + ); + } + for sig in [sig1, sig2] { + r.objects + .insert(TypedObjectId::TimeSignature(sig), ObjectState::Live); + r.time_signature_values + .insert(sig, crate::valuegen::time_signature(sig, 4)); + } + + let pos0 = MusicalPosition(RationalTime::from_int(0)); + let pos1 = MusicalPosition(RationalTime::from_int(1)); + let restorations = vec![ + ValueRestoration::MeterChange { + region, + position: pos0, + value: Some(MeterChange { + anchor: TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(RationalTime::from_int( + 0, + ))), + }, + time_signature: sig1, + }), + }, + ValueRestoration::MeterChange { + region, + position: pos1, + value: Some(MeterChange { + anchor: TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::WallClock(WallClockDuration(0)), + }, + time_signature: sig2, + }), + }, + ]; + let safe = r.select_invariant20_safe_restorations(restorations); + assert_eq!( + safe.len(), + 1, + "BestEffort's canonical-order greedy must admit EXACTLY ONE of the two \ + restorations -- a naive independent filter would admit both, since \ + each is individually safe" + ); + match &safe[0] { + ValueRestoration::MeterChange { + region: r, + position, + value: Some(change), + } => { + assert_eq!(*r, region); + assert_eq!(*position, MusicalPosition(RationalTime::from_int(0))); + assert_eq!( + change.time_signature, sig1, + "the admitted restoration must be the FIRST one in canonical \ + (position) order -- position 0's, naming sig1" + ); + } + _ => panic!("expected the admitted restoration to be a MeterChange with a value"), + } + } + + /// Genesis tranche G3b packet 2, coordinator-directed follow-up: + /// the direct-`Reducer` M46/M47 tests above sign the ALGORITHMS + /// (`aggregate_restorations_violate_invariant20`, + /// `select_invariant20_safe_restorations`), but nothing proved + /// `undo_transaction` actually CALLS them — deleting the call site + /// (replacing the `if + /// self.aggregate_restorations_violate_invariant20(&restorations)` + /// guard with `if false`, or replacing + /// `self.select_invariant20_safe_restorations(restorations)` with + /// the identity) left the whole workspace suite green, since the + /// existing tests bypass `undo_transaction` entirely. These two + /// close that gap end-to-end through the normal + /// envelope/`OperationSet` path. + /// + /// Construction (avoids M41-M44's forward-path checks refusing a + /// transiently-disagreeing transaction member): the measure is + /// created AFTER the transaction commits, so nothing on the + /// forward path ever disagrees — only the UNDO's restoration + /// disagrees with the (already-live) measure. Shape: prerequisite + /// chain, pre-minted `sig0`/`sig1`, a pre-transaction baseline + /// whole grid G0 naming `sig0`, a transaction rewriting it to G1 + /// naming `sig1`, then (after the transaction) a `CreateMeasure` + /// that agrees with G1. Undoing the transaction restores G0, which + /// disagrees with the live measure. + fn build_end_to_end_undo_setup() -> ( + G3bPreservationFixture, + TransactionId, + TimeSignatureId, + TimeSignatureId, + ) { + let region = RegionId::new(ReplicaId(1), 1); + let instance = StaffInstanceId::new(ReplicaId(1), 2); + let staff = StaffId::new(ReplicaId(1), 3); + let instrument = InstrumentId::new(ReplicaId(1), 4); + let sig0 = TimeSignatureId::new(ReplicaId(1), 10); + let sig1 = TimeSignatureId::new(ReplicaId(1), 11); + let throwaway = g3b_region_anchor(region, 1000); + let pos0 = g3b_region_anchor(region, 0); + let tx = TransactionId::new(ReplicaId(1), 700); + + let mut f = G3bPreservationFixture { + labeled: Vec::new(), + region, + active: sig0, + next_counter: 0, + next_causal: CausalContext::new(), + }; + f.push( + "CreateInstrument", + OperationKind::CreateInstrument(CreateInstrumentOp { + instrument: crate::valuegen::instrument(instrument), + }), + ); + f.push( + "CreateStaff", + OperationKind::CreateStaff(CreateStaffOp { + staff: crate::valuegen::staff(staff, instrument), + }), + ); + f.push( + "CreateRegion", + OperationKind::CreateRegion(CreateRegionOp { + region: crate::valuegen::region(region), + }), + ); + f.push( + "CreateStaffInstance", + OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region, + instance: crate::valuegen::staff_instance(instance, staff), + }), + ); + f.push( + "mint sig0 (throwaway)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: throwaway.clone(), + time_signature: Some(crate::valuegen::time_signature(sig0, 4)), + }), + ); + f.push( + "clear throwaway (sig0)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: throwaway.clone(), + time_signature: None, + }), + ); + f.push( + "mint sig1 (throwaway)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: throwaway.clone(), + time_signature: Some(crate::valuegen::time_signature(sig1, 4)), + }), + ); + f.push( + "clear throwaway (sig1)", + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region, + anchor: throwaway, + time_signature: None, + }), + ); + f.push( + "SetMetricGrid baseline G0 (sig0)", + OperationKind::SetMetricGrid(SetMetricGridOp { + region, + grid: Some(MetricGrid { + meter_sequence: vec![MeterChange { + anchor: pos0.clone(), + time_signature: sig0, + }], + }), + }), + ); + f.push_tx( + "DeclareTransaction", + tx, + OperationKind::DeclareTransaction(crate::payload::TransactionDescriptor { + id: tx, + label: String::from("g3b end-to-end undo integration"), + category: None, + }), + ); + f.push_tx( + "tx SetMetricGrid (to G1/sig1)", + tx, + OperationKind::SetMetricGrid(SetMetricGridOp { + region, + grid: Some(MetricGrid { + meter_sequence: vec![MeterChange { + anchor: pos0.clone(), + time_signature: sig1, + }], + }), + }), + ); + f.push( + "CreateMeasure m0 (agrees with G1)", + OperationKind::CreateMeasure(CreateMeasureOp { + instance, + measure: Measure { + id: MeasureId::new(ReplicaId(1), 100), + start: pos0, + time_signature: Some(sig1), + explicit_number: None, + number_visibility: Default::default(), + }, + }), + ); + (f, tx, sig0, sig1) + } + + /// M46, end-to-end: `StrictInverse` undoing the transaction must + /// CONFLICT — restoring G0 (`sig0`) disagrees with the live + /// measure (which declares `sig1`, agreeing with G1). Deleting the + /// `aggregate_restorations_violate_invariant20` call site in + /// `undo_transaction` (replacing its `if` guard with `if false`) + /// must make this go red (the undo would report `Applied` + /// instead). + #[test] + fn m46_end_to_end_strict_inverse_conflicts_on_disagreeing_restoration() { + let (mut f, tx, _sig0, _sig1) = build_end_to_end_undo_setup(); + let undo = undo_env( + 1, + f.next_counter, + f.next_counter as i64, + f.next_causal.clone(), + tx, + UndoPolicy::StrictInverse, + ); + f.labeled + .push(("UndoTransaction (StrictInverse)", undo.clone())); + + let out = f.run_allowing_tail_conflict(1); + let effect = g3b_effect_of(&out.state, undo.id); + assert!( + matches!(effect, Some(OperationEffect::Conflicted { .. })), + "StrictInverse must CONFLICT when restoring the whole-grid predecessor \ + (G0/sig0) would disagree with a live measure (which declares sig1, \ + agreeing with G1) — got {effect:?}" + ); + assert_eq!( + out.state.conflicts.records().len(), + 1, + "exactly one conflict — the undo's own — must be recorded" + ); + // The graph must show the undo did NOT revert the grid: the + // aggregate check refuses before `apply_restorations` runs. + let region_value = out + .score + .canvas + .regions + .iter() + .find(|r| r.id == f.region) + .expect("region present"); + let sequence = region_value + .content + .staff_based() + .and_then(|c| c.default_metric_grid.as_ref()) + .map(|g| g.meter_sequence.clone()) + .unwrap_or_default(); + assert_eq!( + sequence, + vec![MeterChange { + anchor: g3b_region_anchor(f.region, 0), + time_signature: _sig1, + }], + "a conflicted (refused) undo must leave the grid exactly as the \ + transaction left it — G1/sig1, not reverted to G0/sig0" + ); + } + + /// M47, end-to-end: `BestEffort` undoing the SAME transaction must + /// apply the documented canonical-order-greedy safe subset. This + /// scenario adds a SECOND, unrelated restoration (a per-key + /// `SetTimeSignature` write at a position no measure references, + /// freshly minted inside the transaction) that is vacuously safe + /// alone: BestEffort must admit it (graph shows it removed after + /// undo) while SKIPPING the whole-grid restoration (graph must + /// still show `sig1` at pos0, NOT reverted to `sig0`). Replacing + /// `select_invariant20_safe_restorations(restorations)`'s call + /// site with the identity (`restorations` unchanged) must make + /// this go red — it would apply BOTH, reverting pos0 to `sig0` and + /// disagreeing with the live measure. + #[test] + fn m47_end_to_end_best_effort_applies_safe_subset_skips_unsafe() { + let (mut f, tx, _sig0, sig1) = build_end_to_end_undo_setup(); + let sig_far = TimeSignatureId::new(ReplicaId(1), 12); + let far_anchor = g3b_region_anchor(f.region, 50); + f.push_tx( + "tx SetTimeSignature (mint sig_far at a position no measure references)", + tx, + OperationKind::SetTimeSignature(SetTimeSignatureOp { + region: f.region, + anchor: far_anchor, + time_signature: Some(crate::valuegen::time_signature(sig_far, 4)), + }), + ); + let undo = undo_env( + 1, + f.next_counter, + f.next_counter as i64, + f.next_causal.clone(), + tx, + UndoPolicy::BestEffort, + ); + f.labeled + .push(("UndoTransaction (BestEffort)", undo.clone())); + + let out = f.run_allowing_tail_conflict(1); + let effect = g3b_effect_of(&out.state, undo.id); + assert!( + matches!( + effect, + Some(OperationEffect::Applied) + | Some(OperationEffect::AppliedWithRepair { .. }) + ), + "BestEffort must never refuse wholesale — got {effect:?}" + ); + assert!( + out.state.conflicts.is_empty(), + "BestEffort must not conflict" + ); + let region_value = out + .score + .canvas + .regions + .iter() + .find(|r| r.id == f.region) + .expect("region present"); + let sequence = region_value + .content + .staff_based() + .and_then(|c| c.default_metric_grid.as_ref()) + .map(|g| g.meter_sequence.clone()) + .unwrap_or_default(); + assert_eq!( + sequence, + vec![MeterChange { + anchor: g3b_region_anchor(f.region, 0), + time_signature: sig1, + }], + "BestEffort's canonical-order greedy must SKIP the disagreeing \ + whole-grid restoration (G0/sig0) — pos0 must still show sig1, the \ + transaction's own write, unreverted" + ); + } + } } diff --git a/crates/epiphany-testkit/tests/g3b_measure_anchor_agreement.rs b/crates/epiphany-testkit/tests/g3b_measure_anchor_agreement.rs new file mode 100644 index 0000000..de825f9 --- /dev/null +++ b/crates/epiphany-testkit/tests/g3b_measure_anchor_agreement.rs @@ -0,0 +1,347 @@ +//! Genesis tranche G3b packet 2 (`spec/CONTRACT_GENESIS_G3B_MEASURE.md`, +//! architecture note): invariant 20's pin 6/6b comparable relation and +//! musical delta are implemented TWICE — once in `epiphany-core`'s +//! `invariants.rs` (over a materialized `Score`, no operational chains to +//! reconstruct) and once in `epiphany-ops`'s `Reducer` (over operational +//! write chains, both graph-aware and base-free). `epiphany-ops` depends on +//! `epiphany-core`, never the reverse, so invariant 20 cannot call the +//! reducer's private methods, and there is no third crate either could +//! delegate to instead. Two independent implementations of one normative +//! relation is a divergence hazard, and this is the guard against it: drive +//! the SAME anchor pairs through both (`epiphany_core::invariants:: +//! measure_anchor_relation` and `epiphany_ops:: +//! measure_anchor_relation_for_agreement_test`) and assert they agree, on +//! BOTH the comparable-or-not verdict and (when comparable) the ordering — +//! plus the musical delta, pin 6b's companion relation. +//! +//! **Mutation:** perturb ONE implementation only (e.g. let core's relation +//! order across differing `pos`/`edge` selectors, exactly the unsoundness +//! contract pin 6 rules out) and observe this test go red. Counted as an +//! extra mutation beyond M34-M47, reported separately. + +use std::cmp::Ordering; + +use epiphany_core::{ + AnchorOffset, EventId, IdentityContext, Measure, MeasureId, MeasureNumberVisibility, + MeasurePosition, MetricTimeModel, MusicalDuration, RationalTime, Region, RegionContent, + RegionEdge, RegionId, RegionTimeModel, ReplicaId, Score, StaffBasedContent, StaffExtent, + StaffId, StaffInstance, StaffInstanceId, TimeAnchor, TimeExtent, WallClockDuration, + WallClockTime, +}; + +/// Builds a fixture `Score` with TWO staff instances (in the SAME region, +/// to keep it minimal): `inst_a` carries measures `[m1, m2, m3]` in that +/// vector order (positions 0, 1, 2 — c3's "vector index"); `inst_b` carries +/// a single measure `m4`, so a `Measure` pair spanning `inst_a`/`inst_b` is +/// the cross-instance case c3 must refuse. +fn fixture() -> ( + Score, + RegionId, + StaffInstanceId, + StaffInstanceId, + MeasureId, + MeasureId, + MeasureId, + MeasureId, +) { + let replica = ReplicaId(9); + let mut idc = IdentityContext::new(replica); + let region_id: RegionId = idc.mint(); + let staff_a: StaffId = idc.mint(); + let staff_b: StaffId = idc.mint(); + let inst_a: StaffInstanceId = idc.mint(); + let inst_b: StaffInstanceId = idc.mint(); + + let m1 = MeasureId::new(replica, 101); + let m2 = MeasureId::new(replica, 102); + let m3 = MeasureId::new(replica, 103); + let m4 = MeasureId::new(replica, 104); + + let bare = |id: MeasureId, offset_wholes: i32| Measure { + id, + start: TimeAnchor::Region { + id: region_id, + edge: RegionEdge::Start, + offset: if offset_wholes == 0 { + AnchorOffset::Zero + } else { + AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(offset_wholes))) + }, + }, + time_signature: None, + explicit_number: None, + number_visibility: MeasureNumberVisibility::Auto, + }; + + let mut instance_a = StaffInstance::new(inst_a, staff_a); + instance_a.measures = vec![bare(m1, 0), bare(m2, 1), bare(m3, 2)]; + let mut instance_b = StaffInstance::new(inst_b, staff_b); + instance_b.measures = vec![bare(m4, 0)]; + + let region = Region { + id: region_id, + time_model: RegionTimeModel::Metric(MetricTimeModel::default()), + content: RegionContent::StaffBased(StaffBasedContent { + staff_instances: vec![instance_a, instance_b], + ..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_a, staff_b], + }, + local_tempo_map: None, + permits_spanning_slurs: false, + }; + + let mut score = Score::empty(idc.clone()); + score.identity = idc; + score.canvas.regions = vec![region]; + (score, region_id, inst_a, inst_b, m1, m2, m3, m4) +} + +fn measure_anchor(id: MeasureId, pos: MeasurePosition, offset: AnchorOffset) -> TimeAnchor { + TimeAnchor::Measure { + id, + position: pos, + offset, + } +} + +fn region_anchor(region: RegionId, edge: RegionEdge, offset: AnchorOffset) -> TimeAnchor { + TimeAnchor::Region { + id: region, + edge, + offset, + } +} + +fn event_anchor(id: EventId, offset: AnchorOffset) -> TimeAnchor { + TimeAnchor::Event { id, offset } +} + +fn musical(n: i32) -> AnchorOffset { + AnchorOffset::Musical(MusicalDuration(RationalTime::from_int(n))) +} + +fn wallclock(n: i64) -> AnchorOffset { + AnchorOffset::WallClock(WallClockDuration(n)) +} + +/// Asserts both implementations agree on the comparable-or-not verdict, the +/// ordering when comparable, and the musical delta — for one anchor pair, +/// in BOTH orientations (a,b) and (b,a), since the relation and delta are +/// meant to be antisymmetric. +fn assert_agrees(score: &Score, label: &str, a: &TimeAnchor, b: &TimeAnchor) { + let (core_order, core_delta) = epiphany_core::measure_anchor_relation(score, a, b); + let (ops_order, ops_delta) = + epiphany_ops::measure_anchor_relation_for_agreement_test(score, a, b); + assert_eq!( + core_order, ops_order, + "{label}: comparable-order verdict disagrees (core: {core_order:?}, ops: {ops_order:?})" + ); + assert_eq!( + core_delta, ops_delta, + "{label}: musical-delta verdict disagrees (core: {core_delta:?}, ops: {ops_delta:?})" + ); + + // Antisymmetric check, the opposite direction. + let (core_order_rev, core_delta_rev) = epiphany_core::measure_anchor_relation(score, b, a); + let (ops_order_rev, ops_delta_rev) = + epiphany_ops::measure_anchor_relation_for_agreement_test(score, b, a); + assert_eq!( + core_order_rev, ops_order_rev, + "{label} (reversed): comparable-order verdict disagrees" + ); + assert_eq!( + core_delta_rev, ops_delta_rev, + "{label} (reversed): musical-delta verdict disagrees" + ); +} + +#[test] +fn cross_crate_anchor_relation_agrees_on_every_table_row() { + let (score, region, _inst_a, _inst_b, m1, m2, m3, m4) = fixture(); + let e1 = EventId::new(ReplicaId(9), 1); + let e2 = EventId::new(ReplicaId(9), 2); + + // c1: Event, same id. + assert_agrees( + &score, + "c1 same event id, Zero vs Musical(1)", + &event_anchor(e1, AnchorOffset::Zero), + &event_anchor(e1, musical(1)), + ); + // c1: Event, different id -- not comparable. + assert_agrees( + &score, + "c1 different event ids", + &event_anchor(e1, AnchorOffset::Zero), + &event_anchor(e2, AnchorOffset::Zero), + ); + // Cross-clock, same event id -- not comparable. + assert_agrees( + &score, + "c1 same event id, Musical vs WallClock", + &event_anchor(e1, musical(1)), + &event_anchor(e1, wallclock(1)), + ); + + // c2: Measure, same id, same pos (Start), differing Musical offsets. + assert_agrees( + &score, + "c2 same measure id, Start, Musical(0) vs Musical(2)", + &measure_anchor(m1, MeasurePosition::Start, musical(0)), + &measure_anchor(m1, MeasurePosition::Start, musical(2)), + ); + // c2: Measure, same id, but DIFFERENT pos -- not comparable. + assert_agrees( + &score, + "c2 same measure id, Start vs End", + &measure_anchor(m1, MeasurePosition::Start, AnchorOffset::Zero), + &measure_anchor(m1, MeasurePosition::End, AnchorOffset::Zero), + ); + + // c3: distinct measure ids, Start/Zero, SAME instance's vector, + // adjacent and non-adjacent pairs. + assert_agrees( + &score, + "c3 same-vector adjacent (m1, m2)", + &measure_anchor(m1, MeasurePosition::Start, AnchorOffset::Zero), + &measure_anchor(m2, MeasurePosition::Start, AnchorOffset::Zero), + ); + assert_agrees( + &score, + "c3 same-vector non-adjacent (m1, m3)", + &measure_anchor(m1, MeasurePosition::Start, AnchorOffset::Zero), + &measure_anchor(m3, MeasurePosition::Start, AnchorOffset::Zero), + ); + // c3: distinct measure ids, Start/Zero, CROSS-INSTANCE -- not + // comparable (m2 in inst_a's vector, m4 in inst_b's). + assert_agrees( + &score, + "c3 cross-instance (m2, m4)", + &measure_anchor(m2, MeasurePosition::Start, AnchorOffset::Zero), + &measure_anchor(m4, MeasurePosition::Start, AnchorOffset::Zero), + ); + // c3's restriction: nonzero offset -- not comparable even though both + // are Start and in the same vector. + assert_agrees( + &score, + "c3 restriction: nonzero offset", + &measure_anchor(m1, MeasurePosition::Start, musical(1)), + &measure_anchor(m2, MeasurePosition::Start, AnchorOffset::Zero), + ); + // c3's restriction: End position -- not comparable. + assert_agrees( + &score, + "c3 restriction: End position", + &measure_anchor(m1, MeasurePosition::End, AnchorOffset::Zero), + &measure_anchor(m2, MeasurePosition::End, AnchorOffset::Zero), + ); + + // c4: Region, same id, same edge, differing offsets (Musical and + // WallClock clocks separately, plus Zero-normalization). + assert_agrees( + &score, + "c4 same region/edge, Musical(0) vs Musical(3)", + ®ion_anchor(region, RegionEdge::Start, musical(0)), + ®ion_anchor(region, RegionEdge::Start, musical(3)), + ); + assert_agrees( + &score, + "c4 same region/edge, WallClock(10) vs WallClock(20)", + ®ion_anchor(region, RegionEdge::Start, wallclock(10)), + ®ion_anchor(region, RegionEdge::Start, wallclock(20)), + ); + assert_agrees( + &score, + "c4 same region/edge, Zero vs Musical(5)", + ®ion_anchor(region, RegionEdge::Start, AnchorOffset::Zero), + ®ion_anchor(region, RegionEdge::Start, musical(5)), + ); + // c4: same id, DIFFERENT edge -- not comparable, even at Zero/Zero. + assert_agrees( + &score, + "c4 same region id, Start vs End", + ®ion_anchor(region, RegionEdge::Start, AnchorOffset::Zero), + ®ion_anchor(region, RegionEdge::End, AnchorOffset::Zero), + ); + // c4: differing region id -- not comparable. + assert_agrees( + &score, + "c4 different region ids", + ®ion_anchor(region, RegionEdge::Start, AnchorOffset::Zero), + ®ion_anchor( + RegionId::new(ReplicaId(9), 999), + RegionEdge::Start, + AnchorOffset::Zero, + ), + ); + // Draft 3's unsoundness, explicitly regression-locked here too: Start + // vs End with a nonzero offset must NOT be ordered by selector. + assert_agrees( + &score, + "c4 Start/Musical(100) vs End/Zero -- must be unverifiable in both", + ®ion_anchor(region, RegionEdge::Start, musical(100)), + ®ion_anchor(region, RegionEdge::End, AnchorOffset::Zero), + ); + + // c5: WallClock, no referent id. + assert_agrees( + &score, + "c5 WallClock(0) vs WallClock(500)", + &TimeAnchor::WallClock { + time: WallClockTime(0), + }, + &TimeAnchor::WallClock { + time: WallClockTime(500), + }, + ); + + // Cross-variant pairs -- never comparable. + assert_agrees( + &score, + "Event vs Measure", + &event_anchor(e1, AnchorOffset::Zero), + &measure_anchor(m1, MeasurePosition::Start, AnchorOffset::Zero), + ); + assert_agrees( + &score, + "Measure vs Region", + &measure_anchor(m1, MeasurePosition::Start, AnchorOffset::Zero), + ®ion_anchor(region, RegionEdge::Start, AnchorOffset::Zero), + ); + assert_agrees( + &score, + "Region vs WallClock", + ®ion_anchor(region, RegionEdge::Start, AnchorOffset::Zero), + &TimeAnchor::WallClock { + time: WallClockTime(0), + }, + ); +} + +/// Direct, minimal sanity check that the two functions actually AGREE on a +/// concrete verdict (not merely on each other's None/None) -- guards +/// against a degenerate agreement where both sides trivially return `None` +/// for everything. +#[test] +fn cross_crate_anchor_relation_actually_produces_non_trivial_verdicts() { + let (score, region, ..) = fixture(); + let a = region_anchor(region, RegionEdge::Start, musical(0)); + let b = region_anchor(region, RegionEdge::Start, musical(3)); + let (core_order, core_delta) = epiphany_core::measure_anchor_relation(&score, &a, &b); + let (ops_order, ops_delta) = + epiphany_ops::measure_anchor_relation_for_agreement_test(&score, &a, &b); + assert_eq!(core_order, Some(Ordering::Less)); + assert_eq!(ops_order, Some(Ordering::Less)); + assert_eq!(core_delta, Some(MusicalDuration(RationalTime::from_int(3)))); + assert_eq!(ops_delta, Some(MusicalDuration(RationalTime::from_int(3)))); +}