diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 6fdda5f..3b96ecb 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -2084,6 +2084,8 @@ macro_rules! canonical_value { canonical_value! { Event, Rest, + Pitch, + IdentifiedPitch, PitchSpelling, Tie, Slur, @@ -2142,6 +2144,12 @@ mod value_codec_tests { let s = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1)); for ev in s.events.iter() { assert_value_round_trips(ev); + let mut ips = Vec::new(); + ev.collect_identified_pitches(&mut ips); + for ip in ips { + assert_value_round_trips(ip); + assert_value_round_trips(&ip.pitch); + } } } } diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index 9bcacea..e9bdb67 100644 --- a/crates/epiphany-ops/src/fuzz.rs +++ b/crates/epiphany-ops/src/fuzz.rs @@ -30,8 +30,9 @@ use crate::causal::CausalContext; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind, - OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TupletCompensation, + CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp, + InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, + OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation, }; use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::support::AuthorId; @@ -81,7 +82,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(5) { + let kind = match rng.below(10) { 0 => { let voice = VoiceId::new(ReplicaId(7), rng.below(3)); let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32)); @@ -116,7 +117,7 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { event(rng.below(ID_SPACE)), )), }), - _ => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { + 4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { region: RegionId::new(ReplicaId(7), 0), anchor: valuegen::region_start_anchor( RegionId::new(ReplicaId(7), 0), @@ -124,6 +125,31 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { ), present: rng.chance(2), }), + // Group 1 (M2): event & pitch leaf-field ops over the shared id space. + 5 => OperationKind::ModifyEvent(ModifyEventOp { + event: valuegen::insert_event_value( + event(rng.below(ID_SPACE)), + VoiceId::new(ReplicaId(7), rng.below(3)), + MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), + MusicalDuration::whole(), + &[pitch(rng.below(ID_SPACE))], + ), + }), + 6 => OperationKind::Transpose(TransposeOp { + targets: vec![pitch(rng.below(ID_SPACE))], + chromatic_steps: rng.below(5) as i32 - 2, + }), + 7 => OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp { + event: event(rng.below(ID_SPACE)), + pitch: valuegen::identified_pitch(pitch(rng.below(ID_SPACE))), + }), + 8 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp { + pitch: pitch(rng.below(ID_SPACE)), + }), + _ => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp { + pitch: pitch(rng.below(ID_SPACE)), + value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1), + }), }; OperationPayload::Primitive(kind) } diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 9de051b..c5ddc17 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -113,10 +113,11 @@ pub use envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessE pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError}; pub use opset::OperationSet; pub use payload::{ - ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, - OperationKind, OperationKindTag, OperationPayload, PositionRemapping, ResolveConflictPayload, - RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, - TupletCompensation, + ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, + DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp, + ModifyIdentifiedPitchOp, OperationKind, OperationKindTag, OperationPayload, PositionRemapping, + ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, + TransactionDescriptor, TransposeOp, TupletCompensation, }; pub use reduce::{ canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason, diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index 21f1092..8febdcc 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -141,6 +141,16 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind { V0OperationKind::DeclareTransaction(desc.clone()) } OperationKind::Registered(id, bytes) => V0OperationKind::Registered(*id, bytes.clone()), + // v1-native (Group 1): no lossy v0 form — projected verbatim. + OperationKind::ModifyEvent(op) => V0OperationKind::ModifyEvent(op.clone()), + OperationKind::Transpose(op) => V0OperationKind::Transpose(op.clone()), + OperationKind::InsertIdentifiedPitch(op) => { + V0OperationKind::InsertIdentifiedPitch(op.clone()) + } + OperationKind::DeleteIdentifiedPitch(op) => V0OperationKind::DeleteIdentifiedPitch(*op), + OperationKind::ModifyIdentifiedPitch(op) => { + V0OperationKind::ModifyIdentifiedPitch(op.clone()) + } } } @@ -256,6 +266,16 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result OperationKind::Registered(*id, bytes.clone()), + // v1-native (Group 1): identity round-trip (no lossy reconstruction). + V0OperationKind::ModifyEvent(op) => OperationKind::ModifyEvent(op.clone()), + V0OperationKind::Transpose(op) => OperationKind::Transpose(op.clone()), + V0OperationKind::InsertIdentifiedPitch(op) => { + OperationKind::InsertIdentifiedPitch(op.clone()) + } + V0OperationKind::DeleteIdentifiedPitch(op) => OperationKind::DeleteIdentifiedPitch(*op), + V0OperationKind::ModifyIdentifiedPitch(op) => { + OperationKind::ModifyIdentifiedPitch(op.clone()) + } }) } @@ -510,4 +530,42 @@ mod tests { Err(MigrationError::Irreversible(_)) )); } + + #[test] + fn group1_kinds_round_trip_by_identity() { + // The Group-1 (M2) kinds are v1-native: they had no lossy v0 form, so + // project+migrate is the identity (no context needed), and the round-trip + // is exact for every one of them. + let voice = VoiceId::new(ReplicaId(3), 0); + let kinds = [ + OperationKind::ModifyEvent(crate::payload::ModifyEventOp { + event: valuegen::insert_event_value( + ev(1), + voice, + epiphany_core::MusicalPosition::origin(), + MusicalDuration::whole(), + &[PitchId::new(ReplicaId(3), 1)], + ), + }), + OperationKind::Transpose(crate::payload::TransposeOp { + targets: vec![PitchId::new(ReplicaId(3), 1)], + chromatic_steps: -2, + }), + OperationKind::InsertIdentifiedPitch(crate::payload::InsertIdentifiedPitchOp { + event: ev(1), + pitch: valuegen::identified_pitch(PitchId::new(ReplicaId(3), 2)), + }), + OperationKind::DeleteIdentifiedPitch(crate::payload::DeleteIdentifiedPitchOp { + pitch: PitchId::new(ReplicaId(3), 2), + }), + OperationKind::ModifyIdentifiedPitch(crate::payload::ModifyIdentifiedPitchOp { + pitch: PitchId::new(ReplicaId(3), 1), + value: valuegen::pitch_value_nth(3), + }), + ]; + for kind in kinds { + let e = env(primitive(kind)); + assert_eq!(round_trip(&e), Ok(e.clone())); + } + } } diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index 2449a4c..7c882b9 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -32,9 +32,10 @@ //! payloads. use epiphany_core::{ - Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, MusicalDuration, - MusicalPosition, PitchId, PitchSpelling, RegionId, RegionTimeModel, Rest, Slur, Spanner, - StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, VoiceId, + Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch, + MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, RegionId, RegionTimeModel, + Rest, Slur, Spanner, StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, + VoiceId, }; use epiphany_determinism::{sorted_canonical, CanonicalEncode}; @@ -106,6 +107,18 @@ pub enum OperationKind { DeclareTransaction(TransactionDescriptor), /// An extension-defined primitive operation (opaque serialized payload). Registered(OperationKindRegistryId, Vec), + // --- Group 1 (M2): event & pitch leaf-field ops. Discriminants extend the + // stable v1 wire form (0..=7 above) additively. --- + /// Overwrite a live event's value (later-in-canonical-order wins). + ModifyEvent(ModifyEventOp), + /// Transpose live pitches by an interval (order-dependent; ids preserved). + Transpose(TransposeOp), + /// Add a pitch to a live event (mint). + InsertIdentifiedPitch(InsertIdentifiedPitchOp), + /// Tombstone a live pitch (delete-wins). + DeleteIdentifiedPitch(DeleteIdentifiedPitchOp), + /// Overwrite a live pitch's value (later-in-canonical-order wins). + ModifyIdentifiedPitch(ModifyIdentifiedPitchOp), } impl OperationKind { @@ -119,6 +132,11 @@ impl OperationKind { OperationKind::SetUserSystemBreak(_) => 5, OperationKind::DeclareTransaction(_) => 6, OperationKind::Registered(..) => 7, + OperationKind::ModifyEvent(_) => 8, + OperationKind::Transpose(_) => 9, + OperationKind::InsertIdentifiedPitch(_) => 10, + OperationKind::DeleteIdentifiedPitch(_) => 11, + OperationKind::ModifyIdentifiedPitch(_) => 12, } } @@ -135,6 +153,11 @@ impl OperationKind { OperationKind::SetUserSystemBreak(_) => OperationKindTag::SetUserSystemBreak, OperationKind::DeclareTransaction(_) => OperationKindTag::DeclareTransaction, OperationKind::Registered(id, _) => OperationKindTag::Registered(*id), + OperationKind::ModifyEvent(_) => OperationKindTag::ModifyEvent, + OperationKind::Transpose(_) => OperationKindTag::Transpose, + OperationKind::InsertIdentifiedPitch(_) => OperationKindTag::InsertIdentifiedPitch, + OperationKind::DeleteIdentifiedPitch(_) => OperationKindTag::DeleteIdentifiedPitch, + OperationKind::ModifyIdentifiedPitch(_) => OperationKindTag::ModifyIdentifiedPitch, } } } @@ -154,6 +177,11 @@ impl CanonicalEncode for OperationKind { push_canon(out, id); crate::encode::push_lp_bytes(out, bytes); } + OperationKind::ModifyEvent(op) => op.encode_canonical(out), + OperationKind::Transpose(op) => op.encode_canonical(out), + OperationKind::InsertIdentifiedPitch(op) => op.encode_canonical(out), + OperationKind::DeleteIdentifiedPitch(op) => op.encode_canonical(out), + OperationKind::ModifyIdentifiedPitch(op) => op.encode_canonical(out), } } } @@ -181,6 +209,9 @@ pub enum OperationKindTag { SetUserPageBreak, DeclareTransaction, Registered(OperationKindRegistryId), + InsertIdentifiedPitch, + DeleteIdentifiedPitch, + ModifyIdentifiedPitch, } impl OperationKindTag { @@ -203,6 +234,9 @@ impl OperationKindTag { OperationKindTag::SetUserPageBreak => 14, OperationKindTag::DeclareTransaction => 15, OperationKindTag::Registered(_) => 16, + OperationKindTag::InsertIdentifiedPitch => 17, + OperationKindTag::DeleteIdentifiedPitch => 18, + OperationKindTag::ModifyIdentifiedPitch => 19, } } } @@ -621,6 +655,99 @@ impl CanonicalEncode for ResolveConflictPayload { } } +// --- Group 1 (M2): event & pitch leaf-field ops (Chapter 6 §6.10). ----------- + +/// Overwrite a live event's value (Chapter 6 §6.10 ModifyEvent). Carries the +/// full replacement [`Event`] (v1, value-typed). Field-overwrite discipline: +/// later-in-canonical-order wins; concurrent differing modifications conflict. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ModifyEventOp { + pub event: Event, +} + +impl ModifyEventOp { + /// The modified event's identifier (the LWW key). + pub fn event_id(&self) -> EventId { + self.event.id() + } +} + +impl CanonicalEncode for ModifyEventOp { + fn encode_canonical(&self, out: &mut Vec) { + push_lp_bytes(out, &self.event.canonical_bytes()); + } +} + +/// Transpose live pitches by a chromatic interval (Chapter 6 §6.10 Transpose). +/// Pitch identifiers are preserved; reduction is order-dependent (transpositions +/// do not commute). `chromatic_steps` is a minimal interval (a CMN alteration +/// shift); rich interval algebra is deferred (Chapter 4 tuning catalog; P12-K2). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct TransposeOp { + pub targets: Vec, + pub chromatic_steps: i32, +} + +impl CanonicalEncode for TransposeOp { + fn encode_canonical(&self, out: &mut Vec) { + push_seq(out, &sorted_canonical(self.targets.clone())); + out.extend_from_slice(&self.chromatic_steps.to_le_bytes()); + } +} + +/// Add a pitch to a live event (Chapter 6 §6.10 InsertIdentifiedPitch). Carries +/// the full [`IdentifiedPitch`] (v1). Mint discipline. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct InsertIdentifiedPitchOp { + pub event: EventId, + pub pitch: IdentifiedPitch, +} + +impl InsertIdentifiedPitchOp { + /// The minted pitch's identifier. + pub fn pitch_id(&self) -> PitchId { + self.pitch.id + } +} + +impl CanonicalEncode for InsertIdentifiedPitchOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.event); + push_lp_bytes(out, &self.pitch.canonical_bytes()); + } +} + +/// Tombstone a live pitch (Chapter 6 §6.10 DeleteIdentifiedPitch). Delete-wins +/// discipline; the identifier is retained as a tombstone. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct DeleteIdentifiedPitchOp { + pub pitch: PitchId, +} + +impl CanonicalEncode for DeleteIdentifiedPitchOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.pitch); + } +} + +/// Overwrite a live pitch's value (Chapter 6 §6.10 ModifyIdentifiedPitch). +/// Carries the full replacement [`Pitch`] (v1) — its acoustic / scale-position, +/// distinct from [`RespellPitchOp`] which overwrites only the *spelling*. +/// Field-overwrite discipline (later-in-canonical-order wins; concurrent +/// differing conflict). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ModifyIdentifiedPitchOp { + pub pitch: PitchId, + pub value: Pitch, +} + +impl CanonicalEncode for ModifyIdentifiedPitchOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.pitch); + push_lp_bytes(out, &self.value.canonical_bytes()); + } +} + #[cfg(test)] mod tests { use super::*; @@ -671,6 +798,9 @@ mod tests { OperationKindTag::SetUserSystemBreak, OperationKindTag::SetUserPageBreak, OperationKindTag::DeclareTransaction, + OperationKindTag::InsertIdentifiedPitch, + OperationKindTag::DeleteIdentifiedPitch, + OperationKindTag::ModifyIdentifiedPitch, ]; let encoded: std::collections::BTreeSet<_> = tags .iter() diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index b922f76..3ebd4d2 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -32,7 +32,7 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{ derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId, - EventPosition, MusicalDuration, MusicalPosition, OperationId, PitchId, PitchSpelling, + EventPosition, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling, RegionEdge, RegionId, RegionTimeModel, Score, TimeAnchor, TransactionId, TypedObjectId, Voice, VoiceId, VoiceOrigin, }; @@ -50,8 +50,9 @@ use crate::encode::{push_canon, push_len, push_lp_bytes, push_u8_bool}; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind, - OperationPayload, RespellPitchOp, TupletCompensation, + CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp, + InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, + OperationPayload, RespellPitchOp, TransposeOp, TupletCompensation, }; use crate::undo::{UndoPolicy, UndoTransactionPayload}; @@ -317,6 +318,11 @@ struct Reducer<'a> { event_pitches: BTreeMap>, voice_occupancy: BTreeMap>, last_respell: BTreeMap, + // LWW working state for the Group-1 field-overwrite ops: the last modifier + // and the value it wrote, used to detect concurrent *differing* modifications + // (the resolved value itself lives in the graph, not in MaterializedState). + last_event_modify: BTreeMap, + last_pitch_modify: BTreeMap, structures: BTreeMap>, migrated_regions: BTreeSet, region_migrator: BTreeMap, @@ -338,6 +344,8 @@ struct WorkingSnapshot { event_pitches: BTreeMap>, voice_occupancy: BTreeMap>, last_respell: BTreeMap, + last_event_modify: BTreeMap, + last_pitch_modify: BTreeMap, structures: BTreeMap>, migrated_regions: BTreeSet, region_migrator: BTreeMap, @@ -408,6 +416,8 @@ impl<'a> Reducer<'a> { event_pitches: BTreeMap::new(), voice_occupancy: BTreeMap::new(), last_respell: BTreeMap::new(), + last_event_modify: BTreeMap::new(), + last_pitch_modify: BTreeMap::new(), structures: BTreeMap::new(), migrated_regions: BTreeSet::new(), region_migrator: BTreeMap::new(), @@ -1093,6 +1103,11 @@ impl<'a> Reducer<'a> { // Extension-defined primitive: opaque to the core; recorded as // applied (the extension realizes its own effect). OperationKind::Registered(_, _) => OperationEffect::Applied, + OperationKind::ModifyEvent(op) => self.modify_event(env, op), + OperationKind::Transpose(op) => self.transpose(env, op), + OperationKind::InsertIdentifiedPitch(op) => self.insert_identified_pitch(env, op), + OperationKind::DeleteIdentifiedPitch(op) => self.delete_identified_pitch(env, op), + OperationKind::ModifyIdentifiedPitch(op) => self.modify_identified_pitch(env, op), }, OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op), OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op), @@ -1784,6 +1799,308 @@ impl<'a> Reducer<'a> { } } + // --- Group 1 (M2): event & pitch leaf-field ops. ------------------------ + // + // The modify/transpose ops follow respell's field-overwrite discipline but + // do NOT store the resolved value in MaterializedState (an Event/Pitch is a + // graph object, not a bookkeeping-owned annotation like a spelling): their + // canonical footprint is the effect-log entry plus, on a concurrent differing + // write, a StructuralFieldCollision. The resolved value is materialized in the + // graph by reduce_onto. The LWW key/diff uses `last_*_modify` working state. + + fn modify_event(&mut self, env: &OperationEnvelope, op: &ModifyEventOp) -> OperationEffect { + let event_id = op.event_id(); + let ev_obj = TypedObjectId::Event(event_id); + match self.objects.get(&ev_obj) { + None => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + } + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + } + } + Some(ObjectState::Live) => {} + } + let prev = self + .last_event_modify + .get(&event_id) + .map(|(o, e)| (*o, e.clone())); + let effect = match prev { + Some((prev_op, prev_event)) if self.concurrent(env.id, prev_op) => { + if prev_event == op.event { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }; + } + // Later in canonical order wins and materializes; the earlier op + // is the loser. (Winner carries the Conflicted tag; see DECISIONS.) + let conflict = ConflictRecord::new( + ConflictKind::StructuralFieldCollision { + winner: env.id, + loser: prev_op, + field: FieldPath("event".to_string()), + }, + vec![env.id, prev_op], + vec![ev_obj], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + OperationEffect::Conflicted { conflict: cid } + } + // First modify, or a causally-ordered intentional overwrite. + _ => OperationEffect::Applied, + }; + self.last_event_modify + .insert(event_id, (env.id, op.event.clone())); + self.graph_replace_event(&op.event); + effect + } + + fn transpose(&mut self, _env: &OperationEnvelope, op: &TransposeOp) -> OperationEffect { + // Precondition: every target pitch is live. Transpose is order-dependent + // (transpositions do not commute); its canonical footprint is the + // effect-log entry. The transposed values are materialized in the graph. + for pitch in &op.targets { + match self.objects.get(&TypedObjectId::Pitch(*pitch)) { + Some(ObjectState::Live) => {} + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + } + } + None => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + } + } + } + for pitch in &op.targets { + self.graph_transpose_pitch(*pitch, op.chromatic_steps); + } + OperationEffect::Applied + } + + fn insert_identified_pitch( + &mut self, + env: &OperationEnvelope, + op: &InsertIdentifiedPitchOp, + ) -> OperationEffect { + let pitch_id = op.pitch_id(); + let p_obj = TypedObjectId::Pitch(pitch_id); + // The target event must be live; the pitch id must be fresh. + if !matches!( + self.objects.get(&TypedObjectId::Event(op.event)), + Some(ObjectState::Live) + ) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + match self.objects.get(&p_obj) { + Some(ObjectState::Live) => { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + } + } + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + } + } + None => {} + } + self.objects.insert(p_obj, ObjectState::Live); + self.minted_by.insert(p_obj, env.id); + self.note_minted(env, p_obj); + self.event_pitches + .entry(op.event) + .or_default() + .push(pitch_id); + self.graph_insert_pitch(op.event, &op.pitch); + OperationEffect::Applied + } + + fn delete_identified_pitch( + &mut self, + env: &OperationEnvelope, + op: &DeleteIdentifiedPitchOp, + ) -> OperationEffect { + let p_obj = TypedObjectId::Pitch(op.pitch); + let minted_by = match self.objects.get(&p_obj) { + None => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + } + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + } + } + Some(ObjectState::Live) => self.minted_by.get(&p_obj).copied().unwrap_or(env.id), + }; + self.objects.insert( + p_obj, + ObjectState::Tombstoned { + deleted_by: env.id, + minted_by, + }, + ); + for pitches in self.event_pitches.values_mut() { + pitches.retain(|p| *p != op.pitch); + } + self.graph_delete_pitch(op.pitch); + OperationEffect::Applied + } + + fn modify_identified_pitch( + &mut self, + env: &OperationEnvelope, + op: &ModifyIdentifiedPitchOp, + ) -> OperationEffect { + let p_obj = TypedObjectId::Pitch(op.pitch); + match self.objects.get(&p_obj) { + None => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + } + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + } + } + Some(ObjectState::Live) => {} + } + let prev = self + .last_pitch_modify + .get(&op.pitch) + .map(|(o, v)| (*o, v.clone())); + let effect = match prev { + Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => { + if prev_value == op.value { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + }; + } + let conflict = ConflictRecord::new( + ConflictKind::StructuralFieldCollision { + winner: env.id, + loser: prev_op, + field: FieldPath("pitch".to_string()), + }, + vec![env.id, prev_op], + vec![p_obj], + ); + let cid = conflict.id; + self.conflicts.insert(conflict); + OperationEffect::Conflicted { conflict: cid } + } + _ => OperationEffect::Applied, + }; + self.last_pitch_modify + .insert(op.pitch, (env.id, op.value.clone())); + self.graph_modify_pitch(op.pitch, &op.value); + effect + } + + // --- Group 1 graph mutations (reduce_onto only; no-op when graph is None). -- + + fn graph_replace_event(&mut self, new_event: &Event) { + let Some(score) = self.graph.as_mut() else { + return; + }; + if let Some(existing) = score.events.get_mut(new_event.id()) { + // Preserve the original voice membership (placement is owned by the + // voice lists; ModifyEvent overwrites the event's fields, not its + // container). Re-sorting on a position change is deferred. + let voice = existing.voice(); + let mut replacement = new_event.clone(); + replacement.set_voice(voice); + *existing = replacement; + } + } + + fn graph_event_of_pitch(score: &Score, pitch: PitchId) -> Option { + score.events.iter().find_map(|event| { + let mut ips = Vec::new(); + event.collect_identified_pitches(&mut ips); + ips.iter().any(|ip| ip.id == pitch).then(|| event.id()) + }) + } + + fn graph_insert_pitch(&mut self, event: EventId, pitch: &epiphany_core::IdentifiedPitch) { + let Some(score) = self.graph.as_mut() else { + return; + }; + if let Some(Event::Pitched(pe)) = score.events.get_mut(event) { + if !pe.pitches.iter().any(|ip| ip.id == pitch.id) { + pe.pitches.push(pitch.clone()); + } + } + } + + fn graph_delete_pitch(&mut self, pitch: PitchId) { + let Some(score) = self.graph.as_mut() else { + return; + }; + let Some(event) = Self::graph_event_of_pitch(score, pitch) else { + return; + }; + if let Some(Event::Pitched(pe)) = score.events.get_mut(event) { + pe.pitches.retain(|ip| ip.id != pitch); + } + } + + fn graph_modify_pitch(&mut self, pitch: PitchId, value: &Pitch) { + let Some(score) = self.graph.as_mut() else { + return; + }; + let Some(event) = Self::graph_event_of_pitch(score, pitch) else { + return; + }; + if let Some(Event::Pitched(pe)) = score.events.get_mut(event) { + if let Some(ip) = pe.pitches.iter_mut().find(|ip| ip.id == pitch) { + ip.pitch = value.clone(); + } + } + } + + fn graph_transpose_pitch(&mut self, pitch: PitchId, chromatic_steps: i32) { + let Some(score) = self.graph.as_mut() else { + return; + }; + let Some(event) = Self::graph_event_of_pitch(score, pitch) else { + return; + }; + if let Some(Event::Pitched(pe)) = score.events.get_mut(event) { + if let Some(ip) = pe.pitches.iter_mut().find(|ip| ip.id == pitch) { + // Minimal interval: shift the CMN alteration. Full interval + // algebra (Chapter 4 tuning) is deferred — P12-K2. + if let epiphany_core::PitchSpacePosition::Cmn { alteration, .. } = + &mut ip.pitch.scale_position.position + { + let shifted = (*alteration as i32).saturating_add(chromatic_steps); + *alteration = shifted.clamp(i8::MIN as i32, i8::MAX as i32) as i8; + } + } + } + } + // --- Re-anchoring (Chapter 6 §6.5 rule table, representative subset). ---- fn reanchor_for_tombstone( @@ -2009,6 +2326,8 @@ impl<'a> Reducer<'a> { event_pitches: self.event_pitches.clone(), voice_occupancy: self.voice_occupancy.clone(), last_respell: self.last_respell.clone(), + last_event_modify: self.last_event_modify.clone(), + last_pitch_modify: self.last_pitch_modify.clone(), structures: self.structures.clone(), migrated_regions: self.migrated_regions.clone(), region_migrator: self.region_migrator.clone(), @@ -2027,6 +2346,8 @@ impl<'a> Reducer<'a> { self.event_pitches = s.event_pitches; self.voice_occupancy = s.voice_occupancy; self.last_respell = s.last_respell; + self.last_event_modify = s.last_event_modify; + self.last_pitch_modify = s.last_pitch_modify; self.structures = s.structures; self.migrated_regions = s.migrated_regions; self.region_migrator = s.region_migrator; @@ -2384,4 +2705,112 @@ mod tests { "Dismiss action must select the Dismissed state" ); } + + // --- Group 1 (M2) behavior. -------------------------------------------- + + fn prim_env( + replica: u64, + counter: u64, + physical: i64, + ctx: CausalContext, + kind: OperationKind, + ) -> OperationEnvelope { + let id = OperationId::new(ReplicaId(replica), counter); + OperationEnvelope { + id, + author: AuthorId(0), + stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id), + causal_context: ctx, + transaction: None, + payload: OperationPayload::Primitive(kind), + } + } + + fn modify_event_of(event: EventId, pitch: PitchId) -> OperationKind { + OperationKind::ModifyEvent(crate::payload::ModifyEventOp { + event: crate::valuegen::insert_event_value( + event, + VoiceId::new(ReplicaId(9), 1), + pos(0), + epiphany_core::MusicalDuration::whole(), + &[pitch], + ), + }) + } + + #[test] + fn concurrent_differing_modify_event_conflicts() { + let event = EventId::new(ReplicaId(1), 100); + let seen = CausalContext::new().with_seen(ReplicaId(1), 0); + // Insert the event (replica 1), then two concurrent differing modifies. + let insert = insert(1, 0, 10, 1, 100, 0); + let mod_a = prim_env( + 2, + 0, + 20, + seen.clone(), + modify_event_of(event, PitchId::new(ReplicaId(1), 1)), + ); + let mod_b = prim_env( + 3, + 0, + 20, + seen, + modify_event_of(event, PitchId::new(ReplicaId(1), 2)), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![insert, mod_a, mod_b]); + let state = set.reduce(); + assert_eq!( + state.conflicts.records().len(), + 1, + "concurrent differing ModifyEvent must record exactly one conflict" + ); + assert!(matches!( + state.conflicts.records()[0].kind, + ConflictKind::StructuralFieldCollision { .. } + )); + } + + #[test] + fn insert_then_delete_identified_pitch_tombstones() { + let event = EventId::new(ReplicaId(1), 100); + let pitch = PitchId::new(ReplicaId(1), 7); + let insert = insert(1, 0, 10, 1, 100, 0); + let add = prim_env( + 1, + 1, + 20, + CausalContext::new().with_seen(ReplicaId(1), 0), + OperationKind::InsertIdentifiedPitch(crate::payload::InsertIdentifiedPitchOp { + event, + pitch: crate::valuegen::identified_pitch(pitch), + }), + ); + let del = prim_env( + 1, + 2, + 30, + CausalContext::new().with_seen(ReplicaId(1), 1), + OperationKind::DeleteIdentifiedPitch(crate::payload::DeleteIdentifiedPitchOp { pitch }), + ); + + let mut after_add = OperationSet::new(); + after_add.accept_all(vec![insert.clone(), add.clone()]); + assert_eq!( + after_add.reduce().objects.get(&TypedObjectId::Pitch(pitch)), + Some(&ObjectState::Live), + "InsertIdentifiedPitch mints the pitch live" + ); + + let mut after_del = OperationSet::new(); + after_del.accept_all(vec![insert, add, del]); + assert!( + matches!( + after_del.reduce().objects.get(&TypedObjectId::Pitch(pitch)), + Some(ObjectState::Tombstoned { .. }) + ), + "DeleteIdentifiedPitch tombstones the pitch" + ); + } } diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs index ad12975..fdd7fe9 100644 --- a/crates/epiphany-ops/src/v0.rs +++ b/crates/epiphany-ops/src/v0.rs @@ -39,6 +39,9 @@ pub struct V0OperationEnvelope { } /// Frozen v0 payload union (pre-catalog). +// Mirrors the live payload's size profile (the v1-native Group-1 kinds carry +// whole values); inline values are the design (see `payload::OperationKind`). +#[allow(clippy::large_enum_variant)] #[derive(Clone, PartialEq, Eq, Debug)] pub enum V0OperationPayload { Primitive(V0OperationKind), @@ -49,6 +52,7 @@ pub enum V0OperationPayload { } /// Frozen v0 primitive kinds (the representative §6.10 set). +#[allow(clippy::large_enum_variant)] #[derive(Clone, PartialEq, Eq, Debug)] pub enum V0OperationKind { InsertEvent(V0InsertEventOp), @@ -61,6 +65,15 @@ pub enum V0OperationKind { DeclareTransaction(TransactionDescriptor), /// Opaque extension payload; unchanged in v1. Registered(OperationKindRegistryId, Vec), + // --- Group 1 (M2) kinds. These are v1-native: they had no identifier-only + // v0 predecessor, so their "v0 form" carries the v1 payload verbatim and the + // migration round-trips them by identity (only the original kinds above have + // a lossy v0 projection to reconstruct). --- + ModifyEvent(crate::payload::ModifyEventOp), + Transpose(crate::payload::TransposeOp), + InsertIdentifiedPitch(crate::payload::InsertIdentifiedPitchOp), + DeleteIdentifiedPitch(crate::payload::DeleteIdentifiedPitchOp), + ModifyIdentifiedPitch(crate::payload::ModifyIdentifiedPitchOp), } /// 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 582aa2b..d34d4a8 100644 --- a/crates/epiphany-ops/src/valuegen.rs +++ b/crates/epiphany-ops/src/valuegen.rs @@ -51,6 +51,35 @@ pub fn identified_pitch(id: PitchId) -> IdentifiedPitch { } } +/// A distinct CMN [`Pitch`] per `nth` (injective over the `u8`, like +/// [`spelling`]): nominal = `nth % 7`, octave = `nth / 7`. Lets a harness make +/// concurrent `ModifyIdentifiedPitch`es agree or conflict deterministically. +pub fn pitch_value_nth(nth: u8) -> Pitch { + let nominal = match nth % 7 { + 0 => CmnNominal::C, + 1 => CmnNominal::D, + 2 => CmnNominal::E, + 3 => CmnNominal::F, + 4 => CmnNominal::G, + 5 => CmnNominal::A, + _ => CmnNominal::B, + }; + Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("cmn-12"), + position: PitchSpacePosition::Cmn { + nominal, + alteration: 0, + octave: (nth / 7) as i8, + }, + }, + acoustic: AcousticPitch { + tuning: epiphany_core::TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + } +} + /// The event an InsertEvent inserts: a pitched event when `pitch_ids` is /// non-empty, otherwise a visible rest. Mirrors the prototype's /// pitched-or-rest split, now as a real value. diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index a11eb8b..256a73f 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -33,16 +33,16 @@ use epiphany_ops::{ AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry, ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, - ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, IntegrityAnomaly, - IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, NoOpReason, ObjectKind, - ObjectState, OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, - OperationPayload, OperationSet, OperationStamp, PendingReason, PositionRemapping, - PreconditionFailureReason, PreconditionFailureRegistryId, ReanchorReason, - ReanchorReasonRegistryId, ReanchorResult, RepairKind, RepairKindRegistryId, RepairRecord, - ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId, - ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, SetUserSystemBreakOp, - TransactionCategory, TransactionDescriptor, TupletCompensation, TupletCompensationKind, - UndoPolicy, UndoTransactionPayload, + DeleteIdentifiedPitchOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, + InsertIdentifiedPitchOp, IntegrityAnomaly, IntegrityAnomalyKind, IntegrityAnomalyRegistryId, + MaterializedState, ModifyEventOp, ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState, + OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload, + OperationSet, OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason, + PreconditionFailureRegistryId, ReanchorReason, ReanchorReasonRegistryId, ReanchorResult, + RepairKind, RepairKindRegistryId, RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId, + ResolutionAction, ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp, + SerializedCanonicalInputs, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, + TransposeOp, TupletCompensation, TupletCompensationKind, UndoPolicy, UndoTransactionPayload, }; use crate::rng::Rng; @@ -634,7 +634,7 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP } _ => {} } - let kind = match rng.below(8) { + let kind = match rng.below(13) { 0 => { let pitches = if rng.boolean() { vec![obj_pitch(rng.below(pitches))] @@ -694,6 +694,31 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP TransactionCategory::Layout, ])), }), + // Group 1 (M2): event & pitch leaf-field ops over the shared id space. + 7 => OperationKind::ModifyEvent(ModifyEventOp { + event: valuegen::insert_event_value( + obj_event(rng.below(events)), + VoiceId::new(OBJ_REPLICA, rng.below(4)), + MusicalPosition(RationalTime::from_int(rng.below(events) as i32)), + MusicalDuration::whole(), + &[obj_pitch(rng.below(pitches))], + ), + }), + 8 => OperationKind::Transpose(TransposeOp { + targets: vec![obj_pitch(rng.below(pitches))], + chromatic_steps: rng.below(5) as i32 - 2, + }), + 9 => OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp { + event: obj_event(rng.below(events)), + pitch: valuegen::identified_pitch(obj_pitch(rng.below(pitches))), + }), + 10 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp { + pitch: obj_pitch(rng.below(pitches)), + }), + 11 => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp { + pitch: obj_pitch(rng.below(pitches)), + value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1), + }), _ => OperationKind::Registered( OperationKindRegistryId(rng.next_u64() as u128), rng.byte_vec(0, 16), diff --git a/spec/PASS12_BATCH.md b/spec/PASS12_BATCH.md index b82b072..290779b 100644 --- a/spec/PASS12_BATCH.md +++ b/spec/PASS12_BATCH.md @@ -30,6 +30,7 @@ code instead is the failure mode this batch exists to prevent. | P12-I2 | `epiphany-render-svg` / `engrave` I | Stable layout-object id derivation (`MUSCLOID`, Pass-11 item 2.6, deferred to I) is still unwired: the frozen `epiphany-determinism` exposes no `MUSCLOID` tag, so provenance is traced via the provisional `stable_id`. Wiring the ratified derivation is Track A work (already noted in `layout-ir/DECISIONS.md`). | G (determinism tag) / Track A | | P12-I3 | `epiphany-layout-ir` I | The bundled `BRAVURA_METRICS` are *approximations* that disagree with the genuine Bravura outlines the renderer now extracts from the font (e.g. `timeSig4`: metrics bbox `[40,0,1240,2048]` vs real outline ≈ `[0.08,-1.0,1.8,1.004]` staff spaces). Real spacing needs exact metrics; regenerate the metrics table from the font or reconcile it with the outline source. | G / Pass 12 (glyph metrics) | | P12-K1 | `epiphany-ops` K | A v0 `RespellPitch` carried a `ContentHash` *fingerprint* of the spelling, not the `PitchSpelling`. The v0→v1 migration (Operation Catalog, M1) cannot invert a fingerprint, so it recovers the spelling from the score-graph context (an explicit per-pitch spelling attachment whose canonical bytes hash to the fingerprint) and returns `MigrationError::Irreversible` (bundle opens read-only) when the context lacks it. Every other representative payload migrates self-contained; this is the lone exception. Confirm the read-only fallback is the intended disposition vs. requiring a v0 corpus that preserves spelling pre-images. | G / Pass 12 (migration) | +| P12-K2 | `epiphany-ops` K | The `Transpose` op (Operation Catalog, M2 Group 1) carries a minimal `chromatic_steps: i32` interval and `reduce_onto` applies it as a CMN *alteration* shift only. Faithful interval algebra (diatonic vs. chromatic intervals, octave/nominal renormalization, transposition in non-CMN pitch spaces) is the deferred Chapter 4 tuning-catalog territory. Pin the interval representation and transposition semantics when the tuning catalog lands. | G / Pass 12 (tuning) | ## Not yet open elsewhere