476 lines
19 KiB
Rust
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());
|
|
}
|
|
}
|