From 4556ede9f0ba549081f3fd45e3f96114dd629e3f Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 24 Jun 2026 11:52:02 -0400 Subject: [PATCH] Phase 2 (Agent K) M1: value-typed operation payloads + Operation Catalog scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation milestone for Track B's Operation Catalog: shift epiphany-ops from the v0 identifier-only payload projections to durable value-typed payloads, and scaffold the companion spec. Scope: the representative §6.10 set (7 primitives + 2 meta-ops); the slice-driven K0 expansion follows. Core (the K↔J seam): - epiphany-core exposes a public `CanonicalValue` trait (canonical_bytes / decode_canonical) delegating to the existing private `Codec` machinery, for Event/Rest/PitchSpelling/Tie/Slur/Beam/Spanner/RegionTimeModel/TimeAnchor. No new byte layout — a value's bytes equal what the whole-score codec emits, so all goldens / criterion 4 stay byte-identical. Ops (value-typed payloads, frozen reduction rules): - InsertEventOp{staff_instance,event:Event}, RespellPitchOp{pitch,spelling: PitchSpelling}, CreateCrossCuttingOp{structure:CrossCuttingValue}, ChangeRegionTimeModelOp{...,new_time_model:RegionTimeModel}, SetUserSystemBreakOp{...,anchor:TimeAnchor}, ReplaceWithRest{rest:Rest}. Payloads frame each value's CanonicalValue bytes behind a u32 length prefix. - reduce.rs: read-sites only moved onto the value (rules, conflicts, ordering, promotion, re-anchoring, undo, transactions unchanged); reduce_onto now materializes the real event/structure instead of the C4 placeholder. MaterializedState.spellings now stores PitchSpelling (encode + decode updated). - v0.rs: frozen identifier-only shapes (migration regression guard). - migrate.rs: migrate_v0_envelope(v0, &Score) + project_v1_to_v0 + MigrationError; deterministic and equivalence-preserving. Respell spelling recovered from the score context; irreversible case is P12-K1. - valuegen.rs: shared value-type builders (reused by fuzz, migration, tests, testkit). Resolves P11-C1; P11-C10 Dismiss recorded. Testkit (Agent F merge gate): - migration.rs: reduce(v1)==reduce(migrate(project(v1))) byte-identical, plus migration determinism and a non-vacuity guard; wired into acceptance.rs as agent_k_migration_equivalence_gate. Generators/harnesses build v1 payloads. Spec: - spec/operation_catalog.{tex,pdf}: new companion (independent semver) — framework + per-primitive template, the 7+2 representative primitives, the v0→v1 migration contract, and K1 framework slots for the remaining K0 primitives. Builds clean. - PASS12_BATCH.md: P12-K1 (respell fingerprint irreversibility). Gates: cargo build/fmt/clippy -D warnings clean; cargo test --workspace green (incl. criteria 1/4/5/6 and the new K gate); conformance_suite scale 1 passes. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-core/DECISIONS.md | 21 + crates/epiphany-core/src/codec.rs | 128 ++++ crates/epiphany-core/src/lib.rs | 2 +- crates/epiphany-ops/DECISIONS.md | 41 ++ crates/epiphany-ops/src/anomaly.rs | 3 +- crates/epiphany-ops/src/decode.rs | 13 +- crates/epiphany-ops/src/envelope.rs | 5 +- crates/epiphany-ops/src/fuzz.rs | 55 +- crates/epiphany-ops/src/lib.rs | 13 +- crates/epiphany-ops/src/migrate.rs | 355 +++++++++ crates/epiphany-ops/src/opset.rs | 3 +- crates/epiphany-ops/src/payload.rs | 331 +++++---- crates/epiphany-ops/src/reduce.rs | 336 ++++----- crates/epiphany-ops/src/v0.rs | 168 +++++ crates/epiphany-ops/src/valuegen.rs | 192 +++++ .../tests/concurrent_reduction.rs | 20 +- crates/epiphany-ops/tests/graph_reduction.rs | 37 +- crates/epiphany-testkit/src/convergence.rs | 15 +- crates/epiphany-testkit/src/equivocation.rs | 17 +- crates/epiphany-testkit/src/generators.rs | 113 +-- crates/epiphany-testkit/src/lib.rs | 1 + crates/epiphany-testkit/src/migration.rs | 123 ++++ crates/epiphany-testkit/src/negative.rs | 19 +- crates/epiphany-testkit/tests/acceptance.rs | 19 +- spec/PASS12_BATCH.md | 8 +- spec/operation_catalog.pdf | Bin 0 -> 73744 bytes spec/operation_catalog.tex | 686 ++++++++++++++++++ 27 files changed, 2248 insertions(+), 476 deletions(-) create mode 100644 crates/epiphany-ops/src/migrate.rs create mode 100644 crates/epiphany-ops/src/v0.rs create mode 100644 crates/epiphany-ops/src/valuegen.rs create mode 100644 crates/epiphany-testkit/src/migration.rs create mode 100644 spec/operation_catalog.pdf create mode 100644 spec/operation_catalog.tex 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 0000000000000000000000000000000000000000..60e2006e45afb3c9e0dd371d132562719893e1a0 GIT binary patch literal 73744 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

4@hEsug^X~p29`Y-}rA24*|(Q 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, +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}