From a207077cd77f664018734ec535a964104a49a923 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 25 Jun 2026 18:10:15 -0400 Subject: [PATCH] Agent K M2c (Group 3): structural container CRUD operations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third broad-K0 subsystem group — six new value-typed ops for the score-graph containers, reusing M1's disciplines (additive: OperationKind variants 15-20, new apply arms + reduction methods; framework frozen). Per the project lead's call, container deletes are EMPTY-ONLY (no cascade): a precondition NoOp unless the container has no live children, so the caller deletes contents first. - CreateRegion / DeleteRegion, CreateStaffInstance / DeleteStaffInstance, CreateVoice / DeleteVoice. Creates are value-typed mints of an empty container (set-union creation); deletes are delete-wins tombstones gated on emptiness. - core: expose Region / StaffInstance / Voice via CanonicalValue (no new byte layout — they already have whole-score Codec impls), with round-trip coverage. - New PreconditionFailureReason::ContainerNotEmpty (additive discriminant 10; encode + decode), reported when an empty-only delete hits a non-empty container. Reduction (reduce.rs): - Two child-existence indices, region_instances and instance_voices, drive the emptiness checks (a voice's events are read from voice_occupancy), so the ledger projection and the graph agree on every delete result. Populated by seed_from_graph, the create ops, and implicit voice creation in insert_event. - CreateStaffInstance / DeleteStaffInstance maintain the region's staff_extent so it lists exactly the manifested staves (Chapter 5 RegionExtents); valuegen's fresh region uses a far-future wall-clock extent so it never overlaps an existing region in both time and staff. Migration: v1-native (no lossy v0 predecessor) -> project/migrate by identity; group1_and_group2_kinds_round_trip_by_identity extended to cover Group 3. Coverage: - testkit operation_payload + ops fuzz gen_payload emit the six kinds, so the convergence / determinism / migration-equivalence gates exercise the bookkeeping projection at scale. - A reduce_onto graph test materializes a region -> staff instance -> voice subtree (invariant-clean), asserts the empty-only delete refuses a non-empty container with ContainerNotEmpty, and verifies an ordered teardown clears the subtree from both graph and ledger. Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (criterion 1 green with the new container objects in the corpus); 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 core/ops/testkit. Co-Authored-By: Claude Opus 4.8 --- crates/epiphany-core/src/codec.rs | 11 + crates/epiphany-ops/src/decode.rs | 1 + crates/epiphany-ops/src/effect.rs | 6 + crates/epiphany-ops/src/fuzz.rs | 39 ++- crates/epiphany-ops/src/lib.rs | 5 +- crates/epiphany-ops/src/migrate.rs | 37 +++ crates/epiphany-ops/src/payload.rs | 153 ++++++++- crates/epiphany-ops/src/reduce.rs | 325 ++++++++++++++++++- crates/epiphany-ops/src/v0.rs | 7 + crates/epiphany-ops/src/valuegen.rs | 65 +++- crates/epiphany-ops/tests/graph_reduction.rs | 183 +++++++++++ crates/epiphany-testkit/src/generators.rs | 37 ++- 12 files changed, 841 insertions(+), 28 deletions(-) diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 3b96ecb..b9bf4a6 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -2093,6 +2093,10 @@ canonical_value! { Spanner, RegionTimeModel, TimeAnchor, + // Structural containers (M2c) — value-typed create payloads embed these. + Region, + StaffInstance, + Voice, } #[cfg(test)] @@ -2126,6 +2130,13 @@ mod value_codec_tests { let s = valid_score_rich(seed.wrapping_mul(0x0100_0193).wrapping_add(7)); for region in &s.canvas.regions { assert_value_round_trips(®ion.time_model); + assert_value_round_trips(region); + for instance in region.staff_instances() { + assert_value_round_trips(instance); + for voice in &instance.voices { + assert_value_round_trips(voice); + } + } } for tie in &s.cross_cutting.ties { assert_value_round_trips(tie); diff --git a/crates/epiphany-ops/src/decode.rs b/crates/epiphany-ops/src/decode.rs index 08c2793..71f48fd 100644 --- a/crates/epiphany-ops/src/decode.rs +++ b/crates/epiphany-ops/src/decode.rs @@ -218,6 +218,7 @@ fn precondition_reason(reader: &mut Reader<'_>) -> Result Ok(PreconditionFailureReason::ContainerNotEmpty), tag => Err(MaterializedDecodeError::InvalidTag { kind: "PreconditionFailureReason", tag, diff --git a/crates/epiphany-ops/src/effect.rs b/crates/epiphany-ops/src/effect.rs index 4b443b0..33cc678 100644 --- a/crates/epiphany-ops/src/effect.rs +++ b/crates/epiphany-ops/src/effect.rs @@ -132,6 +132,10 @@ pub enum PreconditionFailureReason { PitchSpaceMismatch, /// The operation targeted a voice that does not exist or is tombstoned. VoiceMissing, + /// A structural-container delete (Group 3) targeted a container that still + /// has live children (an empty-only delete; the caller deletes contents + /// first). + ContainerNotEmpty, /// An extension-declared precondition failed. ExtensionPrecondition(ExtensionPreconditionId), /// A registered precondition code from a versioned registry. @@ -151,6 +155,8 @@ impl PreconditionFailureReason { PreconditionFailureReason::VoiceMissing => 7, PreconditionFailureReason::ExtensionPrecondition(_) => 8, PreconditionFailureReason::Registered(_) => 9, + // Additive (Group 3); keeps the ratified 0..=9 discriminants stable. + PreconditionFailureReason::ContainerNotEmpty => 10, } } } diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index 6a9bc5d..845adca 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, TypedObjectId, VoiceId, + ReplicaId, SlurId, StaffId, StaffInstanceId, TypedObjectId, VoiceId, }; use epiphany_determinism::fuzz::SplitMix64; @@ -30,10 +30,11 @@ use crate::causal::CausalContext; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - CreateCrossCuttingOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, - DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, - ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, - SetUserSystemBreakOp, TransposeOp, TupletCompensation, + CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateVoiceOp, CrossCuttingValue, + DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, + DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, + ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, + RespellPitchOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation, }; use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::support::AuthorId; @@ -83,7 +84,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(12) { + let kind = match rng.below(18) { 0 => { let voice = VoiceId::new(ReplicaId(7), rng.below(3)); let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32)); @@ -155,13 +156,37 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { 10 => OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp { structure: TypedObjectId::Slur(SlurId::new(ReplicaId(7), rng.below(ID_SPACE))), }), - _ => OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp { + 11 => 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)), )), }), + // Group 3 (M2c): structural container CRUD over the shared id space. + 12 => OperationKind::CreateRegion(CreateRegionOp { + region: valuegen::region(RegionId::new(ReplicaId(7), rng.below(3))), + }), + 13 => OperationKind::DeleteRegion(DeleteRegionOp { + region: RegionId::new(ReplicaId(7), rng.below(3)), + }), + 14 => OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region: RegionId::new(ReplicaId(7), rng.below(3)), + instance: valuegen::staff_instance( + StaffInstanceId::new(ReplicaId(7), rng.below(3)), + StaffId::new(ReplicaId(7), 0), + ), + }), + 15 => OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp { + staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), + }), + 16 => OperationKind::CreateVoice(CreateVoiceOp { + staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), + voice: valuegen::voice(VoiceId::new(ReplicaId(7), rng.below(3))), + }), + _ => OperationKind::DeleteVoice(DeleteVoiceOp { + voice: VoiceId::new(ReplicaId(7), rng.below(3)), + }), }; OperationPayload::Primitive(kind) } diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 711bcea..083ba98 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -113,8 +113,9 @@ 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, DeleteCrossCuttingOp, - DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, + ChangeRegionTimeModelOp, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, + CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, + DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationKindTag, OperationPayload, PositionRemapping, ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TransposeOp, diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index e04acc4..b87d6e7 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -154,6 +154,13 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind { // v1-native (Group 2): projected verbatim. OperationKind::DeleteCrossCutting(op) => V0OperationKind::DeleteCrossCutting(*op), OperationKind::ModifyCrossCutting(op) => V0OperationKind::ModifyCrossCutting(op.clone()), + // v1-native (Group 3): projected verbatim. + OperationKind::CreateRegion(op) => V0OperationKind::CreateRegion(op.clone()), + OperationKind::DeleteRegion(op) => V0OperationKind::DeleteRegion(*op), + OperationKind::CreateStaffInstance(op) => V0OperationKind::CreateStaffInstance(op.clone()), + OperationKind::DeleteStaffInstance(op) => V0OperationKind::DeleteStaffInstance(*op), + OperationKind::CreateVoice(op) => V0OperationKind::CreateVoice(op.clone()), + OperationKind::DeleteVoice(op) => V0OperationKind::DeleteVoice(*op), } } @@ -282,6 +289,13 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result OperationKind::DeleteCrossCutting(*op), V0OperationKind::ModifyCrossCutting(op) => OperationKind::ModifyCrossCutting(op.clone()), + // v1-native (Group 3): identity round-trip. + V0OperationKind::CreateRegion(op) => OperationKind::CreateRegion(op.clone()), + V0OperationKind::DeleteRegion(op) => OperationKind::DeleteRegion(*op), + V0OperationKind::CreateStaffInstance(op) => OperationKind::CreateStaffInstance(op.clone()), + V0OperationKind::DeleteStaffInstance(op) => OperationKind::DeleteStaffInstance(*op), + V0OperationKind::CreateVoice(op) => OperationKind::CreateVoice(op.clone()), + V0OperationKind::DeleteVoice(op) => OperationKind::DeleteVoice(*op), }) } @@ -578,6 +592,29 @@ mod tests { ev(2), )), }), + OperationKind::CreateRegion(crate::payload::CreateRegionOp { + region: valuegen::region(epiphany_core::RegionId::new(ReplicaId(3), 7)), + }), + OperationKind::DeleteRegion(crate::payload::DeleteRegionOp { + region: epiphany_core::RegionId::new(ReplicaId(3), 7), + }), + OperationKind::CreateStaffInstance(crate::payload::CreateStaffInstanceOp { + region: epiphany_core::RegionId::new(ReplicaId(3), 7), + instance: valuegen::staff_instance( + epiphany_core::StaffInstanceId::new(ReplicaId(3), 8), + StaffId::new(ReplicaId(3), 1), + ), + }), + OperationKind::DeleteStaffInstance(crate::payload::DeleteStaffInstanceOp { + staff_instance: epiphany_core::StaffInstanceId::new(ReplicaId(3), 8), + }), + OperationKind::CreateVoice(crate::payload::CreateVoiceOp { + staff_instance: epiphany_core::StaffInstanceId::new(ReplicaId(3), 8), + voice: valuegen::voice(VoiceId::new(ReplicaId(3), 9)), + }), + OperationKind::DeleteVoice(crate::payload::DeleteVoiceOp { + voice: VoiceId::new(ReplicaId(3), 9), + }), ]; 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 bdebe2f..3c8cc91 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -33,9 +33,9 @@ use epiphany_core::{ Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch, - MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, RegionId, RegionTimeModel, - Rest, Slur, Spanner, StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, - VoiceId, + MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, Region, RegionId, + RegionTimeModel, Rest, Slur, Spanner, StaffInstance, StaffInstanceId, Tie, TimeAnchor, + TransactionId, TupletId, TypedObjectId, Voice, VoiceId, }; use epiphany_determinism::{sorted_canonical, CanonicalEncode}; @@ -125,6 +125,19 @@ pub enum OperationKind { /// Overwrite a cross-cutting structure's value (later-in-canonical-order /// wins). ModifyCrossCutting(ModifyCrossCuttingOp), + // --- Group 3 (M2c): structural container CRUD. Mint + empty-only delete. --- + /// Mint an empty region into the canvas. + CreateRegion(CreateRegionOp), + /// Tombstone an empty region (delete-wins; precondition: no live instances). + DeleteRegion(DeleteRegionOp), + /// Mint an empty staff instance into a live region. + CreateStaffInstance(CreateStaffInstanceOp), + /// Tombstone an empty staff instance (precondition: no live voices). + DeleteStaffInstance(DeleteStaffInstanceOp), + /// Mint an empty voice into a live staff instance. + CreateVoice(CreateVoiceOp), + /// Tombstone an empty voice (precondition: no live events). + DeleteVoice(DeleteVoiceOp), } impl OperationKind { @@ -145,6 +158,12 @@ impl OperationKind { OperationKind::ModifyIdentifiedPitch(_) => 12, OperationKind::DeleteCrossCutting(_) => 13, OperationKind::ModifyCrossCutting(_) => 14, + OperationKind::CreateRegion(_) => 15, + OperationKind::DeleteRegion(_) => 16, + OperationKind::CreateStaffInstance(_) => 17, + OperationKind::DeleteStaffInstance(_) => 18, + OperationKind::CreateVoice(_) => 19, + OperationKind::DeleteVoice(_) => 20, } } @@ -168,6 +187,12 @@ impl OperationKind { OperationKind::ModifyIdentifiedPitch(_) => OperationKindTag::ModifyIdentifiedPitch, OperationKind::DeleteCrossCutting(_) => OperationKindTag::DeleteCrossCutting, OperationKind::ModifyCrossCutting(_) => OperationKindTag::ModifyCrossCutting, + OperationKind::CreateRegion(_) => OperationKindTag::InsertRegion, + OperationKind::DeleteRegion(_) => OperationKindTag::DeleteRegion, + OperationKind::CreateStaffInstance(_) => OperationKindTag::InsertStaffInstance, + OperationKind::DeleteStaffInstance(_) => OperationKindTag::DeleteStaffInstance, + OperationKind::CreateVoice(_) => OperationKindTag::CreateVoice, + OperationKind::DeleteVoice(_) => OperationKindTag::DeleteVoice, } } } @@ -194,6 +219,12 @@ impl CanonicalEncode for OperationKind { OperationKind::ModifyIdentifiedPitch(op) => op.encode_canonical(out), OperationKind::DeleteCrossCutting(op) => op.encode_canonical(out), OperationKind::ModifyCrossCutting(op) => op.encode_canonical(out), + OperationKind::CreateRegion(op) => op.encode_canonical(out), + OperationKind::DeleteRegion(op) => op.encode_canonical(out), + OperationKind::CreateStaffInstance(op) => op.encode_canonical(out), + OperationKind::DeleteStaffInstance(op) => op.encode_canonical(out), + OperationKind::CreateVoice(op) => op.encode_canonical(out), + OperationKind::DeleteVoice(op) => op.encode_canonical(out), } } } @@ -224,6 +255,8 @@ pub enum OperationKindTag { InsertIdentifiedPitch, DeleteIdentifiedPitch, ModifyIdentifiedPitch, + CreateVoice, + DeleteVoice, } impl OperationKindTag { @@ -249,6 +282,8 @@ impl OperationKindTag { OperationKindTag::InsertIdentifiedPitch => 17, OperationKindTag::DeleteIdentifiedPitch => 18, OperationKindTag::ModifyIdentifiedPitch => 19, + OperationKindTag::CreateVoice => 20, + OperationKindTag::DeleteVoice => 21, } } } @@ -805,6 +840,118 @@ impl CanonicalEncode for ModifyCrossCuttingOp { } } +// --- Group 3 (M2c): structural container CRUD (Chapter 6 §6.10). ------------- +// +// Creates are value-typed mints of an *empty* container (set-union creation); +// deletes are empty-only delete-wins tombstones (the container must have no live +// children — the caller deletes contents first). See `DECISIONS.md`. + +/// Mint an empty region into the canvas (Chapter 6 §6.10 InsertRegion). Carries +/// the full [`Region`] value (v1); the reduction preconditions it carries no +/// staff instances (an empty container). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CreateRegionOp { + pub region: Region, +} + +impl CreateRegionOp { + /// The minted region's identifier. + pub fn region_id(&self) -> RegionId { + self.region.id + } +} + +impl CanonicalEncode for CreateRegionOp { + fn encode_canonical(&self, out: &mut Vec) { + push_lp_bytes(out, &self.region.canonical_bytes()); + } +} + +/// Tombstone an empty region (Chapter 6 §6.10 DeleteRegion). Delete-wins, but a +/// precondition NoOp if the region still has live staff instances. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct DeleteRegionOp { + pub region: RegionId, +} + +impl CanonicalEncode for DeleteRegionOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.region); + } +} + +/// Mint an empty staff instance into a live region (Chapter 6 §6.10 +/// InsertStaffInstance). Carries the full [`StaffInstance`] value (v1); the +/// reduction preconditions it carries no voices. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CreateStaffInstanceOp { + pub region: RegionId, + pub instance: StaffInstance, +} + +impl CreateStaffInstanceOp { + /// The minted staff instance's identifier. + pub fn instance_id(&self) -> StaffInstanceId { + self.instance.id + } +} + +impl CanonicalEncode for CreateStaffInstanceOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.region); + push_lp_bytes(out, &self.instance.canonical_bytes()); + } +} + +/// Tombstone an empty staff instance (Chapter 6 §6.10 DeleteStaffInstance). +/// Delete-wins, but a precondition NoOp if it still has live voices. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct DeleteStaffInstanceOp { + pub staff_instance: StaffInstanceId, +} + +impl CanonicalEncode for DeleteStaffInstanceOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.staff_instance); + } +} + +/// Mint an empty voice into a live staff instance (Chapter 6 §6.10 CreateVoice). +/// Carries the full [`Voice`] value (v1); the reduction preconditions it carries +/// no events. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CreateVoiceOp { + pub staff_instance: StaffInstanceId, + pub voice: Voice, +} + +impl CreateVoiceOp { + /// The minted voice's identifier. + pub fn voice_id(&self) -> VoiceId { + self.voice.id + } +} + +impl CanonicalEncode for CreateVoiceOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.staff_instance); + push_lp_bytes(out, &self.voice.canonical_bytes()); + } +} + +/// Tombstone an empty voice (Chapter 6 §6.10 DeleteVoice). Delete-wins, but a +/// precondition NoOp if it still has live events. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct DeleteVoiceOp { + pub voice: VoiceId, +} + +impl CanonicalEncode for DeleteVoiceOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.voice); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index a52614e..93821fd 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -33,8 +33,8 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{ derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId, EventPosition, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling, - RegionEdge, RegionId, RegionTimeModel, Score, TimeAnchor, TransactionId, TypedObjectId, Voice, - VoiceId, VoiceOrigin, + RegionEdge, RegionId, RegionTimeModel, Score, StaffInstance, StaffInstanceId, TimeAnchor, + TransactionId, TypedObjectId, Voice, VoiceId, VoiceOrigin, }; use epiphany_determinism::CanonicalEncode; @@ -50,10 +50,11 @@ 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, DeleteCrossCuttingOp, DeleteEventOp, - DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, - ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, - TransposeOp, TupletCompensation, + CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateVoiceOp, CrossCuttingValue, + DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, + DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, + ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, + RespellPitchOp, TransposeOp, TupletCompensation, }; use crate::undo::{UndoPolicy, UndoTransactionPayload}; @@ -328,6 +329,11 @@ struct Reducer<'a> { // modify maps above: last modifier + value it wrote, keyed by structure id. last_cross_cutting_modify: BTreeMap, structures: BTreeMap>, + // Live child sets for the structural-container empty-only delete (Group 3): + // a region's live staff instances, and a staff instance's live voices. (A + // voice's live events are read from `voice_occupancy`.) + region_instances: BTreeMap>, + instance_voices: BTreeMap>, migrated_regions: BTreeSet, region_migrator: BTreeMap, descriptors: BTreeMap, @@ -352,6 +358,8 @@ struct WorkingSnapshot { last_pitch_modify: BTreeMap, last_cross_cutting_modify: BTreeMap, structures: BTreeMap>, + region_instances: BTreeMap>, + instance_voices: BTreeMap>, migrated_regions: BTreeSet, region_migrator: BTreeMap, descriptors: BTreeMap, @@ -425,6 +433,8 @@ impl<'a> Reducer<'a> { last_pitch_modify: BTreeMap::new(), last_cross_cutting_modify: BTreeMap::new(), structures: BTreeMap::new(), + region_instances: BTreeMap::new(), + instance_voices: BTreeMap::new(), migrated_regions: BTreeSet::new(), region_migrator: BTreeMap::new(), descriptors: BTreeMap::new(), @@ -485,9 +495,17 @@ impl<'a> Reducer<'a> { for region in &score.canvas.regions { self.objects .insert(TypedObjectId::Region(region.id), ObjectState::Live); + let instance_set = self.region_instances.entry(region.id).or_default(); + for instance in region.staff_instances() { + instance_set.insert(instance.id); + } for instance in region.staff_instances() { self.objects .insert(TypedObjectId::StaffInstance(instance.id), ObjectState::Live); + let voice_set = self.instance_voices.entry(instance.id).or_default(); + for voice in &instance.voices { + voice_set.insert(voice.id); + } for measure in &instance.measures { self.objects .insert(TypedObjectId::Measure(measure.id), ObjectState::Live); @@ -1202,6 +1220,12 @@ impl<'a> Reducer<'a> { 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), + OperationKind::CreateRegion(op) => self.create_region(env, op), + OperationKind::DeleteRegion(op) => self.delete_region(env, op), + OperationKind::CreateStaffInstance(op) => self.create_staff_instance(env, op), + OperationKind::DeleteStaffInstance(op) => self.delete_staff_instance(env, op), + OperationKind::CreateVoice(op) => self.create_voice(env, op), + OperationKind::DeleteVoice(op) => self.delete_voice(env, op), }, OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op), OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op), @@ -1309,6 +1333,11 @@ impl<'a> Reducer<'a> { // Implicit voice creation on first use (prototype convention). self.objects.insert(voice_obj, ObjectState::Live); self.minted_by.insert(voice_obj, env.id); + // Track it under its staff instance for the container empty-check. + self.instance_voices + .entry(op.staff_instance) + .or_default() + .insert(orig_voice); } } @@ -1807,6 +1836,286 @@ impl<'a> Reducer<'a> { } } + // --- Group 3 (M2c): structural container CRUD. ------------------------- + // + // Creates mint an empty container (set-union creation; the authoring contract + // is that the carried value is empty, so only its own object is minted). + // Deletes are empty-only delete-wins: a precondition NoOp unless the + // container has no live children. Child liveness is read from + // `region_instances` / `instance_voices` (and `voice_occupancy` for a voice's + // events), so the ledger projection and the graph agree on the result. + + fn mint_container(&mut self, env: &OperationEnvelope, obj: TypedObjectId) { + self.objects.insert(obj, ObjectState::Live); + self.minted_by.insert(obj, env.id); + self.note_minted(env, obj); + } + + /// `Some(effect)` when `obj` cannot be freshly minted (already live or + /// tombstoned); `None` when it is fresh and the create may proceed. + fn mint_precondition(&self, obj: TypedObjectId) -> Option { + match self.objects.get(&obj) { + Some(ObjectState::Live) => Some(OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }), + Some(ObjectState::Tombstoned { .. }) => Some(OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + }), + None => None, + } + } + + fn create_region(&mut self, env: &OperationEnvelope, op: &CreateRegionOp) -> OperationEffect { + let robj = TypedObjectId::Region(op.region_id()); + if let Some(effect) = self.mint_precondition(robj) { + return effect; + } + self.graph_create_region(&op.region); + self.mint_container(env, robj); + self.region_instances.entry(op.region_id()).or_default(); + OperationEffect::Applied + } + + fn create_staff_instance( + &mut self, + env: &OperationEnvelope, + op: &CreateStaffInstanceOp, + ) -> OperationEffect { + if !matches!( + self.objects.get(&TypedObjectId::Region(op.region)), + Some(ObjectState::Live) + ) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + let iobj = TypedObjectId::StaffInstance(op.instance_id()); + if let Some(effect) = self.mint_precondition(iobj) { + return effect; + } + self.graph_create_staff_instance(op.region, &op.instance); + self.mint_container(env, iobj); + self.region_instances + .entry(op.region) + .or_default() + .insert(op.instance_id()); + self.instance_voices.entry(op.instance_id()).or_default(); + OperationEffect::Applied + } + + fn create_voice(&mut self, env: &OperationEnvelope, op: &CreateVoiceOp) -> OperationEffect { + if !matches!( + self.objects + .get(&TypedObjectId::StaffInstance(op.staff_instance)), + Some(ObjectState::Live) + ) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + let vobj = TypedObjectId::Voice(op.voice_id()); + if let Some(effect) = self.mint_precondition(vobj) { + return effect; + } + self.graph_create_voice(op.staff_instance, &op.voice); + self.mint_container(env, vobj); + self.instance_voices + .entry(op.staff_instance) + .or_default() + .insert(op.voice_id()); + OperationEffect::Applied + } + + /// `Some(effect)` when `obj` cannot be deleted (missing, idempotent + /// re-delete, or non-empty); `None` with the resolved minter when the + /// empty-only delete may proceed. + fn delete_precondition( + &self, + obj: TypedObjectId, + env: &OperationEnvelope, + has_live_children: bool, + ) -> Result { + match self.objects.get(&obj) { + None => Err(OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }), + Some(ObjectState::Tombstoned { .. }) => Err(OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }), + Some(ObjectState::Live) if has_live_children => Err(OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::ContainerNotEmpty, + }, + }), + Some(ObjectState::Live) => Ok(self.minted_by.get(&obj).copied().unwrap_or(env.id)), + } + } + + fn delete_region(&mut self, env: &OperationEnvelope, op: &DeleteRegionOp) -> OperationEffect { + let robj = TypedObjectId::Region(op.region); + let has_instances = self + .region_instances + .get(&op.region) + .is_some_and(|s| !s.is_empty()); + let minted_by = match self.delete_precondition(robj, env, has_instances) { + Ok(minter) => minter, + Err(effect) => return effect, + }; + self.objects.insert( + robj, + ObjectState::Tombstoned { + deleted_by: env.id, + minted_by, + }, + ); + self.region_instances.remove(&op.region); + self.graph_delete_region(op.region); + OperationEffect::Applied + } + + fn delete_staff_instance( + &mut self, + env: &OperationEnvelope, + op: &DeleteStaffInstanceOp, + ) -> OperationEffect { + let iobj = TypedObjectId::StaffInstance(op.staff_instance); + let has_voices = self + .instance_voices + .get(&op.staff_instance) + .is_some_and(|s| !s.is_empty()); + let minted_by = match self.delete_precondition(iobj, env, has_voices) { + Ok(minter) => minter, + Err(effect) => return effect, + }; + self.objects.insert( + iobj, + ObjectState::Tombstoned { + deleted_by: env.id, + minted_by, + }, + ); + self.instance_voices.remove(&op.staff_instance); + for set in self.region_instances.values_mut() { + set.remove(&op.staff_instance); + } + self.graph_delete_staff_instance(op.staff_instance); + OperationEffect::Applied + } + + fn delete_voice(&mut self, env: &OperationEnvelope, op: &DeleteVoiceOp) -> OperationEffect { + let vobj = TypedObjectId::Voice(op.voice); + let has_events = self + .voice_occupancy + .get(&op.voice) + .is_some_and(|e| !e.is_empty()); + let minted_by = match self.delete_precondition(vobj, env, has_events) { + Ok(minter) => minter, + Err(effect) => return effect, + }; + self.objects.insert( + vobj, + ObjectState::Tombstoned { + deleted_by: env.id, + minted_by, + }, + ); + self.voice_occupancy.remove(&op.voice); + for set in self.instance_voices.values_mut() { + set.remove(&op.voice); + } + self.graph_delete_voice(op.voice); + OperationEffect::Applied + } + + // --- Group 3 graph mutations (reduce_onto only; no-op when graph is None). -- + + fn graph_create_region(&mut self, region: &epiphany_core::Region) { + if let Some(score) = self.graph.as_mut() { + score.canvas.regions.push(region.clone()); + } + } + + fn graph_create_staff_instance(&mut self, region: RegionId, instance: &StaffInstance) { + let Some(score) = self.graph.as_mut() else { + return; + }; + if let Some(region) = score.canvas.regions.iter_mut().find(|r| r.id == region) { + let staff = instance.staff; + if let Some(instances) = region.content.staff_instances_mut() { + instances.push(instance.clone()); + } + // Keep the region's staff extent listing exactly its manifested + // staves (Chapter 5 RegionExtents). + if !region.staff_extent.staves.contains(&staff) { + region.staff_extent.staves.push(staff); + } + } + } + + fn graph_create_voice(&mut self, staff_instance: StaffInstanceId, voice: &Voice) { + let Some(score) = self.graph.as_mut() else { + return; + }; + for region in &mut score.canvas.regions { + if let Some(instances) = region.content.staff_instances_mut() { + if let Some(instance) = instances.iter_mut().find(|i| i.id == staff_instance) { + instance.voices.push(voice.clone()); + return; + } + } + } + } + + fn graph_delete_region(&mut self, region: RegionId) { + if let Some(score) = self.graph.as_mut() { + score.canvas.regions.retain(|r| r.id != region); + } + } + + fn graph_delete_staff_instance(&mut self, staff_instance: StaffInstanceId) { + let Some(score) = self.graph.as_mut() else { + return; + }; + for region in &mut score.canvas.regions { + let Some(instances) = region.content.staff_instances_mut() else { + continue; + }; + let Some(removed_staff) = instances + .iter() + .find(|i| i.id == staff_instance) + .map(|i| i.staff) + else { + continue; + }; + instances.retain(|i| i.id != staff_instance); + // Drop the staff from the extent if no remaining instance manifests + // it (Chapter 5 RegionExtents). + let still_used = instances.iter().any(|i| i.staff == removed_staff); + if !still_used { + region.staff_extent.staves.retain(|s| *s != removed_staff); + } + } + } + + fn graph_delete_voice(&mut self, voice: VoiceId) { + let Some(score) = self.graph.as_mut() else { + return; + }; + for region in &mut score.canvas.regions { + if let Some(instances) = region.content.staff_instances_mut() { + for instance in instances { + instance.voices.retain(|v| v.id != voice); + } + } + } + } + fn change_region_time_model( &mut self, env: &OperationEnvelope, @@ -2677,6 +2986,8 @@ impl<'a> Reducer<'a> { last_pitch_modify: self.last_pitch_modify.clone(), last_cross_cutting_modify: self.last_cross_cutting_modify.clone(), structures: self.structures.clone(), + region_instances: self.region_instances.clone(), + instance_voices: self.instance_voices.clone(), migrated_regions: self.migrated_regions.clone(), region_migrator: self.region_migrator.clone(), descriptors: self.descriptors.clone(), @@ -2698,6 +3009,8 @@ impl<'a> Reducer<'a> { self.last_pitch_modify = s.last_pitch_modify; self.last_cross_cutting_modify = s.last_cross_cutting_modify; self.structures = s.structures; + self.region_instances = s.region_instances; + self.instance_voices = s.instance_voices; self.migrated_regions = s.migrated_regions; self.region_migrator = s.region_migrator; self.descriptors = s.descriptors; diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs index 4e7227e..7d226c1 100644 --- a/crates/epiphany-ops/src/v0.rs +++ b/crates/epiphany-ops/src/v0.rs @@ -77,6 +77,13 @@ pub enum V0OperationKind { // Group 2 (M2) — also v1-native; round-trip by identity. DeleteCrossCutting(crate::payload::DeleteCrossCuttingOp), ModifyCrossCutting(crate::payload::ModifyCrossCuttingOp), + // Group 3 (M2c) — also v1-native; round-trip by identity. + CreateRegion(crate::payload::CreateRegionOp), + DeleteRegion(crate::payload::DeleteRegionOp), + CreateStaffInstance(crate::payload::CreateStaffInstanceOp), + DeleteStaffInstance(crate::payload::DeleteStaffInstanceOp), + CreateVoice(crate::payload::CreateVoiceOp), + DeleteVoice(crate::payload::DeleteVoiceOp), } /// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant diff --git a/crates/epiphany-ops/src/valuegen.rs b/crates/epiphany-ops/src/valuegen.rs index b43517e..25101dc 100644 --- a/crates/epiphany-ops/src/valuegen.rs +++ b/crates/epiphany-ops/src/valuegen.rs @@ -18,10 +18,11 @@ use epiphany_core::{ AcousticPitch, AcousticRealization, AleatoricAnchoringDiscipline, AleatoricTimeModel, AnchorOffset, Beam, BeamId, CmnNominal, Event, EventId, EventOrderingDAG, EventPosition, IdentifiedPitch, MetricTimeModel, MusicalDuration, MusicalPosition, Pitch, PitchId, - PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, ProportionalTimeModel, - RegionEdge, RegionId, RegionTimeModel, Rest, ScalePosition, Slur, SlurId, SpellingAttachment, - SpellingDirective, SpellingScope, SpellingSource, StemConfiguration, Tie, TieClass, TieId, - TimeAnchor, VoiceId, WallClockDuration, + PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, ProportionalTimeModel, Region, + RegionContent, RegionEdge, RegionId, RegionTimeModel, Rest, ScalePosition, Slur, SlurId, + SpellingAttachment, SpellingDirective, SpellingScope, SpellingSource, StaffBasedContent, + StaffExtent, StaffId, StaffInstance, StaffInstanceId, StemConfiguration, Tie, TieClass, TieId, + TimeAnchor, TimeExtent, Voice, VoiceId, VoiceOrigin, WallClockDuration, WallClockTime, }; /// A deterministic, fully-specified C4 pitch in the cmn-12 space — the neutral @@ -211,6 +212,62 @@ pub fn aleatoric_model() -> RegionTimeModel { }) } +/// An empty, user-declared [`Voice`] (M2c) — the container a `CreateVoice` mints +/// before any event is inserted into it. +pub fn voice(id: VoiceId) -> Voice { + Voice { + id, + events: Vec::new(), + default_stem_direction: None, + is_primary: false, + origin: VoiceOrigin::UserDeclared, + } +} + +/// An empty [`StaffInstance`] (M2c) over the given global `staff` — the container +/// a `CreateStaffInstance` mints before any voice is created in it. +pub fn staff_instance(id: StaffInstanceId, staff: StaffId) -> StaffInstance { + StaffInstance { + id, + staff, + voices: Vec::new(), + clef_sequence: Vec::new(), + key_sequence: Vec::new(), + local_metric_grid: None, + measures: Vec::new(), + instrument_override: None, + staff_lines_override: None, + visible: true, + } +} + +/// An empty metric [`Region`] (M2c) — the container a `CreateRegion` mints before +/// any staff instance is added to it. Carries no staff instances and an empty +/// staff extent (a region with no instances is reference-clean, Chapter 5). +pub fn region(id: RegionId) -> Region { + Region { + id, + time_model: metric_model(), + content: RegionContent::StaffBased(StaffBasedContent { + staff_instances: Vec::new(), + ..Default::default() + }), + // A far-future wall-clock extent so a freshly-created (initially empty) + // region does not overlap an existing region in both time and staff once + // a staff instance is added (Chapter 5 RegionExtents). + time_extent: TimeExtent { + start: TimeAnchor::WallClock { + time: WallClockTime(1_000_000_000), + }, + end: TimeAnchor::WallClock { + time: WallClockTime(1_000_001_000), + }, + }, + staff_extent: StaffExtent { staves: Vec::new() }, + local_tempo_map: None, + } +} + /// An explicit, user-chosen per-pitch [`SpellingAttachment`] — the engraved-layer /// spelling a materialized score carries after a `RespellPitch`. The v0 → v1 /// migration recovers a respell's spelling from exactly these attachments diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index b06f2c9..79318b0 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -1030,3 +1030,186 @@ fn deleting_both_slur_endpoints_cascades_in_both_graph_and_ledger() { ); assert!(check_invariants(&result.score).is_empty()); } + +/// Helper: the effect recorded for `id` in a reduction. +fn effect_of(result: &epiphany_ops::GraphMaterialization, id: OperationId) -> OperationEffect { + result + .state + .effects + .iter() + .find(|(e, _)| *e == id) + .map(|(_, eff)| eff.clone()) + .expect("the operation has an effect") +} + +#[test] +fn structural_containers_create_and_empty_only_delete_in_the_graph() { + use epiphany_core::{RegionId, StaffInstanceId}; + let base = epiphany_core::generators::valid_score(100); + let staff = base.staves[0].id; + let region = RegionId::new(ReplicaId(72), 0); + let instance = StaffInstanceId::new(ReplicaId(72), 1); + let v = VoiceId::new(ReplicaId(72), 2); + + let r = 72; + let create_region = envelope( + r, + 0, + 10, + CausalContext::new(), + None, + OperationPayload::Primitive(OperationKind::CreateRegion(epiphany_ops::CreateRegionOp { + region: valuegen::region(region), + })), + ); + let create_instance = envelope( + r, + 1, + 11, + CausalContext::new().with_seen(ReplicaId(r), 0), + None, + OperationPayload::Primitive(OperationKind::CreateStaffInstance( + epiphany_ops::CreateStaffInstanceOp { + region, + instance: valuegen::staff_instance(instance, staff), + }, + )), + ); + let create_voice = envelope( + r, + 2, + 12, + CausalContext::new().with_seen(ReplicaId(r), 1), + None, + OperationPayload::Primitive(OperationKind::CreateVoice(epiphany_ops::CreateVoiceOp { + staff_instance: instance, + voice: valuegen::voice(v), + })), + ); + let creates = [ + create_region.clone(), + create_instance.clone(), + create_voice.clone(), + ]; + + // Creates materialize the container subtree, invariant-clean. + let mut after_create = OperationSet::new(); + after_create.accept_all(creates.clone()); + let created = after_create.reduce_onto(&base); + let materialized_region = created + .score + .canvas + .regions + .iter() + .find(|rg| rg.id == region) + .expect("the region is materialized"); + assert!( + materialized_region + .staff_instances() + .iter() + .any(|i| i.id == instance), + "the staff instance is materialized in its region" + ); + assert!( + materialized_region + .staff_instances() + .iter() + .flat_map(|i| &i.voices) + .any(|vo| vo.id == v), + "the voice is materialized in its staff instance" + ); + assert!(check_invariants(&created.score).is_empty()); + + // Empty-only: deleting the non-empty region (and instance) is refused. + let del_region_early = envelope( + r, + 3, + 13, + CausalContext::new().with_seen(ReplicaId(r), 2), + None, + OperationPayload::Primitive(OperationKind::DeleteRegion(epiphany_ops::DeleteRegionOp { + region, + })), + ); + let mut early = OperationSet::new(); + early.accept_all(creates.iter().cloned().chain([del_region_early.clone()])); + let early_res = early.reduce_onto(&base); + assert!( + early_res + .score + .canvas + .regions + .iter() + .any(|rg| rg.id == region), + "a non-empty region delete is refused and leaves the region in the graph" + ); + assert_eq!( + effect_of(&early_res, del_region_early.id), + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::ContainerNotEmpty, + }, + }, + "the refused delete reports ContainerNotEmpty" + ); + + // Ordered teardown (voice, instance, region) clears the subtree from graph + // and ledger, invariant-clean. + let del_voice = envelope( + r, + 3, + 13, + CausalContext::new().with_seen(ReplicaId(r), 2), + None, + OperationPayload::Primitive(OperationKind::DeleteVoice(epiphany_ops::DeleteVoiceOp { + voice: v, + })), + ); + let del_instance = envelope( + r, + 4, + 14, + CausalContext::new().with_seen(ReplicaId(r), 3), + None, + OperationPayload::Primitive(OperationKind::DeleteStaffInstance( + epiphany_ops::DeleteStaffInstanceOp { + staff_instance: instance, + }, + )), + ); + let del_region = envelope( + r, + 5, + 15, + CausalContext::new().with_seen(ReplicaId(r), 4), + None, + OperationPayload::Primitive(OperationKind::DeleteRegion(epiphany_ops::DeleteRegionOp { + region, + })), + ); + let mut teardown = OperationSet::new(); + teardown.accept_all( + creates + .into_iter() + .chain([del_voice, del_instance, del_region]), + ); + let result = teardown.reduce_onto(&base); + assert!( + !result.score.canvas.regions.iter().any(|rg| rg.id == region), + "the region is removed from the graph after the ordered teardown" + ); + for obj in [ + TypedObjectId::Region(region), + TypedObjectId::StaffInstance(instance), + TypedObjectId::Voice(v), + ] { + assert!( + matches!( + result.state.objects.get(&obj), + Some(epiphany_ops::ObjectState::Tombstoned { .. }) + ), + "{obj:?} is tombstoned in the ledger after teardown" + ); + } + assert!(check_invariants(&result.score).is_empty()); +} diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index 031a645..b939b79 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -32,11 +32,12 @@ use epiphany_ops::valuegen; use epiphany_ops::{ AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry, - ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteCrossCuttingOp, - DeleteEventOp, DeleteIdentifiedPitchOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock, - InsertEventOp, InsertIdentifiedPitchOp, IntegrityAnomaly, IntegrityAnomalyKind, - IntegrityAnomalyRegistryId, MaterializedState, ModifyCrossCuttingOp, ModifyEventOp, - ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState, OperationEffect, + ConflictResolutionState, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, + CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, + DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, 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, @@ -635,7 +636,7 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP } _ => {} } - let kind = match rng.below(15) { + let kind = match rng.below(21) { 0 => { let pitches = if rng.boolean() { vec![obj_pitch(rng.below(pitches))] @@ -731,6 +732,30 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP obj_event(rng.below(events)), )), }), + // Group 3 (M2c): structural container CRUD over the shared id space. + 14 => OperationKind::CreateRegion(CreateRegionOp { + region: valuegen::region(RegionId::new(OBJ_REPLICA, rng.below(2))), + }), + 15 => OperationKind::DeleteRegion(DeleteRegionOp { + region: RegionId::new(OBJ_REPLICA, rng.below(2)), + }), + 16 => OperationKind::CreateStaffInstance(CreateStaffInstanceOp { + region: RegionId::new(OBJ_REPLICA, rng.below(2)), + instance: valuegen::staff_instance( + StaffInstanceId::new(OBJ_REPLICA, rng.below(2)), + StaffId::new(OBJ_REPLICA, 0), + ), + }), + 17 => OperationKind::DeleteStaffInstance(DeleteStaffInstanceOp { + staff_instance: StaffInstanceId::new(OBJ_REPLICA, rng.below(2)), + }), + 18 => OperationKind::CreateVoice(CreateVoiceOp { + staff_instance: StaffInstanceId::new(OBJ_REPLICA, rng.below(2)), + voice: valuegen::voice(VoiceId::new(OBJ_REPLICA, rng.below(4))), + }), + 19 => OperationKind::DeleteVoice(DeleteVoiceOp { + voice: VoiceId::new(OBJ_REPLICA, rng.below(4)), + }), _ => OperationKind::Registered( OperationKindRegistryId(rng.next_u64() as u128), rng.byte_vec(0, 16),