From e9c4bad7a61e27dc469362c89e2aaacecab5a4c8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sun, 21 Jun 2026 16:37:51 -0400 Subject: [PATCH] Land M1 + M2 (Agent C): framework edge fixes and real-Score graph integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1 — fix Agent C framework defects, tests-first: - causal ordering: topologically order DVV edges instead of assuming HLC alone implies causal order (false for adversarial remote envelopes); HLC only breaks ties among ready operations. - anomaly cutoff: quarantine from the earliest counter participating in any violating HLC pair (suffix-minima), e.g. [100,200,50] quarantines from counter 0, not counter 1. - pending detection: DVV contiguous ranges use the zero-based per-replica counter floor; first absent id in any asserted range holds the dependent pending (vector coverage, not only dots). - transaction snapshots: rollback removes member-generated conflicts. - edge tests in concurrent_reduction.rs for all six audited cases. M2 — reduce onto Agent B's real score graph: - OperationSet::reduce_onto(&Score) -> GraphMaterialization { state, score } mutates the real arena, voices, regions, tombstones, indexes, and cross-cutting structures; base-free reduce() retained. - VoiceOrigin::SystemPromoted now carries { winning_operation, losing_operation, original_voice }; spec and Invariant 18 updated. - graph-aware migration, forward undo, system breaks, promotion pre-pass. - tests/graph_reduction.rs: 11 tests asserting check_invariants is clean, plus a 64-seed order-independence sweep. Pass-11 spec decisions resolved (blocking subset): DVV floor (P11-C7), HLC-vs-causality, promoted-voice derivation inputs (P11-C4 / core P11-3). Payload/Score canonical encoding remain deferred to the companion docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-core/DECISIONS.md | 26 +- crates/epiphany-core/README.md | 7 +- crates/epiphany-core/src/event.rs | 23 + crates/epiphany-core/src/graph.rs | 7 +- crates/epiphany-core/src/ids.rs | 2 +- crates/epiphany-core/src/invariants.rs | 30 +- crates/epiphany-ops/DECISIONS.md | 80 +- crates/epiphany-ops/README.md | 23 +- crates/epiphany-ops/src/anomaly.rs | 66 +- crates/epiphany-ops/src/causal.rs | 13 +- crates/epiphany-ops/src/lib.rs | 8 +- crates/epiphany-ops/src/opset.rs | 10 +- crates/epiphany-ops/src/reduce.rs | 1174 +++++++++++++++-- .../tests/concurrent_reduction.rs | 220 ++- crates/epiphany-ops/tests/graph_reduction.rs | 542 ++++++++ spec/core_spec.tex | 10 +- 16 files changed, 2030 insertions(+), 211 deletions(-) create mode 100644 crates/epiphany-ops/tests/graph_reduction.rs diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 8ea3665..88add5f 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -68,28 +68,24 @@ Chapter 5", but Chapter 5 §"Graph Invariants" actually enumerates **19** items almost certainly a stale count in the QUICKSTART; the spec body is treated as authoritative. Reconcile the two. -### P11-3 — `VoiceOrigin::SystemPromoted` does not carry the spec's full derivation inputs +### P11-3 — resolved in M2: promoted voices retain the full derivation inputs Invariant 18 requires a system-promoted voice's `VoiceId` to equal the deterministic derivation of Chapter 5 §"System-Promoted Voices", whose inputs are *(staff instance, original voice, winning op, losing op)* — four ids. But -`VoiceOrigin::SystemPromoted` records only `{ cause: OperationId, original_voice: -VoiceId }` (one op, not two). +The first-pass `VoiceOrigin::SystemPromoted` recorded only one operation id. +M2 resolves the inconsistency by storing `{ winning_operation, +losing_operation, original_voice }`; the staff instance remains recoverable from +containment. -**Prototype convention (enforced):** the staff instance is recovered from the -containment walk, and `cause` is fed into *both* the winning- and losing-op -slots of `derive_promoted_voice_id`. Invariant 18 now recomputes that derivation -and rejects any `SystemPromoted` voice whose id does not match it (not merely a -wrong namespace) — see `check_voice_origin_consistent` and the +Invariant 18 recomputes the exact derivation and rejects any +`SystemPromoted` voice whose id does not match it (not merely a wrong namespace) +— see `check_voice_origin_consistent` and the `inv18_flags_fabricated_promoted_voice_id_and_accepts_the_derivation` test. -**Open question for the spec:** either `VoiceOrigin::SystemPromoted` should carry -both the winning and losing op ids (and the spec confirm the derivation over -those), or the derivation should be defined over the inputs the origin actually -stores. The derivation function itself is also still deferred ("specified in the -semantic-operations companion document"); this crate's `derive_promoted_voice_id` -and the cause-as-both-slots convention are placeholders for that companion to -ratify. +The core spec listing now carries both operation ids. The exact hash-domain +derivation remains provisional until the semantic-operations companion ratifies +`derive_promoted_voice_id`. ### P11-4 — A prototype canonical encoding precedes the Binary Format companion diff --git a/crates/epiphany-core/README.md b/crates/epiphany-core/README.md index ccef36c..8e640a0 100644 --- a/crates/epiphany-core/README.md +++ b/crates/epiphany-core/README.md @@ -121,10 +121,9 @@ C4 ≠ C5). Empty pitched events are rejected at the arena boundary and re-check tempo map (its `local_tempo_map`, else the score map). Extents that still cannot be placed (no tempo defined, or a deferred curve) are skipped rather than rejected. Sound (no false positives), incomplete (DECISIONS P11-4). -- **System-promoted voice derivation (invariant 18)** is enforced against a - documented convention because `VoiceOrigin::SystemPromoted` (a spec type) - stores a single `cause`, not the winning *and* losing op ids the derivation - formula names — a spec inconsistency batched as DECISIONS P11-3. +- **System-promoted voice derivation (invariant 18)** retains the winning and + losing operation ids on `VoiceOrigin::SystemPromoted`; the checker recomputes + the exact four-input derivation used by `epiphany-ops`. - **The Chapter 4 tuning *catalog*** — `PitchSpace`/`TuningSystem`/ `AccidentalRegistry` *definitions*, the built-in catalog, the hierarchical resolver, and the position→frequency resolution function — is **not** an diff --git a/crates/epiphany-core/src/event.rs b/crates/epiphany-core/src/event.rs index 98db53f..6e911f6 100644 --- a/crates/epiphany-core/src/event.rs +++ b/crates/epiphany-core/src/event.rs @@ -306,6 +306,20 @@ impl Event { } } + /// Sets the event's region-local position (used by time-model migration + /// during canonical operation reduction). + pub fn set_position(&mut self, position: EventPosition) { + match self { + Event::Pitched(e) => e.position = position, + Event::Unpitched(e) => e.position = position, + Event::Rest(e) => e.position = position, + Event::Indeterminate(e) => e.position = position, + Event::Trajectory(e) => e.position = position, + Event::Graphic(e) => e.position = position, + Event::Cue(e) => e.position = position, + } + } + /// Appends references to every [`IdentifiedPitch`] this event embeds: /// chord pitches for [`Event::Pitched`], and explicit/stepwise pitches for /// [`Event::Trajectory`]. Used by the pitch-uniqueness invariant. @@ -371,6 +385,15 @@ pub struct EventArena { by_id: HashMap, } +impl PartialEq for EventArena { + fn eq(&self, other: &Self) -> bool { + let ids = self.ids_canonical(); + ids == other.ids_canonical() && ids.into_iter().all(|id| self.get(id) == other.get(id)) + } +} + +impl Eq for EventArena {} + impl EventArena { /// An empty arena. pub fn new() -> Self { diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index 4754560..2525916 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -396,7 +396,10 @@ pub enum VoiceOrigin { /// System-promoted to resolve a concurrent-edit collision (Chapter 5 /// §"System-Promoted Voices"). SystemPromoted { - cause: OperationId, + /// The lower-id concurrent operation that retained the original voice. + winning_operation: OperationId, + /// The greater-id concurrent operation moved into this voice. + losing_operation: OperationId, original_voice: VoiceId, }, } @@ -1069,7 +1072,7 @@ impl Default for ScoreTuningContext { /// staff groups, parts, tuning context, tempo map, analysis layers, views) /// carry their Chapter 5 identity/reference skeleton with deeper bodies left to /// the consuming crates (Agents C/E) and later companions. -#[derive(Clone, Debug)] +#[derive(Clone, PartialEq, Eq, Debug)] pub struct Score { pub metadata: ScoreMetadata, pub canvas: Canvas, diff --git a/crates/epiphany-core/src/ids.rs b/crates/epiphany-core/src/ids.rs index 5912667..7605261 100644 --- a/crates/epiphany-core/src/ids.rs +++ b/crates/epiphany-core/src/ids.rs @@ -679,7 +679,7 @@ impl CanonicalDecode for TypedObjectId { /// The replica identifier plus identifier-generation state of a score /// (Chapter 5 `IdentityContext`). A single monotonic counter suffices for all /// identifier kinds. -#[derive(Clone, Debug)] +#[derive(Clone, PartialEq, Eq, Debug)] pub struct IdentityContext { /// This replica's identifier, generated at score creation. pub replica_id: ReplicaId, diff --git a/crates/epiphany-core/src/invariants.rs b/crates/epiphany-core/src/invariants.rs index 601ebe7..b932da4 100644 --- a/crates/epiphany-core/src/invariants.rs +++ b/crates/epiphany-core/src/invariants.rs @@ -2102,18 +2102,19 @@ impl<'a> GraphIndex<'a> { for (_r, si, v) in self.score.voices() { match &v.origin { VoiceOrigin::SystemPromoted { - cause, + winning_operation, + losing_operation, original_voice, } => { // A promoted voice's id MUST be the deterministic derivation - // (Chapter 5 §"System-Promoted Voices"). The spec's - // derivation takes the winning *and* losing op ids, but - // `VoiceOrigin::SystemPromoted` records only one `cause` - // (DECISIONS P11-3); this prototype's convention feeds `cause` - // into both slots, and the checker enforces that convention - // exactly — so a fabricated promoted id is caught, not merely - // a wrong namespace. - let expected = derive_promoted_voice_id(si, *original_voice, *cause, *cause); + // (Chapter 5 §"System-Promoted Voices") from the complete + // provenance retained on the graph object. + let expected = derive_promoted_voice_id( + si, + *original_voice, + *winning_operation, + *losing_operation, + ); if v.id != expected { out.push(InvariantViolation::new( GraphInvariant::VoiceOriginConsistent, @@ -2626,8 +2627,9 @@ mod review_fix_tests { let mut s = valid_score(30); let si = s.canvas.regions[0].staff_instances()[0].id; let original = s.canvas.regions[0].staff_instances()[0].voices[0].id; - let cause = OperationId::new(s.identity.replica_id, 5); - let correct = derive_promoted_voice_id(si, original, cause, cause); + let winner = OperationId::new(s.identity.replica_id, 5); + let loser = OperationId::new(s.identity.replica_id, 6); + let correct = derive_promoted_voice_id(si, original, winner, loser); // A SystemPromoted voice with the *correct* derived id is accepted. let good = Voice { @@ -2636,7 +2638,8 @@ mod review_fix_tests { default_stem_direction: None, is_primary: false, origin: VoiceOrigin::SystemPromoted { - cause, + winning_operation: winner, + losing_operation: loser, original_voice: original, }, }; @@ -2656,7 +2659,8 @@ mod review_fix_tests { default_stem_direction: None, is_primary: false, origin: VoiceOrigin::SystemPromoted { - cause, + winning_operation: winner, + losing_operation: loser, original_voice: original, }, }); diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index 900c681..6f6daee 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -48,12 +48,13 @@ companion. This crate mirrors that division exactly: migration; LWW; atomic transactions). The remaining catalog kinds are an additive future change behind the existing `OperationKind` enum. -The materialized state this crate computes is the canonical bookkeeping Chapter 6 -itself owns — the effect log, conflict registry, anomaly register, object -existence/tombstones, spellings, and LWW fields. The full musical-graph mutation -against `epiphany_core::Score` (arena contents, voice event lists, region -positions) is the integration point with Agent B's crate and is genuinely large; -it is the natural next phase, and nothing here blocks it. +`MaterializedState` is the canonical bookkeeping Chapter 6 owns — the effect +log, conflict registry, anomaly register, object existence/tombstones, +spellings, and LWW fields. M2 adds `OperationSet::reduce_onto(&Score)`, which +seeds those indices from a canonical base and returns the corresponding Agent B +graph. Insert/delete, voice promotion, supported reference-level cross-cutting +values, system breaks, migration checks, transaction rollback, and undo mutate +that graph. The base-free `reduce()` remains the operation-set convergence API. ## Pass 11 candidates (ambiguities for the spec, not resolved in code) @@ -67,8 +68,10 @@ only identifiers and the scalar time types). An `OperationEnvelope` must be hashable **today** — the `EnvelopeHash` and slot equivocation both need canonical bytes — so this crate's payloads carry the reduction-relevant *identifiers and canonical scalar coordinates*, plus a `ContentHash` fingerprint where the -reduction needs only equality (a respelling). This is faithful to everything the -chapter's reduction rules actually consume. **For the spec:** pin the payload +reduction needs only equality (a respelling). Graph-aware reduction materializes +this projection as deterministic C4 pitches (or a rest when no pitch ids are +present); those placeholders do not claim to recover musical values absent from +the payload. **For the spec:** pin the payload schemas (the Operation Catalog companion) and the canonical encoding (the Binary Format companion); when they land, the structs regain their full value fields without changing the reduction. The trigger will be a failing cross-crate @@ -101,16 +104,17 @@ collision (and whether the superseded loser should retroactively read ### P11-C4 — voice-promotion derivation inputs and the >2-collision generalization Invariant 18's promoted-voice derivation takes *(staff instance, original voice, -winning op, losing op)* (Agent B's P11-3 already flags that -`VoiceOrigin::SystemPromoted` stores only one op). This crate resolves promotion -in an **order-independent pre-pass**: bucket concurrent same-`(voice, position)` -inserts, keep the smallest `OperationId` in the original voice, and promote each -other op to `derive_promoted_voice_id(staff_instance, voice, smallest, that_op)`. -The op carries its `staff_instance` explicitly (a full reducer recovers it from -the voice's container). Two open points for the spec: (a) confirm the derivation -inputs (this couples to Agent B's P11-3), and (b) define the >2-way collision -case — the spec describes a pairwise rule; this crate generalizes "smallest stays, -rest promote." +winning op, losing op)*. M2 expanded `VoiceOrigin::SystemPromoted` to retain both +operation ids, so Agent B verifies the exact Agent C derivation. This crate resolves promotion +in an **order-independent pre-pass**: bucket inserts by voice, walk them by +`OperationId`, keep a non-overlapping set in the original voice, and promote +each concurrent overlapping loser to `derive_promoted_voice_id(staff_instance, +voice, winner, loser)`. This applies the InsertEvent invariant to partial +interval overlaps as well as identical start positions. The op carries its +`staff_instance` explicitly (a full reducer recovers it from the voice's +container). One open point remains for the spec: define the >2-way collision +case — the spec describes a pairwise rule; this crate uses the first lower-id overlapping +operation retained in the original voice as the winner for each promotion. ### P11-C5 — "nearest surviving anchor" needs resolved positions @@ -124,28 +128,26 @@ cascade) is implemented faithfully; only the metric "nearest" is approximated. **For the spec:** no change needed — this resolves once the graph mutation phase tracks positions; recorded so the approximation is explicit. -### P11-C6 — time-model-migration compatibility is declared, not computed +### P11-C6 — time-model compatibility is computed when a graph is available -`ChangeRegionTimeModel`'s `TimeModelMigrationFailure` conflict requires knowing -which contained events have coordinate kinds incompatible with the new model. The -prototype does not materialize per-event coordinate kinds, so the op carries a -`declared_incompatible` list (the authoring layer's knowledge) that drives the -conflict; concurrent same-region migrations still conflict structurally by -canonical order. **For the spec:** no change — resolves with the graph mutation -phase; recorded so the modeling is explicit. +`ChangeRegionTimeModel` retains a `declared_incompatible` list for base-free +reduction. Graph-aware reduction additionally derives incompatibilities from +every event's actual coordinate variants and mapping coverage, refusing any +migration that would violate Agent B's coordinate discipline. Concurrent +same-region migrations conflict; causally-later migrations are reevaluated +against the first migration's graph. **For the spec:** the rich migration +payload still belongs to the Operation Catalog. -### P11-C7 — the missing-causal-predecessor rule keys on dots + known-bad coverage +### P11-C7 — DVV contiguous ranges use the zero-based operation-counter floor -The DVV's contiguous `vector[r] = n` asserts predecessors `(r, 0..=n)` exist -*somewhere*; detecting a *truly missing* `(r, k)` from the vector alone would -require knowing the expected counter sequence. This crate therefore uses the -spec's explicit channel for non-contiguous predecessors — the **dots** — as the -missing-predecessor signal, plus vector-coverage of *known* equivocated/excluded -ids, with transitive propagation to dependents. This holds a dependent pending -exactly when a referenced predecessor is absent (a dot to a slotless id), -equivocated, or excluded. **For the spec:** confirm whether the contiguous vector -should also synthesize "missing" predecessors (it would need a per-replica -expected-floor convention). +The DVV's contiguous `vector[r] = n` asserts predecessors `(r, 0..=n)` exist, +matching the operation-id and causal-context documentation. Reduction finds the +first absent id in every asserted range and holds the dependent pending; dots +and vector coverage of known equivocated/excluded ids remain direct blocking +signals, with transitive propagation to dependents. The range check walks known +ids rather than expanding `0..=n`, so a sparse context with a very high counter +does not cause proportional work. **For the spec:** explicitly retain the +zero-based per-replica counter floor in the normative DVV definition. ### P11-C8 — forward undo is modeled via minted-object compensation @@ -155,7 +157,9 @@ graph-mutation phase, this crate models the compensation as tombstoning the objects the target transaction *minted*: StrictInverse conflicts if any such object was already tombstoned/modified; BestEffort tombstones the survivors; Cascade is treated as StrictInverse over the same set (dependent-closure undo is -deferred with the rest of the catalog). **For the spec:** this is faithful to the +deferred with the rest of the catalog). Graph-aware reduction also removes those +event, pitch, promoted-voice, and supported cross-cutting mints from the live +graph and records graph tombstones. **For the spec:** this is faithful to the "content-equivalence to pre-target state" definition for insert-shaped transactions; the inverse of every catalog primitive is the Operation Catalog's job. diff --git a/crates/epiphany-ops/README.md b/crates/epiphany-ops/README.md index 547ba45..2cde482 100644 --- a/crates/epiphany-ops/README.md +++ b/crates/epiphany-ops/README.md @@ -42,11 +42,9 @@ property is the determinism heart of the architecture, and the ## The determinism this crate enforces -1. **A single reduction-order function.** `canonical_reduction_order` sorts by - the intrinsic stamp tuple `(physical, logical, replica, counter)`. The - authoring HLC rule guarantees a causal predecessor's tuple is strictly less, - so the sort respects causal order without a topological pass — and, being a - sort by intrinsic keys, it is trivially independent of arrival order. +1. **A single reduction-order function.** `canonical_reduction_order` performs + deterministic causal topological ordering, using the intrinsic stamp tuple + `(physical, logical, replica, counter)` only among ready operations. 2. **Order-independent equivocation.** A duplicate `OperationId` with different canonical bytes transitions its slot to `Equivocated` regardless of which envelope arrived first (Pass 10). Equivocated slots contribute nothing to @@ -58,6 +56,10 @@ property is the determinism heart of the architecture, and the 4. **Byte-identical materialized state.** `MaterializedState::canonical_bytes` serializes the effect log, conflict registry, anomaly register, object existence, spellings, and LWW fields in their normative orders. +5. **Real graph materialization.** `OperationSet::reduce_onto(&base_score)` + returns `GraphMaterialization { state, score }`. The graph is mutated in the + same canonical order and compares by canonical event identity, independent + of arena storage order. ## Hand-off gates @@ -85,11 +87,12 @@ Chapter 6 specifies the framework and a *representative* selection of operations; the full ~60–80-primitive catalog is an explicit open question (§6.11) deferred to the Operation Catalog companion. This crate implements the framework in full and the representative operations, which is sufficient to -exercise every reduction discipline. The full musical-graph mutation against -`epiphany_core::Score` is the next phase. See `DECISIONS.md` for the scope -boundary, the prototype conventions (payloads carry identifiers + fingerprints, -voice promotion via an order-independent pre-pass, undo via minted-object -compensation), and the batched Pass 11 candidates. +exercise every reduction discipline. The representative operations can also +reduce onto an `epiphany_core::Score`: insert/delete, voice promotion, supported +cross-cutting structures, system breaks, migration checks, transaction +rollback, and undo mutate the real graph while preserving Agent B's invariants. +`reduce()` remains the base-free CRDT/bookkeeping API; `reduce_onto()` is the +graph-aware editing path. See `DECISIONS.md` for remaining payload boundaries. Per QUICKSTART "Don't do these": undo is the spec's **forward** compensating operation, never inverse-based; `unsafe` is forbidden; everything is sync. diff --git a/crates/epiphany-ops/src/anomaly.rs b/crates/epiphany-ops/src/anomaly.rs index edd9b6d..a0c37ee 100644 --- a/crates/epiphany-ops/src/anomaly.rs +++ b/crates/epiphany-ops/src/anomaly.rs @@ -208,9 +208,8 @@ impl CanonicalEncode for IntegrityAnomalyKind { /// /// The check is order-independent: it groups the envelopes by authoring /// replica, walks each replica's envelopes in ascending counter order, and -/// flags the first counter `c2` whose monotonicity tuple is strictly less than -/// the maximum tuple of an earlier counter `c1`. The resulting segment's -/// `first_bad_counter` is `c1` (the smaller counter of the violating pair), and +/// finds every pair `c1 < c2` whose monotonicity tuples decrease. The resulting +/// segment starts at the smallest `c1` participating in any violating pair, and /// every envelope of that replica with counter `>= c1` is excluded. /// /// Returns one [`AnomalousReplicaSegment`] per offending replica, in ascending @@ -232,31 +231,27 @@ pub fn detect_replica_anomalies(envelopes: &[&OperationEnvelope]) -> Vec = None; - let mut max_op: Option = None; - let mut first_bad: Option<(u64, OperationId, OperationId)> = None; - for (counter, op, tuple) in &ops { - match max_tuple { - Some(m) if *tuple < m => { - // Violation: pair is (max_op @ earlier counter, this op). - let earlier = max_op.expect("max_op set whenever max_tuple is"); - first_bad = Some((earlier.counter, earlier, *op)); - break; - } - Some(m) if *tuple > m => { - max_tuple = Some(*tuple); - max_op = Some(*op); - } - None => { - max_tuple = Some(*tuple); - max_op = Some(*op); - } - _ => {} // equal tuple: still monotone, keep the earlier max_op + // Find the earliest counter participating as the left side of any + // violating pair. Comparing only against the preceding maximum is not + // enough: [100, 200, 50] also makes (counter 0, counter 2) a violating + // pair, so quarantine must begin at counter 0 rather than counter 1. + // Suffix minima make this linear after the counter sort. + let mut suffix_min = vec![(i64::MAX, u32::MAX, u64::MAX); ops.len()]; + if let Some(last) = ops.last() { + suffix_min[ops.len() - 1] = last.2; + for index in (0..ops.len() - 1).rev() { + suffix_min[index] = ops[index].2.min(suffix_min[index + 1]); } - let _ = counter; } + let first_bad = (0..ops.len().saturating_sub(1)).find_map(|earlier| { + if ops[earlier].2 <= suffix_min[earlier + 1] { + return None; + } + let later = (earlier + 1..ops.len()) + .find(|&later| ops[earlier].2 > ops[later].2) + .expect("suffix minimum proves that a violating successor exists"); + Some((ops[earlier].0, ops[earlier].1, ops[later].1)) + }); if let Some((first_bad_counter, c1_op, c2_op)) = first_bad { let excluded: Vec = ops @@ -337,4 +332,23 @@ mod tests { let backward = detect_replica_anomalies(&[&b, &a]); assert_eq!(forward, backward); } + + #[test] + fn quarantine_starts_at_earliest_counter_in_any_violating_pair() { + // Counter 2 is below both earlier stamps. The first violating pair by + // counter is (0, 2), even though counter 1 carries the maximum stamp. + let a = env(1, 0, 100, 0); + let b = env(1, 1, 200, 0); + let c = env(1, 2, 50, 0); + let seg = detect_replica_anomalies(&[&a, &b, &c]); + assert_eq!(seg.len(), 1); + assert_eq!(seg[0].first_bad_counter, 0); + assert_eq!(seg[0].excluded, vec![a.id, b.id, c.id]); + assert_eq!( + seg[0].reason, + ReplicaAnomalyReason::HlcMonotonicityViolation { + violating_pair: (a.id, c.id), + } + ); + } } diff --git a/crates/epiphany-ops/src/causal.rs b/crates/epiphany-ops/src/causal.rs index 3a604be..b8e4cf7 100644 --- a/crates/epiphany-ops/src/causal.rs +++ b/crates/epiphany-ops/src/causal.rs @@ -11,12 +11,13 @@ //! * `dots`: individual [`OperationId`]s observed but not yet contiguous in the //! vector — "known but not yet contiguous" predecessors. //! -//! The canonical reduction order is causal-first (Chapter 6 §6.3.3); the -//! authoring HLC rule guarantees causal predecessors carry strictly-lesser -//! stamps, so the reduction never needs to topologically sort — see -//! [`crate::canonical_reduction_order`]. The DVV is consumed instead by the -//! *missing-causal-predecessor* rule (an operation whose predecessor is absent, -//! equivocated, or excluded is held pending) and by the transaction +//! The canonical reduction order is causal-first (Chapter 6 §6.3.3). Although +//! correctly authored operations give causal predecessors strictly-lesser HLC +//! stamps, accepted remote envelopes may violate that authoring rule. Reduction +//! therefore topologically orders the DVV edges and uses HLC only among ready +//! operations — see [`crate::canonical_reduction_order`]. The DVV also drives +//! the *missing-causal-predecessor* rule (an operation whose predecessor is +//! absent, equivocated, or excluded is held pending) and the transaction //! descriptor-precedence rule (Chapter 6 §6.7). use std::collections::{BTreeMap, BTreeSet}; diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 9b0ead2..6442132 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -53,7 +53,9 @@ //! * `opset` — [`OperationSet`]: the slot map plus the acceptance pipeline //! (well-formedness → slot transition → causal validation). //! * `reduce` — [`canonical_reduction_order`], [`MaterializedState`], and the -//! reduction driver (Chapter 6 §6.3). +//! reduction driver (Chapter 6 §6.3). [`OperationSet::reduce_onto`] also +//! materializes the representative mutations into an Agent B +//! [`epiphany_core::Score`]. //! //! ## Scope (per QUICKSTART and Chapter 6 §6.11) //! @@ -112,7 +114,9 @@ pub use payload::{ ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TupletCompensation, }; -pub use reduce::{canonical_reduction_order, MaterializedState, ObjectState, PendingReason}; +pub use reduce::{ + canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason, +}; pub use slot::OperationSlot; pub use stamp::{HybridLogicalClock, OperationStamp, StampTuple}; pub use support::{ diff --git a/crates/epiphany-ops/src/opset.rs b/crates/epiphany-ops/src/opset.rs index 03a8eee..5139855 100644 --- a/crates/epiphany-ops/src/opset.rs +++ b/crates/epiphany-ops/src/opset.rs @@ -19,7 +19,9 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::OperationId; use crate::envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError}; -use crate::reduce::{reduce_operation_set, MaterializedState}; +use crate::reduce::{ + reduce_operation_set, reduce_operation_set_onto, GraphMaterialization, MaterializedState, +}; use crate::slot::OperationSlot; /// The outcome of accepting one envelope (Chapter 6 §6.5 transition rules). @@ -192,6 +194,12 @@ impl OperationSet { pub fn reduce(&self) -> MaterializedState { reduce_operation_set(self) } + + /// Reduces this operation set onto a canonical base score and returns both + /// the Chapter 6 bookkeeping state and Agent B's materialized graph. + pub fn reduce_onto(&self, base: &epiphany_core::Score) -> GraphMaterialization { + reduce_operation_set_onto(self, base) + } } #[cfg(test)] diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 4b13a46..436b341 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -5,11 +5,9 @@ //! //! * [`canonical_reduction_order`] is the **single function** that orders //! operations (Chapter 6 §6.3.3). The order is causal-first, then by the HLC -//! tuple `(physical, logical, replica, counter)`. The authoring HLC rule -//! (Chapter 6 §6.1) guarantees a causal predecessor's stamp is strictly less, -//! so a plain lexicographic sort by that tuple already respects causal order -//! — no topological pass is needed, and the sort key is intrinsic to each -//! envelope, so the order is trivially independent of arrival order. +//! tuple `(physical, logical, replica, counter)`. A deterministic topological +//! pass enforces causal precedence even for an accepted remote envelope whose +//! HLC contradicts its causal context; HLC orders only ready operations. //! * [`reduce_operation_set`] walks that order and produces a //! [`MaterializedState`] whose [`canonical_bytes`](MaterializedState::canonical_bytes) //! are **byte-identical across any permutation of the input** (Appendix D @@ -24,19 +22,22 @@ //! ## Prototype scope //! //! The per-kind reduction implements the representative operations of §6.10 -//! against an object-existence + spelling + LWW working state — the canonical -//! bookkeeping Chapter 6 itself owns (effect log, conflict registry, anomaly -//! register, tombstones). The full musical-graph mutation against -//! `epiphany_core::Score` is the integration point with Agent B's crate and the -//! deferred Operation Catalog (§6.11); see `DECISIONS.md` for what is modeled -//! versus deferred, and the prototype conventions (voice promotion via a -//! pre-pass, respell-winner effect tag, undo via minted-object compensation). +//! against the canonical bookkeeping Chapter 6 owns. [`OperationSet::reduce_onto`] +//! additionally seeds that state from and materializes it into Agent B's +//! [`Score`]. Rich values absent from the provisional operation payloads remain +//! deferred to the Operation Catalog (§6.11); see `DECISIONS.md` for the exact +//! boundary. use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{ - derive_promoted_voice_id, EventId, MusicalPosition, OperationId, PitchId, RegionId, - TransactionId, TypedObjectId, VoiceId, + derive_promoted_voice_id, AcousticPitch, AcousticRealization, AleatoricAnchoringDiscipline, + AleatoricTimeModel, AnchorOffset, Beam, CmnNominal, Event, EventDuration, EventId, + EventOrderingDAG, EventPosition, IdentifiedPitch, MetricTimeModel, MusicalDuration, + MusicalPosition, OperationId, Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent, + ProportionalTimeModel, RegionEdge, RegionId, RegionTimeModel, Rest, ScalePosition, Score, Slur, + StemConfiguration, Tie, TieClass, TimeAnchor, TransactionId, TuningReference, TypedObjectId, + Voice, VoiceId, VoiceOrigin, WallClockDuration, }; use epiphany_determinism::{CanonicalEncode, ContentHash}; @@ -59,15 +60,50 @@ use crate::undo::{UndoPolicy, UndoTransactionPayload}; /// §6.3.3): causal-first, then by the HLC tuple `(physical, logical, replica, /// counter)`. Returns the envelopes in that order. /// -/// This is the single ordering function the determinism property tests against: -/// the key is intrinsic to each envelope (no dependence on input order), and the -/// authoring HLC invariant guarantees the sort respects causal order without an -/// explicit topological pass. +/// This is the single ordering function the determinism property tests against. +/// It performs deterministic Kahn topological ordering over causal-context +/// coverage, choosing the smallest HLC reduction tuple among ready operations. +/// A malformed causal cycle has no valid topological order; the smallest HLC +/// tuple deterministically breaks the cycle so every replica still converges. pub fn canonical_reduction_order<'a>( envelopes: &[&'a OperationEnvelope], ) -> Vec<&'a OperationEnvelope> { - let mut ordered: Vec<&'a OperationEnvelope> = envelopes.to_vec(); - ordered.sort_by_key(|e| e.stamp.reduction_tuple()); + let len = envelopes.len(); + let mut indegree = vec![0usize; len]; + let mut successors = vec![Vec::::new(); len]; + for predecessor in 0..len { + for successor in 0..len { + if predecessor != successor + && envelopes[successor] + .causal_context + .covers(envelopes[predecessor].id) + { + successors[predecessor].push(successor); + indegree[successor] += 1; + } + } + } + + let mut emitted = vec![false; len]; + let mut ordered = Vec::with_capacity(len); + for _ in 0..len { + let ready = (0..len) + .filter(|&index| !emitted[index] && indegree[index] == 0) + .min_by_key(|&index| envelopes[index].stamp.reduction_tuple()); + let next = ready.unwrap_or_else(|| { + // A cycle is malformed, but selecting by the canonical tie-breaker + // keeps reduction deterministic and unlocks its outgoing edges. + (0..len) + .filter(|&index| !emitted[index]) + .min_by_key(|&index| envelopes[index].stamp.reduction_tuple()) + .expect("an un-emitted operation remains") + }); + emitted[next] = true; + ordered.push(envelopes[next]); + for &successor in &successors[next] { + indegree[successor] = indegree[successor].saturating_sub(1); + } + } ordered } @@ -173,6 +209,18 @@ pub struct MaterializedState { pub pending: Vec<(OperationId, PendingReason)>, } +/// The result of reducing an operation set onto a canonical base score. +/// +/// `state` remains the byte-canonical Chapter 6 reduction product. `score` is +/// the corresponding Agent B graph materialization used by editing, invariant +/// checking, indexing, and layout; it is derived state, never the source of +/// truth. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct GraphMaterialization { + pub state: MaterializedState, + pub score: Score, +} + impl MaterializedState { /// The canonical byte serialization of the materialized state. Two /// reductions of the same operation set — in any order — produce identical @@ -242,7 +290,16 @@ impl MaterializedState { /// Reduces an [`OperationSet`] to its canonical [`MaterializedState`]. pub fn reduce_operation_set(op_set: &OperationSet) -> MaterializedState { - Reducer::new(op_set).run() + Reducer::new(op_set).run().0 +} + +/// Reduces an [`OperationSet`] onto a canonical base [`Score`]. +pub fn reduce_operation_set_onto(op_set: &OperationSet, base: &Score) -> GraphMaterialization { + let (state, score) = Reducer::new_onto(op_set, base).run(); + GraphMaterialization { + state, + score: score.expect("graph-aware reduction always retains its base score"), + } } /// The working state of one reduction pass. @@ -258,15 +315,17 @@ struct Reducer<'a> { // Transient indices. minted_by: BTreeMap, event_pitches: BTreeMap>, - voice_occupancy: BTreeMap<(VoiceId, MusicalPosition), EventId>, + voice_occupancy: BTreeMap>, last_respell: BTreeMap, structures: BTreeMap>, migrated_regions: BTreeSet, region_migrator: BTreeMap, descriptors: BTreeMap, - promotion: BTreeMap, + // Losing insert -> (promoted voice, winning insert). + promotion: BTreeMap, tx_minted: BTreeMap>, current_tx: Option, + graph: Option, } /// A snapshot of the working state, for atomic transaction rollback. @@ -274,14 +333,102 @@ struct WorkingSnapshot { objects: BTreeMap, spellings: BTreeMap, breaks: BTreeMap<(RegionId, MusicalPosition), bool>, + conflicts: ConflictRegistry, minted_by: BTreeMap, event_pitches: BTreeMap>, - voice_occupancy: BTreeMap<(VoiceId, MusicalPosition), EventId>, + voice_occupancy: BTreeMap>, last_respell: BTreeMap, structures: BTreeMap>, migrated_regions: BTreeSet, region_migrator: BTreeMap, + descriptors: BTreeMap, tx_minted: BTreeMap>, + graph: Option, +} + +fn intervals_overlap( + a_position: &MusicalPosition, + a_duration: &MusicalDuration, + b_position: &MusicalPosition, + b_duration: &MusicalDuration, +) -> bool { + if !a_duration.is_positive() || !b_duration.is_positive() { + return false; + } + let a_end = a_position.clone() + a_duration.clone(); + let b_end = b_position.clone() + b_duration.clone(); + a_position < &b_end && b_position < &a_end +} + +fn insert_intervals_overlap(a: &InsertEventOp, b: &InsertEventOp) -> bool { + intervals_overlap(&a.position, &a.duration, &b.position, &b.duration) +} + +fn graph_voice_location(score: &Score, voice: VoiceId) -> Option<(usize, usize, usize)> { + for (region_index, region) in score.canvas.regions.iter().enumerate() { + for (instance_index, instance) in region.staff_instances().iter().enumerate() { + if let Some(voice_index) = instance + .voices + .iter() + .position(|candidate| candidate.id == voice) + { + return Some((region_index, instance_index, voice_index)); + } + } + } + None +} + +fn placeholder_pitch(id: PitchId) -> IdentifiedPitch { + IdentifiedPitch { + id, + pitch: Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("cmn-12"), + position: PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave: 4, + }, + }, + acoustic: AcousticPitch { + tuning: TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + }, + } +} + +/// Builds the graph value represented by the prototype's identifier-only +/// InsertEvent payload. Rich event payloads remain gated on the Operation +/// Catalog encoding; until then, pitched inserts use deterministic C4 values +/// and pitchless inserts use a visible rest. +fn graph_event_from_insert(op: &InsertEventOp, target_voice: VoiceId) -> Event { + let position = EventPosition::Musical(op.position.clone()); + let duration = EventDuration::Musical(op.duration.clone()); + if op.pitches.is_empty() { + Event::Rest(Rest { + id: op.event, + voice: target_voice, + position, + duration, + vertical_position: None, + visible: true, + }) + } else { + Event::Pitched(PitchedEvent { + id: op.event, + voice: target_voice, + position, + duration, + pitches: op.pitches.iter().copied().map(placeholder_pitch).collect(), + articulations: Vec::new(), + dynamic: None, + ornaments: Vec::new(), + stem: StemConfiguration, + grace: None, + }) + } } impl<'a> Reducer<'a> { @@ -305,10 +452,182 @@ impl<'a> Reducer<'a> { promotion: BTreeMap::new(), tx_minted: BTreeMap::new(), current_tx: None, + graph: None, } } - fn run(mut self) -> MaterializedState { + fn new_onto(op_set: &'a OperationSet, base: &Score) -> Self { + let mut reducer = Self::new(op_set); + reducer.graph = Some(base.clone()); + reducer.seed_from_graph(); + reducer + } + + /// Seeds reduction indices from the canonical base graph. Base objects are + /// live but have no operation minter; if a later operation tombstones one, + /// that deleting operation is used as the provenance fallback already + /// defined by the bookkeeping reducer. + fn seed_from_graph(&mut self) { + let Some(score) = self.graph.as_ref() else { + return; + }; + + for instrument in &score.instruments { + self.objects + .insert(TypedObjectId::Instrument(instrument.id), ObjectState::Live); + } + for staff in &score.staves { + self.objects + .insert(TypedObjectId::Staff(staff.id), ObjectState::Live); + } + for group in &score.staff_groups { + self.objects + .insert(TypedObjectId::StaffGroup(group.id), ObjectState::Live); + } + for part in &score.parts { + self.objects + .insert(TypedObjectId::PartDefinition(part.id), ObjectState::Live); + } + for signature in &score.time_signatures { + self.objects.insert( + TypedObjectId::TimeSignature(signature.id), + ObjectState::Live, + ); + } + for layer in &score.analysis_layers { + self.objects + .insert(TypedObjectId::AnalysisLayer(layer.id), ObjectState::Live); + } + for view in &score.views { + self.objects + .insert(TypedObjectId::View(view.id), ObjectState::Live); + } + + for region in &score.canvas.regions { + self.objects + .insert(TypedObjectId::Region(region.id), ObjectState::Live); + for instance in region.staff_instances() { + self.objects + .insert(TypedObjectId::StaffInstance(instance.id), ObjectState::Live); + for measure in &instance.measures { + self.objects + .insert(TypedObjectId::Measure(measure.id), ObjectState::Live); + } + for voice in &instance.voices { + self.objects + .insert(TypedObjectId::Voice(voice.id), ObjectState::Live); + } + } + } + + for event in score.events.iter_canonical() { + let event_id = event.id(); + self.objects + .insert(TypedObjectId::Event(event_id), ObjectState::Live); + let mut pitch_ids = Vec::new(); + let mut pitches = Vec::new(); + event.collect_identified_pitches(&mut pitches); + for pitch in pitches { + pitch_ids.push(pitch.id); + self.objects + .insert(TypedObjectId::Pitch(pitch.id), ObjectState::Live); + } + self.event_pitches.insert(event_id, pitch_ids); + + if let (EventPosition::Musical(position), EventDuration::Musical(duration)) = + (event.position(), event.duration()) + { + self.voice_occupancy + .entry(event.voice()) + .or_default() + .push((position.clone(), duration.clone(), event_id)); + } + } + + for slur in &score.cross_cutting.slurs { + let id = TypedObjectId::Slur(slur.id); + self.objects.insert(id, ObjectState::Live); + self.structures.insert( + id, + vec![ + TypedObjectId::Event(slur.start_event), + TypedObjectId::Event(slur.end_event), + ], + ); + } + for tie in &score.cross_cutting.ties { + let id = TypedObjectId::Tie(tie.id); + self.objects.insert(id, ObjectState::Live); + self.structures.insert( + id, + vec![ + TypedObjectId::Event(tie.start_event), + TypedObjectId::Event(tie.end_event), + ], + ); + } + for beam in &score.cross_cutting.beams { + let id = TypedObjectId::Beam(beam.id); + self.objects.insert(id, ObjectState::Live); + self.structures.insert( + id, + beam.events + .iter() + .copied() + .map(TypedObjectId::Event) + .collect(), + ); + } + for tuplet in &score.cross_cutting.tuplets { + let id = TypedObjectId::Tuplet(tuplet.id); + self.objects.insert(id, ObjectState::Live); + self.structures.insert( + id, + tuplet + .members + .iter() + .copied() + .map(TypedObjectId::Event) + .collect(), + ); + } + for spanner in &score.cross_cutting.spanners { + self.objects + .insert(TypedObjectId::Spanner(spanner.id), ObjectState::Live); + } + for marker in &score.cross_cutting.markers { + self.objects + .insert(TypedObjectId::Marker(marker.id), ObjectState::Live); + } + for annotation in &score.cross_cutting.analytical { + self.objects.insert( + TypedObjectId::AnalyticalAnnotation(annotation.id), + ObjectState::Live, + ); + } + for comment in &score.cross_cutting.comments { + self.objects + .insert(TypedObjectId::Comment(comment.id), ObjectState::Live); + } + for gesture in &score.cross_cutting.graphic_gestures { + self.objects + .insert(TypedObjectId::GraphicGesture(gesture.id), ObjectState::Live); + } + for repeat in &score.cross_cutting.repeats { + self.objects + .insert(TypedObjectId::RepeatStructure(repeat.id), ObjectState::Live); + } + for lyric in &score.cross_cutting.lyrics { + self.objects + .insert(TypedObjectId::LyricLine(lyric.id), ObjectState::Live); + } + for chord in &score.cross_cutting.chord_symbols { + self.objects + .insert(TypedObjectId::ChordSymbol(chord.id), ObjectState::Live); + } + } + + fn run(mut self) -> (MaterializedState, Option) { let singles = self.op_set.single_envelopes(); let equivocated: BTreeSet = self.op_set.equivocated_ids().into_iter().collect(); @@ -336,9 +655,24 @@ impl<'a> Reducer<'a> { .filter(|e| !excluded.contains(&e.id)) .collect(); let reducible_ids: BTreeSet = reducible.iter().map(|e| e.id).collect(); + let declared_transactions: BTreeSet = singles + .iter() + .filter_map(|env| match &env.payload { + OperationPayload::Primitive(OperationKind::DeclareTransaction(descriptor)) => { + Some(descriptor.id) + } + _ => None, + }) + .collect(); // 3. Missing-causal-predecessor rule → pending set (with reasons). - let pending = compute_pending(&reducible, &reducible_ids, &equivocated, &excluded); + let pending = compute_pending( + &reducible, + &reducible_ids, + &equivocated, + &excluded, + &declared_transactions, + ); let active: Vec<&OperationEnvelope> = reducible .iter() .copied() @@ -370,7 +704,8 @@ impl<'a> Reducer<'a> { let mut pending_vec: Vec<(OperationId, PendingReason)> = pending.into_iter().collect(); pending_vec.sort_by_key(|(id, _)| *id); - MaterializedState { + let graph = self.graph.take(); + let state = MaterializedState { effects: self.effects, conflicts: self.conflicts, anomalies: self.anomalies.into_values().collect(), @@ -378,7 +713,8 @@ impl<'a> Reducer<'a> { spellings: self.spellings, breaks: self.breaks, pending: pending_vec, - } + }; + (state, graph) } fn record_anomaly(&mut self, kind: IntegrityAnomalyKind) { @@ -393,34 +729,413 @@ impl<'a> Reducer<'a> { // --- Voice promotion pre-pass (Chapter 6 §6.10 InsertEvent). ------------ fn compute_promotions(&mut self, active: &[&OperationEnvelope]) { - // Bucket InsertEvent ops by (voice, position). - let mut buckets: BTreeMap<(VoiceId, MusicalPosition), Vec<&OperationEnvelope>> = - BTreeMap::new(); + // Bucket inserts by target voice. Promotion applies only to concurrent + // operations whose half-open duration intervals overlap. + let mut buckets: BTreeMap> = BTreeMap::new(); for env in active { if let OperationPayload::Primitive(OperationKind::InsertEvent(op)) = &env.payload { - buckets - .entry((op.voice, op.position.clone())) - .or_default() - .push(env); + if self.graph_insert_precondition(op).is_err() { + continue; + } + buckets.entry(op.voice).or_default().push(env); } } for (_, mut bucket) in buckets { if bucket.len() < 2 { continue; } - // Smallest OperationId keeps the original voice; the rest promote. + // Retain non-overlapping operations in the original voice in + // OperationId order. A concurrent collision with an already-kept + // insert receives its own deterministic promoted voice. bucket.sort_by_key(|e| e.id); - let winner = bucket[0].id; - for env in &bucket[1..] { - if let OperationPayload::Primitive(OperationKind::InsertEvent(op)) = &env.payload { + let mut original_voice = Vec::<&OperationEnvelope>::new(); + for env in bucket { + let OperationPayload::Primitive(OperationKind::InsertEvent(op)) = &env.payload + else { + continue; + }; + let collision = original_voice.iter().copied().find(|kept| { + let OperationPayload::Primitive(OperationKind::InsertEvent(kept_op)) = + &kept.payload + else { + return false; + }; + self.concurrent(env.id, kept.id) && insert_intervals_overlap(op, kept_op) + }); + if let Some(winner) = collision { let promoted = - derive_promoted_voice_id(op.staff_instance, op.voice, winner, env.id); - self.promotion.insert(env.id, promoted); + derive_promoted_voice_id(op.staff_instance, op.voice, winner.id, env.id); + self.promotion.insert(env.id, (promoted, winner.id)); + } else { + original_voice.push(env); } } } } + fn graph_insert_precondition( + &self, + op: &InsertEventOp, + ) -> Result<(usize, usize, usize), PreconditionFailureReason> { + let Some(score) = self.graph.as_ref() else { + return Ok((0, 0, 0)); + }; + let location = + graph_voice_location(score, op.voice).ok_or(PreconditionFailureReason::VoiceMissing)?; + let (region_index, instance_index, _) = location; + let region = &score.canvas.regions[region_index]; + let instance = ®ion.staff_instances()[instance_index]; + if instance.id != op.staff_instance { + return Err(PreconditionFailureReason::VoiceMissing); + } + if !matches!(region.time_model, epiphany_core::RegionTimeModel::Metric(_)) { + return Err(PreconditionFailureReason::WrongRegionTimeModel); + } + if score.events.contains(op.event) + || score.tombstoned_events.contains(&op.event) + || op.pitches.iter().any(|pitch| { + self.objects.contains_key(&TypedObjectId::Pitch(*pitch)) + || score.tombstoned_pitches.contains(pitch) + }) + { + return Err(PreconditionFailureReason::TargetTombstoned); + } + Ok(location) + } + + fn materialize_graph_insert( + &mut self, + env: &OperationEnvelope, + op: &InsertEventOp, + location: (usize, usize, usize), + target_voice: VoiceId, + promotion: Option<(VoiceId, OperationId)>, + ) -> Result<(), PreconditionFailureReason> { + let Some(score) = self.graph.as_mut() else { + return Ok(()); + }; + let (region_index, instance_index, voice_index) = location; + let event = graph_event_from_insert(op, target_voice); + score + .events + .insert(event) + .map_err(|_| PreconditionFailureReason::EventDurationInvalid)?; + + if let Some((promoted, winner)) = promotion { + let instance = score.canvas.regions[region_index] + .content + .staff_instances_mut() + .expect("the precondition found a staff-based instance") + .get_mut(instance_index) + .expect("the precondition found this instance"); + instance.voices.push(Voice { + id: promoted, + events: vec![op.event], + default_stem_direction: None, + is_primary: false, + origin: VoiceOrigin::SystemPromoted { + winning_operation: winner, + losing_operation: env.id, + original_voice: op.voice, + }, + }); + } else { + let mut ordered = score.canvas.regions[region_index].staff_instances()[instance_index] + .voices[voice_index] + .events + .clone(); + ordered.push(op.event); + ordered.sort_by(|a, b| { + let a_position = score.events.get(*a).map(Event::position); + let b_position = score.events.get(*b).map(Event::position); + match (a_position, b_position) { + ( + Some(EventPosition::Musical(a_position)), + Some(EventPosition::Musical(b_position)), + ) => a_position.cmp(b_position).then_with(|| a.cmp(b)), + _ => a.cmp(b), + } + }); + score.canvas.regions[region_index] + .content + .staff_instances_mut() + .expect("the precondition found a staff-based instance")[instance_index] + .voices[voice_index] + .events = ordered; + } + Ok(()) + } + + fn graph_delete_precondition( + &self, + op: &DeleteEventOp, + ) -> Result<(), PreconditionFailureReason> { + let Some(score) = self.graph.as_ref() else { + return Ok(()); + }; + let event = score + .events + .get(op.event) + .ok_or(PreconditionFailureReason::TargetMissing)?; + let containing_tuplets: Vec<_> = score + .cross_cutting + .tuplets + .iter() + .filter(|tuplet| tuplet.members.contains(&op.event)) + .map(|tuplet| tuplet.id) + .collect(); + match &op.tuplet_compensation { + TupletCompensation::NotInTuplet if !containing_tuplets.is_empty() => { + Err(PreconditionFailureReason::TupletCompensationInvalid) + } + TupletCompensation::NotInTuplet => Ok(()), + TupletCompensation::ReplaceWithRest { new_rest, duration } => { + if score.events.contains(*new_rest) + || score.tombstoned_events.contains(new_rest) + || event.duration() != &EventDuration::Musical(duration.clone()) + { + Err(PreconditionFailureReason::TupletCompensationInvalid) + } else { + Ok(()) + } + } + // The prototype payload carries only ids, not the rewritten tuplet + // values required to preserve invariant 16. Graph-aware reduction + // refuses to fabricate those values. + TupletCompensation::RewriteTuplets { .. } => { + Err(PreconditionFailureReason::TupletCompensationInvalid) + } + TupletCompensation::CascadeDeleteTuplets { tuplets } => { + let listed: BTreeSet<_> = tuplets.iter().copied().collect(); + let containing: BTreeSet<_> = containing_tuplets.into_iter().collect(); + if listed == containing && !listed.is_empty() { + Ok(()) + } else { + Err(PreconditionFailureReason::TupletCompensationInvalid) + } + } + } + } + + fn materialize_graph_delete(&mut self, op: &DeleteEventOp) { + let Some(score) = self.graph.as_mut() else { + return; + }; + let Some(event) = score.events.remove(op.event) else { + return; + }; + let voice_id = event.voice(); + let location = graph_voice_location(score, voice_id); + let region_id = location.map(|(region, _, _)| score.canvas.regions[region].id); + let removed_event_index = location.and_then(|(region, instance, voice)| { + score.canvas.regions[region].staff_instances()[instance].voices[voice] + .events + .iter() + .position(|event| *event == op.event) + }); + if let Some((region_index, instance_index, voice_index)) = location { + score.canvas.regions[region_index] + .content + .staff_instances_mut() + .expect("an event voice belongs to staff-based content")[instance_index] + .voices[voice_index] + .events + .retain(|id| *id != op.event); + } + + let mut identified = Vec::new(); + event.collect_identified_pitches(&mut identified); + let deleted_pitches: Vec = identified.iter().map(|pitch| pitch.id).collect(); + score.tombstoned_events.insert(op.event); + score + .tombstoned_pitches + .extend(deleted_pitches.iter().copied()); + + match &op.tuplet_compensation { + TupletCompensation::ReplaceWithRest { new_rest, duration } => { + for tuplet in &mut score.cross_cutting.tuplets { + for member in &mut tuplet.members { + if *member == op.event { + *member = *new_rest; + } + } + } + let replacement = Event::Rest(Rest { + id: *new_rest, + voice: voice_id, + position: event.position().clone(), + duration: EventDuration::Musical(duration.clone()), + vertical_position: None, + visible: true, + }); + score + .events + .insert(replacement) + .expect("replacement-rest preconditions were checked"); + if let Some((region_index, instance_index, voice_index)) = + graph_voice_location(score, voice_id) + { + let voice = &mut score.canvas.regions[region_index] + .content + .staff_instances_mut() + .expect("an event voice belongs to staff-based content")[instance_index] + .voices[voice_index]; + if !voice.events.contains(new_rest) { + let index = removed_event_index + .unwrap_or(voice.events.len()) + .min(voice.events.len()); + voice.events.insert(index, *new_rest); + } + } + } + TupletCompensation::CascadeDeleteTuplets { tuplets } => { + let removed: BTreeSet<_> = tuplets.iter().copied().collect(); + score + .cross_cutting + .tuplets + .retain(|tuplet| !removed.contains(&tuplet.id)); + } + TupletCompensation::NotInTuplet | TupletCompensation::RewriteTuplets { .. } => {} + } + + // Keep the materialized graph reference-clean. The detailed repair + // records remain in Chapter 6 state; these graph updates realize the + // representative event-anchored structures Agent B models. + score + .cross_cutting + .ties + .retain(|tie| tie.start_event != op.event && tie.end_event != op.event); + score.cross_cutting.beams.retain_mut(|beam| { + beam.events.retain(|event| *event != op.event); + beam.events.len() >= 2 + }); + score + .cross_cutting + .slurs + .retain(|slur| slur.start_event != op.event && slur.end_event != op.event); + score.cross_cutting.lyrics.retain_mut(|line| { + line.events.retain(|event| *event != op.event); + !line.events.is_empty() + }); + if let Some(region) = region_id { + let fallback = TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Zero, + }; + for marker in &mut score.cross_cutting.markers { + if matches!(marker.anchor, TimeAnchor::Event { id, .. } if id == op.event) { + marker.anchor = fallback.clone(); + } + } + } + } + + fn materialize_graph_tombstones(&mut self, targets: &[TypedObjectId]) { + let events: Vec = targets + .iter() + .filter_map(|target| match target { + TypedObjectId::Event(event) => Some(*event), + _ => None, + }) + .collect(); + for event in events { + for placements in self.voice_occupancy.values_mut() { + placements.retain(|(_, _, stored_event)| *stored_event != event); + } + self.voice_occupancy + .retain(|_, placements| !placements.is_empty()); + self.materialize_graph_delete(&DeleteEventOp { + event, + tuplet_compensation: TupletCompensation::NotInTuplet, + }); + } + + let Some(score) = self.graph.as_mut() else { + return; + }; + for target in targets { + match target { + TypedObjectId::Pitch(pitch) => { + score.tombstoned_pitches.insert(*pitch); + } + TypedObjectId::Voice(voice) => { + for region in &mut score.canvas.regions { + if let Some(instances) = region.content.staff_instances_mut() { + for instance in instances { + instance.voices.retain(|candidate| { + candidate.id != *voice || !candidate.events.is_empty() + }); + } + } + } + } + TypedObjectId::Slur(id) => { + score.cross_cutting.slurs.retain(|value| value.id != *id); + } + TypedObjectId::Tie(id) => { + score.cross_cutting.ties.retain(|value| value.id != *id); + } + TypedObjectId::Beam(id) => { + score.cross_cutting.beams.retain(|value| value.id != *id); + } + _ => {} + } + } + } + + fn materialize_graph_cross_cutting( + &mut self, + op: &CreateCrossCuttingOp, + ) -> Result<(), PreconditionFailureReason> { + let Some(score) = self.graph.as_mut() else { + return Ok(()); + }; + let event_endpoints: Option> = op + .structure + .endpoints + .iter() + .map(|endpoint| match endpoint { + TypedObjectId::Event(event) => Some(*event), + _ => None, + }) + .collect(); + + match (op.structure.id, event_endpoints.as_deref()) { + (TypedObjectId::Slur(id), Some([start, end])) => { + score.cross_cutting.slurs.push(Slur { + id, + start_event: *start, + end_event: *end, + }); + } + (TypedObjectId::Beam(id), Some(events)) if events.len() >= 2 => { + score.cross_cutting.beams.push(Beam { + id, + events: events.to_vec(), + level: 1, + }); + } + (TypedObjectId::Tie(id), Some([start, end])) => { + score.cross_cutting.ties.push(Tie { + id, + start_event: *start, + end_event: *end, + pitch_pairing: None, + class: TieClass::LaissezVibrer, + }); + } + (TypedObjectId::Slur(_) | TypedObjectId::Beam(_) | TypedObjectId::Tie(_), _) => { + return Err(PreconditionFailureReason::TargetMissing); + } + // The reference-level prototype payload does not contain the rich + // fields needed to instantiate other cross-cutting variants. Their + // canonical identity/reference projection remains in `state`. + _ => {} + } + Ok(()) + } + // --- Dispatch. ---------------------------------------------------------- fn apply(&mut self, env: &OperationEnvelope) -> OperationEffect { @@ -431,11 +1146,7 @@ impl<'a> Reducer<'a> { OperationKind::RespellPitch(op) => self.respell_pitch(env, op), OperationKind::CreateCrossCutting(op) => self.create_cross_cutting(env, op), OperationKind::ChangeRegionTimeModel(op) => self.change_region_time_model(env, op), - OperationKind::SetUserSystemBreak(op) => { - self.breaks - .insert((op.region, op.anchor.clone()), op.present); - OperationEffect::Applied - } + OperationKind::SetUserSystemBreak(op) => self.set_user_system_break(op), OperationKind::DeclareTransaction(desc) => { self.descriptors.insert(desc.id, env.id); OperationEffect::Applied @@ -451,7 +1162,65 @@ impl<'a> Reducer<'a> { // --- Per-kind reduction. ------------------------------------------------ + fn set_user_system_break( + &mut self, + op: &crate::payload::SetUserSystemBreakOp, + ) -> OperationEffect { + if let Some(score) = self.graph.as_mut() { + let Some(region) = score + .canvas + .regions + .iter_mut() + .find(|region| region.id == op.region) + else { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + }; + let breaks = match &mut region.content { + epiphany_core::RegionContent::StaffBased(content) => { + &mut content.user_system_breaks + } + epiphany_core::RegionContent::Hybrid { staves, .. } => { + &mut staves.user_system_breaks + } + epiphany_core::RegionContent::FreeGraphic(_) => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + } + }; + let anchor = TimeAnchor::Region { + id: op.region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(op.anchor.0.clone())), + }; + if op.present { + if !breaks.contains(&anchor) { + breaks.push(anchor); + } + } else { + breaks.retain(|candidate| candidate != &anchor); + } + } + + self.breaks + .insert((op.region, op.anchor.clone()), op.present); + OperationEffect::Applied + } + fn insert_event(&mut self, env: &OperationEnvelope, op: &InsertEventOp) -> OperationEffect { + if !op.duration.is_positive() { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::EventDurationInvalid, + }, + }; + } let ev_obj = TypedObjectId::Event(op.event); match self.objects.get(&ev_obj) { Some(ObjectState::Live) => { @@ -466,6 +1235,14 @@ impl<'a> Reducer<'a> { } None => {} } + let graph_location = match self.graph_insert_precondition(op) { + Ok(location) => location, + Err(reason) => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { reason }, + } + } + }; let voice_obj = TypedObjectId::Voice(op.voice); match self.objects.get(&voice_obj) { Some(ObjectState::Tombstoned { .. }) => { @@ -483,11 +1260,38 @@ impl<'a> Reducer<'a> { } } + let promotion = self.promotion.get(&env.id).copied(); + let target_voice = promotion.map(|(voice, _)| voice).unwrap_or(op.voice); + if self + .voice_occupancy + .get(&target_voice) + .is_some_and(|events| { + events.iter().any(|(position, duration, _)| { + intervals_overlap(position, duration, &op.position, &op.duration) + }) + }) + { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::EventDurationInvalid, + }, + }; + } + + if let Err(reason) = + self.materialize_graph_insert(env, op, graph_location, target_voice, promotion) + { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { reason }, + }; + } + let mut repairs = Vec::new(); - let target_voice = if let Some(promoted) = self.promotion.get(&env.id).copied() { + if let Some((promoted, _)) = promotion { let pv = TypedObjectId::Voice(promoted); self.objects.entry(pv).or_insert(ObjectState::Live); self.minted_by.entry(pv).or_insert(env.id); + self.note_minted(env, pv); repairs.push(RepairRecord { kind: RepairKind::VoicePromoted { from: op.voice, @@ -495,10 +1299,7 @@ impl<'a> Reducer<'a> { }, target: pv, }); - promoted - } else { - op.voice - }; + } self.objects.insert(ev_obj, ObjectState::Live); self.minted_by.insert(ev_obj, env.id); @@ -512,8 +1313,11 @@ impl<'a> Reducer<'a> { pitches.push(p); } self.event_pitches.insert(op.event, pitches); - self.voice_occupancy - .insert((target_voice, op.position.clone()), op.event); + self.voice_occupancy.entry(target_voice).or_default().push(( + op.position.clone(), + op.duration.clone(), + op.event, + )); if repairs.is_empty() { OperationEffect::Applied @@ -540,6 +1344,19 @@ impl<'a> Reducer<'a> { } Some(ObjectState::Live) => {} } + if let Err(reason) = self.graph_delete_precondition(op) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { reason }, + }; + } + + let deleted_placement = self.voice_occupancy.iter().find_map(|(voice, events)| { + events + .iter() + .find(|(_, _, event)| *event == op.event) + .map(|(position, duration, _)| (*voice, position.clone(), duration.clone())) + }); + self.materialize_graph_delete(op); let minter = self.minted_by.get(&ev_obj).copied().unwrap_or(env.id); self.objects.insert( @@ -549,6 +1366,10 @@ impl<'a> Reducer<'a> { minted_by: minter, }, ); + for events in self.voice_occupancy.values_mut() { + events.retain(|(_, _, event)| *event != op.event); + } + self.voice_occupancy.retain(|_, events| !events.is_empty()); let mut repairs = Vec::new(); // Tombstone contained pitches. @@ -569,10 +1390,19 @@ impl<'a> Reducer<'a> { // Tuplet compensation. match &op.tuplet_compensation { TupletCompensation::NotInTuplet => {} - TupletCompensation::ReplaceWithRest { new_rest, .. } => { + TupletCompensation::ReplaceWithRest { new_rest, duration } => { let rest_obj = TypedObjectId::Event(*new_rest); self.objects.insert(rest_obj, ObjectState::Live); self.minted_by.insert(rest_obj, env.id); + self.note_minted(env, rest_obj); + self.event_pitches.insert(*new_rest, Vec::new()); + if let Some((voice, position, _)) = &deleted_placement { + self.voice_occupancy.entry(*voice).or_default().push(( + position.clone(), + duration.clone(), + *new_rest, + )); + } repairs.push(RepairRecord { kind: RepairKind::TupletCompensated { compensation_kind: TupletCompensationKind::ReplaceWithRest, @@ -687,7 +1517,7 @@ impl<'a> Reducer<'a> { fn create_cross_cutting( &mut self, - _env: &OperationEnvelope, + env: &OperationEnvelope, op: &CreateCrossCuttingOp, ) -> OperationEffect { let sid = op.structure.id; @@ -714,8 +1544,14 @@ impl<'a> Reducer<'a> { }; } } + if let Err(reason) = self.materialize_graph_cross_cutting(op) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { reason }, + }; + } self.objects.insert(sid, ObjectState::Live); - self.minted_by.insert(sid, _env.id); + self.minted_by.insert(sid, env.id); + self.note_minted(env, sid); self.structures.insert(sid, op.structure.endpoints.clone()); OperationEffect::Applied } @@ -725,31 +1561,97 @@ impl<'a> Reducer<'a> { env: &OperationEnvelope, op: &crate::payload::ChangeRegionTimeModelOp, ) -> OperationEffect { - if self.migrated_regions.contains(&op.region) { - // Concurrent same-target migration: earlier applies, later conflicts. - let winner = self - .region_migrator - .get(&op.region) - .copied() - .unwrap_or(env.id); - let conflict = ConflictRecord::new( - ConflictKind::StructuralFieldCollision { - winner, - loser: env.id, - field: FieldPath("time_model".to_string()), - }, - vec![env.id, winner], - vec![TypedObjectId::Region(op.region)], - ); - let cid = conflict.id; - self.conflicts.insert(conflict); - return OperationEffect::Conflicted { conflict: cid }; + if let Some(winner) = self.region_migrator.get(&op.region).copied() { + // Concurrent same-target migrations conflict. A causally-later + // migration is an intentional second structural change and is + // evaluated against the graph produced by the first. + if !self.concurrent(env.id, winner) { + self.migrated_regions.insert(op.region); + } else { + let conflict = ConflictRecord::new( + ConflictKind::StructuralFieldCollision { + winner, + loser: env.id, + field: FieldPath("time_model".to_string()), + }, + vec![env.id, winner], + vec![TypedObjectId::Region(op.region)], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + return OperationEffect::Conflicted { conflict: cid }; + } } - if !op.declared_incompatible.is_empty() { - let incompatible: Vec = op - .declared_incompatible + let mut incompatible_events: BTreeSet = + op.declared_incompatible.iter().copied().collect(); + let mut graph_region_index = None; + if let Some(score) = self.graph.as_ref() { + let Some(region_index) = score + .canvas + .regions .iter() - .map(|e| TypedObjectId::Event(*e)) + .position(|region| region.id == op.region) + else { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + }; + graph_region_index = Some(region_index); + let region = &score.canvas.regions[region_index]; + let event_ids: Vec = region + .staff_instances() + .iter() + .flat_map(|instance| &instance.voices) + .flat_map(|voice| voice.events.iter().copied()) + .collect(); + + for event_id in &event_ids { + let Some(event) = score.events.get(*event_id) else { + incompatible_events.insert(*event_id); + continue; + }; + let compatible = match op.new_time_model { + crate::payload::RegionTimeModelTag::Metric => matches!( + (event.position(), event.duration()), + (EventPosition::Musical(_), EventDuration::Musical(_)) + ), + crate::payload::RegionTimeModelTag::Proportional => matches!( + (event.position(), event.duration()), + (EventPosition::WallClock(_), EventDuration::WallClock(_)) + ), + crate::payload::RegionTimeModelTag::Aleatoric => true, + }; + if !compatible { + incompatible_events.insert(*event_id); + } + } + + if let crate::payload::PositionRemapping::Reassign(remapping) = &op.remapping { + let mapped: BTreeSet = remapping.iter().map(|(event, _)| *event).collect(); + incompatible_events.extend( + event_ids + .iter() + .filter(|event| !mapped.contains(event)) + .copied(), + ); + if matches!( + op.new_time_model, + crate::payload::RegionTimeModelTag::Proportional + ) { + // Reassign carries musical positions in the current + // prototype schema, so it cannot satisfy a proportional + // region's wall-clock coordinate discipline. + incompatible_events.extend(event_ids); + } + } + } + + if !incompatible_events.is_empty() { + let incompatible: Vec = incompatible_events + .into_iter() + .map(TypedObjectId::Event) .collect(); let mut affected = vec![TypedObjectId::Region(op.region)]; affected.extend(incompatible.iter().copied()); @@ -765,6 +1667,53 @@ impl<'a> Reducer<'a> { self.conflicts.insert(conflict); return OperationEffect::Conflicted { conflict: cid }; } + + if let Some(region_index) = graph_region_index { + let score = self + .graph + .as_mut() + .expect("a graph region index implies graph-aware reduction"); + if let crate::payload::PositionRemapping::Reassign(remapping) = &op.remapping { + for (event, position) in remapping { + if let Some(value) = score.events.get_mut(*event) { + value.set_position(EventPosition::Musical(position.clone())); + } + for placements in self.voice_occupancy.values_mut() { + if let Some((stored_position, _, _)) = placements + .iter_mut() + .find(|(_, _, stored_event)| stored_event == event) + { + *stored_position = position.clone(); + } + } + } + } + let region = &mut score.canvas.regions[region_index]; + let wallclock_duration = region + .time_extent + .as_wallclock() + .and_then(|(start, end)| end.checked_sub(start)) + .filter(|duration| *duration > 0) + .unwrap_or(1); + region.time_model = match op.new_time_model { + crate::payload::RegionTimeModelTag::Metric => { + RegionTimeModel::Metric(MetricTimeModel::default()) + } + crate::payload::RegionTimeModelTag::Proportional => { + RegionTimeModel::Proportional(ProportionalTimeModel { + duration: WallClockDuration(wallclock_duration), + }) + } + crate::payload::RegionTimeModelTag::Aleatoric => { + RegionTimeModel::Aleatoric(AleatoricTimeModel { + ordering: EventOrderingDAG::default(), + anchoring: AleatoricAnchoringDiscipline::FreelyMixed, + bounds: BTreeMap::new(), + duration_hint: WallClockDuration(wallclock_duration), + }) + } + }; + } self.migrated_regions.insert(op.region); self.region_migrator.insert(op.region, env.id); OperationEffect::Applied @@ -856,6 +1805,7 @@ impl<'a> Reducer<'a> { target: *t, }); } + self.materialize_graph_tombstones(&targets); OperationEffect::AppliedWithRepair { repairs } } else { // A target was already tombstoned/modified: strict undo conflicts. @@ -879,6 +1829,7 @@ impl<'a> Reducer<'a> { } UndoPolicy::BestEffort => { let mut repairs = Vec::new(); + let mut tombstoned = Vec::new(); for t in &targets { if matches!(self.objects.get(t), Some(ObjectState::Live)) { let minter = self.minted_by.get(t).copied().unwrap_or(env.id); @@ -893,8 +1844,10 @@ impl<'a> Reducer<'a> { kind: RepairKind::CascadeDeleted, target: *t, }); + tombstoned.push(*t); } } + self.materialize_graph_tombstones(&tombstoned); OperationEffect::AppliedWithRepair { repairs } } } @@ -1120,6 +2073,7 @@ impl<'a> Reducer<'a> { objects: self.objects.clone(), spellings: self.spellings.clone(), breaks: self.breaks.clone(), + conflicts: self.conflicts.clone(), minted_by: self.minted_by.clone(), event_pitches: self.event_pitches.clone(), voice_occupancy: self.voice_occupancy.clone(), @@ -1127,7 +2081,9 @@ impl<'a> Reducer<'a> { structures: self.structures.clone(), migrated_regions: self.migrated_regions.clone(), region_migrator: self.region_migrator.clone(), + descriptors: self.descriptors.clone(), tx_minted: self.tx_minted.clone(), + graph: self.graph.clone(), } } @@ -1135,6 +2091,7 @@ impl<'a> Reducer<'a> { self.objects = s.objects; self.spellings = s.spellings; self.breaks = s.breaks; + self.conflicts = s.conflicts; self.minted_by = s.minted_by; self.event_pitches = s.event_pitches; self.voice_occupancy = s.voice_occupancy; @@ -1142,7 +2099,9 @@ impl<'a> Reducer<'a> { self.structures = s.structures; self.migrated_regions = s.migrated_regions; self.region_migrator = s.region_migrator; + self.descriptors = s.descriptors; self.tx_minted = s.tx_minted; + self.graph = s.graph; } } @@ -1200,13 +2159,28 @@ fn compute_pending( reducible_ids: &BTreeSet, equivocated: &BTreeSet, excluded: &BTreeSet, + declared_transactions: &BTreeSet, ) -> BTreeMap { let mut blocked: BTreeMap = BTreeMap::new(); - - // Direct causes: dots referencing non-reducible ids, and vector coverage of - // known equivocated/excluded ids. + let known_ids: BTreeSet = reducible_ids + .iter() + .chain(equivocated) + .chain(excluded) + .copied() + .collect(); + // Direct causes: a hole in an asserted contiguous vector range, a dot + // referencing a non-reducible id, or coverage of a known bad id. for env in reducible { + // An absent transaction descriptor has a more specific normative + // outcome: TransactionConflict. Let transaction reduction report it + // instead of masking it as an ordinary missing predecessor. + if member_transaction(env).is_some_and(|tx| !declared_transactions.contains(&tx)) { + continue; + } let mut causes: Vec<(OperationId, PendingReason)> = Vec::new(); + if let Some(missing) = first_missing_vector_predecessor(env, &known_ids) { + causes.push((missing, PendingReason::MissingCausalPredecessor { missing })); + } for d in env.causal_context.dots() { if !reducible_ids.contains(&d) { let reason = if equivocated.contains(&d) { @@ -1259,6 +2233,42 @@ fn compute_pending( blocked } +/// Finds the smallest absent id asserted by a causal context's contiguous +/// vector ranges without expanding those ranges counter by counter. +fn first_missing_vector_predecessor( + env: &OperationEnvelope, + known_ids: &BTreeSet, +) -> Option { + let mut first_missing = None; + + for (&replica, &high) in &env.causal_context.vector { + let mut expected = 0_u64; + let mut complete = false; + + for id in known_ids.range(OperationId::new(replica, 0)..=OperationId::new(replica, high)) { + if id.counter > expected { + break; + } + if id.counter == expected { + if expected == high { + complete = true; + break; + } + expected += 1; + } + } + + if !complete { + let candidate = OperationId::new(replica, expected); + if first_missing.map_or(true, |current| candidate < current) { + first_missing = Some(candidate); + } + } + } + + first_missing +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/epiphany-ops/tests/concurrent_reduction.rs b/crates/epiphany-ops/tests/concurrent_reduction.rs index f8f88f5..e20f5cd 100644 --- a/crates/epiphany-ops/tests/concurrent_reduction.rs +++ b/crates/epiphany-ops/tests/concurrent_reduction.rs @@ -21,9 +21,10 @@ use epiphany_core::{ }; use epiphany_determinism::{fuzz::SplitMix64, ContentHash}; use epiphany_ops::{ - well_formed, AuthorId, CausalContext, ConflictKind, HybridLogicalClock, InsertEventOp, - IntegrityAnomalyKind, NoOpReason, OperationEffect, OperationEnvelope, OperationKind, - OperationPayload, OperationSet, OperationStamp, RespellPitchOp, TransactionDescriptor, + canonical_reduction_order, well_formed, AuthorId, CausalContext, ConflictKind, + HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect, + OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, + PendingReason, PreconditionFailureReason, RespellPitchOp, TransactionDescriptor, TupletCompensation, UndoPolicy, UndoTransactionPayload, }; @@ -53,12 +54,26 @@ fn envelope( } fn insert(voice: u64, event: u64, pos: i64) -> OperationPayload { + insert_span( + voice, + event, + RationalTime::from_int(pos as i32), + RationalTime::one(), + ) +} + +fn insert_span( + voice: u64, + event: u64, + position: RationalTime, + duration: RationalTime, +) -> OperationPayload { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { voice: VoiceId::new(ReplicaId(9), voice), staff_instance: StaffInstanceId::new(ReplicaId(9), 0), event: EventId::new(ReplicaId(9), event), - position: MusicalPosition(RationalTime::from_int(pos as i32)), - duration: MusicalDuration::whole(), + position: MusicalPosition(position), + duration: MusicalDuration(duration), pitches: vec![PitchId::new(ReplicaId(9), event)], })) } @@ -186,6 +201,51 @@ fn thousand_envelope_set_reduces_identically_in_ten_orders() { } } +#[test] +fn causal_predecessor_dominates_inverted_cross_replica_hlc() { + let predecessor = envelope(1, 0, 100, CausalContext::new(), None, insert(0, 100, 0)); + let successor = envelope( + 2, + 0, + 1, + CausalContext::new().with_dot(predecessor.id), + None, + insert(0, 101, 1), + ); + + let ordered = canonical_reduction_order(&[&successor, &predecessor]); + assert_eq!( + ordered.iter().map(|env| env.id).collect::>(), + vec![predecessor.id, successor.id] + ); +} + +#[test] +fn absent_vector_predecessor_holds_operation_pending() { + let present = envelope(1, 0, 1, CausalContext::new(), None, insert(0, 99, -1)); + let dependent = envelope( + 2, + 0, + 10, + CausalContext::new().with_seen(ReplicaId(1), 2), + None, + insert(0, 100, 0), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![present.clone(), dependent.clone()]); + let state = set.reduce(); + + assert_eq!(state.effects.len(), 1); + assert_eq!(state.effects[0].0, present.id); + assert_eq!( + state.pending, + vec![( + dependent.id, + PendingReason::MissingCausalPredecessor { missing: op(1, 1) } + )] + ); +} + // --- Transactions (Chapter 6 §6.6). ----------------------------------------- fn declare_tx(replica: u64, counter: u64, physical: i64, tx: TransactionId) -> OperationEnvelope { @@ -269,10 +329,61 @@ fn transaction_with_a_failing_member_conflicts_wholesale() { .any(|r| matches!(r.kind, ConflictKind::TransactionConflict { .. }))); } +#[test] +fn failed_transaction_rolls_back_member_generated_conflicts() { + let seed = envelope(2, 0, 1, CausalContext::new(), None, insert(0, 100, 0)); + let initial_spelling = envelope( + 2, + 1, + 2, + CausalContext::new().with_seen(ReplicaId(2), 0), + None, + respell(100, 1), + ); + let tx = TransactionId::from_raw(89); + let descriptor = declare_tx(1, 0, 10, tx); + let tx_ctx = CausalContext::new().with_seen(ReplicaId(1), 0); + // This member conflicts with the concurrent initial spelling. + let conflicting = envelope(1, 1, 11, tx_ctx.clone(), Some(tx), respell(100, 2)); + // This member fails, forcing the whole block to roll back. + let failing = envelope( + 1, + 2, + 12, + tx_ctx, + Some(tx), + OperationPayload::Primitive(OperationKind::DeleteEvent(epiphany_ops::DeleteEventOp { + event: EventId::new(ReplicaId(9), 999), + tuplet_compensation: TupletCompensation::NotInTuplet, + })), + ); + + let mut set = OperationSet::new(); + set.accept_all(vec![ + seed, + initial_spelling, + descriptor, + conflicting, + failing, + ]); + let state = set.reduce(); + + assert_eq!( + state.spellings.get(&PitchId::new(ReplicaId(9), 100)), + Some(&ContentHash([1; 32])) + ); + assert_eq!(state.conflicts.records().len(), 1); + assert!(matches!( + state.conflicts.records()[0].kind, + ConflictKind::TransactionConflict { .. } + )); +} + #[test] fn member_without_its_descriptor_is_a_transaction_conflict() { // A member that declares membership in a transaction whose descriptor is - // absent from the set is malformed against the transaction model. + // absent from the set is malformed against the transaction model. This + // transaction-specific conflict takes precedence over ordinary pending. let tx = TransactionId::from_raw(99); let ctx = CausalContext::new().with_seen(ReplicaId(1), 0); let orphan = envelope(1, 1, 11, ctx, Some(tx), insert(0, 100, 0)); @@ -280,6 +391,7 @@ fn member_without_its_descriptor_is_a_transaction_conflict() { let mut set = OperationSet::new(); set.accept(orphan.clone()); let state = set.reduce(); + assert!(state.pending.is_empty()); let eff = state .effects .iter() @@ -315,6 +427,102 @@ fn hlc_monotonicity_violation_excludes_the_segment() { ))); } +#[test] +fn causally_ordered_same_position_insert_is_not_promoted() { + let first = envelope(1, 0, 10, CausalContext::new(), None, insert(0, 100, 0)); + let second = envelope( + 1, + 1, + 11, + CausalContext::new().with_seen(ReplicaId(1), 0), + None, + insert(0, 101, 0), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![first, second.clone()]); + let state = set.reduce(); + + let effect = state + .effects + .iter() + .find(|(id, _)| *id == second.id) + .map(|(_, effect)| effect); + assert_eq!( + effect, + Some(&OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::EventDurationInvalid, + }, + }) + ); +} + +#[test] +fn concurrent_partial_interval_overlap_promotes_the_greater_id() { + let first = envelope( + 1, + 0, + 10, + CausalContext::new(), + None, + insert_span(0, 100, RationalTime::zero(), RationalTime::one()), + ); + let second = envelope( + 2, + 0, + 10, + CausalContext::new(), + None, + insert_span( + 0, + 200, + RationalTime::new(1, 2).unwrap(), + RationalTime::one(), + ), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![first, second.clone()]); + let state = set.reduce(); + + assert!(matches!( + state + .effects + .iter() + .find(|(id, _)| *id == second.id) + .map(|(_, effect)| effect), + Some(OperationEffect::AppliedWithRepair { repairs }) + if repairs.iter().any(|repair| matches!(repair.kind, epiphany_ops::RepairKind::VoicePromoted { .. })) + )); +} + +#[test] +fn adjacent_half_open_intervals_do_not_collide() { + let first = envelope( + 1, + 0, + 10, + CausalContext::new(), + None, + insert_span(0, 100, RationalTime::zero(), RationalTime::one()), + ); + let second = envelope( + 2, + 0, + 10, + CausalContext::new(), + None, + insert_span(0, 200, RationalTime::one(), RationalTime::one()), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![first, second]); + let state = set.reduce(); + + assert!(state + .effects + .iter() + .all(|(_, effect)| *effect == OperationEffect::Applied)); +} + // --- Forward undo (Chapter 6 §6.8). ----------------------------------------- #[test] diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs new file mode 100644 index 0000000..9867519 --- /dev/null +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -0,0 +1,542 @@ +//! M2 regression coverage for reducing operations onto Agent B's real score +//! graph rather than only the Chapter 6 bookkeeping projection. + +use epiphany_core::{ + check_invariants, derive_promoted_voice_id, AnchorOffset, EventId, MusicalDuration, + MusicalPosition, OperationId, PitchId, RationalTime, RegionEdge, RegionTimeModel, ReplicaId, + Score, SlurId, StaffInstanceId, TimeAnchor, TransactionId, TypedObjectId, VoiceId, VoiceOrigin, + WallClockTime, +}; +use epiphany_ops::{ + AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp, + CrossCuttingRef, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason, OperationEffect, + OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, + PositionRemapping, PreconditionFailureReason, RegionTimeModelTag, SetUserSystemBreakOp, + TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy, + UndoTransactionPayload, +}; + +fn envelope( + replica: u64, + counter: u64, + physical: i64, + context: CausalContext, + transaction: Option, + payload: OperationPayload, +) -> OperationEnvelope { + let id = OperationId::new(ReplicaId(replica), counter); + OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id), + causal_context: context, + transaction, + payload, + } +} + +fn target(score: &Score) -> (StaffInstanceId, VoiceId) { + let instance = &score.canvas.regions[0].staff_instances()[0]; + (instance.id, instance.voices[0].id) +} + +fn insert( + staff_instance: StaffInstanceId, + voice: VoiceId, + event: EventId, + pitch: PitchId, + position: i32, +) -> OperationPayload { + OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { + voice, + staff_instance, + event, + position: MusicalPosition(RationalTime::from_int(position)), + duration: MusicalDuration::whole(), + pitches: vec![pitch], + })) +} + +fn voice(score: &Score, id: VoiceId) -> Option<&epiphany_core::Voice> { + score + .voices() + .find_map(|(_, _, voice)| (voice.id == id).then_some(voice)) +} + +#[test] +fn insert_materializes_in_the_real_arena_and_voice() { + let base = epiphany_core::generators::valid_score(100); + let (staff_instance, target_voice) = target(&base); + let event = EventId::new(ReplicaId(50), 0); + let pitch = PitchId::new(ReplicaId(50), 1); + let op = envelope( + 50, + 0, + 10, + CausalContext::new(), + None, + insert(staff_instance, target_voice, event, pitch, 100), + ); + let mut set = OperationSet::new(); + set.accept(op); + + let result = set.reduce_onto(&base); + + assert!(result.score.events.contains(event)); + assert!(voice(&result.score, target_voice) + .expect("target voice remains present") + .events + .contains(&event)); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn graph_reduction_rejects_an_unknown_voice_without_creating_it() { + let base = epiphany_core::generators::valid_score(101); + let (staff_instance, _) = target(&base); + let missing_voice = VoiceId::new(ReplicaId(51), 99); + let event = EventId::new(ReplicaId(51), 0); + let op = envelope( + 51, + 0, + 10, + CausalContext::new(), + None, + insert( + staff_instance, + missing_voice, + event, + PitchId::new(ReplicaId(51), 1), + 100, + ), + ); + let mut set = OperationSet::new(); + set.accept(op.clone()); + + let result = set.reduce_onto(&base); + + assert!(!result.score.events.contains(event)); + assert!(voice(&result.score, missing_voice).is_none()); + assert_eq!( + result.state.effects, + vec![( + op.id, + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::VoiceMissing, + }, + }, + )] + ); +} + +#[test] +fn concurrent_overlap_materializes_an_invariant_clean_promoted_voice() { + let base = epiphany_core::generators::valid_score(102); + let (staff_instance, target_voice) = target(&base); + let winner = envelope( + 52, + 0, + 10, + CausalContext::new(), + None, + insert( + staff_instance, + target_voice, + EventId::new(ReplicaId(52), 10), + PitchId::new(ReplicaId(52), 11), + 100, + ), + ); + let loser = envelope( + 53, + 0, + 10, + CausalContext::new(), + None, + insert( + staff_instance, + target_voice, + EventId::new(ReplicaId(53), 10), + PitchId::new(ReplicaId(53), 11), + 100, + ), + ); + let promoted = derive_promoted_voice_id(staff_instance, target_voice, winner.id, loser.id); + let mut set = OperationSet::new(); + set.accept_all(vec![loser.clone(), winner.clone()]); + let mut reversed = OperationSet::new(); + reversed.accept_all(vec![winner.clone(), loser.clone()]); + + let result = set.reduce_onto(&base); + assert_eq!(result, reversed.reduce_onto(&base)); + let promoted_voice = voice(&result.score, promoted).expect("promoted voice was materialized"); + + assert!(promoted_voice + .events + .contains(&EventId::new(ReplicaId(53), 10))); + assert_eq!( + promoted_voice.origin, + VoiceOrigin::SystemPromoted { + winning_operation: winner.id, + losing_operation: loser.id, + original_voice: target_voice, + } + ); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn delete_removes_the_event_and_records_graph_tombstones() { + let base = epiphany_core::generators::valid_score(103); + let (staff_instance, target_voice) = target(&base); + let event = EventId::new(ReplicaId(54), 10); + let pitch = PitchId::new(ReplicaId(54), 11); + let insertion = envelope( + 54, + 0, + 10, + CausalContext::new(), + None, + insert(staff_instance, target_voice, event, pitch, 100), + ); + let deletion = envelope( + 54, + 1, + 11, + CausalContext::new().with_seen(ReplicaId(54), 0), + None, + OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp { + event, + tuplet_compensation: TupletCompensation::NotInTuplet, + })), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![deletion, insertion]); + + let result = set.reduce_onto(&base); + + assert!(!result.score.events.contains(event)); + assert!(!voice(&result.score, target_voice) + .expect("target voice remains present") + .events + .contains(&event)); + assert!(result.score.tombstoned_events.contains(&event)); + assert!(result.score.tombstoned_pitches.contains(&pitch)); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn failed_transaction_rolls_back_real_graph_mutations() { + let base = epiphany_core::generators::valid_score(104); + let (staff_instance, target_voice) = target(&base); + let tx = TransactionId::from_raw(77); + let descriptor = envelope( + 55, + 0, + 10, + CausalContext::new(), + Some(tx), + OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor { + id: tx, + label: String::from("graph rollback"), + category: Some(TransactionCategory::NoteEntry), + })), + ); + let tx_context = CausalContext::new().with_seen(ReplicaId(55), 0); + let inserted_event = EventId::new(ReplicaId(55), 10); + let insertion = envelope( + 55, + 1, + 11, + tx_context.clone(), + Some(tx), + insert( + staff_instance, + target_voice, + inserted_event, + PitchId::new(ReplicaId(55), 11), + 100, + ), + ); + let failing = envelope( + 55, + 2, + 12, + tx_context, + Some(tx), + OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp { + event: EventId::new(ReplicaId(55), 999), + tuplet_compensation: TupletCompensation::NotInTuplet, + })), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![failing, insertion, descriptor]); + + let result = set.reduce_onto(&base); + + assert!(!result.score.events.contains(inserted_event)); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn forward_undo_removes_transaction_mints_from_the_graph() { + let base = epiphany_core::generators::valid_score(105); + let (staff_instance, target_voice) = target(&base); + let tx = TransactionId::from_raw(78); + let descriptor = envelope( + 56, + 0, + 10, + CausalContext::new(), + Some(tx), + OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor { + id: tx, + label: String::from("graph undo"), + category: None, + })), + ); + let inserted_event = EventId::new(ReplicaId(56), 10); + let insertion = envelope( + 56, + 1, + 11, + CausalContext::new().with_seen(ReplicaId(56), 0), + Some(tx), + insert( + staff_instance, + target_voice, + inserted_event, + PitchId::new(ReplicaId(56), 11), + 100, + ), + ); + let undo = envelope( + 56, + 2, + 12, + CausalContext::new().with_seen(ReplicaId(56), 1), + None, + OperationPayload::UndoTransaction(UndoTransactionPayload { + target: tx, + policy: UndoPolicy::StrictInverse, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![undo, insertion, descriptor]); + + let result = set.reduce_onto(&base); + + assert!(!result.score.events.contains(inserted_event)); + assert!(result.score.tombstoned_events.contains(&inserted_event)); + assert!(matches!( + result + .state + .objects + .get(&TypedObjectId::Event(inserted_event)), + Some(epiphany_ops::ObjectState::Tombstoned { .. }) + )); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn system_break_lww_state_is_materialized_in_the_region() { + let base = epiphany_core::generators::valid_score(106); + let region = base.canvas.regions[0].id; + let position = MusicalPosition(RationalTime::from_int(8)); + let operation = envelope( + 57, + 0, + 10, + CausalContext::new(), + None, + OperationPayload::Primitive(OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { + region, + anchor: position.clone(), + present: true, + })), + ); + let mut set = OperationSet::new(); + set.accept(operation); + + let result = set.reduce_onto(&base); + let breaks = &result.score.canvas.regions[0] + .content + .staff_based() + .expect("fixture is staff based") + .user_system_breaks; + + assert_eq!( + breaks, + &[TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(position.0)), + }] + ); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn migration_computes_incompatible_events_from_the_graph() { + let base = epiphany_core::generators::valid_score(107); + let region = base.canvas.regions[0].id; + let operation = envelope( + 58, + 0, + 10, + CausalContext::new(), + None, + OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( + ChangeRegionTimeModelOp { + region, + new_time_model: RegionTimeModelTag::Proportional, + declared_incompatible: Vec::new(), + remapping: PositionRemapping::PreserveTime, + }, + )), + ); + let mut set = OperationSet::new(); + set.accept(operation); + + let result = set.reduce_onto(&base); + + assert_eq!(result.score, base); + assert!(result + .state + .conflicts + .records() + .iter() + .any(|record| matches!(record.kind, ConflictKind::TimeModelMigrationFailure { .. }))); +} + +#[test] +fn create_cross_cutting_materializes_supported_graph_structures() { + let base = epiphany_core::generators::valid_score(108); + let endpoints = base.canvas.regions[0].staff_instances()[0].voices[0].events[..2].to_vec(); + let slur = SlurId::new(ReplicaId(59), 10); + let operation = envelope( + 59, + 0, + 10, + CausalContext::new(), + None, + OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingRef { + id: TypedObjectId::Slur(slur), + endpoints: endpoints + .iter() + .copied() + .map(TypedObjectId::Event) + .collect(), + }, + })), + ); + let mut set = OperationSet::new(); + set.accept(operation); + + let result = set.reduce_onto(&base); + + assert!(result + .score + .cross_cutting + .slurs + .iter() + .any(|value| value.id == slur)); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn causally_ordered_time_migrations_do_not_conflict() { + let base = epiphany_core::generators::valid_score(109); + let region = base.canvas.regions[0].id; + let first = envelope( + 60, + 0, + 10, + CausalContext::new(), + None, + OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( + ChangeRegionTimeModelOp { + region, + new_time_model: RegionTimeModelTag::Aleatoric, + declared_incompatible: Vec::new(), + remapping: PositionRemapping::PreserveTime, + }, + )), + ); + let second = envelope( + 60, + 1, + 11, + CausalContext::new().with_seen(ReplicaId(60), 0), + None, + OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( + ChangeRegionTimeModelOp { + region, + new_time_model: RegionTimeModelTag::Metric, + declared_incompatible: Vec::new(), + remapping: PositionRemapping::PreserveTime, + }, + )), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![second, first]); + + let result = set.reduce_onto(&base); + + assert!(result.state.conflicts.is_empty()); + assert!(matches!( + result.score.canvas.regions[0].time_model, + RegionTimeModel::Metric(_) + )); + assert!(check_invariants(&result.score).is_empty()); +} + +#[test] +fn graph_materialization_is_deterministic_across_base_corpus_and_delivery_order() { + for seed in 0..64_u64 { + let base = epiphany_core::generators::valid_score(1_000 + seed); + let (staff_instance, target_voice) = target(&base); + let winner = envelope( + 0xC001, + seed, + 10, + CausalContext::new(), + None, + insert( + staff_instance, + target_voice, + EventId::new(ReplicaId(0xC001), seed), + PitchId::new(ReplicaId(0xC001), seed), + 100, + ), + ); + let loser = envelope( + 0xC002, + seed, + 10, + CausalContext::new(), + None, + insert( + staff_instance, + target_voice, + EventId::new(ReplicaId(0xC002), seed), + PitchId::new(ReplicaId(0xC002), seed), + 100, + ), + ); + let mut forward = OperationSet::new(); + forward.accept_all(vec![winner.clone(), loser.clone()]); + let mut backward = OperationSet::new(); + backward.accept_all(vec![loser, winner]); + + let expected = forward.reduce_onto(&base); + let actual = backward.reduce_onto(&base); + assert_eq!(actual, expected, "base seed {seed}"); + assert!( + check_invariants(&actual.score).is_empty(), + "base seed {seed}" + ); + } +} diff --git a/spec/core_spec.tex b/spec/core_spec.tex index ef7c23d..0fbfe7c 100644 --- a/spec/core_spec.tex +++ b/spec/core_spec.tex @@ -4342,12 +4342,12 @@ pub enum VoiceOrigin { Imported { format: ForeignFormatId }, /// System-promoted to resolve a concurrent-edit collision. - /// The cause operation is the InsertEvent (or similar) whose - /// reduction required voice allocation. The original_voice is - /// the voice into which the user had intended to insert; the - /// promoted voice carries the event that lost the collision. + /// The original_voice is the voice into which the user had + /// intended to insert; the promoted voice carries the event + /// authored by losing_operation. SystemPromoted { - cause: OperationId, + winning_operation: OperationId, + losing_operation: OperationId, original_voice: VoiceId, }, }