//! Operation effects and the re-anchoring vocabulary (Chapter 6 §"Operation //! Effects", §"Re-Anchoring"). //! //! Every operation in the canonical set produces exactly one //! [`OperationEffect`] under reduction, and those effects are *visible graph //! facts* recorded in the materialized state's effect log — "part of canonical //! state, not implementation detail" (Chapter 6 §6.3.2). Two replicas reducing //! the same operation set in the canonical order produce the same effect for //! each operation, including identical [`NoOpReason`] values. //! //! The failure reason on a no-op is a *typed* [`PreconditionFailureReason`], //! never a free-form string: canonical effects must not contain free-form text, //! since two implementations could otherwise produce divergent canonical state //! while agreeing semantically (Chapter 6 §6.3.1). use epiphany_core::{OperationId, TypedObjectId, VoiceId}; use epiphany_determinism::CanonicalEncode; use crate::conflict::ConflictId; use crate::encode::{push_canon, push_seq, push_tag}; use crate::support::{ ExtensionPreconditionId, PreconditionFailureRegistryId, ReanchorReasonRegistryId, RepairKindRegistryId, }; /// The deterministic effect of an operation under canonical reduction /// (Chapter 6 §6.3.2). Recorded as part of materialized state. #[derive(Clone, PartialEq, Eq, Debug)] pub enum OperationEffect { /// Applied cleanly with no compensating changes. Applied, /// Applied with deterministic compensating changes (re-anchoring, /// attachment migration, voice promotion, …). AppliedWithRepair { repairs: Vec }, /// Could not apply cleanly; a conflict record was created. Conflicted { conflict: ConflictId }, /// The target was tombstoned; preserved in the operation set, no graph /// effect beyond the recorded effect. /// /// Reserved: the current reducer expresses this outcome as /// [`NoOpReason::TargetTombstoned`] instead; no reduction path produces /// this variant yet. It stays in the vocabulary (and its discriminant /// stays pinned) because the spec's effect table names it. TombstonedTarget { target: TypedObjectId }, /// Reduces to no effect; the reason is recorded and is canonical. NoOp { reason: NoOpReason }, } impl OperationEffect { fn discriminant(&self) -> u8 { match self { OperationEffect::Applied => 0, OperationEffect::AppliedWithRepair { .. } => 1, OperationEffect::Conflicted { .. } => 2, OperationEffect::TombstonedTarget { .. } => 3, OperationEffect::NoOp { .. } => 4, } } } impl CanonicalEncode for OperationEffect { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); match self { OperationEffect::Applied => {} OperationEffect::AppliedWithRepair { repairs } => push_seq(out, repairs), OperationEffect::Conflicted { conflict } => push_canon(out, conflict), OperationEffect::TombstonedTarget { target } => push_canon(out, target), OperationEffect::NoOp { reason } => reason.encode_canonical(out), } } } /// Why an operation reduced to no effect (Chapter 6 §6.3.2). #[derive(Clone, PartialEq, Eq, Debug)] pub enum NoOpReason { /// The target was tombstoned by a causally-prior operation and the kind has /// no compensating repair rule. TargetTombstoned, /// Duplicates a causally-prior operation's effect. AlreadyApplied, /// A later operation in canonical order subsumed this one's effect. /// /// Reserved: no reduction path produces this yet — Pass 11 item 2.2 /// decided the winner carries `Conflicted` while the superseded loser /// keeps `Applied`. Retagging losers with this reason is an open /// disposition (see the item's rationale in the ratification log). SupersededByLaterOperation { superseder: OperationId }, /// An invariant precondition satisfied at authoring time fails under /// concurrent reduction; the intent is not preserved. PreconditionFailedUnderReduction { reason: PreconditionFailureReason }, /// Belonged to a transaction whose other members failed. TransactionConflict, } impl NoOpReason { fn discriminant(&self) -> u8 { match self { NoOpReason::TargetTombstoned => 0, NoOpReason::AlreadyApplied => 1, NoOpReason::SupersededByLaterOperation { .. } => 2, NoOpReason::PreconditionFailedUnderReduction { .. } => 3, NoOpReason::TransactionConflict => 4, } } } impl CanonicalEncode for NoOpReason { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); match self { NoOpReason::TargetTombstoned | NoOpReason::AlreadyApplied | NoOpReason::TransactionConflict => {} NoOpReason::SupersededByLaterOperation { superseder } => push_canon(out, superseder), NoOpReason::PreconditionFailedUnderReduction { reason } => reason.encode_canonical(out), } } } /// A typed precondition-failure reason (Chapter 6 §6.3.2). Never free-form /// text — canonical effects must be byte-reproducible across implementations. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum PreconditionFailureReason { /// The target object did not exist in the working state at reduction. TargetMissing, /// The target was tombstoned by a causally-prior operation, and a /// repair-on-tombstone attempt itself failed precondition. TargetTombstoned, /// The operation requires a region time model different from the one in /// effect at the target position. WrongRegionTimeModel, /// A declared tuplet compensation is invalid against the current structure. TupletCompensationInvalid, /// An event duration the operation specifies is invalid in the target /// voice or region. EventDurationInvalid, /// The target position falls outside the region declared by the envelope, /// or the region does not exist. /// /// Reserved: producing this requires resolving positions against region /// extents, which is the deferred P11-C5 resolved-position machinery. PositionOutsideRegion, /// A pitch-space or tuning-context precondition failed. /// /// Produced by `TransposeInterval` when a target position is not `Cmn`, so /// the interval's diatonic component has no nominal to move, or when a CMN /// target's enclosing chromatic structure cannot be established /// (operation_catalog §TransposeInterval). Detecting the former reads only /// the position discriminant. The latter intentionally fails closed until /// Push 4b replaces built-in identifier recognition with structural /// pitch-space resolution. 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, /// A `SetTempoSegment` write whose *resulting* tempo map would be malformed /// — segments out of order or overlapping, a non-constant shape missing its /// end data, or a carried segment whose own start disagrees with the /// operation's start key (operation_catalog §"Meter and Tempo Overwrites"). TempoMapMalformed, /// An extension-declared precondition failed. ExtensionPrecondition(ExtensionPreconditionId), /// A registered precondition code from a versioned registry. Registered(PreconditionFailureRegistryId), /// A modify would rewrite the intrinsic content of a `SYSTEM_DERIVED`- /// namespace object in place, invalidating its content derivation /// (Pass 12, P12-K3; core spec Ch5 §System-Derived Identifiers). The /// sanctioned path is minting a replacement object. SystemDerivedContentImmutable, /// A create re-carried a live id with *differing* content /// (operation_catalog §CreateStaff): the target is not missing, its /// content disagrees (Pass 12, P12-K9 — replaces the `TargetMissing` /// misnomer at the value-retaining re-create sites). RecreateContentMismatch, /// A `TransposeInterval` target's `AcousticRealization::AbsoluteHz` /// overrides the tuning system (Push 4a; operation_catalog /// §TransposeInterval). Moving its scale position would move the notehead /// without moving the sound, so the operation refuses instead. AcousticRealizationPinned, /// A `TransposeInterval` would drive a target's `alteration` or `octave` /// past its `i8` bound (Push 4a). The frozen `Transpose` saturates here /// and reports success; this refuses. TranspositionOutOfRange, /// A `CreateMeasure` (or a prospective preservation check) found a /// resolving `time_signature` that disagrees with the effective grid's /// active signature (genesis tranche G3b, contract pin 8b) — distinct /// from the resolution failure, which is `TargetMissing`. MeasureMeterMismatch, /// A `CreateMeasure`'s carried `start` is comparable to the current last /// measure's start (contract pin 6) and is not strictly after it (genesis /// tranche G3b, contract pin 8b). MeasureOutOfOrder, /// A `CreateMeasure` ordering or boundary-distance check could not be /// verified: the two starts are not comparable (contract pin 6), or the /// delta between them is not computable (contract pin 6b) (genesis /// tranche G3b, contract pin 8b). MeasureOrderUnverifiable, } impl PreconditionFailureReason { fn discriminant(&self) -> u8 { match self { PreconditionFailureReason::TargetMissing => 0, PreconditionFailureReason::TargetTombstoned => 1, PreconditionFailureReason::WrongRegionTimeModel => 2, PreconditionFailureReason::TupletCompensationInvalid => 3, PreconditionFailureReason::EventDurationInvalid => 4, PreconditionFailureReason::PositionOutsideRegion => 5, PreconditionFailureReason::PitchSpaceMismatch => 6, PreconditionFailureReason::VoiceMissing => 7, PreconditionFailureReason::ExtensionPrecondition(_) => 8, PreconditionFailureReason::Registered(_) => 9, // Additive (Group 3); keeps the ratified 0..=9 discriminants stable. PreconditionFailureReason::ContainerNotEmpty => 10, // Additive (Phase-3 tranche, SetTempoSegment); appended past 10. PreconditionFailureReason::TempoMapMalformed => 11, // Additive (Pass-12 G-pass, P12-K3/P12-K9); appended past 11. PreconditionFailureReason::SystemDerivedContentImmutable => 12, PreconditionFailureReason::RecreateContentMismatch => 13, // Additive (Push 4a, TransposeInterval); appended past 13. PreconditionFailureReason::AcousticRealizationPinned => 14, PreconditionFailureReason::TranspositionOutOfRange => 15, // Additive (Genesis tranche G3b); appended past 15. PreconditionFailureReason::MeasureMeterMismatch => 16, PreconditionFailureReason::MeasureOutOfOrder => 17, PreconditionFailureReason::MeasureOrderUnverifiable => 18, } } } impl PreconditionFailureReason { /// The G-minor schema-minor epoch this variant requires /// (`spec/PLAN_GMINOR_SCHEMA_MINOR.md` §4, pin 1's ratified table), or /// `None` for a baseline (0..=9) variant — the distinct "no additive /// requirement" sentinel pin 3 requires, never `0`. Exhaustive with no /// wildcard arm (pin 2): a future variant cannot compile without an /// epoch assignment. pub fn introduced_minor(&self) -> Option { match self { PreconditionFailureReason::TargetMissing | PreconditionFailureReason::TargetTombstoned | PreconditionFailureReason::WrongRegionTimeModel | PreconditionFailureReason::TupletCompensationInvalid | PreconditionFailureReason::EventDurationInvalid | PreconditionFailureReason::PositionOutsideRegion | PreconditionFailureReason::PitchSpaceMismatch | PreconditionFailureReason::VoiceMissing | PreconditionFailureReason::ExtensionPrecondition(_) | PreconditionFailureReason::Registered(_) => None, // Minor 2 (M2c). PreconditionFailureReason::ContainerNotEmpty => Some(2), // Minor 4 (Phase-3 first tranche). PreconditionFailureReason::TempoMapMalformed => Some(4), // Minor 5 (Pass-12 G-pass). PreconditionFailureReason::SystemDerivedContentImmutable => Some(5), PreconditionFailureReason::RecreateContentMismatch => Some(5), // Minor 7 (Push 4a). PreconditionFailureReason::AcousticRealizationPinned => Some(7), PreconditionFailureReason::TranspositionOutOfRange => Some(7), // Minor 12 (Genesis tranche G3b). PreconditionFailureReason::MeasureMeterMismatch => Some(12), PreconditionFailureReason::MeasureOutOfOrder => Some(12), PreconditionFailureReason::MeasureOrderUnverifiable => Some(12), } } } impl CanonicalEncode for PreconditionFailureReason { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); match self { PreconditionFailureReason::ExtensionPrecondition(id) => push_canon(out, id), PreconditionFailureReason::Registered(id) => push_canon(out, id), _ => {} } } } /// A deterministic compensating change made during reduction, recorded as part /// of canonical state (Chapter 6 §6.3.2). #[derive(Clone, PartialEq, Eq, Debug)] pub struct RepairRecord { /// The kind of repair performed. pub kind: RepairKind, /// The object affected by this repair. pub target: TypedObjectId, } impl CanonicalEncode for RepairRecord { fn encode_canonical(&self, out: &mut Vec) { self.kind.encode_canonical(out); push_canon(out, &self.target); } } /// The kind of repair a reduction performed to preserve graph invariants in the /// presence of a tombstoning or migrating operation (Chapter 6 §6.3.2). #[derive(Clone, PartialEq, Eq, Debug)] pub enum RepairKind { /// A reference was re-anchored to a surviving target per the re-anchoring /// rule table (Chapter 6 §6.5). Reanchored { from: TypedObjectId, to: TypedObjectId, reason: ReanchorReason, }, /// A spanner or beam lost members but enough remained to survive. SpannerTruncated { removed_members: Vec }, /// A user-content reference was orphaned (target lost, reference kept). Orphaned, /// A reference whose existence required its target was cascade-deleted. CascadeDeleted, /// An attachment transitioned to tombstoned-target state. AttachmentTombstoned, /// A voice was promoted to a system-derived identity on concurrent /// insertion collision (Chapter 6 §6.10 InsertEvent). VoicePromoted { from: VoiceId, to: VoiceId }, /// A tuplet was compensated per the operation's declared compensation. TupletCompensated { compensation_kind: TupletCompensationKind, }, /// A registered extension-defined repair kind. Registered(RepairKindRegistryId), } impl RepairKind { fn discriminant(&self) -> u8 { match self { RepairKind::Reanchored { .. } => 0, RepairKind::SpannerTruncated { .. } => 1, RepairKind::Orphaned => 2, RepairKind::CascadeDeleted => 3, RepairKind::AttachmentTombstoned => 4, RepairKind::VoicePromoted { .. } => 5, RepairKind::TupletCompensated { .. } => 6, RepairKind::Registered(_) => 7, } } } impl CanonicalEncode for RepairKind { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); match self { RepairKind::Reanchored { from, to, reason } => { push_canon(out, from); push_canon(out, to); reason.encode_canonical(out); } RepairKind::SpannerTruncated { removed_members } => push_seq(out, removed_members), RepairKind::Orphaned | RepairKind::CascadeDeleted | RepairKind::AttachmentTombstoned => {} RepairKind::VoicePromoted { from, to } => { push_canon(out, from); push_canon(out, to); } RepairKind::TupletCompensated { compensation_kind } => { compensation_kind.encode_canonical(out) } RepairKind::Registered(id) => push_canon(out, id), } } } /// The reason a reference was re-anchored to a new target (Chapter 6 §6.5). #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum ReanchorReason { SameVoiceNearer, SameStaffInstanceNearer, SameStaffNearer, SameRegionNearer, ExplicitFallback, DeclaredByExtension(ReanchorReasonRegistryId), /// A rank-4 (same-canvas) proximity survivor (Pass 12, P12-C4). /// Semantically the next rung after `SameRegionNearer`; its wire /// discriminant is 6 because `DeclaredByExtension` already owned 5 /// when it was appended. SameCanvasNearer, } impl ReanchorReason { fn discriminant(&self) -> u8 { match self { ReanchorReason::SameVoiceNearer => 0, ReanchorReason::SameStaffInstanceNearer => 1, ReanchorReason::SameStaffNearer => 2, ReanchorReason::SameRegionNearer => 3, ReanchorReason::ExplicitFallback => 4, ReanchorReason::DeclaredByExtension(_) => 5, // Additive (Pass-12 G-pass, P12-C4); appended past 5. ReanchorReason::SameCanvasNearer => 6, } } } impl ReanchorReason { /// The G-minor schema-minor epoch this variant requires, or `None` for a /// baseline (0..=5) variant. See /// [`PreconditionFailureReason::introduced_minor`] for the sentinel and /// exhaustiveness discipline this mirrors. pub fn introduced_minor(&self) -> Option { match self { ReanchorReason::SameVoiceNearer | ReanchorReason::SameStaffInstanceNearer | ReanchorReason::SameStaffNearer | ReanchorReason::SameRegionNearer | ReanchorReason::ExplicitFallback | ReanchorReason::DeclaredByExtension(_) => None, // Minor 5 (Pass-12 G-pass, P12-C4). ReanchorReason::SameCanvasNearer => Some(5), } } } impl CanonicalEncode for ReanchorReason { fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); if let ReanchorReason::DeclaredByExtension(id) = self { push_canon(out, id); } } } /// Which tuplet compensation a [`RepairKind::TupletCompensated`] applied /// (Chapter 6 §6.10 DeleteEvent). Mirrors the operation's declared /// [`crate::TupletCompensation`] choice. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum TupletCompensationKind { ReplaceWithRest, RewriteTuplets, CascadeDeleteTuplets, } impl TupletCompensationKind { fn discriminant(&self) -> u8 { match self { TupletCompensationKind::ReplaceWithRest => 0, TupletCompensationKind::RewriteTuplets => 1, TupletCompensationKind::CascadeDeleteTuplets => 2, } } } impl CanonicalEncode for TupletCompensationKind { #[inline] fn encode_canonical(&self, out: &mut Vec) { push_tag(out, self.discriminant()); } } /// The result of the re-anchor function for one referencing object /// (Chapter 6 §6.5). The reduction maps each result to a [`RepairKind`] (or a /// conflict) on the triggering operation's effect. /// /// Reserved: the current reducer constructs [`RepairKind`] values directly /// rather than routing through this intermediate; it becomes load-bearing when /// the full "nearest surviving anchor" ordering (P11-C5) lands. #[derive(Clone, PartialEq, Eq, Debug)] pub enum ReanchorResult { /// The reference is replaced with a reference to a new target. Reanchored { new_target: TypedObjectId, reason: ReanchorReason, }, /// The reference is preserved but its target is tombstoned. TombstonedTarget, /// The referencing object is marked orphaned but retained. Orphaned, /// Re-anchoring cannot be performed deterministically; a conflict is /// recorded. Conflicted { conflict: ConflictId }, /// The referencing object is cascade-deleted (tombstoned). CascadeDeleted, } #[cfg(test)] mod tests { use super::*; use epiphany_core::{EventId, ReplicaId}; #[test] fn distinct_effects_encode_distinctly() { let a = OperationEffect::Applied; let b = OperationEffect::NoOp { reason: NoOpReason::AlreadyApplied, }; let c = OperationEffect::TombstonedTarget { target: TypedObjectId::Event(EventId::from_raw(1)), }; assert_ne!(a.to_canonical_bytes(), b.to_canonical_bytes()); assert_ne!(a.to_canonical_bytes(), c.to_canonical_bytes()); assert_ne!(b.to_canonical_bytes(), c.to_canonical_bytes()); } #[test] fn typed_precondition_reasons_are_distinguishable() { let a = NoOpReason::PreconditionFailedUnderReduction { reason: PreconditionFailureReason::VoiceMissing, }; let b = NoOpReason::PreconditionFailedUnderReduction { reason: PreconditionFailureReason::TargetMissing, }; assert_ne!(a.to_canonical_bytes(), b.to_canonical_bytes()); } #[test] fn pass12_appended_discriminants_are_locked() { // Pass-12 G-pass appends (binary_format 0.4.0): the wire bytes are // the appended discriminants, nothing prior moved. assert_eq!( PreconditionFailureReason::SystemDerivedContentImmutable.to_canonical_bytes(), vec![12] ); assert_eq!( PreconditionFailureReason::RecreateContentMismatch.to_canonical_bytes(), vec![13] ); assert_eq!( ReanchorReason::SameCanvasNearer.to_canonical_bytes(), vec![6] ); assert_eq!( PreconditionFailureReason::TempoMapMalformed.to_canonical_bytes(), vec![11] ); assert_eq!( ReanchorReason::ExplicitFallback.to_canonical_bytes(), vec![4] ); } /// (M5/M6/M7, M8/M9/M10) Genesis tranche G3b: the three new /// `PreconditionFailureReason` variants sit at discriminants 16, 17, 18 /// (contract pin 8b), each at epoch `Some(12)`. /// /// **Mutation M5/M6/M7:** move any one discriminant to 19 (unused, so it /// compiles); must fail. **Mutation M8/M9/M10:** move any one /// `introduced_minor()` to `Some(11)`; must fail. #[test] fn genesis_g3b_reasons_are_16_17_18_at_epoch_12() { assert_eq!( PreconditionFailureReason::MeasureMeterMismatch.to_canonical_bytes(), vec![16] ); assert_eq!( PreconditionFailureReason::MeasureOutOfOrder.to_canonical_bytes(), vec![17] ); assert_eq!( PreconditionFailureReason::MeasureOrderUnverifiable.to_canonical_bytes(), vec![18] ); assert_eq!( PreconditionFailureReason::MeasureMeterMismatch.introduced_minor(), Some(12) ); assert_eq!( PreconditionFailureReason::MeasureOutOfOrder.introduced_minor(), Some(12) ); assert_eq!( PreconditionFailureReason::MeasureOrderUnverifiable.introduced_minor(), Some(12) ); } #[test] fn voice_promotion_repair_round_trips_shape() { let r = RepairRecord { kind: RepairKind::VoicePromoted { from: VoiceId::new(ReplicaId(1), 2), to: VoiceId::new(ReplicaId::SYSTEM_DERIVED, 99), }, target: TypedObjectId::Voice(VoiceId::new(ReplicaId(1), 2)), }; // Encoding is stable. assert_eq!(r.to_canonical_bytes(), r.to_canonical_bytes()); } }