Agent K M2c (Group 3): structural container CRUD operations
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 <noreply@anthropic.com>
This commit is contained in:
parent
0f1b209e54
commit
a207077cd7
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -218,6 +218,7 @@ fn precondition_reason(reader: &mut Reader<'_>) -> Result<PreconditionFailureRea
|
|||
reader,
|
||||
PreconditionFailureRegistryId,
|
||||
)?)),
|
||||
10 => Ok(PreconditionFailureReason::ContainerNotEmpty),
|
||||
tag => Err(MaterializedDecodeError::InvalidTag {
|
||||
kind: "PreconditionFailureReason",
|
||||
tag,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|||
// v1-native (Group 2): identity round-trip.
|
||||
V0OperationKind::DeleteCrossCutting(op) => 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));
|
||||
|
|
|
|||
|
|
@ -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<u8>) {
|
||||
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<u8>) {
|
||||
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<u8>) {
|
||||
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<u8>) {
|
||||
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<u8>) {
|
||||
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<u8>) {
|
||||
push_canon(out, &self.voice);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -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<TypedObjectId, (OperationId, CrossCuttingValue)>,
|
||||
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
||||
// 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<RegionId, BTreeSet<StaffInstanceId>>,
|
||||
instance_voices: BTreeMap<StaffInstanceId, BTreeSet<VoiceId>>,
|
||||
migrated_regions: BTreeSet<RegionId>,
|
||||
region_migrator: BTreeMap<RegionId, OperationId>,
|
||||
descriptors: BTreeMap<TransactionId, OperationId>,
|
||||
|
|
@ -352,6 +358,8 @@ struct WorkingSnapshot {
|
|||
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
|
||||
last_cross_cutting_modify: BTreeMap<TypedObjectId, (OperationId, CrossCuttingValue)>,
|
||||
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
||||
region_instances: BTreeMap<RegionId, BTreeSet<StaffInstanceId>>,
|
||||
instance_voices: BTreeMap<StaffInstanceId, BTreeSet<VoiceId>>,
|
||||
migrated_regions: BTreeSet<RegionId>,
|
||||
region_migrator: BTreeMap<RegionId, OperationId>,
|
||||
descriptors: BTreeMap<TransactionId, OperationId>,
|
||||
|
|
@ -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<OperationEffect> {
|
||||
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<OperationId, OperationEffect> {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue