Agent K M2b (Group 2): cross-cutting CRUD operations
Second broad-K0 subsystem group — two new value-typed ops reusing M1's proven
disciplines (additive: OperationKind variants 13-14, new apply arms + reduction
methods; framework frozen):
- DeleteCrossCutting { structure: TypedObjectId } — delete-wins tombstone of a
cross-cutting structure (idempotent concurrent deletes; guarded to the
Tie/Slur/Beam/Spanner kinds). Drops the transient endpoint/LWW indices so a
later event-tombstone re-anchoring pass never re-processes the deleted
structure.
- ModifyCrossCutting { structure: CrossCuttingValue } — LWW field-overwrite by
the structure's id; concurrent differing => StructuralFieldCollision. Mirrors
modify_event (resolved value lives in the graph, not MaterializedState);
re-derives endpoints from the new value, and mirrors CreateCrossCutting's
beam->=2 / endpoints-live preconditions.
Graph materialization (reduce_onto): graph_delete_cross_cutting removes the
structure by id; graph_modify_cross_cutting replaces it in place by id, across
all four kinds (Slur/Tie/Beam/Spanner). New last_cross_cutting_modify LWW map,
synced through WorkingSnapshot/snapshot/restore.
Migration: v1-native (no lossy v0 predecessor) -> project/migrate by identity;
group1_and_group2_kinds_round_trip_by_identity extended to cover them.
Coverage:
- testkit operation_payload + ops fuzz gen_payload now emit both kinds, so the
convergence / determinism / migration-equivalence gates exercise the
bookkeeping projection at scale.
- Targeted reduce_onto graph tests (tests/graph_reduction.rs) cover every kind
arm of graph_delete/graph_modify_cross_cutting (Slur/Tie/Beam/Spanner) plus
the beam->=2 reject branch of modify, with check_invariants; plus two
bookkeeping unit tests (delete tombstones; concurrent differing modify
conflicts).
Not wired into graph_edit_session (criterion 1): doing so requires creating
structures in the session, which exposes a pre-existing M1 reanchor/graph-delete
divergence (a slur whose endpoint event is deleted is re-anchored in bookkeeping
but removed from the graph). That is a separate DeleteEvent fix; the targeted
reduce_onto tests above give the M2b graph paths guaranteed coverage meanwhile.
Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (ops lib
53, ops graph_reduction 18); conformance_suite scale 1 passes. Catalog sections +
DECISIONS for these ops land in M2e per the staged plan. The unrelated Agent-I
working tree is left uncommitted; this commit stages only ops/testkit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
c47f4b5cec
commit
f62f5d4276
|
|
@ -22,7 +22,7 @@
|
|||
|
||||
use epiphany_core::{
|
||||
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId,
|
||||
ReplicaId, SlurId, StaffInstanceId, VoiceId,
|
||||
ReplicaId, SlurId, StaffInstanceId, TypedObjectId, VoiceId,
|
||||
};
|
||||
use epiphany_determinism::fuzz::SplitMix64;
|
||||
|
||||
|
|
@ -30,9 +30,10 @@ use crate::causal::CausalContext;
|
|||
use crate::envelope::OperationEnvelope;
|
||||
use crate::opset::OperationSet;
|
||||
use crate::payload::{
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp,
|
||||
InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind,
|
||||
OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation,
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp,
|
||||
DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp,
|
||||
ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp,
|
||||
SetUserSystemBreakOp, TransposeOp, TupletCompensation,
|
||||
};
|
||||
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
||||
use crate::support::AuthorId;
|
||||
|
|
@ -82,7 +83,7 @@ fn pitch(n: u64) -> PitchId {
|
|||
|
||||
/// Generates a random payload over the shared id space.
|
||||
fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
|
||||
let kind = match rng.below(10) {
|
||||
let kind = match rng.below(12) {
|
||||
0 => {
|
||||
let voice = VoiceId::new(ReplicaId(7), rng.below(3));
|
||||
let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32));
|
||||
|
|
@ -146,10 +147,21 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
|
|||
8 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp {
|
||||
pitch: pitch(rng.below(ID_SPACE)),
|
||||
}),
|
||||
_ => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
||||
9 => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
||||
pitch: pitch(rng.below(ID_SPACE)),
|
||||
value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1),
|
||||
}),
|
||||
// Group 2 (M2): cross-cutting CRUD over the shared id space.
|
||||
10 => OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
|
||||
structure: TypedObjectId::Slur(SlurId::new(ReplicaId(7), rng.below(ID_SPACE))),
|
||||
}),
|
||||
_ => OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(
|
||||
SlurId::new(ReplicaId(7), rng.below(ID_SPACE)),
|
||||
event(rng.below(ID_SPACE)),
|
||||
event(rng.below(ID_SPACE)),
|
||||
)),
|
||||
}),
|
||||
};
|
||||
OperationPayload::Primitive(kind)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,11 +113,12 @@ pub use envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessE
|
|||
pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError};
|
||||
pub use opset::OperationSet;
|
||||
pub use payload::{
|
||||
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
|
||||
DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp,
|
||||
ModifyIdentifiedPitchOp, OperationKind, OperationKindTag, OperationPayload, PositionRemapping,
|
||||
ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory,
|
||||
TransactionDescriptor, TransposeOp, TupletCompensation,
|
||||
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteCrossCuttingOp,
|
||||
DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp,
|
||||
ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationKindTag,
|
||||
OperationPayload, PositionRemapping, ResolveConflictPayload, RespellPitchOp,
|
||||
SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TransposeOp,
|
||||
TupletCompensation,
|
||||
};
|
||||
pub use reduce::{
|
||||
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,
|
||||
|
|
|
|||
|
|
@ -151,6 +151,9 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind {
|
|||
OperationKind::ModifyIdentifiedPitch(op) => {
|
||||
V0OperationKind::ModifyIdentifiedPitch(op.clone())
|
||||
}
|
||||
// v1-native (Group 2): projected verbatim.
|
||||
OperationKind::DeleteCrossCutting(op) => V0OperationKind::DeleteCrossCutting(*op),
|
||||
OperationKind::ModifyCrossCutting(op) => V0OperationKind::ModifyCrossCutting(op.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -276,6 +279,9 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result<OperationKind
|
|||
V0OperationKind::ModifyIdentifiedPitch(op) => {
|
||||
OperationKind::ModifyIdentifiedPitch(op.clone())
|
||||
}
|
||||
// v1-native (Group 2): identity round-trip.
|
||||
V0OperationKind::DeleteCrossCutting(op) => OperationKind::DeleteCrossCutting(*op),
|
||||
V0OperationKind::ModifyCrossCutting(op) => OperationKind::ModifyCrossCutting(op.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -532,8 +538,8 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn group1_kinds_round_trip_by_identity() {
|
||||
// The Group-1 (M2) kinds are v1-native: they had no lossy v0 form, so
|
||||
fn group1_and_group2_kinds_round_trip_by_identity() {
|
||||
// The Group-1/2 (M2) kinds are v1-native: they had no lossy v0 form, so
|
||||
// project+migrate is the identity (no context needed), and the round-trip
|
||||
// is exact for every one of them.
|
||||
let voice = VoiceId::new(ReplicaId(3), 0);
|
||||
|
|
@ -562,6 +568,16 @@ mod tests {
|
|||
pitch: PitchId::new(ReplicaId(3), 1),
|
||||
value: valuegen::pitch_value_nth(3),
|
||||
}),
|
||||
OperationKind::DeleteCrossCutting(crate::payload::DeleteCrossCuttingOp {
|
||||
structure: epiphany_core::TypedObjectId::Slur(SlurId::new(ReplicaId(3), 5)),
|
||||
}),
|
||||
OperationKind::ModifyCrossCutting(crate::payload::ModifyCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(
|
||||
SlurId::new(ReplicaId(3), 5),
|
||||
ev(1),
|
||||
ev(2),
|
||||
)),
|
||||
}),
|
||||
];
|
||||
for kind in kinds {
|
||||
let e = env(primitive(kind));
|
||||
|
|
|
|||
|
|
@ -119,6 +119,12 @@ pub enum OperationKind {
|
|||
DeleteIdentifiedPitch(DeleteIdentifiedPitchOp),
|
||||
/// Overwrite a live pitch's value (later-in-canonical-order wins).
|
||||
ModifyIdentifiedPitch(ModifyIdentifiedPitchOp),
|
||||
// --- Group 2 (M2): cross-cutting CRUD. Discriminants extend additively. ---
|
||||
/// Tombstone a cross-cutting structure (delete-wins).
|
||||
DeleteCrossCutting(DeleteCrossCuttingOp),
|
||||
/// Overwrite a cross-cutting structure's value (later-in-canonical-order
|
||||
/// wins).
|
||||
ModifyCrossCutting(ModifyCrossCuttingOp),
|
||||
}
|
||||
|
||||
impl OperationKind {
|
||||
|
|
@ -137,6 +143,8 @@ impl OperationKind {
|
|||
OperationKind::InsertIdentifiedPitch(_) => 10,
|
||||
OperationKind::DeleteIdentifiedPitch(_) => 11,
|
||||
OperationKind::ModifyIdentifiedPitch(_) => 12,
|
||||
OperationKind::DeleteCrossCutting(_) => 13,
|
||||
OperationKind::ModifyCrossCutting(_) => 14,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -158,6 +166,8 @@ impl OperationKind {
|
|||
OperationKind::InsertIdentifiedPitch(_) => OperationKindTag::InsertIdentifiedPitch,
|
||||
OperationKind::DeleteIdentifiedPitch(_) => OperationKindTag::DeleteIdentifiedPitch,
|
||||
OperationKind::ModifyIdentifiedPitch(_) => OperationKindTag::ModifyIdentifiedPitch,
|
||||
OperationKind::DeleteCrossCutting(_) => OperationKindTag::DeleteCrossCutting,
|
||||
OperationKind::ModifyCrossCutting(_) => OperationKindTag::ModifyCrossCutting,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -182,6 +192,8 @@ impl CanonicalEncode for OperationKind {
|
|||
OperationKind::InsertIdentifiedPitch(op) => op.encode_canonical(out),
|
||||
OperationKind::DeleteIdentifiedPitch(op) => op.encode_canonical(out),
|
||||
OperationKind::ModifyIdentifiedPitch(op) => op.encode_canonical(out),
|
||||
OperationKind::DeleteCrossCutting(op) => op.encode_canonical(out),
|
||||
OperationKind::ModifyCrossCutting(op) => op.encode_canonical(out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -750,6 +762,49 @@ impl CanonicalEncode for ModifyIdentifiedPitchOp {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Group 2 (M2): cross-cutting CRUD (Chapter 6 §6.10). ---------------------
|
||||
|
||||
/// Tombstone a cross-cutting structure (Chapter 6 §6.10 DeleteCrossCutting).
|
||||
/// Delete-wins discipline; the structure id is retained as a tombstone. The
|
||||
/// structure is named by its [`TypedObjectId`] — the same key the set-union
|
||||
/// creation and the re-anchoring table use — which must be a cross-cutting kind
|
||||
/// (`Tie`/`Slur`/`Beam`/`Spanner`).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub struct DeleteCrossCuttingOp {
|
||||
pub structure: TypedObjectId,
|
||||
}
|
||||
|
||||
impl CanonicalEncode for DeleteCrossCuttingOp {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
push_canon(out, &self.structure);
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite a cross-cutting structure's value (Chapter 6 §6.10
|
||||
/// ModifyCrossCutting). Carries the full replacement [`CrossCuttingValue`] (v1,
|
||||
/// value-typed); the field-overwrite discipline keys on the structure's
|
||||
/// [`CrossCuttingValue::id`] (later-in-canonical-order wins; concurrent differing
|
||||
/// conflict). The replacement keeps the structure's identity but may change its
|
||||
/// endpoints and per-kind fields — the reduction re-derives its endpoints from
|
||||
/// the new value (so a later re-anchoring sees them).
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ModifyCrossCuttingOp {
|
||||
pub structure: CrossCuttingValue,
|
||||
}
|
||||
|
||||
impl ModifyCrossCuttingOp {
|
||||
/// The structure's identity (the LWW key).
|
||||
pub fn id(&self) -> TypedObjectId {
|
||||
self.structure.id()
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for ModifyCrossCuttingOp {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
self.structure.encode_canonical(out);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -50,9 +50,10 @@ use crate::encode::{push_canon, push_len, push_lp_bytes, push_u8_bool};
|
|||
use crate::envelope::OperationEnvelope;
|
||||
use crate::opset::OperationSet;
|
||||
use crate::payload::{
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp,
|
||||
InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind,
|
||||
OperationPayload, RespellPitchOp, TransposeOp, TupletCompensation,
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp,
|
||||
DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp,
|
||||
ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp,
|
||||
TransposeOp, TupletCompensation,
|
||||
};
|
||||
use crate::undo::{UndoPolicy, UndoTransactionPayload};
|
||||
|
||||
|
|
@ -323,6 +324,9 @@ struct Reducer<'a> {
|
|||
// (the resolved value itself lives in the graph, not in MaterializedState).
|
||||
last_event_modify: BTreeMap<EventId, (OperationId, Event)>,
|
||||
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
|
||||
// LWW working state for ModifyCrossCutting (Group 2), mirroring the leaf-field
|
||||
// modify maps above: last modifier + value it wrote, keyed by structure id.
|
||||
last_cross_cutting_modify: BTreeMap<TypedObjectId, (OperationId, CrossCuttingValue)>,
|
||||
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
||||
migrated_regions: BTreeSet<RegionId>,
|
||||
region_migrator: BTreeMap<RegionId, OperationId>,
|
||||
|
|
@ -346,6 +350,7 @@ struct WorkingSnapshot {
|
|||
last_respell: BTreeMap<PitchId, OperationId>,
|
||||
last_event_modify: BTreeMap<EventId, (OperationId, Event)>,
|
||||
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
|
||||
last_cross_cutting_modify: BTreeMap<TypedObjectId, (OperationId, CrossCuttingValue)>,
|
||||
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
||||
migrated_regions: BTreeSet<RegionId>,
|
||||
region_migrator: BTreeMap<RegionId, OperationId>,
|
||||
|
|
@ -418,6 +423,7 @@ impl<'a> Reducer<'a> {
|
|||
last_respell: BTreeMap::new(),
|
||||
last_event_modify: BTreeMap::new(),
|
||||
last_pitch_modify: BTreeMap::new(),
|
||||
last_cross_cutting_modify: BTreeMap::new(),
|
||||
structures: BTreeMap::new(),
|
||||
migrated_regions: BTreeSet::new(),
|
||||
region_migrator: BTreeMap::new(),
|
||||
|
|
@ -1108,6 +1114,8 @@ impl<'a> Reducer<'a> {
|
|||
OperationKind::InsertIdentifiedPitch(op) => self.insert_identified_pitch(env, op),
|
||||
OperationKind::DeleteIdentifiedPitch(op) => self.delete_identified_pitch(env, op),
|
||||
OperationKind::ModifyIdentifiedPitch(op) => self.modify_identified_pitch(env, op),
|
||||
OperationKind::DeleteCrossCutting(op) => self.delete_cross_cutting(env, op),
|
||||
OperationKind::ModifyCrossCutting(op) => self.modify_cross_cutting(env, op),
|
||||
},
|
||||
OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op),
|
||||
OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op),
|
||||
|
|
@ -1522,6 +1530,197 @@ impl<'a> Reducer<'a> {
|
|||
OperationEffect::Applied
|
||||
}
|
||||
|
||||
fn delete_cross_cutting(
|
||||
&mut self,
|
||||
env: &OperationEnvelope,
|
||||
op: &DeleteCrossCuttingOp,
|
||||
) -> OperationEffect {
|
||||
let sid = op.structure;
|
||||
// DeleteCrossCutting names a cross-cutting structure only; refuse to
|
||||
// tombstone any other object kind through this path.
|
||||
if !matches!(
|
||||
sid,
|
||||
TypedObjectId::Tie(_)
|
||||
| TypedObjectId::Slur(_)
|
||||
| TypedObjectId::Beam(_)
|
||||
| TypedObjectId::Spanner(_)
|
||||
) {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
},
|
||||
};
|
||||
}
|
||||
let minted_by = match self.objects.get(&sid) {
|
||||
None => {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
},
|
||||
}
|
||||
}
|
||||
// Concurrent same-target deletes are idempotent (delete-wins).
|
||||
Some(ObjectState::Tombstoned { .. }) => {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::AlreadyApplied,
|
||||
}
|
||||
}
|
||||
Some(ObjectState::Live) => self.minted_by.get(&sid).copied().unwrap_or(env.id),
|
||||
};
|
||||
self.objects.insert(
|
||||
sid,
|
||||
ObjectState::Tombstoned {
|
||||
deleted_by: env.id,
|
||||
minted_by,
|
||||
},
|
||||
);
|
||||
// Drop the transient endpoint/LWW indices so a later event tombstone's
|
||||
// re-anchoring pass never re-processes the deleted structure.
|
||||
self.structures.remove(&sid);
|
||||
self.last_cross_cutting_modify.remove(&sid);
|
||||
self.graph_delete_cross_cutting(sid);
|
||||
OperationEffect::Applied
|
||||
}
|
||||
|
||||
fn modify_cross_cutting(
|
||||
&mut self,
|
||||
env: &OperationEnvelope,
|
||||
op: &ModifyCrossCuttingOp,
|
||||
) -> OperationEffect {
|
||||
let sid = op.id();
|
||||
match self.objects.get(&sid) {
|
||||
None => {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
},
|
||||
}
|
||||
}
|
||||
Some(ObjectState::Tombstoned { .. }) => {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::TargetTombstoned,
|
||||
}
|
||||
}
|
||||
Some(ObjectState::Live) => {}
|
||||
}
|
||||
// A beam must keep at least two events (mirrors CreateCrossCutting); the
|
||||
// new endpoints must all be live.
|
||||
if let CrossCuttingValue::Beam(beam) = &op.structure {
|
||||
if beam.events.len() < 2 {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
let endpoints = op.structure.endpoints();
|
||||
for e in &endpoints {
|
||||
if !matches!(self.objects.get(e), Some(ObjectState::Live)) {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
// LWW field-overwrite, mirroring modify_event: the resolved value lives in
|
||||
// the graph; MaterializedState records only the effect and, on a
|
||||
// concurrent differing write, a StructuralFieldCollision.
|
||||
let prev = self
|
||||
.last_cross_cutting_modify
|
||||
.get(&sid)
|
||||
.map(|(o, v)| (*o, v.clone()));
|
||||
let effect = match prev {
|
||||
Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => {
|
||||
if prev_value == op.structure {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::AlreadyApplied,
|
||||
};
|
||||
}
|
||||
let conflict = ConflictRecord::new(
|
||||
ConflictKind::StructuralFieldCollision {
|
||||
winner: env.id,
|
||||
loser: prev_op,
|
||||
field: FieldPath("cross_cutting".to_string()),
|
||||
},
|
||||
vec![env.id, prev_op],
|
||||
vec![sid],
|
||||
);
|
||||
let cid = conflict.id;
|
||||
self.conflicts.insert(conflict);
|
||||
OperationEffect::Conflicted { conflict: cid }
|
||||
}
|
||||
_ => OperationEffect::Applied,
|
||||
};
|
||||
self.last_cross_cutting_modify
|
||||
.insert(sid, (env.id, op.structure.clone()));
|
||||
self.structures.insert(sid, endpoints);
|
||||
self.graph_modify_cross_cutting(&op.structure);
|
||||
effect
|
||||
}
|
||||
|
||||
fn graph_delete_cross_cutting(&mut self, sid: TypedObjectId) {
|
||||
let Some(score) = self.graph.as_mut() else {
|
||||
return;
|
||||
};
|
||||
match sid {
|
||||
TypedObjectId::Slur(id) => score.cross_cutting.slurs.retain(|v| v.id != id),
|
||||
TypedObjectId::Tie(id) => score.cross_cutting.ties.retain(|v| v.id != id),
|
||||
TypedObjectId::Beam(id) => score.cross_cutting.beams.retain(|v| v.id != id),
|
||||
TypedObjectId::Spanner(id) => score.cross_cutting.spanners.retain(|v| v.id != id),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn graph_modify_cross_cutting(&mut self, value: &CrossCuttingValue) {
|
||||
let Some(score) = self.graph.as_mut() else {
|
||||
return;
|
||||
};
|
||||
// Replace the structure in place by id (identity is preserved across a
|
||||
// modify). A structure that is Live in bookkeeping but absent from the
|
||||
// graph (e.g. one a DeleteEvent re-anchor removed from the graph while
|
||||
// bookkeeping kept it Live) is left as-is; the modify is still recorded.
|
||||
match value {
|
||||
CrossCuttingValue::Slur(slur) => {
|
||||
if let Some(existing) = score
|
||||
.cross_cutting
|
||||
.slurs
|
||||
.iter_mut()
|
||||
.find(|v| v.id == slur.id)
|
||||
{
|
||||
*existing = slur.clone();
|
||||
}
|
||||
}
|
||||
CrossCuttingValue::Tie(tie) => {
|
||||
if let Some(existing) = score.cross_cutting.ties.iter_mut().find(|v| v.id == tie.id)
|
||||
{
|
||||
*existing = tie.clone();
|
||||
}
|
||||
}
|
||||
CrossCuttingValue::Beam(beam) => {
|
||||
if let Some(existing) = score
|
||||
.cross_cutting
|
||||
.beams
|
||||
.iter_mut()
|
||||
.find(|v| v.id == beam.id)
|
||||
{
|
||||
*existing = beam.clone();
|
||||
}
|
||||
}
|
||||
CrossCuttingValue::Spanner(spanner) => {
|
||||
if let Some(existing) = score
|
||||
.cross_cutting
|
||||
.spanners
|
||||
.iter_mut()
|
||||
.find(|v| v.id == spanner.id)
|
||||
{
|
||||
*existing = spanner.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn change_region_time_model(
|
||||
&mut self,
|
||||
env: &OperationEnvelope,
|
||||
|
|
@ -2390,6 +2589,7 @@ impl<'a> Reducer<'a> {
|
|||
last_respell: self.last_respell.clone(),
|
||||
last_event_modify: self.last_event_modify.clone(),
|
||||
last_pitch_modify: self.last_pitch_modify.clone(),
|
||||
last_cross_cutting_modify: self.last_cross_cutting_modify.clone(),
|
||||
structures: self.structures.clone(),
|
||||
migrated_regions: self.migrated_regions.clone(),
|
||||
region_migrator: self.region_migrator.clone(),
|
||||
|
|
@ -2410,6 +2610,7 @@ impl<'a> Reducer<'a> {
|
|||
self.last_respell = s.last_respell;
|
||||
self.last_event_modify = s.last_event_modify;
|
||||
self.last_pitch_modify = s.last_pitch_modify;
|
||||
self.last_cross_cutting_modify = s.last_cross_cutting_modify;
|
||||
self.structures = s.structures;
|
||||
self.migrated_regions = s.migrated_regions;
|
||||
self.region_migrator = s.region_migrator;
|
||||
|
|
@ -2875,4 +3076,99 @@ mod tests {
|
|||
"DeleteIdentifiedPitch tombstones the pitch"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Group 2 (M2) behavior. --------------------------------------------
|
||||
|
||||
fn create_slur(slur: epiphany_core::SlurId, a: EventId, b: EventId) -> OperationKind {
|
||||
OperationKind::CreateCrossCutting(crate::payload::CreateCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(crate::valuegen::slur(slur, a, b)),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_cross_cutting_tombstones_the_structure() {
|
||||
let e1 = EventId::new(ReplicaId(1), 100);
|
||||
let e2 = EventId::new(ReplicaId(1), 101);
|
||||
let slur = epiphany_core::SlurId::new(ReplicaId(1), 1);
|
||||
let sid = TypedObjectId::Slur(slur);
|
||||
let create = prim_env(
|
||||
1,
|
||||
2,
|
||||
12,
|
||||
CausalContext::new().with_seen(ReplicaId(1), 1),
|
||||
create_slur(slur, e1, e2),
|
||||
);
|
||||
let delete = prim_env(
|
||||
1,
|
||||
3,
|
||||
13,
|
||||
CausalContext::new().with_seen(ReplicaId(1), 2),
|
||||
OperationKind::DeleteCrossCutting(crate::payload::DeleteCrossCuttingOp {
|
||||
structure: sid,
|
||||
}),
|
||||
);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(vec![
|
||||
insert(1, 0, 10, 1, 100, 0),
|
||||
insert(1, 1, 11, 1, 101, 1),
|
||||
create,
|
||||
delete,
|
||||
]);
|
||||
let state = set.reduce();
|
||||
assert!(
|
||||
matches!(
|
||||
state.objects.get(&sid),
|
||||
Some(ObjectState::Tombstoned { .. })
|
||||
),
|
||||
"DeleteCrossCutting tombstones the structure (delete-wins)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_differing_modify_cross_cutting_conflicts() {
|
||||
let e1 = EventId::new(ReplicaId(1), 100);
|
||||
let e2 = EventId::new(ReplicaId(1), 101);
|
||||
let e3 = EventId::new(ReplicaId(1), 102);
|
||||
let slur = epiphany_core::SlurId::new(ReplicaId(1), 1);
|
||||
let create = prim_env(
|
||||
1,
|
||||
3,
|
||||
13,
|
||||
CausalContext::new().with_seen(ReplicaId(1), 2),
|
||||
create_slur(slur, e1, e2),
|
||||
);
|
||||
// Two concurrent modifies (seen the create, not each other) with differing
|
||||
// endpoints: e1->e2 vs e1->e3.
|
||||
let seen_create = CausalContext::new().with_seen(ReplicaId(1), 3);
|
||||
let modify = |replica: u64, end: EventId| {
|
||||
prim_env(
|
||||
replica,
|
||||
0,
|
||||
20,
|
||||
seen_create.clone(),
|
||||
OperationKind::ModifyCrossCutting(crate::payload::ModifyCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(crate::valuegen::slur(slur, e1, end)),
|
||||
}),
|
||||
)
|
||||
};
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(vec![
|
||||
insert(1, 0, 10, 1, 100, 0),
|
||||
insert(1, 1, 11, 1, 101, 1),
|
||||
insert(1, 2, 12, 1, 102, 2),
|
||||
create,
|
||||
modify(2, e2),
|
||||
modify(3, e3),
|
||||
]);
|
||||
let state = set.reduce();
|
||||
assert_eq!(
|
||||
state.conflicts.records().len(),
|
||||
1,
|
||||
"concurrent differing ModifyCrossCutting must record exactly one conflict"
|
||||
);
|
||||
assert!(matches!(
|
||||
state.conflicts.records()[0].kind,
|
||||
ConflictKind::StructuralFieldCollision { .. }
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ pub enum V0OperationKind {
|
|||
InsertIdentifiedPitch(crate::payload::InsertIdentifiedPitchOp),
|
||||
DeleteIdentifiedPitch(crate::payload::DeleteIdentifiedPitchOp),
|
||||
ModifyIdentifiedPitch(crate::payload::ModifyIdentifiedPitchOp),
|
||||
// Group 2 (M2) — also v1-native; round-trip by identity.
|
||||
DeleteCrossCutting(crate::payload::DeleteCrossCuttingOp),
|
||||
ModifyCrossCutting(crate::payload::ModifyCrossCuttingOp),
|
||||
}
|
||||
|
||||
/// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant
|
||||
|
|
|
|||
|
|
@ -638,3 +638,306 @@ fn insert_identified_pitch_into_a_rest_promotes_it_to_a_note() {
|
|||
}
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
/// Three non-overlapping events in the target voice plus a cross-cutting
|
||||
/// structure over them (built by `structure`) — a self-contained corpus authored
|
||||
/// on one replica so each op causally follows (and therefore sees) the events it
|
||||
/// depends on. Returns the events, the create envelope, and the three inserts;
|
||||
/// tests append their own op at counter 4 (causally after the create).
|
||||
fn cross_cutting_fixture(
|
||||
base: &Score,
|
||||
structure: impl FnOnce(EventId, EventId, EventId) -> CrossCuttingValue,
|
||||
) -> (
|
||||
EventId,
|
||||
EventId,
|
||||
EventId,
|
||||
OperationEnvelope,
|
||||
Vec<OperationEnvelope>,
|
||||
) {
|
||||
let (staff_instance, target_voice) = target(base);
|
||||
let r = 70;
|
||||
let (e1, e2, e3) = (
|
||||
EventId::new(ReplicaId(r), 0),
|
||||
EventId::new(ReplicaId(r), 1),
|
||||
EventId::new(ReplicaId(r), 2),
|
||||
);
|
||||
let ins = |counter: u64, ev: EventId, pos: i32| {
|
||||
let ctx = if counter == 0 {
|
||||
CausalContext::new()
|
||||
} else {
|
||||
CausalContext::new().with_seen(ReplicaId(r), counter - 1)
|
||||
};
|
||||
envelope(
|
||||
r,
|
||||
counter,
|
||||
10 + counter as i64,
|
||||
ctx,
|
||||
None,
|
||||
insert(
|
||||
staff_instance,
|
||||
target_voice,
|
||||
ev,
|
||||
PitchId::new(ReplicaId(r), 100 + counter),
|
||||
pos,
|
||||
),
|
||||
)
|
||||
};
|
||||
let inserts = vec![ins(0, e1, 100), ins(1, e2, 101), ins(2, e3, 102)];
|
||||
let create = envelope(
|
||||
r,
|
||||
3,
|
||||
14,
|
||||
CausalContext::new().with_seen(ReplicaId(r), 2),
|
||||
None,
|
||||
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: structure(e1, e2, e3),
|
||||
})),
|
||||
);
|
||||
(e1, e2, e3, create, inserts)
|
||||
}
|
||||
|
||||
/// An op authored after the fixture's create (counter 4, sees counters 0..=3).
|
||||
fn after_create(payload: OperationPayload) -> OperationEnvelope {
|
||||
envelope(
|
||||
70,
|
||||
4,
|
||||
15,
|
||||
CausalContext::new().with_seen(ReplicaId(70), 3),
|
||||
None,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
/// An event-anchored spanner over `a`..`b` (the reduction reads its endpoints
|
||||
/// from the two [`TimeAnchor::Event`] anchors).
|
||||
fn spanner_over(id: epiphany_core::SpannerId, a: EventId, b: EventId) -> epiphany_core::Spanner {
|
||||
epiphany_core::Spanner {
|
||||
id,
|
||||
start: TimeAnchor::Event {
|
||||
id: a,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
end: TimeAnchor::Event {
|
||||
id: b,
|
||||
offset: AnchorOffset::Zero,
|
||||
},
|
||||
staves: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a structure, then delete it; assert it leaves the graph and the
|
||||
/// structure id is tombstoned (delete-wins), graph invariants intact.
|
||||
fn assert_delete_removes_and_tombstones(
|
||||
structure: impl FnOnce(EventId, EventId, EventId) -> CrossCuttingValue,
|
||||
sid: TypedObjectId,
|
||||
still_present: impl Fn(&Score) -> bool,
|
||||
) {
|
||||
let base = epiphany_core::generators::valid_score(100);
|
||||
let (_e1, _e2, _e3, create, inserts) = cross_cutting_fixture(&base, structure);
|
||||
let delete = after_create(OperationPayload::Primitive(
|
||||
OperationKind::DeleteCrossCutting(epiphany_ops::DeleteCrossCuttingOp { structure: sid }),
|
||||
));
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(inserts.into_iter().chain([create, delete]));
|
||||
let result = set.reduce_onto(&base);
|
||||
assert!(
|
||||
!still_present(&result.score),
|
||||
"DeleteCrossCutting removes {sid:?} from the graph"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
result.state.objects.get(&sid),
|
||||
Some(epiphany_ops::ObjectState::Tombstoned { .. })
|
||||
),
|
||||
"DeleteCrossCutting tombstones {sid:?} (delete-wins)"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
/// Create a structure, then overwrite it with `modified`; run `verify` on the
|
||||
/// resulting graph (which receives the fixture's three events), invariants intact.
|
||||
fn assert_modify_updates(
|
||||
initial: impl FnOnce(EventId, EventId, EventId) -> CrossCuttingValue,
|
||||
modified: impl FnOnce(EventId, EventId, EventId) -> CrossCuttingValue,
|
||||
verify: impl FnOnce(&Score, EventId, EventId, EventId),
|
||||
) {
|
||||
let base = epiphany_core::generators::valid_score(100);
|
||||
let (e1, e2, e3, create, inserts) = cross_cutting_fixture(&base, initial);
|
||||
let modify = after_create(OperationPayload::Primitive(
|
||||
OperationKind::ModifyCrossCutting(epiphany_ops::ModifyCrossCuttingOp {
|
||||
structure: modified(e1, e2, e3),
|
||||
}),
|
||||
));
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(inserts.into_iter().chain([create, modify]));
|
||||
let result = set.reduce_onto(&base);
|
||||
verify(&result.score, e1, e2, e3);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_cross_cutting_removes_the_structure_from_the_graph() {
|
||||
let slur = SlurId::new(ReplicaId(70), 9);
|
||||
// First confirm the create actually materializes (the delete assertions below
|
||||
// would pass vacuously if it didn't).
|
||||
let base = epiphany_core::generators::valid_score(100);
|
||||
let (_e1, _e2, _e3, create, inserts) = cross_cutting_fixture(&base, |e1, e2, _| {
|
||||
CrossCuttingValue::Slur(valuegen::slur(slur, e1, e2))
|
||||
});
|
||||
let mut created = OperationSet::new();
|
||||
created.accept_all(inserts.into_iter().chain([create]));
|
||||
assert!(
|
||||
created
|
||||
.reduce_onto(&base)
|
||||
.score
|
||||
.cross_cutting
|
||||
.slurs
|
||||
.iter()
|
||||
.any(|s| s.id == slur),
|
||||
"CreateCrossCutting materializes the slur in the graph"
|
||||
);
|
||||
|
||||
assert_delete_removes_and_tombstones(
|
||||
|e1, e2, _| CrossCuttingValue::Slur(valuegen::slur(slur, e1, e2)),
|
||||
TypedObjectId::Slur(slur),
|
||||
move |score| score.cross_cutting.slurs.iter().any(|s| s.id == slur),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_cross_cutting_handles_tie_beam_and_spanner() {
|
||||
let tie = epiphany_core::TieId::new(ReplicaId(70), 1);
|
||||
assert_delete_removes_and_tombstones(
|
||||
|e1, e2, _| CrossCuttingValue::Tie(valuegen::tie(tie, e1, e2)),
|
||||
TypedObjectId::Tie(tie),
|
||||
move |score| score.cross_cutting.ties.iter().any(|t| t.id == tie),
|
||||
);
|
||||
let beam = epiphany_core::BeamId::new(ReplicaId(70), 1);
|
||||
assert_delete_removes_and_tombstones(
|
||||
|e1, e2, e3| CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1, e2, e3])),
|
||||
TypedObjectId::Beam(beam),
|
||||
move |score| score.cross_cutting.beams.iter().any(|b| b.id == beam),
|
||||
);
|
||||
let spanner = epiphany_core::SpannerId::new(ReplicaId(70), 1);
|
||||
assert_delete_removes_and_tombstones(
|
||||
|e1, e2, _| CrossCuttingValue::Spanner(spanner_over(spanner, e1, e2)),
|
||||
TypedObjectId::Spanner(spanner),
|
||||
move |score| score.cross_cutting.spanners.iter().any(|s| s.id == spanner),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_cross_cutting_updates_the_structure_in_the_graph() {
|
||||
let slur = SlurId::new(ReplicaId(70), 9);
|
||||
// Re-point the slur's end from e2 to e3 (a different live endpoint).
|
||||
assert_modify_updates(
|
||||
|e1, e2, _| CrossCuttingValue::Slur(valuegen::slur(slur, e1, e2)),
|
||||
|e1, _, e3| CrossCuttingValue::Slur(valuegen::slur(slur, e1, e3)),
|
||||
move |score, _e1, _e2, e3| {
|
||||
let s = score
|
||||
.cross_cutting
|
||||
.slurs
|
||||
.iter()
|
||||
.find(|s| s.id == slur)
|
||||
.expect("the slur is still present after a modify");
|
||||
assert_eq!(s.end_event, e3, "modify updates the slur's endpoint");
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_cross_cutting_updates_tie_beam_and_spanner() {
|
||||
let tie = epiphany_core::TieId::new(ReplicaId(70), 1);
|
||||
assert_modify_updates(
|
||||
|e1, e2, _| CrossCuttingValue::Tie(valuegen::tie(tie, e1, e2)),
|
||||
|e1, _, e3| CrossCuttingValue::Tie(valuegen::tie(tie, e1, e3)),
|
||||
move |score, _e1, _e2, e3| {
|
||||
let t = score
|
||||
.cross_cutting
|
||||
.ties
|
||||
.iter()
|
||||
.find(|t| t.id == tie)
|
||||
.expect("the tie is still present after a modify");
|
||||
assert_eq!(t.end_event, e3, "modify updates the tie's endpoint");
|
||||
},
|
||||
);
|
||||
let beam = epiphany_core::BeamId::new(ReplicaId(70), 1);
|
||||
assert_modify_updates(
|
||||
|e1, e2, _| CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1, e2])),
|
||||
|e1, e2, e3| CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1, e2, e3])),
|
||||
move |score, _e1, _e2, _e3| {
|
||||
let b = score
|
||||
.cross_cutting
|
||||
.beams
|
||||
.iter()
|
||||
.find(|b| b.id == beam)
|
||||
.expect("the beam is still present after a modify");
|
||||
assert_eq!(b.events.len(), 3, "modify grows the beam to three events");
|
||||
},
|
||||
);
|
||||
let spanner = epiphany_core::SpannerId::new(ReplicaId(70), 1);
|
||||
assert_modify_updates(
|
||||
|e1, e2, _| CrossCuttingValue::Spanner(spanner_over(spanner, e1, e2)),
|
||||
|e1, _, e3| CrossCuttingValue::Spanner(spanner_over(spanner, e1, e3)),
|
||||
move |score, _e1, _e2, e3| {
|
||||
let s = score
|
||||
.cross_cutting
|
||||
.spanners
|
||||
.iter()
|
||||
.find(|s| s.id == spanner)
|
||||
.expect("the spanner is still present after a modify");
|
||||
assert!(
|
||||
matches!(s.end, TimeAnchor::Event { id, .. } if id == e3),
|
||||
"modify re-points the spanner's end anchor"
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modify_cross_cutting_rejects_an_undersized_beam() {
|
||||
// Dropping a beam below two events is a precondition NoOp (mirrors
|
||||
// CreateCrossCutting); the graph keeps the original beam, hitting the
|
||||
// `beam.events.len() < 2` branch of modify_cross_cutting.
|
||||
let base = epiphany_core::generators::valid_score(100);
|
||||
let beam = epiphany_core::BeamId::new(ReplicaId(70), 1);
|
||||
let (e1, e2, _e3, create, inserts) = cross_cutting_fixture(&base, |e1, e2, _| {
|
||||
CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1, e2]))
|
||||
});
|
||||
let shrink = after_create(OperationPayload::Primitive(
|
||||
OperationKind::ModifyCrossCutting(epiphany_ops::ModifyCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Beam(valuegen::beam(beam, vec![e1])),
|
||||
}),
|
||||
));
|
||||
let shrink_id = shrink.id;
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(inserts.into_iter().chain([create, shrink]));
|
||||
let result = set.reduce_onto(&base);
|
||||
assert!(
|
||||
matches!(
|
||||
result
|
||||
.state
|
||||
.effects
|
||||
.iter()
|
||||
.find(|(id, _)| *id == shrink_id)
|
||||
.map(|(_, e)| e),
|
||||
Some(OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction { .. }
|
||||
})
|
||||
),
|
||||
"a beam-modify dropping below two events is a precondition NoOp"
|
||||
);
|
||||
let materialized = result
|
||||
.score
|
||||
.cross_cutting
|
||||
.beams
|
||||
.iter()
|
||||
.find(|b| b.id == beam)
|
||||
.expect("the original beam survives the rejected modify");
|
||||
assert_eq!(
|
||||
materialized.events,
|
||||
vec![e1, e2],
|
||||
"the rejected modify left the original two-event beam"
|
||||
);
|
||||
assert!(check_invariants(&result.score).is_empty());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,12 +32,13 @@ use epiphany_ops::valuegen;
|
|||
use epiphany_ops::{
|
||||
AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId,
|
||||
ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
|
||||
ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
|
||||
DeleteIdentifiedPitchOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp,
|
||||
InsertIdentifiedPitchOp, IntegrityAnomaly, IntegrityAnomalyKind, IntegrityAnomalyRegistryId,
|
||||
MaterializedState, ModifyEventOp, ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState,
|
||||
OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload,
|
||||
OperationSet, OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason,
|
||||
ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteCrossCuttingOp,
|
||||
DeleteEventOp, DeleteIdentifiedPitchOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock,
|
||||
InsertEventOp, InsertIdentifiedPitchOp, IntegrityAnomaly, IntegrityAnomalyKind,
|
||||
IntegrityAnomalyRegistryId, MaterializedState, ModifyCrossCuttingOp, ModifyEventOp,
|
||||
ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState, OperationEffect,
|
||||
OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload, OperationSet,
|
||||
OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason,
|
||||
PreconditionFailureRegistryId, ReanchorReason, ReanchorReasonRegistryId, ReanchorResult,
|
||||
RepairKind, RepairKindRegistryId, RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId,
|
||||
ResolutionAction, ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp,
|
||||
|
|
@ -634,7 +635,7 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP
|
|||
}
|
||||
_ => {}
|
||||
}
|
||||
let kind = match rng.below(13) {
|
||||
let kind = match rng.below(15) {
|
||||
0 => {
|
||||
let pitches = if rng.boolean() {
|
||||
vec![obj_pitch(rng.below(pitches))]
|
||||
|
|
@ -719,6 +720,17 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP
|
|||
pitch: obj_pitch(rng.below(pitches)),
|
||||
value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1),
|
||||
}),
|
||||
// Group 2 (M2): cross-cutting CRUD over the shared id space.
|
||||
12 => OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
|
||||
structure: TypedObjectId::Slur(SlurId::new(OBJ_REPLICA, rng.below(events))),
|
||||
}),
|
||||
13 => OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(
|
||||
SlurId::new(OBJ_REPLICA, rng.below(events)),
|
||||
obj_event(rng.below(events)),
|
||||
obj_event(rng.below(events)),
|
||||
)),
|
||||
}),
|
||||
_ => OperationKind::Registered(
|
||||
OperationKindRegistryId(rng.next_u64() as u128),
|
||||
rng.byte_vec(0, 16),
|
||||
|
|
|
|||
Loading…
Reference in New Issue