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

10842 lines
442 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! The 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::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use epiphany_core::{
canonical_pitch_bytes, derive_promoted_voice_id, AnchorOffset, AnnotationAnchor,
CanonicalValue, Event, EventDuration, EventId, EventPosition, GestureAnchoring, InstrumentId,
MeterChange, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId,
PitchSpelling, RationalTime, RegionEdge, RegionId, RegionTimeModel, ReplicaId, Score,
ScoreMetadata, SpellingAttachment, SpellingDirective, SpellingScope, SpellingSource, Staff,
StaffId, StaffInstance, StaffInstanceId, StaffLineConfiguration, TempoMap, TempoSegment,
TempoShape, TimeAnchor, TimeSignature, TimeSignatureId, 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::{
resolved_anchor_position, CreateCrossCuttingOp, CreateRegionOp, CreateRepeatStructureOp,
CreateStaffInstanceOp, CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp,
DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteRepeatStructureOp,
DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp,
ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload,
RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp,
SetTimeSignatureOp, SetUserPageBreakOp, TransposeOp, TupletCompensation,
};
use crate::stamp::StampTuple;
use crate::support::{ObjectKind, SerializedCanonicalInputs};
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.
///
/// ## Subquadratic construction (worklist F1 → K fix)
///
/// The order is *defined* pairwise: an edge `p → s` exists iff `p != s` and
/// `s.causal_context.covers(p.id)`; an envelope is *ready* when every present
/// covered predecessor has been emitted; the smallest reduction tuple among
/// ready envelopes emits next (slice position breaks the — duplicate-id-only —
/// tuple ties, matching `min_by_key`'s first-minimum rule, which the retained
/// test-only `canonical_reduction_order_reference` oracle implements literally
/// in O(n²)). Materializing the edges is inherently quadratic for the common
/// chain-context shape (every DVV floor covers the full replica prefix), so
/// this implementation never enumerates pairs. It decomposes each context into
/// *requirement terms* whose conjunction is exactly pairwise readiness:
///
/// * A **vector floor** `(r, n)` covers precisely the present envelopes of
/// replica `r` with counter `<= n` (the zero-based DVV floor, P11-C7) —
/// a *prefix* of the replica's lane sorted by `(counter, slice index)`.
/// The term is satisfied when the lane's emission frontier (the first
/// unemitted lane slot) passes the prefix; a floor that covers the
/// envelope's *own* id exempts only the self-pair, so that term is instead
/// "frontier at own slot and *second* frontier past the prefix". Both
/// frontiers are monotone, so each term is woken exactly once from a
/// `BTreeMap` keyed by the threshold slot.
/// * An **explicit dot** covers precisely the present envelopes bearing that
/// id; the term is satisfied when the id's unemitted multiplicity reaches
/// zero (or one, for a dot naming the envelope's own id). A dot also lying
/// under one of the context's own floors yields a (redundant) second term —
/// harmless, because readiness is the conjunction of terms, not a
/// predecessor count, so no per-pair dedup is needed.
///
/// Context entries covering no present envelope (absent replicas, floors below
/// every present counter, absent dots) yield no term, exactly as they yield no
/// edge pairwise. Total work is `O((n + Σ context entries) · log n)`.
pub fn canonical_reduction_order<'a>(
envelopes: &[&'a OperationEnvelope],
) -> Vec<&'a OperationEnvelope> {
let len = envelopes.len();
let keys: Vec<StampTuple> = envelopes
.iter()
.map(|env| env.stamp.reduction_tuple())
.collect();
// Static indexes over the present set: per-replica lanes sorted by
// (counter, slice index), each envelope's (lane, slot) position, and the
// per-id unemitted multiplicity (> 1 only for duplicate ids, e.g.
// equivocation twins fed to this function directly).
let mut lane_of: BTreeMap<ReplicaId, usize> = BTreeMap::new();
let mut lanes: Vec<OrderLane> = Vec::new();
let mut position = vec![(0usize, 0usize); len];
{
let mut sorted: Vec<(ReplicaId, u64, usize)> = envelopes
.iter()
.enumerate()
.map(|(index, env)| (env.id.replica, env.id.counter, index))
.collect();
sorted.sort_unstable();
for (replica, counter, index) in sorted {
let lane_index = *lane_of.entry(replica).or_insert_with(|| {
lanes.push(OrderLane::default());
lanes.len() - 1
});
let lane = &mut lanes[lane_index];
position[index] = (lane_index, lane.slots.len());
lane.slots.push(index);
lane.counters.push(counter);
}
for lane in &mut lanes {
// All slots start unemitted: frontier 0, second frontier 1
// (clamped to the lane length, the "exhausted" sentinel).
lane.second = 1.min(lane.slots.len());
}
}
let mut id_slots: BTreeMap<OperationId, IdSlot> = BTreeMap::new();
for env in envelopes {
id_slots.entry(env.id).or_default().unemitted += 1;
}
// One requirement term per covering context entry; `remaining` counts the
// currently-unsatisfied terms. Terms already satisfied here (they cover
// nothing, or nothing beyond the envelope itself) register no watcher.
let mut remaining = vec![0usize; len];
for (index, env) in envelopes.iter().enumerate() {
for (&replica, &floor) in &env.causal_context.vector {
let Some(&lane_index) = lane_of.get(&replica) else {
continue;
};
let lane = &mut lanes[lane_index];
let prefix = lane.counters.partition_point(|&counter| counter <= floor);
if prefix == 0 {
continue;
}
if env.id.replica == replica && env.id.counter <= floor {
// The floor covers this envelope's own id; only the self-pair
// is exempt. Required: every other prefix slot emitted, i.e.
// frontier at the own slot *and* second frontier past the
// prefix (the own slot stays unemitted until emission).
let own_slot = position[index].1;
if lane.frontier < own_slot {
remaining[index] += 1;
lane.frontier_watchers.entry(own_slot).or_default().push(
FloorWatcher::ExceptSelf {
node: index,
prefix,
},
);
} else if lane.second < prefix {
remaining[index] += 1;
lane.second_watchers.entry(prefix).or_default().push(index);
}
} else if lane.frontier < prefix {
remaining[index] += 1;
lane.frontier_watchers
.entry(prefix)
.or_default()
.push(FloorWatcher::Whole { node: index });
}
}
for dot in env.causal_context.dots() {
let Some(id_slot) = id_slots.get_mut(&dot) else {
continue; // absent id: covers nothing present, no edge
};
if dot == env.id {
// A dot naming the envelope's own id covers only duplicates.
if id_slot.unemitted > 1 {
remaining[index] += 1;
id_slot.watch_one.push(index);
}
} else {
remaining[index] += 1;
id_slot.watch_zero.push(index);
}
}
}
// Deterministic Kahn walk. The heap holds every envelope whose terms are
// all satisfied (pushed exactly at the transition; entries for envelopes
// already emitted through cycle-breaking are skipped lazily), keyed by
// (reduction tuple, slice index) — the reference's `min_by_key` order.
let mut heap: BinaryHeap<Reverse<(StampTuple, usize)>> = (0..len)
.filter(|&index| remaining[index] == 0)
.map(|index| Reverse((keys[index], index)))
.collect();
let mut by_key: Vec<usize> = (0..len).collect();
by_key.sort_unstable_by_key(|&index| (keys[index], index));
let mut cycle_cursor = 0usize;
let mut emitted = vec![false; len];
let mut ordered = Vec::with_capacity(len);
let mut woken: Vec<usize> = Vec::new();
while ordered.len() < len {
let mut ready = None;
while let Some(Reverse((_, index))) = heap.pop() {
if !emitted[index] {
ready = Some(index);
break;
}
}
let next = match ready {
Some(index) => index,
None => {
// A cycle is malformed, but selecting by the canonical
// tie-breaker keeps reduction deterministic and unlocks the
// forced envelope's dependents.
while emitted[by_key[cycle_cursor]] {
cycle_cursor += 1;
}
by_key[cycle_cursor]
}
};
emitted[next] = true;
ordered.push(envelopes[next]);
// Dot wake-ups: the id's unemitted multiplicity dropped by one.
let id_slot = id_slots
.get_mut(&envelopes[next].id)
.expect("every present id has a slot");
id_slot.unemitted -= 1;
if id_slot.unemitted <= 1 {
woken.append(&mut id_slot.watch_one);
}
if id_slot.unemitted == 0 {
woken.append(&mut id_slot.watch_zero);
}
// Floor wake-ups: advance the lane frontiers (both point at unemitted
// slots — or the lane length — by invariant, so an emission below the
// second frontier is at one of them) and drain the passed watchers.
let (lane_index, slot) = position[next];
let lane = &mut lanes[lane_index];
if slot == lane.frontier {
lane.frontier = lane.second;
lane.second = next_unemitted(&lane.slots, &emitted, lane.frontier + 1);
} else if slot == lane.second {
lane.second = next_unemitted(&lane.slots, &emitted, slot + 1);
}
while let Some(entry) = lane.frontier_watchers.first_entry() {
if *entry.key() > lane.frontier {
break;
}
for watcher in entry.remove() {
match watcher {
FloorWatcher::Whole { node } => woken.push(node),
FloorWatcher::ExceptSelf { node, prefix } => {
if lane.second >= prefix {
woken.push(node);
} else {
lane.second_watchers.entry(prefix).or_default().push(node);
}
}
}
}
}
while let Some(entry) = lane.second_watchers.first_entry() {
if *entry.key() > lane.second {
break;
}
for node in entry.remove() {
woken.push(node);
}
}
for node in woken.drain(..) {
remaining[node] -= 1;
if remaining[node] == 0 && !emitted[node] {
heap.push(Reverse((keys[node], node)));
}
}
}
ordered
}
/// One replica's present envelopes in [`canonical_reduction_order`], sorted by
/// `(counter, slice index)`, with the two monotone emission frontiers and the
/// floor-term watchers keyed by the frontier slot they wait for.
#[derive(Default)]
struct OrderLane {
/// Envelope slice indexes, sorted by `(counter, slice index)`.
slots: Vec<usize>,
/// The slots' counters (parallel to `slots`, ascending).
counters: Vec<u64>,
/// First unemitted slot (== `slots.len()` once exhausted).
frontier: usize,
/// Second unemitted slot (>= `slots.len()` once fewer than two remain).
second: usize,
/// Floor terms waiting for `frontier >= key`.
frontier_watchers: BTreeMap<usize, Vec<FloorWatcher>>,
/// Self-exempt floor terms waiting for `second >= key`.
second_watchers: BTreeMap<usize, Vec<usize>>,
}
/// One present `OperationId`'s bookkeeping in [`canonical_reduction_order`]:
/// its unemitted multiplicity and the dot terms watching it.
#[derive(Default)]
struct IdSlot {
/// Present envelopes bearing this id that are not yet emitted.
unemitted: usize,
/// Dot terms satisfied when `unemitted` reaches zero.
watch_zero: Vec<usize>,
/// Self-dot terms (duplicate ids) satisfied when `unemitted` reaches one.
watch_one: Vec<usize>,
}
/// A floor term parked in [`OrderLane::frontier_watchers`].
enum FloorWatcher {
/// Satisfied outright when the frontier reaches its key (the prefix end).
Whole { node: usize },
/// A floor covering the node's own id: when the frontier reaches the
/// node's own slot (its key), the term is satisfied if the second
/// frontier already passed `prefix`, else it re-parks on the second
/// frontier.
ExceptSelf { node: usize, prefix: usize },
}
/// The first unemitted slot position at or after `from` (== `slots.len()` when
/// exhausted). Frontier scans only ever move forward, so the total scan work
/// per lane is linear.
fn next_unemitted(slots: &[usize], emitted: &[bool], from: usize) -> usize {
let mut at = from.min(slots.len());
while at < slots.len() && emitted[slots[at]] {
at += 1;
}
at
}
/// The pre-F1 O(n²) implementation, retained verbatim as the property-test
/// oracle for [`canonical_reduction_order`]: it materializes every covered
/// `(predecessor, successor)` pair and re-scans the whole set per emission,
/// which *is* the order's pairwise definition, executed literally.
#[cfg(test)]
pub(crate) fn canonical_reduction_order_reference<'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 },
/// Reduction halted at a system-derived identifier collision (Chapter 5
/// §"System-Derived Counter Collisions"): this operation is at or past the
/// collision point in canonical order — or is the earlier occupant of the
/// collided counter — and is held pending external recovery. `at` is the
/// operation whose mint collided.
HaltedBySystemCollision { at: OperationId },
}
impl PendingReason {
fn discriminant(&self) -> u8 {
match self {
PendingReason::MissingCausalPredecessor { .. } => 0,
PendingReason::DependsOnEquivocated { .. } => 1,
PendingReason::DependsOnExcluded { .. } => 2,
PendingReason::DependsOnPending { .. } => 3,
PendingReason::HaltedBySystemCollision { .. } => 4,
}
}
fn blocker(&self) -> OperationId {
match self {
PendingReason::MissingCausalPredecessor { missing } => *missing,
PendingReason::DependsOnEquivocated { on }
| PendingReason::DependsOnExcluded { on }
| PendingReason::DependsOnPending { on } => *on,
PendingReason::HaltedBySystemCollision { at } => *at,
}
}
}
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"),
}
}
/// One write in a per-key canonical-order write chain (operation_catalog
/// §UndoTransaction, "Value restoration"): the writer, the transaction it was a
/// member of (if any), and the value it wrote.
#[derive(Clone, PartialEq, Eq, Debug)]
struct ChainWrite<V> {
op: OperationId,
tx: Option<TransactionId>,
value: V,
}
/// The canonical-order write chain of one LWW-overwritten key (operation_catalog
/// §UndoTransaction). `base` is the key's pre-operational value — seeded from
/// the base graph (`seed_from_graph`) or from the value a mint carried — so a
/// chain-predecessor is defined for keys that exist before the first overwrite;
/// a key with no base entry restores to *absence*. `writes` append in canonical
/// processing order (the reducer applies operations in canonical order, so
/// appends are inherently canonical and the chain is permutation-invariant).
#[derive(Clone, PartialEq, Eq, Debug)]
struct WriteChain<V> {
base: Option<V>,
writes: Vec<ChainWrite<V>>,
}
impl<V: Clone> WriteChain<V> {
fn new() -> Self {
WriteChain {
base: None,
writes: Vec::new(),
}
}
/// Seeds the base value only if the chain has neither a base nor writes
/// (idempotent seeding; a mint never clobbers recorded history).
fn seed(&mut self, base: V) {
if self.base.is_none() && self.writes.is_empty() {
self.base = Some(base);
}
}
/// Appends a write in canonical processing order.
fn record(&mut self, op: OperationId, tx: Option<TransactionId>, value: V) {
self.writes.push(ChainWrite { op, tx, value });
}
/// The most recent write, if any (the LWW concurrency comparison point;
/// the base entry never participates in conflict detection).
fn last_write(&self) -> Option<&ChainWrite<V>> {
self.writes.last()
}
/// The key's current resolved value: the last write, falling back to the
/// base entry.
fn current(&self) -> Option<&V> {
self.last_write()
.map(|write| &write.value)
.or(self.base.as_ref())
}
/// The undo verdict for transaction `tx` on this key (operation_catalog
/// §UndoTransaction, "Value restoration").
fn undo_verdict(&self, tx: TransactionId) -> ChainUndoVerdict<V> {
if !self.writes.iter().any(|w| w.tx == Some(tx)) {
return ChainUndoVerdict::NotWritten;
}
let last = self.writes.last().expect("chain has a write by `tx`");
if last.tx != Some(tx) {
return ChainUndoVerdict::Superseded { by: last.op };
}
// Chain-predecessor: the latest entry not written by the transaction,
// falling back to the base value, else absence.
let predecessor = self
.writes
.iter()
.rev()
.find(|w| w.tx != Some(tx))
.map(|w| Predecessor::Write(w.value.clone()))
.or_else(|| self.base.clone().map(Predecessor::Base));
ChainUndoVerdict::Restore(predecessor)
}
}
/// The chain-predecessor an undo restores: a prior operational write, or the
/// key's base (pre-operational) value. The distinction matters for the
/// canonical bookkeeping maps (`spellings`, `breaks`, `page_breaks`), which
/// return to key-*absence* when the predecessor is the base value — the base
/// state lives in the graph, not in the operational ledger.
#[derive(Clone, Debug)]
enum Predecessor<V> {
Write(V),
Base(V),
}
impl<V> Predecessor<V> {
fn into_value(self) -> V {
match self {
Predecessor::Write(v) | Predecessor::Base(v) => v,
}
}
}
/// The per-key outcome of undoing a transaction's write chain.
enum ChainUndoVerdict<V> {
/// The transaction never wrote this key.
NotWritten,
/// The transaction wrote the key but a later write superseded it.
Superseded { by: OperationId },
/// The transaction's write is still last: restore the chain-predecessor
/// (`None` = the transaction introduced the first value; restore absence).
Restore(Option<Predecessor<V>>),
}
/// The staff-instance layout advisories `SetStaffLayout` overwrites as a unit
/// (operation_catalog §SetStaffLayout).
type StaffLayoutValue = (Option<InstrumentId>, Option<StaffLineConfiguration>, bool);
/// One key restoration a value-restoring undo applies (operation_catalog
/// §UndoTransaction "Value restoration"). For the always-valued families
/// (event, pitch, cross-cutting, metadata, staff layout) `None` means the
/// chain knows no predecessor (an object minted before this reduction's
/// horizon, reachable only base-free): the undo verdict stands but there is no
/// value to write back — a bookkeeping-only restoration. For the optional
/// families (grid, meter change, tempo segment) the flattened `None` *is* the
/// restoration: clear/remove at the key. The canonical bookkeeping families
/// (spelling, breaks) keep the [`Predecessor`] distinction: a base predecessor
/// returns the ledger map to key-absence while the graph restores the base
/// value.
enum ValueRestoration {
Event {
event: EventId,
value: Option<Event>,
},
Pitch {
pitch: PitchId,
value: Option<Pitch>,
},
Spelling {
pitch: PitchId,
predecessor: Option<Predecessor<PitchSpelling>>,
},
CrossCutting {
id: TypedObjectId,
value: Option<CrossCuttingValue>,
},
Metadata {
value: Option<ScoreMetadata>,
},
MetricGrid {
region: RegionId,
value: Option<MetricGrid>,
},
MeterChange {
region: RegionId,
position: MusicalPosition,
value: Option<MeterChange>,
},
TempoSegment {
region: Option<RegionId>,
position: MusicalPosition,
value: Option<TempoSegment>,
},
StaffLayout {
instance: StaffInstanceId,
value: Option<StaffLayoutValue>,
},
SystemBreak {
region: RegionId,
position: MusicalPosition,
predecessor: Option<Predecessor<(TimeAnchor, bool)>>,
},
PageBreak {
region: RegionId,
position: MusicalPosition,
predecessor: Option<Predecessor<(TimeAnchor, bool)>>,
},
}
/// Whether a tempo segment's shape carries the end data it requires
/// (operation_catalog §"Meter and Tempo Overwrites"): a non-constant shape
/// needs both an explicit `end` and an `end_tempo`; a constant segment's
/// `end_tempo`, when present, must equal its `start_tempo` (the same structural
/// rules the graph-invariant checker applies to tempo maps).
fn tempo_segment_shape_well_formed(segment: &TempoSegment) -> bool {
match segment.shape {
TempoShape::Constant => segment
.end_tempo
.as_ref()
.map_or(true, |end| end == &segment.start_tempo),
TempoShape::Linear | TempoShape::Exponential | TempoShape::Curve => {
segment.end_tempo.is_some() && segment.end.is_some()
}
}
}
/// Replaces (or removes, for `None`) the segment at `position` in `map`,
/// keeping the segment list ordered by resolved start position — the same
/// coarse anchor resolution the LWW key uses, so one resolved position holds
/// exactly one segment.
fn edit_tempo_map_segments(
map: &mut TempoMap,
position: &MusicalPosition,
segment: &Option<TempoSegment>,
) {
map.segments
.retain(|existing| resolved_anchor_position(&existing.start) != *position);
if let Some(segment) = segment {
let index = map
.segments
.iter()
.position(|existing| resolved_anchor_position(&existing.start) > *position)
.unwrap_or(map.segments.len());
map.segments.insert(index, segment.clone());
}
}
/// 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)>>,
// Per-key canonical-order write chains for every LWW overwrite family
// (operation_catalog §UndoTransaction "Value restoration"). Each chain's
// last write doubles as the LWW working state the concurrent-differing
// conflict detection reads (formerly the `last_*` maps); the full chain is
// what value-restoring undo walks. Advisory families (metadata, breaks,
// staff layout) keep chains for undo but record no conflicts.
respell_chain: BTreeMap<PitchId, WriteChain<PitchSpelling>>,
event_modify_chain: BTreeMap<EventId, WriteChain<Event>>,
pitch_modify_chain: BTreeMap<PitchId, WriteChain<Pitch>>,
cross_cutting_modify_chain: BTreeMap<TypedObjectId, WriteChain<CrossCuttingValue>>,
metric_grid_chain: BTreeMap<RegionId, WriteChain<Option<MetricGrid>>>,
metadata_chain: WriteChain<ScoreMetadata>,
break_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<(TimeAnchor, bool)>>,
page_break_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<(TimeAnchor, bool)>>,
// Meter/tempo overwrite chains (Phase-3 tranche): `Some` = a set/replace at
// the key, `None` = an explicit removal. The meter value is the graph-level
// `MeterChange` (anchor + signature id) so a restoration can re-install it.
meter_change_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<Option<MeterChange>>>,
tempo_segment_chain:
BTreeMap<(Option<RegionId>, MusicalPosition), WriteChain<Option<TempoSegment>>>,
staff_layout_chain: BTreeMap<StaffInstanceId, WriteChain<StaffLayoutValue>>,
// Carried values of set-union-minted staves and time signatures, for the
// byte-identical-re-carry idempotence check (operation_catalog §CreateStaff:
// identical re-create is idempotent; a differing value under a live id is a
// precondition no-op). Seeded from the base graph.
staff_values: BTreeMap<StaffId, Staff>,
time_signature_values: BTreeMap<TimeSignatureId, TimeSignature>,
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>>,
// The staff each staff instance manifests, for the containment-proximity
// key of the re-anchoring "nearest" ordering (same staff = rank 2). Kept
// base-free (seeded + maintained by CreateStaffInstance) so reduce() and
// reduce_onto() rank identically wherever both represent the scenario.
instance_staff: BTreeMap<StaffInstanceId, StaffId>,
// 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)>,
// System-derived mint registry for the counter-collision check (Chapter 5
// §"System-Derived Counter Collisions"): (kind, derived counter) → the
// canonical inputs that derived it, plus the minting operation (None for
// an occupant seeded from the base graph). Consulted only by the pre-walk
// collision detection, never mutated during apply, so it needs no
// transaction snapshot.
system_mints: BTreeMap<(ObjectKind, u64), (SerializedCanonicalInputs, Option<OperationId>)>,
tx_minted: BTreeMap<TransactionId, Vec<TypedObjectId>>,
current_tx: Option<TransactionId>,
// ResolveEquivocation promotion results (operation_catalog
// §"ResolveEquivocation"), computed by the set-level pre-pass in `run`:
// target slot id → (governing resolve id, chosen candidate hash), and
// target slot id → the promoted candidate envelope (from the opset's
// diagnostic candidate store). Pure functions of the slot map — never
// mutated during apply — so, like `system_mints`, they need no transaction
// snapshot.
equivocation_resolutions: BTreeMap<OperationId, (OperationId, crate::EnvelopeHash)>,
promoted_singles: BTreeMap<OperationId, &'a OperationEnvelope>,
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)>>,
respell_chain: BTreeMap<PitchId, WriteChain<PitchSpelling>>,
event_modify_chain: BTreeMap<EventId, WriteChain<Event>>,
pitch_modify_chain: BTreeMap<PitchId, WriteChain<Pitch>>,
cross_cutting_modify_chain: BTreeMap<TypedObjectId, WriteChain<CrossCuttingValue>>,
metric_grid_chain: BTreeMap<RegionId, WriteChain<Option<MetricGrid>>>,
metadata_chain: WriteChain<ScoreMetadata>,
break_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<(TimeAnchor, bool)>>,
page_break_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<(TimeAnchor, bool)>>,
meter_change_chain: BTreeMap<(RegionId, MusicalPosition), WriteChain<Option<MeterChange>>>,
tempo_segment_chain:
BTreeMap<(Option<RegionId>, MusicalPosition), WriteChain<Option<TempoSegment>>>,
staff_layout_chain: BTreeMap<StaffInstanceId, WriteChain<StaffLayoutValue>>,
staff_values: BTreeMap<StaffId, Staff>,
time_signature_values: BTreeMap<TimeSignatureId, TimeSignature>,
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
region_instances: BTreeMap<RegionId, BTreeSet<StaffInstanceId>>,
instance_voices: BTreeMap<StaffInstanceId, BTreeSet<VoiceId>>,
instance_staff: BTreeMap<StaffInstanceId, StaffId>,
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());
}
}
/// The 64-byte canonical input preimage of a promoted-voice derivation
/// (`MUSCSVCE`, Chapter 5 §"System-Promoted Voices"): staff_instance ‖
/// original_voice ‖ winning_op ‖ losing_op, 16 big-endian bytes each — exactly
/// the bytes [`derive_promoted_voice_id`] hashes. The collision check compares
/// these inputs to distinguish two derivations contending for one counter.
fn promoted_voice_inputs(
staff_instance: StaffInstanceId,
original_voice: VoiceId,
winning_op: OperationId,
losing_op: OperationId,
) -> Vec<u8> {
let mut inputs = Vec::with_capacity(64);
inputs.extend_from_slice(&staff_instance.canonical_bytes());
inputs.extend_from_slice(&original_voice.canonical_bytes());
inputs.extend_from_slice(&winning_op.canonical_bytes());
inputs.extend_from_slice(&losing_op.canonical_bytes());
inputs
}
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(),
)
}
pub(crate) 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
}
// --- Re-anchoring referent support (Chapter 6 §"Total Ordering for Nearest",
// §"The Re-Anchoring Rule Table"). --------------------------------------------
/// The tombstoned referent's resolved placement and containment, captured from
/// the graph at the moment [`Reducer::materialize_graph_delete`] removes it —
/// the referent side of the four-key "nearest" ordering and of the range
/// reconstructions in the re-anchoring rule table.
struct ReferentContext {
voice: VoiceId,
region: Option<RegionId>,
position: EventPosition,
duration: EventDuration,
}
/// Proximity bound "same staff instance" (the rule table's declared maximum for
/// markers and graphic gestures): candidates ranked farther than the referent's
/// staff instance are excluded from "nearest".
const PROXIMITY_SAME_STAFF_INSTANCE: u8 = 1;
/// Maps an *established* containment-proximity rank (k1 of the "nearest"
/// ordering) to the ratified [`ReanchorReason`] vocabulary. Rank 4 (same
/// canvas) records the appended `SameCanvasNearer` (Pass 12, P12-C4; wire
/// discriminant 6 — `DeclaredByExtension` already owned 5). Callers with a
/// possibly-*unestablished* rank 4 — `containment_rank` also returns 4 when a
/// voice's placement is unresolvable, a selection-order tie the recording
/// must not launder into a positive proximity claim — route through
/// [`Reduction::rank_reason`], which downgrades those to `ExplicitFallback`.
fn reason_for_rank(rank: u8) -> ReanchorReason {
match rank {
0 => ReanchorReason::SameVoiceNearer,
1 => ReanchorReason::SameStaffInstanceNearer,
2 => ReanchorReason::SameStaffNearer,
3 => ReanchorReason::SameRegionNearer,
4 => ReanchorReason::SameCanvasNearer,
_ => ReanchorReason::ExplicitFallback,
}
}
/// The OBJECT references among a set of [`TimeAnchor`]s — events, measures,
/// and regions (a wall-clock anchor references nothing). The mint-time
/// liveness set: every one of these must be `Live` for a mint to leave the
/// graph satisfying the reference-resolution invariants
/// (`anchor_target_exists` checks exactly these three kinds). Both reduction
/// modes agree: the base seed registers regions and measures in `objects`,
/// and base-free reductions simply have fewer live objects.
fn anchor_object_refs<'a>(anchors: impl IntoIterator<Item = &'a TimeAnchor>) -> Vec<TypedObjectId> {
anchors
.into_iter()
.filter_map(|anchor| match anchor {
TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)),
TimeAnchor::Measure { id, .. } => Some(TypedObjectId::Measure(*id)),
TimeAnchor::Region { id, .. } => Some(TypedObjectId::Region(*id)),
TimeAnchor::WallClock { .. } => None,
})
.collect()
}
/// The event references among a set of [`TimeAnchor`]s (the referent-index
/// entries a tombstone must repair). Non-event anchors contribute nothing.
fn anchor_event_refs<'a>(anchors: impl IntoIterator<Item = &'a TimeAnchor>) -> Vec<TypedObjectId> {
anchors
.into_iter()
.filter_map(|anchor| match anchor {
TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)),
_ => None,
})
.collect()
}
/// The event references an annotation anchor carries: its point event, or any
/// event-anchored range endpoints. Region anchors reference no event.
fn annotation_anchor_event_refs(anchor: &AnnotationAnchor) -> Vec<TypedObjectId> {
match anchor {
AnnotationAnchor::Event(event) => vec![TypedObjectId::Event(*event)],
AnnotationAnchor::Range { start, end } => anchor_event_refs([start, end]),
AnnotationAnchor::Region(_) => Vec::new(),
}
}
/// The event references a gesture anchoring carries. `Free` anchoring follows
/// no score content and so never enters the referent index (table row: "for
/// Free anchoring, no action").
fn gesture_event_refs(anchoring: &GestureAnchoring) -> Vec<TypedObjectId> {
match anchoring {
GestureAnchoring::Events(events) => {
events.iter().copied().map(TypedObjectId::Event).collect()
}
GestureAnchoring::Range { start, end, .. } => anchor_event_refs([start, end]),
GestureAnchoring::Free => Vec::new(),
}
}
/// Replaces a range endpoint anchored to the tombstoned event with the
/// containing region's edge — the deterministic "truncate" reading for
/// range-anchored referents (start endpoints move to the region start, end
/// endpoints to the region end; see DECISIONS.md and the proposed Pass-12 row
/// on the underdetermined "truncate" semantics).
fn retarget_dead_endpoint(
endpoint: &mut TimeAnchor,
deleted: EventId,
region: RegionId,
edge: RegionEdge,
) {
if matches!(endpoint, TimeAnchor::Event { id, .. } if *id == deleted) {
*endpoint = TimeAnchor::Region {
id: region,
edge,
offset: AnchorOffset::Zero,
};
}
}
/// Degrades an orphaned annotation anchor's dead event references to the
/// containing-region forms, so the orphaned (kept) referent stays
/// reference-clean under invariant 10. The ledger records `Orphaned`; this is
/// anchor hygiene, not a re-anchoring choice.
fn orphan_annotation_anchor(anchor: &mut AnnotationAnchor, deleted: EventId, region: RegionId) {
match anchor {
AnnotationAnchor::Event(event) if *event == deleted => {
*anchor = AnnotationAnchor::Region(region);
}
AnnotationAnchor::Range { start, end } => {
retarget_dead_endpoint(start, deleted, region, RegionEdge::Start);
retarget_dead_endpoint(end, deleted, region, RegionEdge::End);
}
_ => {}
}
}
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(),
respell_chain: BTreeMap::new(),
event_modify_chain: BTreeMap::new(),
pitch_modify_chain: BTreeMap::new(),
cross_cutting_modify_chain: BTreeMap::new(),
metric_grid_chain: BTreeMap::new(),
metadata_chain: WriteChain::new(),
break_chain: BTreeMap::new(),
page_break_chain: BTreeMap::new(),
meter_change_chain: BTreeMap::new(),
tempo_segment_chain: BTreeMap::new(),
staff_layout_chain: BTreeMap::new(),
staff_values: BTreeMap::new(),
time_signature_values: BTreeMap::new(),
structures: BTreeMap::new(),
region_instances: BTreeMap::new(),
instance_voices: BTreeMap::new(),
instance_staff: BTreeMap::new(),
staff_based_regions: BTreeSet::new(),
migrated_regions: BTreeSet::new(),
region_migrator: BTreeMap::new(),
descriptors: BTreeMap::new(),
promotion: BTreeMap::new(),
system_mints: BTreeMap::new(),
tx_minted: BTreeMap::new(),
current_tx: None,
equivocation_resolutions: BTreeMap::new(),
promoted_singles: BTreeMap::new(),
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);
// The carried value backs CreateStaff's byte-identical-re-carry
// idempotence check against base staves.
self.staff_values.insert(staff.id, staff.clone());
}
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,
);
self.time_signature_values
.insert(signature.id, signature.clone());
}
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);
}
// The score-level LWW chains seed with the base values so a
// value-restoring undo of the first operational write can restore the
// pre-operational state (operation_catalog §UndoTransaction).
self.metadata_chain.seed(score.metadata.clone());
for segment in &score.tempo_map.segments {
self.tempo_segment_chain
.entry((None, resolved_anchor_position(&segment.start)))
.or_insert_with(WriteChain::new)
.seed(Some(segment.clone()));
}
for region in &score.canvas.regions {
self.objects
.insert(TypedObjectId::Region(region.id), ObjectState::Live);
if let Some(content) = region.content.staff_based() {
self.staff_based_regions.insert(region.id);
self.metric_grid_chain
.entry(region.id)
.or_insert_with(WriteChain::new)
.seed(content.default_metric_grid.clone());
if let Some(grid) = &content.default_metric_grid {
for change in &grid.meter_sequence {
self.meter_change_chain
.entry((region.id, resolved_anchor_position(&change.anchor)))
.or_insert_with(WriteChain::new)
.seed(Some(change.clone()));
}
}
for anchor in &content.user_system_breaks {
self.break_chain
.entry((region.id, resolved_anchor_position(anchor)))
.or_insert_with(WriteChain::new)
.seed((anchor.clone(), true));
}
for anchor in &content.user_page_breaks {
self.page_break_chain
.entry((region.id, resolved_anchor_position(anchor)))
.or_insert_with(WriteChain::new)
.seed((anchor.clone(), true));
}
}
if let Some(local) = &region.local_tempo_map {
for segment in &local.segments {
self.tempo_segment_chain
.entry((Some(region.id), resolved_anchor_position(&segment.start)))
.or_insert_with(WriteChain::new)
.seed(Some(segment.clone()));
}
}
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);
self.instance_staff.insert(instance.id, instance.staff);
self.staff_layout_chain
.entry(instance.id)
.or_insert_with(WriteChain::new)
.seed((
instance.instrument_override,
instance.staff_lines_override.clone(),
instance.visible,
));
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);
// Register base system-promoted voices in the mint registry
// so a promotion minted by this reduction is collision-checked
// against them (Chapter 5 §"System-Derived Counter Collisions").
// Base-internal duplicates keep the first registration: a base
// that already collided is invariant-11/18 territory, not a
// reduction-time mint.
if voice.id.replica() == ReplicaId::SYSTEM_DERIVED {
if let VoiceOrigin::SystemPromoted {
winning_operation,
losing_operation,
original_voice,
} = &voice.origin
{
let inputs = promoted_voice_inputs(
instance.id,
*original_voice,
*winning_operation,
*losing_operation,
);
self.system_mints
.entry((ObjectKind::Voice, voice.id.counter()))
.or_insert((SerializedCanonicalInputs(inputs), None));
}
}
}
}
}
for event in score.events.iter_canonical() {
let event_id = event.id();
self.objects
.insert(TypedObjectId::Event(event_id), ObjectState::Live);
self.event_modify_chain
.entry(event_id)
.or_insert_with(WriteChain::new)
.seed(event.clone());
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.pitch_modify_chain
.entry(pitch.id)
.or_insert_with(WriteChain::new)
.seed(pitch.pitch.clone());
// Register base synthetic pitches in the mint registry (same
// rule as promoted voices above).
if pitch.id.replica() == ReplicaId::SYSTEM_DERIVED {
self.system_mints
.entry((ObjectKind::Pitch, pitch.id.counter()))
.or_insert((
SerializedCanonicalInputs(canonical_pitch_bytes(&pitch.pitch)),
None,
));
}
}
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));
}
// A cue event *references* its source events (Chapter 5 §"Cue
// Events"), so it enters the referent index: a source tombstone
// cascade-deletes the cue through the re-anchoring rule table.
if let Event::Cue(cue) = event {
if !cue.source.is_empty() {
self.structures.insert(
TypedObjectId::Event(event_id),
cue.source
.iter()
.copied()
.map(TypedObjectId::Event)
.collect(),
);
}
}
}
for slur in &score.cross_cutting.slurs {
let id = TypedObjectId::Slur(slur.id);
self.objects.insert(id, ObjectState::Live);
self.cross_cutting_modify_chain
.entry(id)
.or_insert_with(WriteChain::new)
.seed(CrossCuttingValue::Slur(slur.clone()));
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.cross_cutting_modify_chain
.entry(id)
.or_insert_with(WriteChain::new)
.seed(CrossCuttingValue::Tie(tie.clone()));
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.cross_cutting_modify_chain
.entry(id)
.or_insert_with(WriteChain::new)
.seed(CrossCuttingValue::Beam(beam.clone()));
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);
self.cross_cutting_modify_chain
.entry(id)
.or_insert_with(WriteChain::new)
.seed(CrossCuttingValue::Spanner(spanner.clone()));
// 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(),
);
}
// The remaining referent kinds of the re-anchoring rule table enter the
// same index as slurs/ties/beams/spanners, keyed by their typed id and
// listing the event references a tombstone must repair. Non-event
// anchorings (region, measure, wall-clock, free) contribute no entry.
for marker in &score.cross_cutting.markers {
self.objects
.insert(TypedObjectId::Marker(marker.id), ObjectState::Live);
let refs = anchor_event_refs([&marker.anchor]);
if !refs.is_empty() {
self.structures
.insert(TypedObjectId::Marker(marker.id), refs);
}
}
for annotation in &score.cross_cutting.analytical {
self.objects.insert(
TypedObjectId::AnalyticalAnnotation(annotation.id),
ObjectState::Live,
);
let refs = annotation_anchor_event_refs(&annotation.anchor);
if !refs.is_empty() {
self.structures
.insert(TypedObjectId::AnalyticalAnnotation(annotation.id), refs);
}
}
for comment in &score.cross_cutting.comments {
self.objects
.insert(TypedObjectId::Comment(comment.id), ObjectState::Live);
let refs = annotation_anchor_event_refs(&comment.anchor);
if !refs.is_empty() {
self.structures
.insert(TypedObjectId::Comment(comment.id), refs);
}
}
for gesture in &score.cross_cutting.graphic_gestures {
self.objects
.insert(TypedObjectId::GraphicGesture(gesture.id), ObjectState::Live);
let refs = gesture_event_refs(&gesture.anchoring);
if !refs.is_empty() {
self.structures
.insert(TypedObjectId::GraphicGesture(gesture.id), refs);
}
}
for repeat in &score.cross_cutting.repeats {
self.objects
.insert(TypedObjectId::RepeatStructure(repeat.id), ObjectState::Live);
// Repeats participate in event re-anchoring across every anchor
// site (rule table "Repeat structure / Anchor").
let refs = anchor_event_refs(repeat.anchor_sites());
if !refs.is_empty() {
self.structures
.insert(TypedObjectId::RepeatStructure(repeat.id), refs);
}
}
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);
}
// The base score's explicit user-chosen per-pitch spellings seed the
// respell chains, so undoing the first operational respell restores
// the base attachment value rather than dropping it (the bookkeeping
// `spellings` map still returns to key-absence — base state lives in
// the graph, not the operational ledger).
for attachment in &score.spelling_attachments {
if attachment.layer.is_none() && matches!(attachment.source, SpellingSource::UserChosen)
{
if let (SpellingScope::Pitch(pitch), SpellingDirective::Explicit(spelling)) =
(&attachment.scope, &attachment.directive)
{
self.respell_chain
.entry(*pitch)
.or_insert_with(WriteChain::new)
.seed(spelling.clone());
}
}
}
}
fn run(mut self) -> (MaterializedState, Option<Score>) {
let singles = self.op_set.single_envelopes();
let equivocated_all: 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,
});
}
// 1b. ResolveEquivocation promotion (operation_catalog
// §"ResolveEquivocation"): a set-level, order-independent pre-pass.
// Among the Single-slot, non-excluded resolves whose `target` is an
// Equivocated slot and whose `chosen` names one of its candidates, the
// resolve earliest in canonical order (smallest reduction tuple — the
// same total HLC order `canonical_reduction_order` selects ready
// operations by) governs. The chosen candidate envelope joins the
// reducible set at its own canonical position — the slot reduces as if
// it had always been Single — and no `OperationSlotEquivocated`
// anomaly is recorded for it. The verdict is a pure function of the
// slot map (never of arrival order), so every replica agrees. A
// resolve that is itself equivocated occupies no Single slot and thus
// never governs; a resolve in a quarantined segment is excluded from
// reduction and likewise never governs.
let mut governing: BTreeMap<OperationId, &OperationEnvelope> = BTreeMap::new();
for &env in &singles {
if excluded.contains(&env.id) {
continue;
}
let OperationPayload::ResolveEquivocation(op) = &env.payload else {
continue;
};
let Some(slot) = self.op_set.slot(op.target) else {
continue;
};
if !slot.is_equivocated() || !slot.candidates().any(|c| c == op.chosen) {
continue;
}
governing
.entry(op.target)
.and_modify(|current| {
if env.stamp.reduction_tuple() < current.stamp.reduction_tuple() {
*current = env;
}
})
.or_insert(env);
}
for (target, resolve) in &governing {
let OperationPayload::ResolveEquivocation(op) = &resolve.payload else {
unreachable!("only ResolveEquivocation envelopes govern a promotion");
};
let candidate = self
.op_set
.candidate(op.chosen)
.expect("every candidate hash of an equivocated slot is retained in the store");
self.promoted_singles.insert(*target, candidate);
self.equivocation_resolutions
.insert(*target, (resolve.id, op.chosen));
}
// The losing candidates remain only in the opset's diagnostic
// candidate store; a resolved slot records no equivocation anomaly.
let equivocated: BTreeSet<OperationId> = equivocated_all
.into_iter()
.filter(|id| !governing.contains_key(id))
.collect();
for id in &equivocated {
self.record_anomaly(IntegrityAnomalyKind::OperationSlotEquivocated {
operation_id: *id,
});
}
// 2. Reducible candidates = Single slots minus excluded, plus the
// promoted candidates (each at its own canonical position).
let reducible: Vec<&OperationEnvelope> = singles
.iter()
.copied()
.filter(|e| !excluded.contains(&e.id))
.chain(self.promoted_singles.values().copied())
.collect();
let reducible_ids: BTreeSet<OperationId> = reducible.iter().map(|e| e.id).collect();
let declared_transactions: BTreeSet<TransactionId> = singles
.iter()
.copied()
.chain(self.promoted_singles.values().copied())
.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);
// 5a. System-derived counter collision check (Chapter 5 §"System-Derived
// Counter Collisions"): on a collision, reduction does not continue past
// the collision point, and neither colliding input set occupies the
// collided counter. Held operations stay in the (grow-only) operation
// set and surface in `pending` for external recovery.
let mut held: BTreeMap<OperationId, PendingReason> = BTreeMap::new();
if let Some((halt_index, at, earlier_owner)) = self.detect_system_collision(&order) {
for env in &order[halt_index..] {
held.insert(env.id, PendingReason::HaltedBySystemCollision { at });
}
if let Some(owner) = earlier_owner {
held.insert(owner, PendingReason::HaltedBySystemCollision { at });
}
// Transitive closure: a transaction with a held member is wholly
// held (atomicity), and an operation causally covering a held
// operation is held behind it.
loop {
let mut changed = false;
for env in &order {
if held.contains_key(&env.id) {
continue;
}
let tx_blocked = member_transaction(env)
.and_then(|tx| tx_members.get(&tx))
.and_then(|members| {
members
.iter()
.map(|m| m.id)
.filter(|id| held.contains_key(id))
.min()
});
let causal_blocked =
held.keys().copied().find(|h| env.causal_context.covers(*h));
if let Some(on) = tx_blocked.into_iter().chain(causal_blocked).min() {
held.insert(env.id, PendingReason::DependsOnPending { on });
changed = true;
}
}
if !changed {
break;
}
}
}
let mut processed: BTreeSet<OperationId> = BTreeSet::new();
for env in &order {
if processed.contains(&env.id) || held.contains_key(&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().chain(held).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> {
// A slot promoted by a governing ResolveEquivocation reduces as if it
// had always been Single with the chosen candidate.
self.op_set
.slot(id)
.and_then(|s| s.single())
.or_else(|| self.promoted_singles.get(&id).copied())
}
// --- 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);
}
}
}
}
// --- System-derived counter collision check (Chapter 5 §"System-Derived
// Counter Collisions"). ---------------------------------------------------
/// The system-derived identifiers an operation would admit into canonical
/// state, each with the canonical inputs of its derivation: the promoted
/// voice assigned by the promotion pre-pass, and any `SYSTEM_DERIVED`-
/// namespace pitch carried by a minting payload (InsertEvent,
/// InsertIdentifiedPitch). Non-minting references to system-derived ids —
/// e.g. a ModifyEvent rewriting a live pitch's content in place — are
/// deliberately not treated as mints (that is Invariant-11 territory, not a
/// derivation collision).
fn prospective_system_mints(
&self,
env: &OperationEnvelope,
) -> Vec<((ObjectKind, u64), SerializedCanonicalInputs)> {
let mut mints = Vec::new();
let OperationPayload::Primitive(kind) = &env.payload else {
return mints;
};
match kind {
OperationKind::InsertEvent(op) => {
if let Some((promoted, winner)) = self.promotion.get(&env.id) {
mints.push((
(ObjectKind::Voice, promoted.counter()),
SerializedCanonicalInputs(promoted_voice_inputs(
op.staff_instance,
op.voice(),
*winner,
env.id,
)),
));
}
let mut pitches = Vec::new();
op.event.collect_identified_pitches(&mut pitches);
for pitch in pitches {
if pitch.id.replica() == ReplicaId::SYSTEM_DERIVED {
mints.push((
(ObjectKind::Pitch, pitch.id.counter()),
SerializedCanonicalInputs(canonical_pitch_bytes(&pitch.pitch)),
));
}
}
}
OperationKind::InsertIdentifiedPitch(op)
if op.pitch.id.replica() == ReplicaId::SYSTEM_DERIVED =>
{
mints.push((
(ObjectKind::Pitch, op.pitch.id.counter()),
SerializedCanonicalInputs(canonical_pitch_bytes(&op.pitch.pitch)),
));
}
_ => {}
}
mints
}
/// Walks the canonical order checking every prospective system-derived
/// mint against the registry (base-seeded occupants plus earlier mints in
/// the walk). On the first collision — the same `(kind, counter)` claimed
/// by *different* canonical inputs — records the
/// `SystemIdentifierCollision` anomaly and returns the halt point
/// `(index, colliding op, earlier occupant op)`: reduction must not
/// continue past the collision, and neither input set may occupy the
/// collided counter (§"System-Derived Counter Collisions"). The earlier
/// occupant is `None` when it was seeded from the base graph, which cannot
/// be evicted by this reduction and is left to diagnostic recovery.
///
/// The check is conservative: a claimed mint participates even if the
/// operation would later fail an unrelated apply-time precondition —
/// two input sets contending for one counter is a structural identity
/// failure regardless of which contender ultimately materializes.
fn detect_system_collision(
&mut self,
order: &[&OperationEnvelope],
) -> Option<(usize, OperationId, Option<OperationId>)> {
for (index, env) in order.iter().enumerate() {
for (key, inputs) in self.prospective_system_mints(env) {
match self.system_mints.get(&key) {
None => {
self.system_mints.insert(key, (inputs, Some(env.id)));
}
Some((existing, _)) if existing.0 == inputs.0 => {
// The same derivation re-observed: not a collision.
}
Some((existing, owner)) => {
let owner = *owner;
let existing = existing.clone();
self.record_anomaly(IntegrityAnomalyKind::SystemIdentifierCollision {
kind: key.0,
colliding_counter: key.1,
input_set_a: existing,
input_set_b: inputs,
});
return Some((index, env.id, owner));
}
}
}
}
None
}
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)
}
}
}
}
/// Removes the deleted event from the materialized graph and keeps it
/// reference-clean. Returns the repair records for the re-anchoring this
/// performs beyond the bookkeeping rules — the rule-table rows for the
/// graph-only referent kinds (markers, cue events, comments, analytical
/// annotations, graphic gestures) via [`Self::reanchor_event_referents`] —
/// so the triggering operation's effect can record them (Chapter 6
/// §Re-Anchoring: "Re-anchoring actions MUST be recorded as RepairRecord
/// entries in the triggering operation's effect").
fn materialize_graph_delete(
&mut self,
env: &OperationEnvelope,
op: &DeleteEventOp,
) -> Vec<RepairRecord> {
let Some(score) = self.graph.as_mut() else {
return Vec::new();
};
let Some(event) = score.events.remove(op.event) else {
return Vec::new();
};
let voice_id = event.voice();
let location = graph_voice_location(score, voice_id);
let region_id = location.map(|(region, _, _)| score.canvas.regions[region].id);
// The referent side of the four-key "nearest" ordering, captured before
// any mutation: the tombstoned event's containment and resolved
// placement (positions are region-relative; exact rational time in
// metric regions).
let referent = ReferentContext {
voice: voice_id,
region: region_id,
position: event.position().clone(),
duration: event.duration().clone(),
};
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));
// A decomposition component records its tuplet by id; once that tuplet is
// gone the reference would dangle (invariant 6, cross-cutting refs
// resolve), so drop any attachment that names a removed tuplet. The
// member it described is being tombstoned in the same cascade, so the
// decomposition has nothing left to describe.
score.decomposition_attachments.retain(|attachment| {
!attachment
.components
.iter()
.any(|component| component.tuplet.is_some_and(|t| removed.contains(&t)))
});
}
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;
// Repeat structures follow the same rule across EVERY anchor site
// (start/end, jump targets, volta spans): each dead event anchor
// re-anchors to the nearest surviving event anchor — the
// lexicographically smallest, matching `nearest_survivor`, so the
// graph and the ledger agree on both existence and target — and the
// structure cascade-deletes only when no event anchor survives.
let repeats = std::mem::take(&mut score.cross_cutting.repeats);
let kept_repeats: Vec<_> = repeats
.into_iter()
.filter_map(|mut repeat| {
let hit = repeat
.anchor_sites()
.into_iter()
.any(|a| matches!(a, TimeAnchor::Event { id, .. } if *id == op.event));
if !hit {
return Some(repeat);
}
let survivor = repeat
.anchor_sites()
.into_iter()
.filter_map(|a| match a {
TimeAnchor::Event { id, .. }
if *id != op.event && score.events.contains(*id) =>
{
Some(*id)
}
_ => None,
})
.min()?;
for site in repeat.anchor_sites_mut() {
if let TimeAnchor::Event { id, .. } = site {
if *id == op.event {
*id = survivor;
}
}
}
Some(repeat)
})
.collect();
score.cross_cutting.repeats = kept_repeats;
score.cross_cutting.lyrics.retain_mut(|line| {
line.events.retain(|event| *event != op.event);
!line.events.is_empty()
});
// The remaining rule-table rows — markers, cue events, comments,
// analytical annotations, graphic gestures — are decided and applied
// together (ledger record + graph mutation) once the event is out of
// the graph, so the two can never disagree.
self.reanchor_event_referents(env, op.event, &referent)
}
fn materialize_graph_tombstones(
&mut self,
env: &OperationEnvelope,
targets: &[TypedObjectId],
) -> Vec<RepairRecord> {
let mut repairs = Vec::new();
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());
repairs.extend(self.materialize_graph_delete(
env,
&DeleteEventOp {
event,
tuplet_compensation: TupletCompensation::NotInTuplet,
},
));
}
let Some(score) = self.graph.as_mut() else {
return repairs;
};
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);
}
// Spanner was missing from this walk (an undone spanner mint
// left a ghost value in the graph) — fixed alongside the
// repeat arm; both mirror the slur/tie/beam removals.
TypedObjectId::Spanner(id) => {
score.cross_cutting.spanners.retain(|value| value.id != *id);
}
TypedObjectId::RepeatStructure(id) => {
score.cross_cutting.repeats.retain(|value| value.id != *id);
}
// Phase-3 mints: a tombstoned staff / time signature leaves the
// graph (the undo path preconditions no live reference remains).
TypedObjectId::Staff(id) => {
score.staves.retain(|value| value.id != *id);
}
TypedObjectId::TimeSignature(id) => {
score.time_signatures.retain(|value| value.id != *id);
}
_ => {}
}
}
repairs
}
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(env, 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(env, op),
OperationKind::SetMetricGrid(op) => self.set_metric_grid(env, op),
OperationKind::SetUserPageBreak(op) => self.set_user_page_break(env, op),
OperationKind::CreateStaff(op) => self.create_staff(env, op),
OperationKind::SetTimeSignature(op) => self.set_time_signature(env, op),
OperationKind::SetTempoSegment(op) => self.set_tempo_segment(env, op),
OperationKind::SetStaffLayout(op) => self.set_staff_layout(env, op),
OperationKind::CreateRepeatStructure(op) => self.create_repeat_structure(env, op),
OperationKind::DeleteRepeatStructure(op) => self.delete_repeat_structure(env, op),
},
OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op),
OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op),
OperationPayload::ResolveEquivocation(op) => self.resolve_equivocation(env, op),
}
}
// --- Per-kind reduction. ------------------------------------------------
fn set_user_system_break(
&mut self,
env: &OperationEnvelope,
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.
let key = (op.region, op.resolved_position());
self.breaks.insert(key.clone(), op.present);
self.break_chain
.entry(key)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, (op.anchor.clone(), 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, env: &OperationEnvelope, 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. The
// write chain backs value-restoring undo only.
self.metadata_chain
.record(env.id, env.transaction, op.metadata.clone());
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
.metric_grid_chain
.get(&op.region)
.and_then(|chain| chain.last_write())
.map(|write| (write.op, write.value.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.metric_grid_chain
.entry(op.region)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, 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,
env: &OperationEnvelope,
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);
}
}
}
let key = (op.region, op.resolved_position());
self.page_breaks.insert(key.clone(), op.present);
self.page_break_chain
.entry(key)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, (op.anchor.clone(), 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 },
}
}
};
// Pitch-id freshness in base-free reduction: a carried pitch id that
// already exists in canonical state (live or tombstoned) is not fresh.
// The graph-aware precondition above already enforces this (with the
// same reason), so this only fires when no graph is present — it keeps
// the two reduction APIs in agreement on the same operation set.
if op
.pitch_ids()
.iter()
.any(|pitch| self.objects.contains_key(&TypedObjectId::Pitch(*pitch)))
{
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetTombstoned,
},
};
}
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);
// Seed the write chains with the minted values (the same value the
// graph materializes), so a later modify's chain-predecessor is the
// inserted state in graph-free and graph-aware reduction alike.
self.event_modify_chain
.entry(event_id)
.or_insert_with(WriteChain::new)
.seed(graph_event_from_insert(op, target_voice));
let mut carried: Vec<&epiphany_core::IdentifiedPitch> = Vec::new();
op.event.collect_identified_pitches(&mut carried);
for ip in &carried {
self.pitch_modify_chain
.entry(ip.id)
.or_insert_with(WriteChain::new)
.seed(ip.pitch.clone());
}
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()))
});
// The tombstoned referent's voice, for the containment-proximity key of
// the re-anchoring reasons below: the occupancy index (base-free) with
// the graph as fallback for non-metric events. Captured before the
// graph delete removes the event.
let referent_voice = deleted_placement
.as_ref()
.map(|(voice, _, _)| *voice)
.or_else(|| {
self.graph
.as_ref()
.and_then(|score| score.events.get(op.event))
.map(Event::voice)
});
// Tombstone the event in `objects` BEFORE the graph delete (P13-D2).
// `materialize_graph_delete` re-anchors the deleted event's referents,
// and a cue among them cascades — running `reanchor_for_tombstone` over
// the cue's own referents. A structure anchored on {this event,
// cue-of-this-event} must see this event as already dead there, or it
// would re-anchor onto it (recording `Reanchored{to: X}`) only to
// cascade when X's tombstone lands a line later — a contradictory
// same-effect trail. Tombstoning first matches `cascade_cue` and the
// undo path, which already tombstone before their graph delete.
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,
},
);
let graph_repairs = self.materialize_graph_delete(env, op);
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 = graph_repairs;
// 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, referent_voice);
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) => {}
}
let prev_op = self
.respell_chain
.get(&op.pitch)
.and_then(|chain| chain.last_write())
.map(|write| write.op);
match prev_op {
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.respell_chain
.entry(op.pitch)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, op.spelling.clone());
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 => {}
}
// Every anchored object must exist (live) — events for the
// event-anchored kinds, and a spanner's region/measure anchor targets
// too, so a spanner anchored to a missing region/measure is rejected
// rather than minted dangling (P13-D3). Deterministic across both
// reduction modes: the base seed registers regions and measures in
// `objects` (as the repeat mint precondition relies on).
for e in &op.structure.anchor_object_refs() {
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);
// Seed the write chain with the minted value, so a later modify's
// chain-predecessor is the created state.
self.cross_cutting_modify_chain
.entry(sid)
.or_insert_with(WriteChain::new)
.seed(op.structure.clone());
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. The write
// chain goes with it: a delete is not inverted (P11-C8), so a
// tombstoned structure's chain can never be restored.
self.structures.remove(&sid);
self.cross_cutting_modify_chain.remove(&sid);
self.graph_delete_cross_cutting(sid);
OperationEffect::Applied
}
fn create_repeat_structure(
&mut self,
env: &OperationEnvelope,
op: &CreateRepeatStructureOp,
) -> OperationEffect {
let sid = TypedObjectId::RepeatStructure(op.repeat.id);
match self.objects.get(&sid) {
// Set-union: a repeat create of a live id reads AlreadyApplied
// without value comparison (the cross-cutting discipline; the
// RecreateContentMismatch scope stays CreateStaff + carried
// TimeSignature).
Some(ObjectState::Live) => {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
None => {}
}
// Every OBJECT-referencing anchor site must resolve live — start/end,
// the kind's jump targets, each volta's span; events, measures, AND
// regions (operation_catalog §"Repeat Structures": the mint must
// leave the graph satisfying the reference-resolution invariants,
// which check all three anchor kinds — an event-only check would
// mint a dangling repeat off a missing region/measure anchor).
for referenced in anchor_object_refs(op.repeat.anchor_sites()) {
if !matches!(self.objects.get(&referenced), Some(ObjectState::Live)) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
}
if let Some(score) = self.graph.as_mut() {
score.cross_cutting.repeats.push(op.repeat.clone());
}
self.objects.insert(sid, ObjectState::Live);
self.minted_by.insert(sid, env.id);
self.note_minted(env, sid);
// Register the EVENT refs into the referent index so event tombstones
// repair the structure per the rule table (no write chain: there is
// no ModifyRepeatStructure at this revision). Measure/region anchors
// stay unindexed, as for spanners — non-event referent tombstones
// are the filed P13-D3 class.
let endpoints = anchor_event_refs(op.repeat.anchor_sites());
if !endpoints.is_empty() {
self.structures.insert(sid, endpoints);
}
OperationEffect::Applied
}
fn delete_repeat_structure(
&mut self,
env: &OperationEnvelope,
op: &DeleteRepeatStructureOp,
) -> OperationEffect {
let sid = TypedObjectId::RepeatStructure(op.repeat);
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 referent-index entry so a later event tombstone's
// re-anchoring pass never re-processes the deleted structure.
self.structures.remove(&sid);
if let Some(score) = self.graph.as_mut() {
score.cross_cutting.repeats.retain(|r| r.id != op.repeat);
}
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,
},
};
}
}
// Every anchored object of the NEW value must be live — events for the
// event-anchored kinds, and a spanner's region/measure anchor targets
// too (P13-D3, modify sibling): otherwise a live event-anchored spanner
// could be modified onto a missing region/measure — whose empty
// `endpoints()` slipped the check — and written into the graph past the
// core invariant that validates spanner anchors at all three kinds.
// Mirrors `create_cross_cutting`; `endpoints()` still feeds the
// event-only referent index below.
let endpoints = op.structure.endpoints();
for e in &op.structure.anchor_object_refs() {
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
.cross_cutting_modify_chain
.get(&sid)
.and_then(|chain| chain.last_write())
.map(|write| (write.op, write.value.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.cross_cutting_modify_chain
.entry(sid)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, 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 let Some(content) = op.region.content.staff_based() {
self.staff_based_regions.insert(op.region_id());
// Seed the region's layout/metric write chains from the carried
// content (empty of typed children, but it may carry a grid or
// break advisories), so a later overwrite's chain-predecessor is
// the created state.
self.metric_grid_chain
.entry(op.region_id())
.or_insert_with(WriteChain::new)
.seed(content.default_metric_grid.clone());
if let Some(grid) = &content.default_metric_grid {
for change in &grid.meter_sequence {
self.meter_change_chain
.entry((op.region_id(), resolved_anchor_position(&change.anchor)))
.or_insert_with(WriteChain::new)
.seed(Some(change.clone()));
}
}
for anchor in &content.user_system_breaks {
self.break_chain
.entry((op.region_id(), resolved_anchor_position(anchor)))
.or_insert_with(WriteChain::new)
.seed((anchor.clone(), true));
}
for anchor in &content.user_page_breaks {
self.page_break_chain
.entry((op.region_id(), resolved_anchor_position(anchor)))
.or_insert_with(WriteChain::new)
.seed((anchor.clone(), true));
}
}
if let Some(local) = &op.region.local_tempo_map {
for segment in &local.segments {
self.tempo_segment_chain
.entry((
Some(op.region_id()),
resolved_anchor_position(&segment.start),
))
.or_insert_with(WriteChain::new)
.seed(Some(segment.clone()));
}
}
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();
}
// With staves mintable (operation_catalog §CreateStaff), the instance's
// referenced global Staff must be live — the mint must leave the graph
// satisfying reference resolution. Graph-aware only (like the insert
// preconditions): base-free reduction has no staff universe to check
// against, and the base-seeded scenarios satisfy this vacuously.
if self.graph.is_some()
&& !matches!(
self.objects.get(&TypedObjectId::Staff(op.instance.staff)),
Some(ObjectState::Live)
)
{
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
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();
self.instance_staff
.insert(op.instance_id(), op.instance.staff);
// Seed the layout-advisory chain with the minted instance's fields, so
// a later SetStaffLayout's chain-predecessor is the created state.
self.staff_layout_chain
.entry(op.instance_id())
.or_insert_with(WriteChain::new)
.seed((
op.instance.instrument_override,
op.instance.staff_lines_override.clone(),
op.instance.visible,
));
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
}
// --- Phase-3 first tranche (operation_catalog §CreateStaff, §"Meter and
// Tempo Overwrites", §SetStaffLayout). ------------------------------------
/// Set-union creation of a global `Staff` on the score root
/// (operation_catalog §CreateStaff): fresh id mints; a byte-identical
/// re-carry is idempotent; a differing value under a live id is a
/// precondition no-op. Graph-aware reduction additionally preconditions
/// that the referenced instrument is live and, when `group` is present,
/// that the staff group resolves.
fn create_staff(&mut self, env: &OperationEnvelope, op: &CreateStaffOp) -> OperationEffect {
let sobj = TypedObjectId::Staff(op.staff_id());
match self.objects.get(&sobj) {
Some(ObjectState::Live) => {
let identical = self
.staff_values
.get(&op.staff_id())
.is_some_and(|known| known == &op.staff);
return if identical {
OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
} else {
OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::RecreateContentMismatch,
},
}
};
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
None => {}
}
// Reference-resolution preconditions are graph-aware (like the insert
// preconditions): base-free reduction has no instrument/group universe
// to check against.
if self.graph.is_some() {
if !matches!(
self.objects
.get(&TypedObjectId::Instrument(op.staff.instrument)),
Some(ObjectState::Live)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
if let Some(group) = op.staff.group {
if !matches!(
self.objects.get(&TypedObjectId::StaffGroup(group)),
Some(ObjectState::Live)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
}
}
if let Some(score) = self.graph.as_mut() {
score.staves.push(op.staff.clone());
}
self.mint_container(env, sobj);
self.staff_values.insert(op.staff_id(), op.staff.clone());
OperationEffect::Applied
}
/// Set-union mint of a `TimeSignature` carried by a `SetTimeSignature`
/// (operation_catalog §"Meter and Tempo Overwrites"): fresh id mints;
/// byte-identical re-carry is idempotent; a differing value under a live
/// id — or a tombstoned id — refuses the whole operation.
fn mint_time_signature(
&mut self,
env: &OperationEnvelope,
signature: &TimeSignature,
) -> Result<(), OperationEffect> {
let obj = TypedObjectId::TimeSignature(signature.id);
match self.objects.get(&obj) {
Some(ObjectState::Live) => {
let identical = self
.time_signature_values
.get(&signature.id)
.is_some_and(|known| known == signature);
if identical {
Ok(())
} else {
Err(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::RecreateContentMismatch,
},
})
}
}
Some(ObjectState::Tombstoned { .. }) => Err(OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}),
None => {
self.mint_container(env, obj);
self.time_signature_values
.insert(signature.id, signature.clone());
if let Some(score) = self.graph.as_mut() {
if !score.time_signatures.iter().any(|t| t.id == signature.id) {
score.time_signatures.push(signature.clone());
}
}
Ok(())
}
}
}
/// Sets, replaces, or removes the single meter change at the anchor's
/// resolved position in the region's default metric grid — an LWW
/// structural overwrite keyed by `(region, resolved position)`
/// (operation_catalog §"Meter and Tempo Overwrites"). The carried
/// signature's beat-group sum is validated at construction and at decode,
/// so a malformed value never reaches this reduction.
fn set_time_signature(
&mut self,
env: &OperationEnvelope,
op: &SetTimeSignatureOp,
) -> OperationEffect {
if let Some(effect) = self.layout_region_slot(op.region) {
return effect;
}
if let Some(signature) = &op.time_signature {
if let Err(effect) = self.mint_time_signature(env, signature) {
return effect;
}
}
let key = (op.region, op.resolved_position());
let written: Option<MeterChange> =
op.time_signature.as_ref().map(|signature| MeterChange {
anchor: op.anchor.clone(),
time_signature: signature.id,
});
let prev = self
.meter_change_chain
.get(&key)
.and_then(|chain| chain.last_write())
.map(|write| (write.op, write.value.clone()));
let effect = match prev {
Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => {
if prev_value == written {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
};
}
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
loser: prev_op,
field: FieldPath("meter_sequence".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.meter_change_chain
.entry(key.clone())
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, written.clone());
self.graph_apply_meter_change(op.region, &key.1, &written);
effect
}
/// Applies a meter-change overwrite (or removal) to the region's default
/// metric grid, keeping the sequence ordered by resolved position. A set
/// on a region whose grid is `None` creates the grid; a removal that
/// empties the sequence normalizes the slot back to `None` unless a
/// whole-grid write (or the base) holds a grid value independently.
fn graph_apply_meter_change(
&mut self,
region: RegionId,
position: &MusicalPosition,
change: &Option<MeterChange>,
) {
let baseline_grid = self
.metric_grid_chain
.get(&region)
.and_then(|chain| chain.current())
.is_some_and(|grid| grid.is_some());
let Some(score) = self.graph.as_mut() else {
return;
};
let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region) else {
return;
};
let Some(content) = region.content.staff_based_mut() else {
return;
};
match change {
Some(meter) => {
let grid = content
.default_metric_grid
.get_or_insert_with(MetricGrid::default);
grid.meter_sequence
.retain(|existing| resolved_anchor_position(&existing.anchor) != *position);
let index = grid
.meter_sequence
.iter()
.position(|existing| resolved_anchor_position(&existing.anchor) > *position)
.unwrap_or(grid.meter_sequence.len());
grid.meter_sequence.insert(index, meter.clone());
}
None => {
if let Some(grid) = content.default_metric_grid.as_mut() {
grid.meter_sequence
.retain(|existing| resolved_anchor_position(&existing.anchor) != *position);
if grid.meter_sequence.is_empty() && !baseline_grid {
content.default_metric_grid = None;
}
}
}
}
}
/// Whether the scope's *resulting* tempo map is well-formed with `written`
/// installed at `key` (operation_catalog §"Meter and Tempo Overwrites"):
/// the carried segment's own start equals the operation's key, every
/// segment's shape carries its end data, and resolved ends neither precede
/// their own start nor overlap the next segment. Read purely from the
/// tempo chains' current values (which seed from the base map), so
/// graph-free and graph-aware reduction agree wherever both represent the
/// scope.
fn prospective_tempo_write_well_formed(
&self,
key: &(Option<RegionId>, MusicalPosition),
written: &TempoSegment,
) -> bool {
if resolved_anchor_position(&written.start) != key.1 {
return false;
}
let mut segments: Vec<(MusicalPosition, &TempoSegment)> = Vec::new();
for ((scope, position), chain) in &self.tempo_segment_chain {
if scope != &key.0 || *position == key.1 {
continue;
}
if let Some(Some(segment)) = chain.current() {
segments.push((position.clone(), segment));
}
}
segments.push((key.1.clone(), written));
segments.sort_by(|(a, _), (b, _)| a.cmp(b));
for (index, (position, segment)) in segments.iter().enumerate() {
if !tempo_segment_shape_well_formed(segment) {
return false;
}
if let Some(end) = &segment.end {
let resolved_end = resolved_anchor_position(end);
if resolved_end < *position {
return false;
}
if let Some((next_position, _)) = segments.get(index + 1) {
if resolved_end > *next_position {
return false;
}
}
}
}
true
}
/// Sets, replaces, or removes the single tempo segment starting at the
/// resolved position in the scoped tempo map — an LWW structural overwrite
/// keyed by `(scope, resolved start)` (operation_catalog §"Meter and Tempo
/// Overwrites"). A write that would malform the resulting map is refused
/// (`TempoMapMalformed`).
fn set_tempo_segment(
&mut self,
env: &OperationEnvelope,
op: &SetTempoSegmentOp,
) -> OperationEffect {
if let Some(region) = op.region {
if !matches!(
self.objects.get(&TypedObjectId::Region(region)),
Some(ObjectState::Live)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
}
let key = (op.region, op.resolved_start());
if let Some(segment) = &op.segment {
if !self.prospective_tempo_write_well_formed(&key, segment) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TempoMapMalformed,
},
};
}
}
let prev = self
.tempo_segment_chain
.get(&key)
.and_then(|chain| chain.last_write())
.map(|write| (write.op, write.value.clone()));
let effect = match prev {
Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => {
if prev_value == op.segment {
return OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
};
}
let affected = match op.region {
Some(region) => vec![TypedObjectId::Region(region)],
None => vec![],
};
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
loser: prev_op,
field: FieldPath("tempo_segments".to_string()),
},
vec![env.id, prev_op],
affected,
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
_ => OperationEffect::Applied,
};
self.tempo_segment_chain
.entry(key.clone())
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, op.segment.clone());
self.graph_apply_tempo_segment(op.region, &key.1, &op.segment);
effect
}
/// Applies a tempo-segment overwrite (or removal) to the scoped map. A set
/// on a region with no local map creates one; an empty, `initial`-less
/// local map left behind by a removal normalizes back to `None` (an empty
/// local map would shadow the score map instead of falling back to it).
fn graph_apply_tempo_segment(
&mut self,
scope: Option<RegionId>,
position: &MusicalPosition,
segment: &Option<TempoSegment>,
) {
let Some(score) = self.graph.as_mut() else {
return;
};
match scope {
None => edit_tempo_map_segments(&mut score.tempo_map, position, segment),
Some(region_id) => {
let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region_id)
else {
return;
};
if segment.is_some() && region.local_tempo_map.is_none() {
region.local_tempo_map = Some(TempoMap::default());
}
if let Some(map) = region.local_tempo_map.as_mut() {
edit_tempo_map_segments(map, position, segment);
}
if region
.local_tempo_map
.as_ref()
.is_some_and(|map| map.initial.is_none() && map.segments.is_empty())
{
region.local_tempo_map = None;
}
}
}
}
/// Overwrites a staff instance's three inline layout advisories as a unit
/// — an LWW *advisory* keyed by `staff_instance` (operation_catalog
/// §SetStaffLayout): no conflicts; the latest write in canonical order
/// wins. A present `instrument_override` must resolve to a live instrument
/// under graph-aware reduction.
fn set_staff_layout(
&mut self,
env: &OperationEnvelope,
op: &SetStaffLayoutOp,
) -> OperationEffect {
match self
.objects
.get(&TypedObjectId::StaffInstance(op.staff_instance))
{
None => {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
}
Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
}
Some(ObjectState::Live) => {}
}
if self.graph.is_some() {
if let Some(instrument) = op.instrument_override {
if !matches!(
self.objects.get(&TypedObjectId::Instrument(instrument)),
Some(ObjectState::Live)
) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
}
}
let value: StaffLayoutValue = (
op.instrument_override,
op.staff_lines_override.clone(),
op.visible,
);
self.staff_layout_chain
.entry(op.staff_instance)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, value.clone());
self.graph_set_staff_layout(op.staff_instance, &value);
OperationEffect::Applied
}
fn graph_set_staff_layout(&mut self, instance_id: StaffInstanceId, value: &StaffLayoutValue) {
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 == instance_id) {
instance.instrument_override = value.0;
instance.staff_lines_override = value.1.clone();
instance.visible = value.2;
return;
}
}
}
}
/// `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 { by, action }) => {
if action == op.action {
OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
} else {
// Differing concurrent resolution → meta-conflict. The
// earlier resolve stands (its action materialized), so it
// is the record's winner; this op is the loser, and both
// resolvers are named as causes ("at least two for a true
// conflict", Chapter 6 §Conflict Records). A conflict
// record has no TypedObjectId, so `affected_objects`
// cannot name the contested conflict and stays empty.
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: by,
loser: env.id,
field: FieldPath("conflict_resolution".to_string()),
},
vec![by, env.id],
vec![],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
}
Some(RS::Dismissed { .. }) => OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
},
}
}
/// The recorded effect of a `ResolveEquivocation` (operation_catalog
/// §"ResolveEquivocation"). The slot promotion itself happened in the
/// set-level pre-pass ([`Reducer::run`] step 1b); this only records the
/// per-resolve verdict, mirroring [`Reducer::resolve_conflict`]'s
/// discipline: the governing (earliest-in-canonical-order) resolve is
/// `Applied`; a later resolve naming the same candidate reduces
/// idempotently; a later valid resolve naming a differing candidate is a
/// `StructuralFieldCollision` meta-conflict on `equivocation_resolution`.
/// A resolve whose target is not an equivocated slot, or whose chosen is
/// not among the slot's candidates, is a precondition no-op
/// (`TargetMissing` — the named target/candidate pair does not exist).
fn resolve_equivocation(
&mut self,
env: &OperationEnvelope,
op: &crate::payload::ResolveEquivocationPayload,
) -> OperationEffect {
let precondition_noop = OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
match self.equivocation_resolutions.get(&op.target) {
// No governing resolve exists for the target: it is absent, holds
// a Single slot, or is equivocated with no valid resolve — in
// every case this resolve's precondition ("an Equivocated slot for
// `target` with `chosen` among its candidates") failed, or it
// would have governed.
None => precondition_noop,
Some((winner, chosen)) => {
if env.id == *winner {
OperationEffect::Applied
} else if op.chosen == *chosen {
// A later resolve naming the same candidate: idempotent.
OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}
} else if !self
.op_set
.slot(op.target)
.is_some_and(|slot| slot.candidates().any(|c| c == op.chosen))
{
// A differing `chosen` that never named a real candidate
// is a failed precondition, not a contested resolution.
precondition_noop
} else {
// Two valid resolves naming differing candidates: the
// governing (earlier) resolve stands as the record's
// winner; this op is the loser, both named as causes. A
// slot is not a TypedObjectId, so `affected_objects`
// stays empty (the ResolveConflict discipline).
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: *winner,
loser: env.id,
field: FieldPath("equivocation_resolution".to_string()),
},
vec![*winner, env.id],
vec![],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
OperationEffect::Conflicted { conflict: cid }
}
}
}
}
/// Forward compensating undo (operation_catalog §UndoTransaction): the
/// minted-object tombstoning pass plus, per this revision, the
/// value-restoration pass over every LWW write chain the target
/// transaction wrote. `StrictInverse` refuses the whole undo if *either*
/// part fails; `BestEffort` compensates what it cleanly can. A fully
/// clean compensation is `Applied`; `AppliedWithRepair` carries only the
/// tombstone repairs from minted objects.
fn undo_transaction(
&mut self,
env: &OperationEnvelope,
op: &UndoTransactionPayload,
) -> OperationEffect {
let targets = self.tx_minted.get(&op.target).cloned().unwrap_or_default();
let (restorations, superseded) = self.collect_restorations(op.target, &targets);
if targets.is_empty() && restorations.is_empty() && superseded.is_empty() {
// The transaction minted nothing and overwrote nothing this
// reduction knows of (unknown, rolled back, or all its written
// keys are gone): nothing to compensate.
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
match op.policy {
UndoPolicy::StrictInverse | UndoPolicy::Cascade => {
// A minted target already tombstoned: strict undo conflicts
// (the pre-existing minted-object discipline).
if let Some(stuck) = targets
.iter()
.find(|t| !matches!(self.objects.get(t), Some(ObjectState::Live)))
.copied()
{
let conflict = ConflictRecord::new(
ConflictKind::TombstonedTarget {
target: stuck,
operation: env.id,
},
vec![env.id],
vec![stuck],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
return OperationEffect::Conflicted { conflict: cid };
}
// A minted staff still manifested by a live instance, or a
// minted time signature still referenced by a surviving meter
// change: tombstoning it would strand the reference
// (operation_catalog §CreateStaff undo semantics).
if let Some((blocked, referencer)) = targets
.iter()
.find_map(|t| self.undo_strand_block(t, &targets, &restorations))
{
let conflict = ConflictRecord::new(
ConflictKind::TransactionConflict {
transaction: op.target,
failed_members: vec![env.id],
},
vec![env.id],
vec![blocked, referencer],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
return OperationEffect::Conflicted { conflict: cid };
}
// A written key superseded by a later writer: strict undo
// refuses the whole compensation, naming the undo and the
// (canonically first) superseding writer.
if let Some(by) = superseded.first().copied() {
let conflict = ConflictRecord::new(
ConflictKind::TransactionConflict {
transaction: op.target,
failed_members: vec![env.id],
},
vec![env.id, by],
vec![],
);
let cid = conflict.id;
self.conflicts.insert(conflict);
return OperationEffect::Conflicted { conflict: cid };
}
let repairs = self.tombstone_undo_targets(env, &targets);
self.apply_restorations(env, restorations);
if repairs.is_empty() {
OperationEffect::Applied
} else {
OperationEffect::AppliedWithRepair { repairs }
}
}
UndoPolicy::BestEffort => {
// Tombstone the still-live, non-stranding mints; restore the
// still-last-written keys; skip the rest.
let tombstonable: Vec<TypedObjectId> = targets
.iter()
.filter(|t| matches!(self.objects.get(t), Some(ObjectState::Live)))
.filter(|t| self.undo_strand_block(t, &targets, &restorations).is_none())
.copied()
.collect();
let repairs = self.tombstone_undo_targets(env, &tombstonable);
self.apply_restorations(env, restorations);
if repairs.is_empty() {
OperationEffect::Applied
} else {
OperationEffect::AppliedWithRepair { repairs }
}
}
}
}
/// Tombstones the minted objects of an undone transaction and materializes
/// the graph-side removals, returning the `CascadeDeleted` repair records.
fn tombstone_undo_targets(
&mut self,
env: &OperationEnvelope,
targets: &[TypedObjectId],
) -> Vec<RepairRecord> {
let mut repairs = Vec::new();
// Each tombstoned event's voice, captured BEFORE the graph tombstone
// removes it from `voice_occupancy` — the containment-proximity key for
// the re-anchoring reasons below (mirrors `delete_event`).
let event_voices: Vec<(TypedObjectId, Option<VoiceId>)> = targets
.iter()
.filter(|t| matches!(t, TypedObjectId::Event(_)))
.map(|t| {
let TypedObjectId::Event(id) = t else {
unreachable!()
};
let voice = self
.voice_occupancy
.iter()
.find_map(|(voice, events)| {
events.iter().find(|(_, _, e)| e == id).map(|_| *voice)
})
.or_else(|| {
self.graph
.as_ref()
.and_then(|score| score.events.get(*id))
.map(Event::voice)
});
(*t, voice)
})
.collect();
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,
});
}
repairs.extend(self.materialize_graph_tombstones(env, targets));
// P13-D1: mirror the graph re-anchoring in the LEDGER. The graph side
// (`materialize_graph_delete`, run above) silently re-anchors or
// cascade-deletes a structure whose anchor event was undo-tombstoned,
// on the standing assumption that `reanchor_for_tombstone` records the
// repair and updates `objects`. The direct-delete path pairs the two;
// the undo path historically ran only the graph half, leaving an
// orphaned structure Live in `objects` with no RepairRecord — a Ch6
// same-step-recording MUST unmet for undo-driven tombstones. Running
// the ledger half here brings the two into agreement (the same
// `min`-survivor rule, so graph and ledger converge on existence and
// target). Every target is already tombstoned in `objects` above, so
// `surviving_endpoints` sees the full undo, and the liveness guard in
// `reanchor_for_tombstone` skips the target-structures themselves.
for (ev, voice) in event_voices {
self.reanchor_for_tombstone(env, ev, &mut repairs, voice);
}
repairs
}
/// `Some((blocked, referencer))` when tombstoning `target` under undo
/// would strand a live reference: a minted `Staff` still manifested by a
/// live staff instance (operation_catalog §CreateStaff), or a minted
/// `TimeSignature` still referenced by a meter change that survives the
/// restoration pass. References held by objects the same undo tombstones
/// do not block.
fn undo_strand_block(
&self,
target: &TypedObjectId,
targets: &[TypedObjectId],
restorations: &[ValueRestoration],
) -> Option<(TypedObjectId, TypedObjectId)> {
match target {
TypedObjectId::Staff(staff) => {
self.instance_staff
.iter()
.find_map(|(instance, manifested)| {
let iobj = TypedObjectId::StaffInstance(*instance);
(manifested == staff
&& !targets.contains(&iobj)
&& matches!(self.objects.get(&iobj), Some(ObjectState::Live)))
.then_some((*target, iobj))
})
}
TypedObjectId::TimeSignature(id) => {
self.meter_change_chain
.iter()
.find_map(|((region, position), chain)| {
// The prospective post-undo value at this key: the
// restoration's value where one applies, else the
// chain's current value.
let prospective: Option<MeterChange> = restorations
.iter()
.find_map(|restoration| match restoration {
ValueRestoration::MeterChange {
region: r,
position: p,
value,
} if r == region && p == position => Some(value.clone()),
_ => None,
})
.unwrap_or_else(|| chain.current().cloned().flatten());
prospective.and_then(|meter| {
(meter.time_signature == *id)
.then_some((*target, TypedObjectId::Region(*region)))
})
})
}
_ => None,
}
}
/// Walks every write chain and collects, for the target transaction: the
/// keys still last-written by it (with their chain-predecessor
/// restorations) and the operations that superseded its other writes
/// (sorted, deduplicated). Keys whose owning object is tombstoned — or is
/// itself one of the transaction's mints, about to be tombstoned by this
/// undo — are skipped entirely: there is no live slot to restore.
fn collect_restorations(
&self,
tx: TransactionId,
targets: &[TypedObjectId],
) -> (Vec<ValueRestoration>, Vec<OperationId>) {
let mut restorations = Vec::new();
let mut superseded: Vec<OperationId> = Vec::new();
let slot_live = |obj: TypedObjectId| {
matches!(self.objects.get(&obj), Some(ObjectState::Live)) && !targets.contains(&obj)
};
for (event, chain) in &self.event_modify_chain {
if !slot_live(TypedObjectId::Event(*event)) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::Event {
event: *event,
value: predecessor.map(Predecessor::into_value),
})
}
}
}
for (pitch, chain) in &self.pitch_modify_chain {
if !slot_live(TypedObjectId::Pitch(*pitch)) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::Pitch {
pitch: *pitch,
value: predecessor.map(Predecessor::into_value),
})
}
}
}
for (pitch, chain) in &self.respell_chain {
if !slot_live(TypedObjectId::Pitch(*pitch)) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::Spelling {
pitch: *pitch,
predecessor,
})
}
}
}
for (id, chain) in &self.cross_cutting_modify_chain {
if !slot_live(*id) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::CrossCutting {
id: *id,
value: predecessor.map(Predecessor::into_value),
})
}
}
}
match self.metadata_chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::Metadata {
value: predecessor.map(Predecessor::into_value),
})
}
}
for (region, chain) in &self.metric_grid_chain {
if !slot_live(TypedObjectId::Region(*region)) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
// Flattened: no predecessor and a cleared-grid predecessor
// both restore "no grid".
restorations.push(ValueRestoration::MetricGrid {
region: *region,
value: match predecessor {
Some(p) => p.into_value(),
None => None,
},
})
}
}
}
for ((region, position), chain) in &self.meter_change_chain {
if !slot_live(TypedObjectId::Region(*region)) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::MeterChange {
region: *region,
position: position.clone(),
value: match predecessor {
Some(p) => p.into_value(),
None => None,
},
})
}
}
}
for ((scope, position), chain) in &self.tempo_segment_chain {
if let Some(region) = scope {
if !slot_live(TypedObjectId::Region(*region)) {
continue;
}
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::TempoSegment {
region: *scope,
position: position.clone(),
value: match predecessor {
Some(p) => p.into_value(),
None => None,
},
})
}
}
}
for (instance, chain) in &self.staff_layout_chain {
if !slot_live(TypedObjectId::StaffInstance(*instance)) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::StaffLayout {
instance: *instance,
value: predecessor.map(Predecessor::into_value),
})
}
}
}
for ((region, position), chain) in &self.break_chain {
if !slot_live(TypedObjectId::Region(*region)) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::SystemBreak {
region: *region,
position: position.clone(),
predecessor,
})
}
}
}
for ((region, position), chain) in &self.page_break_chain {
if !slot_live(TypedObjectId::Region(*region)) {
continue;
}
match chain.undo_verdict(tx) {
ChainUndoVerdict::NotWritten => {}
ChainUndoVerdict::Superseded { by } => superseded.push(by),
ChainUndoVerdict::Restore(predecessor) => {
restorations.push(ValueRestoration::PageBreak {
region: *region,
position: position.clone(),
predecessor,
})
}
}
}
superseded.sort();
superseded.dedup();
(restorations, superseded)
}
/// Applies the collected restorations to the bookkeeping and the graph,
/// recording each restored *value* into its chain as a new write by the
/// undo operation (so a later undo sees the restoration as the key's last
/// writer — the pinned undo-of-undo discipline; see DECISIONS.md). An
/// absence restoration (no predecessor at all) leaves the chain
/// unchanged: a repeated undo of the same transaction re-restores absence
/// idempotently.
fn apply_restorations(&mut self, env: &OperationEnvelope, restorations: Vec<ValueRestoration>) {
for restoration in restorations {
match restoration {
ValueRestoration::Event { event, value } => {
if let Some(value) = value {
self.apply_event_value(&value);
self.event_modify_chain
.entry(event)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, value);
}
}
ValueRestoration::Pitch { pitch, value } => {
if let Some(value) = value {
self.graph_modify_pitch(pitch, &value);
self.pitch_modify_chain
.entry(pitch)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, value);
}
}
ValueRestoration::Spelling { pitch, predecessor } => match predecessor {
Some(Predecessor::Write(spelling)) => {
self.spellings.insert(pitch, spelling.clone());
self.respell_chain
.entry(pitch)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, spelling.clone());
self.graph_respell_pitch(pitch, &spelling);
}
Some(Predecessor::Base(spelling)) => {
// The ledger returns to key-absence (the base state
// lives in the graph); the graph attachment restores
// the base value.
self.spellings.remove(&pitch);
self.respell_chain
.entry(pitch)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, spelling.clone());
self.graph_respell_pitch(pitch, &spelling);
}
None => {
self.spellings.remove(&pitch);
self.graph_remove_respell(pitch);
}
},
ValueRestoration::CrossCutting { id, value } => {
if let Some(value) = value {
self.structures.insert(id, value.endpoints());
self.graph_modify_cross_cutting(&value);
self.cross_cutting_modify_chain
.entry(id)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, value);
}
}
ValueRestoration::Metadata { value } => {
if let Some(value) = value {
if let Some(score) = self.graph.as_mut() {
score.metadata = value.clone();
}
self.metadata_chain.record(env.id, env.transaction, value);
}
}
ValueRestoration::MetricGrid { region, value } => {
self.metric_grid_chain
.entry(region)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, value.clone());
self.graph_set_metric_grid(region, &value);
}
ValueRestoration::MeterChange {
region,
position,
value,
} => {
self.meter_change_chain
.entry((region, position.clone()))
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, value.clone());
self.graph_apply_meter_change(region, &position, &value);
}
ValueRestoration::TempoSegment {
region,
position,
value,
} => {
self.tempo_segment_chain
.entry((region, position.clone()))
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, value.clone());
self.graph_apply_tempo_segment(region, &position, &value);
}
ValueRestoration::StaffLayout { instance, value } => {
if let Some(value) = value {
self.graph_set_staff_layout(instance, &value);
self.staff_layout_chain
.entry(instance)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, value);
}
}
ValueRestoration::SystemBreak {
region,
position,
predecessor,
} => self.restore_break(env, region, position, predecessor, false),
ValueRestoration::PageBreak {
region,
position,
predecessor,
} => self.restore_break(env, region, position, predecessor, true),
}
}
}
/// Restores one user-break key: a write predecessor re-enters the ledger
/// map; a base predecessor returns the map to key-absence while the graph
/// restores the base anchor; no predecessor removes the key from map and
/// graph alike.
fn restore_break(
&mut self,
env: &OperationEnvelope,
region: RegionId,
position: MusicalPosition,
predecessor: Option<Predecessor<(TimeAnchor, bool)>>,
page: bool,
) {
let key = (region, position.clone());
match predecessor {
Some(Predecessor::Write((anchor, present))) => {
if page {
self.page_breaks.insert(key.clone(), present);
self.page_break_chain
.entry(key)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, (anchor.clone(), present));
} else {
self.breaks.insert(key.clone(), present);
self.break_chain
.entry(key)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, (anchor.clone(), present));
}
self.graph_apply_break(region, &anchor, present, page);
}
Some(Predecessor::Base((anchor, present))) => {
if page {
self.page_breaks.remove(&key);
self.page_break_chain
.entry(key)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, (anchor.clone(), present));
} else {
self.breaks.remove(&key);
self.break_chain
.entry(key)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, (anchor.clone(), present));
}
self.graph_apply_break(region, &anchor, present, page);
}
None => {
if page {
self.page_breaks.remove(&key);
} else {
self.breaks.remove(&key);
}
self.graph_clear_break(region, &position, page);
}
}
}
fn graph_apply_break(
&mut self,
region: RegionId,
anchor: &TimeAnchor,
present: bool,
page: bool,
) {
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() {
let list = if page {
&mut content.user_page_breaks
} else {
&mut content.user_system_breaks
};
apply_break_lww(list, anchor, present);
}
}
}
fn graph_clear_break(&mut self, region: RegionId, position: &MusicalPosition, page: bool) {
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() {
let list = if page {
&mut content.user_page_breaks
} else {
&mut content.user_system_breaks
};
list.retain(|existing| resolved_anchor_position(existing) != *position);
}
}
}
/// Removes the user-chosen explicit spelling attachment for `pitch` — the
/// inverse of the attachment `graph_respell_pitch` installs, used when a
/// respell restoration has no predecessor (the operation introduced the
/// first spelling).
fn graph_remove_respell(&mut self, pitch: PitchId) {
let Some(score) = self.graph.as_mut() else {
return;
};
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(_)))
});
}
// --- 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.
/// A `SYSTEM_DERIVED` pitch's intrinsic content is immutable under
/// reduction (Pass 12, P12-K3; core spec Ch5 §System-Derived
/// Identifiers): its identifier is content-derived, so an in-place
/// rewrite would invalidate the derivation. The check compares the
/// replacement value's canonical pitch bytes against the id's
/// *registered derivation inputs* — the same `system_mints` registry the
/// collision pre-walk maintains (base-seeded occupants plus op mints),
/// so `reduce()` and `reduce_onto()` agree wherever the registry holds
/// the entry. An unregistered id (base-free reduction over a pitch the
/// base seeded) is unverifiable and passes, like the other
/// graph-aware-only preconditions.
///
/// This is the *rewrite* precondition (a live system pitch's content
/// changed in place). The sibling *introduction* case — a system pitch
/// never minted, introduced by a `ModifyEvent` replacement — is refused
/// upfront in [`Self::modify_event`] (P13-K1, resolved: reject the
/// introduction), which closes the checkpoint-cut asymmetry that once let
/// an introduced system pitch slip through in-session yet read
/// `SystemDerivedContentImmutable` after a snapshot re-seeded it here.
fn system_derived_rewrite(&self, id: PitchId, value: &Pitch) -> bool {
if id.replica() != ReplicaId::SYSTEM_DERIVED {
return false;
}
match self.system_mints.get(&(ObjectKind::Pitch, id.counter())) {
Some((inputs, _)) => inputs.0 != canonical_pitch_bytes(value),
None => false,
}
}
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) => {}
}
let mut carried = Vec::new();
op.event.collect_identified_pitches(&mut carried);
// Introduction precondition (P13-K1, ratified: reject the
// introduction) before the identity one: a `ModifyEvent` may not
// *introduce* a never-minted SYSTEM-DERIVED pitch id — that id lives in
// the reserved system namespace and only the system mints it. A carried
// system-derived pitch absent from `objects` (never minted) is refused,
// consistently across a snapshot cut: this closes the asymmetry where
// an introduced system pitch slipped through in-session (`system_mints`
// had no entry, so the identity check below saw nothing) yet read
// `SystemDerivedContentImmutable` once a snapshot re-seeded it as a
// system mint. A user-replica pitch carries no such namespace claim and
// has no snapshot asymmetry, so it is untouched here; the event's own
// live system pitches are in `objects` (both reduction modes), so an
// in-place content rewrite passes.
if carried.iter().any(|ip| {
ip.id.replica() == ReplicaId::SYSTEM_DERIVED
&& !matches!(
self.objects.get(&TypedObjectId::Pitch(ip.id)),
Some(ObjectState::Live)
)
}) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
// Identity precondition (P12-K3) before the placement precondition:
// a replacement value that rewrites a system-derived pitch's
// intrinsic content in place is refused outright.
if carried
.iter()
.any(|ip| self.system_derived_rewrite(ip.id, &ip.pitch))
{
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
},
};
}
// 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
.event_modify_chain
.get(&event_id)
.and_then(|chain| chain.last_write())
.map(|write| (write.op, write.value.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.event_modify_chain
.entry(event_id)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, op.event.clone());
self.apply_event_value(&op.event);
effect
}
/// Applies an event *value* (a modify's replacement, or an undo's restored
/// predecessor) to the graph and the occupancy index. A move materializes
/// only when it is a sanctioned metric move (`Moved`) *and* the value is
/// well-formed — so the graph and the occupancy index move together. A
/// malformed (empty) pitched value is not materialized in the graph
/// (`graph_replace_event` skips it), so it must not move occupancy either;
/// a refused or non-metric placement leaves placement untouched (only
/// same-placement field edits apply).
fn apply_event_value(&mut self, value: &Event) {
let placement = self.metric_placement_verdict(value);
let materialize_move = matches!(placement, PlacementVerdict::Moved { .. })
&& !matches!(value, Event::Pitched(pe) if !pe.is_well_formed());
self.graph_replace_event(value, 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 == value.id()) {
slot.0 = position.clone();
slot.1 = duration.clone();
}
}
}
}
}
/// 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: a target that never entered canonical state is a
// dangling reference — the whole operation refuses. Tombstoned targets
// are *skipped* per the catalog's re-anchoring rule ("the transpose
// applies only to live pitches", Operation Catalog §Transpose); the
// shift still applies to the remaining live targets. Transpose is
// order-dependent (transpositions do not commute); its canonical
// footprint is the effect-log entry. The transposed values are
// materialized in the graph.
if op
.targets
.iter()
.any(|pitch| !self.objects.contains_key(&TypedObjectId::Pitch(*pitch)))
{
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
}
let live: Vec<PitchId> = op
.targets
.iter()
.copied()
.filter(|pitch| {
matches!(
self.objects.get(&TypedObjectId::Pitch(*pitch)),
Some(ObjectState::Live)
)
})
.collect();
if live.is_empty() {
// Every target was tombstoned by a causally-prior delete: the
// skip-all case degenerates to no effect.
return OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
};
}
// P12-K3: a SYSTEM_DERIVED pitch's intrinsic content is immutable —
// an in-place alteration shift would desynchronize the content from
// the id's derivation inputs (and from the `system_mints` registry).
// System-derived targets are *skipped* like tombstoned ones (the
// shift still applies to the remaining targets); a transpose whose
// live targets are all system-derived reduces as a precondition
// no-op. The filter reads only the id's namespace, so base-free and
// graph-aware reduction agree.
let mutable: Vec<PitchId> = live
.into_iter()
.filter(|pitch| pitch.replica() != ReplicaId::SYSTEM_DERIVED)
.collect();
if mutable.is_empty() {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
},
};
}
for pitch in mutable {
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);
// Seed the pitch's write chain with the minted value, so a later
// modify's chain-predecessor is the inserted state.
self.pitch_modify_chain
.entry(pitch_id)
.or_insert_with(WriteChain::new)
.seed(op.pitch.pitch.clone());
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) => {}
}
// Identity precondition (P12-K3): a system-derived pitch's intrinsic
// content is immutable in place.
if self.system_derived_rewrite(op.pitch, &op.value) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
},
};
}
let prev = self
.pitch_modify_chain
.get(&op.pitch)
.and_then(|chain| chain.last_write())
.map(|write| (write.op, write.value.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.pitch_modify_chain
.entry(op.pitch)
.or_insert_with(WriteChain::new)
.record(env.id, env.transaction, 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>,
referent_voice: Option<VoiceId>,
) {
// 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 {
// Skip a structure that is no longer Live — it may already have
// been tombstoned by this same effect (an undo tombstones a whole
// transaction's mints together, and its stale index entry lingers)
// or cascaded by an earlier referent in this call. Re-processing it
// would push a duplicate repair. The direct-delete path never hits
// this (a tombstoned structure is dropped from `structures`), so the
// guard is a no-op there and load-bearing only for the undo path.
if !matches!(self.objects.get(&sid), Some(ObjectState::Live)) {
continue;
}
match sid {
TypedObjectId::Tie(_) => {
// A tie's existence requires both endpoints: cascade-delete.
self.cascade_structure(env, sid, repairs);
}
// The graph-only referent kinds — markers, cue events,
// comments, analytical annotations, graphic gestures — are
// repaired where their graph mutation happens
// (`reanchor_event_referents`, run from
// `materialize_graph_delete`), so the ledger record and the
// graph always agree. They only exist under graph-aware
// reduction (none is creatable by an operation).
TypedObjectId::Marker(_)
| TypedObjectId::Comment(_)
| TypedObjectId::AnalyticalAnnotation(_)
| TypedObjectId::GraphicGesture(_)
| TypedObjectId::Event(_) => {}
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,
});
}
}
// Repeat structures follow the spanner row across every
// anchor site (rule table "Repeat structure / Anchor").
TypedObjectId::Slur(_)
| TypedObjectId::Spanner(_)
| TypedObjectId::RepeatStructure(_) => {
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) {
// The survivor is fixed (the structure's other
// endpoint — surviving-endpoint collapse per the rule
// table), so only the containment key applies: the
// reason names the survivor's actual proximity rank to
// the tombstoned endpoint rather than a hardcoded
// same-voice claim.
let survivor_voice = match to {
TypedObjectId::Event(event) => self.event_voice(event),
_ => None,
};
let reason = match (referent_voice, survivor_voice) {
(Some(referent), Some(survivor)) => self.rank_reason(
self.containment_rank(referent, survivor),
referent,
Some(survivor),
),
// No indexed placement for either side (a
// non-metric endpoint): the pre-four-key default.
_ => ReanchorReason::SameVoiceNearer,
};
repairs.push(RepairRecord {
kind: RepairKind::Reanchored {
from: tombstoned,
to,
reason,
},
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> {
// For slurs/spanners the rule table prescribes surviving-endpoint
// collapse, so the candidate set is the structure's own endpoints; the
// lexicographically-smallest survivor realizes the id tie-break (a
// two-endpoint structure has exactly one). Proximity-aware re-targeting
// beyond the endpoints stays a deferred refinement (the table says so
// explicitly); the open-candidate four-key ordering lives in
// `nearest_live_event`.
self.structures.get(&sid).and_then(|eps| {
eps.iter()
.filter(|e| {
**e != just_tombstoned
&& matches!(self.objects.get(*e), Some(ObjectState::Live))
})
.min()
.copied()
})
}
// --- The four-key "nearest" ordering (Chapter 6 §"Total Ordering for
// Nearest") and the graph-only rule-table rows. ---------------------------
/// The staff instance a voice lives in, from the base-free ledger index.
fn voice_instance(&self, voice: VoiceId) -> Option<StaffInstanceId> {
self.instance_voices
.iter()
.find_map(|(instance, voices)| voices.contains(&voice).then_some(*instance))
}
/// The region a staff instance lives in, from the base-free ledger index.
fn instance_region_of(&self, instance: StaffInstanceId) -> Option<RegionId> {
self.region_instances
.iter()
.find_map(|(region, instances)| instances.contains(&instance).then_some(*region))
}
/// The voice of a live event with an indexed metric placement.
fn event_voice(&self, event: EventId) -> Option<VoiceId> {
self.voice_occupancy.iter().find_map(|(voice, placements)| {
placements
.iter()
.any(|(_, _, placed)| *placed == event)
.then_some(*voice)
})
}
/// Containment proximity (key 1 of the "nearest" ordering): same voice 0,
/// same staff instance 1, same staff 2, same region 3, same canvas 4.
/// Computed from the base-free ledger indices, so `reduce()` and
/// `reduce_onto()` rank identically wherever both represent the scenario.
/// Whether a rank-4 verdict from [`Self::containment_rank`] was
/// *established* (both voices' placements resolve through instance and
/// region, so "same canvas" is a proven fact of the singleton canvas)
/// rather than the unresolvable-placement fallthrough. Recording reads
/// this so an unestablished 4 stays the honest `ExplicitFallback`
/// (Pass 12, P12-C4) — selection order is unaffected either way.
fn canvas_rank_established(&self, referent_voice: VoiceId, candidate_voice: VoiceId) -> bool {
let (Some(referent), Some(candidate)) = (
self.voice_instance(referent_voice),
self.voice_instance(candidate_voice),
) else {
return false;
};
self.instance_region_of(referent).is_some() && self.instance_region_of(candidate).is_some()
}
/// The [`ReanchorReason`] to *record* for an achieved rank: ranks 03 map
/// directly; rank 4 records `SameCanvasNearer` only when the proximity
/// was established ([`Self::canvas_rank_established`]), else the honest
/// `ExplicitFallback`. `survivor_voice` is `None` when the winning
/// candidate's voice is itself unresolvable — never established.
fn rank_reason(
&self,
rank: u8,
referent_voice: VoiceId,
survivor_voice: Option<VoiceId>,
) -> ReanchorReason {
if rank == 4 {
let established = survivor_voice
.is_some_and(|survivor| self.canvas_rank_established(referent_voice, survivor));
return if established {
ReanchorReason::SameCanvasNearer
} else {
ReanchorReason::ExplicitFallback
};
}
reason_for_rank(rank)
}
fn containment_rank(&self, referent_voice: VoiceId, candidate_voice: VoiceId) -> u8 {
if referent_voice == candidate_voice {
return 0;
}
let (Some(referent), Some(candidate)) = (
self.voice_instance(referent_voice),
self.voice_instance(candidate_voice),
) else {
return 4;
};
if referent == candidate {
return 1;
}
if let (Some(a), Some(b)) = (
self.instance_staff.get(&referent),
self.instance_staff.get(&candidate),
) {
if a == b {
return 2;
}
}
if let (Some(a), Some(b)) = (
self.instance_region_of(referent),
self.instance_region_of(candidate),
) {
if a == b {
return 3;
}
}
4
}
/// The nearest surviving live event to the tombstoned referent under the
/// four-key total order (Chapter 6 §"Total Ordering for Nearest"): the
/// strict lexicographic minimum of (containment proximity, absolute time
/// distance from the referent's resolved position, forward before
/// backward, typed id bytes ascending — an `EventId`'s numeric order *is*
/// its canonical 16-byte order). Candidates ranked farther than `max_rank`
/// are excluded. Read entirely from the canonical ledger indices, so the
/// choice is a function of canonical state (permutation-invariant). Only
/// *metric* placements are indexed; wall-clock distance (proportional
/// regions) is a deferred refinement, so a wall-clock referent finds no
/// candidate and falls to the kind's declared failure action.
fn nearest_live_event(
&self,
referent: &ReferentContext,
exclude: EventId,
max_rank: u8,
) -> Option<(EventId, u8)> {
let EventPosition::Musical(referent_position) = &referent.position else {
return None;
};
let mut best: Option<(u8, RationalTime, u8, EventId)> = None;
for (voice, placements) in &self.voice_occupancy {
let rank = self.containment_rank(referent.voice, *voice);
if rank > max_rank {
continue;
}
for (position, _, event) in placements {
if *event == exclude
|| !matches!(
self.objects.get(&TypedObjectId::Event(*event)),
Some(ObjectState::Live)
)
{
continue;
}
let signed = position.0.sub(&referent_position.0);
let (direction, distance) = if signed.is_negative() {
(1u8, RationalTime::zero().sub(&signed))
} else {
(0u8, signed)
};
let key = (rank, distance, direction, *event);
if best.as_ref().map_or(true, |current| key < *current) {
best = Some(key);
}
}
}
best.map(|(rank, _, _, event)| (event, rank))
}
/// Drops `dead` from `sid`'s referent-index entry, removing the entry when
/// no event reference remains.
fn drop_structure_ref(&mut self, sid: TypedObjectId, dead: TypedObjectId) {
if let Some(refs) = self.structures.get_mut(&sid) {
refs.retain(|existing| *existing != dead);
if refs.is_empty() {
self.structures.remove(&sid);
}
}
}
/// The rule-table rows for the graph-only referent kinds — markers, cue
/// events, comments, analytical annotations, graphic gestures (Chapter 6
/// §"The Re-Anchoring Rule Table"). Runs from
/// [`Self::materialize_graph_delete`], so both the DeleteEvent path and the
/// undo path record the same repairs in the triggering operation's effect.
/// Each row's ledger record and graph mutation are decided together, in
/// canonical id order ("the graph follows the ledger").
fn reanchor_event_referents(
&mut self,
env: &OperationEnvelope,
deleted: EventId,
referent: &ReferentContext,
) -> Vec<RepairRecord> {
let mut repairs = Vec::new();
let deleted_obj = TypedObjectId::Event(deleted);
// The deleted event's own referent entry (a cue's source list) dies
// with it.
self.structures.remove(&deleted_obj);
let referencing: Vec<TypedObjectId> = self
.structures
.iter()
.filter(|(sid, refs)| {
matches!(
sid,
TypedObjectId::Marker(_)
| TypedObjectId::Comment(_)
| TypedObjectId::AnalyticalAnnotation(_)
| TypedObjectId::GraphicGesture(_)
| TypedObjectId::Event(_)
) && refs.contains(&deleted_obj)
&& matches!(self.objects.get(sid), Some(ObjectState::Live))
})
.map(|(sid, _)| *sid)
.collect();
for sid in referencing {
match sid {
TypedObjectId::Marker(_) => {
self.reanchor_marker(deleted, referent, sid, &mut repairs)
}
TypedObjectId::Event(cue) => self.cascade_cue(env, cue, &mut repairs),
TypedObjectId::Comment(_) => {
self.orphan_comment(deleted, referent, sid, &mut repairs)
}
TypedObjectId::AnalyticalAnnotation(_) => {
self.reanchor_annotation(deleted, referent, sid, &mut repairs)
}
TypedObjectId::GraphicGesture(_) => {
self.reanchor_gesture(deleted, referent, sid, &mut repairs)
}
_ => {}
}
}
repairs
}
/// Row "Marker / Anchor": re-anchor to the nearest event in the same staff
/// instance (proximity max: same staff instance); orphan on failure.
fn reanchor_marker(
&mut self,
deleted: EventId,
referent: &ReferentContext,
sid: TypedObjectId,
repairs: &mut Vec<RepairRecord>,
) {
let TypedObjectId::Marker(marker) = sid else {
return;
};
match self.nearest_live_event(referent, deleted, PROXIMITY_SAME_STAFF_INSTANCE) {
Some((to, rank)) => {
if let Some(score) = self.graph.as_mut() {
if let Some(value) = score
.cross_cutting
.markers
.iter_mut()
.find(|value| value.id == marker)
{
if let TimeAnchor::Event { id, .. } = &mut value.anchor {
if *id == deleted {
// The anchor offset is preserved: the survivor
// shares the staff instance, hence the region
// and its offset discipline (invariant 9).
*id = to;
}
}
}
}
self.structures.insert(sid, vec![TypedObjectId::Event(to)]);
repairs.push(RepairRecord {
kind: RepairKind::Reanchored {
from: TypedObjectId::Event(deleted),
to: TypedObjectId::Event(to),
reason: reason_for_rank(rank),
},
target: sid,
});
}
None => {
// Orphan: the marker (user content) is kept. Invariant 10
// rejects a dangling event anchor, so the graph anchor degrades
// to the containing region's start — anchor hygiene, not a
// re-anchoring choice; the ledger records the orphaning.
if let Some(region) = referent.region {
if let Some(score) = self.graph.as_mut() {
if let Some(value) = score
.cross_cutting
.markers
.iter_mut()
.find(|value| value.id == marker)
{
if matches!(value.anchor, TimeAnchor::Event { id, .. } if id == deleted)
{
value.anchor = TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
};
}
}
}
}
self.structures.remove(&sid);
repairs.push(RepairRecord {
kind: RepairKind::Orphaned,
target: sid,
});
}
}
}
/// Row "Cue event / Source event": cascade-delete — the plain normative
/// action, on *any* source deletion (the multi-source rationale tension is
/// a proposed Pass-12 row). The cascaded cue is itself a tombstoned event,
/// so the full re-anchoring pass — the graph-only rows via the recursive
/// `materialize_graph_delete`, and the tie/beam/slur/spanner ledger arm via
/// `reanchor_for_tombstone` — runs over its own referents transitively, in
/// the same reduction step.
fn cascade_cue(
&mut self,
env: &OperationEnvelope,
cue: EventId,
repairs: &mut Vec<RepairRecord>,
) {
let sid = TypedObjectId::Event(cue);
// A cue this pass already cascaded transitively (a cue-of-a-cue chain
// reaching back into the referencing list) must not double-record.
if !matches!(self.objects.get(&sid), Some(ObjectState::Live)) {
return;
}
let cue_voice = self.event_voice(cue);
self.cascade_structure(env, sid, repairs);
self.structures.remove(&sid);
for events in self.voice_occupancy.values_mut() {
events.retain(|(_, _, event)| *event != cue);
}
self.voice_occupancy.retain(|_, events| !events.is_empty());
self.event_pitches.remove(&cue);
let cue_delete = DeleteEventOp {
event: cue,
tuplet_compensation: TupletCompensation::NotInTuplet,
};
repairs.extend(self.materialize_graph_delete(env, &cue_delete));
self.reanchor_for_tombstone(env, sid, repairs, cue_voice);
}
/// Row "Comment / Anchor": orphan — user content is never silently
/// deleted. The comment stays live in ledger and graph; its dangling
/// anchor references degrade to the containing-region forms so invariant
/// 10 keeps holding.
fn orphan_comment(
&mut self,
deleted: EventId,
referent: &ReferentContext,
sid: TypedObjectId,
repairs: &mut Vec<RepairRecord>,
) {
let TypedObjectId::Comment(comment) = sid else {
return;
};
if let Some(region) = referent.region {
if let Some(score) = self.graph.as_mut() {
if let Some(value) = score
.cross_cutting
.comments
.iter_mut()
.find(|value| value.id == comment)
{
orphan_annotation_anchor(&mut value.anchor, deleted, region);
}
}
}
self.drop_structure_ref(sid, TypedObjectId::Event(deleted));
repairs.push(RepairRecord {
kind: RepairKind::Orphaned,
target: sid,
});
}
/// Row "Analytical annotation / Anchor": re-anchor to a time range
/// preserving the original extent; orphan when the range cannot be
/// reconstructed. Reconstruction needs the containing region plus an exact
/// musical placement — the range endpoints become region-start offsets, so
/// they resolve to the deleted event's exact span. A wall-clock or
/// indeterminate span is not expressible as a stored region-relative range
/// (the expressibility gap is a proposed Pass-12 row), so it orphans.
fn reanchor_annotation(
&mut self,
deleted: EventId,
referent: &ReferentContext,
sid: TypedObjectId,
repairs: &mut Vec<RepairRecord>,
) {
let TypedObjectId::AnalyticalAnnotation(annotation) = sid else {
return;
};
let deleted_obj = TypedObjectId::Event(deleted);
let current = self.graph.as_ref().and_then(|score| {
score
.cross_cutting
.analytical
.iter()
.find(|value| value.id == annotation)
.map(|value| value.anchor.clone())
});
let Some(current) = current else {
self.drop_structure_ref(sid, deleted_obj);
return;
};
// A stale index entry (the anchor no longer references the deleted
// event) drops the reference with no repair.
if !annotation_anchor_event_refs(&current).contains(&deleted_obj) {
self.drop_structure_ref(sid, deleted_obj);
return;
}
let musical_span = match (&referent.position, &referent.duration) {
(EventPosition::Musical(position), EventDuration::Musical(duration)) => {
Some((position.0.clone(), duration.0.clone()))
}
_ => None,
};
let range_point = |resolved: RationalTime, region: RegionId| TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(resolved)),
};
let reconstructed: Option<AnnotationAnchor> = match &current {
AnnotationAnchor::Event(event) if *event == deleted => {
match (musical_span.as_ref(), referent.region) {
(Some((position, duration)), Some(region)) => Some(AnnotationAnchor::Range {
start: range_point(position.clone(), region),
end: range_point(position.add(duration), region),
}),
_ => None,
}
}
AnnotationAnchor::Range { start, end } => {
// Per endpoint: an event-anchored endpoint of the deleted event
// is rebuilt at its resolved position (the event's position
// plus any musical anchor offset); live endpoints are kept.
let rebuild = |endpoint: &TimeAnchor| -> Option<TimeAnchor> {
match endpoint {
TimeAnchor::Event { id, offset } if *id == deleted => {
let (position, _) = musical_span.as_ref()?;
let region = referent.region?;
let resolved = match offset {
AnchorOffset::Zero => position.clone(),
AnchorOffset::Musical(delta) => position.add(&delta.0),
AnchorOffset::WallClock(_) => return None,
};
Some(range_point(resolved, region))
}
other => Some(other.clone()),
}
};
match (rebuild(start), rebuild(end)) {
(Some(start), Some(end)) => Some(AnnotationAnchor::Range { start, end }),
_ => None,
}
}
// Stale index entry (the anchor no longer references the deleted
// event): drop the reference, no repair.
_ => {
self.drop_structure_ref(sid, deleted_obj);
return;
}
};
match reconstructed {
Some(anchor) => {
let region = referent
.region
.expect("range reconstruction required the containing region");
if let Some(score) = self.graph.as_mut() {
if let Some(value) = score
.cross_cutting
.analytical
.iter_mut()
.find(|value| value.id == annotation)
{
value.anchor = anchor;
}
}
self.drop_structure_ref(sid, deleted_obj);
repairs.push(RepairRecord {
kind: RepairKind::Reanchored {
from: deleted_obj,
to: TypedObjectId::Region(region),
reason: ReanchorReason::ExplicitFallback,
},
target: sid,
});
}
None => {
if let Some(region) = referent.region {
if let Some(score) = self.graph.as_mut() {
if let Some(value) = score
.cross_cutting
.analytical
.iter_mut()
.find(|value| value.id == annotation)
{
orphan_annotation_anchor(&mut value.anchor, deleted, region);
}
}
}
self.drop_structure_ref(sid, deleted_obj);
repairs.push(RepairRecord {
kind: RepairKind::Orphaned,
target: sid,
});
}
}
}
/// Row "Graphic gesture / Anchor event": re-anchor each deleted event
/// reference to the nearest surviving event of the same staff instance
/// (proximity max: same staff instance); with no candidate the reference is
/// dropped — truncation while references remain, orphaning when the list
/// empties. Range anchoring truncates (dead endpoints move to the region
/// edges); Free anchoring is never indexed.
fn reanchor_gesture(
&mut self,
deleted: EventId,
referent: &ReferentContext,
sid: TypedObjectId,
repairs: &mut Vec<RepairRecord>,
) {
let TypedObjectId::GraphicGesture(gesture) = sid else {
return;
};
let deleted_obj = TypedObjectId::Event(deleted);
let current = self.graph.as_ref().and_then(|score| {
score
.cross_cutting
.graphic_gestures
.iter()
.find(|value| value.id == gesture)
.map(|value| value.anchoring.clone())
});
let Some(anchoring) = current else {
self.drop_structure_ref(sid, deleted_obj);
return;
};
let set_anchoring = |reducer: &mut Self, anchoring: GestureAnchoring| {
if let Some(score) = reducer.graph.as_mut() {
if let Some(value) = score
.cross_cutting
.graphic_gestures
.iter_mut()
.find(|value| value.id == gesture)
{
value.anchoring = anchoring;
}
}
};
match anchoring {
GestureAnchoring::Events(events) => {
if !events.contains(&deleted) {
self.drop_structure_ref(sid, deleted_obj);
return;
}
match self.nearest_live_event(referent, deleted, PROXIMITY_SAME_STAFF_INSTANCE) {
Some((to, rank)) => {
let retargeted: Vec<EventId> = events
.iter()
.map(|event| if *event == deleted { to } else { *event })
.collect();
self.structures.insert(
sid,
retargeted
.iter()
.copied()
.map(TypedObjectId::Event)
.collect(),
);
set_anchoring(self, GestureAnchoring::Events(retargeted));
repairs.push(RepairRecord {
kind: RepairKind::Reanchored {
from: deleted_obj,
to: TypedObjectId::Event(to),
reason: reason_for_rank(rank),
},
target: sid,
});
}
None => {
let remaining: Vec<EventId> = events
.iter()
.copied()
.filter(|event| *event != deleted)
.collect();
let emptied = remaining.is_empty();
if emptied {
self.structures.remove(&sid);
} else {
self.structures.insert(
sid,
remaining
.iter()
.copied()
.map(TypedObjectId::Event)
.collect(),
);
}
set_anchoring(self, GestureAnchoring::Events(remaining));
repairs.push(RepairRecord {
kind: if emptied {
// The reference list emptied: the gesture (user
// content) is kept, reference-free.
RepairKind::Orphaned
} else {
RepairKind::SpannerTruncated {
removed_members: vec![deleted_obj],
}
},
target: sid,
});
}
}
}
GestureAnchoring::Range { start, end, staves } => {
let Some(region) = referent.region else {
self.drop_structure_ref(sid, deleted_obj);
return;
};
let mut start = start;
let mut end = end;
retarget_dead_endpoint(&mut start, deleted, region, RegionEdge::Start);
retarget_dead_endpoint(&mut end, deleted, region, RegionEdge::End);
set_anchoring(self, GestureAnchoring::Range { start, end, staves });
self.drop_structure_ref(sid, deleted_obj);
repairs.push(RepairRecord {
kind: RepairKind::Reanchored {
from: deleted_obj,
to: TypedObjectId::Region(region),
reason: ReanchorReason::ExplicitFallback,
},
target: sid,
});
}
GestureAnchoring::Free => {
self.drop_structure_ref(sid, deleted_obj);
}
}
}
// --- 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(),
respell_chain: self.respell_chain.clone(),
event_modify_chain: self.event_modify_chain.clone(),
pitch_modify_chain: self.pitch_modify_chain.clone(),
cross_cutting_modify_chain: self.cross_cutting_modify_chain.clone(),
metric_grid_chain: self.metric_grid_chain.clone(),
metadata_chain: self.metadata_chain.clone(),
break_chain: self.break_chain.clone(),
page_break_chain: self.page_break_chain.clone(),
meter_change_chain: self.meter_change_chain.clone(),
tempo_segment_chain: self.tempo_segment_chain.clone(),
staff_layout_chain: self.staff_layout_chain.clone(),
staff_values: self.staff_values.clone(),
time_signature_values: self.time_signature_values.clone(),
structures: self.structures.clone(),
region_instances: self.region_instances.clone(),
instance_voices: self.instance_voices.clone(),
instance_staff: self.instance_staff.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.respell_chain = s.respell_chain;
self.event_modify_chain = s.event_modify_chain;
self.pitch_modify_chain = s.pitch_modify_chain;
self.cross_cutting_modify_chain = s.cross_cutting_modify_chain;
self.metric_grid_chain = s.metric_grid_chain;
self.metadata_chain = s.metadata_chain;
self.break_chain = s.break_chain;
self.page_break_chain = s.page_break_chain;
self.meter_change_chain = s.meter_change_chain;
self.tempo_segment_chain = s.tempo_segment_chain;
self.staff_layout_chain = s.staff_layout_chain;
self.staff_values = s.staff_values;
self.time_signature_values = s.time_signature_values;
self.structures = s.structures;
self.region_instances = s.region_instances;
self.instance_voices = s.instance_voices;
self.instance_staff = s.instance_staff;
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(),
&[],
),
})),
}
}
// --- Subquadratic canonical order vs. the retained O(n²) oracle. --------
/// A minimal envelope for pure ordering tests: payload content never
/// affects the canonical reduction order.
fn order_env(
replica: ReplicaId,
counter: u64,
physical: i64,
logical: u32,
ctx: CausalContext,
) -> OperationEnvelope {
let id = OperationId::new(replica, counter);
OperationEnvelope {
id,
author: AuthorId(1),
stamp: OperationStamp::new(
HybridLogicalClock::new(WallClockTime(physical), logical),
id,
),
causal_context: ctx,
transaction: None,
payload: OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: EventId::new(ReplicaId(7), counter % 5),
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
}
}
/// Asserts the subquadratic order equals the reference oracle's
/// element-for-element — by slice element identity (pointer equality), the
/// strictest possible check: it distinguishes even byte-identical
/// duplicate envelopes, whose tuple ties both implementations must break
/// by slice position.
fn assert_order_matches_reference(envelopes: &[OperationEnvelope]) {
let refs: Vec<&OperationEnvelope> = envelopes.iter().collect();
let fast = canonical_reduction_order(&refs);
let oracle = canonical_reduction_order_reference(&refs);
assert_eq!(fast.len(), oracle.len());
for (at, (a, b)) in fast.iter().zip(&oracle).enumerate() {
assert!(
std::ptr::eq(*a, *b),
"canonical order diverges from the reference at position {at}: \
{:?} vs {:?} (n = {})",
a.id,
b.id,
envelopes.len()
);
}
}
/// In-place FisherYates driven by the seeded generator (slice order is an
/// input to the tuple tie-break, so permutations must be exercised too).
fn shuffle_envelopes(
envelopes: &mut [OperationEnvelope],
rng: &mut epiphany_determinism::fuzz::SplitMix64,
) {
for i in (1..envelopes.len()).rev() {
let j = (rng.next_u64() % (i as u64 + 1)) as usize;
envelopes.swap(i, j);
}
}
/// A hostile ordering input the crate's well-formed generators avoid:
/// floors that cover the envelope's own id or absent counters, dots to
/// present / absent / own ids, duplicate ids (byte-identical twins),
/// `SYSTEM_DERIVED` replicas, colliding stamps that contradict the causal
/// edges (so the topological pass and the cycle-breaker both engage), and
/// empty contexts.
fn adversarial_set(
rng: &mut epiphany_determinism::fuzz::SplitMix64,
n: usize,
) -> Vec<OperationEnvelope> {
let replicas = 1 + rng.next_u64() % 4;
let mut next_counter: BTreeMap<ReplicaId, u64> = BTreeMap::new();
let mut envs: Vec<OperationEnvelope> = Vec::with_capacity(n);
for _ in 0..n {
if !envs.is_empty() && rng.next_u64() % 8 == 0 {
// A duplicate id — and a byte-identical stamp, so the
// reduction tuple genuinely ties.
let victim = envs[(rng.next_u64() % envs.len() as u64) as usize].clone();
envs.push(victim);
continue;
}
let replica = if rng.next_u64() % 16 == 0 {
ReplicaId::SYSTEM_DERIVED
} else {
ReplicaId(1 + rng.next_u64() % replicas)
};
let slot = next_counter.entry(replica).or_insert(0);
// Occasionally skip counters so floors assert absent predecessors.
let counter = *slot + rng.next_u64() % 2;
*slot = counter + 1;
let id = OperationId::new(replica, counter);
let mut ctx = CausalContext::new();
for _ in 0..rng.next_u64() % 3 {
let target = match rng.next_u64() % 5 {
0 => replica, // may cover the envelope's own id
1 => ReplicaId(9), // absent replica
2 => ReplicaId::SYSTEM_DERIVED,
_ => ReplicaId(1 + rng.next_u64() % replicas),
};
// May exceed every present counter (covering future authoring
// of that replica — a causal cycle) or fall below all of them.
ctx = ctx.with_seen(target, rng.next_u64() % 8);
}
for _ in 0..rng.next_u64() % 3 {
let dot = if !envs.is_empty() && rng.next_u64() % 2 == 0 {
envs[(rng.next_u64() % envs.len() as u64) as usize].id
} else if rng.next_u64() % 4 == 0 {
id // the envelope's own id
} else {
OperationId::new(ReplicaId(1 + rng.next_u64() % 5), rng.next_u64() % 10)
};
ctx = ctx.with_dot(dot);
}
// Tiny stamp ranges force heavy tuple collisions and stamps that
// contradict the causal edges.
envs.push(order_env(
replica,
counter,
(rng.next_u64() % 6) as i64,
(rng.next_u64() % 3) as u32,
ctx,
));
}
envs
}
#[test]
fn canonical_order_matches_reference_on_fuzz_sets() {
// The crate's own well-formed generator: multi-replica meshes of
// vector floors, occasional equivocation twins (duplicate ids) and
// HLC-monotonicity anomalies, empty contexts on counter-0 roots.
let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0xF1_0DE2_0001);
for _ in 0..250 {
let n = 1 + (rng.next_u64() % 40) as usize;
let mut envs = crate::fuzz::gen_envelope_set(&mut rng, n);
assert_order_matches_reference(&envs);
shuffle_envelopes(&mut envs, &mut rng);
assert_order_matches_reference(&envs);
}
}
#[test]
fn canonical_order_matches_reference_on_adversarial_sets() {
let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0xADE5_A71A_0002);
assert_order_matches_reference(&[]);
for iteration in 0..400 {
let n = 1 + (rng.next_u64() % 60) as usize;
let mut envs = adversarial_set(&mut rng, n);
assert_order_matches_reference(&envs);
if iteration % 4 == 0 {
shuffle_envelopes(&mut envs, &mut rng);
assert_order_matches_reference(&envs);
}
}
}
#[test]
fn canonical_order_matches_reference_on_directed_shapes() {
let r = ReplicaId(1);
// A 2,000-envelope single-replica chain whose every DVV floor covers
// the full replica prefix — the inherently-quadratic-pairs shape the
// subquadratic construction exists for — with *descending* stamps, so
// the causal edges (not the HLC) decide every single emission.
let full_chain: Vec<OperationEnvelope> = (0..2_000)
.map(|c| {
let ctx = if c == 0 {
CausalContext::new()
} else {
CausalContext::new().with_seen(r, c - 1)
};
order_env(r, c, 2_000 - c as i64, 0, ctx)
})
.collect();
assert_order_matches_reference(&full_chain);
// A self-covering chain: every floor also covers the envelope's own
// id (the exempted self-pair).
let self_chain: Vec<OperationEnvelope> = (0..600)
.map(|c| {
order_env(
r,
c,
600 - c as i64,
0,
CausalContext::new().with_seen(r, c),
)
})
.collect();
assert_order_matches_reference(&self_chain);
// A dot-only chain (no vector floors at all).
let dot_chain: Vec<OperationEnvelope> = (0..600)
.map(|c| {
let ctx = if c == 0 {
CausalContext::new()
} else {
CausalContext::new().with_dot(OperationId::new(r, c - 1))
};
order_env(r, c, 600 - c as i64, 0, ctx)
})
.collect();
assert_order_matches_reference(&dot_chain);
// Malformed dot cycles (2-cycle and 3-cycle) among bystanders with
// empty contexts and identical stamps.
let id = |rep: u64, c: u64| OperationId::new(ReplicaId(rep), c);
let cycles = vec![
order_env(
ReplicaId(2),
0,
5,
0,
CausalContext::new().with_dot(id(2, 1)),
),
order_env(
ReplicaId(2),
1,
5,
0,
CausalContext::new().with_dot(id(2, 0)),
),
order_env(
ReplicaId(3),
0,
5,
0,
CausalContext::new().with_dot(id(3, 2)),
),
order_env(
ReplicaId(3),
1,
5,
0,
CausalContext::new().with_dot(id(3, 0)),
),
order_env(
ReplicaId(3),
2,
5,
0,
CausalContext::new().with_dot(id(3, 1)),
),
order_env(ReplicaId(4), 0, 5, 0, CausalContext::new()),
order_env(ReplicaId(5), 0, 5, 0, CausalContext::new()),
];
assert_order_matches_reference(&cycles);
// Mutual full-coverage floors (a floor cycle where each envelope also
// covers itself), plus coverage of absent ids on an absent replica.
let floor_cycle = vec![
order_env(
ReplicaId(6),
0,
9,
0,
CausalContext::new().with_seen(ReplicaId(6), 1),
),
order_env(
ReplicaId(6),
1,
8,
0,
CausalContext::new().with_seen(ReplicaId(6), 1),
),
order_env(
ReplicaId(6),
2,
7,
0,
CausalContext::new()
.with_seen(ReplicaId(40), 12)
.with_dot(id(41, 3)),
),
];
assert_order_matches_reference(&floor_cycle);
// Byte-identical duplicate ids (tuple ties broken by slice position)
// in several slice orders, including a dot and a floor onto the
// duplicated id.
let twin = order_env(ReplicaId(2), 3, 1, 0, CausalContext::new());
let mut twins = vec![
twin.clone(),
twin.clone(),
order_env(
ReplicaId(2),
4,
0,
0,
CausalContext::new().with_dot(id(2, 3)),
),
order_env(
ReplicaId(3),
0,
0,
0,
CausalContext::new().with_seen(ReplicaId(2), 3),
),
// A twin that dots its own id: covers only its duplicate.
order_env(
ReplicaId(2),
3,
1,
0,
CausalContext::new().with_dot(id(2, 3)),
),
];
assert_order_matches_reference(&twins);
twins.reverse();
assert_order_matches_reference(&twins);
twins.swap(0, 2);
assert_order_matches_reference(&twins);
// SYSTEM_DERIVED authoring under floors and dots from user replicas.
let sys = ReplicaId::SYSTEM_DERIVED;
let system = vec![
order_env(sys, 0, 3, 0, CausalContext::new()),
order_env(sys, 1, 2, 0, CausalContext::new().with_seen(sys, 0)),
order_env(
ReplicaId(2),
0,
1,
0,
CausalContext::new().with_seen(sys, 1),
),
order_env(
ReplicaId(2),
1,
0,
0,
CausalContext::new().with_dot(id(u64::MAX, 0)),
),
];
assert_order_matches_reference(&system);
}
#[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 { .. }
));
}
// --- Phase D: repeat authoring (schema-major-2 revision). ----------------
fn create_repeat(
rid: epiphany_core::RepeatStructureId,
a: EventId,
b: EventId,
) -> OperationKind {
OperationKind::CreateRepeatStructure(CreateRepeatStructureOp {
repeat: crate::valuegen::repeat_structure(rid, a, b),
})
}
#[test]
fn create_repeat_structure_mints_and_delete_wins_tombstones() {
let e1 = EventId::new(ReplicaId(1), 100);
let e2 = EventId::new(ReplicaId(1), 101);
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1);
let sid = TypedObjectId::RepeatStructure(rid);
let mut set = OperationSet::new();
set.accept_all(vec![
insert(1, 0, 10, 1, 100, 0),
insert(1, 1, 11, 1, 101, 1),
prim_env(1, 2, 12, seen_r1(1), create_repeat(rid, e1, e2)),
]);
assert!(
matches!(set.reduce().objects.get(&sid), Some(ObjectState::Live)),
"set-union mint with live anchors"
);
// Delete-wins: the tombstone survives a concurrent re-delete
// (idempotent) and a post-delete re-create (TargetTombstoned no-op).
let mut set = OperationSet::new();
set.accept_all(vec![
insert(1, 0, 10, 1, 100, 0),
insert(1, 1, 11, 1, 101, 1),
prim_env(1, 2, 12, seen_r1(1), create_repeat(rid, e1, e2)),
prim_env(
1,
3,
13,
seen_r1(2),
OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { repeat: rid }),
),
prim_env(
2,
0,
14,
seen_r1(3),
OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { repeat: rid }),
),
prim_env(3, 0, 15, seen_r1(3), create_repeat(rid, e1, e2)),
]);
assert!(
matches!(
set.reduce().objects.get(&sid),
Some(ObjectState::Tombstoned { .. })
),
"the tombstone survives re-delete and re-create (delete-wins)"
);
}
#[test]
fn create_repeat_structure_preconditions_every_anchor_site_live() {
// The all-anchors-live precondition covers the kind's jump targets and
// each volta's span, not just start/end (operation_catalog §"Repeat
// Structures").
let e1 = EventId::new(ReplicaId(1), 100);
let e2 = EventId::new(ReplicaId(1), 101);
let ghost = EventId::new(ReplicaId(1), 6_666);
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1);
let mut dal_segno = crate::valuegen::repeat_structure(rid, e1, e2);
dal_segno.kind = epiphany_core::RepeatKind::DalSegno {
segno: crate::valuegen::event_anchor(ghost),
end_target: crate::valuegen::event_anchor(e2),
};
let mut volta = crate::valuegen::volta_repeat(rid, e1, e2);
volta.voltas[1].end = crate::valuegen::event_anchor(ghost);
for repeat in [dal_segno, volta] {
let mut set = OperationSet::new();
set.accept_all(vec![
insert(1, 0, 10, 1, 100, 0),
insert(1, 1, 11, 1, 101, 1),
prim_env(
1,
2,
12,
seen_r1(1),
OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { repeat }),
),
]);
assert!(
!set.reduce()
.objects
.contains_key(&TypedObjectId::RepeatStructure(rid)),
"a dead jump-target/volta anchor is a TargetMissing no-op — nothing minted"
);
}
}
#[test]
fn deleting_an_event_reanchors_a_repeat_across_every_anchor_site() {
// The rule table's "Repeat structure / Anchor" row: re-anchor to the
// nearest surviving anchor, across start/end AND volta spans, recorded
// as a RepairRecord — and the graph agrees with the ledger.
use epiphany_core::generators::valid_score;
let mut base = valid_score(0x5EED);
let voice_events = base
.voices()
.map(|(_, _, v)| v.events.clone())
.next()
.expect("the fixture has a voice");
let (e0, e1) = (voice_events[0], voice_events[1]);
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(9), 800);
base.cross_cutting
.repeats
.push(epiphany_core::RepeatStructure {
id: rid,
start: crate::valuegen::event_anchor(e0),
end: crate::valuegen::event_anchor(e1),
kind: epiphany_core::RepeatKind::Volta,
voltas: vec![epiphany_core::Volta {
endings: vec![1],
start: crate::valuegen::event_anchor(e0),
end: crate::valuegen::event_anchor(e1),
}],
});
let del = prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::DeleteEvent(DeleteEventOp {
event: e0,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![del.clone()]);
let result = set.reduce_onto(&base);
let effect = result
.state
.effects
.iter()
.find(|(id, _)| *id == del.id)
.map(|(_, e)| e)
.expect("delete effect recorded");
let OperationEffect::AppliedWithRepair { repairs } = effect else {
panic!("expected AppliedWithRepair, got {effect:?}");
};
assert!(
repairs.iter().any(|r| {
r.target == TypedObjectId::RepeatStructure(rid)
&& r.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(e0),
to: TypedObjectId::Event(e1),
reason: ReanchorReason::SameVoiceNearer,
}
}),
"the repeat re-anchor is a recorded repair: {repairs:?}"
);
let repeat = result
.score
.cross_cutting
.repeats
.iter()
.find(|r| r.id == rid)
.expect("the repeat survives with one live anchor");
let expect_e1 =
|anchor: &TimeAnchor| matches!(anchor, TimeAnchor::Event { id, .. } if *id == e1);
assert!(
expect_e1(&repeat.start)
&& expect_e1(&repeat.end)
&& expect_e1(&repeat.voltas[0].start)
&& expect_e1(&repeat.voltas[0].end),
"EVERY dead anchor site — start and the volta span — moved to the survivor"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
}
#[test]
fn create_repeat_structure_preconditions_non_event_anchor_targets() {
// The all-anchor-sites-live precondition covers OBJECT references of
// every kind — a missing REGION or MEASURE target refuses the mint
// exactly like a missing event (an event-only check minted a
// dangling repeat straight past CrossCuttingRefsResolve).
use epiphany_core::generators::valid_score_rich;
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1);
// Base-free: live event anchors, but start names a missing region.
let e1 = EventId::new(ReplicaId(1), 100);
let e2 = EventId::new(ReplicaId(1), 101);
let mut repeat = crate::valuegen::repeat_structure(rid, e1, e2);
repeat.start = TimeAnchor::Region {
id: RegionId::new(ReplicaId(1), 6_666),
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
};
let mut set = OperationSet::new();
set.accept_all(vec![
insert(1, 0, 10, 1, 100, 0),
insert(1, 1, 11, 1, 101, 1),
prim_env(
1,
2,
12,
seen_r1(1),
OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { repeat }),
),
]);
assert!(
!set.reduce()
.objects
.contains_key(&TypedObjectId::RepeatStructure(rid)),
"a missing region anchor target refuses the mint"
);
// Graph-aware: a live measure + live region anchor MINTS (and the
// graph stays invariant-clean); a ghost measure in a volta span
// refuses.
let base = valid_score_rich(0x0D0D);
let (region_id, measure_id) = base
.canvas
.regions
.iter()
.find_map(|r| {
r.staff_instances()
.iter()
.find_map(|si| si.measures.first().map(|m| (r.id, m.id)))
})
.expect("the rich fixture has a measure");
let measure_anchor = TimeAnchor::Measure {
id: measure_id,
position: epiphany_core::MeasurePosition::Start,
offset: AnchorOffset::Zero,
};
let region_anchor = TimeAnchor::Region {
id: region_id,
edge: RegionEdge::End,
offset: AnchorOffset::Zero,
};
let live = epiphany_core::RepeatStructure {
id: rid,
start: measure_anchor.clone(),
end: region_anchor.clone(),
kind: epiphany_core::RepeatKind::SimpleRepeat { count: 2 },
voltas: Vec::new(),
};
let mut set = OperationSet::new();
set.accept_all(vec![prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { repeat: live }),
)]);
let result = set.reduce_onto(&base);
assert!(
matches!(
result
.state
.objects
.get(&TypedObjectId::RepeatStructure(rid)),
Some(ObjectState::Live)
),
"live measure/region anchors mint"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
let mut ghost_volta = epiphany_core::RepeatStructure {
id: epiphany_core::RepeatStructureId::new(ReplicaId(1), 2),
start: measure_anchor,
end: region_anchor.clone(),
kind: epiphany_core::RepeatKind::Volta,
voltas: vec![epiphany_core::Volta {
endings: vec![1],
start: region_anchor,
end: TimeAnchor::Measure {
id: epiphany_core::MeasureId::new(ReplicaId(1), 6_666),
position: epiphany_core::MeasurePosition::Start,
offset: AnchorOffset::Zero,
},
}],
};
let ghost_id = ghost_volta.id;
ghost_volta.voltas[0].endings = vec![1];
let mut set = OperationSet::new();
set.accept_all(vec![prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::CreateRepeatStructure(CreateRepeatStructureOp {
repeat: ghost_volta,
}),
)]);
let result = set.reduce_onto(&base);
assert!(
!result
.state
.objects
.contains_key(&TypedObjectId::RepeatStructure(ghost_id)),
"a missing measure target inside a volta span refuses the mint"
);
assert!(
!result
.score
.cross_cutting
.repeats
.iter()
.any(|r| r.id == ghost_id),
"nothing dangling reaches the graph"
);
}
#[test]
fn create_cross_cutting_spanner_preconditions_region_measure_anchors() {
// P13-D3 (fix the mint): a spanner anchored to a missing region or
// measure is refused (the mint checks `anchor_object_refs`, not just the
// event endpoints `endpoints()` returns), and a spanner anchored to a
// live region/measure mints. Non-event referent re-anchoring stays
// events-only (ratified — the spanner discipline).
use epiphany_core::generators::valid_score_rich;
use epiphany_core::{MeasurePosition, SpannerId};
let base = valid_score_rich(0x0D0E);
let (region_id, measure_id) = base
.canvas
.regions
.iter()
.find_map(|r| {
r.staff_instances()
.iter()
.find_map(|si| si.measures.first().map(|m| (r.id, m.id)))
})
.expect("the rich fixture has a measure");
let spanner = |id, start, end| {
OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Spanner(epiphany_core::Spanner {
id,
start,
end,
staves: Vec::new(),
kind: Default::default(),
style: Default::default(),
}),
})
};
let region_anchor = |id| TimeAnchor::Region {
id,
edge: RegionEdge::End,
offset: AnchorOffset::Zero,
};
// Start names a missing region: refused, not minted dangling.
let dangling = SpannerId::new(ReplicaId(9), 810);
let mut set = OperationSet::new();
set.accept_all(vec![prim_env(
9,
0,
10,
CausalContext::new(),
spanner(
dangling,
TimeAnchor::Region {
id: RegionId::new(ReplicaId(9), 7_777),
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
region_anchor(region_id),
),
)]);
assert!(
!set.reduce_onto(&base)
.state
.objects
.contains_key(&TypedObjectId::Spanner(dangling)),
"a spanner anchored to a missing region is refused, not minted dangling"
);
// Live measure + live region anchors: mints, invariant-clean.
let live = SpannerId::new(ReplicaId(9), 811);
let mut set = OperationSet::new();
set.accept_all(vec![prim_env(
9,
0,
10,
CausalContext::new(),
spanner(
live,
TimeAnchor::Measure {
id: measure_id,
position: MeasurePosition::Start,
offset: AnchorOffset::Zero,
},
region_anchor(region_id),
),
)]);
let result = set.reduce_onto(&base);
assert!(
matches!(
result.state.objects.get(&TypedObjectId::Spanner(live)),
Some(ObjectState::Live)
),
"live region/measure anchors mint the spanner"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
// MODIFY sibling: a live event-anchored spanner MODIFIED onto a missing
// region is refused too — `endpoints()` is empty for the new region
// anchors, so the pre-fix modify check slipped a dangling value into the
// graph past the core invariant.
let events = base
.voices()
.map(|(_, _, v)| v.events.clone())
.next()
.expect("the fixture has a voice");
let (e0, e1) = (events[0], events[1]);
let mod_id = SpannerId::new(ReplicaId(9), 812);
let mod_obj = TypedObjectId::Spanner(mod_id);
let mut set = OperationSet::new();
set.accept_all(vec![
prim_env(
9,
0,
10,
CausalContext::new(),
spanner(
mod_id,
crate::valuegen::event_anchor(e0),
crate::valuegen::event_anchor(e1),
),
),
prim_env(
9,
1,
11,
CausalContext::new().with_seen(ReplicaId(9), 0),
OperationKind::ModifyCrossCutting(crate::payload::ModifyCrossCuttingOp {
structure: CrossCuttingValue::Spanner(epiphany_core::Spanner {
id: mod_id,
start: TimeAnchor::Region {
id: RegionId::new(ReplicaId(9), 7_778),
edge: RegionEdge::Start,
offset: AnchorOffset::Zero,
},
end: region_anchor(region_id),
staves: Vec::new(),
kind: Default::default(),
style: Default::default(),
}),
}),
),
]);
let result = set.reduce_onto(&base);
// The spanner stays live at its original event anchors (modify refused)…
assert!(
matches!(result.state.objects.get(&mod_obj), Some(ObjectState::Live)),
"the spanner survives the refused modify"
);
// …and the dangling region anchor never reached the graph.
assert!(
!result.score.cross_cutting.spanners.iter().any(|s| {
s.id == mod_id
&& matches!(s.start, TimeAnchor::Region { id, .. } if id == RegionId::new(ReplicaId(9), 7_778))
}),
"the dangling modify never reached the graph"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
}
#[test]
fn deleting_an_event_rewires_a_dal_segno_jump_target() {
// The kind's jump targets are anchor SITES like any other: a dead
// segno re-anchors to the surviving event anchor (graph + ledger),
// and a repeat whose ONLY event anchor is its segno cascades when
// that event dies. (No other test exercised event-anchored jump
// targets through the delete path.)
use epiphany_core::generators::valid_score;
let mut base = valid_score(0x5EED);
let voice_events = base
.voices()
.map(|(_, _, v)| v.events.clone())
.next()
.expect("the fixture has a voice");
let (e0, e1) = (voice_events[0], voice_events[1]);
let region = base.canvas.regions[0].id;
let region_edge = |edge| TimeAnchor::Region {
id: region,
edge,
offset: AnchorOffset::Zero,
};
// Repeat A: start/end/segno all event-anchored; e0 dies -> every dead
// site (including the segno) moves to e1.
let rid_a = epiphany_core::RepeatStructureId::new(ReplicaId(9), 810);
base.cross_cutting
.repeats
.push(epiphany_core::RepeatStructure {
id: rid_a,
start: crate::valuegen::event_anchor(e0),
end: crate::valuegen::event_anchor(e1),
kind: epiphany_core::RepeatKind::DalSegno {
segno: crate::valuegen::event_anchor(e0),
end_target: crate::valuegen::event_anchor(e1),
},
voltas: Vec::new(),
});
// Repeat B: the ONLY event anchor is the segno; e0 dies -> cascade.
let rid_b = epiphany_core::RepeatStructureId::new(ReplicaId(9), 811);
base.cross_cutting
.repeats
.push(epiphany_core::RepeatStructure {
id: rid_b,
start: region_edge(epiphany_core::RegionEdge::Start),
end: region_edge(epiphany_core::RegionEdge::End),
kind: epiphany_core::RepeatKind::DalSegno {
segno: crate::valuegen::event_anchor(e0),
end_target: region_edge(epiphany_core::RegionEdge::End),
},
voltas: Vec::new(),
});
let del = prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::DeleteEvent(DeleteEventOp {
event: e0,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![del]);
let result = set.reduce_onto(&base);
let repeat_a = result
.score
.cross_cutting
.repeats
.iter()
.find(|r| r.id == rid_a)
.expect("repeat A survives on its e1 anchors");
let segno_moved = matches!(
&repeat_a.kind,
epiphany_core::RepeatKind::DalSegno { segno: TimeAnchor::Event { id, .. }, .. }
if *id == e1
);
assert!(
segno_moved,
"the dead segno jump target re-anchored to the survivor: {:?}",
repeat_a.kind
);
assert!(
matches!(repeat_a.start, TimeAnchor::Event { id, .. } if id == e1),
"start moved with it"
);
assert!(
!result
.score
.cross_cutting
.repeats
.iter()
.any(|r| r.id == rid_b),
"a repeat whose only event anchor was its segno cascades"
);
assert!(
matches!(
result
.state
.objects
.get(&TypedObjectId::RepeatStructure(rid_b)),
Some(ObjectState::Tombstoned { .. })
),
"the ledger agrees on the cascade"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
}
#[test]
fn deleting_the_only_anchor_event_cascades_the_repeat() {
// No surviving event anchor: cascade-delete, in the ledger (tombstone
// + CascadeDeleted repair) and the graph together.
use epiphany_core::generators::valid_score;
let mut base = valid_score(0x5EED);
let voice_events = base
.voices()
.map(|(_, _, v)| v.events.clone())
.next()
.expect("the fixture has a voice");
let e0 = voice_events[0];
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(9), 801);
base.cross_cutting
.repeats
.push(epiphany_core::RepeatStructure {
id: rid,
start: crate::valuegen::event_anchor(e0),
end: crate::valuegen::event_anchor(e0),
kind: epiphany_core::RepeatKind::SimpleRepeat { count: 2 },
voltas: Vec::new(),
});
let del = prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::DeleteEvent(DeleteEventOp {
event: e0,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![del.clone()]);
let result = set.reduce_onto(&base);
assert!(
matches!(
result
.state
.objects
.get(&TypedObjectId::RepeatStructure(rid)),
Some(ObjectState::Tombstoned { .. })
),
"no surviving anchor: the ledger cascade-tombstones the repeat"
);
let effect = result
.state
.effects
.iter()
.find(|(id, _)| *id == del.id)
.map(|(_, e)| e)
.expect("delete effect recorded");
let OperationEffect::AppliedWithRepair { repairs } = effect else {
panic!("expected AppliedWithRepair, got {effect:?}");
};
assert!(
repairs.iter().any(|r| {
r.target == TypedObjectId::RepeatStructure(rid)
&& r.kind == RepairKind::CascadeDeleted
}),
"the cascade is a recorded repair: {repairs:?}"
);
assert!(
!result
.score
.cross_cutting
.repeats
.iter()
.any(|r| r.id == rid),
"the graph agrees: the repeat is gone"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
}
#[test]
fn undoing_a_repeat_or_spanner_create_removes_it_from_the_graph() {
// Undo tombstones the minted structure AND materializes the graph-side
// removal (materialize_graph_tombstones). The spanner leg regression-
// locks the pre-existing gap fixed alongside Phase D: an undone
// spanner mint used to leave a ghost value in the graph.
use epiphany_core::generators::valid_score;
let base = valid_score(0x5EED);
let voice_events = base
.voices()
.map(|(_, _, v)| v.events.clone())
.next()
.expect("the fixture has a voice");
let (e0, e1) = (voice_events[0], voice_events[1]);
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(9), 802);
let spanner_id = epiphany_core::SpannerId::new(ReplicaId(9), 803);
let tx = TransactionId::new(ReplicaId(1), 900);
let mut set = OperationSet::new();
set.accept_all(vec![
declare_transaction(1, 0, 10, CausalContext::new(), tx),
tx_member(1, 1, 11, seen_r1(0), tx, create_repeat(rid, e0, e1)),
tx_member(
1,
2,
12,
seen_r1(1),
tx,
OperationKind::CreateCrossCutting(crate::payload::CreateCrossCuttingOp {
structure: CrossCuttingValue::Spanner(epiphany_core::Spanner {
id: spanner_id,
start: crate::valuegen::event_anchor(e0),
end: crate::valuegen::event_anchor(e1),
staves: Vec::new(),
kind: Default::default(),
style: Default::default(),
}),
}),
),
undo_env(1, 3, 13, seen_r1(2), tx, UndoPolicy::StrictInverse),
]);
let result = set.reduce_onto(&base);
assert!(
matches!(
result
.state
.objects
.get(&TypedObjectId::RepeatStructure(rid)),
Some(ObjectState::Tombstoned { .. })
) && matches!(
result
.state
.objects
.get(&TypedObjectId::Spanner(spanner_id)),
Some(ObjectState::Tombstoned { .. })
),
"undo tombstones both mints"
);
assert!(
!result
.score
.cross_cutting
.repeats
.iter()
.any(|r| r.id == rid),
"the undone repeat mint leaves the graph"
);
assert!(
!result
.score
.cross_cutting
.spanners
.iter()
.any(|sp| sp.id == spanner_id),
"the undone spanner mint leaves the graph (the ghost-value fix)"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
}
// --- Push-1 spec-compliance fixes (Transpose skip, meta-conflict record,
// marker re-anchor repair, system-derived counter collisions). -----------
/// An InsertEvent envelope whose event carries exactly one identified
/// pitch with the given id and intrinsic content.
#[allow(clippy::too_many_arguments)]
fn insert_with_pitch_content(
replica: u64,
counter: u64,
physical: i64,
voice: u64,
event: u64,
pos_units: i64,
pitch_id: PitchId,
content: &Pitch,
) -> OperationEnvelope {
let mut env = insert(replica, counter, physical, voice, event, pos_units);
if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = env.payload {
op.event = crate::valuegen::insert_event_value(
op.event_id(),
op.voice(),
pos(pos_units),
epiphany_core::MusicalDuration::whole(),
&[pitch_id],
);
if let Event::Pitched(pe) = &mut op.event {
pe.pitches[0].pitch = content.clone();
}
}
env
}
#[test]
fn transpose_skips_tombstoned_targets_and_shifts_the_live_ones() {
// Operation Catalog §Transpose (re-anchoring): "Tombstoned targets are
// skipped (the transpose applies only to live pitches)."
let p1 = PitchId::new(ReplicaId(9), 501);
let p2 = PitchId::new(ReplicaId(9), 502);
let neutral = crate::valuegen::pitch_value();
let a = insert_with_pitch_content(1, 0, 10, 1, 100, 0, p1, &neutral);
let b = insert_with_pitch_content(1, 1, 11, 2, 101, 0, p2, &neutral);
let del = prim_env(
1,
2,
20,
CausalContext::new().with_seen(ReplicaId(1), 1),
OperationKind::DeleteEvent(DeleteEventOp {
event: EventId::new(ReplicaId(1), 100),
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
let after_delete = CausalContext::new().with_seen(ReplicaId(1), 2);
// Mixed live/tombstoned targets: skips p1, shifts p2, applies.
let t_mixed = prim_env(
3,
0,
30,
after_delete.clone(),
OperationKind::Transpose(TransposeOp {
targets: vec![p1, p2],
chromatic_steps: 2,
}),
);
// All targets tombstoned: degenerates to no effect.
let t_dead = prim_env(
4,
0,
31,
after_delete.clone(),
OperationKind::Transpose(TransposeOp {
targets: vec![p1],
chromatic_steps: 2,
}),
);
// A target that never existed: dangling reference, whole op refuses.
let t_missing = prim_env(
5,
0,
32,
after_delete,
OperationKind::Transpose(TransposeOp {
targets: vec![p2, PitchId::new(ReplicaId(9), 999)],
chromatic_steps: 2,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![
a,
b,
del,
t_mixed.clone(),
t_dead.clone(),
t_missing.clone(),
]);
let state = set.reduce();
let effect = |id: OperationId| {
state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff)
.expect("effect recorded")
};
assert_eq!(effect(t_mixed.id), &OperationEffect::Applied);
assert_eq!(
effect(t_dead.id),
&OperationEffect::NoOp {
reason: NoOpReason::TargetTombstoned,
}
);
assert_eq!(
effect(t_missing.id),
&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}
);
}
#[test]
fn schema_majors_follow_the_minimal_stamping_rule() {
// Binary Format §Schema Major 2, "Minimal stamping": the stamp is the
// lowest major whose layouts decode the payload's bytes — a pure
// function of the value. Mandatory-append kinds are always 2; the
// Option-hidden embeddings are value-dependent; everything else keeps
// its prior major.
use crate::payload::{
CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CrossCuttingValue,
SetStaffLayoutOp,
};
use epiphany_core::{RegionId, SlurId, StaffInstanceId};
let slur = crate::valuegen::slur(
SlurId::new(ReplicaId(9), 1),
EventId::new(ReplicaId(9), 1),
EventId::new(ReplicaId(9), 2),
);
assert_eq!(
OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(slur),
})
.schema_major(),
2,
"mandatory v2 appends stamp 2"
);
let region = crate::valuegen::region(RegionId::new(ReplicaId(9), 3));
assert_eq!(
OperationKind::CreateRegion(CreateRegionOp {
region: region.clone()
})
.schema_major(),
1,
"an empty-instance CreateRegion keeps its v1 stamp"
);
let iid = StaffInstanceId::new(ReplicaId(9), 4);
let mut instance = crate::valuegen::staff_instance(iid, StaffId::new(ReplicaId(9), 5));
assert_eq!(
OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
region: region.id,
instance: instance.clone(),
})
.schema_major(),
0,
"a None-override instance encodes byte-identically at v0"
);
instance.staff_lines_override = Some(epiphany_core::StaffLineConfiguration::default());
assert_eq!(
OperationKind::CreateStaffInstance(CreateStaffInstanceOp {
region: region.id,
instance,
})
.schema_major(),
2,
"a Some-override instance bears the v2 StaffLineConfiguration"
);
assert_eq!(
OperationKind::SetStaffLayout(SetStaffLayoutOp {
staff_instance: iid,
instrument_override: None,
staff_lines_override: None,
visible: true,
})
.schema_major(),
0
);
assert_eq!(
OperationKind::SetStaffLayout(SetStaffLayoutOp {
staff_instance: iid,
instrument_override: None,
staff_lines_override: Some(epiphany_core::StaffLineConfiguration::default()),
visible: true,
})
.schema_major(),
2,
"a Some-override layout bears the v2 StaffLineConfiguration"
);
// The repeat pair (Phase D): the create is born at v2 — its carried
// RepeatStructure's kind/voltas are unconditional fields, so even the
// migration-default value has no lower-major layout. The delete's
// bare-id payload is a major-0 layout under a minor kind append.
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(9), 6);
assert_eq!(
OperationKind::CreateRepeatStructure(CreateRepeatStructureOp {
repeat: crate::valuegen::repeat_structure(
rid,
EventId::new(ReplicaId(9), 1),
EventId::new(ReplicaId(9), 2),
),
})
.schema_major(),
2,
"CreateRepeatStructure is born at v2 — even for default kind/voltas"
);
assert_eq!(
OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { repeat: rid })
.schema_major(),
0,
"DeleteRepeatStructure carries a bare id — a major-0 layout"
);
}
#[test]
fn the_canonical_base_embeds_no_repeat_values() {
// The surgical form of the cross-major byte-identity promise for the
// repeat vocabulary: two reductions identical except for the created
// repeat's v2 content (kind payload) must produce byte-identical
// canonical bases — the base records the mint as a TypedObjectId
// (discriminant 23) plus an Applied effect, never the filled value.
let e1 = EventId::new(ReplicaId(1), 100);
let e2 = EventId::new(ReplicaId(1), 101);
let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1);
let base_bytes = |count: u32| {
let mut repeat = crate::valuegen::repeat_structure(rid, e1, e2);
repeat.kind = epiphany_core::RepeatKind::SimpleRepeat { count };
let mut set = OperationSet::new();
set.accept_all(vec![
insert(1, 0, 10, 1, 100, 0),
insert(1, 1, 11, 1, 101, 1),
prim_env(
1,
2,
12,
seen_r1(1),
OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { repeat }),
),
]);
let state = set.reduce();
assert!(
matches!(
state.objects.get(&TypedObjectId::RepeatStructure(rid)),
Some(ObjectState::Live)
),
"the create must APPLY for this test to mean anything"
);
state.canonical_bytes()
};
assert_eq!(
base_bytes(2),
base_bytes(9),
"differing repeat v2 content must not reach the canonical base"
);
}
#[test]
fn the_canonical_base_is_byte_identical_across_data_model_majors() {
// Binary Format §Schema Major 1 / §Schema Major 2: the canonical-base
// MaterializedState embeds none of the data-model-major values, so its
// bytes MUST NOT move across those bumps. This golden-locks a seeded
// reduction's canonical bytes; if it fails after a data-model change,
// a filled type has leaked into the canonical base — which the majors
// promise not to do. (A deliberate change to the base's own vocabulary
// — an appended discriminant the seeded corpus emits — re-pins this
// consciously. Re-pinned at Phase D: `gen_payload` gained the repeat
// pair, discriminants 28/29, which shifted the seeded RNG stream and
// with it the whole corpus. NOTE the seeded corpus's repeat creates
// all no-op (their random anchors miss the live events), so THIS pin
// cannot detect a repeat-value leak into the base — the dedicated
// `the_canonical_base_embeds_no_repeat_values` test below covers
// that with an APPLIED create.)
let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0xBA5E);
let envelopes = crate::fuzz::gen_envelope_set(&mut rng, 200);
let mut set = OperationSet::new();
set.accept_all(envelopes);
let bytes = set.reduce().canonical_bytes();
let digest = epiphany_determinism::blake3_256(&bytes);
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
assert_eq!(
hex,
"6e47a3113cfc54116af2e4a1b66fae16b9d1b63436fd26d9e6fc332bf501f5ed"
);
}
#[test]
fn transpose_skips_system_derived_targets_p12_k3() {
// Review finding on P12-K3: Transpose must not rewrite a
// SYSTEM_DERIVED pitch's intrinsic content in place — that would
// desynchronize the content from the id's derivation inputs (and from
// the `system_mints` registry the modify preconditions consult),
// making the K3 verdict depend on where a snapshot was cut. System
// targets are skipped like tombstoned ones; all-system degenerates to
// the K3 precondition no-op.
use epiphany_core::derive_system_pitch_id;
let content = crate::valuegen::pitch_value_nth(1);
let system_id = derive_system_pitch_id(&content);
let normal = PitchId::new(ReplicaId(9), 501);
let a = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content);
let b = insert_with_pitch_content(1, 1, 11, 2, 101, 0, normal, &content);
let after_inserts = CausalContext::new().with_seen(ReplicaId(1), 1);
// Mixed normal/system targets: the system pitch is skipped, the
// normal one shifts, the op applies.
let t_mixed = prim_env(
3,
0,
30,
after_inserts.clone(),
OperationKind::Transpose(TransposeOp {
targets: vec![system_id, normal],
chromatic_steps: 2,
}),
);
// All targets system-derived: the K3 precondition no-op.
let t_system = prim_env(
4,
0,
31,
after_inserts,
OperationKind::Transpose(TransposeOp {
targets: vec![system_id],
chromatic_steps: 2,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![a, b, t_mixed.clone(), t_system.clone()]);
let state = set.reduce();
let effect = |id: OperationId| {
state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff)
.expect("effect recorded")
};
assert_eq!(effect(t_mixed.id), &OperationEffect::Applied);
assert_eq!(
effect(t_system.id),
&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
},
},
"an all-system-derived transpose refuses under P12-K3"
);
}
#[test]
fn differing_concurrent_resolves_name_both_resolvers_in_the_meta_conflict() {
// Chapter 6 §Conflict Resolution Operations: a later differing resolve
// reduces to Conflicted with a meta-conflict record; the record names
// the earlier resolver (whose action stands) as winner and both
// resolvers as causes.
use crate::conflict::ResolutionAction;
use crate::payload::{ResolveConflictPayload, RespellPitchOp};
let pitch = PitchId::new(ReplicaId(9), 500);
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],
);
}
let respell = |replica: u64, physical: i64, byte: u8| {
prim_env(
replica,
0,
physical,
CausalContext::new().with_seen(ReplicaId(1), 0),
OperationKind::RespellPitch(RespellPitchOp {
pitch,
spelling: crate::valuegen::spelling(byte),
}),
)
};
let respell_a = respell(2, 20, 0xAA);
let respell_b = respell(3, 21, 0xBB);
let mut set = OperationSet::new();
set.accept_all(vec![
insert_env.clone(),
respell_a.clone(),
respell_b.clone(),
]);
let cid = set.reduce().conflicts.records()[0].id;
let resolve = |replica: u64, physical: i64, action: ResolutionAction| {
let id = OperationId::new(ReplicaId(replica), 0);
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: CausalContext::new()
.with_dot(respell_a.id)
.with_dot(respell_b.id),
transaction: None,
payload: OperationPayload::ResolveConflict(ResolveConflictPayload {
target: cid,
action,
}),
}
};
let first = resolve(4, 30, ResolutionAction::KeepWinner);
let second = resolve(5, 31, ResolutionAction::AcceptLoser);
let mut set2 = OperationSet::new();
set2.accept_all(vec![
insert_env,
respell_a,
respell_b,
first.clone(),
second.clone(),
]);
let state = set2.reduce();
let meta = state
.conflicts
.records()
.iter()
.find(|r| r.id != cid)
.expect("a meta-conflict record exists");
assert_eq!(
meta.kind,
ConflictKind::StructuralFieldCollision {
winner: first.id,
loser: second.id,
field: FieldPath("conflict_resolution".to_string()),
},
"the earlier resolver's action stands, so it is the winner"
);
assert_eq!(
meta.caused_by,
vec![first.id, second.id],
"both resolvers are named as causes"
);
}
#[test]
fn deleting_an_event_records_the_marker_reanchor_repair() {
// Chapter 6 §Re-Anchoring: "Re-anchoring actions MUST be recorded as
// RepairRecord entries in the triggering operation's effect", and the
// rule table's marker row: re-anchor to the *nearest event in the same
// staff instance* (four-key ordering). The fixture voice's events sit
// at ascending quarter positions, so deleting the first re-anchors the
// marker to the second (same voice: proximity rank 0 dominates any
// closer event in a sibling voice).
use epiphany_core::generators::valid_score;
let mut base = valid_score(0x5EED);
let voice_events = base
.voices()
.map(|(_, _, v)| v.events.clone())
.next()
.expect("the fixture has a voice");
let event_id = voice_events[0];
let expected = voice_events[1];
let marker_id = epiphany_core::MarkerId::new(ReplicaId(9), 700);
base.cross_cutting.markers.push(epiphany_core::Marker {
id: marker_id,
anchor: TimeAnchor::Event {
id: event_id,
offset: AnchorOffset::Zero,
},
});
let del = prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::DeleteEvent(DeleteEventOp {
event: event_id,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![del.clone()]);
let result = set.reduce_onto(&base);
let effect = result
.state
.effects
.iter()
.find(|(id, _)| *id == del.id)
.map(|(_, e)| e)
.expect("delete effect recorded");
let OperationEffect::AppliedWithRepair { repairs } = effect else {
panic!("expected AppliedWithRepair, got {effect:?}");
};
assert!(
repairs.iter().any(|r| {
r.target == TypedObjectId::Marker(marker_id)
&& r.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(event_id),
to: TypedObjectId::Event(expected),
reason: ReanchorReason::SameVoiceNearer,
}
}),
"the marker re-anchor to the nearest same-voice event is a recorded \
repair, not a silent mutation: {repairs:?}"
);
let marker = result
.score
.cross_cutting
.markers
.iter()
.find(|m| m.id == marker_id)
.expect("marker survives");
assert!(
matches!(marker.anchor, TimeAnchor::Event { id, .. } if id == expected),
"the graph agrees with the recorded repair"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
}
#[test]
fn marker_reanchor_prefers_the_forward_survivor_on_distance_ties() {
// Four-key ordering, key 3: forward (0) before backward (1). Three
// whole-note events at positions 10/12/14 in the base's first voice;
// deleting the middle one leaves survivors at equal distance 2 on both
// sides, so the *forward* neighbor (position 14) wins.
use epiphany_core::generators::valid_score;
let mut base = valid_score(0x5EED);
let (staff_instance, voice) = {
let instance = &base.canvas.regions[0].staff_instances()[0];
(instance.id, instance.voices[0].id)
};
let backward = EventId::new(ReplicaId(3), 0);
let referent = EventId::new(ReplicaId(3), 1);
let forward = EventId::new(ReplicaId(3), 2);
let insert_at = |counter: u64, event: EventId, position: i64| {
let ctx = if counter == 0 {
CausalContext::new()
} else {
CausalContext::new().with_seen(ReplicaId(3), counter - 1)
};
prim_env(
3,
counter,
10 + counter as i64,
ctx,
OperationKind::InsertEvent(InsertEventOp {
staff_instance,
event: crate::valuegen::insert_event_value(
event,
voice,
pos(position),
epiphany_core::MusicalDuration::whole(),
&[],
),
}),
)
};
let marker_id = epiphany_core::MarkerId::new(ReplicaId(9), 701);
base.cross_cutting.markers.push(epiphany_core::Marker {
id: marker_id,
anchor: TimeAnchor::Event {
id: referent,
offset: AnchorOffset::Zero,
},
});
let del = prim_env(
3,
3,
20,
CausalContext::new().with_seen(ReplicaId(3), 2),
OperationKind::DeleteEvent(DeleteEventOp {
event: referent,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![
insert_at(0, backward, 10),
insert_at(1, referent, 12),
insert_at(2, forward, 14),
del.clone(),
]);
let result = set.reduce_onto(&base);
let effect = result
.state
.effects
.iter()
.find(|(id, _)| *id == del.id)
.map(|(_, e)| e)
.expect("delete effect recorded");
let OperationEffect::AppliedWithRepair { repairs } = effect else {
panic!("expected AppliedWithRepair, got {effect:?}");
};
assert!(
repairs.iter().any(|r| {
r.target == TypedObjectId::Marker(marker_id)
&& r.kind
== RepairKind::Reanchored {
from: TypedObjectId::Event(referent),
to: TypedObjectId::Event(forward),
reason: ReanchorReason::SameVoiceNearer,
}
}),
"equal distances tie-break forward before backward: {repairs:?}"
);
assert!(
matches!(
result
.score
.cross_cutting
.markers
.iter()
.find(|m| m.id == marker_id)
.expect("marker survives")
.anchor,
TimeAnchor::Event { id, .. } if id == forward
),
"the graph agrees with the recorded repair"
);
assert!(epiphany_core::check_invariants(&result.score).is_empty());
}
#[test]
fn system_pitch_counter_collision_halts_reduction() {
// Chapter 5 §"System-Derived Counter Collisions": two different
// canonical input sets claiming one system-derived counter record a
// SystemIdentifierCollision, reduction does not continue past the
// collision, and neither input set occupies the collided counter.
use epiphany_core::derive_system_pitch_id;
let content_x = crate::valuegen::pitch_value_nth(1);
let content_y = crate::valuegen::pitch_value_nth(2);
let system_id = derive_system_pitch_id(&content_x);
assert_eq!(system_id.replica(), ReplicaId::SYSTEM_DERIVED);
let before = insert(3, 0, 5, 4, 400, 0);
let legit = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content_x);
let claim = insert_with_pitch_content(2, 0, 20, 2, 200, 5, system_id, &content_y);
let after = insert(1, 1, 30, 3, 300, 9);
let mut set = OperationSet::new();
set.accept_all(vec![
before.clone(),
legit.clone(),
claim.clone(),
after.clone(),
]);
let state = set.reduce();
assert_eq!(state.anomalies.len(), 1, "exactly one collision recorded");
match &state.anomalies[0].kind {
IntegrityAnomalyKind::SystemIdentifierCollision {
kind,
colliding_counter,
input_set_a,
input_set_b,
} => {
assert_eq!(*kind, ObjectKind::Pitch);
assert_eq!(*colliding_counter, system_id.counter());
let mut sets = [input_set_a.0.clone(), input_set_b.0.clone()];
sets.sort();
let mut expected = [
canonical_pitch_bytes(&content_x),
canonical_pitch_bytes(&content_y),
];
expected.sort();
assert_eq!(sets, expected, "the anomaly retains both input sets");
}
other => panic!("expected SystemIdentifierCollision, got {other:?}"),
}
// Only the operation before the collision point reduced.
assert_eq!(state.effects.len(), 1);
assert_eq!(state.effects[0].0, before.id);
// Neither input set occupies the collided counter.
assert!(!state.objects.contains_key(&TypedObjectId::Pitch(system_id)));
assert!(!state
.objects
.contains_key(&TypedObjectId::Event(EventId::new(ReplicaId(1), 100))));
// The colliding pair and everything past the halt are held pending.
let pending: BTreeMap<_, _> = state.pending.iter().copied().collect();
let halted = PendingReason::HaltedBySystemCollision { at: claim.id };
assert_eq!(pending.get(&legit.id), Some(&halted));
assert_eq!(pending.get(&claim.id), Some(&halted));
assert_eq!(pending.get(&after.id), Some(&halted));
// Determinism: any permutation reduces to identical bytes.
let mut reversed = OperationSet::new();
reversed.accept_all(vec![after, claim, legit, before]);
assert_eq!(state.canonical_bytes(), reversed.reduce().canonical_bytes());
}
#[test]
fn reobserving_the_same_system_derivation_is_not_a_collision() {
// The same (counter, inputs) pair re-observed is idempotent, not a
// collision; the duplicate insert is refused by pitch-id freshness
// (base-free parity with the graph-aware precondition).
use epiphany_core::derive_system_pitch_id;
let content_x = crate::valuegen::pitch_value_nth(1);
let system_id = derive_system_pitch_id(&content_x);
let a = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content_x);
let b = insert_with_pitch_content(2, 0, 20, 2, 200, 5, system_id, &content_x);
let mut set = OperationSet::new();
set.accept_all(vec![a.clone(), b.clone()]);
let state = set.reduce();
assert!(state.anomalies.is_empty(), "same derivation: no collision");
assert!(state.pending.is_empty(), "nothing is held");
let effect = |id: OperationId| {
state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff)
.expect("effect recorded")
};
assert_eq!(effect(a.id), &OperationEffect::Applied);
assert_eq!(
effect(b.id),
&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetTombstoned,
},
},
"a reused pitch id is not fresh, in base-free reduction too"
);
}
#[test]
fn base_seeded_system_pitch_collides_with_an_op_claim() {
// The registry seeds from the base graph: an operation claiming a
// base-occupied system counter with different content collides. The
// base occupant is graph state (not an operation of this reduction),
// so it is left in place for diagnostic recovery.
use epiphany_core::generators::valid_score;
use epiphany_core::{derive_system_pitch_id, IdentifiedPitch};
let content_x = crate::valuegen::pitch_value_nth(1);
let content_y = crate::valuegen::pitch_value_nth(2);
let system_id = derive_system_pitch_id(&content_x);
let mut base = valid_score(0x5EED);
let pitched_id = base
.voices()
.flat_map(|(_, _, v)| v.events.clone())
.find(|e| matches!(base.events.get(*e), Some(Event::Pitched(_))))
.expect("the fixture has a pitched event");
if let Some(Event::Pitched(pe)) = base.events.get_mut(pitched_id) {
pe.pitches[0] = IdentifiedPitch {
id: system_id,
pitch: content_x.clone(),
};
}
let claim = insert_with_pitch_content(2, 0, 10, 2, 200, 5, system_id, &content_y);
let mut set = OperationSet::new();
set.accept_all(vec![claim.clone()]);
let result = set.reduce_onto(&base);
assert_eq!(result.state.anomalies.len(), 1);
assert!(matches!(
&result.state.anomalies[0].kind,
IntegrityAnomalyKind::SystemIdentifierCollision {
kind: ObjectKind::Pitch,
..
}
));
assert!(result.state.effects.is_empty(), "reduction halted");
let pending: BTreeMap<_, _> = result.state.pending.iter().copied().collect();
assert_eq!(
pending.get(&claim.id),
Some(&PendingReason::HaltedBySystemCollision { at: claim.id })
);
assert!(
result.score.events.get(pitched_id).is_some(),
"the base occupant stays; recovery is external"
);
}
#[test]
fn a_system_derived_pitch_content_rewrite_is_refused_p12_k3() {
// Pass 12 (P12-K3): a SYSTEM_DERIVED pitch's intrinsic content is
// immutable — an in-place rewrite would invalidate the id's content
// derivation. A modify carrying the *registered* derivation content
// still applies (nothing is rewritten).
use epiphany_core::derive_system_pitch_id;
let content = crate::valuegen::pitch_value_nth(1);
let rewritten = crate::valuegen::pitch_value_nth(2);
let system_id = derive_system_pitch_id(&content);
let mint = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content);
let rewrite = prim_env(
1,
1,
20,
seen_r1(0),
OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
pitch: system_id,
value: rewritten.clone(),
}),
);
let same = prim_env(
1,
2,
30,
seen_r1(1),
OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
pitch: system_id,
value: content.clone(),
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![mint.clone(), rewrite.clone(), same.clone()]);
let state = set.reduce();
let effect_of = |id: OperationId| {
state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff)
};
assert_eq!(
effect_of(rewrite.id),
Some(&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
},
}),
"rewriting a system-derived pitch's intrinsic content is refused"
);
assert_eq!(
effect_of(same.id),
Some(&OperationEffect::Applied),
"a modify carrying the registered derivation content passes"
);
// Determinism: any permutation reduces to identical bytes.
let mut reversed = OperationSet::new();
reversed.accept_all(vec![same, rewrite, mint]);
assert_eq!(state.canonical_bytes(), reversed.reduce().canonical_bytes());
}
#[test]
fn a_modify_event_rewriting_a_system_pitch_is_refused_p12_k3() {
// The same identity precondition through the ModifyEvent path: the
// replacement event carries the system pitch with rewritten content.
use epiphany_core::derive_system_pitch_id;
let content = crate::valuegen::pitch_value_nth(1);
let rewritten = crate::valuegen::pitch_value_nth(2);
let system_id = derive_system_pitch_id(&content);
let mint = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content);
let mut replacement = crate::valuegen::insert_event_value(
EventId::new(ReplicaId(1), 100),
VoiceId::new(ReplicaId(9), 1),
pos(0),
epiphany_core::MusicalDuration::whole(),
&[system_id],
);
if let Event::Pitched(pe) = &mut replacement {
pe.pitches[0].pitch = rewritten;
}
let modify = prim_env(
1,
1,
20,
seen_r1(0),
OperationKind::ModifyEvent(ModifyEventOp { event: replacement }),
);
let mut set = OperationSet::new();
set.accept_all(vec![mint, modify.clone()]);
let state = set.reduce();
assert_eq!(
state
.effects
.iter()
.find(|(e, _)| *e == modify.id)
.map(|(_, eff)| eff),
Some(&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
},
}),
"a ModifyEvent rewriting a carried system pitch is refused"
);
}
#[test]
fn a_modify_event_introducing_a_never_minted_system_pitch_is_refused_p13_k1() {
// P13-K1 (ratified: reject the introduction). The INTRODUCTION sibling
// of the p12_k3 rewrite refusal: a ModifyEvent whose replacement
// carries a *never-minted* system-derived pitch id is refused
// (`TargetMissing`) — the system namespace is the system's to mint.
// Before the fix this slipped through in-session (`system_mints` had no
// entry, so the identity check saw nothing) yet read
// `SystemDerivedContentImmutable` after a snapshot re-seeded the pitch
// as a system mint — the snapshot-cut asymmetry the candidate named.
// The verdict no longer depends on the registry: the pitch is not live
// in `objects` in either frame, so both refuse identically.
use epiphany_core::derive_system_pitch_id;
let content = crate::valuegen::pitch_value_nth(3);
let system_id = derive_system_pitch_id(&content); // never minted
// A live, pitchless event modified to CARRY the never-minted system
// pitch (an introduction, not an in-place rewrite).
let insert = insert(1, 0, 10, 1, 100, 0);
let replacement = crate::valuegen::insert_event_value(
EventId::new(ReplicaId(1), 100),
VoiceId::new(ReplicaId(9), 1),
pos(0),
epiphany_core::MusicalDuration::whole(),
&[system_id],
);
let modify = prim_env(
1,
1,
20,
seen_r1(0),
OperationKind::ModifyEvent(ModifyEventOp { event: replacement }),
);
let mut set = OperationSet::new();
set.accept_all(vec![insert, modify.clone()]);
let state = set.reduce();
assert_eq!(
state
.effects
.iter()
.find(|(e, _)| *e == modify.id)
.map(|(_, eff)| eff),
Some(&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
}),
"a ModifyEvent introducing a never-minted system pitch is refused"
);
assert!(
!state.objects.contains_key(&TypedObjectId::Pitch(system_id)),
"the introduced system pitch never enters the ledger"
);
}
#[test]
fn undo_orphaning_a_pre_existing_slur_cascades_it_in_the_ledger_p13_d1() {
// P13-D1: a PRE-EXISTING structure whose every anchor event is
// undo-tombstoned must cascade in the LEDGER (`objects` → Tombstoned)
// with a same-step RepairRecord — not merely leave the graph while
// staying Live with no record (Ch6 §Re-Anchoring MUST). The slur is
// created OUTSIDE the undone transaction, so the undo ORPHANS it rather
// than tombstoning it directly. Before the fix the undo path ran only
// the graph half of the re-anchor, leaving `objects[slur]` Live and no
// repair recorded.
use epiphany_core::SlurId;
let tx = TransactionId::new(ReplicaId(1), 900);
let e1 = EventId::new(ReplicaId(1), 100);
let e2 = EventId::new(ReplicaId(1), 101);
let slur_id = SlurId::new(ReplicaId(9), 500);
let sid = TypedObjectId::Slur(slur_id);
let insert_kind = |event: EventId, at: i64| {
OperationKind::InsertEvent(InsertEventOp {
staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
event: crate::valuegen::insert_event_value(
event,
VoiceId::new(ReplicaId(9), 1),
pos(at),
epiphany_core::MusicalDuration::whole(),
&[],
),
})
};
let slur_op = OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(crate::valuegen::slur(slur_id, e1, e2)),
});
let undo = undo_env(1, 4, 40, seen_r1(3), tx, UndoPolicy::StrictInverse);
let mut set = OperationSet::new();
set.accept_all(vec![
declare_transaction(1, 0, 10, CausalContext::new(), tx),
tx_member(1, 1, 11, seen_r1(0), tx, insert_kind(e1, 0)),
tx_member(1, 2, 12, seen_r1(1), tx, insert_kind(e2, 10)),
prim_env(1, 3, 13, seen_r1(2), slur_op),
undo.clone(),
]);
let state = set.reduce();
// The orphaned slur cascades in the ledger (not left Live)…
assert!(
matches!(
state.objects.get(&sid),
Some(ObjectState::Tombstoned { .. })
),
"the orphaned slur cascades in the ledger, not left Live with no repair"
);
// …and the undo records the same-step CascadeDeleted repair (Ch6 MUST).
let undo_records_cascade = matches!(
state.effects.iter().find(|(id, _)| *id == undo.id).map(|(_, e)| e),
Some(OperationEffect::AppliedWithRepair { repairs })
if repairs.iter().any(|r| r.target == sid
&& matches!(r.kind, RepairKind::CascadeDeleted))
);
assert!(
undo_records_cascade,
"the undo records the orphaned slur's cascade repair (Ch6 same-step MUST)"
);
// Determinism: a permuted delivery reduces to identical bytes.
let mut permuted = OperationSet::new();
permuted.accept_all(vec![
undo.clone(),
prim_env(
1,
3,
13,
seen_r1(2),
OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(crate::valuegen::slur(slur_id, e1, e2)),
}),
),
tx_member(1, 2, 12, seen_r1(1), tx, insert_kind(e2, 10)),
tx_member(1, 1, 11, seen_r1(0), tx, insert_kind(e1, 0)),
declare_transaction(1, 0, 10, CausalContext::new(), tx),
]);
assert_eq!(
state.canonical_bytes(),
permuted.reduce().canonical_bytes(),
"the orphan-cascade converges regardless of delivery order"
);
}
#[test]
fn deleting_a_cue_source_does_not_leave_a_contradictory_repair_for_a_bridging_slur_p13_d2() {
// P13-D2: a slur Z anchored on {X, cue-of-X}. Deleting X cascades the
// cue (a cue depends on its source event). The cue-cascade's ledger
// re-anchor must NOT re-anchor Z onto X — X is itself being deleted —
// which would record `Reanchored{to: X}` for Z and then `CascadeDeleted`
// in the same effect (a contradictory trail). With X tombstoned before
// the graph delete, Z sees no live survivor and cascades once.
use epiphany_core::generators::valid_score;
use epiphany_core::{
check_invariants, CueEvent, CueRendering, Event, EventDuration, EventPosition,
MusicalDuration, MusicalPosition, RationalTime, SlurId,
};
let mut base = valid_score(0x5EED);
let (voice_id, x, count) = {
let v = &base.canvas.regions[0].staff_instances()[0].voices[0];
(v.id, v.events[0], v.events.len() as i64)
};
let cue = EventId::new(ReplicaId(9), 9_001);
let slur_id = SlurId::new(ReplicaId(9), 9_002);
let sid = TypedObjectId::Slur(slur_id);
// A cue sourced on X, placed just past the voice's last event, and a
// slur bridging X and its cue.
base.events
.insert(Event::Cue(CueEvent {
id: cue,
voice: voice_id,
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(count, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
source: vec![x],
rendering: CueRendering,
}))
.expect("fresh cue id");
base.canvas.regions[0]
.content
.staff_instances_mut()
.expect("staff-based content")[0]
.voices[0]
.events
.push(cue);
base.cross_cutting
.slurs
.push(crate::valuegen::slur(slur_id, x, cue));
assert!(
check_invariants(&base).is_empty(),
"the cue + bridging-slur base is well-formed"
);
let del = prim_env(
2,
0,
20,
CausalContext::new(),
OperationKind::DeleteEvent(DeleteEventOp {
event: x,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![del.clone()]);
let result = set.reduce_onto(&base);
// The bridging slur cascaded (both its anchors — X and cue-of-X — died).
assert!(
matches!(
result.state.objects.get(&sid),
Some(ObjectState::Tombstoned { .. })
),
"the bridging slur cascades"
);
// …recorded exactly ONCE as a cascade, never a contradictory
// `Reanchored{to: X}` onto the event being deleted.
let repairs = match result
.state
.effects
.iter()
.find(|(id, _)| *id == del.id)
.map(|(_, e)| e)
{
Some(OperationEffect::AppliedWithRepair { repairs }) => repairs.clone(),
other => panic!("expected AppliedWithRepair, got {other:?}"),
};
let slur_repairs: Vec<_> = repairs.iter().filter(|r| r.target == sid).collect();
assert_eq!(
slur_repairs.len(),
1,
"the bridging slur is repaired exactly once: {slur_repairs:?}"
);
assert!(
matches!(slur_repairs[0].kind, RepairKind::CascadeDeleted),
"the one record is a cascade, not a contradictory reanchor: {:?}",
slur_repairs[0].kind
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn a_rank_four_reanchor_records_same_canvas_nearer_p12_c4() {
// Pass 12 (P12-C4): the rank-4 (same-canvas) proximity survivor has
// its own appended reason; ExplicitFallback stays the beyond-ladder
// recording only.
assert_eq!(reason_for_rank(4), ReanchorReason::SameCanvasNearer);
assert_eq!(reason_for_rank(3), ReanchorReason::SameRegionNearer);
assert_eq!(reason_for_rank(5), ReanchorReason::ExplicitFallback);
}
#[test]
fn an_unestablished_rank_four_reanchor_keeps_the_explicit_fallback() {
// Review finding on P12-C4: `containment_rank` also returns 4 when a
// voice's staff-instance placement is *unresolvable* (here: base-free
// reduction, where inserted voices have no op-created instance), and
// that conflated 4 must NOT be recorded as a positive
// `SameCanvasNearer` claim — the honest recording stays
// `ExplicitFallback`. Selection order is unchanged either way.
let e1 = EventId::new(ReplicaId(1), 100);
let e2 = EventId::new(ReplicaId(1), 101);
let slur = epiphany_core::SlurId::new(ReplicaId(1), 1);
let create = prim_env(
1,
2,
12,
CausalContext::new().with_seen(ReplicaId(1), 1),
create_slur(slur, e1, e2),
);
let del = prim_env(
1,
3,
13,
CausalContext::new().with_seen(ReplicaId(1), 2),
OperationKind::DeleteEvent(DeleteEventOp {
event: e1,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
// Different voices on different staff instances, neither instance
// op-created in a region: instance→region never resolves, so
// containment_rank falls through to its unestablished 4.
let a = insert(1, 0, 10, 1, 100, 0);
let mut b = insert(1, 1, 11, 2, 101, 1);
if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = b.payload {
op.staff_instance = StaffInstanceId::new(ReplicaId(9), 1);
}
let mut set = OperationSet::new();
set.accept_all(vec![a, b, create, del.clone()]);
let state = set.reduce();
let repair = state
.effects
.iter()
.find(|(id, _)| *id == del.id)
.and_then(|(_, eff)| match eff {
OperationEffect::AppliedWithRepair { repairs } => repairs
.iter()
.find(|r| r.target == TypedObjectId::Slur(slur))
.cloned(),
_ => None,
})
.expect("the slur endpoint deletion records a re-anchor repair");
assert_eq!(
repair.kind,
RepairKind::Reanchored {
from: TypedObjectId::Event(e1),
to: TypedObjectId::Event(e2),
reason: ReanchorReason::ExplicitFallback,
},
"an unresolvable-placement rank 4 records the honest fallback"
);
}
// --- ResolveEquivocation (operation_catalog §"ResolveEquivocation"). -----
/// A `RespellPitch` envelope at `id` with an explicit causal context.
fn respell_at(
id: OperationId,
physical: i64,
spelling: u8,
ctx: CausalContext,
) -> OperationEnvelope {
use crate::payload::RespellPitchOp;
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: ctx,
transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: epiphany_core::PitchId::new(ReplicaId(9), 500),
spelling: crate::valuegen::spelling(spelling),
})),
}
}
/// A `ResolveEquivocation` envelope at `id` naming `(target, chosen)`.
fn resolve_equivocation_env(
id: OperationId,
physical: i64,
target: OperationId,
chosen: crate::EnvelopeHash,
) -> OperationEnvelope {
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::ResolveEquivocation(
crate::payload::ResolveEquivocationPayload { target, chosen },
),
}
}
fn effect_of(state: &MaterializedState, id: OperationId) -> Option<&OperationEffect> {
state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff)
}
#[test]
fn resolve_equivocation_promotes_the_chosen_candidate_and_unblocks_dependents() {
let pitch = epiphany_core::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],
);
}
// An equivocated pair of respellings under one id, both after the insert.
let eq_id = OperationId::new(ReplicaId(9), 0);
let after_insert = CausalContext::new().with_seen(ReplicaId(1), 0);
let cand_a = respell_at(eq_id, 20, 0xAA, after_insert.clone());
let cand_b = respell_at(eq_id, 20, 0xBB, after_insert.clone());
assert_ne!(cand_a.envelope_hash(), cand_b.envelope_hash());
// A dependent causally covering the equivocated id: previously held
// pending (DependsOnEquivocated); must unblock and reduce.
let dependent = respell_at(
OperationId::new(ReplicaId(2), 0),
30,
0xCC,
CausalContext::new()
.with_seen(ReplicaId(1), 0)
.with_seen(ReplicaId(9), 0),
);
// Baseline (no resolve): the slot is anomalous, the dependent pending.
let mut without = OperationSet::new();
without.accept_all(vec![
insert_env.clone(),
cand_a.clone(),
cand_b.clone(),
dependent.clone(),
]);
let state = without.reduce();
assert!(state.anomalies.iter().any(|an| matches!(
an.kind,
IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == eq_id
)));
let pending: BTreeMap<_, _> = state.pending.iter().copied().collect();
assert_eq!(
pending.get(&dependent.id),
Some(&PendingReason::DependsOnEquivocated { on: eq_id })
);
// With a resolve choosing candidate A: the slot reduces as if it had
// always been Single with A — A contributes at its own canonical
// position, the dependent unblocks, and no anomaly is recorded.
let resolve = resolve_equivocation_env(
OperationId::new(ReplicaId(3), 0),
40,
eq_id,
cand_a.envelope_hash(),
);
let all = vec![
insert_env.clone(),
cand_a.clone(),
cand_b.clone(),
dependent.clone(),
resolve.clone(),
];
let mut set = OperationSet::new();
set.accept_all(all.clone());
let state = set.reduce();
assert!(
state.is_clean(),
"no conflict, anomaly, or pending: {state:?}"
);
assert_eq!(effect_of(&state, eq_id), Some(&OperationEffect::Applied));
assert_eq!(
effect_of(&state, dependent.id),
Some(&OperationEffect::Applied)
);
assert_eq!(
effect_of(&state, resolve.id),
Some(&OperationEffect::Applied)
);
// The dependent respell is causally after the promoted candidate, so
// its spelling (0xCC) is the resolved value — an intentional overwrite.
assert_eq!(
state.spellings.get(&pitch),
Some(&crate::valuegen::spelling(0xCC))
);
// Losing candidate B remains only in the diagnostic candidate store.
assert!(set.candidate(cand_b.envelope_hash()).is_some());
// Order-independent: a reversed acceptance order reduces to the bytes.
let mut reversed = OperationSet::new();
let mut rev = all;
rev.reverse();
reversed.accept_all(rev);
assert_eq!(reversed.reduce().canonical_bytes(), state.canonical_bytes());
}
#[test]
fn later_resolves_reduce_idempotently_or_collide_on_equivocation_resolution() {
let eq_id = OperationId::new(ReplicaId(9), 0);
let cand_a = respell_at(eq_id, 10, 0xAA, CausalContext::new());
let cand_b = respell_at(eq_id, 10, 0xBB, CausalContext::new());
// Three resolves: the earliest (in canonical order) governs; a later
// one naming the same candidate is idempotent; a later one naming a
// differing candidate collides.
let first = resolve_equivocation_env(
OperationId::new(ReplicaId(2), 0),
20,
eq_id,
cand_a.envelope_hash(),
);
let same = resolve_equivocation_env(
OperationId::new(ReplicaId(3), 0),
30,
eq_id,
cand_a.envelope_hash(),
);
let differing = resolve_equivocation_env(
OperationId::new(ReplicaId(4), 0),
40,
eq_id,
cand_b.envelope_hash(),
);
let mut set = OperationSet::new();
set.accept_all(vec![
cand_a.clone(),
cand_b.clone(),
first.clone(),
same.clone(),
differing.clone(),
]);
let state = set.reduce();
assert_eq!(effect_of(&state, first.id), Some(&OperationEffect::Applied));
assert_eq!(
effect_of(&state, same.id),
Some(&OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
})
);
assert!(matches!(
effect_of(&state, differing.id),
Some(OperationEffect::Conflicted { .. })
));
assert_eq!(state.conflicts.records().len(), 1);
let record = &state.conflicts.records()[0];
assert_eq!(
record.kind,
ConflictKind::StructuralFieldCollision {
winner: first.id,
loser: differing.id,
field: FieldPath("equivocation_resolution".to_string()),
}
);
assert_eq!(record.caused_by, vec![first.id, differing.id]);
assert!(record.affected_objects.is_empty());
// The slot still promoted A; no anomaly for it.
assert!(state.anomalies.is_empty());
assert!(effect_of(&state, eq_id).is_some());
}
#[test]
fn resolve_without_a_matching_equivocation_is_a_precondition_noop() {
let precondition_noop = OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TargetMissing,
},
};
// (a) Target id entirely absent from the operation set.
let absent = resolve_equivocation_env(
OperationId::new(ReplicaId(2), 0),
20,
OperationId::new(ReplicaId(9), 7),
crate::EnvelopeHash([1; 32]),
);
let mut set = OperationSet::new();
set.accept_all(vec![absent.clone()]);
let state = set.reduce();
assert_eq!(effect_of(&state, absent.id), Some(&precondition_noop));
// (b) Target occupies an ordinary Single slot (no equivocation).
let single = respell_at(
OperationId::new(ReplicaId(9), 0),
10,
0xAA,
CausalContext::new(),
);
let not_equivocated = resolve_equivocation_env(
OperationId::new(ReplicaId(2), 0),
20,
single.id,
single.envelope_hash(),
);
let mut set = OperationSet::new();
set.accept_all(vec![single.clone(), not_equivocated.clone()]);
let state = set.reduce();
assert_eq!(
effect_of(&state, not_equivocated.id),
Some(&precondition_noop)
);
// (c) Target is equivocated but `chosen` names no candidate: the slot
// stays equivocated (anomaly recorded, dependents still pending) and
// the resolve is a precondition no-op — behavior is otherwise exactly
// the unresolved baseline.
let eq_id = OperationId::new(ReplicaId(9), 0);
let cand_a = respell_at(eq_id, 10, 0xAA, CausalContext::new());
let cand_b = respell_at(eq_id, 10, 0xBB, CausalContext::new());
let dependent = respell_at(
OperationId::new(ReplicaId(4), 0),
30,
0xCC,
CausalContext::new().with_seen(ReplicaId(9), 0),
);
let bogus = resolve_equivocation_env(
OperationId::new(ReplicaId(2), 0),
20,
eq_id,
crate::EnvelopeHash([0xEE; 32]),
);
let mut set = OperationSet::new();
set.accept_all(vec![
cand_a.clone(),
cand_b.clone(),
dependent.clone(),
bogus.clone(),
]);
let state = set.reduce();
assert_eq!(effect_of(&state, bogus.id), Some(&precondition_noop));
assert!(effect_of(&state, eq_id).is_none());
assert!(state.anomalies.iter().any(|an| matches!(
an.kind,
IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == eq_id
)));
let pending: BTreeMap<_, _> = state.pending.iter().copied().collect();
assert_eq!(
pending.get(&dependent.id),
Some(&PendingReason::DependsOnEquivocated { on: eq_id })
);
}
#[test]
fn an_equivocated_resolve_is_excluded_like_any_equivocated_slot() {
let eq_id = OperationId::new(ReplicaId(9), 0);
let cand_a = respell_at(eq_id, 10, 0xAA, CausalContext::new());
let cand_b = respell_at(eq_id, 10, 0xBB, CausalContext::new());
// Two distinct resolve envelopes under one id (differing `chosen`):
// the resolve slot itself equivocates, so it never governs.
let resolve_id = OperationId::new(ReplicaId(5), 0);
let resolve_a = resolve_equivocation_env(resolve_id, 20, eq_id, cand_a.envelope_hash());
let resolve_b = resolve_equivocation_env(resolve_id, 20, eq_id, cand_b.envelope_hash());
assert_ne!(resolve_a.envelope_hash(), resolve_b.envelope_hash());
let mut set = OperationSet::new();
set.accept_all(vec![cand_a, cand_b, resolve_a, resolve_b]);
let state = set.reduce();
// Neither slot contributes; both record equivocation anomalies.
assert!(state.effects.is_empty());
for id in [eq_id, resolve_id] {
assert!(
state.anomalies.iter().any(|an| matches!(
an.kind,
IntegrityAnomalyKind::OperationSlotEquivocated { operation_id } if operation_id == id
)),
"expected an equivocation anomaly for {id:?}"
);
}
}
// =========================================================================
// Phase-3 first tranche: CreateStaff, SetTimeSignature, SetTempoSegment,
// SetStaffLayout, and value-restoring undo (operation_catalog
// §CreateStaff, §"Meter and Tempo Overwrites", §SetStaffLayout, §undo).
// =========================================================================
fn tx_member(
replica: u64,
counter: u64,
physical: i64,
ctx: CausalContext,
tx: TransactionId,
kind: OperationKind,
) -> OperationEnvelope {
let mut env = prim_env(replica, counter, physical, ctx, kind);
env.transaction = Some(tx);
env
}
fn declare_transaction(
replica: u64,
counter: u64,
physical: i64,
ctx: CausalContext,
tx: TransactionId,
) -> OperationEnvelope {
tx_member(
replica,
counter,
physical,
ctx,
tx,
OperationKind::DeclareTransaction(crate::payload::TransactionDescriptor {
id: tx,
label: String::from("phase-3 undo scenario"),
category: None,
}),
)
}
fn undo_env(
replica: u64,
counter: u64,
physical: i64,
ctx: CausalContext,
target: TransactionId,
policy: UndoPolicy,
) -> 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::UndoTransaction(UndoTransactionPayload { target, policy }),
}
}
fn seen_r1(counter: u64) -> CausalContext {
CausalContext::new().with_seen(ReplicaId(1), counter)
}
fn respell_kind(pitch: PitchId, nth: u8) -> OperationKind {
OperationKind::RespellPitch(RespellPitchOp {
pitch,
spelling: crate::valuegen::spelling(nth),
})
}
#[test]
fn create_staff_set_union_mint_discipline() {
let staff_id = StaffId::new(ReplicaId(9), 7);
let instrument = InstrumentId::new(ReplicaId(9), 1);
let value = crate::valuegen::staff(staff_id, instrument);
let create = prim_env(
1,
0,
10,
CausalContext::new(),
OperationKind::CreateStaff(CreateStaffOp {
staff: value.clone(),
}),
);
let identical = prim_env(
2,
0,
20,
CausalContext::new(),
OperationKind::CreateStaff(CreateStaffOp {
staff: value.clone(),
}),
);
let mut differing_value = value.clone();
differing_value.name = String::from("something else");
let differing = prim_env(
3,
0,
30,
CausalContext::new(),
OperationKind::CreateStaff(CreateStaffOp {
staff: differing_value,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![differing.clone(), identical.clone(), create.clone()]);
let state = set.reduce();
let effect_of = |id: OperationId| {
state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff)
};
assert_eq!(effect_of(create.id), Some(&OperationEffect::Applied));
assert_eq!(
effect_of(identical.id),
Some(&OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
}),
"a byte-identical re-create reduces idempotently"
);
assert_eq!(
effect_of(differing.id),
Some(&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::RecreateContentMismatch,
},
}),
"a differing value under a live id is a precondition no-op (P12-K9)"
);
assert!(matches!(
state.objects.get(&TypedObjectId::Staff(staff_id)),
Some(ObjectState::Live)
));
}
fn create_region_env(
replica: u64,
counter: u64,
physical: i64,
region: RegionId,
) -> OperationEnvelope {
prim_env(
replica,
counter,
physical,
CausalContext::new(),
OperationKind::CreateRegion(CreateRegionOp {
region: crate::valuegen::region(region),
}),
)
}
fn set_meter_kind(
region: RegionId,
at: MusicalPosition,
signature: Option<TimeSignature>,
) -> OperationKind {
OperationKind::SetTimeSignature(SetTimeSignatureOp {
region,
anchor: crate::valuegen::region_start_anchor(region, at),
time_signature: signature,
})
}
#[test]
fn concurrent_differing_meter_writes_collide_on_meter_sequence() {
let region = RegionId::new(ReplicaId(9), 3);
let create = create_region_env(1, 0, 10, region);
let seen = seen_r1(0);
let ts_a = crate::valuegen::time_signature(TimeSignatureId::new(ReplicaId(9), 1), 4);
let ts_b = crate::valuegen::time_signature(TimeSignatureId::new(ReplicaId(9), 2), 3);
let set_a = prim_env(
2,
0,
20,
seen.clone(),
set_meter_kind(region, pos(0), Some(ts_a)),
);
let set_b = prim_env(3, 0, 20, seen, set_meter_kind(region, pos(0), Some(ts_b)));
let mut set = OperationSet::new();
set.accept_all(vec![set_b.clone(), set_a.clone(), create]);
let state = set.reduce();
assert_eq!(state.conflicts.records().len(), 1);
assert!(matches!(
&state.conflicts.records()[0].kind,
ConflictKind::StructuralFieldCollision { field, winner, loser }
if field.0 == "meter_sequence" && *winner == set_b.id && *loser == set_a.id
));
}
#[test]
fn identical_concurrent_meter_writes_reduce_idempotently() {
let region = RegionId::new(ReplicaId(9), 3);
let create = create_region_env(1, 0, 10, region);
let seen = seen_r1(0);
let ts = crate::valuegen::time_signature(TimeSignatureId::new(ReplicaId(9), 1), 4);
let set_a = prim_env(
2,
0,
20,
seen.clone(),
set_meter_kind(region, pos(0), Some(ts.clone())),
);
let set_b = prim_env(3, 0, 20, seen, set_meter_kind(region, pos(0), Some(ts)));
let mut set = OperationSet::new();
set.accept_all(vec![set_b.clone(), set_a.clone(), create]);
let state = set.reduce();
assert!(state.conflicts.is_empty());
let effect_of = |id: OperationId| {
state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff)
};
assert_eq!(effect_of(set_a.id), Some(&OperationEffect::Applied));
assert_eq!(
effect_of(set_b.id),
Some(&OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied,
})
);
}
#[test]
fn a_differing_recarry_of_a_live_time_signature_is_refused() {
let region = RegionId::new(ReplicaId(9), 3);
let signature_id = TimeSignatureId::new(ReplicaId(9), 1);
let create = create_region_env(1, 0, 10, region);
let set_a = prim_env(
1,
1,
20,
seen_r1(0),
set_meter_kind(
region,
pos(0),
Some(crate::valuegen::time_signature(signature_id, 4)),
),
);
// Causally later, same signature id, different value, different key.
let set_b = prim_env(
1,
2,
30,
seen_r1(1),
set_meter_kind(
region,
pos(4),
Some(crate::valuegen::time_signature(signature_id, 3)),
),
);
let mut set = OperationSet::new();
set.accept_all(vec![create, set_a, set_b.clone()]);
let state = set.reduce();
assert_eq!(
state
.effects
.iter()
.find(|(e, _)| *e == set_b.id)
.map(|(_, eff)| eff),
Some(&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::RecreateContentMismatch,
},
}),
"a differing value under a live signature id is a precondition no-op (P12-K9)"
);
}
#[test]
fn set_tempo_segment_refuses_writes_that_would_malform_the_map() {
let region = RegionId::new(ReplicaId(9), 0);
let anchor_at = |at: i64| crate::valuegen::region_start_anchor(region, pos(at));
let malformed = |counter: u64, op: SetTempoSegmentOp| {
prim_env(
1,
counter,
(counter as i64 + 1) * 10,
if counter == 0 {
CausalContext::new()
} else {
seen_r1(counter - 1)
},
OperationKind::SetTempoSegment(op),
)
};
// (0) A clean open constant segment at 0 applies (score scope).
let clean = malformed(
0,
SetTempoSegmentOp {
region: None,
start: anchor_at(0),
segment: Some(crate::valuegen::tempo_segment(region, pos(0), 120.0)),
},
);
// (1) Carried segment start disagrees with the operation's key.
let key_mismatch = malformed(
1,
SetTempoSegmentOp {
region: None,
start: anchor_at(2),
segment: Some(crate::valuegen::tempo_segment(region, pos(3), 90.0)),
},
);
// (2) A non-constant shape missing its end data.
let mut ramp = crate::valuegen::tempo_segment(region, pos(4), 60.0);
ramp.shape = epiphany_core::TempoShape::Linear;
let missing_end = malformed(
2,
SetTempoSegmentOp {
region: None,
start: anchor_at(4),
segment: Some(ramp),
},
);
// (3) An explicit end overlapping the next segment: a segment at -4
// whose end (at 2) runs past the existing segment's start at 0.
let mut overlapping = crate::valuegen::tempo_segment(region, pos(-4), 100.0);
overlapping.end = Some(anchor_at(2));
let overlap = malformed(
3,
SetTempoSegmentOp {
region: None,
start: anchor_at(-4),
segment: Some(overlapping),
},
);
let mut set = OperationSet::new();
set.accept_all(vec![
clean.clone(),
key_mismatch.clone(),
missing_end.clone(),
overlap.clone(),
]);
let state = set.reduce();
let effect_of = |id: OperationId| {
state
.effects
.iter()
.find(|(e, _)| *e == id)
.map(|(_, eff)| eff)
};
assert_eq!(effect_of(clean.id), Some(&OperationEffect::Applied));
let refused = OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::TempoMapMalformed,
},
};
assert_eq!(effect_of(key_mismatch.id), Some(&refused));
assert_eq!(effect_of(missing_end.id), Some(&refused));
assert_eq!(effect_of(overlap.id), Some(&refused));
}
/// The base scenario of the value-restoring undo unit tests: an event with
/// one pitch (r1c0), a pre-transaction respell to `spelling(1)` (r1c1), a
/// transaction T (declared r1c2) whose member respells to `spelling(2)`
/// (r1c3).
fn respell_undo_fixture(tx: TransactionId) -> (PitchId, Vec<OperationEnvelope>) {
let pitch = PitchId::new(ReplicaId(1), 50);
let e0 =
insert_with_pitch_content(1, 0, 10, 1, 100, 0, pitch, &crate::valuegen::pitch_value());
let pre = prim_env(1, 1, 20, seen_r1(0), respell_kind(pitch, 1));
let declare = declare_transaction(1, 2, 30, seen_r1(1), tx);
let member = tx_member(1, 3, 40, seen_r1(2), tx, respell_kind(pitch, 2));
(pitch, vec![e0, pre, declare, member])
}
#[test]
fn undo_restores_the_chain_predecessor_spelling() {
let tx = TransactionId::from_raw(21);
let (pitch, mut envelopes) = respell_undo_fixture(tx);
envelopes.push(undo_env(
1,
4,
50,
seen_r1(3),
tx,
UndoPolicy::StrictInverse,
));
let mut set = OperationSet::new();
set.accept_all(envelopes);
let state = set.reduce();
assert!(state.conflicts.is_empty());
assert_eq!(
state.spellings.get(&pitch),
Some(&crate::valuegen::spelling(1)),
"undo must restore the pre-transaction spelling"
);
// A pure value restoration (no mints) is a clean `Applied`.
assert_eq!(effect_at(&state, 4), Some(&OperationEffect::Applied));
}
#[test]
fn undo_removes_a_first_spelling_write() {
// No pre-transaction respell: the transaction introduced the first
// spelling, so undo restores absence.
let tx = TransactionId::from_raw(22);
let pitch = PitchId::new(ReplicaId(1), 50);
let e0 =
insert_with_pitch_content(1, 0, 10, 1, 100, 0, pitch, &crate::valuegen::pitch_value());
let declare = declare_transaction(1, 1, 20, seen_r1(0), tx);
let member = tx_member(1, 2, 30, seen_r1(1), tx, respell_kind(pitch, 2));
let undo = undo_env(1, 3, 40, seen_r1(2), tx, UndoPolicy::StrictInverse);
let mut set = OperationSet::new();
set.accept_all(vec![undo, member, declare, e0]);
let state = set.reduce();
assert!(state.conflicts.is_empty());
assert_eq!(state.spellings.get(&pitch), None);
assert_eq!(effect_at(&state, 3), Some(&OperationEffect::Applied));
}
#[test]
fn superseded_undo_conflicts_strict_and_skips_best_effort() {
let tx = TransactionId::from_raw(23);
let (pitch, mut envelopes) = respell_undo_fixture(tx);
// A causally-later respell supersedes the transaction's write.
let superseder = prim_env(1, 4, 50, seen_r1(3), respell_kind(pitch, 3));
let strict = undo_env(1, 5, 60, seen_r1(4), tx, UndoPolicy::StrictInverse);
let best_effort = undo_env(1, 6, 70, seen_r1(5), tx, UndoPolicy::BestEffort);
envelopes.extend([superseder.clone(), strict.clone(), best_effort.clone()]);
let mut set = OperationSet::new();
set.accept_all(envelopes);
let state = set.reduce();
// StrictInverse refuses the whole undo, naming the undo and the
// superseding writer.
assert!(matches!(
effect_at(&state, 5),
Some(OperationEffect::Conflicted { .. })
));
let record = state
.conflicts
.records()
.iter()
.find(|record| record.caused_by.contains(&strict.id))
.expect("the strict undo records a conflict");
assert!(matches!(
&record.kind,
ConflictKind::TransactionConflict { transaction, .. } if *transaction == tx
));
assert!(record.caused_by.contains(&superseder.id));
// BestEffort skips the superseded key: applied, nothing restored.
assert_eq!(effect_at(&state, 6), Some(&OperationEffect::Applied));
assert_eq!(
state.spellings.get(&pitch),
Some(&crate::valuegen::spelling(3)),
"the superseding write stands"
);
}
#[test]
fn undo_of_undo_restores_the_pre_undo_value() {
// PINNED (see DECISIONS.md): an undo's restoration enters the write
// chain as a new write by the undo operation. Undoing the undo's own
// enclosing transaction therefore restores the value the first undo
// removed.
let tx = TransactionId::from_raw(24);
let (pitch, mut envelopes) = respell_undo_fixture(tx);
let undo_tx = TransactionId::from_raw(25);
let declare_undo_tx = declare_transaction(1, 4, 50, seen_r1(3), undo_tx);
let mut first_undo = undo_env(1, 5, 60, seen_r1(4), tx, UndoPolicy::StrictInverse);
first_undo.transaction = Some(undo_tx);
let second_undo = undo_env(1, 6, 70, seen_r1(5), undo_tx, UndoPolicy::StrictInverse);
envelopes.extend([declare_undo_tx, first_undo, second_undo]);
let mut set = OperationSet::new();
set.accept_all(envelopes);
let state = set.reduce();
assert!(state.conflicts.is_empty());
assert_eq!(
state.spellings.get(&pitch),
Some(&crate::valuegen::spelling(2)),
"undoing the undo restores the originally-undone spelling"
);
}
#[test]
fn a_second_undo_of_the_same_transaction_sees_the_first_as_superseding() {
// PINNED (see DECISIONS.md): the first undo's restoration is a write,
// so a repeated strict undo of the same transaction conflicts rather
// than double-restoring.
let tx = TransactionId::from_raw(26);
let (pitch, mut envelopes) = respell_undo_fixture(tx);
let first = undo_env(1, 4, 50, seen_r1(3), tx, UndoPolicy::StrictInverse);
let second = undo_env(1, 5, 60, seen_r1(4), tx, UndoPolicy::StrictInverse);
envelopes.extend([first, second]);
let mut set = OperationSet::new();
set.accept_all(envelopes);
let state = set.reduce();
assert_eq!(
state.spellings.get(&pitch),
Some(&crate::valuegen::spelling(1)),
"the first undo's restoration stands"
);
assert!(matches!(
effect_at(&state, 5),
Some(OperationEffect::Conflicted { .. })
));
}
#[test]
fn undo_restoration_is_permutation_invariant() {
// The write chains append in canonical processing order, so the undo's
// restoration verdict and restored values are pure functions of the
// operation set: any delivery order reduces to byte-identical state.
let tx = TransactionId::from_raw(27);
let (_, mut envelopes) = respell_undo_fixture(tx);
// Add a meter overwrite + undo flavor alongside the respell flavor.
let region = RegionId::new(ReplicaId(9), 3);
envelopes.push(prim_env(
2,
0,
15,
CausalContext::new(),
OperationKind::CreateRegion(CreateRegionOp {
region: crate::valuegen::region(region),
}),
));
envelopes.push(undo_env(
1,
4,
50,
seen_r1(3),
tx,
UndoPolicy::StrictInverse,
));
let baseline = {
let mut set = OperationSet::new();
set.accept_all(envelopes.clone());
set.reduce().canonical_bytes()
};
let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0x9E37_79B9_7F4A_7C15);
for _ in 0..4 {
let mut shuffled = envelopes.clone();
shuffle_envelopes(&mut shuffled, &mut rng);
let mut set = OperationSet::new();
set.accept_all(shuffled);
assert_eq!(
set.reduce().canonical_bytes(),
baseline,
"value-restoring undo must be permutation-invariant"
);
}
}
}