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