From 314cd7a80da03eec0c7bc14d29361fc88a8ce813 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 15:13:07 -0400 Subject: [PATCH] G3b packet 2: graph invariant 20, and the operations that must preserve it Invariant 20 checks measure/meter agreement and boundary consistency, and nothing else: invariant 10 already checks that a measure's signature reference resolves, so 20 never re-checks it. A None signature avoids only the agreement clause -- inherited meter still governs distance. Where agreement or delta is not computable the invariant ABSTAINS rather than failing closed, the deliberate opposite of create_measure, because base-ingested data may predate the rule. Pickup first measures are exempt. epiphany-ops depends on epiphany-core and never the reverse, so invariant 20 cannot reuse packet 1's reducer predicates and implements the comparable relation and delta a second time over the graph alone. Two implementations of one normative relation is a divergence hazard, so a cross-crate agreement test drives a table of anchor pairs through both and a one-sided perturbation signs it. SetMetricGrid and SetTimeSignature now refuse writes that would break either clause for a live measure, and every check precedes the mint -- set_time_signature previously minted its carried signature before writing the chain, so a refusal appended afterward would leak a TimeSignature from a non-transactional operation with no undo to reclaim it. Undo restoration safety is evaluated in aggregate, because individually-unsafe restorations can be jointly safe and the reverse. StrictInverse conflicts on the whole set; BestEffort applies the maximal safe subset under a documented canonical-order greedy. These are the first callers to pass overlapping overrides into the grid oracle, so they meet packet 1's tie-break for real. Both aggregate paths are signed end-to-end as well as by unit test: the measures are created after the transaction commits, so nothing disagrees on the forward path and only the restoration of the older grid conflicts. Deleting either call site was previously invisible to the whole suite. Executed against spec/CONTRACT_GENESIS_G3B_MEASURE.md, mutations M34-M47. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01QjsEnYhm1gPpf6ii2iFxFV --- crates/epiphany-core/src/generators.rs | 53 + crates/epiphany-core/src/invariants.rs | 834 +++++++++- crates/epiphany-core/src/lib.rs | 4 +- crates/epiphany-ops/src/lib.rs | 3 +- crates/epiphany-ops/src/reduce.rs | 1384 ++++++++++++++++- .../tests/g3b_measure_anchor_agreement.rs | 347 +++++ 6 files changed, 2606 insertions(+), 19 deletions(-) create mode 100644 crates/epiphany-testkit/tests/g3b_measure_anchor_agreement.rs 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)))); +}