Land M1 + M2 (Agent C): framework edge fixes and real-Score graph integration

M1 — fix Agent C framework defects, tests-first:
- causal ordering: topologically order DVV edges instead of assuming HLC
  alone implies causal order (false for adversarial remote envelopes);
  HLC only breaks ties among ready operations.
- anomaly cutoff: quarantine from the earliest counter participating in
  any violating HLC pair (suffix-minima), e.g. [100,200,50] quarantines
  from counter 0, not counter 1.
- pending detection: DVV contiguous ranges use the zero-based per-replica
  counter floor; first absent id in any asserted range holds the dependent
  pending (vector coverage, not only dots).
- transaction snapshots: rollback removes member-generated conflicts.
- edge tests in concurrent_reduction.rs for all six audited cases.

M2 — reduce onto Agent B's real score graph:
- OperationSet::reduce_onto(&Score) -> GraphMaterialization { state, score }
  mutates the real arena, voices, regions, tombstones, indexes, and
  cross-cutting structures; base-free reduce() retained.
- VoiceOrigin::SystemPromoted now carries { winning_operation,
  losing_operation, original_voice }; spec and Invariant 18 updated.
- graph-aware migration, forward undo, system breaks, promotion pre-pass.
- tests/graph_reduction.rs: 11 tests asserting check_invariants is clean,
  plus a 64-seed order-independence sweep.

Pass-11 spec decisions resolved (blocking subset): DVV floor (P11-C7),
HLC-vs-causality, promoted-voice derivation inputs (P11-C4 / core P11-3).
Payload/Score canonical encoding remain deferred to the companion docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-21 16:37:51 -04:00
parent 3d1c55d73e
commit e9c4bad7a6
16 changed files with 2030 additions and 211 deletions

View File

@ -68,28 +68,24 @@ Chapter 5", but Chapter 5 §"Graph Invariants" actually enumerates **19** items
almost certainly a stale count in the QUICKSTART; the spec body is treated as
authoritative. Reconcile the two.
### P11-3 — `VoiceOrigin::SystemPromoted` does not carry the spec's full derivation inputs
### P11-3 — resolved in M2: promoted voices retain the full derivation inputs
Invariant 18 requires a system-promoted voice's `VoiceId` to equal the
deterministic derivation of Chapter 5 §"System-Promoted Voices", whose inputs
are *(staff instance, original voice, winning op, losing op)* — four ids. But
`VoiceOrigin::SystemPromoted` records only `{ cause: OperationId, original_voice:
VoiceId }` (one op, not two).
The first-pass `VoiceOrigin::SystemPromoted` recorded only one operation id.
M2 resolves the inconsistency by storing `{ winning_operation,
losing_operation, original_voice }`; the staff instance remains recoverable from
containment.
**Prototype convention (enforced):** the staff instance is recovered from the
containment walk, and `cause` is fed into *both* the winning- and losing-op
slots of `derive_promoted_voice_id`. Invariant 18 now recomputes that derivation
and rejects any `SystemPromoted` voice whose id does not match it (not merely a
wrong namespace) — see `check_voice_origin_consistent` and the
Invariant 18 recomputes the exact derivation and rejects any
`SystemPromoted` voice whose id does not match it (not merely a wrong namespace)
— see `check_voice_origin_consistent` and the
`inv18_flags_fabricated_promoted_voice_id_and_accepts_the_derivation` test.
**Open question for the spec:** either `VoiceOrigin::SystemPromoted` should carry
both the winning and losing op ids (and the spec confirm the derivation over
those), or the derivation should be defined over the inputs the origin actually
stores. The derivation function itself is also still deferred ("specified in the
semantic-operations companion document"); this crate's `derive_promoted_voice_id`
and the cause-as-both-slots convention are placeholders for that companion to
ratify.
The core spec listing now carries both operation ids. The exact hash-domain
derivation remains provisional until the semantic-operations companion ratifies
`derive_promoted_voice_id`.
### P11-4 — A prototype canonical encoding precedes the Binary Format companion

View File

@ -121,10 +121,9 @@ C4 ≠ C5). Empty pitched events are rejected at the arena boundary and re-check
tempo map (its `local_tempo_map`, else the score map). Extents that still
cannot be placed (no tempo defined, or a deferred curve) are skipped rather
than rejected. Sound (no false positives), incomplete (DECISIONS P11-4).
- **System-promoted voice derivation (invariant 18)** is enforced against a
documented convention because `VoiceOrigin::SystemPromoted` (a spec type)
stores a single `cause`, not the winning *and* losing op ids the derivation
formula names — a spec inconsistency batched as DECISIONS P11-3.
- **System-promoted voice derivation (invariant 18)** retains the winning and
losing operation ids on `VoiceOrigin::SystemPromoted`; the checker recomputes
the exact four-input derivation used by `epiphany-ops`.
- **The Chapter 4 tuning *catalog*** — `PitchSpace`/`TuningSystem`/
`AccidentalRegistry` *definitions*, the built-in catalog, the hierarchical
resolver, and the position→frequency resolution function — is **not** an

View File

@ -306,6 +306,20 @@ impl Event {
}
}
/// Sets the event's region-local position (used by time-model migration
/// during canonical operation reduction).
pub fn set_position(&mut self, position: EventPosition) {
match self {
Event::Pitched(e) => e.position = position,
Event::Unpitched(e) => e.position = position,
Event::Rest(e) => e.position = position,
Event::Indeterminate(e) => e.position = position,
Event::Trajectory(e) => e.position = position,
Event::Graphic(e) => e.position = position,
Event::Cue(e) => e.position = position,
}
}
/// Appends references to every [`IdentifiedPitch`] this event embeds:
/// chord pitches for [`Event::Pitched`], and explicit/stepwise pitches for
/// [`Event::Trajectory`]. Used by the pitch-uniqueness invariant.
@ -371,6 +385,15 @@ pub struct EventArena {
by_id: HashMap<EventId, EventKey>,
}
impl PartialEq for EventArena {
fn eq(&self, other: &Self) -> bool {
let ids = self.ids_canonical();
ids == other.ids_canonical() && ids.into_iter().all(|id| self.get(id) == other.get(id))
}
}
impl Eq for EventArena {}
impl EventArena {
/// An empty arena.
pub fn new() -> Self {

View File

@ -396,7 +396,10 @@ pub enum VoiceOrigin {
/// System-promoted to resolve a concurrent-edit collision (Chapter 5
/// §"System-Promoted Voices").
SystemPromoted {
cause: OperationId,
/// The lower-id concurrent operation that retained the original voice.
winning_operation: OperationId,
/// The greater-id concurrent operation moved into this voice.
losing_operation: OperationId,
original_voice: VoiceId,
},
}
@ -1069,7 +1072,7 @@ impl Default for ScoreTuningContext {
/// staff groups, parts, tuning context, tempo map, analysis layers, views)
/// carry their Chapter 5 identity/reference skeleton with deeper bodies left to
/// the consuming crates (Agents C/E) and later companions.
#[derive(Clone, Debug)]
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Score {
pub metadata: ScoreMetadata,
pub canvas: Canvas,

View File

@ -679,7 +679,7 @@ impl CanonicalDecode for TypedObjectId {
/// The replica identifier plus identifier-generation state of a score
/// (Chapter 5 `IdentityContext`). A single monotonic counter suffices for all
/// identifier kinds.
#[derive(Clone, Debug)]
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct IdentityContext {
/// This replica's identifier, generated at score creation.
pub replica_id: ReplicaId,

View File

@ -2102,18 +2102,19 @@ impl<'a> GraphIndex<'a> {
for (_r, si, v) in self.score.voices() {
match &v.origin {
VoiceOrigin::SystemPromoted {
cause,
winning_operation,
losing_operation,
original_voice,
} => {
// A promoted voice's id MUST be the deterministic derivation
// (Chapter 5 §"System-Promoted Voices"). The spec's
// derivation takes the winning *and* losing op ids, but
// `VoiceOrigin::SystemPromoted` records only one `cause`
// (DECISIONS P11-3); this prototype's convention feeds `cause`
// into both slots, and the checker enforces that convention
// exactly — so a fabricated promoted id is caught, not merely
// a wrong namespace.
let expected = derive_promoted_voice_id(si, *original_voice, *cause, *cause);
// (Chapter 5 §"System-Promoted Voices") from the complete
// provenance retained on the graph object.
let expected = derive_promoted_voice_id(
si,
*original_voice,
*winning_operation,
*losing_operation,
);
if v.id != expected {
out.push(InvariantViolation::new(
GraphInvariant::VoiceOriginConsistent,
@ -2626,8 +2627,9 @@ mod review_fix_tests {
let mut s = valid_score(30);
let si = s.canvas.regions[0].staff_instances()[0].id;
let original = s.canvas.regions[0].staff_instances()[0].voices[0].id;
let cause = OperationId::new(s.identity.replica_id, 5);
let correct = derive_promoted_voice_id(si, original, cause, cause);
let winner = OperationId::new(s.identity.replica_id, 5);
let loser = OperationId::new(s.identity.replica_id, 6);
let correct = derive_promoted_voice_id(si, original, winner, loser);
// A SystemPromoted voice with the *correct* derived id is accepted.
let good = Voice {
@ -2636,7 +2638,8 @@ mod review_fix_tests {
default_stem_direction: None,
is_primary: false,
origin: VoiceOrigin::SystemPromoted {
cause,
winning_operation: winner,
losing_operation: loser,
original_voice: original,
},
};
@ -2656,7 +2659,8 @@ mod review_fix_tests {
default_stem_direction: None,
is_primary: false,
origin: VoiceOrigin::SystemPromoted {
cause,
winning_operation: winner,
losing_operation: loser,
original_voice: original,
},
});

View File

@ -48,12 +48,13 @@ companion. This crate mirrors that division exactly:
migration; LWW; atomic transactions). The remaining catalog kinds are an
additive future change behind the existing `OperationKind` enum.
The materialized state this crate computes is the canonical bookkeeping Chapter 6
itself owns — the effect log, conflict registry, anomaly register, object
existence/tombstones, spellings, and LWW fields. The full musical-graph mutation
against `epiphany_core::Score` (arena contents, voice event lists, region
positions) is the integration point with Agent B's crate and is genuinely large;
it is the natural next phase, and nothing here blocks it.
`MaterializedState` is the canonical bookkeeping Chapter 6 owns — the effect
log, conflict registry, anomaly register, object existence/tombstones,
spellings, and LWW fields. M2 adds `OperationSet::reduce_onto(&Score)`, which
seeds those indices from a canonical base and returns the corresponding Agent B
graph. Insert/delete, voice promotion, supported reference-level cross-cutting
values, system breaks, migration checks, transaction rollback, and undo mutate
that graph. The base-free `reduce()` remains the operation-set convergence API.
## Pass 11 candidates (ambiguities for the spec, not resolved in code)
@ -67,8 +68,10 @@ only identifiers and the scalar time types). An `OperationEnvelope` must be
hashable **today** — the `EnvelopeHash` and slot equivocation both need canonical
bytes — so this crate's payloads carry the reduction-relevant *identifiers and
canonical scalar coordinates*, plus a `ContentHash` fingerprint where the
reduction needs only equality (a respelling). This is faithful to everything the
chapter's reduction rules actually consume. **For the spec:** pin the payload
reduction needs only equality (a respelling). Graph-aware reduction materializes
this projection as deterministic C4 pitches (or a rest when no pitch ids are
present); those placeholders do not claim to recover musical values absent from
the payload. **For the spec:** pin the payload
schemas (the Operation Catalog companion) and the canonical encoding (the Binary
Format companion); when they land, the structs regain their full value fields
without changing the reduction. The trigger will be a failing cross-crate
@ -101,16 +104,17 @@ collision (and whether the superseded loser should retroactively read
### P11-C4 — voice-promotion derivation inputs and the >2-collision generalization
Invariant 18's promoted-voice derivation takes *(staff instance, original voice,
winning op, losing op)* (Agent B's P11-3 already flags that
`VoiceOrigin::SystemPromoted` stores only one op). This crate resolves promotion
in an **order-independent pre-pass**: bucket concurrent same-`(voice, position)`
inserts, keep the smallest `OperationId` in the original voice, and promote each
other op to `derive_promoted_voice_id(staff_instance, voice, smallest, that_op)`.
The op carries its `staff_instance` explicitly (a full reducer recovers it from
the voice's container). Two open points for the spec: (a) confirm the derivation
inputs (this couples to Agent B's P11-3), and (b) define the >2-way collision
case — the spec describes a pairwise rule; this crate generalizes "smallest stays,
rest promote."
winning op, losing op)*. M2 expanded `VoiceOrigin::SystemPromoted` to retain both
operation ids, so Agent B verifies the exact Agent C derivation. This crate resolves promotion
in an **order-independent pre-pass**: bucket inserts by voice, walk them by
`OperationId`, keep a non-overlapping set in the original voice, and promote
each concurrent overlapping loser to `derive_promoted_voice_id(staff_instance,
voice, winner, loser)`. This applies the InsertEvent invariant to partial
interval overlaps as well as identical start positions. The op carries its
`staff_instance` explicitly (a full reducer recovers it from the voice's
container). One open point remains for the spec: define the >2-way collision
case — the spec describes a pairwise rule; this crate uses the first lower-id overlapping
operation retained in the original voice as the winner for each promotion.
### P11-C5 — "nearest surviving anchor" needs resolved positions
@ -124,28 +128,26 @@ cascade) is implemented faithfully; only the metric "nearest" is approximated.
**For the spec:** no change needed — this resolves once the graph mutation phase
tracks positions; recorded so the approximation is explicit.
### P11-C6 — time-model-migration compatibility is declared, not computed
### P11-C6 — time-model compatibility is computed when a graph is available
`ChangeRegionTimeModel`'s `TimeModelMigrationFailure` conflict requires knowing
which contained events have coordinate kinds incompatible with the new model. The
prototype does not materialize per-event coordinate kinds, so the op carries a
`declared_incompatible` list (the authoring layer's knowledge) that drives the
conflict; concurrent same-region migrations still conflict structurally by
canonical order. **For the spec:** no change — resolves with the graph mutation
phase; recorded so the modeling is explicit.
`ChangeRegionTimeModel` retains a `declared_incompatible` list for base-free
reduction. Graph-aware reduction additionally derives incompatibilities from
every event's actual coordinate variants and mapping coverage, refusing any
migration that would violate Agent B's coordinate discipline. Concurrent
same-region migrations conflict; causally-later migrations are reevaluated
against the first migration's graph. **For the spec:** the rich migration
payload still belongs to the Operation Catalog.
### P11-C7 — the missing-causal-predecessor rule keys on dots + known-bad coverage
### P11-C7 — DVV contiguous ranges use the zero-based operation-counter floor
The DVV's contiguous `vector[r] = n` asserts predecessors `(r, 0..=n)` exist
*somewhere*; detecting a *truly missing* `(r, k)` from the vector alone would
require knowing the expected counter sequence. This crate therefore uses the
spec's explicit channel for non-contiguous predecessors — the **dots** — as the
missing-predecessor signal, plus vector-coverage of *known* equivocated/excluded
ids, with transitive propagation to dependents. This holds a dependent pending
exactly when a referenced predecessor is absent (a dot to a slotless id),
equivocated, or excluded. **For the spec:** confirm whether the contiguous vector
should also synthesize "missing" predecessors (it would need a per-replica
expected-floor convention).
The DVV's contiguous `vector[r] = n` asserts predecessors `(r, 0..=n)` exist,
matching the operation-id and causal-context documentation. Reduction finds the
first absent id in every asserted range and holds the dependent pending; dots
and vector coverage of known equivocated/excluded ids remain direct blocking
signals, with transitive propagation to dependents. The range check walks known
ids rather than expanding `0..=n`, so a sparse context with a very high counter
does not cause proportional work. **For the spec:** explicitly retain the
zero-based per-replica counter floor in the normative DVV definition.
### P11-C8 — forward undo is modeled via minted-object compensation
@ -155,7 +157,9 @@ graph-mutation phase, this crate models the compensation as tombstoning the
objects the target transaction *minted*: StrictInverse conflicts if any such
object was already tombstoned/modified; BestEffort tombstones the survivors;
Cascade is treated as StrictInverse over the same set (dependent-closure undo is
deferred with the rest of the catalog). **For the spec:** this is faithful to the
deferred with the rest of the catalog). Graph-aware reduction also removes those
event, pitch, promoted-voice, and supported cross-cutting mints from the live
graph and records graph tombstones. **For the spec:** this is faithful to the
"content-equivalence to pre-target state" definition for insert-shaped
transactions; the inverse of every catalog primitive is the Operation Catalog's
job.

View File

@ -42,11 +42,9 @@ property is the determinism heart of the architecture, and the
## The determinism this crate enforces
1. **A single reduction-order function.** `canonical_reduction_order` sorts by
the intrinsic stamp tuple `(physical, logical, replica, counter)`. The
authoring HLC rule guarantees a causal predecessor's tuple is strictly less,
so the sort respects causal order without a topological pass — and, being a
sort by intrinsic keys, it is trivially independent of arrival order.
1. **A single reduction-order function.** `canonical_reduction_order` performs
deterministic causal topological ordering, using the intrinsic stamp tuple
`(physical, logical, replica, counter)` only among ready operations.
2. **Order-independent equivocation.** A duplicate `OperationId` with different
canonical bytes transitions its slot to `Equivocated` regardless of which
envelope arrived first (Pass 10). Equivocated slots contribute nothing to
@ -58,6 +56,10 @@ property is the determinism heart of the architecture, and the
4. **Byte-identical materialized state.** `MaterializedState::canonical_bytes`
serializes the effect log, conflict registry, anomaly register, object
existence, spellings, and LWW fields in their normative orders.
5. **Real graph materialization.** `OperationSet::reduce_onto(&base_score)`
returns `GraphMaterialization { state, score }`. The graph is mutated in the
same canonical order and compares by canonical event identity, independent
of arena storage order.
## Hand-off gates
@ -85,11 +87,12 @@ Chapter 6 specifies the framework and a *representative* selection of
operations; the full ~6080-primitive catalog is an explicit open question
(§6.11) deferred to the Operation Catalog companion. This crate implements the
framework in full and the representative operations, which is sufficient to
exercise every reduction discipline. The full musical-graph mutation against
`epiphany_core::Score` is the next phase. See `DECISIONS.md` for the scope
boundary, the prototype conventions (payloads carry identifiers + fingerprints,
voice promotion via an order-independent pre-pass, undo via minted-object
compensation), and the batched Pass 11 candidates.
exercise every reduction discipline. The representative operations can also
reduce onto an `epiphany_core::Score`: insert/delete, voice promotion, supported
cross-cutting structures, system breaks, migration checks, transaction
rollback, and undo mutate the real graph while preserving Agent B's invariants.
`reduce()` remains the base-free CRDT/bookkeeping API; `reduce_onto()` is the
graph-aware editing path. See `DECISIONS.md` for remaining payload boundaries.
Per QUICKSTART "Don't do these": undo is the spec's **forward** compensating
operation, never inverse-based; `unsafe` is forbidden; everything is sync.

View File

@ -208,9 +208,8 @@ impl CanonicalEncode for IntegrityAnomalyKind {
///
/// The check is order-independent: it groups the envelopes by authoring
/// replica, walks each replica's envelopes in ascending counter order, and
/// flags the first counter `c2` whose monotonicity tuple is strictly less than
/// the maximum tuple of an earlier counter `c1`. The resulting segment's
/// `first_bad_counter` is `c1` (the smaller counter of the violating pair), and
/// finds every pair `c1 < c2` whose monotonicity tuples decrease. The resulting
/// segment starts at the smallest `c1` participating in any violating pair, and
/// every envelope of that replica with counter `>= c1` is excluded.
///
/// Returns one [`AnomalousReplicaSegment`] per offending replica, in ascending
@ -232,31 +231,27 @@ pub fn detect_replica_anomalies(envelopes: &[&OperationEnvelope]) -> Vec<Anomalo
let mut segments = Vec::new();
for (replica, mut ops) in by_replica {
ops.sort_by_key(|(counter, _, _)| *counter);
// Walk ascending counter; track the max tuple and the counter/op that
// set it. The first strict decrease is the violation.
let mut max_tuple: Option<(i64, u32, u64)> = None;
let mut max_op: Option<OperationId> = None;
let mut first_bad: Option<(u64, OperationId, OperationId)> = None;
for (counter, op, tuple) in &ops {
match max_tuple {
Some(m) if *tuple < m => {
// Violation: pair is (max_op @ earlier counter, this op).
let earlier = max_op.expect("max_op set whenever max_tuple is");
first_bad = Some((earlier.counter, earlier, *op));
break;
}
Some(m) if *tuple > m => {
max_tuple = Some(*tuple);
max_op = Some(*op);
}
None => {
max_tuple = Some(*tuple);
max_op = Some(*op);
}
_ => {} // equal tuple: still monotone, keep the earlier max_op
// Find the earliest counter participating as the left side of any
// violating pair. Comparing only against the preceding maximum is not
// enough: [100, 200, 50] also makes (counter 0, counter 2) a violating
// pair, so quarantine must begin at counter 0 rather than counter 1.
// Suffix minima make this linear after the counter sort.
let mut suffix_min = vec![(i64::MAX, u32::MAX, u64::MAX); ops.len()];
if let Some(last) = ops.last() {
suffix_min[ops.len() - 1] = last.2;
for index in (0..ops.len() - 1).rev() {
suffix_min[index] = ops[index].2.min(suffix_min[index + 1]);
}
let _ = counter;
}
let first_bad = (0..ops.len().saturating_sub(1)).find_map(|earlier| {
if ops[earlier].2 <= suffix_min[earlier + 1] {
return None;
}
let later = (earlier + 1..ops.len())
.find(|&later| ops[earlier].2 > ops[later].2)
.expect("suffix minimum proves that a violating successor exists");
Some((ops[earlier].0, ops[earlier].1, ops[later].1))
});
if let Some((first_bad_counter, c1_op, c2_op)) = first_bad {
let excluded: Vec<OperationId> = ops
@ -337,4 +332,23 @@ mod tests {
let backward = detect_replica_anomalies(&[&b, &a]);
assert_eq!(forward, backward);
}
#[test]
fn quarantine_starts_at_earliest_counter_in_any_violating_pair() {
// Counter 2 is below both earlier stamps. The first violating pair by
// counter is (0, 2), even though counter 1 carries the maximum stamp.
let a = env(1, 0, 100, 0);
let b = env(1, 1, 200, 0);
let c = env(1, 2, 50, 0);
let seg = detect_replica_anomalies(&[&a, &b, &c]);
assert_eq!(seg.len(), 1);
assert_eq!(seg[0].first_bad_counter, 0);
assert_eq!(seg[0].excluded, vec![a.id, b.id, c.id]);
assert_eq!(
seg[0].reason,
ReplicaAnomalyReason::HlcMonotonicityViolation {
violating_pair: (a.id, c.id),
}
);
}
}

View File

@ -11,12 +11,13 @@
//! * `dots`: individual [`OperationId`]s observed but not yet contiguous in the
//! vector — "known but not yet contiguous" predecessors.
//!
//! The canonical reduction order is causal-first (Chapter 6 §6.3.3); the
//! authoring HLC rule guarantees causal predecessors carry strictly-lesser
//! stamps, so the reduction never needs to topologically sort — see
//! [`crate::canonical_reduction_order`]. The DVV is consumed instead by the
//! *missing-causal-predecessor* rule (an operation whose predecessor is absent,
//! equivocated, or excluded is held pending) and by the transaction
//! The canonical reduction order is causal-first (Chapter 6 §6.3.3). Although
//! correctly authored operations give causal predecessors strictly-lesser HLC
//! stamps, accepted remote envelopes may violate that authoring rule. Reduction
//! therefore topologically orders the DVV edges and uses HLC only among ready
//! operations — see [`crate::canonical_reduction_order`]. The DVV also drives
//! the *missing-causal-predecessor* rule (an operation whose predecessor is
//! absent, equivocated, or excluded is held pending) and the transaction
//! descriptor-precedence rule (Chapter 6 §6.7).
use std::collections::{BTreeMap, BTreeSet};

View File

@ -53,7 +53,9 @@
//! * `opset` — [`OperationSet`]: the slot map plus the acceptance pipeline
//! (well-formedness → slot transition → causal validation).
//! * `reduce` — [`canonical_reduction_order`], [`MaterializedState`], and the
//! reduction driver (Chapter 6 §6.3).
//! reduction driver (Chapter 6 §6.3). [`OperationSet::reduce_onto`] also
//! materializes the representative mutations into an Agent B
//! [`epiphany_core::Score`].
//!
//! ## Scope (per QUICKSTART and Chapter 6 §6.11)
//!
@ -112,7 +114,9 @@ pub use payload::{
ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory,
TransactionDescriptor, TupletCompensation,
};
pub use reduce::{canonical_reduction_order, MaterializedState, ObjectState, PendingReason};
pub use reduce::{
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,
};
pub use slot::OperationSlot;
pub use stamp::{HybridLogicalClock, OperationStamp, StampTuple};
pub use support::{

View File

@ -19,7 +19,9 @@ use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::OperationId;
use crate::envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError};
use crate::reduce::{reduce_operation_set, MaterializedState};
use crate::reduce::{
reduce_operation_set, reduce_operation_set_onto, GraphMaterialization, MaterializedState,
};
use crate::slot::OperationSlot;
/// The outcome of accepting one envelope (Chapter 6 §6.5 transition rules).
@ -192,6 +194,12 @@ impl OperationSet {
pub fn reduce(&self) -> MaterializedState {
reduce_operation_set(self)
}
/// Reduces this operation set onto a canonical base score and returns both
/// the Chapter 6 bookkeeping state and Agent B's materialized graph.
pub fn reduce_onto(&self, base: &epiphany_core::Score) -> GraphMaterialization {
reduce_operation_set_onto(self, base)
}
}
#[cfg(test)]

File diff suppressed because it is too large Load Diff

View File

@ -21,9 +21,10 @@ use epiphany_core::{
};
use epiphany_determinism::{fuzz::SplitMix64, ContentHash};
use epiphany_ops::{
well_formed, AuthorId, CausalContext, ConflictKind, HybridLogicalClock, InsertEventOp,
IntegrityAnomalyKind, NoOpReason, OperationEffect, OperationEnvelope, OperationKind,
OperationPayload, OperationSet, OperationStamp, RespellPitchOp, TransactionDescriptor,
canonical_reduction_order, well_formed, AuthorId, CausalContext, ConflictKind,
HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect,
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
PendingReason, PreconditionFailureReason, RespellPitchOp, TransactionDescriptor,
TupletCompensation, UndoPolicy, UndoTransactionPayload,
};
@ -53,12 +54,26 @@ fn envelope(
}
fn insert(voice: u64, event: u64, pos: i64) -> OperationPayload {
insert_span(
voice,
event,
RationalTime::from_int(pos as i32),
RationalTime::one(),
)
}
fn insert_span(
voice: u64,
event: u64,
position: RationalTime,
duration: RationalTime,
) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
voice: VoiceId::new(ReplicaId(9), voice),
staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
event: EventId::new(ReplicaId(9), event),
position: MusicalPosition(RationalTime::from_int(pos as i32)),
duration: MusicalDuration::whole(),
position: MusicalPosition(position),
duration: MusicalDuration(duration),
pitches: vec![PitchId::new(ReplicaId(9), event)],
}))
}
@ -186,6 +201,51 @@ fn thousand_envelope_set_reduces_identically_in_ten_orders() {
}
}
#[test]
fn causal_predecessor_dominates_inverted_cross_replica_hlc() {
let predecessor = envelope(1, 0, 100, CausalContext::new(), None, insert(0, 100, 0));
let successor = envelope(
2,
0,
1,
CausalContext::new().with_dot(predecessor.id),
None,
insert(0, 101, 1),
);
let ordered = canonical_reduction_order(&[&successor, &predecessor]);
assert_eq!(
ordered.iter().map(|env| env.id).collect::<Vec<_>>(),
vec![predecessor.id, successor.id]
);
}
#[test]
fn absent_vector_predecessor_holds_operation_pending() {
let present = envelope(1, 0, 1, CausalContext::new(), None, insert(0, 99, -1));
let dependent = envelope(
2,
0,
10,
CausalContext::new().with_seen(ReplicaId(1), 2),
None,
insert(0, 100, 0),
);
let mut set = OperationSet::new();
set.accept_all(vec![present.clone(), dependent.clone()]);
let state = set.reduce();
assert_eq!(state.effects.len(), 1);
assert_eq!(state.effects[0].0, present.id);
assert_eq!(
state.pending,
vec![(
dependent.id,
PendingReason::MissingCausalPredecessor { missing: op(1, 1) }
)]
);
}
// --- Transactions (Chapter 6 §6.6). -----------------------------------------
fn declare_tx(replica: u64, counter: u64, physical: i64, tx: TransactionId) -> OperationEnvelope {
@ -269,10 +329,61 @@ fn transaction_with_a_failing_member_conflicts_wholesale() {
.any(|r| matches!(r.kind, ConflictKind::TransactionConflict { .. })));
}
#[test]
fn failed_transaction_rolls_back_member_generated_conflicts() {
let seed = envelope(2, 0, 1, CausalContext::new(), None, insert(0, 100, 0));
let initial_spelling = envelope(
2,
1,
2,
CausalContext::new().with_seen(ReplicaId(2), 0),
None,
respell(100, 1),
);
let tx = TransactionId::from_raw(89);
let descriptor = declare_tx(1, 0, 10, tx);
let tx_ctx = CausalContext::new().with_seen(ReplicaId(1), 0);
// This member conflicts with the concurrent initial spelling.
let conflicting = envelope(1, 1, 11, tx_ctx.clone(), Some(tx), respell(100, 2));
// This member fails, forcing the whole block to roll back.
let failing = envelope(
1,
2,
12,
tx_ctx,
Some(tx),
OperationPayload::Primitive(OperationKind::DeleteEvent(epiphany_ops::DeleteEventOp {
event: EventId::new(ReplicaId(9), 999),
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(vec![
seed,
initial_spelling,
descriptor,
conflicting,
failing,
]);
let state = set.reduce();
assert_eq!(
state.spellings.get(&PitchId::new(ReplicaId(9), 100)),
Some(&ContentHash([1; 32]))
);
assert_eq!(state.conflicts.records().len(), 1);
assert!(matches!(
state.conflicts.records()[0].kind,
ConflictKind::TransactionConflict { .. }
));
}
#[test]
fn member_without_its_descriptor_is_a_transaction_conflict() {
// A member that declares membership in a transaction whose descriptor is
// absent from the set is malformed against the transaction model.
// absent from the set is malformed against the transaction model. This
// transaction-specific conflict takes precedence over ordinary pending.
let tx = TransactionId::from_raw(99);
let ctx = CausalContext::new().with_seen(ReplicaId(1), 0);
let orphan = envelope(1, 1, 11, ctx, Some(tx), insert(0, 100, 0));
@ -280,6 +391,7 @@ fn member_without_its_descriptor_is_a_transaction_conflict() {
let mut set = OperationSet::new();
set.accept(orphan.clone());
let state = set.reduce();
assert!(state.pending.is_empty());
let eff = state
.effects
.iter()
@ -315,6 +427,102 @@ fn hlc_monotonicity_violation_excludes_the_segment() {
)));
}
#[test]
fn causally_ordered_same_position_insert_is_not_promoted() {
let first = envelope(1, 0, 10, CausalContext::new(), None, insert(0, 100, 0));
let second = envelope(
1,
1,
11,
CausalContext::new().with_seen(ReplicaId(1), 0),
None,
insert(0, 101, 0),
);
let mut set = OperationSet::new();
set.accept_all(vec![first, second.clone()]);
let state = set.reduce();
let effect = state
.effects
.iter()
.find(|(id, _)| *id == second.id)
.map(|(_, effect)| effect);
assert_eq!(
effect,
Some(&OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::EventDurationInvalid,
},
})
);
}
#[test]
fn concurrent_partial_interval_overlap_promotes_the_greater_id() {
let first = envelope(
1,
0,
10,
CausalContext::new(),
None,
insert_span(0, 100, RationalTime::zero(), RationalTime::one()),
);
let second = envelope(
2,
0,
10,
CausalContext::new(),
None,
insert_span(
0,
200,
RationalTime::new(1, 2).unwrap(),
RationalTime::one(),
),
);
let mut set = OperationSet::new();
set.accept_all(vec![first, second.clone()]);
let state = set.reduce();
assert!(matches!(
state
.effects
.iter()
.find(|(id, _)| *id == second.id)
.map(|(_, effect)| effect),
Some(OperationEffect::AppliedWithRepair { repairs })
if repairs.iter().any(|repair| matches!(repair.kind, epiphany_ops::RepairKind::VoicePromoted { .. }))
));
}
#[test]
fn adjacent_half_open_intervals_do_not_collide() {
let first = envelope(
1,
0,
10,
CausalContext::new(),
None,
insert_span(0, 100, RationalTime::zero(), RationalTime::one()),
);
let second = envelope(
2,
0,
10,
CausalContext::new(),
None,
insert_span(0, 200, RationalTime::one(), RationalTime::one()),
);
let mut set = OperationSet::new();
set.accept_all(vec![first, second]);
let state = set.reduce();
assert!(state
.effects
.iter()
.all(|(_, effect)| *effect == OperationEffect::Applied));
}
// --- Forward undo (Chapter 6 §6.8). -----------------------------------------
#[test]

View File

@ -0,0 +1,542 @@
//! M2 regression coverage for reducing operations onto Agent B's real score
//! graph rather than only the Chapter 6 bookkeeping projection.
use epiphany_core::{
check_invariants, derive_promoted_voice_id, AnchorOffset, EventId, MusicalDuration,
MusicalPosition, OperationId, PitchId, RationalTime, RegionEdge, RegionTimeModel, ReplicaId,
Score, SlurId, StaffInstanceId, TimeAnchor, TransactionId, TypedObjectId, VoiceId, VoiceOrigin,
WallClockTime,
};
use epiphany_ops::{
AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp,
CrossCuttingRef, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason, OperationEffect,
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
PositionRemapping, PreconditionFailureReason, RegionTimeModelTag, SetUserSystemBreakOp,
TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy,
UndoTransactionPayload,
};
fn envelope(
replica: u64,
counter: u64,
physical: i64,
context: CausalContext,
transaction: Option<TransactionId>,
payload: OperationPayload,
) -> OperationEnvelope {
let id = OperationId::new(ReplicaId(replica), counter);
OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
causal_context: context,
transaction,
payload,
}
}
fn target(score: &Score) -> (StaffInstanceId, VoiceId) {
let instance = &score.canvas.regions[0].staff_instances()[0];
(instance.id, instance.voices[0].id)
}
fn insert(
staff_instance: StaffInstanceId,
voice: VoiceId,
event: EventId,
pitch: PitchId,
position: i32,
) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
voice,
staff_instance,
event,
position: MusicalPosition(RationalTime::from_int(position)),
duration: MusicalDuration::whole(),
pitches: vec![pitch],
}))
}
fn voice(score: &Score, id: VoiceId) -> Option<&epiphany_core::Voice> {
score
.voices()
.find_map(|(_, _, voice)| (voice.id == id).then_some(voice))
}
#[test]
fn insert_materializes_in_the_real_arena_and_voice() {
let base = epiphany_core::generators::valid_score(100);
let (staff_instance, target_voice) = target(&base);
let event = EventId::new(ReplicaId(50), 0);
let pitch = PitchId::new(ReplicaId(50), 1);
let op = envelope(
50,
0,
10,
CausalContext::new(),
None,
insert(staff_instance, target_voice, event, pitch, 100),
);
let mut set = OperationSet::new();
set.accept(op);
let result = set.reduce_onto(&base);
assert!(result.score.events.contains(event));
assert!(voice(&result.score, target_voice)
.expect("target voice remains present")
.events
.contains(&event));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn graph_reduction_rejects_an_unknown_voice_without_creating_it() {
let base = epiphany_core::generators::valid_score(101);
let (staff_instance, _) = target(&base);
let missing_voice = VoiceId::new(ReplicaId(51), 99);
let event = EventId::new(ReplicaId(51), 0);
let op = envelope(
51,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
missing_voice,
event,
PitchId::new(ReplicaId(51), 1),
100,
),
);
let mut set = OperationSet::new();
set.accept(op.clone());
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(event));
assert!(voice(&result.score, missing_voice).is_none());
assert_eq!(
result.state.effects,
vec![(
op.id,
OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::VoiceMissing,
},
},
)]
);
}
#[test]
fn concurrent_overlap_materializes_an_invariant_clean_promoted_voice() {
let base = epiphany_core::generators::valid_score(102);
let (staff_instance, target_voice) = target(&base);
let winner = envelope(
52,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(52), 10),
PitchId::new(ReplicaId(52), 11),
100,
),
);
let loser = envelope(
53,
0,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(53), 10),
PitchId::new(ReplicaId(53), 11),
100,
),
);
let promoted = derive_promoted_voice_id(staff_instance, target_voice, winner.id, loser.id);
let mut set = OperationSet::new();
set.accept_all(vec![loser.clone(), winner.clone()]);
let mut reversed = OperationSet::new();
reversed.accept_all(vec![winner.clone(), loser.clone()]);
let result = set.reduce_onto(&base);
assert_eq!(result, reversed.reduce_onto(&base));
let promoted_voice = voice(&result.score, promoted).expect("promoted voice was materialized");
assert!(promoted_voice
.events
.contains(&EventId::new(ReplicaId(53), 10)));
assert_eq!(
promoted_voice.origin,
VoiceOrigin::SystemPromoted {
winning_operation: winner.id,
losing_operation: loser.id,
original_voice: target_voice,
}
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn delete_removes_the_event_and_records_graph_tombstones() {
let base = epiphany_core::generators::valid_score(103);
let (staff_instance, target_voice) = target(&base);
let event = EventId::new(ReplicaId(54), 10);
let pitch = PitchId::new(ReplicaId(54), 11);
let insertion = envelope(
54,
0,
10,
CausalContext::new(),
None,
insert(staff_instance, target_voice, event, pitch, 100),
);
let deletion = envelope(
54,
1,
11,
CausalContext::new().with_seen(ReplicaId(54), 0),
None,
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event,
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(vec![deletion, insertion]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(event));
assert!(!voice(&result.score, target_voice)
.expect("target voice remains present")
.events
.contains(&event));
assert!(result.score.tombstoned_events.contains(&event));
assert!(result.score.tombstoned_pitches.contains(&pitch));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn failed_transaction_rolls_back_real_graph_mutations() {
let base = epiphany_core::generators::valid_score(104);
let (staff_instance, target_voice) = target(&base);
let tx = TransactionId::from_raw(77);
let descriptor = envelope(
55,
0,
10,
CausalContext::new(),
Some(tx),
OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor {
id: tx,
label: String::from("graph rollback"),
category: Some(TransactionCategory::NoteEntry),
})),
);
let tx_context = CausalContext::new().with_seen(ReplicaId(55), 0);
let inserted_event = EventId::new(ReplicaId(55), 10);
let insertion = envelope(
55,
1,
11,
tx_context.clone(),
Some(tx),
insert(
staff_instance,
target_voice,
inserted_event,
PitchId::new(ReplicaId(55), 11),
100,
),
);
let failing = envelope(
55,
2,
12,
tx_context,
Some(tx),
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: EventId::new(ReplicaId(55), 999),
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(vec![failing, insertion, descriptor]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(inserted_event));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn forward_undo_removes_transaction_mints_from_the_graph() {
let base = epiphany_core::generators::valid_score(105);
let (staff_instance, target_voice) = target(&base);
let tx = TransactionId::from_raw(78);
let descriptor = envelope(
56,
0,
10,
CausalContext::new(),
Some(tx),
OperationPayload::Primitive(OperationKind::DeclareTransaction(TransactionDescriptor {
id: tx,
label: String::from("graph undo"),
category: None,
})),
);
let inserted_event = EventId::new(ReplicaId(56), 10);
let insertion = envelope(
56,
1,
11,
CausalContext::new().with_seen(ReplicaId(56), 0),
Some(tx),
insert(
staff_instance,
target_voice,
inserted_event,
PitchId::new(ReplicaId(56), 11),
100,
),
);
let undo = envelope(
56,
2,
12,
CausalContext::new().with_seen(ReplicaId(56), 1),
None,
OperationPayload::UndoTransaction(UndoTransactionPayload {
target: tx,
policy: UndoPolicy::StrictInverse,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![undo, insertion, descriptor]);
let result = set.reduce_onto(&base);
assert!(!result.score.events.contains(inserted_event));
assert!(result.score.tombstoned_events.contains(&inserted_event));
assert!(matches!(
result
.state
.objects
.get(&TypedObjectId::Event(inserted_event)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn system_break_lww_state_is_materialized_in_the_region() {
let base = epiphany_core::generators::valid_score(106);
let region = base.canvas.regions[0].id;
let position = MusicalPosition(RationalTime::from_int(8));
let operation = envelope(
57,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
region,
anchor: position.clone(),
present: true,
})),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
let breaks = &result.score.canvas.regions[0]
.content
.staff_based()
.expect("fixture is staff based")
.user_system_breaks;
assert_eq!(
breaks,
&[TimeAnchor::Region {
id: region,
edge: RegionEdge::Start,
offset: AnchorOffset::Musical(MusicalDuration(position.0)),
}]
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn migration_computes_incompatible_events_from_the_graph() {
let base = epiphany_core::generators::valid_score(107);
let region = base.canvas.regions[0].id;
let operation = envelope(
58,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: RegionTimeModelTag::Proportional,
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
assert_eq!(result.score, base);
assert!(result
.state
.conflicts
.records()
.iter()
.any(|record| matches!(record.kind, ConflictKind::TimeModelMigrationFailure { .. })));
}
#[test]
fn create_cross_cutting_materializes_supported_graph_structures() {
let base = epiphany_core::generators::valid_score(108);
let endpoints = base.canvas.regions[0].staff_instances()[0].voices[0].events[..2].to_vec();
let slur = SlurId::new(ReplicaId(59), 10);
let operation = envelope(
59,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingRef {
id: TypedObjectId::Slur(slur),
endpoints: endpoints
.iter()
.copied()
.map(TypedObjectId::Event)
.collect(),
},
})),
);
let mut set = OperationSet::new();
set.accept(operation);
let result = set.reduce_onto(&base);
assert!(result
.score
.cross_cutting
.slurs
.iter()
.any(|value| value.id == slur));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn causally_ordered_time_migrations_do_not_conflict() {
let base = epiphany_core::generators::valid_score(109);
let region = base.canvas.regions[0].id;
let first = envelope(
60,
0,
10,
CausalContext::new(),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: RegionTimeModelTag::Aleatoric,
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let second = envelope(
60,
1,
11,
CausalContext::new().with_seen(ReplicaId(60), 0),
None,
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp {
region,
new_time_model: RegionTimeModelTag::Metric,
declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime,
},
)),
);
let mut set = OperationSet::new();
set.accept_all(vec![second, first]);
let result = set.reduce_onto(&base);
assert!(result.state.conflicts.is_empty());
assert!(matches!(
result.score.canvas.regions[0].time_model,
RegionTimeModel::Metric(_)
));
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn graph_materialization_is_deterministic_across_base_corpus_and_delivery_order() {
for seed in 0..64_u64 {
let base = epiphany_core::generators::valid_score(1_000 + seed);
let (staff_instance, target_voice) = target(&base);
let winner = envelope(
0xC001,
seed,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(0xC001), seed),
PitchId::new(ReplicaId(0xC001), seed),
100,
),
);
let loser = envelope(
0xC002,
seed,
10,
CausalContext::new(),
None,
insert(
staff_instance,
target_voice,
EventId::new(ReplicaId(0xC002), seed),
PitchId::new(ReplicaId(0xC002), seed),
100,
),
);
let mut forward = OperationSet::new();
forward.accept_all(vec![winner.clone(), loser.clone()]);
let mut backward = OperationSet::new();
backward.accept_all(vec![loser, winner]);
let expected = forward.reduce_onto(&base);
let actual = backward.reduce_onto(&base);
assert_eq!(actual, expected, "base seed {seed}");
assert!(
check_invariants(&actual.score).is_empty(),
"base seed {seed}"
);
}
}

View File

@ -4342,12 +4342,12 @@ pub enum VoiceOrigin {
Imported { format: ForeignFormatId },
/// System-promoted to resolve a concurrent-edit collision.
/// The cause operation is the InsertEvent (or similar) whose
/// reduction required voice allocation. The original_voice is
/// the voice into which the user had intended to insert; the
/// promoted voice carries the event that lost the collision.
/// The original_voice is the voice into which the user had
/// intended to insert; the promoted voice carries the event
/// authored by losing_operation.
SystemPromoted {
cause: OperationId,
winning_operation: OperationId,
losing_operation: OperationId,
original_voice: VoiceId,
},
}