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

605 lines
22 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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").
//!
//! ## Why these payloads carry identifiers, not whole graph objects
//!
//! The spec's payload structs embed rich graph values — `InsertEventOp` holds an
//! `Event`, `RespellPitchOp` holds a `PitchSpelling`, and so on. Their *canonical
//! wire encoding*, however, is deferred to the Binary Format companion (Agent B's
//! `epiphany-core` canonically encodes only identifiers and the scalar time
//! types; the value-type encoding is its Pass 11 candidate P11-4). To keep an
//! [`OperationEnvelope`](crate::OperationEnvelope) **hashable today** — the
//! [`EnvelopeHash`](crate::EnvelopeHash) and slot equivocation both need
//! canonical bytes — these payloads carry the reduction-relevant *identifiers and
//! canonical scalar coordinates*, plus a content fingerprint where the reduction
//! only needs equality (a respelling's [`ContentHash`]). This is faithful to
//! everything Chapter 6's reduction rules actually consume, and is recorded as a
//! Pass 11 candidate (see `DECISIONS.md`): when the companion lands, the structs
//! grow back their full value fields without changing the reduction.
//!
//! The *set* of kinds here is the representative selection of §6.10, not the full
//! ~6080-primitive catalog (an explicit open question, §6.11). Together they
//! exercise every reduction discipline the chapter defines.
use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, PitchId, RegionId, StaffInstanceId, TransactionId,
TupletId, TypedObjectId, VoiceId,
};
use epiphany_determinism::{sorted_canonical, CanonicalEncode, ContentHash};
use crate::conflict::{ConflictId, ResolutionAction};
use crate::encode::{push_canon, 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").
#[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<u8>) {
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).
#[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<u8>),
}
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,
}
}
/// 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),
}
}
}
impl CanonicalEncode for OperationKind {
fn encode_canonical(&self, out: &mut Vec<u8>) {
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);
}
}
}
}
/// 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),
}
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,
}
}
}
impl CanonicalEncode for OperationKindTag {
fn encode_canonical(&self, out: &mut Vec<u8>) {
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).
///
/// The `staff_instance` makes the system-promoted-voice derivation total
/// without a containment walk (a full reducer recovers it from the voice's
/// container; see `DECISIONS.md`). `position`/`duration` are exact musical
/// rationals, the collision key for voice promotion. `pitches` are tombstoned
/// with the event on delete.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct InsertEventOp {
pub voice: VoiceId,
pub staff_instance: StaffInstanceId,
pub event: EventId,
pub position: MusicalPosition,
pub duration: MusicalDuration,
pub pitches: Vec<PitchId>,
}
impl CanonicalEncode for InsertEventOp {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.voice);
push_canon(out, &self.staff_instance);
push_canon(out, &self.event);
push_canon(out, &self.position);
push_canon(out, &self.duration);
push_seq(out, &sorted_canonical(self.pitches.clone()));
}
}
/// 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<u8>) {
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 is represented by its freshly-minted [`EventId`] and
/// duration (the reducer adds it live and validates the duration); the full
/// `Rest` value is deferred with the rest of the payload encoding.
#[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 {
new_rest: EventId,
duration: MusicalDuration,
},
/// Rewrite the enclosing tuplet(s) to remain consistent.
RewriteTuplets { tuplets: Vec<TupletId> },
/// Cascade-delete the tuplet group(s) containing the target.
CascadeDeleteTuplets { tuplets: Vec<TupletId> },
}
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<u8>) {
push_tag(out, self.discriminant());
match self {
TupletCompensation::NotInTuplet => {}
TupletCompensation::ReplaceWithRest { new_rest, duration } => {
push_canon(out, new_rest);
push_canon(out, duration);
}
TupletCompensation::RewriteTuplets { tuplets }
| TupletCompensation::CascadeDeleteTuplets { tuplets } => {
push_seq(out, &sorted_canonical(tuplets.clone()));
}
}
}
}
/// Overwrite a pitch's spelling (Chapter 6 §6.10 RespellPitch). The intended
/// [`PitchSpelling`](epiphany_core::PitchSpelling) is represented by a
/// [`ContentHash`] fingerprint: the reduction only needs spelling *equality*
/// (identical concurrent respellings reduce idempotently; differing ones
/// conflict).
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub struct RespellPitchOp {
pub pitch: PitchId,
pub spelling: ContentHash,
}
impl CanonicalEncode for RespellPitchOp {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.pitch);
push_canon(out, &self.spelling);
}
}
/// Create a cross-cutting structure (Chapter 6 §6.10 CreateCrossCutting). The
/// structure is identified by its [`TypedObjectId`] (whose variant carries the
/// kind — Slur, Tie, Beam, …) and its referenced endpoints; that is everything
/// the set-union reduction and the re-anchoring rule table consume.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CreateCrossCuttingOp {
pub structure: CrossCuttingRef,
}
impl CanonicalEncode for CreateCrossCuttingOp {
fn encode_canonical(&self, out: &mut Vec<u8>) {
self.structure.encode_canonical(out);
}
}
/// A reference-level view of a cross-cutting structure: its identity and the
/// objects it references (Chapter 6 §6.5 re-anchoring operands).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct CrossCuttingRef {
/// The structure's identity; its `TypedObjectId` variant names the kind.
pub id: TypedObjectId,
/// The objects this structure references (its endpoints/anchors).
pub endpoints: Vec<TypedObjectId>,
}
impl CanonicalEncode for CrossCuttingRef {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.id);
// Endpoint order is meaningful (start before end), so it is NOT sorted.
push_seq(out, &self.endpoints);
}
}
/// Change a region's time model (Chapter 6 §6.10 ChangeRegionTimeModel). The
/// target model is represented by its [`RegionTimeModelTag`]; the prototype's
/// reducer cannot recompute coordinate-kind compatibility from the not-yet-
/// canonical region contents, so the authoring layer declares any events it
/// knows to be un-migratable (`declared_incompatible`), which drive the
/// `TimeModelMigrationFailure` conflict (see `DECISIONS.md`).
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct ChangeRegionTimeModelOp {
pub region: RegionId,
pub new_time_model: RegionTimeModelTag,
pub declared_incompatible: Vec<EventId>,
pub remapping: PositionRemapping,
}
impl CanonicalEncode for ChangeRegionTimeModelOp {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.region);
self.new_time_model.encode_canonical(out);
push_seq(out, &sorted_canonical(self.declared_incompatible.clone()));
self.remapping.encode_canonical(out);
}
}
/// Which region time model a migration targets (Chapter 3 §"Region Time
/// Models"). The discriminator the reducer needs; the full
/// [`RegionTimeModel`](epiphany_core::RegionTimeModel) value is deferred.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum RegionTimeModelTag {
Metric,
Proportional,
Aleatoric,
}
impl RegionTimeModelTag {
fn discriminant(&self) -> u8 {
match self {
RegionTimeModelTag::Metric => 0,
RegionTimeModelTag::Proportional => 1,
RegionTimeModelTag::Aleatoric => 2,
}
}
}
impl CanonicalEncode for RegionTimeModelTag {
#[inline]
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_tag(out, self.discriminant());
}
}
/// 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<u8>) {
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<u8>, 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). The
/// anchor is represented by its resolved [`MusicalPosition`] — the canonical
/// LWW bucketing key; the full [`TimeAnchor`](epiphany_core::TimeAnchor) value
/// is deferred.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct SetUserSystemBreakOp {
pub region: RegionId,
pub anchor: MusicalPosition,
pub present: bool,
}
impl CanonicalEncode for SetUserSystemBreakOp {
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.region);
push_canon(out, &self.anchor);
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<TransactionCategory>,
}
impl CanonicalEncode for TransactionDescriptor {
fn encode_canonical(&self, out: &mut Vec<u8>) {
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<u8>) {
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<u8>) {
push_canon(out, &self.target);
self.action.encode_canonical(out);
}
}
#[cfg(test)]
mod tests {
use super::*;
use epiphany_core::{ReplicaId, SlurId};
#[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: ContentHash([4u8; 32]),
});
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,
];
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() {
let s = TypedObjectId::Slur(SlurId::new(ReplicaId(1), 1));
let a = TypedObjectId::Event(EventId::new(ReplicaId(1), 2));
let b = TypedObjectId::Event(EventId::new(ReplicaId(1), 3));
let fwd = CrossCuttingRef {
id: s,
endpoints: vec![a, b],
};
let rev = CrossCuttingRef {
id: s,
endpoints: vec![b, a],
};
assert_ne!(fwd.to_canonical_bytes(), rev.to_canonical_bytes());
}
}