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:
Levi Neuwirth 2026-06-25 14:31:24 -04:00
parent c47f4b5cec
commit f62f5d4276
8 changed files with 721 additions and 23 deletions

View File

@ -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)
}

View File

@ -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,

View File

@ -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));

View File

@ -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::*;

View File

@ -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 { .. }
));
}
}

View File

@ -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

View File

@ -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());
}

View File

@ -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),