//! Operation payloads: [`OperationKind`], the discriminator-only //! [`OperationKindTag`], [`OperationPayload`], and the representative operation //! structs the chapter specifies reduction rules for (Chapter 6 §"The Operation //! Framework", §"Representative Operations"). //! //! ## These payloads are value-typed (Operation Catalog, v1) //! //! Track B's Operation Catalog (Agent K) shifted these payloads from the v0 //! *identifier-only projection* (an `InsertEvent` that carried only an `EventId` //! plus scalars; a `RespellPitch` that carried only a [`ContentHash`] fingerprint //! of the new spelling) to the **value-typed** form the spec describes: //! `InsertEventOp` carries the real [`Event`], `RespellPitchOp` carries the real //! [`PitchSpelling`], `CreateCrossCuttingOp` carries the real cross-cutting //! structure, and so on. This is what makes an operation *durable* — replayable //! in a fresh context (a backup restore, a cross-tool round-trip) without the //! originating graph. //! //! The payloads serialize canonically by embedding each value's //! [`CanonicalValue`] bytes behind a `u32` length prefix (the same ratified byte //! layout `epiphany-core`'s whole-score codec uses — Pass 11 item 1.8, //! `req:format:codec-conventions`), so an [`OperationEnvelope`](crate::OperationEnvelope) //! stays hashable ([`EnvelopeHash`](crate::EnvelopeHash) / slot equivocation). The //! v0 shapes are retained in [`crate::v0`] solely as the migration regression //! guard; the [`migrate_v0_envelope`](crate::migrate_v0_envelope) path lifts a v0 //! envelope to this v1 form (deterministically, preserving canonical reduction //! state). See `DECISIONS.md` (P11-C1 resolved; P12-K1). //! //! The *set* of kinds here is the representative selection of §6.10, not the full //! ~60–80-primitive catalog (an explicit open question, §6.11). Together they //! exercise every reduction discipline the chapter defines. The Operation Catalog //! companion (`spec/operation_catalog.tex`) is the normative schema for these //! payloads. use epiphany_core::{ Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch, MetricGrid, MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, Region, RegionId, RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, StaffInstance, StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, Voice, VoiceId, }; use epiphany_determinism::{sorted_canonical, CanonicalEncode}; use crate::conflict::{ConflictId, ResolutionAction}; use crate::encode::{push_canon, push_lp_bytes, push_seq, push_str, push_tag, push_u8_bool}; use crate::support::OperationKindRegistryId; use crate::undo::UndoTransactionPayload; /// The full payload of an operation envelope: a primitive, or one of the two /// meta-operations (Chapter 6 §"Operation Envelopes"). // v1 payloads carry whole graph values, so the `Primitive` variant is // intentionally larger than the meta-operations — the durability the catalog // requires. Operation payloads are not packed in a hot path, so the size // disparity is acceptable rather than worth a `Box` indirection on every match. #[allow(clippy::large_enum_variant)] #[derive(Clone, PartialEq, Eq, Debug)] pub enum OperationPayload { /// A primitive mutation. Primitive(OperationKind), /// A meta-operation that resolves a previously-recorded conflict. ResolveConflict(ResolveConflictPayload), /// A meta-operation that compensates for a previously-committed /// transaction; the realization of "undo". UndoTransaction(UndoTransactionPayload), } impl OperationPayload { fn discriminant(&self) -> u8 { match self { OperationPayload::Primitive(_) => 0, OperationPayload::ResolveConflict(_) => 1, OperationPayload::UndoTransaction(_) => 2, } } } impl CanonicalEncode for OperationPayload { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); match self { OperationPayload::Primitive(k) => k.encode_canonical(out), OperationPayload::ResolveConflict(p) => p.encode_canonical(out), OperationPayload::UndoTransaction(p) => p.encode_canonical(out), } } } /// The catalog of primitive operation kinds reduced by this crate (Chapter 6 /// §"Operation Envelopes", representative subset of §6.10). // `InsertEvent` carries a whole `Event`, so this variant is intentionally larger // than the others (see `OperationPayload`); inline values are the v1 design. #[allow(clippy::large_enum_variant)] #[derive(Clone, PartialEq, Eq, Debug)] pub enum OperationKind { /// Insert an event into a voice (position-keyed; voice promotion on /// concurrent overlap). InsertEvent(InsertEventOp), /// Tombstone an event and its pitches, with tuplet compensation. DeleteEvent(DeleteEventOp), /// Overwrite a pitch's spelling (later-in-canonical-order wins). RespellPitch(RespellPitchOp), /// Create a cross-cutting structure (set union). CreateCrossCutting(CreateCrossCuttingOp), /// Change a region's time model (structural migration). ChangeRegionTimeModel(ChangeRegionTimeModelOp), /// Set a user system-break preference (LWW advisory). SetUserSystemBreak(SetUserSystemBreakOp), /// Declare a transaction; member primitives reference it by id. 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), // --- Group 2 (M2): cross-cutting CRUD. Discriminants extend additively. --- /// Tombstone a cross-cutting structure (delete-wins). DeleteCrossCutting(DeleteCrossCuttingOp), /// Overwrite a cross-cutting structure's value (later-in-canonical-order /// wins). ModifyCrossCutting(ModifyCrossCuttingOp), // --- 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), // --- Group 4 (M2d): score settings. LWW field-overwrite. --- /// Overwrite the score metadata (later-in-canonical-order wins). SetMetadata(SetMetadataOp), /// Overwrite a region's default metric grid (later-in-canonical-order wins). SetMetricGrid(SetMetricGridOp), /// Set a user page-break preference (LWW advisory; the page-break sibling of /// `SetUserSystemBreak`). SetUserPageBreak(SetUserPageBreakOp), } impl OperationKind { fn discriminant(&self) -> u8 { match self { OperationKind::InsertEvent(_) => 0, OperationKind::DeleteEvent(_) => 1, OperationKind::RespellPitch(_) => 2, OperationKind::CreateCrossCutting(_) => 3, OperationKind::ChangeRegionTimeModel(_) => 4, OperationKind::SetUserSystemBreak(_) => 5, OperationKind::DeclareTransaction(_) => 6, OperationKind::Registered(..) => 7, OperationKind::ModifyEvent(_) => 8, OperationKind::Transpose(_) => 9, OperationKind::InsertIdentifiedPitch(_) => 10, OperationKind::DeleteIdentifiedPitch(_) => 11, 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, OperationKind::SetMetadata(_) => 21, OperationKind::SetMetricGrid(_) => 22, OperationKind::SetUserPageBreak(_) => 23, } } /// The discriminator-only [`OperationKindTag`] for this kind. Used by edit /// barriers (Chapter 7/8 `prohibited_operation_kinds`) to name a kind /// without its payload. pub fn tag(&self) -> OperationKindTag { match self { OperationKind::InsertEvent(_) => OperationKindTag::InsertEvent, OperationKind::DeleteEvent(_) => OperationKindTag::DeleteEvent, OperationKind::RespellPitch(_) => OperationKindTag::RespellPitch, OperationKind::CreateCrossCutting(_) => OperationKindTag::CreateCrossCutting, OperationKind::ChangeRegionTimeModel(_) => OperationKindTag::ChangeRegionTimeModel, 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, 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, OperationKind::SetMetadata(_) => OperationKindTag::SetMetadata, OperationKind::SetMetricGrid(_) => OperationKindTag::SetMetricGrid, OperationKind::SetUserPageBreak(_) => OperationKindTag::SetUserPageBreak, } } } impl CanonicalEncode for OperationKind { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); match self { OperationKind::InsertEvent(op) => op.encode_canonical(out), OperationKind::DeleteEvent(op) => op.encode_canonical(out), OperationKind::RespellPitch(op) => op.encode_canonical(out), OperationKind::CreateCrossCutting(op) => op.encode_canonical(out), OperationKind::ChangeRegionTimeModel(op) => op.encode_canonical(out), OperationKind::SetUserSystemBreak(op) => op.encode_canonical(out), OperationKind::DeclareTransaction(op) => op.encode_canonical(out), OperationKind::Registered(id, bytes) => { 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), 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), OperationKind::SetMetadata(op) => op.encode_canonical(out), OperationKind::SetMetricGrid(op) => op.encode_canonical(out), OperationKind::SetUserPageBreak(op) => op.encode_canonical(out), } } } /// The discriminator-only projection of [`OperationKind`] (Chapter 6; the type /// edit barriers store in `prohibited_operation_kinds`). Carries no payload — /// except the registry id for [`Registered`](OperationKindTag::Registered), so a /// barrier can prohibit a specific extension kind. #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] pub enum OperationKindTag { InsertEvent, DeleteEvent, ModifyEvent, RespellPitch, Transpose, CreateCrossCutting, DeleteCrossCutting, ModifyCrossCutting, ChangeRegionTimeModel, InsertRegion, DeleteRegion, InsertStaffInstance, DeleteStaffInstance, SetUserSystemBreak, SetUserPageBreak, DeclareTransaction, Registered(OperationKindRegistryId), InsertIdentifiedPitch, DeleteIdentifiedPitch, ModifyIdentifiedPitch, CreateVoice, DeleteVoice, SetMetadata, SetMetricGrid, } impl OperationKindTag { fn discriminant(&self) -> u8 { match self { OperationKindTag::InsertEvent => 0, OperationKindTag::DeleteEvent => 1, OperationKindTag::ModifyEvent => 2, OperationKindTag::RespellPitch => 3, OperationKindTag::Transpose => 4, OperationKindTag::CreateCrossCutting => 5, OperationKindTag::DeleteCrossCutting => 6, OperationKindTag::ModifyCrossCutting => 7, OperationKindTag::ChangeRegionTimeModel => 8, OperationKindTag::InsertRegion => 9, OperationKindTag::DeleteRegion => 10, OperationKindTag::InsertStaffInstance => 11, OperationKindTag::DeleteStaffInstance => 12, OperationKindTag::SetUserSystemBreak => 13, OperationKindTag::SetUserPageBreak => 14, OperationKindTag::DeclareTransaction => 15, OperationKindTag::Registered(_) => 16, OperationKindTag::InsertIdentifiedPitch => 17, OperationKindTag::DeleteIdentifiedPitch => 18, OperationKindTag::ModifyIdentifiedPitch => 19, OperationKindTag::CreateVoice => 20, OperationKindTag::DeleteVoice => 21, OperationKindTag::SetMetadata => 22, OperationKindTag::SetMetricGrid => 23, } } } impl CanonicalEncode for OperationKindTag { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); if let OperationKindTag::Registered(id) = self { push_canon(out, id); } } } // --- Representative operation payloads (Chapter 6 §6.10). -------------------- /// Insert an event into a voice (Chapter 6 §6.10 InsertEvent). Carries the full /// [`Event`] value (v1, value-typed); the voice, position, duration, and pitch /// identities the reduction keys on are read from it via the accessors below. /// /// `staff_instance` is retained alongside the event so the system-promoted-voice /// derivation is total without a containment walk (a full reducer recovers it /// from the voice's container; see `DECISIONS.md`). #[derive(Clone, PartialEq, Eq, Debug)] pub struct InsertEventOp { pub staff_instance: StaffInstanceId, pub event: Event, } impl InsertEventOp { /// The voice the event is inserted into (the bucketing / promotion key). pub fn voice(&self) -> VoiceId { self.event.voice() } /// The inserted event's identifier. pub fn event_id(&self) -> EventId { self.event.id() } /// The pitch identities the event embeds (minted live with the event; /// tombstoned with it on delete). pub fn pitch_ids(&self) -> Vec { let mut ips: Vec<&epiphany_core::IdentifiedPitch> = Vec::new(); self.event.collect_identified_pitches(&mut ips); ips.iter().map(|ip| ip.id).collect() } /// The event's musical position — the voice-promotion collision key. A /// non-musical position (reachable only in a non-metric region, which the /// graph precondition rejects for InsertEvent) reads as the origin. pub fn musical_position(&self) -> MusicalPosition { match self.event.position() { EventPosition::Musical(p) => p.clone(), EventPosition::WallClock(_) => MusicalPosition::origin(), } } /// The event's musical duration — the other half of the collision interval. pub fn musical_duration(&self) -> MusicalDuration { match self.event.duration() { EventDuration::Musical(d) => d.clone(), EventDuration::WallClock(_) | EventDuration::Indeterminate(_) => { MusicalDuration::zero() } } } } impl CanonicalEncode for InsertEventOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.staff_instance); push_lp_bytes(out, &self.event.canonical_bytes()); } } /// Tombstone an event and its pitches (Chapter 6 §6.10 DeleteEvent). #[derive(Clone, PartialEq, Eq, Debug)] pub struct DeleteEventOp { pub event: EventId, pub tuplet_compensation: TupletCompensation, } impl CanonicalEncode for DeleteEventOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.event); self.tuplet_compensation.encode_canonical(out); } } /// Compensation for a deleted event's tuplet membership (Chapter 6 §6.10). /// The replacement rest carries its full [`Rest`] value (v1, value-typed); the /// reducer adds it live and validates the duration. #[derive(Clone, PartialEq, Eq, Debug)] pub enum TupletCompensation { /// Target is not in any tuplet. NotInTuplet, /// Replace the deleted event with a rest of the same duration. ReplaceWithRest { rest: Rest }, /// Rewrite the enclosing tuplet(s) to remain consistent. RewriteTuplets { tuplets: Vec }, /// Cascade-delete the tuplet group(s) containing the target. CascadeDeleteTuplets { tuplets: Vec }, } impl TupletCompensation { fn discriminant(&self) -> u8 { match self { TupletCompensation::NotInTuplet => 0, TupletCompensation::ReplaceWithRest { .. } => 1, TupletCompensation::RewriteTuplets { .. } => 2, TupletCompensation::CascadeDeleteTuplets { .. } => 3, } } /// Whether this declares any tuplet compensation (i.e. is not /// [`NotInTuplet`](TupletCompensation::NotInTuplet)). pub fn is_compensating(&self) -> bool { !matches!(self, TupletCompensation::NotInTuplet) } } impl CanonicalEncode for TupletCompensation { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); match self { TupletCompensation::NotInTuplet => {} TupletCompensation::ReplaceWithRest { rest } => { push_lp_bytes(out, &rest.canonical_bytes()); } TupletCompensation::RewriteTuplets { tuplets } | TupletCompensation::CascadeDeleteTuplets { tuplets } => { push_seq(out, &sorted_canonical(tuplets.clone())); } } } } /// Overwrite a pitch's spelling (Chapter 6 §6.10 RespellPitch). Carries the full /// [`PitchSpelling`] value (v1, value-typed); the reduction needs spelling /// *equality* (identical concurrent respellings reduce idempotently; differing /// ones conflict), which is now structural value equality rather than a /// fingerprint comparison. #[derive(Clone, PartialEq, Eq, Debug)] pub struct RespellPitchOp { pub pitch: PitchId, pub spelling: PitchSpelling, } impl CanonicalEncode for RespellPitchOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.pitch); push_lp_bytes(out, &self.spelling.canonical_bytes()); } } /// Create a cross-cutting structure (Chapter 6 §6.10 CreateCrossCutting). Carries /// the full typed structure value (v1, value-typed); the set-union reduction and /// the re-anchoring rule table key on its [`CrossCuttingValue::id`] and /// [`CrossCuttingValue::endpoints`], which it derives from the value. #[derive(Clone, PartialEq, Eq, Debug)] pub struct CreateCrossCuttingOp { pub structure: CrossCuttingValue, } impl CanonicalEncode for CreateCrossCuttingOp { fn encode_canonical(&self, out: &mut Vec) { self.structure.encode_canonical(out); } } /// A cross-cutting structure value (Chapter 5 §"Cross-Cutting Structures"): the /// representative event-anchored family the reduction materializes. The reduction /// keys only on the [`id`](CrossCuttingValue::id) and /// [`endpoints`](CrossCuttingValue::endpoints); the rich per-kind fields (a tie's /// class, a beam's level) carry through to the graph materialization. #[derive(Clone, PartialEq, Eq, Debug)] pub enum CrossCuttingValue { Tie(Tie), Slur(Slur), Beam(Beam), Spanner(Spanner), } impl CrossCuttingValue { fn discriminant(&self) -> u8 { match self { CrossCuttingValue::Tie(_) => 0, CrossCuttingValue::Slur(_) => 1, CrossCuttingValue::Beam(_) => 2, CrossCuttingValue::Spanner(_) => 3, } } /// The structure's identity; its [`TypedObjectId`] variant names the kind. pub fn id(&self) -> TypedObjectId { match self { CrossCuttingValue::Tie(t) => TypedObjectId::Tie(t.id), CrossCuttingValue::Slur(s) => TypedObjectId::Slur(s.id), CrossCuttingValue::Beam(b) => TypedObjectId::Beam(b.id), CrossCuttingValue::Spanner(s) => TypedObjectId::Spanner(s.id), } } /// The objects this structure references (its endpoints/anchors), in /// significant order (start before end). A spanner contributes the event /// ids of any [`TimeAnchor::Event`] endpoints it carries. pub fn endpoints(&self) -> Vec { match self { CrossCuttingValue::Tie(t) => vec![ TypedObjectId::Event(t.start_event), TypedObjectId::Event(t.end_event), ], CrossCuttingValue::Slur(s) => vec![ TypedObjectId::Event(s.start_event), TypedObjectId::Event(s.end_event), ], CrossCuttingValue::Beam(b) => { b.events.iter().copied().map(TypedObjectId::Event).collect() } CrossCuttingValue::Spanner(s) => [&s.start, &s.end] .into_iter() .filter_map(|anchor| match anchor { TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)), _ => None, }) .collect(), } } } impl CanonicalEncode for CrossCuttingValue { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); let bytes = match self { CrossCuttingValue::Tie(t) => t.canonical_bytes(), CrossCuttingValue::Slur(s) => s.canonical_bytes(), CrossCuttingValue::Beam(b) => b.canonical_bytes(), CrossCuttingValue::Spanner(s) => s.canonical_bytes(), }; push_lp_bytes(out, &bytes); } } /// Change a region's time model (Chapter 6 §6.10 ChangeRegionTimeModel). Carries /// the full target [`RegionTimeModel`] value (v1, value-typed); the reduction /// keys coordinate-kind compatibility on the value's *kind*. The authoring layer /// additionally declares any events it knows to be un-migratable /// (`declared_incompatible`), which drive the `TimeModelMigrationFailure` /// conflict (see `DECISIONS.md`, P11-C6 — graph-aware reduction derives the rest /// from the region contents). #[derive(Clone, PartialEq, Eq, Debug)] pub struct ChangeRegionTimeModelOp { pub region: RegionId, pub new_time_model: RegionTimeModel, pub declared_incompatible: Vec, pub remapping: PositionRemapping, } impl CanonicalEncode for ChangeRegionTimeModelOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.region); push_lp_bytes(out, &self.new_time_model.canonical_bytes()); push_seq(out, &sorted_canonical(self.declared_incompatible.clone())); self.remapping.encode_canonical(out); } } /// How event positions are remapped under a time-model change (Chapter 6 /// §6.10). `PreserveTime`'s converter and `Reassign`'s full event positions /// follow the deferred payload encoding; the reducer consumes the event set. #[derive(Clone, PartialEq, Eq, Debug)] pub enum PositionRemapping { /// Preserve absolute time positions where possible. PreserveTime, /// Reassign positions explicitly. Reassign(Vec<(EventId, MusicalPosition)>), } impl PositionRemapping { fn discriminant(&self) -> u8 { match self { PositionRemapping::PreserveTime => 0, PositionRemapping::Reassign(_) => 1, } } } impl CanonicalEncode for PositionRemapping { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); if let PositionRemapping::Reassign(map) = self { // Canonicalize: ascending by EventId so equal mappings built in // different orders encode identically (Appendix D §Ordered Iteration). let mut entries = map.clone(); entries.sort_by_key(|(e, _)| e.canonical_bytes()); push_len_pairs(out, &entries); } } } fn push_len_pairs(out: &mut Vec, entries: &[(EventId, MusicalPosition)]) { crate::encode::push_len(out, entries.len()); for (e, p) in entries { push_canon(out, e); let mut scratch = Vec::new(); p.encode_canonical(&mut scratch); crate::encode::push_lp_bytes(out, &scratch); } } /// Set a user system-break preference (Chapter 6 §6.10 SetUserSystemBreak). /// Carries the full [`TimeAnchor`] value (v1, value-typed); the reduction's LWW /// bucketing key is the anchor's resolved [`MusicalPosition`] /// ([`SetUserSystemBreakOp::resolved_position`]). #[derive(Clone, PartialEq, Eq, Debug)] pub struct SetUserSystemBreakOp { pub region: RegionId, pub anchor: TimeAnchor, pub present: bool, } /// The musical position a [`TimeAnchor`] resolves to for user-break LWW /// bucketing. A region-relative musical offset resolves to that offset; any /// other anchor shape resolves to the region origin (the break still applies to /// the region, the prototype LWW key is coarse — see `DECISIONS.md`). The graph /// reducer keys break anchors by this same position, so two anchors resolving /// here to one position occupy a single LWW slot. pub(crate) fn resolved_anchor_position(anchor: &TimeAnchor) -> MusicalPosition { match anchor { TimeAnchor::Region { offset: epiphany_core::AnchorOffset::Musical(d), .. } => MusicalPosition(d.0.clone()), _ => MusicalPosition::origin(), } } impl SetUserSystemBreakOp { /// The anchor's resolved musical position — the canonical LWW bucketing key /// (see [`resolved_anchor_position`]). pub fn resolved_position(&self) -> MusicalPosition { resolved_anchor_position(&self.anchor) } } impl CanonicalEncode for SetUserSystemBreakOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.region); push_lp_bytes(out, &self.anchor.canonical_bytes()); push_u8_bool(out, self.present); } } /// A transaction descriptor (Chapter 6 §6.6). The descriptor is a separate /// `DeclareTransaction` envelope; member primitives reference its id and MUST /// causally depend on it (Chapter 6 §6.6.1). #[derive(Clone, PartialEq, Eq, Debug)] pub struct TransactionDescriptor { pub id: TransactionId, /// Human-readable label, for undo history and diagnostics (NFC canonical). pub label: String, /// Optional categorization, used by UIs and analytics. pub category: Option, } impl CanonicalEncode for TransactionDescriptor { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.id); push_str(out, &self.label); match &self.category { None => push_tag(out, 0), Some(c) => { push_tag(out, 1); c.encode_canonical(out); } } } } /// A transaction category (Chapter 6 §6.6). The spec leaves the set open ("used /// by UIs and analytics"); these are a minimal core set plus a registered /// escape (see `DECISIONS.md`). #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum TransactionCategory { NoteEntry, Structural, Layout, Import, Registered(OperationKindRegistryId), } impl TransactionCategory { fn discriminant(&self) -> u8 { match self { TransactionCategory::NoteEntry => 0, TransactionCategory::Structural => 1, TransactionCategory::Layout => 2, TransactionCategory::Import => 3, TransactionCategory::Registered(_) => 4, } } } impl CanonicalEncode for TransactionCategory { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); if let TransactionCategory::Registered(id) = self { push_canon(out, id); } } } /// The payload of a conflict-resolution meta-operation (Chapter 6 §6.4.4). #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub struct ResolveConflictPayload { pub target: ConflictId, pub action: ResolutionAction, } impl CanonicalEncode for ResolveConflictPayload { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.target); self.action.encode_canonical(out); } } // --- 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 in the general /// case (interval composition need not commute). `chromatic_steps` is a minimal /// interval (a CMN alteration shift) that, in this prototype, commutes except at /// the alteration's `i8` saturation bound; 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()); } } // --- Group 2 (M2): cross-cutting CRUD (Chapter 6 §6.10). --------------------- /// Tombstone a cross-cutting structure (Chapter 6 §6.10 DeleteCrossCutting). /// Delete-wins discipline; the structure id is retained as a tombstone. The /// structure is named by its [`TypedObjectId`] — the same key the set-union /// creation and the re-anchoring table use — which must be a cross-cutting kind /// (`Tie`/`Slur`/`Beam`/`Spanner`). #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub struct DeleteCrossCuttingOp { pub structure: TypedObjectId, } impl CanonicalEncode for DeleteCrossCuttingOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.structure); } } /// Overwrite a cross-cutting structure's value (Chapter 6 §6.10 /// ModifyCrossCutting). Carries the full replacement [`CrossCuttingValue`] (v1, /// value-typed); the field-overwrite discipline keys on the structure's /// [`CrossCuttingValue::id`] (later-in-canonical-order wins; concurrent differing /// conflict). The replacement keeps the structure's identity but may change its /// endpoints and per-kind fields — the reduction re-derives its endpoints from /// the new value (so a later re-anchoring sees them). #[derive(Clone, PartialEq, Eq, Debug)] pub struct ModifyCrossCuttingOp { pub structure: CrossCuttingValue, } impl ModifyCrossCuttingOp { /// The structure's identity (the LWW key). pub fn id(&self) -> TypedObjectId { self.structure.id() } } impl CanonicalEncode for ModifyCrossCuttingOp { fn encode_canonical(&self, out: &mut Vec) { self.structure.encode_canonical(out); } } // --- 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); } } // --- Group 4 (M2d): score settings (Chapter 6 §6.10). LWW field-overwrite. --- /// Overwrite the score metadata (Chapter 6 §6.10 SetMetadata). Carries the full /// [`ScoreMetadata`] (v1); the score-singleton field-overwrite is *advisory* /// last-writer-wins — the latest write in canonical order silently wins and no /// conflict is recorded (operation_catalog §set-user-system-break "LWW advisory"). #[derive(Clone, PartialEq, Eq, Debug)] pub struct SetMetadataOp { pub metadata: ScoreMetadata, } impl CanonicalEncode for SetMetadataOp { fn encode_canonical(&self, out: &mut Vec) { push_lp_bytes(out, &self.metadata.canonical_bytes()); } } /// Overwrite a region's default metric grid (Chapter 6 §6.10 SetMetricGrid). /// Carries the full target [`MetricGrid`] (or `None` to clear it); LWW /// field-overwrite keyed by region (concurrent differing ⇒ /// structural-field-collision). #[derive(Clone, PartialEq, Eq, Debug)] pub struct SetMetricGridOp { pub region: RegionId, pub grid: Option, } impl CanonicalEncode for SetMetricGridOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.region); match &self.grid { None => push_tag(out, 0), Some(grid) => { push_tag(out, 1); push_lp_bytes(out, &grid.canonical_bytes()); } } } } /// Set a user page-break preference (Chapter 6 §6.10 SetUserPageBreak) — the /// page-break sibling of [`SetUserSystemBreakOp`]. Carries the full /// [`TimeAnchor`] (v1); the LWW bucketing key is the anchor's resolved /// [`MusicalPosition`]. #[derive(Clone, PartialEq, Eq, Debug)] pub struct SetUserPageBreakOp { pub region: RegionId, pub anchor: TimeAnchor, pub present: bool, } impl SetUserPageBreakOp { /// The anchor's resolved musical position — the canonical LWW bucketing key /// (see [`resolved_anchor_position`]). pub fn resolved_position(&self) -> MusicalPosition { resolved_anchor_position(&self.anchor) } } impl CanonicalEncode for SetUserPageBreakOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.region); push_lp_bytes(out, &self.anchor.canonical_bytes()); push_u8_bool(out, self.present); } } #[cfg(test)] mod tests { use super::*; use epiphany_core::{ReplicaId, SlurId}; #[test] fn transaction_category_discriminants_are_golden() { // RATIFIED by Pass 11 (item 2.4, req:semops:transaction-category): the // declaration-order discriminants are normative and canonically encoded. assert_eq!(TransactionCategory::NoteEntry.discriminant(), 0); assert_eq!(TransactionCategory::Structural.discriminant(), 1); assert_eq!(TransactionCategory::Layout.discriminant(), 2); assert_eq!(TransactionCategory::Import.discriminant(), 3); assert_eq!( TransactionCategory::Registered(OperationKindRegistryId(0)).discriminant(), 4 ); } #[test] fn kind_tag_projects_and_round_trips_registered_id() { let reg = OperationKindRegistryId(7); let k = OperationKind::Registered(reg, vec![1, 2, 3]); assert_eq!(k.tag(), OperationKindTag::Registered(reg)); let prim = OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(ReplicaId(1), 1), spelling: crate::valuegen::spelling(4), }); assert_eq!(prim.tag(), OperationKindTag::RespellPitch); } #[test] fn every_normative_operation_tag_has_a_distinct_canonical_discriminant() { let tags = [ OperationKindTag::InsertEvent, OperationKindTag::DeleteEvent, OperationKindTag::ModifyEvent, OperationKindTag::RespellPitch, OperationKindTag::Transpose, OperationKindTag::CreateCrossCutting, OperationKindTag::DeleteCrossCutting, OperationKindTag::ModifyCrossCutting, OperationKindTag::ChangeRegionTimeModel, OperationKindTag::InsertRegion, OperationKindTag::DeleteRegion, OperationKindTag::InsertStaffInstance, OperationKindTag::DeleteStaffInstance, OperationKindTag::SetUserSystemBreak, OperationKindTag::SetUserPageBreak, OperationKindTag::DeclareTransaction, OperationKindTag::InsertIdentifiedPitch, OperationKindTag::DeleteIdentifiedPitch, OperationKindTag::ModifyIdentifiedPitch, OperationKindTag::CreateVoice, OperationKindTag::DeleteVoice, OperationKindTag::SetMetadata, OperationKindTag::SetMetricGrid, ]; let encoded: std::collections::BTreeSet<_> = tags .iter() .map(CanonicalEncode::to_canonical_bytes) .collect(); assert_eq!(encoded.len(), tags.len()); } #[test] fn reassign_remapping_is_order_independent() { let e1 = EventId::new(ReplicaId(1), 1); let e2 = EventId::new(ReplicaId(1), 2); let p = MusicalPosition::default(); let a = PositionRemapping::Reassign(vec![(e1, p.clone()), (e2, p.clone())]); let b = PositionRemapping::Reassign(vec![(e2, p.clone()), (e1, p.clone())]); assert_eq!(a.to_canonical_bytes(), b.to_canonical_bytes()); } #[test] fn cross_cutting_endpoint_order_is_significant() { // A slur's (start, end) order is meaningful: swapping the endpoints // changes the canonical bytes. let s = SlurId::new(ReplicaId(1), 1); let a = EventId::new(ReplicaId(1), 2); let b = EventId::new(ReplicaId(1), 3); let fwd = CrossCuttingValue::Slur(crate::valuegen::slur(s, a, b)); let rev = CrossCuttingValue::Slur(crate::valuegen::slur(s, b, a)); assert_ne!(fwd.to_canonical_bytes(), rev.to_canonical_bytes()); } }