From f62f5d42769558cb0e24f2fff3c43bbb62b983a3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 25 Jun 2026 14:31:24 -0400 Subject: [PATCH] Agent K M2b (Group 2): cross-cutting CRUD operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/epiphany-ops/src/fuzz.rs | 24 +- crates/epiphany-ops/src/lib.rs | 11 +- crates/epiphany-ops/src/migrate.rs | 20 +- crates/epiphany-ops/src/payload.rs | 55 ++++ crates/epiphany-ops/src/reduce.rs | 302 +++++++++++++++++- crates/epiphany-ops/src/v0.rs | 3 + crates/epiphany-ops/tests/graph_reduction.rs | 303 +++++++++++++++++++ crates/epiphany-testkit/src/generators.rs | 26 +- 8 files changed, 721 insertions(+), 23 deletions(-) diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index e9bdb67..6a9bc5d 100644 --- a/crates/epiphany-ops/src/fuzz.rs +++ b/crates/epiphany-ops/src/fuzz.rs @@ -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) } diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index c5ddc17..711bcea 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -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, diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index 8febdcc..e04acc4 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -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::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)); diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index a6876c8..bdebe2f 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -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) { + 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) { + self.structure.encode_canonical(out); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 1870c34..e647622 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -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, last_pitch_modify: BTreeMap, + // 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, structures: BTreeMap>, migrated_regions: BTreeSet, region_migrator: BTreeMap, @@ -346,6 +350,7 @@ struct WorkingSnapshot { last_respell: BTreeMap, last_event_modify: BTreeMap, last_pitch_modify: BTreeMap, + last_cross_cutting_modify: BTreeMap, structures: BTreeMap>, migrated_regions: BTreeSet, region_migrator: BTreeMap, @@ -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 { .. } + )); + } } diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs index fdd7fe9..4e7227e 100644 --- a/crates/epiphany-ops/src/v0.rs +++ b/crates/epiphany-ops/src/v0.rs @@ -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 diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index 4a361f5..d492615 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -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, +) { + 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()); +} diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index bdbe097..45debde 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -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),