diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 7267303..0b5fdcd 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -139,6 +139,27 @@ companion lands, reconcile this crate's `CanonicalEncode`/`CanonicalDecode` and the whole-score `codec` with it (a failing cross-crate round-trip test would be the trigger, per the QUICKSTART process notes). +**Phase 2 — Agent K (Operation Catalog): the `CanonicalValue` seam.** Track B's +Operation Catalog shifts `epiphany-ops` from identifier-only operation payloads +to *value-typed* ones (an `InsertEvent` carrying the real `Event`, a +`RespellPitch` carrying the real `PitchSpelling`). Those payloads must serialize +canonically (an envelope stays hashable across an implementation boundary), so +`epiphany-ops` needs to canonically encode/decode core value types. The internal +`Codec` trait and its `Reader` cursor stay `pub(crate)` (they are composition +machinery, not a stable surface); instead `src/codec.rs` exposes a thin **public +`CanonicalValue` trait** (`canonical_bytes` / `decode_canonical`) implemented — +via a macro that *delegates to the existing `Codec` impls* — for exactly the +value types operation payloads embed (`Event`, `Rest`, `PitchSpelling`, `Tie`, +`Slur`, `Beam`, `Spanner`, `RegionTimeModel`, `TimeAnchor`). This introduces **no +new byte layout**: a `value_codec` test asserts each value's `CanonicalValue` +bytes equal the bytes the whole-score codec already embeds for it, so all +existing goldens / criterion 4 stay byte-for-byte green. This is the **K↔J +coordination seam**: value-type wire encoding is nominally the Binary Format +companion's (Agent J) to formalize, but K needs the surface now and J inherits +core's ratified conventions (Pass 11 item 1.8, `req:format:codec-conventions`) +rather than reconciling a second codec. Rejected alternative: a parallel value +codec inside `epiphany-ops` (two sources of truth for one byte layout). + ### P11-5 — Scope boundary: the Chapter 4 tuning catalog is referenced, not defined here `epiphany-core` (Agent B) owns the score graph and the pitch/time primitives. It diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 440566a..6fdda5f 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -2019,6 +2019,134 @@ impl Score { } } +// =========================================================================== +// Public per-value canonical codec (the K↔J seam). +// =========================================================================== + +/// A public, whole-buffer canonical byte form for an *individual* value +/// reachable from a [`Score`] — the per-type analogue of +/// [`Score::canonical_bytes`]. +/// +/// ## Why this exists +/// +/// The internal `Codec` trait (and its `Reader`) are crate-private: they are +/// the composition machinery for the whole-score codec, and exposing them would +/// leak the cursor and the combinator surface. But Track B's Operation Catalog +/// (Agent K) needs *value-typed* operation payloads — an `InsertEvent` that +/// carries the real [`Event`], a `RespellPitch` that carries the real +/// [`PitchSpelling`] — and those payloads must serialize canonically so an +/// operation envelope stays hashable across an implementation boundary. +/// +/// `CanonicalValue` is that agreed surface: a thin, public, per-type +/// `canonical_bytes`/`decode_canonical` pair that **delegates to the existing, +/// ratified `Codec` byte layout** (Pass 11 item 1.8, +/// `req:format:codec-conventions`). It introduces *no new byte layout* — the +/// bytes are byte-for-byte the same ones the whole-score codec already emits for +/// these values, merely made reachable for an individual value. The Binary +/// Format companion (Agent J) documents this surface as normative; until then it +/// inherits core's conventions exactly, so core/ops stay consistent. See +/// `DECISIONS.md` (P11-4 / the K↔J seam). +/// +/// Implemented only for the value types operation payloads embed, not for every +/// `Codec` type, to keep the public surface intentional. +pub trait CanonicalValue: Sized { + /// The canonical bytes of this single value, using the same layout the + /// whole-score codec uses for it. + fn canonical_bytes(&self) -> Vec; + + /// The exact inverse of [`CanonicalValue::canonical_bytes`]. Validates every + /// tag, length, primitive, and invariant; rejects trailing bytes. + fn decode_canonical(bytes: &[u8]) -> core::result::Result; +} + +/// Implements [`CanonicalValue`] for value types that already have a [`Codec`], +/// by delegating to it. No new byte layout is introduced. +macro_rules! canonical_value { + ($($ty:ty),* $(,)?) => { + $( + impl CanonicalValue for $ty { + fn canonical_bytes(&self) -> Vec { + let mut out = Vec::new(); + Codec::enc(self, &mut out); + out + } + fn decode_canonical(bytes: &[u8]) -> Result { + let mut r = Reader::new(bytes); + let v = <$ty as Codec>::dec(&mut r)?; + r.finish()?; + Ok(v) + } + } + )* + }; +} + +canonical_value! { + Event, + Rest, + PitchSpelling, + Tie, + Slur, + Beam, + Spanner, + RegionTimeModel, + TimeAnchor, +} + +#[cfg(test)] +mod value_codec_tests { + use super::*; + use crate::generators::{valid_score, valid_score_rich}; + + /// Every `CanonicalValue` round-trips, and its per-value bytes are exactly + /// the bytes the whole-score codec embeds for that value (no new layout). + fn assert_value_round_trips(v: &T) + where + T: CanonicalValue + Codec + PartialEq + core::fmt::Debug, + { + let bytes = v.canonical_bytes(); + // Per-value bytes equal what the internal Codec emits (the embedded form). + let mut embedded = Vec::new(); + Codec::enc(v, &mut embedded); + assert_eq!(bytes, embedded, "CanonicalValue diverged from Codec layout"); + let decoded = T::decode_canonical(&bytes).expect("value decodes"); + assert_eq!(&decoded, v, "value round-trip changed the value"); + assert_eq!( + decoded.canonical_bytes(), + bytes, + "value re-encode not byte-identical" + ); + } + + #[test] + fn value_types_round_trip_over_generator_corpus() { + for seed in 0..64u64 { + let s = valid_score_rich(seed.wrapping_mul(0x0100_0193).wrapping_add(7)); + for region in &s.canvas.regions { + assert_value_round_trips(®ion.time_model); + } + for tie in &s.cross_cutting.ties { + assert_value_round_trips(tie); + } + for slur in &s.cross_cutting.slurs { + assert_value_round_trips(slur); + } + for beam in &s.cross_cutting.beams { + assert_value_round_trips(beam); + } + for spanner in &s.cross_cutting.spanners { + assert_value_round_trips(spanner); + } + } + for seed in 0..200u64 { + let s = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1)); + for ev in s.events.iter() { + assert_value_round_trips(ev); + } + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index aa6a08c..3ce1955 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -116,7 +116,7 @@ pub use tempo::{ INVERSION_MAX_ITERATIONS, INVERSION_TOLERANCE_WHOLE_NOTES, }; -pub use codec::ScoreDecodeError; +pub use codec::{CanonicalValue, ScoreDecodeError}; pub use indexes::ScoreIndexes; diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index 570ecd6..9265f25 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -70,6 +70,27 @@ that graph. The base-free `reduce()` remains the operation-set convergence API. ### P11-C1 — operation payload schemas are deferred; we carry identifiers + fingerprints +> **RESOLVED (Phase 2, Agent K — Operation Catalog, M1).** The representative +> payloads are now **value-typed**: `InsertEventOp { staff_instance, event: +> Event }`, `RespellPitchOp { pitch, spelling: PitchSpelling }`, +> `CreateCrossCuttingOp { structure: CrossCuttingValue }`, +> `ChangeRegionTimeModelOp { …, new_time_model: RegionTimeModel }`, +> `SetUserSystemBreakOp { …, anchor: TimeAnchor }`, and +> `TupletCompensation::ReplaceWithRest { rest: Rest }`. They serialize by framing +> each value's `epiphany_core::CanonicalValue` bytes behind a `u32` length prefix +> — the ratified byte-convention baseline (Pass 11 item 1.8, +> `req:format:codec-conventions`), introducing no new layout (the K↔J seam; see +> `epiphany-core/DECISIONS.md`). Graph-aware reduction now materializes the +> **real** event/structure rather than the C4 placeholder described below. +> `reduce_onto`'s reduction rules are unchanged — only the field read-sites moved +> onto the value. The v0 identifier-only shapes are frozen in `src/v0.rs` as the +> migration regression guard, and `src/migrate.rs` lifts a v0 envelope to v1 +> deterministically and equivalence-preservingly (`migrate_v0_envelope(v0, +> context: &Score)`; `epiphany-testkit::migration` is the merge gate). The full +> K0 set + the literal wire layout (Binary Format companion, Agent J) follow; the +> remainder of this entry is the historical v0 rationale. See `P12-K1` below and +> `spec/operation_catalog.tex`. + Chapter 6's payload structs embed rich graph values (`InsertEventOp { event: Event }`, `RespellPitchOp { new_spelling: PitchSpelling }`, …), but the *canonical wire encoding* of those graph value types is itself deferred to the @@ -190,6 +211,26 @@ a single `ResolveConflictPayload { target, action }`. This crate maps every applied resolve to `Resolved { action }`; `Dismissed` is reachable as a state but is not authored by a representative op. **For the spec:** define how a `ResolveConflict` selects Dismissed (a distinct action, or a separate payload). +*Phase 2 update: the Operation Catalog (Ch. ResolveConflict) records that +`ResolutionAction::Dismiss` is the action that selects `Dismissed`; resolved.* + +## Pass 12 candidates (Agent K — Operation Catalog) + +### P12-K1 — a v0 `RespellPitch` fingerprint is not invertible to a spelling + +The v0 `RespellPitchOp` carried a `ContentHash` *fingerprint* of the new +spelling, not the `PitchSpelling`. The v0→v1 migration (`src/migrate.rs`) must +reconstruct the value, but a fingerprint cannot be inverted without a side table. +The migration recovers the spelling from the score-graph **context** — an +explicit per-pitch spelling attachment (`SpellingScope::Pitch` + +`SpellingDirective::Explicit`) whose canonical bytes hash to the fingerprint — +and, when the context lacks it, returns `MigrationError::Irreversible` (the +bundle opens read-only, per the QUICKSTART migration contract). This is the one +representative payload that is not self-contained under migration. **For Pass +12:** confirm the read-only fallback is the intended long-term disposition (vs. +requiring a richer v0 corpus that preserves spelling pre-images). Recorded in +`spec/PASS12_BATCH.md` and `spec/operation_catalog.tex` +(§RespellPitch, §Migration). ## Provisional canonical encoding (mirrors Agent B's P11-4) diff --git a/crates/epiphany-ops/src/anomaly.rs b/crates/epiphany-ops/src/anomaly.rs index 24e742d..e60f8ea 100644 --- a/crates/epiphany-ops/src/anomaly.rs +++ b/crates/epiphany-ops/src/anomaly.rs @@ -283,7 +283,6 @@ mod tests { use crate::support::AuthorId; use crate::OperationPayload; use epiphany_core::{PitchId, WallClockTime}; - use epiphany_determinism::ContentHash; fn env(replica: u64, counter: u64, physical: i64, logical: u32) -> OperationEnvelope { let id = OperationId::new(ReplicaId(replica), counter); @@ -298,7 +297,7 @@ mod tests { transaction: None, payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(ReplicaId(replica), counter), - spelling: ContentHash([1u8; 32]), + spelling: crate::valuegen::spelling(1), })), } } diff --git a/crates/epiphany-ops/src/decode.rs b/crates/epiphany-ops/src/decode.rs index 8e4672a..08c2793 100644 --- a/crates/epiphany-ops/src/decode.rs +++ b/crates/epiphany-ops/src/decode.rs @@ -6,8 +6,10 @@ use std::collections::BTreeMap; -use epiphany_core::{MusicalPosition, OperationId, PitchId, RegionId, TypedObjectId}; -use epiphany_determinism::{CanonicalDecode, ContentHash}; +use epiphany_core::{ + CanonicalValue, MusicalPosition, OperationId, PitchId, PitchSpelling, RegionId, TypedObjectId, +}; +use epiphany_determinism::CanonicalDecode; use crate::{ ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry, @@ -516,8 +518,11 @@ pub(crate) fn decode_materialized_state(bytes: &[u8]) -> Result(&mut reader, 16, "PitchId")?; - let hash = fixed::(&mut reader, 32, "ContentHash")?; - spellings.insert(pitch, hash); + // The value is the full PitchSpelling (v1), encoded behind a u32 length + // prefix via its canonical value bytes. + let spelling = PitchSpelling::decode_canonical(reader.lp_bytes()?) + .map_err(|_| MaterializedDecodeError::InvalidValue("PitchSpelling"))?; + spellings.insert(pitch, spelling); } let break_count = reader.len()?; diff --git a/crates/epiphany-ops/src/envelope.rs b/crates/epiphany-ops/src/envelope.rs index 4e0d459..e347485 100644 --- a/crates/epiphany-ops/src/envelope.rs +++ b/crates/epiphany-ops/src/envelope.rs @@ -195,7 +195,6 @@ mod tests { use crate::payload::{OperationKind, RespellPitchOp}; use crate::stamp::HybridLogicalClock; use epiphany_core::{PitchId, ReplicaId, WallClockTime}; - use epiphany_determinism::ContentHash; fn env(id: OperationId, stamp_id: OperationId) -> OperationEnvelope { OperationEnvelope { @@ -206,7 +205,7 @@ mod tests { transaction: None, payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(ReplicaId(1), 1), - spelling: ContentHash([7u8; 32]), + spelling: crate::valuegen::spelling(7), })), } } @@ -257,7 +256,7 @@ mod tests { let ha = a.envelope_hash(); a.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(ReplicaId(1), 1), - spelling: ContentHash([9u8; 32]), // different spelling + spelling: crate::valuegen::spelling(9), // different spelling })); assert_ne!(ha, a.envelope_hash()); } diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index 497c0ee..9bcacea 100644 --- a/crates/epiphany-ops/src/fuzz.rs +++ b/crates/epiphany-ops/src/fuzz.rs @@ -22,19 +22,20 @@ use epiphany_core::{ EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId, - ReplicaId, SlurId, StaffInstanceId, TypedObjectId, VoiceId, + ReplicaId, SlurId, StaffInstanceId, VoiceId, }; -use epiphany_determinism::{fuzz::SplitMix64, ContentHash}; +use epiphany_determinism::fuzz::SplitMix64; use crate::causal::CausalContext; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - CreateCrossCuttingOp, CrossCuttingRef, DeleteEventOp, InsertEventOp, OperationKind, + CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind, OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TupletCompensation, }; use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::support::AuthorId; +use crate::valuegen; use crate::IntegrityAnomalyKind; /// Number of replicas the generator draws authors from. @@ -81,38 +82,46 @@ fn pitch(n: u64) -> PitchId { /// Generates a random payload over the shared id space. fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { let kind = match rng.below(5) { - 0 => OperationKind::InsertEvent(InsertEventOp { - voice: VoiceId::new(ReplicaId(7), rng.below(3)), - staff_instance: StaffInstanceId::new(ReplicaId(7), 0), - event: event(rng.below(ID_SPACE)), - position: MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), - duration: MusicalDuration::whole(), - pitches: if rng.chance(2) { + 0 => { + let voice = VoiceId::new(ReplicaId(7), rng.below(3)); + let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32)); + let pitches = if rng.chance(2) { vec![pitch(rng.below(ID_SPACE))] } else { vec![] - }, - }), + }; + OperationKind::InsertEvent(InsertEventOp { + staff_instance: StaffInstanceId::new(ReplicaId(7), 0), + event: valuegen::insert_event_value( + event(rng.below(ID_SPACE)), + voice, + position, + MusicalDuration::whole(), + &pitches, + ), + }) + } 1 => OperationKind::DeleteEvent(DeleteEventOp { event: event(rng.below(ID_SPACE)), tuplet_compensation: TupletCompensation::NotInTuplet, }), 2 => OperationKind::RespellPitch(RespellPitchOp { pitch: pitch(rng.below(ID_SPACE)), - spelling: ContentHash([(rng.below(4) as u8) + 1; 32]), + spelling: valuegen::spelling(rng.below(4) as u8 + 1), }), 3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp { - structure: CrossCuttingRef { - id: TypedObjectId::Slur(SlurId::new(ReplicaId(7), rng.below(ID_SPACE))), - endpoints: vec![ - TypedObjectId::Event(event(rng.below(ID_SPACE))), - TypedObjectId::Event(event(rng.below(ID_SPACE))), - ], - }, + structure: CrossCuttingValue::Slur(valuegen::slur( + SlurId::new(ReplicaId(7), rng.below(ID_SPACE)), + event(rng.below(ID_SPACE)), + event(rng.below(ID_SPACE)), + )), }), _ => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { region: RegionId::new(ReplicaId(7), 0), - anchor: MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), + anchor: valuegen::region_start_anchor( + RegionId::new(ReplicaId(7), 0), + MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), + ), present: rng.chance(2), }), }; @@ -218,7 +227,7 @@ pub fn gen_envelope_set(rng: &mut SplitMix64, n: usize) -> Vec) -> core::fmt::Result { + match self { + MigrationError::Irreversible(what) => { + write!(f, "v0 envelope is not migratable: {what}") + } + } + } +} + +impl std::error::Error for MigrationError {} + +/// The deterministic fingerprint a v0 `RespellPitch` carried for a spelling. +fn spelling_fingerprint(spelling: &PitchSpelling) -> ContentHash { + ContentHash::of_blob(&spelling.canonical_bytes()) +} + +// =========================================================================== +// Projection: v1 → v0 (total). +// =========================================================================== + +/// Projects a v1 envelope down to its v0 wire shape (dropping the value content +/// to identifiers, scalars, and a spelling fingerprint). Total — every v1 +/// envelope has a v0 projection. The inverse of [`migrate_v0_envelope`] on the +/// reduction-relevant content. +pub fn project_v1_to_v0(env: &OperationEnvelope) -> V0OperationEnvelope { + V0OperationEnvelope { + id: env.id, + author: env.author, + stamp: env.stamp, + causal_context: env.causal_context.clone(), + transaction: env.transaction, + payload: project_payload(&env.payload), + } +} + +fn project_payload(p: &OperationPayload) -> V0OperationPayload { + match p { + OperationPayload::Primitive(kind) => V0OperationPayload::Primitive(project_kind(kind)), + OperationPayload::ResolveConflict(rc) => V0OperationPayload::ResolveConflict(*rc), + OperationPayload::UndoTransaction(u) => V0OperationPayload::UndoTransaction(*u), + } +} + +fn project_kind(kind: &OperationKind) -> V0OperationKind { + match kind { + OperationKind::InsertEvent(op) => V0OperationKind::InsertEvent(V0InsertEventOp { + voice: op.voice(), + staff_instance: op.staff_instance, + event: op.event_id(), + position: op.musical_position(), + duration: op.musical_duration(), + pitches: op.pitch_ids(), + }), + OperationKind::DeleteEvent(op) => V0OperationKind::DeleteEvent(V0DeleteEventOp { + event: op.event, + tuplet_compensation: project_tuplet(&op.tuplet_compensation), + }), + OperationKind::RespellPitch(op) => V0OperationKind::RespellPitch(V0RespellPitchOp { + pitch: op.pitch, + spelling: spelling_fingerprint(&op.spelling), + }), + OperationKind::CreateCrossCutting(op) => { + V0OperationKind::CreateCrossCutting(V0CreateCrossCuttingOp { + id: op.structure.id(), + endpoints: op.structure.endpoints(), + }) + } + OperationKind::ChangeRegionTimeModel(op) => { + V0OperationKind::ChangeRegionTimeModel(crate::v0::V0ChangeRegionTimeModelOp { + region: op.region, + new_time_model: project_time_model(&op.new_time_model), + declared_incompatible: op.declared_incompatible.clone(), + remapping: project_remapping(&op.remapping), + }) + } + OperationKind::SetUserSystemBreak(op) => { + V0OperationKind::SetUserSystemBreak(V0SetUserSystemBreakOp { + region: op.region, + anchor: op.resolved_position(), + present: op.present, + }) + } + OperationKind::DeclareTransaction(desc) => { + V0OperationKind::DeclareTransaction(desc.clone()) + } + OperationKind::Registered(id, bytes) => V0OperationKind::Registered(*id, bytes.clone()), + } +} + +fn project_tuplet(t: &TupletCompensation) -> V0TupletCompensation { + match t { + TupletCompensation::NotInTuplet => V0TupletCompensation::NotInTuplet, + TupletCompensation::ReplaceWithRest { rest } => V0TupletCompensation::ReplaceWithRest { + new_rest: rest.id, + duration: match &rest.duration { + epiphany_core::EventDuration::Musical(d) => d.clone(), + _ => epiphany_core::MusicalDuration::zero(), + }, + }, + TupletCompensation::RewriteTuplets { tuplets } => V0TupletCompensation::RewriteTuplets { + tuplets: tuplets.clone(), + }, + TupletCompensation::CascadeDeleteTuplets { tuplets } => { + V0TupletCompensation::CascadeDeleteTuplets { + tuplets: tuplets.clone(), + } + } + } +} + +fn project_time_model(m: &epiphany_core::RegionTimeModel) -> V0RegionTimeModelTag { + match m { + epiphany_core::RegionTimeModel::Metric(_) => V0RegionTimeModelTag::Metric, + epiphany_core::RegionTimeModel::Proportional(_) => V0RegionTimeModelTag::Proportional, + epiphany_core::RegionTimeModel::Aleatoric(_) => V0RegionTimeModelTag::Aleatoric, + } +} + +fn project_remapping(r: &PositionRemapping) -> V0PositionRemapping { + match r { + PositionRemapping::PreserveTime => V0PositionRemapping::PreserveTime, + PositionRemapping::Reassign(v) => V0PositionRemapping::Reassign(v.clone()), + } +} + +// =========================================================================== +// Migration: v0 → v1 (uses context to reconstruct values). +// =========================================================================== + +/// Lifts a v0 envelope to v1, reconstructing value payloads from `context`. +/// Deterministic and equivalence-preserving (see module docs). Returns +/// [`MigrationError::Irreversible`] for the one representative payload whose +/// value the v0 projection cannot reconstruct without the context (a respell's +/// spelling, P12-K1). +pub fn migrate_v0_envelope( + v0: V0OperationEnvelope, + context: &Score, +) -> Result { + let payload = migrate_payload(&v0.payload, context)?; + Ok(v0.rewrap(payload)) +} + +fn migrate_payload( + p: &V0OperationPayload, + context: &Score, +) -> Result { + Ok(match p { + V0OperationPayload::Primitive(kind) => { + OperationPayload::Primitive(migrate_kind(kind, context)?) + } + V0OperationPayload::ResolveConflict(rc) => OperationPayload::ResolveConflict(*rc), + V0OperationPayload::UndoTransaction(u) => OperationPayload::UndoTransaction(*u), + }) +} + +fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result { + Ok(match kind { + V0OperationKind::InsertEvent(op) => OperationKind::InsertEvent(InsertEventOp { + staff_instance: op.staff_instance, + // The v0 op carried every reduction-relevant scalar, so the event + // value is reconstructed self-contained (no context needed). + event: valuegen::insert_event_value( + op.event, + op.voice, + op.position.clone(), + op.duration.clone(), + &op.pitches, + ), + }), + V0OperationKind::DeleteEvent(op) => OperationKind::DeleteEvent(DeleteEventOp { + event: op.event, + tuplet_compensation: migrate_tuplet(&op.tuplet_compensation), + }), + V0OperationKind::RespellPitch(op) => OperationKind::RespellPitch(RespellPitchOp { + pitch: op.pitch, + spelling: recover_spelling(op, context)?, + }), + V0OperationKind::CreateCrossCutting(op) => { + OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: reconstruct_structure(op)?, + }) + } + V0OperationKind::ChangeRegionTimeModel(op) => { + OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp { + region: op.region, + new_time_model: migrate_time_model(op.new_time_model), + declared_incompatible: op.declared_incompatible.clone(), + remapping: migrate_remapping(&op.remapping), + }) + } + V0OperationKind::SetUserSystemBreak(op) => { + OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { + region: op.region, + anchor: valuegen::region_start_anchor(op.region, op.anchor.clone()), + present: op.present, + }) + } + V0OperationKind::DeclareTransaction(desc) => { + OperationKind::DeclareTransaction(desc.clone()) + } + V0OperationKind::Registered(id, bytes) => OperationKind::Registered(*id, bytes.clone()), + }) +} + +fn migrate_tuplet(t: &V0TupletCompensation) -> TupletCompensation { + match t { + V0TupletCompensation::NotInTuplet => TupletCompensation::NotInTuplet, + V0TupletCompensation::ReplaceWithRest { new_rest, duration } => { + // The v0 op dropped the rest's voice/position; they are recovered + // from the deleted event's placement at reduction, so a placeholder + // voice is faithful for the rest value's own field. + TupletCompensation::ReplaceWithRest { + rest: valuegen::rest_value( + *new_rest, + VoiceId::new(ReplicaId(1), 0), + duration.clone(), + ), + } + } + V0TupletCompensation::RewriteTuplets { tuplets } => TupletCompensation::RewriteTuplets { + tuplets: tuplets.clone(), + }, + V0TupletCompensation::CascadeDeleteTuplets { tuplets } => { + TupletCompensation::CascadeDeleteTuplets { + tuplets: tuplets.clone(), + } + } + } +} + +fn migrate_time_model(tag: V0RegionTimeModelTag) -> epiphany_core::RegionTimeModel { + match tag { + V0RegionTimeModelTag::Metric => valuegen::metric_model(), + V0RegionTimeModelTag::Proportional => valuegen::proportional_model(), + V0RegionTimeModelTag::Aleatoric => valuegen::aleatoric_model(), + } +} + +fn migrate_remapping(r: &V0PositionRemapping) -> PositionRemapping { + match r { + V0PositionRemapping::PreserveTime => PositionRemapping::PreserveTime, + V0PositionRemapping::Reassign(v) => PositionRemapping::Reassign(v.clone()), + } +} + +/// Reconstructs a cross-cutting structure value from its reference-level v0 +/// projection. The id's kind selects the structure; the rich per-kind fields +/// (a tie's class, a beam's level) are not recoverable from the reference and +/// are rebuilt with defaults — they do not affect canonical reduction state +/// (only the graph), so equivalence holds. +fn reconstruct_structure(op: &V0CreateCrossCuttingOp) -> Result { + use epiphany_core::TypedObjectId; + let events: Vec<_> = op + .endpoints + .iter() + .filter_map(|e| match e { + TypedObjectId::Event(id) => Some(*id), + _ => None, + }) + .collect(); + Ok(match op.id { + TypedObjectId::Slur(id) if events.len() == 2 => { + CrossCuttingValue::Slur(valuegen::slur(id, events[0], events[1])) + } + TypedObjectId::Tie(id) if events.len() == 2 => { + CrossCuttingValue::Tie(valuegen::tie(id, events[0], events[1])) + } + TypedObjectId::Beam(id) if events.len() >= 2 => { + CrossCuttingValue::Beam(valuegen::beam(id, events)) + } + _ => { + return Err(MigrationError::Irreversible( + "cross-cutting reference does not name a representative event-anchored structure", + )) + } + }) +} + +/// Recovers a respell's [`PitchSpelling`] from the context: an explicit +/// per-pitch spelling attachment whose canonical bytes hash to the v0 +/// fingerprint (P12-K1). +fn recover_spelling( + op: &V0RespellPitchOp, + context: &Score, +) -> Result { + for att in &context.spelling_attachments { + if let (SpellingScope::Pitch(pitch), SpellingDirective::Explicit(spelling)) = + (&att.scope, &att.directive) + { + if *pitch == op.pitch && spelling_fingerprint(spelling) == op.spelling { + return Ok(spelling.clone()); + } + } + } + Err(MigrationError::Irreversible( + "respell spelling fingerprint has no matching explicit spelling in context (P12-K1)", + )) +} diff --git a/crates/epiphany-ops/src/opset.rs b/crates/epiphany-ops/src/opset.rs index 5139855..2a69ecf 100644 --- a/crates/epiphany-ops/src/opset.rs +++ b/crates/epiphany-ops/src/opset.rs @@ -211,7 +211,6 @@ mod tests { use crate::support::AuthorId; use crate::OperationPayload; use epiphany_core::{PitchId, ReplicaId, WallClockTime}; - use epiphany_determinism::ContentHash; fn env_with(id: OperationId, spelling: u8) -> OperationEnvelope { OperationEnvelope { @@ -222,7 +221,7 @@ mod tests { transaction: None, payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(ReplicaId(1), 1), - spelling: ContentHash([spelling; 32]), + spelling: crate::valuegen::spelling(spelling), })), } } diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index 07b8c1c..2449a4c 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -3,39 +3,53 @@ //! structs the chapter specifies reduction rules for (Chapter 6 §"The Operation //! Framework", §"Representative Operations"). //! -//! ## Why these payloads carry identifiers, not whole graph objects +//! ## These payloads are value-typed (Operation Catalog, v1) //! -//! 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. +//! 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. +//! 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::{ - EventId, MusicalDuration, MusicalPosition, PitchId, RegionId, StaffInstanceId, TransactionId, - TupletId, TypedObjectId, VoiceId, + Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, MusicalDuration, + MusicalPosition, PitchId, PitchSpelling, RegionId, RegionTimeModel, Rest, Slur, Spanner, + StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, VoiceId, }; -use epiphany_determinism::{sorted_canonical, CanonicalEncode, ContentHash}; +use epiphany_determinism::{sorted_canonical, CanonicalEncode}; use crate::conflict::{ConflictId, ResolutionAction}; -use crate::encode::{push_canon, push_seq, push_str, push_tag, push_u8_bool}; +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. @@ -70,6 +84,9 @@ impl CanonicalEncode for OperationPayload { /// 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 @@ -201,31 +218,63 @@ impl CanonicalEncode for OperationKindTag { // --- Representative operation payloads (Chapter 6 §6.10). -------------------- -/// Insert an event into a voice (Chapter 6 §6.10 InsertEvent). +/// 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. /// -/// 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. +/// `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 voice: VoiceId, pub staff_instance: StaffInstanceId, - pub event: EventId, - pub position: MusicalPosition, - pub duration: MusicalDuration, - pub pitches: Vec, + 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.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())); + push_lp_bytes(out, &self.event.canonical_bytes()); } } @@ -244,18 +293,14 @@ impl CanonicalEncode for DeleteEventOp { } /// 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. +/// 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 { - new_rest: EventId, - duration: MusicalDuration, - }, + ReplaceWithRest { rest: Rest }, /// Rewrite the enclosing tuplet(s) to remain consistent. RewriteTuplets { tuplets: Vec }, /// Cascade-delete the tuplet group(s) containing the target. @@ -284,9 +329,8 @@ impl CanonicalEncode for TupletCompensation { push_tag(out, self.discriminant()); match self { TupletCompensation::NotInTuplet => {} - TupletCompensation::ReplaceWithRest { new_rest, duration } => { - push_canon(out, new_rest); - push_canon(out, duration); + TupletCompensation::ReplaceWithRest { rest } => { + push_lp_bytes(out, &rest.canonical_bytes()); } TupletCompensation::RewriteTuplets { tuplets } | TupletCompensation::CascadeDeleteTuplets { tuplets } => { @@ -296,31 +340,31 @@ impl CanonicalEncode for TupletCompensation { } } -/// 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)] +/// 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: ContentHash, + pub spelling: PitchSpelling, } impl CanonicalEncode for RespellPitchOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.pitch); - push_canon(out, &self.spelling); + push_lp_bytes(out, &self.spelling.canonical_bytes()); } } -/// 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. +/// 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: CrossCuttingRef, + pub structure: CrossCuttingValue, } impl CanonicalEncode for CreateCrossCuttingOp { @@ -329,34 +373,90 @@ impl CanonicalEncode for CreateCrossCuttingOp { } } -/// A reference-level view of a cross-cutting structure: its identity and the -/// objects it references (Chapter 6 §6.5 re-anchoring operands). +/// 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 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, +pub enum CrossCuttingValue { + Tie(Tie), + Slur(Slur), + Beam(Beam), + Spanner(Spanner), } -impl CanonicalEncode for CrossCuttingRef { - fn encode_canonical(&self, out: &mut Vec) { - push_canon(out, &self.id); - // Endpoint order is meaningful (start before end), so it is NOT sorted. - push_seq(out, &self.endpoints); +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(), + } } } -/// 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`). +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: RegionTimeModelTag, + pub new_time_model: RegionTimeModel, pub declared_incompatible: Vec, pub remapping: PositionRemapping, } @@ -364,39 +464,12 @@ pub struct ChangeRegionTimeModelOp { impl CanonicalEncode for ChangeRegionTimeModelOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.region); - self.new_time_model.encode_canonical(out); + push_lp_bytes(out, &self.new_time_model.canonical_bytes()); 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) { - 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. @@ -440,21 +513,37 @@ fn push_len_pairs(out: &mut Vec, entries: &[(EventId, MusicalPosition)]) { } } -/// 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. +/// 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: MusicalPosition, + pub anchor: TimeAnchor, pub present: bool, } +impl SetUserSystemBreakOp { + /// The anchor's resolved musical position — the canonical LWW bucketing key. + /// 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`). + pub fn resolved_position(&self) -> MusicalPosition { + match &self.anchor { + TimeAnchor::Region { + offset: epiphany_core::AnchorOffset::Musical(d), + .. + } => MusicalPosition(d.0.clone()), + _ => MusicalPosition::origin(), + } + } +} + impl CanonicalEncode for SetUserSystemBreakOp { fn encode_canonical(&self, out: &mut Vec) { push_canon(out, &self.region); - push_canon(out, &self.anchor); + push_lp_bytes(out, &self.anchor.canonical_bytes()); push_u8_bool(out, self.present); } } @@ -558,7 +647,7 @@ mod tests { assert_eq!(k.tag(), OperationKindTag::Registered(reg)); let prim = OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(ReplicaId(1), 1), - spelling: ContentHash([4u8; 32]), + spelling: crate::valuegen::spelling(4), }); assert_eq!(prim.tag(), OperationKindTag::RespellPitch); } @@ -602,17 +691,13 @@ mod tests { #[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], - }; + // 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()); } } diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index c12ea96..b922f76 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -31,15 +31,12 @@ use std::collections::{BTreeMap, BTreeSet}; use epiphany_core::{ - derive_promoted_voice_id, AcousticPitch, AcousticRealization, AleatoricAnchoringDiscipline, - AleatoricTimeModel, AnchorOffset, Beam, CmnNominal, Event, EventDuration, EventId, - EventOrderingDAG, EventPosition, IdentifiedPitch, MetricTimeModel, MusicalDuration, - MusicalPosition, OperationId, Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent, - ProportionalTimeModel, RegionEdge, RegionId, RegionTimeModel, Rest, ScalePosition, Score, Slur, - StemConfiguration, Tie, TieClass, TimeAnchor, TransactionId, TuningReference, TypedObjectId, - Voice, VoiceId, VoiceOrigin, WallClockDuration, + derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId, + EventPosition, MusicalDuration, MusicalPosition, OperationId, PitchId, PitchSpelling, + RegionEdge, RegionId, RegionTimeModel, Score, TimeAnchor, TransactionId, TypedObjectId, Voice, + VoiceId, VoiceOrigin, }; -use epiphany_determinism::{CanonicalEncode, ContentHash}; +use epiphany_determinism::CanonicalEncode; use crate::anomaly::{detect_replica_anomalies, IntegrityAnomaly, IntegrityAnomalyKind}; use crate::conflict::{ @@ -49,12 +46,12 @@ use crate::effect::{ NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, RepairKind, RepairRecord, TupletCompensationKind, }; -use crate::encode::{push_canon, push_len, push_u8_bool}; +use crate::encode::{push_canon, push_len, push_lp_bytes, push_u8_bool}; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - CreateCrossCuttingOp, DeleteEventOp, InsertEventOp, OperationKind, OperationPayload, - RespellPitchOp, TupletCompensation, + CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind, + OperationPayload, RespellPitchOp, TupletCompensation, }; use crate::undo::{UndoPolicy, UndoTransactionPayload}; @@ -204,7 +201,7 @@ pub struct MaterializedState { /// Object existence (live/tombstoned), keyed by `TypedObjectId`. pub objects: BTreeMap, /// Current resolved spelling per pitch (the `RespellPitch` field). - pub spellings: BTreeMap, + pub spellings: BTreeMap, /// User system-break preferences (LWW advisory), keyed by region+anchor. pub breaks: BTreeMap<(RegionId, MusicalPosition), bool>, /// Operations held pending, ordered by `OperationId`. @@ -252,11 +249,12 @@ impl MaterializedState { push_canon(&mut out, id); state.encode_canonical(&mut out); } - // Spellings (PitchId order). + // Spellings (PitchId order). The value is the full PitchSpelling (v1), + // encoded behind a u32 length prefix via its canonical value bytes. push_len(&mut out, self.spellings.len()); - for (pitch, hash) in &self.spellings { + for (pitch, spelling) in &self.spellings { push_canon(&mut out, pitch); - push_canon(&mut out, hash); + push_lp_bytes(&mut out, &spelling.canonical_bytes()); } // Breaks (region+anchor order). push_len(&mut out, self.breaks.len()); @@ -309,7 +307,7 @@ struct Reducer<'a> { op_set: &'a OperationSet, // Canonical results. objects: BTreeMap, - spellings: BTreeMap, + spellings: BTreeMap, breaks: BTreeMap<(RegionId, MusicalPosition), bool>, conflicts: ConflictRegistry, effects: Vec<(OperationId, OperationEffect)>, @@ -333,7 +331,7 @@ struct Reducer<'a> { /// A snapshot of the working state, for atomic transaction rollback. struct WorkingSnapshot { objects: BTreeMap, - spellings: BTreeMap, + spellings: BTreeMap, breaks: BTreeMap<(RegionId, MusicalPosition), bool>, conflicts: ConflictRegistry, minted_by: BTreeMap, @@ -363,7 +361,12 @@ fn intervals_overlap( } fn insert_intervals_overlap(a: &InsertEventOp, b: &InsertEventOp) -> bool { - intervals_overlap(&a.position, &a.duration, &b.position, &b.duration) + intervals_overlap( + &a.musical_position(), + &a.musical_duration(), + &b.musical_position(), + &b.musical_duration(), + ) } fn graph_voice_location(score: &Score, voice: VoiceId) -> Option<(usize, usize, usize)> { @@ -381,56 +384,14 @@ fn graph_voice_location(score: &Score, voice: VoiceId) -> Option<(usize, usize, None } -fn placeholder_pitch(id: PitchId) -> IdentifiedPitch { - IdentifiedPitch { - id, - pitch: Pitch { - scale_position: ScalePosition { - space: PitchSpaceId::new("cmn-12"), - position: PitchSpacePosition::Cmn { - nominal: CmnNominal::C, - alteration: 0, - octave: 4, - }, - }, - acoustic: AcousticPitch { - tuning: TuningReference::Inherit, - realization: AcousticRealization::Implicit, - }, - }, - } -} - -/// Builds the graph value represented by the prototype's identifier-only -/// InsertEvent payload. Rich event payloads remain gated on the Operation -/// Catalog encoding; until then, pitched inserts use deterministic C4 values -/// and pitchless inserts use a visible rest. +/// The graph value inserted by a value-typed InsertEvent: the carried [`Event`] +/// itself, with its voice rebound to the (possibly system-promoted) target +/// voice. The Operation Catalog (v1) carries the real event, so this is no +/// longer a placeholder reconstruction. fn graph_event_from_insert(op: &InsertEventOp, target_voice: VoiceId) -> Event { - let position = EventPosition::Musical(op.position.clone()); - let duration = EventDuration::Musical(op.duration.clone()); - if op.pitches.is_empty() { - Event::Rest(Rest { - id: op.event, - voice: target_voice, - position, - duration, - vertical_position: None, - visible: true, - }) - } else { - Event::Pitched(PitchedEvent { - id: op.event, - voice: target_voice, - position, - duration, - pitches: op.pitches.iter().copied().map(placeholder_pitch).collect(), - articulations: Vec::new(), - dynamic: None, - ornaments: Vec::new(), - stem: StemConfiguration, - grace: None, - }) - } + let mut event = op.event.clone(); + event.set_voice(target_voice); + event } impl<'a> Reducer<'a> { @@ -742,7 +703,7 @@ impl<'a> Reducer<'a> { if self.graph_insert_precondition(op).is_err() { continue; } - buckets.entry(op.voice).or_default().push(env); + buckets.entry(op.voice()).or_default().push(env); } } for (_, mut bucket) in buckets { @@ -769,7 +730,7 @@ impl<'a> Reducer<'a> { }); if let Some(winner) = collision { let promoted = - derive_promoted_voice_id(op.staff_instance, op.voice, winner.id, env.id); + derive_promoted_voice_id(op.staff_instance, op.voice(), winner.id, env.id); self.promotion.insert(env.id, (promoted, winner.id)); } else { original_voice.push(env); @@ -785,8 +746,8 @@ impl<'a> Reducer<'a> { let Some(score) = self.graph.as_ref() else { return Ok((0, 0, 0)); }; - let location = - graph_voice_location(score, op.voice).ok_or(PreconditionFailureReason::VoiceMissing)?; + let location = graph_voice_location(score, op.voice()) + .ok_or(PreconditionFailureReason::VoiceMissing)?; let (region_index, instance_index, _) = location; let region = &score.canvas.regions[region_index]; let instance = ®ion.staff_instances()[instance_index]; @@ -796,9 +757,10 @@ impl<'a> Reducer<'a> { if !matches!(region.time_model, epiphany_core::RegionTimeModel::Metric(_)) { return Err(PreconditionFailureReason::WrongRegionTimeModel); } - if score.events.contains(op.event) - || score.tombstoned_events.contains(&op.event) - || op.pitches.iter().any(|pitch| { + let event_id = op.event_id(); + if score.events.contains(event_id) + || score.tombstoned_events.contains(&event_id) + || op.pitch_ids().iter().any(|pitch| { self.objects.contains_key(&TypedObjectId::Pitch(*pitch)) || score.tombstoned_pitches.contains(pitch) }) @@ -835,13 +797,13 @@ impl<'a> Reducer<'a> { .expect("the precondition found this instance"); instance.voices.push(Voice { id: promoted, - events: vec![op.event], + events: vec![op.event_id()], default_stem_direction: None, is_primary: false, origin: VoiceOrigin::SystemPromoted { winning_operation: winner, losing_operation: env.id, - original_voice: op.voice, + original_voice: op.voice(), }, }); } else { @@ -849,7 +811,7 @@ impl<'a> Reducer<'a> { .voices[voice_index] .events .clone(); - ordered.push(op.event); + ordered.push(op.event_id()); ordered.sort_by(|a, b| { let a_position = score.events.get(*a).map(Event::position); let b_position = score.events.get(*b).map(Event::position); @@ -894,10 +856,10 @@ impl<'a> Reducer<'a> { Err(PreconditionFailureReason::TupletCompensationInvalid) } TupletCompensation::NotInTuplet => Ok(()), - TupletCompensation::ReplaceWithRest { new_rest, duration } => { - if score.events.contains(*new_rest) - || score.tombstoned_events.contains(new_rest) - || event.duration() != &EventDuration::Musical(duration.clone()) + TupletCompensation::ReplaceWithRest { rest } => { + if score.events.contains(rest.id) + || score.tombstoned_events.contains(&rest.id) + || event.duration() != &rest.duration { Err(PreconditionFailureReason::TupletCompensationInvalid) } else { @@ -957,25 +919,23 @@ impl<'a> Reducer<'a> { .extend(deleted_pitches.iter().copied()); match &op.tuplet_compensation { - TupletCompensation::ReplaceWithRest { new_rest, duration } => { + TupletCompensation::ReplaceWithRest { rest } => { + let new_rest = rest.id; for tuplet in &mut score.cross_cutting.tuplets { for member in &mut tuplet.members { if *member == op.event { - *member = *new_rest; + *member = new_rest; } } } - let replacement = Event::Rest(Rest { - id: *new_rest, - voice: voice_id, - position: event.position().clone(), - duration: EventDuration::Musical(duration.clone()), - vertical_position: None, - visible: true, - }); + // The value-typed payload (v1) carries the replacement Rest; it + // is placed at the deleted event's voice and position. + let mut replacement = rest.clone(); + replacement.voice = voice_id; + replacement.position = event.position().clone(); score .events - .insert(replacement) + .insert(Event::Rest(replacement)) .expect("replacement-rest preconditions were checked"); if let Some((region_index, instance_index, voice_index)) = graph_voice_location(score, voice_id) @@ -985,11 +945,11 @@ impl<'a> Reducer<'a> { .staff_instances_mut() .expect("an event voice belongs to staff-based content")[instance_index] .voices[voice_index]; - if !voice.events.contains(new_rest) { + if !voice.events.contains(&new_rest) { let index = removed_event_index .unwrap_or(voice.events.len()) .min(voice.events.len()); - voice.events.insert(index, *new_rest); + voice.events.insert(index, new_rest); } } } @@ -1096,47 +1056,21 @@ impl<'a> Reducer<'a> { let Some(score) = self.graph.as_mut() else { return Ok(()); }; - let event_endpoints: Option> = op - .structure - .endpoints - .iter() - .map(|endpoint| match endpoint { - TypedObjectId::Event(event) => Some(*event), - _ => None, - }) - .collect(); - - match (op.structure.id, event_endpoints.as_deref()) { - (TypedObjectId::Slur(id), Some([start, end])) => { - score.cross_cutting.slurs.push(Slur { - id, - start_event: *start, - end_event: *end, - }); + // The value-typed payload (v1) carries the real structure with its rich + // fields, so materialization clones it directly rather than rebuilding a + // default from the reference-level projection. + match &op.structure { + CrossCuttingValue::Slur(slur) => score.cross_cutting.slurs.push(slur.clone()), + CrossCuttingValue::Beam(beam) => { + if beam.events.len() < 2 { + return Err(PreconditionFailureReason::TargetMissing); + } + score.cross_cutting.beams.push(beam.clone()); } - (TypedObjectId::Beam(id), Some(events)) if events.len() >= 2 => { - score.cross_cutting.beams.push(Beam { - id, - events: events.to_vec(), - level: 1, - }); + CrossCuttingValue::Tie(tie) => score.cross_cutting.ties.push(tie.clone()), + CrossCuttingValue::Spanner(spanner) => { + score.cross_cutting.spanners.push(spanner.clone()) } - (TypedObjectId::Tie(id), Some([start, end])) => { - score.cross_cutting.ties.push(Tie { - id, - start_event: *start, - end_event: *end, - pitch_pairing: None, - class: TieClass::LaissezVibrer, - }); - } - (TypedObjectId::Slur(_) | TypedObjectId::Beam(_) | TypedObjectId::Tie(_), _) => { - return Err(PreconditionFailureReason::TargetMissing); - } - // The reference-level prototype payload does not contain the rich - // fields needed to instantiate other cross-cutting variants. Their - // canonical identity/reference projection remains in `state`. - _ => {} } Ok(()) } @@ -1199,11 +1133,9 @@ impl<'a> Reducer<'a> { } } }; - let anchor = TimeAnchor::Region { - id: op.region, - edge: RegionEdge::Start, - offset: AnchorOffset::Musical(MusicalDuration(op.anchor.0.clone())), - }; + // The value-typed payload (v1) carries the full TimeAnchor, so the + // graph break is the anchor itself rather than a reconstructed one. + let anchor = op.anchor.clone(); if op.present { if !breaks.contains(&anchor) { breaks.push(anchor); @@ -1213,20 +1145,26 @@ impl<'a> Reducer<'a> { } } + // The LWW bucketing key is the anchor's resolved musical position. self.breaks - .insert((op.region, op.anchor.clone()), op.present); + .insert((op.region, op.resolved_position()), op.present); OperationEffect::Applied } fn insert_event(&mut self, env: &OperationEnvelope, op: &InsertEventOp) -> OperationEffect { - if !op.duration.is_positive() { + // The reduction keys are read from the carried event value (v1). + let event_id = op.event_id(); + let orig_voice = op.voice(); + let op_position = op.musical_position(); + let op_duration = op.musical_duration(); + if !op_duration.is_positive() { return OperationEffect::NoOp { reason: NoOpReason::PreconditionFailedUnderReduction { reason: PreconditionFailureReason::EventDurationInvalid, }, }; } - let ev_obj = TypedObjectId::Event(op.event); + let ev_obj = TypedObjectId::Event(event_id); match self.objects.get(&ev_obj) { Some(ObjectState::Live) => { return OperationEffect::NoOp { @@ -1248,7 +1186,7 @@ impl<'a> Reducer<'a> { } } }; - let voice_obj = TypedObjectId::Voice(op.voice); + let voice_obj = TypedObjectId::Voice(orig_voice); match self.objects.get(&voice_obj) { Some(ObjectState::Tombstoned { .. }) => { return OperationEffect::NoOp { @@ -1266,13 +1204,13 @@ impl<'a> Reducer<'a> { } let promotion = self.promotion.get(&env.id).copied(); - let target_voice = promotion.map(|(voice, _)| voice).unwrap_or(op.voice); + let target_voice = promotion.map(|(voice, _)| voice).unwrap_or(orig_voice); if self .voice_occupancy .get(&target_voice) .is_some_and(|events| { events.iter().any(|(position, duration, _)| { - intervals_overlap(position, duration, &op.position, &op.duration) + intervals_overlap(position, duration, &op_position, &op_duration) }) }) { @@ -1299,7 +1237,7 @@ impl<'a> Reducer<'a> { self.note_minted(env, pv); repairs.push(RepairRecord { kind: RepairKind::VoicePromoted { - from: op.voice, + from: orig_voice, to: promoted, }, target: pv, @@ -1310,18 +1248,18 @@ impl<'a> Reducer<'a> { self.minted_by.insert(ev_obj, env.id); self.note_minted(env, ev_obj); let mut pitches = Vec::new(); - for &p in &op.pitches { + for p in op.pitch_ids() { let p_obj = TypedObjectId::Pitch(p); self.objects.insert(p_obj, ObjectState::Live); self.minted_by.insert(p_obj, env.id); self.note_minted(env, p_obj); pitches.push(p); } - self.event_pitches.insert(op.event, pitches); + self.event_pitches.insert(event_id, pitches); self.voice_occupancy.entry(target_voice).or_default().push(( - op.position.clone(), - op.duration.clone(), - op.event, + op_position, + op_duration, + event_id, )); if repairs.is_empty() { @@ -1395,17 +1333,24 @@ impl<'a> Reducer<'a> { // Tuplet compensation. match &op.tuplet_compensation { TupletCompensation::NotInTuplet => {} - TupletCompensation::ReplaceWithRest { new_rest, duration } => { - let rest_obj = TypedObjectId::Event(*new_rest); + TupletCompensation::ReplaceWithRest { rest } => { + let new_rest = rest.id; + let rest_duration = match &rest.duration { + EventDuration::Musical(d) => d.clone(), + EventDuration::WallClock(_) | EventDuration::Indeterminate(_) => { + MusicalDuration::zero() + } + }; + let rest_obj = TypedObjectId::Event(new_rest); self.objects.insert(rest_obj, ObjectState::Live); self.minted_by.insert(rest_obj, env.id); self.note_minted(env, rest_obj); - self.event_pitches.insert(*new_rest, Vec::new()); + self.event_pitches.insert(new_rest, Vec::new()); if let Some((voice, position, _)) = &deleted_placement { self.voice_occupancy.entry(*voice).or_default().push(( position.clone(), - duration.clone(), - *new_rest, + rest_duration, + new_rest, )); } repairs.push(RepairRecord { @@ -1476,15 +1421,15 @@ impl<'a> Reducer<'a> { match self.last_respell.get(&op.pitch).copied() { None => { - self.spellings.insert(op.pitch, op.spelling); + self.spellings.insert(op.pitch, op.spelling.clone()); self.last_respell.insert(op.pitch, env.id); OperationEffect::Applied } Some(prev_op) => { - let prev_spelling = self.spellings.get(&op.pitch).copied(); + let prev_spelling = self.spellings.get(&op.pitch).cloned(); let concurrent = self.concurrent(env.id, prev_op); if concurrent { - if prev_spelling == Some(op.spelling) { + if prev_spelling.as_ref() == Some(&op.spelling) { // Identical concurrent respelling: idempotent. OperationEffect::NoOp { reason: NoOpReason::AlreadyApplied, @@ -1495,7 +1440,7 @@ impl<'a> Reducer<'a> { // earlier op is recorded as the loser. (Prototype // convention: the winner carries the Conflicted effect; // see DECISIONS.md.) - self.spellings.insert(op.pitch, op.spelling); + self.spellings.insert(op.pitch, op.spelling.clone()); self.last_respell.insert(op.pitch, env.id); let conflict = ConflictRecord::new( ConflictKind::StructuralFieldCollision { @@ -1512,7 +1457,7 @@ impl<'a> Reducer<'a> { } } else { // Causally-ordered re-respell: intentional overwrite. - self.spellings.insert(op.pitch, op.spelling); + self.spellings.insert(op.pitch, op.spelling.clone()); self.last_respell.insert(op.pitch, env.id); OperationEffect::Applied } @@ -1525,7 +1470,8 @@ impl<'a> Reducer<'a> { env: &OperationEnvelope, op: &CreateCrossCuttingOp, ) -> OperationEffect { - let sid = op.structure.id; + let sid = op.structure.id(); + let endpoints = op.structure.endpoints(); match self.objects.get(&sid) { Some(ObjectState::Live) => { return OperationEffect::NoOp { @@ -1540,7 +1486,7 @@ impl<'a> Reducer<'a> { None => {} } // Endpoints must exist (live). - for e in &op.structure.endpoints { + for e in &endpoints { if !matches!(self.objects.get(e), Some(ObjectState::Live)) { return OperationEffect::NoOp { reason: NoOpReason::PreconditionFailedUnderReduction { @@ -1557,7 +1503,7 @@ impl<'a> Reducer<'a> { self.objects.insert(sid, ObjectState::Live); self.minted_by.insert(sid, env.id); self.note_minted(env, sid); - self.structures.insert(sid, op.structure.endpoints.clone()); + self.structures.insert(sid, endpoints); OperationEffect::Applied } @@ -1617,16 +1563,16 @@ impl<'a> Reducer<'a> { incompatible_events.insert(*event_id); continue; }; - let compatible = match op.new_time_model { - crate::payload::RegionTimeModelTag::Metric => matches!( + let compatible = match &op.new_time_model { + RegionTimeModel::Metric(_) => matches!( (event.position(), event.duration()), (EventPosition::Musical(_), EventDuration::Musical(_)) ), - crate::payload::RegionTimeModelTag::Proportional => matches!( + RegionTimeModel::Proportional(_) => matches!( (event.position(), event.duration()), (EventPosition::WallClock(_), EventDuration::WallClock(_)) ), - crate::payload::RegionTimeModelTag::Aleatoric => true, + RegionTimeModel::Aleatoric(_) => true, }; if !compatible { incompatible_events.insert(*event_id); @@ -1641,10 +1587,7 @@ impl<'a> Reducer<'a> { .filter(|event| !mapped.contains(event)) .copied(), ); - if matches!( - op.new_time_model, - crate::payload::RegionTimeModelTag::Proportional - ) { + if matches!(op.new_time_model, RegionTimeModel::Proportional(_)) { // Reassign carries musical positions in the current // prototype schema, so it cannot satisfy a proportional // region's wall-clock coordinate discipline. @@ -1693,31 +1636,11 @@ impl<'a> Reducer<'a> { } } } + // The value-typed payload (v1) carries the real target model, so the + // region adopts it directly rather than rebuilding a default from a + // discriminator tag. let region = &mut score.canvas.regions[region_index]; - let wallclock_duration = region - .time_extent - .as_wallclock() - .and_then(|(start, end)| end.checked_sub(start)) - .filter(|duration| *duration > 0) - .unwrap_or(1); - region.time_model = match op.new_time_model { - crate::payload::RegionTimeModelTag::Metric => { - RegionTimeModel::Metric(MetricTimeModel::default()) - } - crate::payload::RegionTimeModelTag::Proportional => { - RegionTimeModel::Proportional(ProportionalTimeModel { - duration: WallClockDuration(wallclock_duration), - }) - } - crate::payload::RegionTimeModelTag::Aleatoric => { - RegionTimeModel::Aleatoric(AleatoricTimeModel { - ordering: EventOrderingDAG::default(), - anchoring: AleatoricAnchoringDiscipline::FreelyMixed, - bounds: BTreeMap::new(), - duration_hint: WallClockDuration(wallclock_duration), - }) - } - }; + region.time_model = op.new_time_model.clone(); } self.migrated_regions.insert(op.region); self.region_migrator.insert(op.region, env.id); @@ -2307,12 +2230,14 @@ mod tests { payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { // Voice and staff instance live in a shared (author-independent) // namespace so two authors can target the same voice. - voice: VoiceId::new(ReplicaId(9), voice), staff_instance: StaffInstanceId::new(ReplicaId(9), 0), - event: EventId::new(ReplicaId(replica), event), - position: pos(pos_units), - duration: epiphany_core::MusicalDuration::whole(), - pitches: vec![], + event: crate::valuegen::insert_event_value( + EventId::new(ReplicaId(replica), event), + VoiceId::new(ReplicaId(9), voice), + pos(pos_units), + epiphany_core::MusicalDuration::whole(), + &[], + ), })), } } @@ -2370,7 +2295,6 @@ mod tests { use crate::conflict::{ConflictResolutionState, ResolutionAction}; use crate::payload::{ResolveConflictPayload, RespellPitchOp}; use epiphany_core::PitchId; - use epiphany_determinism::ContentHash; let pitch = PitchId::new(ReplicaId(9), 500); @@ -2379,7 +2303,13 @@ mod tests { if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = insert_env.payload { - op.pitches = vec![pitch]; + op.event = crate::valuegen::insert_event_value( + op.event_id(), + op.voice(), + op.musical_position(), + op.musical_duration(), + &[pitch], + ); } // Two concurrent, differing respellings of `pitch`, both causally after @@ -2395,7 +2325,7 @@ mod tests { transaction: None, payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch, - spelling: ContentHash([byte; 32]), + spelling: crate::valuegen::spelling(byte), })), } }; diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs new file mode 100644 index 0000000..ad12975 --- /dev/null +++ b/crates/epiphany-ops/src/v0.rs @@ -0,0 +1,168 @@ +//! Frozen **v0** (identifier-only) operation payload shapes, retained solely as +//! the migration regression guard (Chapter 6; QUICKSTART Agent K, "v0 → v1 +//! payload migration", migration *option 2*). +//! +//! Track B's Operation Catalog shifted the live [`OperationPayload`] from +//! carrying *identifiers + scalars + a content fingerprint* to carrying the real +//! **value-typed** graph payloads. Production code now speaks only v1; these v0 +//! shapes never appear in a live envelope. They exist so the test corpus can +//! exercise the [`migrate_v0_envelope`](crate::migrate_v0_envelope) path and +//! prove it is *deterministic* and *equivalence-preserving*: a v0 envelope and +//! its v1 migration reduce to byte-identical canonical [`MaterializedState`]. +//! +//! These are deliberately a **byte-for-byte snapshot** of the pre-catalog +//! payload structs. They carry no `CanonicalEncode`: the regression guard works +//! on v0 *values* (projected from v1, or built directly), never on a historical +//! v0 *wire* form — there is no production corpus of v0 bundle bytes to read +//! (the prototype never shipped), so the wire form is not part of the guard. + +use epiphany_core::{ + EventId, MusicalDuration, MusicalPosition, PitchId, RegionId, StaffInstanceId, TransactionId, + TupletId, TypedObjectId, VoiceId, +}; +use epiphany_determinism::ContentHash; + +use crate::payload::{ResolveConflictPayload, TransactionDescriptor}; +use crate::support::OperationKindRegistryId; +use crate::undo::UndoTransactionPayload; +use crate::OperationEnvelope; + +/// Frozen v0 envelope: identical envelope wrapper, identifier-only payload. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct V0OperationEnvelope { + pub id: epiphany_core::OperationId, + pub author: crate::support::AuthorId, + pub stamp: crate::OperationStamp, + pub causal_context: crate::CausalContext, + pub transaction: Option, + pub payload: V0OperationPayload, +} + +/// Frozen v0 payload union (pre-catalog). +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum V0OperationPayload { + Primitive(V0OperationKind), + /// Value-complete in v0 already; unchanged in v1. + ResolveConflict(ResolveConflictPayload), + /// Value-complete in v0 already; unchanged in v1. + UndoTransaction(UndoTransactionPayload), +} + +/// Frozen v0 primitive kinds (the representative §6.10 set). +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum V0OperationKind { + InsertEvent(V0InsertEventOp), + DeleteEvent(V0DeleteEventOp), + RespellPitch(V0RespellPitchOp), + CreateCrossCutting(V0CreateCrossCuttingOp), + ChangeRegionTimeModel(V0ChangeRegionTimeModelOp), + SetUserSystemBreak(V0SetUserSystemBreakOp), + /// Value-complete in v0 already; unchanged in v1. + DeclareTransaction(TransactionDescriptor), + /// Opaque extension payload; unchanged in v1. + Registered(OperationKindRegistryId, Vec), +} + +/// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant +/// scalars (position/duration/pitch ids), not the full `Event` value. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct V0InsertEventOp { + pub voice: VoiceId, + pub staff_instance: StaffInstanceId, + pub event: EventId, + pub position: MusicalPosition, + pub duration: MusicalDuration, + pub pitches: Vec, +} + +/// v0 `DeleteEvent`: identifier-keyed (delete needs only the id); the +/// replacement-rest value was deferred. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct V0DeleteEventOp { + pub event: EventId, + pub tuplet_compensation: V0TupletCompensation, +} + +/// v0 tuplet compensation (the replacement rest was an id + duration, not a +/// `Rest` value). +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum V0TupletCompensation { + NotInTuplet, + ReplaceWithRest { + new_rest: EventId, + duration: MusicalDuration, + }, + RewriteTuplets { + tuplets: Vec, + }, + CascadeDeleteTuplets { + tuplets: Vec, + }, +} + +/// v0 `RespellPitch`: the new spelling was a [`ContentHash`] *fingerprint*, never +/// the `PitchSpelling` value (the reduction only needed spelling equality). This +/// is the irreversible case: a fingerprint cannot be inverted to a spelling +/// without a side table (see `migrate`, `MigrationError::Irreversible`, P12-K1). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct V0RespellPitchOp { + pub pitch: PitchId, + pub spelling: ContentHash, +} + +/// v0 `CreateCrossCutting`: a reference-only view (id + endpoints), not the +/// typed structure value. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct V0CreateCrossCuttingOp { + pub id: TypedObjectId, + pub endpoints: Vec, +} + +/// v0 `ChangeRegionTimeModel`: a discriminator tag, not the `RegionTimeModel` +/// value. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct V0ChangeRegionTimeModelOp { + pub region: RegionId, + pub new_time_model: V0RegionTimeModelTag, + pub declared_incompatible: Vec, + pub remapping: V0PositionRemapping, +} + +/// v0 region-time-model discriminator. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum V0RegionTimeModelTag { + Metric, + Proportional, + Aleatoric, +} + +/// v0 position remapping (unchanged shape; copied so v0 is self-contained). +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum V0PositionRemapping { + PreserveTime, + Reassign(Vec<(EventId, MusicalPosition)>), +} + +/// v0 `SetUserSystemBreak`: the anchor was a resolved [`MusicalPosition`], not a +/// `TimeAnchor` value. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct V0SetUserSystemBreakOp { + pub region: RegionId, + pub anchor: MusicalPosition, + pub present: bool, +} + +impl V0OperationEnvelope { + /// The envelope wrapper carried over verbatim (identity, stamp, causal + /// context, transaction); only the [`payload`](Self::payload) is migrated. + pub(crate) fn rewrap(&self, payload: crate::OperationPayload) -> OperationEnvelope { + OperationEnvelope { + id: self.id, + author: self.author, + stamp: self.stamp, + causal_context: self.causal_context.clone(), + transaction: self.transaction, + payload, + } + } +} diff --git a/crates/epiphany-ops/src/valuegen.rs b/crates/epiphany-ops/src/valuegen.rs new file mode 100644 index 0000000..b0eda87 --- /dev/null +++ b/crates/epiphany-ops/src/valuegen.rs @@ -0,0 +1,192 @@ +//! Small builders for the **value-typed** graph values that v1 operation +//! payloads now embed (Operation Catalog). +//! +//! Before the catalog, payloads carried only identifiers, so a test or fuzz +//! harness could mint an `InsertEvent` from a bare `EventId`. v1 payloads carry +//! the real [`Event`], [`PitchSpelling`], cross-cutting structure, and +//! [`TimeAnchor`], so harnesses need a deterministic way to build a faithful +//! value from a handful of ids. These builders are that single source — used by +//! the reduction fuzzer, the migration regression guard, the in-crate tests, and +//! (re-exported) by `epiphany-testkit`'s generators — so the value shapes stay +//! consistent everywhere. They are intentionally simple (a default C-octave-4 +//! pitch space, whole-note durations unless given) — the catalog defines the +//! schema, not these helpers. + +use std::collections::BTreeMap; + +use epiphany_core::{ + AcousticPitch, AcousticRealization, AleatoricAnchoringDiscipline, AleatoricTimeModel, + AnchorOffset, Beam, BeamId, CmnNominal, Event, EventId, EventOrderingDAG, EventPosition, + IdentifiedPitch, MetricTimeModel, MusicalDuration, MusicalPosition, Pitch, PitchId, + PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, ProportionalTimeModel, + RegionEdge, RegionId, RegionTimeModel, Rest, ScalePosition, Slur, SlurId, SpellingAttachment, + SpellingDirective, SpellingScope, SpellingSource, StemConfiguration, Tie, TieClass, TieId, + TimeAnchor, VoiceId, WallClockDuration, +}; + +/// A deterministic, fully-specified C4 pitch in the cmn-12 space — the neutral +/// pitch value an identified pitch wraps when a harness only has the id. +pub fn pitch_value() -> Pitch { + Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("cmn-12"), + position: PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave: 4, + }, + }, + acoustic: AcousticPitch { + tuning: epiphany_core::TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + } +} + +/// An [`IdentifiedPitch`] with the given id and the neutral [`pitch_value`]. +pub fn identified_pitch(id: PitchId) -> IdentifiedPitch { + IdentifiedPitch { + id, + pitch: pitch_value(), + } +} + +/// The event an InsertEvent inserts: a pitched event when `pitch_ids` is +/// non-empty, otherwise a visible rest. Mirrors the prototype's +/// pitched-or-rest split, now as a real value. +pub fn insert_event_value( + id: EventId, + voice: VoiceId, + position: MusicalPosition, + duration: MusicalDuration, + pitch_ids: &[PitchId], +) -> Event { + let position = EventPosition::Musical(position); + let duration = epiphany_core::EventDuration::Musical(duration); + if pitch_ids.is_empty() { + Event::Rest(Rest { + id, + voice, + position, + duration, + vertical_position: None, + visible: true, + }) + } else { + Event::Pitched(PitchedEvent { + id, + voice, + position, + duration, + pitches: pitch_ids.iter().copied().map(identified_pitch).collect(), + articulations: Vec::new(), + dynamic: None, + ornaments: Vec::new(), + stem: StemConfiguration, + grace: None, + }) + } +} + +/// A bare replacement [`Rest`] value (tuplet compensation) of the given duration. +pub fn rest_value(id: EventId, voice: VoiceId, duration: MusicalDuration) -> Rest { + Rest { + id, + voice, + position: EventPosition::Musical(MusicalPosition::origin()), + duration: epiphany_core::EventDuration::Musical(duration), + vertical_position: None, + visible: true, + } +} + +/// One of seven distinct CMN spellings (C4..B4), selected by `nth`. Distinct +/// `nth` give distinct [`PitchSpelling`] values, so concurrent respellings can be +/// made to agree or conflict deterministically. +pub fn spelling(nth: u8) -> PitchSpelling { + let nominal = match nth % 7 { + 0 => CmnNominal::C, + 1 => CmnNominal::D, + 2 => CmnNominal::E, + 3 => CmnNominal::F, + 4 => CmnNominal::G, + 5 => CmnNominal::A, + _ => CmnNominal::B, + }; + PitchSpelling::cmn(nominal, 4) +} + +/// A [`Slur`] over two event endpoints. +pub fn slur(id: SlurId, start: EventId, end: EventId) -> Slur { + Slur { + id, + start_event: start, + end_event: end, + } +} + +/// A [`Tie`] over two event endpoints (laissez-vibrer class, no pitch pairing). +pub fn tie(id: TieId, start: EventId, end: EventId) -> Tie { + Tie { + id, + start_event: start, + end_event: end, + pitch_pairing: None, + class: TieClass::LaissezVibrer, + } +} + +/// A level-1 [`Beam`] over a run of events. +pub fn beam(id: BeamId, events: Vec) -> Beam { + Beam { + id, + events, + level: 1, + } +} + +/// A region-start [`TimeAnchor`] at the given musical offset — the anchor a +/// system-break advisory uses; its resolved position is `offset`. +pub fn region_start_anchor(region: RegionId, offset: MusicalPosition) -> TimeAnchor { + TimeAnchor::Region { + id: region, + edge: RegionEdge::Start, + offset: AnchorOffset::Musical(MusicalDuration(offset.0)), + } +} + +/// The default metric region time model. +pub fn metric_model() -> RegionTimeModel { + RegionTimeModel::Metric(MetricTimeModel::default()) +} + +/// A minimal proportional region time model. +pub fn proportional_model() -> RegionTimeModel { + RegionTimeModel::Proportional(ProportionalTimeModel { + duration: WallClockDuration(1), + }) +} + +/// A minimal aleatoric region time model (freely-mixed, empty bounds). +pub fn aleatoric_model() -> RegionTimeModel { + RegionTimeModel::Aleatoric(AleatoricTimeModel { + ordering: EventOrderingDAG::default(), + anchoring: AleatoricAnchoringDiscipline::FreelyMixed, + bounds: BTreeMap::new(), + duration_hint: WallClockDuration(1), + }) +} + +/// An explicit, user-chosen per-pitch [`SpellingAttachment`] — the engraved-layer +/// spelling a materialized score carries after a `RespellPitch`. The v0 → v1 +/// migration recovers a respell's spelling from exactly these attachments +/// ([`crate::migrate_v0_envelope`]). +pub fn explicit_spelling_attachment(pitch: PitchId, spelling: PitchSpelling) -> SpellingAttachment { + SpellingAttachment { + scope: SpellingScope::Pitch(pitch), + directive: SpellingDirective::Explicit(spelling), + source: SpellingSource::UserChosen, + priority: 0, + layer: None, + } +} diff --git a/crates/epiphany-ops/tests/concurrent_reduction.rs b/crates/epiphany-ops/tests/concurrent_reduction.rs index e20f5cd..e48789a 100644 --- a/crates/epiphany-ops/tests/concurrent_reduction.rs +++ b/crates/epiphany-ops/tests/concurrent_reduction.rs @@ -19,9 +19,9 @@ use epiphany_core::{ EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId, StaffInstanceId, TransactionId, TypedObjectId, VoiceId, WallClockTime, }; -use epiphany_determinism::{fuzz::SplitMix64, ContentHash}; +use epiphany_determinism::fuzz::SplitMix64; use epiphany_ops::{ - canonical_reduction_order, well_formed, AuthorId, CausalContext, ConflictKind, + canonical_reduction_order, valuegen, well_formed, AuthorId, CausalContext, ConflictKind, HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, PendingReason, PreconditionFailureReason, RespellPitchOp, TransactionDescriptor, @@ -69,19 +69,21 @@ fn insert_span( duration: RationalTime, ) -> OperationPayload { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { - voice: VoiceId::new(ReplicaId(9), voice), staff_instance: StaffInstanceId::new(ReplicaId(9), 0), - event: EventId::new(ReplicaId(9), event), - position: MusicalPosition(position), - duration: MusicalDuration(duration), - pitches: vec![PitchId::new(ReplicaId(9), event)], + event: valuegen::insert_event_value( + EventId::new(ReplicaId(9), event), + VoiceId::new(ReplicaId(9), voice), + MusicalPosition(position), + MusicalDuration(duration), + &[PitchId::new(ReplicaId(9), event)], + ), })) } fn respell(pitch: u64, spelling: u8) -> OperationPayload { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(ReplicaId(9), pitch), - spelling: ContentHash([spelling; 32]), + spelling: valuegen::spelling(spelling), })) } @@ -370,7 +372,7 @@ fn failed_transaction_rolls_back_member_generated_conflicts() { assert_eq!( state.spellings.get(&PitchId::new(ReplicaId(9), 100)), - Some(&ContentHash([1; 32])) + Some(&valuegen::spelling(1)) ); assert_eq!(state.conflicts.records().len(), 1); assert!(matches!( diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index 9867519..42d5e13 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -8,10 +8,10 @@ use epiphany_core::{ WallClockTime, }; use epiphany_ops::{ - AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp, - CrossCuttingRef, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason, OperationEffect, - OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, - PositionRemapping, PreconditionFailureReason, RegionTimeModelTag, SetUserSystemBreakOp, + valuegen, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp, + CrossCuttingValue, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason, + OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet, + OperationStamp, PositionRemapping, PreconditionFailureReason, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy, UndoTransactionPayload, }; @@ -48,12 +48,14 @@ fn insert( position: i32, ) -> OperationPayload { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { - voice, staff_instance, - event, - position: MusicalPosition(RationalTime::from_int(position)), - duration: MusicalDuration::whole(), - pitches: vec![pitch], + event: valuegen::insert_event_value( + event, + voice, + MusicalPosition(RationalTime::from_int(position)), + MusicalDuration::whole(), + &[pitch], + ), })) } @@ -352,7 +354,7 @@ fn system_break_lww_state_is_materialized_in_the_region() { None, OperationPayload::Primitive(OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { region, - anchor: position.clone(), + anchor: valuegen::region_start_anchor(region, position.clone()), present: true, })), ); @@ -390,7 +392,7 @@ fn migration_computes_incompatible_events_from_the_graph() { OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( ChangeRegionTimeModelOp { region, - new_time_model: RegionTimeModelTag::Proportional, + new_time_model: valuegen::proportional_model(), declared_incompatible: Vec::new(), remapping: PositionRemapping::PreserveTime, }, @@ -422,14 +424,7 @@ fn create_cross_cutting_materializes_supported_graph_structures() { CausalContext::new(), None, OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp { - structure: CrossCuttingRef { - id: TypedObjectId::Slur(slur), - endpoints: endpoints - .iter() - .copied() - .map(TypedObjectId::Event) - .collect(), - }, + structure: CrossCuttingValue::Slur(valuegen::slur(slur, endpoints[0], endpoints[1])), })), ); let mut set = OperationSet::new(); @@ -459,7 +454,7 @@ fn causally_ordered_time_migrations_do_not_conflict() { OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( ChangeRegionTimeModelOp { region, - new_time_model: RegionTimeModelTag::Aleatoric, + new_time_model: valuegen::aleatoric_model(), declared_incompatible: Vec::new(), remapping: PositionRemapping::PreserveTime, }, @@ -474,7 +469,7 @@ fn causally_ordered_time_migrations_do_not_conflict() { OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( ChangeRegionTimeModelOp { region, - new_time_model: RegionTimeModelTag::Metric, + new_time_model: valuegen::metric_model(), declared_incompatible: Vec::new(), remapping: PositionRemapping::PreserveTime, }, diff --git a/crates/epiphany-testkit/src/convergence.rs b/crates/epiphany-testkit/src/convergence.rs index a744cd9..e4a3ae0 100644 --- a/crates/epiphany-testkit/src/convergence.rs +++ b/crates/epiphany-testkit/src/convergence.rs @@ -297,10 +297,9 @@ pub fn materialized_score(seed: u64) -> (Score, Vec) { #[cfg(test)] mod tests { use super::*; - use epiphany_core::{PitchId, ReplicaId, WallClockTime}; - use epiphany_determinism::ContentHash; + use epiphany_core::{PitchId, PitchSpelling, ReplicaId, WallClockTime}; use epiphany_ops::{ - AuthorId, CausalContext, HybridLogicalClock, OperationKind, OperationPayload, + valuegen, AuthorId, CausalContext, HybridLogicalClock, OperationKind, OperationPayload, OperationStamp, RespellPitchOp, }; use std::collections::BTreeMap; @@ -354,21 +353,21 @@ mod tests { transaction: None, payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(ReplicaId(0x0B7E_C700), 0), - spelling: ContentHash([spelling; 32]), + spelling: valuegen::spelling(spelling), })), } } /// A deliberately broken, order-*dependent* reducer: last spelling wins by /// arrival order. Stands in for the bug the harness must catch. - fn naive_arrival_order_spelling(envs: &[OperationEnvelope]) -> Option<[u8; 32]> { - let mut last: BTreeMap = BTreeMap::new(); + fn naive_arrival_order_spelling(envs: &[OperationEnvelope]) -> Option { + let mut last: BTreeMap = BTreeMap::new(); for e in envs { if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &e.payload { - last.insert(op.pitch, op.spelling.0); + last.insert(op.pitch, op.spelling.clone()); } } - last.values().next().copied() + last.values().next().cloned() } #[test] diff --git a/crates/epiphany-testkit/src/equivocation.rs b/crates/epiphany-testkit/src/equivocation.rs index 4ff343f..8910efd 100644 --- a/crates/epiphany-testkit/src/equivocation.rs +++ b/crates/epiphany-testkit/src/equivocation.rs @@ -20,9 +20,8 @@ use epiphany_core::{ EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId, StaffInstanceId, VoiceId, WallClockTime, }; -use epiphany_determinism::ContentHash; use epiphany_ops::{ - AuthorId, CausalContext, HybridLogicalClock, InsertEventOp, MaterializedState, + valuegen, AuthorId, CausalContext, HybridLogicalClock, InsertEventOp, MaterializedState, OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationSlot, OperationStamp, RespellPitchOp, }; @@ -150,7 +149,7 @@ fn respell_env(id: OperationId, phys: i64, pitch: u64, spelling: u8) -> Operatio transaction: None, payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(OBJ_REPLICA, pitch), - spelling: ContentHash([spelling; 32]), + spelling: valuegen::spelling(spelling), })), } } @@ -165,12 +164,14 @@ fn insert_pitch_env(id: OperationId, phys: i64, event: u64, pitch: u64) -> Opera causal_context: CausalContext::new(), transaction: None, payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { - voice: VoiceId::new(OBJ_REPLICA, 0), staff_instance: StaffInstanceId::new(OBJ_REPLICA, 0), - event: EventId::new(OBJ_REPLICA, event), - position: MusicalPosition(RationalTime::from_int(0)), - duration: MusicalDuration::whole(), - pitches: vec![PitchId::new(OBJ_REPLICA, pitch)], + event: valuegen::insert_event_value( + EventId::new(OBJ_REPLICA, event), + VoiceId::new(OBJ_REPLICA, 0), + MusicalPosition(RationalTime::from_int(0)), + MusicalDuration::whole(), + &[PitchId::new(OBJ_REPLICA, pitch)], + ), })), } } diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index 26098b3..a11eb8b 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -28,20 +28,21 @@ use epiphany_determinism::{ CanonicalEncode, CanonicalF64, ChunkId, ContentHash, DomainTag, QuantizedCoord, Tolerance, ToleranceClass, ToleranceGovernance, }; +use epiphany_ops::valuegen; use epiphany_ops::{ AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry, - ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingRef, DeleteEventOp, + ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, IntegrityAnomaly, IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, NoOpReason, ObjectKind, ObjectState, OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload, OperationSet, OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason, PreconditionFailureRegistryId, ReanchorReason, - ReanchorReasonRegistryId, ReanchorResult, RegionTimeModelTag, RepairKind, RepairKindRegistryId, - RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, - ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, - SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TupletCompensation, - TupletCompensationKind, UndoPolicy, UndoTransactionPayload, + ReanchorReasonRegistryId, ReanchorResult, RepairKind, RepairKindRegistryId, RepairRecord, + ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId, + ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, SetUserSystemBreakOp, + TransactionCategory, TransactionDescriptor, TupletCompensation, TupletCompensationKind, + UndoPolicy, UndoTransactionPayload, }; use crate::rng::Rng; @@ -634,47 +635,53 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP _ => {} } let kind = match rng.below(8) { - 0 => OperationKind::InsertEvent(InsertEventOp { - voice: VoiceId::new(OBJ_REPLICA, rng.below(4)), - staff_instance: StaffInstanceId::new(OBJ_REPLICA, rng.below(2)), - event: obj_event(rng.below(events)), - position: MusicalPosition(RationalTime::from_int(rng.below(events) as i32)), - duration: MusicalDuration::whole(), - pitches: if rng.boolean() { + 0 => { + let pitches = if rng.boolean() { vec![obj_pitch(rng.below(pitches))] } else { vec![] - }, - }), + }; + OperationKind::InsertEvent(InsertEventOp { + staff_instance: StaffInstanceId::new(OBJ_REPLICA, rng.below(2)), + event: valuegen::insert_event_value( + obj_event(rng.below(events)), + VoiceId::new(OBJ_REPLICA, rng.below(4)), + MusicalPosition(RationalTime::from_int(rng.below(events) as i32)), + MusicalDuration::whole(), + &pitches, + ), + }) + } 1 => OperationKind::DeleteEvent(DeleteEventOp { event: obj_event(rng.below(events)), tuplet_compensation: TupletCompensation::NotInTuplet, }), 2 => OperationKind::RespellPitch(RespellPitchOp { pitch: obj_pitch(rng.below(pitches)), - spelling: ContentHash([(rng.below(4) as u8) + 1; 32]), + spelling: valuegen::spelling(rng.below(4) as u8 + 1), }), 3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp { - structure: CrossCuttingRef { - id: TypedObjectId::Slur(SlurId::new(OBJ_REPLICA, rng.below(events))), - endpoints: vec![ - TypedObjectId::Event(obj_event(rng.below(events))), - TypedObjectId::Event(obj_event(rng.below(events))), - ], - }, + structure: CrossCuttingValue::Slur(valuegen::slur( + SlurId::new(OBJ_REPLICA, rng.below(events)), + obj_event(rng.below(events)), + obj_event(rng.below(events)), + )), }), 4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { region: RegionId::new(OBJ_REPLICA, 0), - anchor: MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), + anchor: valuegen::region_start_anchor( + RegionId::new(OBJ_REPLICA, 0), + MusicalPosition(RationalTime::from_int(rng.below(4) as i32)), + ), present: rng.boolean(), }), 5 => OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp { region: RegionId::new(OBJ_REPLICA, rng.below(2)), - new_time_model: *rng.choose(&[ - RegionTimeModelTag::Metric, - RegionTimeModelTag::Proportional, - RegionTimeModelTag::Aleatoric, - ]), + new_time_model: match rng.below(3) { + 0 => valuegen::metric_model(), + 1 => valuegen::proportional_model(), + _ => valuegen::aleatoric_model(), + }, declared_incompatible: Vec::new(), remapping: PositionRemapping::PreserveTime, }), @@ -853,12 +860,14 @@ pub const TWO_STAFF_EVENTS_PER_STAFF: u64 = TWO_STAFF_BARS * EVENTS_PER_BAR; /// whole-notes, i.e. two per 4/4 bar). fn insert_at(instance: u64, voice: u64, event: u64, pitch: u64, index: u64) -> OperationPayload { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { - voice: VoiceId::new(OBJ_REPLICA, voice), staff_instance: StaffInstanceId::new(OBJ_REPLICA, instance), - event: obj_event(event), - position: MusicalPosition(RationalTime::new(index as i64, EVENTS_PER_BAR as i64).unwrap()), - duration: MusicalDuration(RationalTime::new(1, EVENTS_PER_BAR as i64).unwrap()), - pitches: vec![obj_pitch(pitch)], + event: valuegen::insert_event_value( + obj_event(event), + VoiceId::new(OBJ_REPLICA, voice), + MusicalPosition(RationalTime::new(index as i64, EVENTS_PER_BAR as i64).unwrap()), + MusicalDuration(RationalTime::new(1, EVENTS_PER_BAR as i64).unwrap()), + &[obj_pitch(pitch)], + ), })) } @@ -892,7 +901,7 @@ pub fn two_staff_edit_session(rng: &mut Rng) -> Vec { } else { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: obj_pitch(rng.below(total)), - spelling: ContentHash([(rng.below(4) as u8) + 1; 32]), + spelling: valuegen::spelling(rng.below(4) as u8 + 1), })) }; session.author(rng, r, payload); @@ -953,19 +962,21 @@ fn insert_into( index: u64, ) -> OperationPayload { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { - voice, staff_instance, - event: obj_event(event), // position = GRAPH_SESSION_OFFSET + index/2 (two half-notes per 4/4 bar). - position: MusicalPosition( - RationalTime::new( - GRAPH_SESSION_OFFSET * EVENTS_PER_BAR as i64 + index as i64, - 2, - ) - .unwrap(), + event: valuegen::insert_event_value( + obj_event(event), + voice, + MusicalPosition( + RationalTime::new( + GRAPH_SESSION_OFFSET * EVENTS_PER_BAR as i64 + index as i64, + 2, + ) + .unwrap(), + ), + MusicalDuration(RationalTime::new(1, 2).unwrap()), + &[obj_pitch(pitch)], ), - duration: MusicalDuration(RationalTime::new(1, 2).unwrap()), - pitches: vec![obj_pitch(pitch)], })) } @@ -1022,7 +1033,7 @@ pub fn graph_edit_session( } else { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: obj_pitch(rng.below(total)), - spelling: ContentHash([(rng.below(4) as u8) + 1; 32]), + spelling: valuegen::spelling(rng.below(4) as u8 + 1), })) }; session.author(rng, r, payload); @@ -1047,14 +1058,14 @@ pub fn content_mutation_pair() -> (Vec, Vec OperationEnvelope { let mut twin = env.clone(); twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: obj_pitch(0), - spelling: ContentHash([0xEE; 32]), + spelling: valuegen::spelling(0xEE), })); if twin.envelope_hash() == env.envelope_hash() { twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: obj_pitch(1), - spelling: ContentHash([0x11; 32]), + spelling: valuegen::spelling(0x11), })); } twin @@ -1460,7 +1471,7 @@ mod tests { .insert(typed_object_id(&mut rng), object_state(&mut rng)); state .spellings - .insert(pitch_id(&mut rng), content_hash(&mut rng)); + .insert(pitch_id(&mut rng), valuegen::spelling(rng.below(7) as u8)); state.breaks.insert( (region_id(&mut rng), musical_position(&mut rng)), rng.boolean(), diff --git a/crates/epiphany-testkit/src/lib.rs b/crates/epiphany-testkit/src/lib.rs index f81fc79..9769b41 100644 --- a/crates/epiphany-testkit/src/lib.rs +++ b/crates/epiphany-testkit/src/lib.rs @@ -105,6 +105,7 @@ pub mod prepass_harness; pub mod convergence; pub mod equivocation; +pub mod migration; pub mod negative; pub mod bundle_harness; diff --git a/crates/epiphany-testkit/src/migration.rs b/crates/epiphany-testkit/src/migration.rs new file mode 100644 index 0000000..97d8078 --- /dev/null +++ b/crates/epiphany-testkit/src/migration.rs @@ -0,0 +1,123 @@ +//! Agent K's merge gate: the v0 → v1 operation-payload migration is +//! **deterministic** and **equivalence-preserving** (QUICKSTART Agent K +//! acceptance: "v0 envelopes migrated to v1 reduce to byte-identical canonical +//! state as v0 envelopes did under v0 payloads"). +//! +//! The v0 wire shape is reconstructed by [`project_v1_to_v0`] (the total inverse +//! direction), so the gate can drive the migration without a hand-built corpus +//! of historical v0 bytes: +//! +//! ```text +//! v1 corpus ──project──▶ v0 ──migrate(context)──▶ v1' +//! assert reduce(v1') == reduce(v1) (equivalence-preserving) +//! assert v1' == v1 (migration inverts projection) +//! assert migrate twice is byte-identical (deterministic) +//! ``` +//! +//! and a **non-vacuity** guard proves the equivalence assertion is not trivially +//! satisfiable: the canonical reduction state is sensitive to a respell's +//! spelling content, so a migration that recovered the *wrong* spelling would be +//! caught. + +use epiphany_core::{IdentityContext, ReplicaId, Score}; +use epiphany_ops::{ + migrate_v0_envelope, project_v1_to_v0, valuegen, OperationEnvelope, OperationKind, + OperationPayload, OperationSet, +}; + +use crate::generators::{content_mutation_pair, operation_envelopes}; +use crate::rng::Rng; + +/// A context [`Score`] carrying an explicit per-pitch spelling attachment for +/// every `RespellPitch` in the corpus. A respell's spelling is the one payload a +/// v0 projection cannot reconstruct from itself (it kept only a fingerprint); the +/// migration recovers it from exactly these attachments (P12-K1). +fn migration_context(corpus: &[OperationEnvelope]) -> Score { + let mut score = Score::empty(IdentityContext::new(ReplicaId(1))); + for env in corpus { + if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &env.payload { + score + .spelling_attachments + .push(valuegen::explicit_spelling_attachment( + op.pitch, + op.spelling.clone(), + )); + } + } + score +} + +fn reduce_bytes(envs: &[OperationEnvelope]) -> Vec { + let mut set = OperationSet::new(); + set.accept_all(envs.iter().cloned()); + set.reduce().canonical_bytes() +} + +fn migrate_all(v1: &[OperationEnvelope], ctx: &Score) -> Vec { + v1.iter() + .map(|env| { + migrate_v0_envelope(project_v1_to_v0(env), ctx) + .expect("the representative corpus migrates without irreversible payloads") + }) + .collect() +} + +/// The migration equivalence + determinism gate over an `n_ops` random corpus. +pub fn run_migration_equivalence(n_ops: usize, seed: u64) { + let mut rng = Rng::new(seed); + let v1 = operation_envelopes(&mut rng, n_ops, 3, 6, 6); + let ctx = migration_context(&v1); + + let migrated = migrate_all(&v1, &ctx); + + // Equivalence-preserving: identical canonical reduction state. + assert_eq!( + reduce_bytes(&v1), + reduce_bytes(&migrated), + "v0->v1 migration changed the canonical reduction state (seed {seed})" + ); + + // The migration faithfully inverts the projection on the representative + // payloads (a stronger property than reduction-equivalence alone). + assert_eq!( + v1, migrated, + "migration is not the inverse of projection (seed {seed})" + ); + + // Deterministic: migrating the same projection against the same context + // twice yields byte-identical envelopes. + let again = migrate_all(&v1, &ctx); + assert_eq!( + migrated, again, + "migration is not deterministic (seed {seed})" + ); +} + +/// Non-vacuity guard: prove the equivalence gate would *fail* if the migration +/// were wrong. A respell's spelling genuinely affects the canonical reduction +/// state, so a migration recovering a different spelling reduces to different +/// bytes — which the equivalence assertion above would catch. +pub fn assert_migration_gate_is_not_vacuous() { + // `base` inserts a pitch and respells it; `mutated` is identical except the + // respelling's spelling differs. + let (base, mutated) = content_mutation_pair(); + + // The migration round-trip on `base` is equivalence-preserving. + let ctx = migration_context(&base); + let migrated = migrate_all(&base, &ctx); + assert_eq!( + reduce_bytes(&base), + reduce_bytes(&migrated), + "migration of the controlled corpus is not equivalence-preserving" + ); + + // But the reduction is sensitive to the respelling's content: `base` and + // `mutated` reduce to *different* bytes. Had the migration recovered the + // wrong spelling, the equivalence assertion would have diverged just like + // this — so the gate is discriminating, not vacuous. + assert_ne!( + reduce_bytes(&base), + reduce_bytes(&mutated), + "non-vacuity control is mis-constructed: differing spellings must reduce differently" + ); +} diff --git a/crates/epiphany-testkit/src/negative.rs b/crates/epiphany-testkit/src/negative.rs index c5227a6..2bf9190 100644 --- a/crates/epiphany-testkit/src/negative.rs +++ b/crates/epiphany-testkit/src/negative.rs @@ -30,9 +30,8 @@ use epiphany_core::{ EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId, StaffInstanceId, TransactionId, VoiceId, WallClockTime, }; -use epiphany_determinism::ContentHash; use epiphany_ops::{ - canonical_reduction_order, AuthorId, CausalContext, ConflictKind, DeleteEventOp, + canonical_reduction_order, valuegen, AuthorId, CausalContext, ConflictKind, DeleteEventOp, HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, PendingReason, PreconditionFailureReason, RepairKind, RespellPitchOp, TransactionDescriptor, @@ -72,12 +71,14 @@ fn insert_span( duration: RationalTime, ) -> OperationPayload { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { - voice: VoiceId::new(OBJ, voice), staff_instance: StaffInstanceId::new(OBJ, 0), - event: EventId::new(OBJ, event), - position: MusicalPosition(position), - duration: MusicalDuration(duration), - pitches: vec![PitchId::new(OBJ, event)], + event: valuegen::insert_event_value( + EventId::new(OBJ, event), + VoiceId::new(OBJ, voice), + MusicalPosition(position), + MusicalDuration(duration), + &[PitchId::new(OBJ, event)], + ), })) } @@ -93,7 +94,7 @@ fn insert(voice: u64, event: u64, pos: i64) -> OperationPayload { fn respell(pitch: u64, spelling: u8) -> OperationPayload { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { pitch: PitchId::new(OBJ, pitch), - spelling: ContentHash([spelling; 32]), + spelling: valuegen::spelling(spelling), })) } @@ -255,7 +256,7 @@ pub fn assert_failed_transaction_rolls_back_member_conflicts() { assert_eq!( state.spellings.get(&PitchId::new(OBJ, 100)), - Some(&ContentHash([1; 32])), + Some(&valuegen::spelling(1)), "the pre-transaction spelling must survive the rollback" ); assert_eq!( diff --git a/crates/epiphany-testkit/tests/acceptance.rs b/crates/epiphany-testkit/tests/acceptance.rs index d1b4e48..8152d25 100644 --- a/crates/epiphany-testkit/tests/acceptance.rs +++ b/crates/epiphany-testkit/tests/acceptance.rs @@ -9,8 +9,8 @@ //! module (Agent E has landed). See the crate docs for the harness policy. use epiphany_testkit::{ - bundle_harness, convergence, equivocation, fixtures, generators, layout_stub, negative, - roundtrip, Rng, + bundle_harness, convergence, equivocation, fixtures, generators, layout_stub, migration, + negative, roundtrip, Rng, }; /// Criterion 1 — **Convergence (real Score).** Overlapping edits to a real @@ -228,3 +228,18 @@ fn manifest_selection_harness() { bundle_harness::run_manifest_selection(seed); } } + +/// **Agent K — Operation Catalog v0→v1 migration.** The shift from +/// identifier-only payloads to value-typed payloads ships a one-time migration; +/// this is its merge gate (QUICKSTART Agent K acceptance: deterministic and +/// equivalence-preserving migration). For each random corpus, the v1 envelopes +/// project to their v0 wire shape and migrate back, reducing to byte-identical +/// canonical state — and the gate's non-vacuity guard proves a wrong migration +/// would be caught. See [`migration`]. +#[test] +fn agent_k_migration_equivalence_gate() { + migration::assert_migration_gate_is_not_vacuous(); + for seed in 0..32u64 { + migration::run_migration_equivalence(48, seed.wrapping_mul(0x9E37_79B9).wrapping_add(5)); + } +} diff --git a/spec/PASS12_BATCH.md b/spec/PASS12_BATCH.md index 8ac80b1..b82b072 100644 --- a/spec/PASS12_BATCH.md +++ b/spec/PASS12_BATCH.md @@ -29,9 +29,11 @@ code instead is the failure mode this batch exists to prevent. | P12-I1 | `epiphany-layout-ir` / `engrave` / `render-svg` I | The v0 `to_logical`/`to_constrained` pipeline is a **structural placeholder**: each layout object becomes one *arbitrary* glyph (`BRAVURA_METRICS[discriminant % N]`) at `y = 0`, not real notation. Chapter 7 says the *logical* stage has "engraving decisions made"; the spec should clarify which engraving decisions (glyph-by-duration selection, pitch→staff-position, clef/key/meter/barline realization, stems/beams) are core-IR construction versus solver work, so the real-notation engraving has a defined home before it is built next phase. Consequence: the QUICKSTART human visual-acceptance gate ("the SVG visually parses as standard notation") is a **next-phase** gate, *not* met by stub output — this phase's gate is renderer correctness/faithfulness. | G / Pass 12 (Ch 7 engraving boundary) | | P12-I2 | `epiphany-render-svg` / `engrave` I | Stable layout-object id derivation (`MUSCLOID`, Pass-11 item 2.6, deferred to I) is still unwired: the frozen `epiphany-determinism` exposes no `MUSCLOID` tag, so provenance is traced via the provisional `stable_id`. Wiring the ratified derivation is Track A work (already noted in `layout-ir/DECISIONS.md`). | G (determinism tag) / Track A | | P12-I3 | `epiphany-layout-ir` I | The bundled `BRAVURA_METRICS` are *approximations* that disagree with the genuine Bravura outlines the renderer now extracts from the font (e.g. `timeSig4`: metrics bbox `[40,0,1240,2048]` vs real outline ≈ `[0.08,-1.0,1.8,1.004]` staff spaces). Real spacing needs exact metrics; regenerate the metrics table from the font or reconcile it with the outline source. | G / Pass 12 (glyph metrics) | +| P12-K1 | `epiphany-ops` K | A v0 `RespellPitch` carried a `ContentHash` *fingerprint* of the spelling, not the `PitchSpelling`. The v0→v1 migration (Operation Catalog, M1) cannot invert a fingerprint, so it recovers the spelling from the score-graph context (an explicit per-pitch spelling attachment whose canonical bytes hash to the fingerprint) and returns `MigrationError::Irreversible` (bundle opens read-only) when the context lacks it. Every other representative payload migrates self-contained; this is the lone exception. Confirm the read-only fallback is the intended disposition vs. requiring a v0 corpus that preserves spelling pre-images. | G / Pass 12 (migration) | ## Not yet open elsewhere -Agent I (Track A) has contributed P12-I1..I3 above. Track B (K, J) has not yet -contributed items. When they do, append rows; the batch is already open, so they -join directly (no new threshold). +Agent I (Track A) has contributed P12-I1..I3 above. Track B's Agent K has +contributed P12-K1 (Operation Catalog M1). Agent J (Binary Format companion) has +not yet contributed; when it does, append rows — the batch is already open, so it +joins directly (no new threshold). diff --git a/spec/operation_catalog.pdf b/spec/operation_catalog.pdf new file mode 100644 index 0000000..60e2006 Binary files /dev/null and b/spec/operation_catalog.pdf differ diff --git a/spec/operation_catalog.tex b/spec/operation_catalog.tex new file mode 100644 index 0000000..ca20b5a --- /dev/null +++ b/spec/operation_catalog.tex @@ -0,0 +1,686 @@ +% !TEX program = xelatex +% +% Epiphany --- Operation Catalog (companion specification) +% Companion to the Core Specification. Compile with XeLaTeX. +% +% This document is versioned independently of the Core Specification +% (independent semver; see the Versioning note in the front matter). Its preamble +% is intentionally a self-contained copy of the core specification's preamble so +% the two documents build independently; factoring a shared preamble file is a +% later cleanup, not a v0.1 deliverable. + +\documentclass[11pt,letterpaper]{report} + +% --------------------------------------------------------------------------- +% Packages +% --------------------------------------------------------------------------- +\usepackage{fontspec} +\usepackage{geometry} +\geometry{ + letterpaper, + top=1.05in, + bottom=1.05in, + left=1.15in, + right=1.15in, + headheight=15pt +} + +\usepackage[english]{babel} +\usepackage{microtype} +\usepackage{parskip} +\usepackage{xcolor} +\usepackage{hyperref} +\usepackage{enumitem} +\usepackage{titlesec} +\usepackage{fancyhdr} +\usepackage{booktabs} +\usepackage{array} +\usepackage{longtable} +\usepackage{listings} +\usepackage{amsmath} +\usepackage{amssymb} +\usepackage{tcolorbox} +\tcbuselibrary{breakable, skins} + +% --------------------------------------------------------------------------- +% Color palette (shared with the core specification) +% --------------------------------------------------------------------------- +\definecolor{epiphanyteal}{HTML}{1A4044} +\definecolor{epiphanygold}{HTML}{8E6E2E} +\definecolor{epiphanyink}{HTML}{1F1B16} +\definecolor{epiphanyslate}{HTML}{6B6660} +\definecolor{epiphanycream}{HTML}{F8F4ED} +\definecolor{epiphanymist}{HTML}{ECE8E0} +\definecolor{epiphanycode}{HTML}{2A2520} +\definecolor{epiphanycrimson}{HTML}{7A2424} + +\hypersetup{ + colorlinks=true, + linkcolor=epiphanyteal, + citecolor=epiphanyteal, + urlcolor=epiphanygold, + pdftitle={Epiphany --- Operation Catalog}, + pdfauthor={The Epiphany Project}, + pdfsubject={Operation Catalog companion for the Epiphany music notation platform}, + pdfkeywords={music notation, operations, CRDT, reduction, serialization}, + bookmarksnumbered=true, + bookmarksopen=true +} + +% --------------------------------------------------------------------------- +% Typography (shared with the core specification) +% --------------------------------------------------------------------------- +\setmainfont{TeX Gyre Pagella}[Numbers={OldStyle, Proportional}, Ligatures={TeX, Common}] +\setsansfont{TeX Gyre Heros}[Scale=0.94, Ligatures={TeX, Common}] +\setmonofont{TeX Gyre Cursor}[Scale=0.88, Ligatures={TeX}] +\newfontfamily\titlefont{TeX Gyre Pagella}[Numbers={OldStyle}, Ligatures={TeX, Common}] +\newcommand{\tablenums}[1]{{\addfontfeatures{Numbers={Lining,Tabular}}#1}} +\newcommand{\sectionsc}[1]{{\addfontfeatures{Letters=SmallCaps}#1}} + +% --------------------------------------------------------------------------- +% Section styling (shared with the core specification) +% --------------------------------------------------------------------------- +\titleformat{\chapter}[display] + {\normalfont\filright} + {\raggedright\color{epiphanygold}\fontsize{14pt}{16pt}\selectfont + \scshape Chapter\ \thechapter} + {16pt} + {\raggedright\color{epiphanyteal}\fontsize{32pt}{36pt}\selectfont\bfseries} + [\vspace{4pt}{\color{epiphanygold}\rule{2in}{0.6pt}}] +\titlespacing*{\chapter}{0pt}{-20pt}{30pt} +\titleformat{\section} + {\normalfont\Large\bfseries\color{epiphanyteal}} + {\color{epiphanygold}\thesection}{1em}{} +\titleformat{\subsection} + {\normalfont\large\bfseries\color{epiphanyteal}} + {\color{epiphanygold}\thesubsection}{1em}{} +\titleformat{\subsubsection} + {\normalfont\normalsize\bfseries\color{epiphanyink}} + {\thesubsubsection}{1em}{} + +% --------------------------------------------------------------------------- +% Headers and footers (shared with the core specification) +% --------------------------------------------------------------------------- +\pagestyle{fancy} +\fancyhf{} +\renewcommand{\headrulewidth}{0pt} +\renewcommand{\footrulewidth}{0pt} +\fancyhead[L]{\small\scshape\color{epiphanyslate}Epiphany --- Operation Catalog} +\fancyhead[R]{\small\itshape\color{epiphanyslate}\leftmark} +\fancyfoot[C]{\small\color{epiphanyslate}\thepage} +\renewcommand{\headrule}{ + \color{epiphanygold!50}\hrule width\headwidth height 0.4pt + \vspace{1pt} + \color{epiphanygold!30}\hrule width\headwidth height 0.2pt +} + +% --------------------------------------------------------------------------- +% Code listing style (shared with the core specification) +% --------------------------------------------------------------------------- +\lstdefinelanguage{Rust}{ + keywords={fn,let,mut,pub,struct,enum,impl,trait,for,in,if,else,match,return, + use,mod,crate,self,Self,as,where,move,async,await,const,static, + ref,type,unsafe,extern,dyn,box,break,continue,loop,while}, + keywordstyle=\color{epiphanyteal}\bfseries, + ndkeywords={i8,i16,i32,i64,i128,u8,u16,u32,u64,u128,f32,f64,bool,char,str, + String,Vec,Option,Result,Box,Rc,Arc,HashMap,BTreeMap, + NonZeroU16,NonZeroU32,NonZeroU64,Duration,Timestamp}, + ndkeywordstyle=\color{epiphanygold}\bfseries, + sensitive=true, + comment=[l]{//}, + morecomment=[s]{/*}{*/}, + commentstyle=\color{epiphanyslate}\itshape, + stringstyle=\color{epiphanycrimson}, + morestring=[b]", + morestring=[b]' +} +\lstset{ + basicstyle=\ttfamily\small\color{epiphanycode}, + backgroundcolor=\color{epiphanycream}, + frame=leftline, + rulecolor=\color{epiphanygold!60}, + framesep=8pt, + framerule=1.5pt, + xleftmargin=10pt, + xrightmargin=4pt, + breaklines=true, + showstringspaces=false, + numberstyle=\tiny\color{epiphanyslate}, + numbersep=10pt, + captionpos=b, + aboveskip=10pt, + belowskip=10pt, + language=Rust +} + +% --------------------------------------------------------------------------- +% Custom environments (shared with the core specification) +% --------------------------------------------------------------------------- +\newtcolorbox{openquestion}[1][]{ + enhanced, breakable, + colback=epiphanymist, colframe=epiphanycrimson, + fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Open Question}, + coltitle=white, colbacktitle=epiphanycrimson, + arc=1pt, boxrule=0pt, leftrule=2pt, + left=10pt, right=10pt, top=8pt, bottom=8pt, + attach boxed title to top left={xshift=0pt, yshift=0pt}, + boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt}, + #1 +} +\newtcolorbox{rationale}[1][]{ + enhanced, breakable, + colback=epiphanymist, colframe=epiphanyteal, + fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Rationale}, + coltitle=white, colbacktitle=epiphanyteal, + arc=1pt, boxrule=0pt, leftrule=2pt, + left=10pt, right=10pt, top=8pt, bottom=8pt, + attach boxed title to top left={xshift=0pt, yshift=0pt}, + boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt}, + #1 +} +\newtcolorbox{requirement}[1][]{ + enhanced, breakable, + colback=white, colframe=epiphanygold, + fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Requirement}, + coltitle=white, colbacktitle=epiphanygold, + arc=1pt, boxrule=0pt, leftrule=2pt, + left=10pt, right=10pt, top=8pt, bottom=8pt, + attach boxed title to top left={xshift=0pt, yshift=0pt}, + boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt}, + #1 +} +\newtcolorbox{nongoal}[1][]{ + enhanced, breakable, + colback=epiphanymist, colframe=epiphanyslate, + fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Non-Goal}, + coltitle=white, colbacktitle=epiphanyslate, + arc=1pt, boxrule=0pt, leftrule=2pt, + left=10pt, right=10pt, top=8pt, bottom=8pt, + attach boxed title to top left={xshift=0pt, yshift=0pt}, + boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt}, + #1 +} + +\newcommand{\MUST}{\textbf{MUST}} +\newcommand{\MUSTNOT}{\textbf{MUST}\nobreak\ \textbf{NOT}} +\newcommand{\SHOULD}{\textbf{SHOULD}} +\newcommand{\SHOULDNOT}{\textbf{SHOULD}\nobreak\ \textbf{NOT}} +\newcommand{\MAY}{\textbf{MAY}} + +\setlist[itemize]{topsep=2pt, itemsep=3pt, parsep=0pt} +\setlist[enumerate]{topsep=2pt, itemsep=3pt, parsep=0pt} +\setlist[description]{topsep=2pt, itemsep=5pt, parsep=0pt} +\AtBeginDocument{\color{epiphanyink}} + +% --------------------------------------------------------------------------- +% Document +% --------------------------------------------------------------------------- +\begin{document} + +\begin{titlepage} + \thispagestyle{empty} + \centering + \vspace*{2.2in} + {\color{epiphanygold}\rule{3in}{0.8pt}}\\[18pt] + {\titlefont\fontsize{34pt}{38pt}\selectfont\color{epiphanyteal}\bfseries Epiphany}\\[10pt] + {\Large\scshape\color{epiphanyslate}Operation Catalog}\\[6pt] + {\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt] + {\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt] + {\normalsize\color{epiphanyink}Version 0.1.0 --- Phase 2 (K0 representative primitives)}\\[4pt] + {\small\color{epiphanyslate}Normative for the operation kinds it defines} + \vfill +\end{titlepage} + +\tableofcontents + +% =========================================================================== +\chapter{About This Companion} +\label{ch:about} + +The \emph{Operation Catalog} is a companion to the Epiphany Core Specification. +It fulfils the open question the core specification raises in its +\emph{Operation Catalog Conformance} section (Chapter~6, +\sectionsc{Semantic Operations and Concurrent Reduction}, the +\texttt{sec:semops:catalog} open question), which states that the catalog +``is normative once published; this specification is non-final until the catalog +is delivered.'' This v0.1 release delivers the catalog \emph{framework} and the +\textbf{K0 representative primitive set} --- the operation kinds the Phase~2 +visible slice and binary format actually exercise. The remaining K0 and the full +$60$--$80$-primitive catalog are drafted as framework slots +(Chapter~\ref{ch:k1}) and completed in Phase~3. + +\section{Relationship to the Core Specification} + +This companion does not restate the operation framework; it \emph{references} it. +The framework --- operation identity and stamps, the hybrid-logical-clock +monotonicity rule, the dotted-version-vector causal context, the +order-independent operation-slot model and equivocation, the canonical reduction +order, the four-phase lifecycle, conflict records and the conflict registry, +re-anchoring, transactions, forward undo, and the LWW discipline --- is the core +specification's Chapter~6. This companion defines, for each operation kind, the +\emph{payload schema} and how that kind \emph{instantiates} the framework's +reduction, conflict, undo, and re-anchoring rules. + +It also consumes, rather than re-deriving, the \textbf{ratified byte-convention +baseline} (core specification Chapter~8, \sectionsc{Binary Format Companion}, +requirement \texttt{req:format:codec-conventions}, and the +\sectionsc{Canonical Byte-Layout Reference} appendix): little-endian integers, a +single discriminant byte per tagged union, \texttt{u32} length prefixes on every +variable-width leaf, and raw UTF-8 free text. Operation-payload encodings are +expressed in terms of that baseline; the literal wire layout of each payload is +the Binary Format companion's (Agent~J's) to pin, in coordination with this +catalog. + +\begin{rationale} +The catalog is versioned \emph{independently} of the core specification +(independent semver). Operation kinds are added over time; the catalog should +evolve --- adding primitives, refining conflict cases --- without forcing a core +specification revision. The core specification changes only when the +\emph{framework} changes. +\end{rationale} + +\section{Conformance Profiles} + +A \textbf{Phase-2 profile} implementation \MUST{} implement every primitive in +Chapter~\ref{ch:k0} with the schema, reduction rule, conflict cases, undo +semantics, and re-anchoring behaviour defined there. The K1 primitives of +Chapter~\ref{ch:k1} are \emph{unavailable} under the Phase-2 profile: an +implementation \MUST{} reject (not silently ignore) an operation whose kind is a +K1 primitive it does not implement. + +% =========================================================================== +\chapter{The Catalog Framework} +\label{ch:framework} + +\section{Value-Typed Payloads} +\label{sec:framework:value-typed} + +Every operation payload in this catalog is \textbf{value-typed}: it carries the +real graph values its effect introduces, not identifiers that point at values in +some ambient graph. An \texttt{InsertEvent} carries the whole \texttt{Event}; a +\texttt{RespellPitch} carries the whole \texttt{PitchSpelling}; a +\texttt{CreateCrossCutting} carries the whole tie, slur, beam, or spanner. + +\begin{rationale} +The v0 prototype carried \emph{identifier-only projections} --- an +\texttt{InsertEvent} held an \texttt{EventId} plus reduction-relevant scalars; a +\texttt{RespellPitch} held a content-hash \emph{fingerprint} of the new spelling. +That was sufficient to make an envelope hashable and to drive reduction, but it +is \textbf{not durable}: an operation replayed in a fresh context --- a backup +restore, a cross-tool round-trip --- needs the full value, which an +identifier-only payload cannot supply. Value-typed payloads are what make an +Epiphany document portable. +\end{rationale} + +\begin{requirement} +\label{req:catalog:value-encoding} +A value-typed payload field \MUST{} be encoded by emitting the field's value +under the core specification's canonical value encoding +(\texttt{req:format:codec-conventions}), framed by a \texttt{u32} little-endian +length prefix. Decoding \MUST{} be the exact inverse and \MUST{} reject trailing +bytes within the framed region. The encoding introduces no byte layout beyond the +ratified baseline: a value's bytes here are byte-for-byte the bytes the whole +document codec emits for that value. +\end{requirement} + +\section{Per-Primitive Schema Template} +\label{sec:framework:template} + +Each catalog primitive is specified under a fixed six-part template. A new +primitive is a schema-fill against this template, not a fresh design: + +\begin{description} + \item[Payload schema] The value-typed fields the operation carries, with their + core-specification types. + \item[Canonical encoding] The field order and framing, consuming + Requirement~\ref{req:catalog:value-encoding}. (The literal byte layout is the + Binary Format companion's; this catalog fixes the \emph{field set and order}.) + \item[Reduction rule] How the operation mutates canonical state when it is + reached in canonical reduction order --- the preconditions it checks, the + objects it mints or tombstones, and the bookkeeping it records. + \item[Conflict cases] The conflict records the operation can produce, by + \texttt{ConflictKind}, and which participant materialises. + \item[Undo semantics] The compensating effect of undoing a transaction that + contains the operation, under each \texttt{UndoPolicy} + (\texttt{StrictInverse} / \texttt{BestEffort} / \texttt{Cascade}). + \item[Re-anchoring] The behaviour when an object the operation references is + tombstoned before or concurrently with it. +\end{description} + +\section{Reduction-Discipline Coverage} + +The K0 representative set is chosen so that, between them, the primitives +exercise \emph{every} reduction discipline the framework defines: +position-keyed insert with system-voice promotion; delete-wins with tombstones, +tuplet compensation, and cross-cutting re-anchoring; field-overwrite with +last-writer-wins and structural-field-collision conflicts; set-union creation; +structural time-model migration; an LWW advisory; atomic transactions with +descriptor precedence; and the two meta-operations (conflict resolution and +forward undo). A primitive added later that reuses one of these disciplines +inherits its reduction, conflict, undo, and re-anchoring treatment. + +% =========================================================================== +\chapter{K0 --- Representative Primitives} +\label{ch:k0} + +This chapter is normative under the Phase-2 profile. Each primitive's reduction, +conflict, undo, and re-anchoring behaviour is the behaviour the core +specification's Chapter~6 defines for its discipline; the description here states +how the primitive instantiates it. The reference implementation is +\texttt{epiphany-ops} (the \texttt{payload}, \texttt{reduce}, and \texttt{migrate} +modules). + +\section{InsertEvent} +\label{sec:k0:insert-event} + +\textbf{Payload schema.} \texttt{InsertEventOp \{ staff\_instance: +StaffInstanceId, event: Event \}}. The voice, region-local position, duration, +and pitch identities are read from the \texttt{Event} value; the +\texttt{staff\_instance} is retained alongside it so the system-voice promotion +derivation is total without a containment walk. + +\textbf{Canonical encoding.} \texttt{staff\_instance}, then the length-framed +canonical bytes of \texttt{event}. + +\textbf{Reduction rule.} A position-keyed insert. Preconditions: the event's +duration is positive; the event id is neither live nor tombstoned; in graph-aware +reduction the target voice exists in a metric region and the event's pitch ids +are fresh. The event and its pitches are minted live; the voice is created on +first use. Concurrent inserts whose half-open duration intervals overlap in the +same voice are resolved by an order-independent promotion pre-pass: the +lower-\texttt{OperationId} insert is retained in the original voice, and each +overlapping loser is promoted to the deterministic system voice +\texttt{derive\_promoted\_voice\_id(staff\_instance, voice, winner, loser)} and +tagged \texttt{VoicePromoted}. + +\textbf{Conflict cases.} None at reduction time; promotion is a deterministic +repair, not a conflict. + +\textbf{Undo semantics.} Undoing the enclosing transaction tombstones the minted +event, its pitches, and any promoted voice. \texttt{StrictInverse} conflicts if +any was already tombstoned or modified; \texttt{BestEffort} tombstones the +survivors; \texttt{Cascade} is \texttt{StrictInverse} over the same minted set +(dependent-closure undo is a Phase-3 refinement). + +\textbf{Re-anchoring.} Not applicable (the operation mints, it does not +reference a pre-existing object that could be tombstoned). + +\section{DeleteEvent} +\label{sec:k0:delete-event} + +\textbf{Payload schema.} \texttt{DeleteEventOp \{ event: EventId, +tuplet\_compensation: TupletCompensation \}}, where \texttt{TupletCompensation} +is \texttt{NotInTuplet}, \texttt{ReplaceWithRest \{ rest: Rest \}} (value-typed +replacement rest), \texttt{RewriteTuplets \{ tuplets \}}, or +\texttt{CascadeDeleteTuplets \{ tuplets \}}. + +\textbf{Canonical encoding.} \texttt{event}, then the tuplet-compensation +discriminant and its payload (a length-framed \texttt{Rest} value for +\texttt{ReplaceWithRest}). + +\textbf{Reduction rule.} Delete-wins: the event and its contained pitches are +tombstoned (retaining their identifiers). Tuplet compensation, when present, adds +the replacement rest live or tombstones the cascaded tuplet group. Concurrent +deletes of the same event are idempotent. + +\textbf{Conflict cases.} None for the delete itself. Graph-aware reduction +refuses an ill-formed tuplet compensation as a precondition failure (no-op). + +\textbf{Undo semantics.} An insert-shaped compensation re-introduces the +tombstoned content; for the prototype's minted-object model this is the inverse +of the tombstone set, with the policy treatment described under InsertEvent. + +\textbf{Re-anchoring.} Tombstoning the event runs the framework's re-anchoring +rule table over every cross-cutting structure that referenced it: a tie +cascade-deletes; a comment or analytical annotation orphans (user content is +never silently deleted); a beam truncates while $\geq 2$ members survive and +otherwise cascade-deletes; a slur or spanner re-anchors to the nearest surviving +endpoint while $\geq 1$ survives and otherwise cascade-deletes. + +\section{RespellPitch} +\label{sec:k0:respell-pitch} + +\textbf{Payload schema.} \texttt{RespellPitchOp \{ pitch: PitchId, spelling: +PitchSpelling \}} --- the full spelling value (v1), not a fingerprint. + +\textbf{Canonical encoding.} \texttt{pitch}, then the length-framed canonical +bytes of \texttt{spelling}. + +\textbf{Reduction rule.} A last-writer-wins field overwrite, keyed by pitch. +Precondition: the pitch is live. The resolved spelling is the one carried by the +operation latest in canonical order. Two respellings of one pitch that are +causally ordered overwrite intentionally; two \emph{concurrent} respellings with +\emph{equal} spelling reduce idempotently. + +\textbf{Conflict cases.} Two concurrent respellings of one pitch with +\emph{differing} spellings produce a \texttt{StructuralFieldCollision} conflict +recording the winner (later in canonical order), the loser, and the field +\texttt{spelling}. The winner materialises and carries the \texttt{Conflicted} +effect tag. + +\textbf{Undo semantics.} Undo restores the pre-operation spelling (or removes the +spelling if the operation introduced the first one), under the active policy. + +\textbf{Re-anchoring.} If the target pitch is tombstoned, the respelling is a +no-op (\texttt{TargetTombstoned}). + +\begin{openquestion} +\textbf{P12-K1.} A v0 \texttt{RespellPitch} carried only a content-hash \emph{fingerprint} of the +spelling. The fingerprint cannot be inverted to a \texttt{PitchSpelling} without +a side table, so the v0$\rightarrow$v1 migration (Chapter~\ref{ch:migration}) +recovers the spelling from the score graph context --- an explicit per-pitch +spelling attachment whose canonical bytes hash to the fingerprint --- and, when +the context lacks it, declares the envelope unmigratable (the bundle opens +read-only). This is the one representative payload that is not self-contained +under migration; the disposition (whether a richer v0 corpus, or a documented +read-only fallback, is the long-term answer) is a Pass-12 question. +\end{openquestion} + +\section{CreateCrossCutting} +\label{sec:k0:create-cross-cutting} + +\textbf{Payload schema.} \texttt{CreateCrossCuttingOp \{ structure: +CrossCuttingValue \}}, where \texttt{CrossCuttingValue} is the typed value of a +\texttt{Tie}, \texttt{Slur}, \texttt{Beam}, or \texttt{Spanner}. Its identity and +referenced endpoints are read from the value. + +\textbf{Canonical encoding.} A discriminant for the structure kind, then the +length-framed canonical bytes of the structure value. + +\textbf{Reduction rule.} Set-union creation: the structure is minted live if its +id is not already live and every referenced endpoint is live; a second create of +a live id is idempotent. Graph-aware reduction materialises the structure with +its full fields. + +\textbf{Conflict cases.} None (all-or-nothing creation; union is deterministic). + +\textbf{Undo semantics.} Undo tombstones the minted structure, under the active +policy. + +\textbf{Re-anchoring.} The structure participates in the re-anchoring rule table +when one of its endpoints is later tombstoned (see DeleteEvent). + +\section{ChangeRegionTimeModel} +\label{sec:k0:change-region-time-model} + +\textbf{Payload schema.} \texttt{ChangeRegionTimeModelOp \{ region: RegionId, +new\_time\_model: RegionTimeModel, declared\_incompatible: Vec, +remapping: PositionRemapping \}} --- the full target model value (v1). + +\textbf{Canonical encoding.} \texttt{region}, the length-framed +\texttt{new\_time\_model} value, the canonically-ordered +\texttt{declared\_incompatible} set, then \texttt{remapping}. + +\textbf{Reduction rule.} Structural migration. The region adopts the target time +model. Graph-aware reduction derives coordinate-kind incompatibilities from the +region's events (and from the remapping coverage) and refuses a migration that +would violate the coordinate discipline. + +\textbf{Conflict cases.} Concurrent same-region migrations produce a +\texttt{StructuralFieldCollision} on the field \texttt{time\_model}; a migration +with incompatible events produces a \texttt{TimeModelMigrationFailure} naming the +region and the incompatible events. A causally-later migration is re-evaluated +against the first migration's graph rather than conflicting. + +\textbf{Undo semantics.} Undo restores the region's prior time model under the +active policy. + +\textbf{Re-anchoring.} Not applicable. + +\begin{openquestion} +\textbf{P11-C6.} The rich migration payload --- a coordinate converter rather than a +\texttt{declared\_incompatible} list plus a \texttt{PositionRemapping} --- remains +the catalog's to design when graph-aware migration is the only reduction path. +\end{openquestion} + +\section{SetUserSystemBreak} +\label{sec:k0:set-user-system-break} + +\textbf{Payload schema.} \texttt{SetUserSystemBreakOp \{ region: RegionId, +anchor: TimeAnchor, present: bool \}} --- the full anchor value (v1). + +\textbf{Canonical encoding.} \texttt{region}, the length-framed \texttt{anchor} +value, then the boolean. + +\textbf{Reduction rule.} A last-writer-wins advisory. The break preference is +recorded for the region keyed by the anchor's \emph{resolved musical position}; +graph-aware reduction adds or removes the anchor from the region's user +system-break list. + +\textbf{Conflict cases.} None (LWW advisory). + +\textbf{Undo semantics.} Undo restores the prior advisory value for the +\texttt{(region, resolved-position)} key. + +\textbf{Re-anchoring.} Not applicable in the prototype (the advisory is keyed by +resolved position; a tombstoned anchor target degrades to the region origin). + +\section{DeclareTransaction} +\label{sec:k0:declare-transaction} + +\textbf{Payload schema.} \texttt{TransactionDescriptor \{ id: TransactionId, +label: String, category: Option \}}. Value-complete in v0 +and unchanged. + +\textbf{Reduction rule.} Records the descriptor. Member primitives reference the +transaction id and \MUST{} causally depend on the descriptor; the members reduce +atomically (all-or-nothing) in canonical order. + +\textbf{Conflict cases.} A missing descriptor or a member that does not causally +follow it produces a \texttt{TransactionConflict}; any member failure rolls back +the whole transaction and all members read \texttt{NoOp\{TransactionConflict\}}. + +\textbf{Undo / re-anchoring.} Transactions are the unit of undo +(Section~\ref{sec:k0:undo}); re-anchoring is per member. + +\section{ResolveConflict (meta-operation)} +\label{sec:k0:resolve-conflict} + +\textbf{Payload schema.} \texttt{ResolveConflictPayload \{ target: ConflictId, +action: ResolutionAction \}}. Value-complete. + +\textbf{Reduction rule.} Transitions the target conflict's resolution state. An +action of \texttt{Dismiss} reaches the \texttt{Dismissed} state; any other action +reaches \texttt{Resolved}. Re-resolving with the same action is idempotent; two +concurrent resolves with differing actions produce a meta-conflict. + +\begin{rationale} +Pass~11 added \texttt{ResolutionAction::Dismiss} (item 2.5) precisely so the +\texttt{Dismissed} state is reachable by an authored operation rather than merely +representable. The catalog records that \texttt{Dismiss} is the action that +selects it (resolving the v0 ambiguity P11-C10). +\end{rationale} + +\section{UndoTransaction (meta-operation)} +\label{sec:k0:undo} + +\textbf{Payload schema.} \texttt{UndoTransactionPayload \{ target: TransactionId, +policy: UndoPolicy \}}, with \texttt{UndoPolicy} one of \texttt{StrictInverse}, +\texttt{BestEffort}, \texttt{Cascade}. Value-complete. + +\textbf{Reduction rule.} A forward compensating edit computed against the +materialised state at the undo's canonical position (never literal time travel). +The prototype models the compensation as tombstoning the objects the target +transaction minted: \texttt{StrictInverse} conflicts (\texttt{TombstonedTarget}) +if any minted object was already tombstoned; \texttt{BestEffort} tombstones the +survivors; \texttt{Cascade} is \texttt{StrictInverse} over the same set +(dependent-closure undo is a Phase-3 refinement, P11-C8). + +% =========================================================================== +\chapter{v0 \texorpdfstring{$\rightarrow$}{->} v1 Payload Migration} +\label{ch:migration} + +A v0 envelope carries an identifier-only payload; a v1 envelope carries the +value-typed payload this catalog defines. The two forms do not coexist as +permanent dialects (that would double the reducer surface forever); instead the +catalog ships a \textbf{one-time migration} that lifts a v0 envelope to v1 using +the score graph as context, applied once on read. Production code carries only v1 +payloads; v0 envelopes survive only as a regression corpus. + +\begin{requirement} +\label{req:migration:properties} +The migration \texttt{migrate\_v0\_envelope(v0, context: \&Score)} \MUST{} be +\textbf{deterministic} (two implementations migrating the same v0 envelope +against the same context produce byte-identical v1 envelopes) and +\textbf{equivalence-preserving} (a v0 envelope and its v1 migration reduce to +byte-identical canonical \texttt{MaterializedState}). When a value cannot be +reconstructed from the v0 projection plus the context, the migration \MUST{} +report the envelope unmigratable rather than fabricate a value, and the bundle +opens read-only. +\end{requirement} + +The reference implementation (\texttt{epiphany-ops::migrate}) reconstructs the +\texttt{InsertEvent} event, the \texttt{DeleteEvent} compensation, the +\texttt{ChangeRegionTimeModel} model, the \texttt{SetUserSystemBreak} anchor, and +the cross-cutting structure self-containedly from the v0 projection; it recovers +a \texttt{RespellPitch} spelling from the context (P12-K1, +Section~\ref{sec:k0:respell-pitch}). The migration's merge gate +(\texttt{epiphany-testkit::migration}) drives the inverse direction --- projecting +a v1 corpus to v0 and migrating it back --- and asserts byte-identical reduction +plus a non-vacuity guard. + +% =========================================================================== +\chapter{K1 --- Framework Slots (Phase 3)} +\label{ch:k1} + +The following K0 catalogue bullet items are \emph{drafted} here as framework +slots and completed in Phase~3. Under the Phase-2 profile they are +\textbf{unavailable}: an implementation \MUST{} reject an operation of one of +these kinds. Each is a schema-fill against the template of +Chapter~\ref{ch:framework}; adding one is not a fresh design. + +\begin{description} + \item[Create score / canvas / region / staff / staff instance / voice] + Structural mint operations. Discipline: set-union creation + (Section~\ref{sec:k0:create-cross-cutting}); undo tombstones the mint; + re-anchoring is not applicable. + \item[ModifyEvent] + Field overwrite on an event's non-identity fields (articulations, dynamics, + stem). Discipline: last-writer-wins with structural-field-collision + (Section~\ref{sec:k0:respell-pitch}). + \item[Insert / delete / modify identified pitch] + Pitch-level mint, tombstone, and field overwrite within an event. Disciplines + as for the event-level analogues. + \item[Set metadata (title / composer / lyricist / copyright)] + Field overwrite on score metadata. Discipline: LWW advisory + (Section~\ref{sec:k0:set-user-system-break}). + \item[Set metric grid / time signature / tempo segment] + Structural field overwrite on a region's metric model. Discipline: + last-writer-wins, with a structural-field-collision on concurrent differing + grids. + \item[Delete / update tie / slur / beam / spanner] + Cross-cutting tombstone and field overwrite. Disciplines: delete-wins with + re-anchoring (Section~\ref{sec:k0:delete-event}) and field overwrite. + \item[Set layout / system-break advisory] + The page/layout advisory companion to + Section~\ref{sec:k0:set-user-system-break}. +\end{description} + +\begin{nongoal} +The full $60$--$80$-primitive catalogue is not a Phase-2 deliverable. The +framework (Chapter~\ref{ch:framework}) and the K0 representative set +(Chapter~\ref{ch:k0}) are sufficient to exercise every reduction discipline; the +remaining primitives are Phase-3 schema-fill. +\end{nongoal} + +\end{document}