epiphany/crates/epiphany-ops/src/reduce.rs

4257 lines
170 KiB
Rust

//! The canonical reduction (Chapter 6 §"The Canonical Reduction").
//!
//! The materialized score state is the deterministic reduction of the operation
//! set. This module is the determinism heart of the architecture:
//!
//! * [`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)`. 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
//! §"Canonical score determinism"; v0 acceptance criteria 1 and 5).
//!
//! The reduction handles, deterministically: equivocated-slot and
//! anomalous-segment exclusion, the missing-causal-predecessor rule (dependents
//! held pending), atomic transactions with the descriptor-precedence rule,
//! tombstones with re-anchoring, conflict generation with content-derived ids,
//! LWW-advisory fields, and forward (compensating) undo.
//!
//! ## Prototype scope
//!
//! The per-kind reduction implements the representative operations of §6.10
//! 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, AnchorOffset, CanonicalValue, Event, EventDuration, EventId,
EventPosition, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
PitchSpelling, RegionEdge, RegionId, RegionTimeModel, Score, SpellingAttachment,
SpellingDirective, SpellingScope, SpellingSource, StaffInstance, StaffInstanceId, TimeAnchor,
TransactionId, TypedObjectId, Voice, VoiceId, VoiceOrigin,
};
use epiphany_determinism::CanonicalEncode;
use crate::anomaly::{detect_replica_anomalies, IntegrityAnomaly, IntegrityAnomalyKind};
use crate::conflict::{
ConflictKind, ConflictRecord, ConflictRegistry, FieldPath, ResolutionAction,
};
use crate::effect::{
NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, RepairKind,
RepairRecord, TupletCompensationKind,
};
use crate::encode::{push_canon, push_len, push_lp_bytes, push_u8_bool};
use crate::envelope::OperationEnvelope;
use crate::opset::OperationSet;
use crate::payload::{
CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateVoiceOp, CrossCuttingValue,
DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp,
DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp,
ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload,
RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetUserPageBreakOp, TransposeOp,
TupletCompensation,
};
use crate::undo::{UndoPolicy, UndoTransactionPayload};
/// Orders operation envelopes into the canonical reduction order (Chapter 6
/// §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.
/// 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 len = envelopes.len();
let mut indegree = vec![0usize; len];
let mut successors = vec![Vec::<usize>::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
}
/// The state of an object's identifier in the materialized graph (Chapter 6
/// §"Object Existence and Tombstones"). `Unknown` is not represented: an
/// identifier the reduction has never seen simply has no entry.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum ObjectState {
/// Resolves to a current object.
Live,
/// Resolves to a deletion record carrying the deleting and minting
/// operations. Tombstones are never resurrected except by an explicit
/// inverse-of-delete (Chapter 6 §6.3.4).
Tombstoned {
deleted_by: OperationId,
minted_by: OperationId,
},
}
impl ObjectState {
fn discriminant(&self) -> u8 {
match self {
ObjectState::Live => 0,
ObjectState::Tombstoned { .. } => 1,
}
}
}
impl CanonicalEncode for ObjectState {
fn encode_canonical(&self, out: &mut Vec<u8>) {
out.push(self.discriminant());
if let ObjectState::Tombstoned {
deleted_by,
minted_by,
} = self
{
push_canon(out, deleted_by);
push_canon(out, minted_by);
}
}
}
/// Why an operation is held pending rather than reduced (Chapter 6 §6.5, §6.6).
/// A pending operation is retained in the operation set but produces no
/// canonical effect until its blocking cause is resolved.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum PendingReason {
/// A causal predecessor is absent from the operation set entirely.
MissingCausalPredecessor { missing: OperationId },
/// A causal predecessor's slot is equivocated.
DependsOnEquivocated { on: OperationId },
/// A causal predecessor was excluded by an anomalous-replica segment.
DependsOnExcluded { on: OperationId },
/// A causal predecessor is itself held pending.
DependsOnPending { on: OperationId },
}
impl PendingReason {
fn discriminant(&self) -> u8 {
match self {
PendingReason::MissingCausalPredecessor { .. } => 0,
PendingReason::DependsOnEquivocated { .. } => 1,
PendingReason::DependsOnExcluded { .. } => 2,
PendingReason::DependsOnPending { .. } => 3,
}
}
fn blocker(&self) -> OperationId {
match self {
PendingReason::MissingCausalPredecessor { missing } => *missing,
PendingReason::DependsOnEquivocated { on }
| PendingReason::DependsOnExcluded { on }
| PendingReason::DependsOnPending { on } => *on,
}
}
}
impl CanonicalEncode for PendingReason {
fn encode_canonical(&self, out: &mut Vec<u8>) {
out.push(self.discriminant());
push_canon(out, &self.blocker());
}
}
/// The canonical materialized state produced by the reduction (Chapter 6 §6.3).
/// Every field is in a normative total order, so [`MaterializedState::canonical_bytes`]
/// is byte-identical across any permutation of the input operation set.
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct MaterializedState {
/// The effect log: one [`OperationEffect`] per reduced operation, in
/// canonical reduction order (Chapter 6 §6.3.2). Part of canonical state.
pub effects: Vec<(OperationId, OperationEffect)>,
/// The conflict registry, ordered by `ConflictId`.
pub conflicts: ConflictRegistry,
/// The integrity-anomaly register, ordered by `IntegrityAnomalyId`.
pub anomalies: Vec<IntegrityAnomaly>,
/// Object existence (live/tombstoned), keyed by `TypedObjectId`.
pub objects: BTreeMap<TypedObjectId, ObjectState>,
/// Current resolved spelling per pitch (the `RespellPitch` field).
pub spellings: BTreeMap<PitchId, PitchSpelling>,
/// User system-break preferences (LWW advisory), keyed by region+anchor.
pub breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
/// User page-break preferences (LWW advisory), keyed by region+anchor (the
/// page-break sibling of [`MaterializedState::breaks`], M2d).
pub page_breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
/// Operations held pending, ordered by `OperationId`.
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
/// bytes (the determinism property; v0 acceptance criteria 1 and 5).
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut out = Vec::new();
// Effects, in reduction order.
push_len(&mut out, self.effects.len());
for (id, eff) in &self.effects {
push_canon(&mut out, id);
let mut scratch = Vec::new();
eff.encode_canonical(&mut scratch);
crate::encode::push_lp_bytes(&mut out, &scratch);
}
// Conflict registry (ConflictId order).
self.conflicts.encode_canonical(&mut out);
// Anomaly register (IntegrityAnomalyId order).
push_len(&mut out, self.anomalies.len());
for a in &self.anomalies {
let mut scratch = Vec::new();
a.encode_canonical(&mut scratch);
crate::encode::push_lp_bytes(&mut out, &scratch);
}
// Objects (TypedObjectId order).
push_len(&mut out, self.objects.len());
for (id, state) in &self.objects {
push_canon(&mut out, id);
state.encode_canonical(&mut out);
}
// Spellings (PitchId order). The value is the full PitchSpelling (v1),
// encoded behind a u32 length prefix via its canonical value bytes.
push_len(&mut out, self.spellings.len());
for (pitch, spelling) in &self.spellings {
push_canon(&mut out, pitch);
push_lp_bytes(&mut out, &spelling.canonical_bytes());
}
// Breaks (region+anchor order).
push_len(&mut out, self.breaks.len());
for ((region, anchor), present) in &self.breaks {
push_canon(&mut out, region);
push_canon(&mut out, anchor);
push_u8_bool(&mut out, *present);
}
// Page breaks (region+anchor order) — sibling of breaks (M2d).
push_len(&mut out, self.page_breaks.len());
for ((region, anchor), present) in &self.page_breaks {
push_canon(&mut out, region);
push_canon(&mut out, anchor);
push_u8_bool(&mut out, *present);
}
// Pending (OperationId order).
push_len(&mut out, self.pending.len());
for (id, reason) in &self.pending {
push_canon(&mut out, id);
reason.encode_canonical(&mut out);
}
out
}
/// Decodes the exact inverse of [`MaterializedState::canonical_bytes`].
///
/// The decoder validates all nested tags, lengths, primitive values,
/// canonical ordering, and trailing-byte discipline. Structurally valid but
/// non-canonical bytes are rejected rather than silently normalized.
pub fn decode_canonical(bytes: &[u8]) -> Result<Self, crate::MaterializedDecodeError> {
crate::decode::decode_materialized_state(bytes)
}
/// Whether the materialized state is free of conflicts and anomalies and
/// has no pending operations — a clean reduction.
pub fn is_clean(&self) -> bool {
self.conflicts.is_empty() && self.anomalies.is_empty() && self.pending.is_empty()
}
}
/// Reduces an [`OperationSet`] to its canonical [`MaterializedState`].
pub fn reduce_operation_set(op_set: &OperationSet) -> MaterializedState {
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.
struct Reducer<'a> {
op_set: &'a OperationSet,
// Canonical results.
objects: BTreeMap<TypedObjectId, ObjectState>,
spellings: BTreeMap<PitchId, PitchSpelling>,
breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
page_breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
conflicts: ConflictRegistry,
effects: Vec<(OperationId, OperationEffect)>,
anomalies: BTreeMap<epiphany_core::IntegrityAnomalyId, IntegrityAnomaly>,
// Transient indices.
minted_by: BTreeMap<TypedObjectId, OperationId>,
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
voice_occupancy: BTreeMap<VoiceId, Vec<(MusicalPosition, MusicalDuration, EventId)>>,
last_respell: BTreeMap<PitchId, OperationId>,
// LWW working state for the Group-1 field-overwrite ops: the last modifier
// and the value it wrote, used to detect concurrent *differing* modifications
// (the resolved value itself lives in the graph, not in MaterializedState).
last_event_modify: BTreeMap<EventId, (OperationId, Event)>,
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
// LWW working state for ModifyCrossCutting (Group 2), mirroring the leaf-field
// modify maps above: last modifier + value it wrote, keyed by structure id.
last_cross_cutting_modify: BTreeMap<TypedObjectId, (OperationId, CrossCuttingValue)>,
// LWW working state for SetMetricGrid (Group 4, M2d): the last writer and the
// grid it wrote, used to detect concurrent *differing* grids (the resolved
// value lives in the graph). SetMetadata is advisory LWW — no working state,
// no conflict — so the latest write in canonical order silently wins.
last_metric_grid: BTreeMap<RegionId, (OperationId, Option<MetricGrid>)>,
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
// Live child sets for the structural-container empty-only delete (Group 3):
// a region's live staff instances, and a staff instance's live voices. (A
// voice's live events are read from `voice_occupancy`.)
region_instances: BTreeMap<RegionId, BTreeSet<StaffInstanceId>>,
instance_voices: BTreeMap<StaffInstanceId, BTreeSet<VoiceId>>,
// Regions whose content carries a staff-based slot (staff-based or hybrid),
// and so can hold a metric grid or user break. FreeGraphic regions cannot.
// Tracking this lets SetMetricGrid / SetUserPageBreak / SetUserSystemBreak
// reach the same precondition verdict for any region *represented in reducer
// state* (those an op stream creates/deletes) whether or not a graph is
// present. A base-only region exists in reducer state solely after
// `seed_from_graph` (reduce_onto), so a base-free reduce() that never sees it
// can still diverge on a base-region target — the corpus targets only
// op-created regions, where the two agree.
staff_based_regions: BTreeSet<RegionId>,
migrated_regions: BTreeSet<RegionId>,
region_migrator: BTreeMap<RegionId, OperationId>,
descriptors: BTreeMap<TransactionId, OperationId>,
// Losing insert -> (promoted voice, winning insert).
promotion: BTreeMap<OperationId, (VoiceId, OperationId)>,
tx_minted: BTreeMap<TransactionId, Vec<TypedObjectId>>,
current_tx: Option<TransactionId>,
graph: Option<Score>,
}
/// A snapshot of the working state, for atomic transaction rollback.
struct WorkingSnapshot {
objects: BTreeMap<TypedObjectId, ObjectState>,
spellings: BTreeMap<PitchId, PitchSpelling>,
breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
page_breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
conflicts: ConflictRegistry,
minted_by: BTreeMap<TypedObjectId, OperationId>,
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
voice_occupancy: BTreeMap<VoiceId, Vec<(MusicalPosition, MusicalDuration, EventId)>>,
last_respell: BTreeMap<PitchId, OperationId>,
last_event_modify: BTreeMap<EventId, (OperationId, Event)>,
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
last_cross_cutting_modify: BTreeMap<TypedObjectId, (OperationId, CrossCuttingValue)>,
last_metric_grid: BTreeMap<RegionId, (OperationId, Option<MetricGrid>)>,
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
region_instances: BTreeMap<RegionId, BTreeSet<StaffInstanceId>>,
instance_voices: BTreeMap<StaffInstanceId, BTreeSet<VoiceId>>,
staff_based_regions: BTreeSet<RegionId>,
migrated_regions: BTreeSet<RegionId>,
region_migrator: BTreeMap<RegionId, OperationId>,
descriptors: BTreeMap<TransactionId, OperationId>,
tx_minted: BTreeMap<TransactionId, Vec<TypedObjectId>>,
graph: Option<Score>,
}
/// The precondition no-op a structural create or delete returns when a container
/// is non-empty where the operation requires it empty (a create carrying children,
/// or a delete of a container with live children).
fn container_not_empty() -> OperationEffect {
OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::ContainerNotEmpty,
},
}
}
/// Apply a user break to a region's break list under the canonical LWW key — the
/// anchor's resolved musical position. Any existing anchor resolving to that same
/// position is dropped first, so two anchors at one position never both persist,
/// then the new anchor is pushed iff the break is present. The graph break list
/// then matches the resolved-position-keyed ledger map.
fn apply_break_lww(breaks: &mut Vec<TimeAnchor>, anchor: &TimeAnchor, present: bool) {
let resolved = crate::payload::resolved_anchor_position(anchor);
breaks.retain(|existing| crate::payload::resolved_anchor_position(existing) != resolved);
if present {
breaks.push(anchor.clone());
}
}
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.musical_position(),
&a.musical_duration(),
&b.musical_position(),
&b.musical_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
}
/// The verdict on a [`ModifyEvent`](OperationKind::ModifyEvent)'s placement: whether
/// it moves the target event's metric span, and if so whether the move keeps
/// invariant 3 (`VoiceEventsSortedNonOverlap`). A non-metric or same-placement modify
/// is [`Unchanged`](PlacementVerdict::Unchanged) — handled by the existing field-edit
/// path.
enum PlacementVerdict {
/// Same placement, or a non-metric event: nothing to materialize or refuse.
Unchanged,
/// A metric move that would overlap a sibling, or carries a non-positive span.
Refused,
/// A valid metric move to `position`/`duration` within `voice`.
Moved {
voice: VoiceId,
position: MusicalPosition,
duration: MusicalDuration,
},
}
/// The graph value inserted by a value-typed InsertEvent: the carried [`Event`]
/// itself, with its voice rebound to the (possibly system-promoted) target
/// voice. The Operation Catalog (v1) carries the real event, so this is no
/// longer a placeholder reconstruction.
fn graph_event_from_insert(op: &InsertEventOp, target_voice: VoiceId) -> Event {
let mut event = op.event.clone();
event.set_voice(target_voice);
event
}
impl<'a> Reducer<'a> {
fn new(op_set: &'a OperationSet) -> Self {
Reducer {
op_set,
objects: BTreeMap::new(),
spellings: BTreeMap::new(),
breaks: BTreeMap::new(),
page_breaks: BTreeMap::new(),
conflicts: ConflictRegistry::new(),
effects: Vec::new(),
anomalies: BTreeMap::new(),
minted_by: BTreeMap::new(),
event_pitches: BTreeMap::new(),
voice_occupancy: BTreeMap::new(),
last_respell: BTreeMap::new(),
last_event_modify: BTreeMap::new(),
last_pitch_modify: BTreeMap::new(),
last_cross_cutting_modify: BTreeMap::new(),
last_metric_grid: BTreeMap::new(),
structures: BTreeMap::new(),
region_instances: BTreeMap::new(),
instance_voices: BTreeMap::new(),
staff_based_regions: BTreeSet::new(),
migrated_regions: BTreeSet::new(),
region_migrator: BTreeMap::new(),
descriptors: BTreeMap::new(),
promotion: BTreeMap::new(),
tx_minted: BTreeMap::new(),
current_tx: None,
graph: None,
}
}
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);
if region.content.staff_based().is_some() {
self.staff_based_regions.insert(region.id);
}
let instance_set = self.region_instances.entry(region.id).or_default();
for instance in region.staff_instances() {
instance_set.insert(instance.id);
}
for instance in region.staff_instances() {
self.objects
.insert(TypedObjectId::StaffInstance(instance.id), ObjectState::Live);
let voice_set = self.instance_voices.entry(instance.id).or_default();
for voice in &instance.voices {
voice_set.insert(voice.id);
}
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 {
let id = TypedObjectId::Spanner(spanner.id);
self.objects.insert(id, ObjectState::Live);
// Record the spanner's event-anchored endpoints so a later event
// tombstone re-anchors it through the same rule table as a created
// spanner (keeping the graph and ledger consistent on delete).
self.structures.insert(
id,
[&spanner.start, &spanner.end]
.into_iter()
.filter_map(|anchor| match anchor {
TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)),
_ => None,
})
.collect(),
);
}
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<Score>) {
let singles = self.op_set.single_envelopes();
let equivocated: BTreeSet<OperationId> =
self.op_set.equivocated_ids().into_iter().collect();
// 1. HLC monotonicity: exclude anomalous segments.
let segments = detect_replica_anomalies(&singles);
let mut excluded: BTreeSet<OperationId> = BTreeSet::new();
for seg in &segments {
excluded.extend(seg.excluded.iter().copied());
self.record_anomaly(IntegrityAnomalyKind::ReplicaStreamQuarantined {
replica: seg.replica,
first_bad_counter: seg.first_bad_counter,
});
}
for id in &equivocated {
self.record_anomaly(IntegrityAnomalyKind::OperationSlotEquivocated {
operation_id: *id,
});
}
// 2. Reducible candidates = Single slots minus excluded.
let reducible: Vec<&OperationEnvelope> = singles
.iter()
.copied()
.filter(|e| !excluded.contains(&e.id))
.collect();
let reducible_ids: BTreeSet<OperationId> = reducible.iter().map(|e| e.id).collect();
let declared_transactions: BTreeSet<TransactionId> = 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,
&declared_transactions,
);
let active: Vec<&OperationEnvelope> = reducible
.iter()
.copied()
.filter(|e| !pending.contains_key(&e.id))
.collect();
// 4. Voice-promotion pre-pass (order-independent assignment).
self.compute_promotions(&active);
// 5. Walk active ops in canonical reduction order; group transactions.
let order = canonical_reduction_order(&active);
let tx_members = transaction_members(&active);
let mut processed: BTreeSet<OperationId> = BTreeSet::new();
for env in &order {
if processed.contains(&env.id) {
continue;
}
if let Some(tx) = member_transaction(env) {
let members = tx_members.get(&tx).cloned().unwrap_or_default();
self.reduce_transaction_block(tx, &members);
processed.extend(members.iter().map(|m| m.id));
} else {
let effect = self.apply(env);
self.effects.push((env.id, effect));
processed.insert(env.id);
}
}
let mut pending_vec: Vec<(OperationId, PendingReason)> = pending.into_iter().collect();
pending_vec.sort_by_key(|(id, _)| *id);
let graph = self.graph.take();
let state = MaterializedState {
effects: self.effects,
conflicts: self.conflicts,
anomalies: self.anomalies.into_values().collect(),
objects: self.objects,
spellings: self.spellings,
breaks: self.breaks,
page_breaks: self.page_breaks,
pending: pending_vec,
};
(state, graph)
}
fn record_anomaly(&mut self, kind: IntegrityAnomalyKind) {
let a = IntegrityAnomaly::new(kind);
self.anomalies.entry(a.id).or_insert(a);
}
fn env_of(&self, id: OperationId) -> Option<&'a OperationEnvelope> {
self.op_set.slot(id).and_then(|s| s.single())
}
// --- Voice promotion pre-pass (Chapter 6 §6.10 InsertEvent). ------------
fn compute_promotions(&mut self, active: &[&OperationEnvelope]) {
// Bucket inserts by target voice. Bucketing by `op.voice` alone realizes
// the spec's `(staff_instance, original_voice)` key: a VoiceId is
// globally unique and (Invariant 5) belongs to exactly one staff
// instance, so the voice id alone determines the pair. Promotion applies
// only to concurrent operations whose half-open duration intervals overlap.
let mut buckets: BTreeMap<VoiceId, Vec<&OperationEnvelope>> = BTreeMap::new();
for env in active {
if let OperationPayload::Primitive(OperationKind::InsertEvent(op)) = &env.payload {
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;
}
// 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 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.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 = &region.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);
}
let event_id = op.event_id();
if score.events.contains(event_id)
|| score.tombstoned_events.contains(&event_id)
|| op.pitch_ids().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_id()],
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_id());
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 { rest } => {
if score.events.contains(rest.id)
|| score.tombstoned_events.contains(&rest.id)
|| event.duration() != &rest.duration
{
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<PitchId> = 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 { rest } => {
let new_rest = rest.id;
for tuplet in &mut score.cross_cutting.tuplets {
for member in &mut tuplet.members {
if *member == op.event {
*member = new_rest;
}
}
}
// The value-typed payload (v1) carries the replacement Rest; it
// is placed at the deleted event's voice and position.
let mut replacement = rest.clone();
replacement.voice = voice_id;
replacement.position = event.position().clone();
score
.events
.insert(Event::Rest(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
});
// Slurs and spanners follow the bookkeeping re-anchoring rule: an
// endpoint-deleted structure re-anchors to its surviving endpoint while
// one survives, and cascade-deletes only when none does (Chapter 6 §6.5;
// matching `reanchor_for_tombstone`, so the graph and the ledger agree on
// the structure's existence). A re-anchored two-endpoint structure
// collapses onto the survivor — degenerate but reference-clean (both
// endpoints stay live); proximity-aware re-anchoring is deferred (P11-C5).
let slurs = std::mem::take(&mut score.cross_cutting.slurs);
let kept_slurs: Vec<_> = slurs
.into_iter()
.filter_map(|mut slur| {
let start_hit = slur.start_event == op.event;
let end_hit = slur.end_event == op.event;
if !start_hit && !end_hit {
return Some(slur);
}
let survivor = if start_hit {
slur.end_event
} else {
slur.start_event
};
if survivor != op.event && score.events.contains(survivor) {
slur.start_event = if start_hit {
survivor
} else {
slur.start_event
};
slur.end_event = if end_hit { survivor } else { slur.end_event };
Some(slur)
} else {
None
}
})
.collect();
score.cross_cutting.slurs = kept_slurs;
let spanners = std::mem::take(&mut score.cross_cutting.spanners);
let kept_spanners: Vec<_> = spanners
.into_iter()
.filter_map(|mut spanner| {
let start_hit =
matches!(spanner.start, TimeAnchor::Event { id, .. } if id == op.event);
let end_hit = matches!(spanner.end, TimeAnchor::Event { id, .. } if id == op.event);
if !start_hit && !end_hit {
return Some(spanner);
}
// The survivor is the *other* anchor's event, if it is an event
// anchor on a live event; otherwise the spanner cascade-deletes.
let other = if start_hit {
&spanner.end
} else {
&spanner.start
};
let survivor = match other {
TimeAnchor::Event { id, .. }
if *id != op.event && score.events.contains(*id) =>
{
Some(*id)
}
_ => None,
};
let to = survivor?;
if start_hit {
if let TimeAnchor::Event { id, .. } = &mut spanner.start {
*id = to;
}
}
if end_hit {
if let TimeAnchor::Event { id, .. } = &mut spanner.end {
*id = to;
}
}
Some(spanner)
})
.collect();
score.cross_cutting.spanners = kept_spanners;
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<EventId> = 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(());
};
// The value-typed payload (v1) carries the real structure with its rich
// fields, so materialization clones it directly rather than rebuilding a
// default from the reference-level projection.
match &op.structure {
CrossCuttingValue::Slur(slur) => score.cross_cutting.slurs.push(slur.clone()),
CrossCuttingValue::Beam(beam) => {
if beam.events.len() < 2 {
return Err(PreconditionFailureReason::TargetMissing);
}
score.cross_cutting.beams.push(beam.clone());
}
CrossCuttingValue::Tie(tie) => score.cross_cutting.ties.push(tie.clone()),
CrossCuttingValue::Spanner(spanner) => {
score.cross_cutting.spanners.push(spanner.clone())
}
}
Ok(())
}
// --- Dispatch. ----------------------------------------------------------
fn apply(&mut self, env: &OperationEnvelope) -> OperationEffect {
match &env.payload {
OperationPayload::Primitive(kind) => match kind {
OperationKind::InsertEvent(op) => self.insert_event(env, op),
OperationKind::DeleteEvent(op) => self.delete_event(env, op),
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.set_user_system_break(op),
OperationKind::DeclareTransaction(desc) => {
self.descriptors.insert(desc.id, env.id);
OperationEffect::Applied
}
// Extension-defined primitive: opaque to the core; recorded as
// applied (the extension realizes its own effect).
OperationKind::Registered(_, _) => OperationEffect::Applied,
OperationKind::ModifyEvent(op) => self.modify_event(env, op),
OperationKind::Transpose(op) => self.transpose(env, op),
OperationKind::InsertIdentifiedPitch(op) => self.insert_identified_pitch(env, op),
OperationKind::DeleteIdentifiedPitch(op) => self.delete_identified_pitch(env, op),
OperationKind::ModifyIdentifiedPitch(op) => self.modify_identified_pitch(env, op),
OperationKind::DeleteCrossCutting(op) => self.delete_cross_cutting(env, op),
OperationKind::ModifyCrossCutting(op) => self.modify_cross_cutting(env, op),
OperationKind::CreateRegion(op) => self.create_region(env, op),
OperationKind::DeleteRegion(op) => self.delete_region(env, op),
OperationKind::CreateStaffInstance(op) => self.create_staff_instance(env, op),
OperationKind::DeleteStaffInstance(op) => self.delete_staff_instance(env, op),
OperationKind::CreateVoice(op) => self.create_voice(env, op),
OperationKind::DeleteVoice(op) => self.delete_voice(env, op),
OperationKind::SetMetadata(op) => self.set_metadata(op),
OperationKind::SetMetricGrid(op) => self.set_metric_grid(env, op),
OperationKind::SetUserPageBreak(op) => self.set_user_page_break(op),
},
OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op),
OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op),
}
}
// --- Per-kind reduction. ------------------------------------------------
fn set_user_system_break(
&mut self,
op: &crate::payload::SetUserSystemBreakOp,
) -> OperationEffect {
if let Some(effect) = self.layout_region_slot(op.region) {
return effect;
}
if let Some(score) = self.graph.as_mut() {
if let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == op.region) {
if let Some(content) = region.content.staff_based_mut() {
apply_break_lww(&mut content.user_system_breaks, &op.anchor, op.present);
}
}
}
// The LWW bucketing key is the anchor's resolved musical position.
self.breaks
.insert((op.region, op.resolved_position()), op.present);
OperationEffect::Applied
}
// --- Group 4 (M2d): score settings (LWW field-overwrite). --------------
//
// SetMetadata is an *advisory* last-writer-wins field (operation_catalog
// §set-user-system-break "LWW advisory"): the latest write in canonical order
// silently wins, with no conflict — a clean concurrent metadata edit keeps the
// state clean. SetMetricGrid is a structural field-overwrite: the resolved grid
// lives in the graph and a concurrent differing grid records a
// StructuralFieldCollision. SetUserPageBreak mirrors SetUserSystemBreak: a
// canonical LWW advisory (page_breaks).
//
// SetMetricGrid / SetUserPageBreak target a region's staff-based slot, so they
// share `layout_region_slot`: the region must be live *and* staff-based (a
// FreeGraphic region has neither a metric grid nor a break list). That verdict
// reads only the base-free indices, so reduce() and reduce_onto() agree on it.
/// `Some(NoOp)` when `region` cannot carry a metric grid or user break — it is
/// missing, tombstoned, or FreeGraphic; `None` when it has a staff-based slot.
fn layout_region_slot(&self, region: RegionId) -> Option<OperationEffect> {
let live = matches!(
self.objects.get(&TypedObjectId::Region(region)),
Some(ObjectState::Live)
);
if live && self.staff_based_regions.contains(&region) {
None
} else {
Some(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
})
}
}
fn set_metadata(&mut self, op: &SetMetadataOp) -> OperationEffect {
// Advisory LWW: no conflict, no idempotence short-circuit. The resolved
// value is the last write in canonical order, held in the graph.
if let Some(score) = self.graph.as_mut() {
score.metadata = op.metadata.clone();
}
OperationEffect::Applied
}
fn set_metric_grid(
&mut self,
env: &OperationEnvelope,
op: &SetMetricGridOp,
) -> OperationEffect {
if let Some(effect) = self.layout_region_slot(op.region) {
return effect;
}
// A non-empty grid names a time signature per meter change; the graph
// invariant (epiphany-core invariants.rs) rejects a grid that references an
// undeclared signature, so reject it here rather than install an
// invariant-violating grid. Time signatures are seeded from the base, so
// this verdict is identical with or without a graph.
if let Some(grid) = &op.grid {
for change in &grid.meter_sequence {
if !matches!(
self.objects
.get(&TypedObjectId::TimeSignature(change.time_signature)),
Some(ObjectState::Live)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
}
}
let prev = self
.last_metric_grid
.get(&op.region)
.map(|(o, g)| (*o, g.clone()));
let effect = match prev {
Some((prev_op, prev_grid)) if self.concurrent(env.id, prev_op) => {
if prev_grid == op.grid {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
};
}
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
loser: prev_op,
field: FieldPath("metric_grid".to_string()),
},
vec![env.id, prev_op],
vec![TypedObjectId::Region(op.region)],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
_ => OperationEffect::Applied,
};
self.last_metric_grid
.insert(op.region, (env.id, op.grid.clone()));
self.graph_set_metric_grid(op.region, &op.grid);
effect
}
fn graph_set_metric_grid(&mut self, region: RegionId, grid: &Option<MetricGrid>) {
let Some(score) = self.graph.as_mut() else {
return;
};
if let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region) {
if let Some(content) = region.content.staff_based_mut() {
content.default_metric_grid = grid.clone();
}
}
}
fn set_user_page_break(&mut self, op: &SetUserPageBreakOp) -> OperationEffect {
if let Some(effect) = self.layout_region_slot(op.region) {
return effect;
}
if let Some(score) = self.graph.as_mut() {
if let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == op.region) {
if let Some(content) = region.content.staff_based_mut() {
apply_break_lww(&mut content.user_page_breaks, &op.anchor, op.present);
}
}
}
self.page_breaks
.insert((op.region, op.resolved_position()), op.present);
OperationEffect::Applied
}
fn insert_event(&mut self, env: &OperationEnvelope, op: &InsertEventOp) -> OperationEffect {
// The reduction keys are read from the carried event value (v1).
let event_id = op.event_id();
let orig_voice = op.voice();
let op_position = op.musical_position();
let op_duration = op.musical_duration();
if !op_duration.is_positive() {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::EventDurationInvalid,
},
};
}
let ev_obj = TypedObjectId::Event(event_id);
match self.objects.get(&ev_obj) {
Some(ObjectState::Live) => {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
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(orig_voice);
match self.objects.get(&voice_obj) {
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::VoiceMissing,
},
}
}
Some(ObjectState::Live) => {}
None => {
// Implicit voice creation on first use (prototype convention).
self.objects.insert(voice_obj, ObjectState::Live);
self.minted_by.insert(voice_obj, env.id);
// Track it under its staff instance for the container empty-check.
self.instance_voices
.entry(op.staff_instance)
.or_default()
.insert(orig_voice);
}
}
let promotion = self.promotion.get(&env.id).copied();
let target_voice = promotion.map(|(voice, _)| voice).unwrap_or(orig_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();
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: orig_voice,
to: promoted,
},
target: pv,
});
}
self.objects.insert(ev_obj, ObjectState::Live);
self.minted_by.insert(ev_obj, env.id);
self.note_minted(env, ev_obj);
let mut pitches = Vec::new();
for p in op.pitch_ids() {
let p_obj = TypedObjectId::Pitch(p);
self.objects.insert(p_obj, ObjectState::Live);
self.minted_by.insert(p_obj, env.id);
self.note_minted(env, p_obj);
pitches.push(p);
}
self.event_pitches.insert(event_id, pitches);
self.voice_occupancy.entry(target_voice).or_default().push((
op_position,
op_duration,
event_id,
));
if repairs.is_empty() {
OperationEffect::Applied
} else {
OperationEffect::AppliedWithRepair { repairs }
}
}
fn delete_event(&mut self, env: &OperationEnvelope, op: &DeleteEventOp) -> OperationEffect {
let ev_obj = TypedObjectId::Event(op.event);
match self.objects.get(&ev_obj) {
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
Some(ObjectState::Tombstoned { .. }) => {
// Concurrent same-target deletes are idempotent.
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
};
}
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(
ev_obj,
ObjectState::Tombstoned {
deleted_by: env.id,
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.
if let Some(pitches) = self.event_pitches.get(&op.event).cloned() {
for p in pitches {
let p_obj = TypedObjectId::Pitch(p);
let pm = self.minted_by.get(&p_obj).copied().unwrap_or(env.id);
self.objects.insert(
p_obj,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by: pm,
},
);
}
}
// Tuplet compensation.
match &op.tuplet_compensation {
TupletCompensation::NotInTuplet => {}
TupletCompensation::ReplaceWithRest { rest } => {
let new_rest = rest.id;
let rest_duration = match &rest.duration {
EventDuration::Musical(d) => d.clone(),
EventDuration::WallClock(_) | EventDuration::Indeterminate(_) => {
MusicalDuration::zero()
}
};
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(),
rest_duration,
new_rest,
));
}
repairs.push(RepairRecord {
kind: RepairKind::TupletCompensated {
compensation_kind: TupletCompensationKind::ReplaceWithRest,
},
target: rest_obj,
});
}
TupletCompensation::RewriteTuplets { tuplets } => {
if let Some(first) = tuplets.first() {
repairs.push(RepairRecord {
kind: RepairKind::TupletCompensated {
compensation_kind: TupletCompensationKind::RewriteTuplets,
},
target: TypedObjectId::Tuplet(*first),
});
}
}
TupletCompensation::CascadeDeleteTuplets { tuplets } => {
for t in tuplets {
let t_obj = TypedObjectId::Tuplet(*t);
let tm = self.minted_by.get(&t_obj).copied().unwrap_or(env.id);
self.objects.insert(
t_obj,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by: tm,
},
);
repairs.push(RepairRecord {
kind: RepairKind::TupletCompensated {
compensation_kind: TupletCompensationKind::CascadeDeleteTuplets,
},
target: t_obj,
});
}
}
}
// Re-anchor cross-cutting structures referencing the tombstoned event.
self.reanchor_for_tombstone(env, ev_obj, &mut repairs);
if repairs.is_empty() {
OperationEffect::Applied
} else {
OperationEffect::AppliedWithRepair { repairs }
}
}
fn respell_pitch(&mut self, env: &OperationEnvelope, op: &RespellPitchOp) -> OperationEffect {
let p_obj = TypedObjectId::Pitch(op.pitch);
match self.objects.get(&p_obj) {
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
Some(ObjectState::Live) => {}
}
match self.last_respell.get(&op.pitch).copied() {
None => {
self.materialize_respell(env, op);
OperationEffect::Applied
}
Some(prev_op) => {
let prev_spelling = self.spellings.get(&op.pitch).cloned();
let concurrent = self.concurrent(env.id, prev_op);
if concurrent {
if prev_spelling.as_ref() == Some(&op.spelling) {
// Identical concurrent respelling: idempotent.
OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
} else {
// Differing concurrent respelling: this op is later in
// canonical order, so it wins and materializes; the
// earlier op is recorded as the loser. (Prototype
// convention: the winner carries the Conflicted effect;
// see DECISIONS.md.)
self.materialize_respell(env, op);
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
loser: prev_op,
field: FieldPath("spelling".to_string()),
},
vec![env.id, prev_op],
vec![p_obj],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
} else {
// Causally-ordered re-respell: intentional overwrite.
self.materialize_respell(env, op);
OperationEffect::Applied
}
}
}
}
/// Records a winning respell: the canonical bookkeeping spelling, the LWW
/// marker, and — for graph-aware reduction — an explicit user-chosen
/// [`SpellingAttachment`] so the spelling/decomposition pre-passes (Agent H's
/// `derive_annotations`) resolve the override with manual-override precedence.
/// Without the graph attachment a reduced `RespellPitch` would be visible only
/// in `MaterializedState.spellings` and lost before annotation derivation.
fn materialize_respell(&mut self, env: &OperationEnvelope, op: &RespellPitchOp) {
self.spellings.insert(op.pitch, op.spelling.clone());
self.last_respell.insert(op.pitch, env.id);
self.graph_respell_pitch(op.pitch, &op.spelling);
}
fn graph_respell_pitch(&mut self, pitch: PitchId, spelling: &PitchSpelling) {
let Some(score) = self.graph.as_mut() else {
return;
};
// Upsert the user-chosen explicit override (one per pitch), keeping the
// attachment list's canonical order stable for the resolver's tie-break.
if let Some(existing) = score.spelling_attachments.iter_mut().find(|a| {
a.layer.is_none()
&& matches!(a.source, SpellingSource::UserChosen)
&& matches!(&a.scope, SpellingScope::Pitch(p) if *p == pitch)
&& matches!(a.directive, SpellingDirective::Explicit(_))
}) {
existing.directive = SpellingDirective::Explicit(spelling.clone());
} else {
score.spelling_attachments.push(SpellingAttachment {
scope: SpellingScope::Pitch(pitch),
directive: SpellingDirective::Explicit(spelling.clone()),
source: SpellingSource::UserChosen,
priority: 0,
layer: None,
});
}
}
fn create_cross_cutting(
&mut self,
env: &OperationEnvelope,
op: &CreateCrossCuttingOp,
) -> OperationEffect {
let sid = op.structure.id();
let endpoints = op.structure.endpoints();
match self.objects.get(&sid) {
Some(ObjectState::Live) => {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
None => {}
}
// Endpoints must exist (live).
for e in &endpoints {
if !matches!(self.objects.get(e), Some(ObjectState::Live)) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
}
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.note_minted(env, sid);
self.structures.insert(sid, endpoints);
OperationEffect::Applied
}
fn delete_cross_cutting(
&mut self,
env: &OperationEnvelope,
op: &DeleteCrossCuttingOp,
) -> OperationEffect {
let sid = op.structure;
// DeleteCrossCutting names a cross-cutting structure only; refuse to
// tombstone any other object kind through this path.
if !matches!(
sid,
TypedObjectId::Tie(_)
| TypedObjectId::Slur(_)
| TypedObjectId::Beam(_)
| TypedObjectId::Spanner(_)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
let minted_by = match self.objects.get(&sid) {
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
// Concurrent same-target deletes are idempotent (delete-wins).
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
}
Some(ObjectState::Live) => self.minted_by.get(&sid).copied().unwrap_or(env.id),
};
self.objects.insert(
sid,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by,
},
);
// Drop the transient endpoint/LWW indices so a later event tombstone's
// re-anchoring pass never re-processes the deleted structure.
self.structures.remove(&sid);
self.last_cross_cutting_modify.remove(&sid);
self.graph_delete_cross_cutting(sid);
OperationEffect::Applied
}
fn modify_cross_cutting(
&mut self,
env: &OperationEnvelope,
op: &ModifyCrossCuttingOp,
) -> OperationEffect {
let sid = op.id();
match self.objects.get(&sid) {
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
Some(ObjectState::Live) => {}
}
// A beam must keep at least two events (mirrors CreateCrossCutting); the
// new endpoints must all be live.
if let CrossCuttingValue::Beam(beam) = &op.structure {
if beam.events.len() < 2 {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
}
let endpoints = op.structure.endpoints();
for e in &endpoints {
if !matches!(self.objects.get(e), Some(ObjectState::Live)) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
}
// LWW field-overwrite, mirroring modify_event: the resolved value lives in
// the graph; MaterializedState records only the effect and, on a
// concurrent differing write, a StructuralFieldCollision.
let prev = self
.last_cross_cutting_modify
.get(&sid)
.map(|(o, v)| (*o, v.clone()));
let effect = match prev {
Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => {
if prev_value == op.structure {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
};
}
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
loser: prev_op,
field: FieldPath("cross_cutting".to_string()),
},
vec![env.id, prev_op],
vec![sid],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
_ => OperationEffect::Applied,
};
self.last_cross_cutting_modify
.insert(sid, (env.id, op.structure.clone()));
self.structures.insert(sid, endpoints);
self.graph_modify_cross_cutting(&op.structure);
effect
}
fn graph_delete_cross_cutting(&mut self, sid: TypedObjectId) {
let Some(score) = self.graph.as_mut() else {
return;
};
match sid {
TypedObjectId::Slur(id) => score.cross_cutting.slurs.retain(|v| v.id != id),
TypedObjectId::Tie(id) => score.cross_cutting.ties.retain(|v| v.id != id),
TypedObjectId::Beam(id) => score.cross_cutting.beams.retain(|v| v.id != id),
TypedObjectId::Spanner(id) => score.cross_cutting.spanners.retain(|v| v.id != id),
_ => {}
}
}
fn graph_modify_cross_cutting(&mut self, value: &CrossCuttingValue) {
let Some(score) = self.graph.as_mut() else {
return;
};
// Replace the structure in place by id (identity is preserved across a
// modify). A structure that is Live in bookkeeping but absent from the
// graph (e.g. one a DeleteEvent re-anchor removed from the graph while
// bookkeeping kept it Live) is left as-is; the modify is still recorded.
match value {
CrossCuttingValue::Slur(slur) => {
if let Some(existing) = score
.cross_cutting
.slurs
.iter_mut()
.find(|v| v.id == slur.id)
{
*existing = slur.clone();
}
}
CrossCuttingValue::Tie(tie) => {
if let Some(existing) = score.cross_cutting.ties.iter_mut().find(|v| v.id == tie.id)
{
*existing = tie.clone();
}
}
CrossCuttingValue::Beam(beam) => {
if let Some(existing) = score
.cross_cutting
.beams
.iter_mut()
.find(|v| v.id == beam.id)
{
*existing = beam.clone();
}
}
CrossCuttingValue::Spanner(spanner) => {
if let Some(existing) = score
.cross_cutting
.spanners
.iter_mut()
.find(|v| v.id == spanner.id)
{
*existing = spanner.clone();
}
}
}
}
// --- Group 3 (M2c): structural container CRUD. -------------------------
//
// Creates mint an empty container (set-union creation; the authoring contract
// is that the carried value is empty, so only its own object is minted).
// Deletes are empty-only delete-wins: a precondition NoOp unless the
// container has no live children. Child liveness is read from
// `region_instances` / `instance_voices` (and `voice_occupancy` for a voice's
// events), so the ledger projection and the graph agree on the result.
fn mint_container(&mut self, env: &OperationEnvelope, obj: TypedObjectId) {
self.objects.insert(obj, ObjectState::Live);
self.minted_by.insert(obj, env.id);
self.note_minted(env, obj);
}
/// `Some(effect)` when `obj` cannot be freshly minted (already live or
/// tombstoned); `None` when it is fresh and the create may proceed.
fn mint_precondition(&self, obj: TypedObjectId) -> Option<OperationEffect> {
match self.objects.get(&obj) {
Some(ObjectState::Live) => Some(OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}),
Some(ObjectState::Tombstoned { .. }) => Some(OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}),
None => None,
}
}
fn create_region(&mut self, env: &OperationEnvelope, op: &CreateRegionOp) -> OperationEffect {
let robj = TypedObjectId::Region(op.region_id());
if let Some(effect) = self.mint_precondition(robj) {
return effect;
}
// A create mints an *empty* container: its child objects are minted (or
// base-seeded) separately. A carried value bearing any typed child object
// — a staff instance, a barline-alignment group, or a graphic object, each
// a distinct TypedObjectId the reducer does not mint here — would import an
// unminted object into the graph, so it is rejected (Catalog §Structural
// Containers).
if !op.region.content.staff_instances().is_empty()
|| !op.region.content.barline_alignment_groups().is_empty()
|| !op.region.content.graphic_objects().is_empty()
{
return container_not_empty();
}
self.graph_create_region(&op.region);
self.mint_container(env, robj);
self.region_instances.entry(op.region_id()).or_default();
if op.region.content.staff_based().is_some() {
self.staff_based_regions.insert(op.region_id());
}
OperationEffect::Applied
}
fn create_staff_instance(
&mut self,
env: &OperationEnvelope,
op: &CreateStaffInstanceOp,
) -> OperationEffect {
if !matches!(
self.objects.get(&TypedObjectId::Region(op.region)),
Some(ObjectState::Live)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
let iobj = TypedObjectId::StaffInstance(op.instance_id());
if let Some(effect) = self.mint_precondition(iobj) {
return effect;
}
// Reject a carried staff instance bearing any typed child object — a voice
// or a measure (the two object collections it can hold).
if !op.instance.voices.is_empty() || !op.instance.measures.is_empty() {
return container_not_empty();
}
self.graph_create_staff_instance(op.region, &op.instance);
self.mint_container(env, iobj);
self.region_instances
.entry(op.region)
.or_default()
.insert(op.instance_id());
self.instance_voices.entry(op.instance_id()).or_default();
OperationEffect::Applied
}
fn create_voice(&mut self, env: &OperationEnvelope, op: &CreateVoiceOp) -> OperationEffect {
if !matches!(
self.objects
.get(&TypedObjectId::StaffInstance(op.staff_instance)),
Some(ObjectState::Live)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
let vobj = TypedObjectId::Voice(op.voice_id());
if let Some(effect) = self.mint_precondition(vobj) {
return effect;
}
if !op.voice.events.is_empty() {
return container_not_empty();
}
self.graph_create_voice(op.staff_instance, &op.voice);
self.mint_container(env, vobj);
self.instance_voices
.entry(op.staff_instance)
.or_default()
.insert(op.voice_id());
OperationEffect::Applied
}
/// `Some(effect)` when `obj` cannot be deleted (missing, idempotent
/// re-delete, or non-empty); `None` with the resolved minter when the
/// empty-only delete may proceed.
fn delete_precondition(
&self,
obj: TypedObjectId,
env: &OperationEnvelope,
has_live_children: bool,
) -> Result<OperationId, OperationEffect> {
match self.objects.get(&obj) {
None => Err(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}),
Some(ObjectState::Tombstoned { .. }) => Err(OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}),
Some(ObjectState::Live) if has_live_children => Err(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::ContainerNotEmpty,
},
}),
Some(ObjectState::Live) => Ok(self.minted_by.get(&obj).copied().unwrap_or(env.id)),
}
}
fn delete_region(&mut self, env: &OperationEnvelope, op: &DeleteRegionOp) -> OperationEffect {
let robj = TypedObjectId::Region(op.region);
let has_instances = self
.region_instances
.get(&op.region)
.is_some_and(|s| !s.is_empty());
let minted_by = match self.delete_precondition(robj, env, has_instances) {
Ok(minter) => minter,
Err(effect) => return effect,
};
self.objects.insert(
robj,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by,
},
);
self.region_instances.remove(&op.region);
self.staff_based_regions.remove(&op.region);
self.graph_delete_region(op.region);
OperationEffect::Applied
}
fn delete_staff_instance(
&mut self,
env: &OperationEnvelope,
op: &DeleteStaffInstanceOp,
) -> OperationEffect {
let iobj = TypedObjectId::StaffInstance(op.staff_instance);
let has_voices = self
.instance_voices
.get(&op.staff_instance)
.is_some_and(|s| !s.is_empty());
let minted_by = match self.delete_precondition(iobj, env, has_voices) {
Ok(minter) => minter,
Err(effect) => return effect,
};
self.objects.insert(
iobj,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by,
},
);
self.instance_voices.remove(&op.staff_instance);
for set in self.region_instances.values_mut() {
set.remove(&op.staff_instance);
}
self.graph_delete_staff_instance(op.staff_instance);
OperationEffect::Applied
}
fn delete_voice(&mut self, env: &OperationEnvelope, op: &DeleteVoiceOp) -> OperationEffect {
let vobj = TypedObjectId::Voice(op.voice);
let has_events = self
.voice_occupancy
.get(&op.voice)
.is_some_and(|e| !e.is_empty());
let minted_by = match self.delete_precondition(vobj, env, has_events) {
Ok(minter) => minter,
Err(effect) => return effect,
};
self.objects.insert(
vobj,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by,
},
);
self.voice_occupancy.remove(&op.voice);
for set in self.instance_voices.values_mut() {
set.remove(&op.voice);
}
self.graph_delete_voice(op.voice);
OperationEffect::Applied
}
// --- Group 3 graph mutations (reduce_onto only; no-op when graph is None). --
fn graph_create_region(&mut self, region: &epiphany_core::Region) {
if let Some(score) = self.graph.as_mut() {
score.canvas.regions.push(region.clone());
}
}
fn graph_create_staff_instance(&mut self, region: RegionId, instance: &StaffInstance) {
let Some(score) = self.graph.as_mut() else {
return;
};
if let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region) {
let staff = instance.staff;
if let Some(instances) = region.content.staff_instances_mut() {
instances.push(instance.clone());
}
// Keep the region's staff extent listing exactly its manifested
// staves (Chapter 5 RegionExtents).
if !region.staff_extent.staves.contains(&staff) {
region.staff_extent.staves.push(staff);
}
}
}
fn graph_create_voice(&mut self, staff_instance: StaffInstanceId, voice: &Voice) {
let Some(score) = self.graph.as_mut() else {
return;
};
for region in &mut score.canvas.regions {
if let Some(instances) = region.content.staff_instances_mut() {
if let Some(instance) = instances.iter_mut().find(|i| i.id == staff_instance) {
instance.voices.push(voice.clone());
return;
}
}
}
}
fn graph_delete_region(&mut self, region: RegionId) {
if let Some(score) = self.graph.as_mut() {
score.canvas.regions.retain(|r| r.id != region);
}
}
fn graph_delete_staff_instance(&mut self, staff_instance: StaffInstanceId) {
let Some(score) = self.graph.as_mut() else {
return;
};
for region in &mut score.canvas.regions {
let Some(instances) = region.content.staff_instances_mut() else {
continue;
};
let Some(removed_staff) = instances
.iter()
.find(|i| i.id == staff_instance)
.map(|i| i.staff)
else {
continue;
};
instances.retain(|i| i.id != staff_instance);
// Drop the staff from the extent if no remaining instance manifests
// it (Chapter 5 RegionExtents).
let still_used = instances.iter().any(|i| i.staff == removed_staff);
if !still_used {
region.staff_extent.staves.retain(|s| *s != removed_staff);
}
}
}
fn graph_delete_voice(&mut self, voice: VoiceId) {
let Some(score) = self.graph.as_mut() else {
return;
};
for region in &mut score.canvas.regions {
if let Some(instances) = region.content.staff_instances_mut() {
for instance in instances {
instance.voices.retain(|v| v.id != voice);
}
}
}
}
fn change_region_time_model(
&mut self,
env: &OperationEnvelope,
op: &crate::payload::ChangeRegionTimeModelOp,
) -> OperationEffect {
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 };
}
}
let mut incompatible_events: BTreeSet<EventId> =
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()
.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<EventId> = 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 {
RegionTimeModel::Metric(_) => matches!(
(event.position(), event.duration()),
(EventPosition::Musical(_), EventDuration::Musical(_))
),
RegionTimeModel::Proportional(_) => matches!(
(event.position(), event.duration()),
(EventPosition::WallClock(_), EventDuration::WallClock(_))
),
RegionTimeModel::Aleatoric(_) => true,
};
if !compatible {
incompatible_events.insert(*event_id);
}
}
if let crate::payload::PositionRemapping::Reassign(remapping) = &op.remapping {
let mapped: BTreeSet<EventId> = remapping.iter().map(|(event, _)| *event).collect();
incompatible_events.extend(
event_ids
.iter()
.filter(|event| !mapped.contains(event))
.copied(),
);
if matches!(op.new_time_model, RegionTimeModel::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<TypedObjectId> = incompatible_events
.into_iter()
.map(TypedObjectId::Event)
.collect();
let mut affected = vec![TypedObjectId::Region(op.region)];
affected.extend(incompatible.iter().copied());
let conflict = ConflictRecord::new(
ConflictKind::TimeModelMigrationFailure {
region: op.region,
incompatible_events: incompatible,
},
vec![env.id],
affected,
);
let cid = conflict.id;
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();
}
}
}
}
// The value-typed payload (v1) carries the real target model, so the
// region adopts it directly rather than rebuilding a default from a
// discriminator tag.
let region = &mut score.canvas.regions[region_index];
region.time_model = op.new_time_model.clone();
}
self.migrated_regions.insert(op.region);
self.region_migrator.insert(op.region, env.id);
OperationEffect::Applied
}
fn resolve_conflict(
&mut self,
env: &OperationEnvelope,
op: &crate::payload::ResolveConflictPayload,
) -> OperationEffect {
use crate::conflict::ConflictResolutionState as RS;
let existing_state = self
.conflicts
.get_mut(op.target)
.map(|r| r.resolution_state);
match existing_state {
None => OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
},
Some(RS::Unresolved) => {
if let Some(rec) = self.conflicts.get_mut(op.target) {
// ResolutionAction::Dismiss selects the Dismissed state;
// every other action selects Resolved with that action
// applied (Pass 11, item 2.5).
rec.resolution_state = match op.action {
ResolutionAction::Dismiss => RS::Dismissed { by: env.id },
action => RS::Resolved { by: env.id, action },
};
}
OperationEffect::Applied
}
Some(RS::Resolved { action, .. }) => {
if action == op.action {
OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
} else {
// Differing concurrent resolution → meta-conflict.
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
loser: env.id,
field: FieldPath("conflict_resolution".to_string()),
},
vec![env.id],
vec![],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
}
Some(RS::Dismissed { .. }) => OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
},
}
}
fn undo_transaction(
&mut self,
env: &OperationEnvelope,
op: &UndoTransactionPayload,
) -> OperationEffect {
let targets = self.tx_minted.get(&op.target).cloned().unwrap_or_default();
if targets.is_empty() {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
let all_live = targets
.iter()
.all(|t| matches!(self.objects.get(t), Some(ObjectState::Live)));
match op.policy {
UndoPolicy::StrictInverse | UndoPolicy::Cascade => {
if all_live {
let mut repairs = Vec::new();
for t in &targets {
let minter = self.minted_by.get(t).copied().unwrap_or(env.id);
self.objects.insert(
*t,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by: minter,
},
);
repairs.push(RepairRecord {
kind: RepairKind::CascadeDeleted,
target: *t,
});
}
self.materialize_graph_tombstones(&targets);
OperationEffect::AppliedWithRepair { repairs }
} else {
// A target was already tombstoned/modified: strict undo conflicts.
let stuck = targets
.iter()
.find(|t| !matches!(self.objects.get(t), Some(ObjectState::Live)))
.copied()
.unwrap_or(targets[0]);
let conflict = ConflictRecord::new(
ConflictKind::TombstonedTarget {
target: stuck,
operation: env.id,
},
vec![env.id],
vec![stuck],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
}
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);
self.objects.insert(
*t,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by: minter,
},
);
repairs.push(RepairRecord {
kind: RepairKind::CascadeDeleted,
target: *t,
});
tombstoned.push(*t);
}
}
self.materialize_graph_tombstones(&tombstoned);
OperationEffect::AppliedWithRepair { repairs }
}
}
}
// --- Group 1 (M2): event & pitch leaf-field ops. ------------------------
//
// The modify/transpose ops follow respell's field-overwrite discipline but
// do NOT store the resolved value in MaterializedState (an Event/Pitch is a
// graph object, not a bookkeeping-owned annotation like a spelling): their
// canonical footprint is the effect-log entry plus, on a concurrent differing
// write, a StructuralFieldCollision. The resolved value is materialized in the
// graph by reduce_onto. The LWW key/diff uses `last_*_modify` working state.
fn modify_event(&mut self, env: &OperationEnvelope, op: &ModifyEventOp) -> OperationEffect {
let event_id = op.event_id();
let ev_obj = TypedObjectId::Event(event_id);
match self.objects.get(&ev_obj) {
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
Some(ObjectState::Live) => {}
}
// A `ModifyEvent` that moves a metric event's span (a trim or move) is now
// materialized, but it must keep invariant 3 (`VoiceEventsSortedNonOverlap`):
// refuse a move onto another live event in the voice, or one with a
// non-positive span, rather than skip it silently (which would log a clean op
// that never took effect). The verdict reads `voice_occupancy`, the
// graph-independent index, so `reduce()` and `reduce_onto()` agree on it.
let placement = self.metric_placement_verdict(&op.event);
if matches!(placement, PlacementVerdict::Refused) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::EventDurationInvalid,
},
};
}
let prev = self
.last_event_modify
.get(&event_id)
.map(|(o, e)| (*o, e.clone()));
let effect = match prev {
Some((prev_op, prev_event)) if self.concurrent(env.id, prev_op) => {
if prev_event == op.event {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
};
}
// Later in canonical order wins and materializes; the earlier op
// is the loser. (Winner carries the Conflicted tag; see DECISIONS.)
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
loser: prev_op,
field: FieldPath("event".to_string()),
},
vec![env.id, prev_op],
vec![ev_obj],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
// First modify, or a causally-ordered intentional overwrite.
_ => OperationEffect::Applied,
};
self.last_event_modify
.insert(event_id, (env.id, op.event.clone()));
// Materialize a move only when it is a sanctioned metric move (`Moved`) *and*
// the replacement is well-formed — so the graph and the occupancy index move
// together. A malformed (empty) pitched replacement is not materialized in the
// graph (`graph_replace_event` skips it), so it must not move occupancy either.
let materialize_move = matches!(placement, PlacementVerdict::Moved { .. })
&& !matches!(&op.event, Event::Pitched(pe) if !pe.is_well_formed());
self.graph_replace_event(&op.event, materialize_move);
// Keep the voice-occupancy index in step with a materialized move, so a later
// insert sees the freed/changed span (the same index its overlap check reads).
if materialize_move {
if let PlacementVerdict::Moved {
voice,
position,
duration,
} = placement
{
if let Some(events) = self.voice_occupancy.get_mut(&voice) {
for slot in events.iter_mut().filter(|slot| slot.2 == event_id) {
slot.0 = position.clone();
slot.1 = duration.clone();
}
}
}
}
effect
}
/// The verdict on a [`ModifyEvent`](OperationKind::ModifyEvent)'s placement: does
/// it move the event's metric span, and if so does the move keep invariant 3
/// (`VoiceEventsSortedNonOverlap`)? Read from `voice_occupancy` — the canonical,
/// graph-independent placement index — so the verdict is identical with or without
/// a base graph.
fn metric_placement_verdict(&self, new_event: &Event) -> PlacementVerdict {
let event_id = new_event.id();
let Some((voice, current_position, current_duration)) =
self.voice_occupancy.iter().find_map(|(voice, events)| {
events
.iter()
.find(|(_, _, event)| *event == event_id)
.map(|(position, duration, _)| (*voice, position.clone(), duration.clone()))
})
else {
// The event has no metric occupancy entry (untracked or non-metric):
// nothing to materialize or refuse here.
return PlacementVerdict::Unchanged;
};
let (EventPosition::Musical(new_position), EventDuration::Musical(new_duration)) =
(new_event.position(), new_event.duration())
else {
// A non-metric placement is left deferred, neither moved nor refused.
return PlacementVerdict::Unchanged;
};
if *new_position == current_position && *new_duration == current_duration {
return PlacementVerdict::Unchanged;
}
if !new_duration.is_positive() {
return PlacementVerdict::Refused;
}
let overlaps = self.voice_occupancy.get(&voice).is_some_and(|events| {
events.iter().any(|(position, duration, event)| {
*event != event_id
&& intervals_overlap(new_position, new_duration, position, duration)
})
});
if overlaps {
PlacementVerdict::Refused
} else {
PlacementVerdict::Moved {
voice,
position: new_position.clone(),
duration: new_duration.clone(),
}
}
}
/// Re-sorts `voice`'s graph event list by ascending position (id-tiebroken), the
/// same order an insert maintains — run after a materialized placement change so
/// the voice stays sorted (invariant 3). A no-op when the graph is absent.
fn resort_voice(&mut self, voice: VoiceId) {
let Some(score) = self.graph.as_mut() else {
return;
};
let Some((region_index, instance_index, voice_index)) = graph_voice_location(score, voice)
else {
return;
};
let mut ordered = score.canvas.regions[region_index].staff_instances()[instance_index]
.voices[voice_index]
.events
.clone();
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 voice was located in a staff-based instance")[instance_index]
.voices[voice_index]
.events = ordered;
}
fn transpose(&mut self, _env: &OperationEnvelope, op: &TransposeOp) -> OperationEffect {
// Precondition: every target pitch is live. Transpose is order-dependent
// (transpositions do not commute); its canonical footprint is the
// effect-log entry. The transposed values are materialized in the graph.
for pitch in &op.targets {
match self.objects.get(&TypedObjectId::Pitch(*pitch)) {
Some(ObjectState::Live) => {}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
}
}
for pitch in &op.targets {
self.graph_transpose_pitch(*pitch, op.chromatic_steps);
}
OperationEffect::Applied
}
fn insert_identified_pitch(
&mut self,
env: &OperationEnvelope,
op: &InsertIdentifiedPitchOp,
) -> OperationEffect {
let pitch_id = op.pitch_id();
let p_obj = TypedObjectId::Pitch(pitch_id);
// The target event must be live; the pitch id must be fresh.
if !matches!(
self.objects.get(&TypedObjectId::Event(op.event)),
Some(ObjectState::Live)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
match self.objects.get(&p_obj) {
Some(ObjectState::Live) => {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
None => {}
}
self.objects.insert(p_obj, ObjectState::Live);
self.minted_by.insert(p_obj, env.id);
self.note_minted(env, p_obj);
self.event_pitches
.entry(op.event)
.or_default()
.push(pitch_id);
self.graph_insert_pitch(op.event, &op.pitch);
OperationEffect::Applied
}
fn delete_identified_pitch(
&mut self,
env: &OperationEnvelope,
op: &DeleteIdentifiedPitchOp,
) -> OperationEffect {
let p_obj = TypedObjectId::Pitch(op.pitch);
let minted_by = match self.objects.get(&p_obj) {
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
}
Some(ObjectState::Live) => self.minted_by.get(&p_obj).copied().unwrap_or(env.id),
};
self.objects.insert(
p_obj,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by,
},
);
for pitches in self.event_pitches.values_mut() {
pitches.retain(|p| *p != op.pitch);
}
self.graph_delete_pitch(op.pitch);
OperationEffect::Applied
}
fn modify_identified_pitch(
&mut self,
env: &OperationEnvelope,
op: &ModifyIdentifiedPitchOp,
) -> OperationEffect {
let p_obj = TypedObjectId::Pitch(op.pitch);
match self.objects.get(&p_obj) {
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
Some(ObjectState::Live) => {}
}
let prev = self
.last_pitch_modify
.get(&op.pitch)
.map(|(o, v)| (*o, v.clone()));
let effect = match prev {
Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => {
if prev_value == op.value {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
};
}
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
loser: prev_op,
field: FieldPath("pitch".to_string()),
},
vec![env.id, prev_op],
vec![p_obj],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
_ => OperationEffect::Applied,
};
self.last_pitch_modify
.insert(op.pitch, (env.id, op.value.clone()));
self.graph_modify_pitch(op.pitch, &op.value);
effect
}
// --- Group 1 graph mutations (reduce_onto only; no-op when graph is None). --
/// Applies a `ModifyEvent`'s value to the graph. `materialize_move` is the caller's
/// sanction (from [`Self::metric_placement_verdict`]) that a *placement* change is a
/// valid metric move and should be applied + the voice re-sorted; when it is false a
/// placement change is deferred (a non-metric move, a no-occupancy event, or a
/// refused move), leaving only same-placement field edits to apply. Keeping
/// materialization gated on this single sanction is what holds the graph and the
/// `voice_occupancy` index in agreement.
fn graph_replace_event(&mut self, new_event: &Event, materialize_move: bool) {
let placement_changed;
let voice;
{
let Some(score) = self.graph.as_mut() else {
return;
};
// A ModifyEvent carrying a malformed (empty) pitched event must not
// corrupt the arena: `get_mut` bypasses `insert`'s well-formedness guard,
// so an empty chord would only be caught later by `check_invariants`.
// Skip the graph replace in that case (bookkeeping still records it).
if let Event::Pitched(pe) = new_event {
if !pe.is_well_formed() {
return;
}
}
let Some(existing) = score.events.get_mut(new_event.id()) else {
return;
};
placement_changed = new_event.position() != existing.position()
|| new_event.duration() != existing.duration();
// A placement change is materialized (and the voice re-sorted below) only
// when the caller sanctioned it as a valid metric move; otherwise it is
// deferred, leaving the LWW bookkeeping to record the modify. Same-placement
// field edits always apply, preserving the original voice membership.
if placement_changed && !materialize_move {
return;
}
voice = existing.voice();
let mut replacement = new_event.clone();
replacement.set_voice(voice);
*existing = replacement;
}
if placement_changed {
self.resort_voice(voice);
}
}
fn graph_event_of_pitch(score: &Score, pitch: PitchId) -> Option<EventId> {
score.events.iter().find_map(|event| {
let mut ips = Vec::new();
event.collect_identified_pitches(&mut ips);
ips.iter().any(|ip| ip.id == pitch).then(|| event.id())
})
}
fn graph_insert_pitch(&mut self, event: EventId, pitch: &epiphany_core::IdentifiedPitch) {
let Some(score) = self.graph.as_mut() else {
return;
};
let Some(slot) = score.events.get_mut(event) else {
return;
};
if let Event::Pitched(pe) = slot {
if !pe.pitches.iter().any(|ip| ip.id == pitch.id) {
pe.pitches.push(pitch.clone());
}
return;
}
// Adding a pitch to a rest turns the rest into a note — the dual of a
// last-pitch delete (below). Without this, the bookkeeping mints the
// pitch live while the graph silently drops it (a non-pitched slot has
// no pitch list), so the two would diverge.
if let Event::Rest(rest) = slot {
let replacement = epiphany_core::PitchedEvent {
id: rest.id,
voice: rest.voice,
position: rest.position.clone(),
duration: rest.duration.clone(),
pitches: vec![pitch.clone()],
articulations: Vec::new(),
dynamic: None,
ornaments: Vec::new(),
stem: epiphany_core::StemConfiguration,
grace: None,
};
*slot = Event::Pitched(replacement);
}
}
fn graph_delete_pitch(&mut self, pitch: PitchId) {
let Some(score) = self.graph.as_mut() else {
return;
};
// Drop any user-chosen respell override for the deleted pitch (the dual
// of `graph_respell_pitch`), so no spelling attachment is left targeting a
// pitch that is no longer present (Chapter 5 SpellingScopeResolves). The
// pitch is not added to `tombstoned_pitches` here: unlike a whole-event
// delete the event survives, and a later ModifyEvent may legitimately
// reintroduce the id — tombstoning it would make it both live and
// tombstoned (invariant 11).
score.spelling_attachments.retain(|a| {
!(a.layer.is_none()
&& matches!(a.source, SpellingSource::UserChosen)
&& matches!(&a.scope, SpellingScope::Pitch(p) if *p == pitch)
&& matches!(a.directive, SpellingDirective::Explicit(_)))
});
let Some(event) = Self::graph_event_of_pitch(score, pitch) else {
return;
};
let Some(slot) = score.events.get_mut(event) else {
return;
};
if let Event::Pitched(pe) = slot {
if pe.pitches.iter().filter(|ip| ip.id != pitch).count() == 0 {
// Removing the last pitch would leave an empty (invalid) pitched
// event; Chapter 5 forbids that ("use Rest for the no-pitch
// case"), so the note degrades to a rest of the same placement
// and duration. Keeps `get_mut` from materializing a malformed
// chord that `check_invariants` would later reject.
let rest = epiphany_core::Rest {
id: pe.id,
voice: pe.voice,
position: pe.position.clone(),
duration: pe.duration.clone(),
vertical_position: None,
visible: true,
};
*slot = Event::Rest(rest);
} else {
pe.pitches.retain(|ip| ip.id != pitch);
}
}
}
fn graph_modify_pitch(&mut self, pitch: PitchId, value: &Pitch) {
let Some(score) = self.graph.as_mut() else {
return;
};
let Some(event) = Self::graph_event_of_pitch(score, pitch) else {
return;
};
if let Some(Event::Pitched(pe)) = score.events.get_mut(event) {
if let Some(ip) = pe.pitches.iter_mut().find(|ip| ip.id == pitch) {
ip.pitch = value.clone();
}
}
}
fn graph_transpose_pitch(&mut self, pitch: PitchId, chromatic_steps: i32) {
let Some(score) = self.graph.as_mut() else {
return;
};
let Some(event) = Self::graph_event_of_pitch(score, pitch) else {
return;
};
if let Some(Event::Pitched(pe)) = score.events.get_mut(event) {
if let Some(ip) = pe.pitches.iter_mut().find(|ip| ip.id == pitch) {
// Minimal interval: shift the CMN alteration, saturating at the
// `i8` bound (a lossy stand-in — an extreme transpose clamps
// rather than renormalizing nominal/octave). Full interval
// algebra (Chapter 4 tuning) is deferred — P12-K2.
if let epiphany_core::PitchSpacePosition::Cmn { alteration, .. } =
&mut ip.pitch.scale_position.position
{
let shifted = (*alteration as i32).saturating_add(chromatic_steps);
*alteration = shifted.clamp(i8::MIN as i32, i8::MAX as i32) as i8;
}
}
}
}
// --- Re-anchoring (Chapter 6 §6.5 rule table, representative subset). ----
fn reanchor_for_tombstone(
&mut self,
env: &OperationEnvelope,
tombstoned: TypedObjectId,
repairs: &mut Vec<RepairRecord>,
) {
// Find structures referencing the tombstoned object.
let referencing: Vec<TypedObjectId> = self
.structures
.iter()
.filter(|(_, endpoints)| endpoints.contains(&tombstoned))
.map(|(sid, _)| *sid)
.collect();
for sid in referencing {
match sid {
TypedObjectId::Tie(_) => {
// A tie's existence requires both endpoints: cascade-delete.
self.cascade_structure(env, sid, repairs);
}
TypedObjectId::Comment(_) | TypedObjectId::AnalyticalAnnotation(_) => {
// User content is never silently deleted: orphan.
repairs.push(RepairRecord {
kind: RepairKind::Orphaned,
target: sid,
});
}
TypedObjectId::Beam(_) => {
let survivors = self.surviving_endpoints(sid, tombstoned);
if survivors < 2 {
self.cascade_structure(env, sid, repairs);
} else {
repairs.push(RepairRecord {
kind: RepairKind::SpannerTruncated {
removed_members: vec![tombstoned],
},
target: sid,
});
}
}
TypedObjectId::Slur(_) | TypedObjectId::Spanner(_) => {
let survivors = self.surviving_endpoints(sid, tombstoned);
if survivors < 1 {
self.cascade_structure(env, sid, repairs);
} else if let Some(to) = self.nearest_survivor(sid, tombstoned) {
repairs.push(RepairRecord {
kind: RepairKind::Reanchored {
from: tombstoned,
to,
reason: ReanchorReason::SameVoiceNearer,
},
target: sid,
});
} else {
self.cascade_structure(env, sid, repairs);
}
}
_ => {
repairs.push(RepairRecord {
kind: RepairKind::AttachmentTombstoned,
target: sid,
});
}
}
}
}
fn cascade_structure(
&mut self,
env: &OperationEnvelope,
sid: TypedObjectId,
repairs: &mut Vec<RepairRecord>,
) {
let minter = self.minted_by.get(&sid).copied().unwrap_or(env.id);
self.objects.insert(
sid,
ObjectState::Tombstoned {
deleted_by: env.id,
minted_by: minter,
},
);
repairs.push(RepairRecord {
kind: RepairKind::CascadeDeleted,
target: sid,
});
}
fn surviving_endpoints(&self, sid: TypedObjectId, just_tombstoned: TypedObjectId) -> usize {
self.structures
.get(&sid)
.map(|eps| {
eps.iter()
.filter(|e| {
**e != just_tombstoned
&& matches!(self.objects.get(*e), Some(ObjectState::Live))
})
.count()
})
.unwrap_or(0)
}
fn nearest_survivor(
&self,
sid: TypedObjectId,
just_tombstoned: TypedObjectId,
) -> Option<TypedObjectId> {
// Deterministic "nearest" stand-in: the lexicographically-smallest
// surviving endpoint (the spec's full proximity ordering needs resolved
// positions; see DECISIONS.md).
self.structures.get(&sid).and_then(|eps| {
eps.iter()
.filter(|e| {
**e != just_tombstoned
&& matches!(self.objects.get(*e), Some(ObjectState::Live))
})
.min()
.copied()
})
}
// --- Transactions (Chapter 6 §6.6). -------------------------------------
fn reduce_transaction_block(&mut self, tx: TransactionId, members: &[&'a OperationEnvelope]) {
let ordered = canonical_reduction_order(members);
// Descriptor-precedence rule: the DeclareTransaction must be present and
// causally precede every member.
let desc = self.descriptors.get(&tx).copied();
let well_formed = desc.is_some()
&& ordered
.iter()
.all(|m| m.causal_context.covers(desc.expect("checked is_some")));
if !well_formed {
let member_ids: Vec<OperationId> = ordered.iter().map(|m| m.id).collect();
let conflict = ConflictRecord::new(
ConflictKind::TransactionConflict {
transaction: tx,
failed_members: member_ids.clone(),
},
member_ids,
vec![],
);
self.conflicts.insert(conflict);
for m in &ordered {
self.effects.push((
m.id,
OperationEffect::NoOp {
reason: NoOpReason::TransactionConflict,
},
));
}
return;
}
// Atomic: apply members against a snapshot; if any fails, roll back.
let snapshot = self.snapshot();
self.current_tx = Some(tx);
let mut member_effects: Vec<(OperationId, OperationEffect)> = Vec::new();
let mut failed_members: Vec<OperationId> = Vec::new();
for m in &ordered {
let eff = self.apply(m);
if is_member_failure(&eff) {
failed_members.push(m.id);
}
member_effects.push((m.id, eff));
}
self.current_tx = None;
if failed_members.is_empty() {
for (id, eff) in member_effects {
self.effects.push((id, eff));
}
} else {
self.restore(snapshot);
let member_ids: Vec<OperationId> = ordered.iter().map(|m| m.id).collect();
let conflict = ConflictRecord::new(
ConflictKind::TransactionConflict {
transaction: tx,
failed_members,
},
member_ids,
vec![],
);
self.conflicts.insert(conflict);
for m in &ordered {
self.effects.push((
m.id,
OperationEffect::NoOp {
reason: NoOpReason::TransactionConflict,
},
));
}
}
}
/// Records an object as minted by the operation's transaction (if any), so
/// undo can compensate it later.
fn note_minted(&mut self, _env: &OperationEnvelope, obj: TypedObjectId) {
if let Some(tx) = self.current_tx {
self.tx_minted.entry(tx).or_default().push(obj);
}
}
fn concurrent(&self, a: OperationId, b: OperationId) -> bool {
let a_ctx = self
.env_of(a)
.map(|e| e.causal_context.covers(b))
.unwrap_or(false);
let b_ctx = self
.env_of(b)
.map(|e| e.causal_context.covers(a))
.unwrap_or(false);
!a_ctx && !b_ctx
}
fn snapshot(&self) -> WorkingSnapshot {
WorkingSnapshot {
objects: self.objects.clone(),
spellings: self.spellings.clone(),
breaks: self.breaks.clone(),
page_breaks: self.page_breaks.clone(),
conflicts: self.conflicts.clone(),
minted_by: self.minted_by.clone(),
event_pitches: self.event_pitches.clone(),
voice_occupancy: self.voice_occupancy.clone(),
last_respell: self.last_respell.clone(),
last_event_modify: self.last_event_modify.clone(),
last_pitch_modify: self.last_pitch_modify.clone(),
last_cross_cutting_modify: self.last_cross_cutting_modify.clone(),
last_metric_grid: self.last_metric_grid.clone(),
structures: self.structures.clone(),
region_instances: self.region_instances.clone(),
instance_voices: self.instance_voices.clone(),
staff_based_regions: self.staff_based_regions.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(),
}
}
fn restore(&mut self, s: WorkingSnapshot) {
self.objects = s.objects;
self.spellings = s.spellings;
self.breaks = s.breaks;
self.page_breaks = s.page_breaks;
self.conflicts = s.conflicts;
self.minted_by = s.minted_by;
self.event_pitches = s.event_pitches;
self.voice_occupancy = s.voice_occupancy;
self.last_respell = s.last_respell;
self.last_event_modify = s.last_event_modify;
self.last_pitch_modify = s.last_pitch_modify;
self.last_cross_cutting_modify = s.last_cross_cutting_modify;
self.last_metric_grid = s.last_metric_grid;
self.structures = s.structures;
self.region_instances = s.region_instances;
self.instance_voices = s.instance_voices;
self.staff_based_regions = s.staff_based_regions;
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;
}
}
/// Whether a transaction member's effect counts as an invariant-precondition
/// failure that conflicts the whole transaction (Chapter 6 §6.6).
fn is_member_failure(effect: &OperationEffect) -> bool {
matches!(
effect,
OperationEffect::Conflicted { .. }
| OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction { .. }
}
| OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned
}
)
}
/// The transaction an envelope is a *member* of: its `transaction` field, unless
/// it is the `DeclareTransaction` descriptor itself (which is not a member of
/// the transaction it declares).
fn member_transaction(env: &OperationEnvelope) -> Option<TransactionId> {
let tx = env.transaction?;
if let OperationPayload::Primitive(OperationKind::DeclareTransaction(desc)) = &env.payload {
if desc.id == tx {
return None;
}
}
Some(tx)
}
/// Groups active operations into transaction blocks by membership.
fn transaction_members<'a>(
active: &[&'a OperationEnvelope],
) -> BTreeMap<TransactionId, Vec<&'a OperationEnvelope>> {
let mut map: BTreeMap<TransactionId, Vec<&'a OperationEnvelope>> = BTreeMap::new();
for env in active {
if let Some(tx) = member_transaction(env) {
map.entry(tx).or_default().push(env);
}
}
map
}
/// Computes which reducible operations are held pending under the
/// missing-causal-predecessor rule (Chapter 6 §6.5, §6.6), and why.
///
/// An operation is directly blocked if a causal predecessor is absent,
/// equivocated, or excluded; the block then propagates transitively to anything
/// that causally depends on a blocked operation. Ties between multiple blocking
/// causes are broken by smallest blocker `OperationId`, so the reason is
/// deterministic.
fn compute_pending(
reducible: &[&OperationEnvelope],
reducible_ids: &BTreeSet<OperationId>,
equivocated: &BTreeSet<OperationId>,
excluded: &BTreeSet<OperationId>,
declared_transactions: &BTreeSet<TransactionId>,
) -> BTreeMap<OperationId, PendingReason> {
let mut blocked: BTreeMap<OperationId, PendingReason> = BTreeMap::new();
let known_ids: BTreeSet<OperationId> = 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) {
PendingReason::DependsOnEquivocated { on: d }
} else if excluded.contains(&d) {
PendingReason::DependsOnExcluded { on: d }
} else {
PendingReason::MissingCausalPredecessor { missing: d }
};
causes.push((d, reason));
}
}
for e in equivocated {
if env.causal_context.covers(*e) {
causes.push((*e, PendingReason::DependsOnEquivocated { on: *e }));
}
}
for x in excluded {
if env.causal_context.covers(*x) {
causes.push((*x, PendingReason::DependsOnExcluded { on: *x }));
}
}
if let Some((_, reason)) = causes.into_iter().min_by_key(|(id, _)| *id) {
blocked.insert(env.id, reason);
}
}
// Transitive propagation: an op covering a blocked op is itself blocked.
loop {
let mut changed = false;
for env in reducible {
if blocked.contains_key(&env.id) {
continue;
}
// Smallest blocked id this op covers.
let cover = blocked
.keys()
.copied()
.find(|b| env.causal_context.covers(*b));
if let Some(on) = cover {
blocked.insert(env.id, PendingReason::DependsOnPending { on });
changed = true;
}
}
if !changed {
break;
}
}
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<OperationId>,
) -> Option<OperationId> {
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::*;
use crate::causal::CausalContext;
use crate::stamp::{HybridLogicalClock, OperationStamp};
use crate::support::AuthorId;
use epiphany_core::{RationalTime, ReplicaId, StaffInstanceId, WallClockTime};
fn pos(n: i64) -> MusicalPosition {
MusicalPosition(RationalTime::from_int(n as i32))
}
fn insert(
replica: u64,
counter: u64,
physical: i64,
voice: u64,
event: u64,
pos_units: i64,
) -> OperationEnvelope {
let id = OperationId::new(ReplicaId(replica), counter);
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
// Voice and staff instance live in a shared (author-independent)
// namespace so two authors can target the same voice.
staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
event: crate::valuegen::insert_event_value(
EventId::new(ReplicaId(replica), event),
VoiceId::new(ReplicaId(9), voice),
pos(pos_units),
epiphany_core::MusicalDuration::whole(),
&[],
),
})),
}
}
#[test]
fn reduction_is_permutation_invariant() {
let envs = vec![
insert(1, 0, 10, 1, 100, 0),
insert(1, 1, 20, 1, 101, 1),
insert(2, 0, 15, 2, 200, 0),
];
let mut forward = OperationSet::new();
forward.accept_all(envs.clone());
let mut backward = OperationSet::new();
let mut rev = envs.clone();
rev.reverse();
backward.accept_all(rev);
assert_eq!(
forward.reduce().canonical_bytes(),
backward.reduce().canonical_bytes()
);
}
#[test]
fn concurrent_same_position_insert_promotes_the_greater_id() {
// Two concurrent inserts, same voice and position, different events.
let a = insert(1, 0, 10, 7, 100, 5);
let b = insert(2, 0, 10, 7, 200, 5);
let mut set = OperationSet::new();
set.accept_all(vec![a.clone(), b.clone()]);
let state = set.reduce();
// The greater OperationId (replica 2) is promoted to a system voice.
let promoted = state
.effects
.iter()
.find(|(id, _)| *id == b.id)
.map(|(_, e)| e);
assert!(matches!(
promoted,
Some(OperationEffect::AppliedWithRepair { .. })
));
let kept = state
.effects
.iter()
.find(|(id, _)| *id == a.id)
.map(|(_, e)| e);
assert_eq!(kept, Some(&OperationEffect::Applied));
}
#[test]
fn resolve_conflict_with_dismiss_reaches_dismissed_state() {
// Pass 11, item 2.5: an authored ResolveConflict whose action is
// `Dismiss` must reach the `Dismissed` resolution state (previously
// `Dismissed` was representable but unreachable by any authored op).
use crate::conflict::{ConflictResolutionState, ResolutionAction};
use crate::payload::{ResolveConflictPayload, RespellPitchOp};
use epiphany_core::PitchId;
let pitch = PitchId::new(ReplicaId(9), 500);
// An InsertEvent carrying `pitch` makes the pitch Live.
let mut insert_env = insert(1, 0, 10, 1, 100, 0);
if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) =
insert_env.payload
{
op.event = crate::valuegen::insert_event_value(
op.event_id(),
op.voice(),
op.musical_position(),
op.musical_duration(),
&[pitch],
);
}
// Two concurrent, differing respellings of `pitch`, both causally after
// the insert (so the pitch is Live) but concurrent with each other (so
// they collide into a StructuralFieldCollision conflict).
let respell = |replica: u64, counter: u64, physical: i64, byte: u8| {
let id = OperationId::new(ReplicaId(replica), counter);
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: CausalContext::new().with_seen(ReplicaId(1), 0),
transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch,
spelling: crate::valuegen::spelling(byte),
})),
}
};
let respell_a = respell(2, 0, 20, 0xAA);
let respell_b = respell(3, 0, 21, 0xBB);
// Phase 1: reduce to discover the content-derived conflict id.
let mut set = OperationSet::new();
set.accept_all(vec![
insert_env.clone(),
respell_a.clone(),
respell_b.clone(),
]);
let state = set.reduce();
assert_eq!(
state.conflicts.records().len(),
1,
"expected exactly one field-collision conflict"
);
let cid = state.conflicts.records()[0].id;
assert_eq!(
state.conflicts.records()[0].resolution_state,
ConflictResolutionState::Unresolved
);
// Phase 2: author ResolveConflict { Dismiss } against that conflict,
// causally after the colliding respells so it reduces against the
// already-created conflict record.
let resolve_id = OperationId::new(ReplicaId(4), 0);
let resolve_env = OperationEnvelope {
id: resolve_id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(30), 0), resolve_id),
causal_context: CausalContext::new()
.with_dot(respell_a.id)
.with_dot(respell_b.id),
transaction: None,
payload: OperationPayload::ResolveConflict(ResolveConflictPayload {
target: cid,
action: ResolutionAction::Dismiss,
}),
};
let mut set2 = OperationSet::new();
set2.accept_all(vec![insert_env, respell_a, respell_b, resolve_env]);
let state2 = set2.reduce();
let rec = state2
.conflicts
.records()
.iter()
.find(|r| r.id == cid)
.expect("conflict still present after resolution");
assert_eq!(
rec.resolution_state,
ConflictResolutionState::Dismissed { by: resolve_id },
"Dismiss action must select the Dismissed state"
);
}
// --- Group 1 (M2) behavior. --------------------------------------------
fn prim_env(
replica: u64,
counter: u64,
physical: i64,
ctx: CausalContext,
kind: OperationKind,
) -> OperationEnvelope {
let id = OperationId::new(ReplicaId(replica), counter);
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: ctx,
transaction: None,
payload: OperationPayload::Primitive(kind),
}
}
fn modify_event_of(event: EventId, pitch: PitchId) -> OperationKind {
OperationKind::ModifyEvent(crate::payload::ModifyEventOp {
event: crate::valuegen::insert_event_value(
event,
VoiceId::new(ReplicaId(9), 1),
pos(0),
epiphany_core::MusicalDuration::whole(),
&[pitch],
),
})
}
#[test]
fn concurrent_differing_modify_event_conflicts() {
let event = EventId::new(ReplicaId(1), 100);
let seen = CausalContext::new().with_seen(ReplicaId(1), 0);
// Insert the event (replica 1), then two concurrent differing modifies.
let insert = insert(1, 0, 10, 1, 100, 0);
let mod_a = prim_env(
2,
0,
20,
seen.clone(),
modify_event_of(event, PitchId::new(ReplicaId(1), 1)),
);
let mod_b = prim_env(
3,
0,
20,
seen,
modify_event_of(event, PitchId::new(ReplicaId(1), 2)),
);
let mut set = OperationSet::new();
set.accept_all(vec![insert, mod_a, mod_b]);
let state = set.reduce();
assert_eq!(
state.conflicts.records().len(),
1,
"concurrent differing ModifyEvent must record exactly one conflict"
);
assert!(matches!(
state.conflicts.records()[0].kind,
ConflictKind::StructuralFieldCollision { .. }
));
}
// --- ModifyEvent placement changes (trim/move): the make-room enabler. ------
fn musical(numerator: i64, denominator: i64) -> MusicalDuration {
MusicalDuration(RationalTime::new(numerator, denominator).unwrap())
}
fn position_of(numerator: i64, denominator: i64) -> MusicalPosition {
MusicalPosition(RationalTime::new(numerator, denominator).unwrap())
}
/// An InsertEvent of a rest at an explicit metric span.
fn insert_at(
replica: u64,
counter: u64,
event: u64,
voice: u64,
position: MusicalPosition,
duration: MusicalDuration,
ctx: CausalContext,
) -> OperationEnvelope {
prim_env(
replica,
counter,
(counter as i64 + 1) * 10,
ctx,
OperationKind::InsertEvent(InsertEventOp {
staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
event: crate::valuegen::insert_event_value(
EventId::new(ReplicaId(replica), event),
VoiceId::new(ReplicaId(9), voice),
position,
duration,
&[],
),
}),
)
}
/// A ModifyEvent that re-places `event` (a rest) at a new metric span.
fn modify_to(
replica: u64,
counter: u64,
event: u64,
voice: u64,
position: MusicalPosition,
duration: MusicalDuration,
ctx: CausalContext,
) -> OperationEnvelope {
prim_env(
replica,
counter,
(counter as i64 + 1) * 10,
ctx,
OperationKind::ModifyEvent(crate::payload::ModifyEventOp {
event: crate::valuegen::insert_event_value(
EventId::new(ReplicaId(replica), event),
VoiceId::new(ReplicaId(9), voice),
position,
duration,
&[],
),
}),
)
}
fn effect_at(state: &MaterializedState, counter: u64) -> Option<&OperationEffect> {
state
.effects
.iter()
.find(|(id, _)| *id == OperationId::new(ReplicaId(1), counter))
.map(|(_, effect)| effect)
}
#[test]
fn modify_event_trim_frees_the_voice_slot() {
// e1 fills [0, 1). Trim it to [0, 1/2), then insert e2 into the freed [1/2, 1):
// the insert only fits if the trim updated the voice-occupancy index.
let e1 = insert(1, 0, 10, 1, 100, 0);
let trim = modify_to(
1,
1,
100,
1,
position_of(0, 1),
musical(1, 2),
CausalContext::new().with_seen(ReplicaId(1), 0),
);
let e2 = insert_at(
1,
2,
101,
1,
position_of(1, 2),
musical(1, 2),
CausalContext::new().with_seen(ReplicaId(1), 1),
);
let mut set = OperationSet::new();
set.accept_all(vec![e1, trim, e2]);
let state = set.reduce();
assert!(
matches!(effect_at(&state, 1), Some(OperationEffect::Applied)),
"the trim applies"
);
assert!(
matches!(effect_at(&state, 2), Some(OperationEffect::Applied)),
"the insert fits the span the trim freed (occupancy was updated)"
);
}
#[test]
fn modify_event_move_onto_a_sibling_is_refused() {
// e1 [0, 1), e2 [1, 2). Moving e1 onto e2's span would break invariant 3, so
// it is refused (a clean NoOp), not silently skipped.
let e1 = insert(1, 0, 10, 1, 100, 0);
let e2 = insert(1, 1, 20, 1, 101, 1);
let onto_sibling = modify_to(
1,
2,
100,
1,
position_of(1, 1),
musical(1, 1),
CausalContext::new().with_seen(ReplicaId(1), 1),
);
let mut set = OperationSet::new();
set.accept_all(vec![e1, e2, onto_sibling]);
let state = set.reduce();
assert!(
matches!(
effect_at(&state, 2),
Some(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::EventDurationInvalid,
},
})
),
"a move onto a live sibling is refused"
);
assert!(
state.is_clean(),
"a refused move records no conflict/anomaly (like an insert overlap no-op)"
);
}
#[test]
fn modify_event_trim_materializes_in_the_graph() {
use epiphany_core::check_invariants;
use epiphany_core::generators::valid_score;
// Shrink the first metric event's duration; the change must now reach the
// graph (it was previously deferred) and leave the voice invariant-3 valid.
let base = valid_score(0x5EED);
let (event_id, duration) = base
.voices()
.flat_map(|(_, _, v)| v.events.clone())
.find_map(|eid| {
let ev = base.events.get(eid)?;
match (ev.position(), ev.duration()) {
(EventPosition::Musical(_), EventDuration::Musical(d)) => {
Some((eid, d.clone()))
}
_ => None,
}
})
.expect("the fixture has a metric event");
let half = MusicalDuration(duration.0.mul(&RationalTime::new(1, 2).unwrap()));
let mut shrunk = base.events.get(event_id).unwrap().clone();
match &mut shrunk {
Event::Pitched(pe) => pe.duration = EventDuration::Musical(half.clone()),
Event::Rest(rest) => rest.duration = EventDuration::Musical(half.clone()),
_ => panic!("a metric event is pitched or a rest"),
}
let modify = prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::ModifyEvent(crate::payload::ModifyEventOp { event: shrunk }),
);
let mut set = OperationSet::new();
set.accept_all(vec![modify]);
let result = set.reduce_onto(&base);
assert_eq!(
result.score.events.get(event_id).map(Event::duration),
Some(&EventDuration::Musical(half)),
"the trimmed duration is materialized in the graph"
);
assert!(
check_invariants(&result.score).is_empty(),
"the trimmed voice stays sorted and non-overlapping"
);
}
#[test]
fn modify_event_does_not_rewrite_a_non_metric_event_as_metric() {
use epiphany_core::check_invariants;
use epiphany_core::generators::valid_score_rich;
// A ModifyEvent that rewrites a wall-clock event (a proportional region) into a
// metric one must stay deferred — materializing it would re-place a non-metric
// event onto a musical grid, breaking the region's time-model invariant.
let base = valid_score_rich(0x5EED);
let event_id = base
.voices()
.flat_map(|(_, _, v)| v.events.clone())
.find(|eid| {
matches!(
base.events.get(*eid).map(Event::position),
Some(EventPosition::WallClock(_))
)
})
.expect("the fixture has a proportional region with a wall-clock event");
let voice = base.events.get(event_id).unwrap().voice();
let as_metric = crate::valuegen::insert_event_value(
event_id,
voice,
position_of(0, 1),
musical(1, 2),
&[],
);
let modify = prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::ModifyEvent(crate::payload::ModifyEventOp { event: as_metric }),
);
let mut set = OperationSet::new();
set.accept_all(vec![modify]);
let result = set.reduce_onto(&base);
assert!(
matches!(
result.score.events.get(event_id).map(Event::position),
Some(EventPosition::WallClock(_))
),
"the non-metric event is left as-is, not rewritten onto the musical grid"
);
assert!(
check_invariants(&result.score).is_empty(),
"the graph stays invariant-valid"
);
}
#[test]
fn modify_event_malformed_move_does_not_free_the_slot() {
// A ModifyEvent rewriting e1 (filling [0, 1)) to an *empty* (malformed) pitched
// event at [0, 1/2) is not materialized in the graph — so it must not move the
// occupancy index either. A later insert into [1/2, 1) is therefore refused,
// since e1 still occupies [0, 1).
let e1 = insert(1, 0, 10, 1, 100, 0);
let empty = Event::Pitched(epiphany_core::PitchedEvent {
id: EventId::new(ReplicaId(1), 100),
voice: VoiceId::new(ReplicaId(9), 1),
position: EventPosition::Musical(position_of(0, 1)),
duration: EventDuration::Musical(musical(1, 2)),
pitches: vec![],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: epiphany_core::StemConfiguration,
grace: None,
});
let malformed = prim_env(
1,
1,
20,
CausalContext::new().with_seen(ReplicaId(1), 0),
OperationKind::ModifyEvent(crate::payload::ModifyEventOp { event: empty }),
);
let e2 = insert_at(
1,
2,
101,
1,
position_of(1, 2),
musical(1, 2),
CausalContext::new().with_seen(ReplicaId(1), 1),
);
let mut set = OperationSet::new();
set.accept_all(vec![e1, malformed, e2]);
let state = set.reduce();
assert!(
matches!(
effect_at(&state, 2),
Some(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction { .. },
})
),
"the malformed trim did not free the slot, so the later insert is refused"
);
}
#[test]
fn insert_then_delete_identified_pitch_tombstones() {
let event = EventId::new(ReplicaId(1), 100);
let pitch = PitchId::new(ReplicaId(1), 7);
let insert = insert(1, 0, 10, 1, 100, 0);
let add = prim_env(
1,
1,
20,
CausalContext::new().with_seen(ReplicaId(1), 0),
OperationKind::InsertIdentifiedPitch(crate::payload::InsertIdentifiedPitchOp {
event,
pitch: crate::valuegen::identified_pitch(pitch),
}),
);
let del = prim_env(
1,
2,
30,
CausalContext::new().with_seen(ReplicaId(1), 1),
OperationKind::DeleteIdentifiedPitch(crate::payload::DeleteIdentifiedPitchOp { pitch }),
);
let mut after_add = OperationSet::new();
after_add.accept_all(vec![insert.clone(), add.clone()]);
assert_eq!(
after_add.reduce().objects.get(&TypedObjectId::Pitch(pitch)),
Some(&ObjectState::Live),
"InsertIdentifiedPitch mints the pitch live"
);
let mut after_del = OperationSet::new();
after_del.accept_all(vec![insert, add, del]);
assert!(
matches!(
after_del.reduce().objects.get(&TypedObjectId::Pitch(pitch)),
Some(ObjectState::Tombstoned { .. })
),
"DeleteIdentifiedPitch tombstones the pitch"
);
}
// --- Group 2 (M2) behavior. --------------------------------------------
fn create_slur(slur: epiphany_core::SlurId, a: EventId, b: EventId) -> OperationKind {
OperationKind::CreateCrossCutting(crate::payload::CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(crate::valuegen::slur(slur, a, b)),
})
}
#[test]
fn delete_cross_cutting_tombstones_the_structure() {
let e1 = EventId::new(ReplicaId(1), 100);
let e2 = EventId::new(ReplicaId(1), 101);
let slur = epiphany_core::SlurId::new(ReplicaId(1), 1);
let sid = TypedObjectId::Slur(slur);
let create = prim_env(
1,
2,
12,
CausalContext::new().with_seen(ReplicaId(1), 1),
create_slur(slur, e1, e2),
);
let delete = prim_env(
1,
3,
13,
CausalContext::new().with_seen(ReplicaId(1), 2),
OperationKind::DeleteCrossCutting(crate::payload::DeleteCrossCuttingOp {
structure: sid,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![
insert(1, 0, 10, 1, 100, 0),
insert(1, 1, 11, 1, 101, 1),
create,
delete,
]);
let state = set.reduce();
assert!(
matches!(
state.objects.get(&sid),
Some(ObjectState::Tombstoned { .. })
),
"DeleteCrossCutting tombstones the structure (delete-wins)"
);
}
#[test]
fn concurrent_differing_modify_cross_cutting_conflicts() {
let e1 = EventId::new(ReplicaId(1), 100);
let e2 = EventId::new(ReplicaId(1), 101);
let e3 = EventId::new(ReplicaId(1), 102);
let slur = epiphany_core::SlurId::new(ReplicaId(1), 1);
let create = prim_env(
1,
3,
13,
CausalContext::new().with_seen(ReplicaId(1), 2),
create_slur(slur, e1, e2),
);
// Two concurrent modifies (seen the create, not each other) with differing
// endpoints: e1->e2 vs e1->e3.
let seen_create = CausalContext::new().with_seen(ReplicaId(1), 3);
let modify = |replica: u64, end: EventId| {
prim_env(
replica,
0,
20,
seen_create.clone(),
OperationKind::ModifyCrossCutting(crate::payload::ModifyCrossCuttingOp {
structure: CrossCuttingValue::Slur(crate::valuegen::slur(slur, e1, end)),
}),
)
};
let mut set = OperationSet::new();
set.accept_all(vec![
insert(1, 0, 10, 1, 100, 0),
insert(1, 1, 11, 1, 101, 1),
insert(1, 2, 12, 1, 102, 2),
create,
modify(2, e2),
modify(3, e3),
]);
let state = set.reduce();
assert_eq!(
state.conflicts.records().len(),
1,
"concurrent differing ModifyCrossCutting must record exactly one conflict"
);
assert!(matches!(
state.conflicts.records()[0].kind,
ConflictKind::StructuralFieldCollision { .. }
));
}
}