epiphany/crates/epiphany-ops/src/effect.rs

476 lines
19 KiB
Rust

//! 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<RepairRecord> },
/// 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<u8>) {
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<u8>) {
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,
}
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,
}
}
}
impl CanonicalEncode for PreconditionFailureReason {
fn encode_canonical(&self, out: &mut Vec<u8>) {
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<u8>) {
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<TypedObjectId> },
/// 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<u8>) {
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 CanonicalEncode for ReanchorReason {
fn encode_canonical(&self, out: &mut Vec<u8>) {
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<u8>) {
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]
);
}
#[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());
}
}