Phase 2 (Agent K) M1: value-typed operation payloads + Operation Catalog scaffold

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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-24 11:52:02 -04:00
parent ac7c076e73
commit 4556ede9f0
27 changed files with 2248 additions and 476 deletions

View File

@ -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 whole-score `codec` with it (a failing cross-crate round-trip test would be
the trigger, per the QUICKSTART process notes). 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 ### 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 `epiphany-core` (Agent B) owns the score graph and the pitch/time primitives. It

View File

@ -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<u8>;
/// 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<Self, ScoreDecodeError>;
}
/// 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<u8> {
let mut out = Vec::new();
Codec::enc(self, &mut out);
out
}
fn decode_canonical(bytes: &[u8]) -> Result<Self> {
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<T>(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(&region.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -116,7 +116,7 @@ pub use tempo::{
INVERSION_MAX_ITERATIONS, INVERSION_TOLERANCE_WHOLE_NOTES, INVERSION_MAX_ITERATIONS, INVERSION_TOLERANCE_WHOLE_NOTES,
}; };
pub use codec::ScoreDecodeError; pub use codec::{CanonicalValue, ScoreDecodeError};
pub use indexes::ScoreIndexes; pub use indexes::ScoreIndexes;

View File

@ -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 ### 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: Chapter 6's payload structs embed rich graph values (`InsertEventOp { event:
Event }`, `RespellPitchOp { new_spelling: PitchSpelling }`, …), but the Event }`, `RespellPitchOp { new_spelling: PitchSpelling }`, …), but the
*canonical wire encoding* of those graph value types is itself deferred to 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 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 is not authored by a representative op. **For the spec:** define how a
`ResolveConflict` selects Dismissed (a distinct action, or a separate payload). `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) ## Provisional canonical encoding (mirrors Agent B's P11-4)

View File

@ -283,7 +283,6 @@ mod tests {
use crate::support::AuthorId; use crate::support::AuthorId;
use crate::OperationPayload; use crate::OperationPayload;
use epiphany_core::{PitchId, WallClockTime}; use epiphany_core::{PitchId, WallClockTime};
use epiphany_determinism::ContentHash;
fn env(replica: u64, counter: u64, physical: i64, logical: u32) -> OperationEnvelope { fn env(replica: u64, counter: u64, physical: i64, logical: u32) -> OperationEnvelope {
let id = OperationId::new(ReplicaId(replica), counter); let id = OperationId::new(ReplicaId(replica), counter);
@ -298,7 +297,7 @@ mod tests {
transaction: None, transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(ReplicaId(replica), counter), pitch: PitchId::new(ReplicaId(replica), counter),
spelling: ContentHash([1u8; 32]), spelling: crate::valuegen::spelling(1),
})), })),
} }
} }

View File

@ -6,8 +6,10 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use epiphany_core::{MusicalPosition, OperationId, PitchId, RegionId, TypedObjectId}; use epiphany_core::{
use epiphany_determinism::{CanonicalDecode, ContentHash}; CanonicalValue, MusicalPosition, OperationId, PitchId, PitchSpelling, RegionId, TypedObjectId,
};
use epiphany_determinism::CanonicalDecode;
use crate::{ use crate::{
ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry, ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
@ -516,8 +518,11 @@ pub(crate) fn decode_materialized_state(bytes: &[u8]) -> Result<MaterializedStat
let mut spellings = BTreeMap::new(); let mut spellings = BTreeMap::new();
for _ in 0..spelling_count { for _ in 0..spelling_count {
let pitch = fixed::<PitchId>(&mut reader, 16, "PitchId")?; let pitch = fixed::<PitchId>(&mut reader, 16, "PitchId")?;
let hash = fixed::<ContentHash>(&mut reader, 32, "ContentHash")?; // The value is the full PitchSpelling (v1), encoded behind a u32 length
spellings.insert(pitch, hash); // 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()?; let break_count = reader.len()?;

View File

@ -195,7 +195,6 @@ mod tests {
use crate::payload::{OperationKind, RespellPitchOp}; use crate::payload::{OperationKind, RespellPitchOp};
use crate::stamp::HybridLogicalClock; use crate::stamp::HybridLogicalClock;
use epiphany_core::{PitchId, ReplicaId, WallClockTime}; use epiphany_core::{PitchId, ReplicaId, WallClockTime};
use epiphany_determinism::ContentHash;
fn env(id: OperationId, stamp_id: OperationId) -> OperationEnvelope { fn env(id: OperationId, stamp_id: OperationId) -> OperationEnvelope {
OperationEnvelope { OperationEnvelope {
@ -206,7 +205,7 @@ mod tests {
transaction: None, transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(ReplicaId(1), 1), 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(); let ha = a.envelope_hash();
a.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { a.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(ReplicaId(1), 1), 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()); assert_ne!(ha, a.envelope_hash());
} }

View File

@ -22,19 +22,20 @@
use epiphany_core::{ use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId, 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::causal::CausalContext;
use crate::envelope::OperationEnvelope; use crate::envelope::OperationEnvelope;
use crate::opset::OperationSet; use crate::opset::OperationSet;
use crate::payload::{ use crate::payload::{
CreateCrossCuttingOp, CrossCuttingRef, DeleteEventOp, InsertEventOp, OperationKind, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind,
OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TupletCompensation, OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TupletCompensation,
}; };
use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::stamp::{HybridLogicalClock, OperationStamp};
use crate::support::AuthorId; use crate::support::AuthorId;
use crate::valuegen;
use crate::IntegrityAnomalyKind; use crate::IntegrityAnomalyKind;
/// Number of replicas the generator draws authors from. /// 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. /// Generates a random payload over the shared id space.
fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
let kind = match rng.below(5) { let kind = match rng.below(5) {
0 => OperationKind::InsertEvent(InsertEventOp { 0 => {
voice: VoiceId::new(ReplicaId(7), rng.below(3)), let voice = VoiceId::new(ReplicaId(7), rng.below(3));
staff_instance: StaffInstanceId::new(ReplicaId(7), 0), let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32));
event: event(rng.below(ID_SPACE)), let pitches = if rng.chance(2) {
position: MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
duration: MusicalDuration::whole(),
pitches: if rng.chance(2) {
vec![pitch(rng.below(ID_SPACE))] vec![pitch(rng.below(ID_SPACE))]
} else { } else {
vec![] 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 { 1 => OperationKind::DeleteEvent(DeleteEventOp {
event: event(rng.below(ID_SPACE)), event: event(rng.below(ID_SPACE)),
tuplet_compensation: TupletCompensation::NotInTuplet, tuplet_compensation: TupletCompensation::NotInTuplet,
}), }),
2 => OperationKind::RespellPitch(RespellPitchOp { 2 => OperationKind::RespellPitch(RespellPitchOp {
pitch: pitch(rng.below(ID_SPACE)), 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 { 3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingRef { structure: CrossCuttingValue::Slur(valuegen::slur(
id: TypedObjectId::Slur(SlurId::new(ReplicaId(7), rng.below(ID_SPACE))), SlurId::new(ReplicaId(7), rng.below(ID_SPACE)),
endpoints: vec![ event(rng.below(ID_SPACE)),
TypedObjectId::Event(event(rng.below(ID_SPACE))), event(rng.below(ID_SPACE)),
TypedObjectId::Event(event(rng.below(ID_SPACE))), )),
],
},
}), }),
_ => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { _ => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
region: RegionId::new(ReplicaId(7), 0), 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), present: rng.chance(2),
}), }),
}; };
@ -218,7 +227,7 @@ pub fn gen_envelope_set(rng: &mut SplitMix64, n: usize) -> Vec<OperationEnvelope
let mut twin = victim.clone(); let mut twin = victim.clone();
twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: pitch(rng.below(ID_SPACE)), pitch: pitch(rng.below(ID_SPACE)),
spelling: ContentHash([0xEE; 32]), spelling: valuegen::spelling(6),
})); }));
if twin.envelope_hash() != victim.envelope_hash() { if twin.envelope_hash() != victim.envelope_hash() {
envs.push(twin); envs.push(twin);
@ -279,7 +288,7 @@ pub fn run_equivocation_fuzz(iters: u64, seed: u64) {
transaction: None, transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: pitch(0), pitch: pitch(0),
spelling: ContentHash([spelling; 32]), spelling: valuegen::spelling(spelling),
})), })),
}; };
let a = mk(&mut rng, 1); let a = mk(&mut rng, 1);

View File

@ -84,12 +84,15 @@ mod decode;
mod effect; mod effect;
mod encode; mod encode;
mod envelope; mod envelope;
mod migrate;
mod opset; mod opset;
mod payload; mod payload;
mod reduce; mod reduce;
mod slot; mod slot;
mod stamp; mod stamp;
mod support; mod support;
mod v0;
pub mod valuegen;
pub mod fuzz; pub mod fuzz;
@ -107,12 +110,13 @@ pub use effect::{
RepairKind, RepairRecord, TupletCompensationKind, RepairKind, RepairRecord, TupletCompensationKind,
}; };
pub use envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError}; pub use envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessError};
pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError};
pub use opset::OperationSet; pub use opset::OperationSet;
pub use payload::{ pub use payload::{
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingRef, DeleteEventOp, InsertEventOp, ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp,
OperationKind, OperationKindTag, OperationPayload, PositionRemapping, RegionTimeModelTag, OperationKind, OperationKindTag, OperationPayload, PositionRemapping, ResolveConflictPayload,
ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
TransactionDescriptor, TupletCompensation, TupletCompensation,
}; };
pub use reduce::{ pub use reduce::{
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason, canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,
@ -127,5 +131,6 @@ pub use support::{
}; };
pub use undo::{UndoPolicy, UndoTransactionPayload}; pub use undo::{UndoPolicy, UndoTransactionPayload};
pub use v0::V0OperationEnvelope;
mod undo; mod undo;

View File

@ -0,0 +1,355 @@
//! v0 → v1 operation-payload migration (QUICKSTART Agent K, "v0 → v1 payload
//! migration", *option 2*: a one-time migration, not parallel dialects forever).
//!
//! A v0 envelope carried identifier-only payloads; a v1 envelope carries the
//! real value-typed payloads ([`crate::payload`]). [`migrate_v0_envelope`] lifts
//! a v0 envelope to v1, **using the score graph as context** to reconstruct the
//! values the v0 projection dropped. It is:
//!
//! * **Deterministic** — the same `(v0, context)` always yields byte-identical
//! v1, and
//! * **Equivalence-preserving** — a v0 envelope and its v1 migration reduce to
//! byte-identical canonical [`MaterializedState`](crate::MaterializedState).
//!
//! [`project_v1_to_v0`] is the total inverse direction (v1 → the v0 wire shape),
//! used to drive the regression guard: `reduce(migrate(project(v1))) ==
//! reduce(v1)` proves the migration loses no reduction-relevant content. See
//! `crates/epiphany-testkit` (the migration harness).
//!
//! ## Irreversible case (P12-K1)
//!
//! A v0 `RespellPitch` carried only a [`ContentHash`] *fingerprint* of the new
//! spelling, not the spelling itself. Migration recovers the [`PitchSpelling`]
//! from the context — an explicit per-pitch spelling attachment whose canonical
//! bytes hash to the fingerprint. If the context does not contain it (a fresh
//! context that never materialized the respell), the spelling cannot be
//! reconstructed and migration returns [`MigrationError::Irreversible`]; the
//! quickstart's contract is that such a bundle opens read-only. This is the one
//! representative payload that is not self-contained, recorded as Pass-12
//! candidate P12-K1.
use epiphany_core::{
CanonicalValue, PitchSpelling, ReplicaId, Score, SpellingDirective, SpellingScope, VoiceId,
};
use epiphany_determinism::ContentHash;
use crate::payload::{
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp,
OperationKind, OperationPayload, PositionRemapping, RespellPitchOp, SetUserSystemBreakOp,
TupletCompensation,
};
use crate::v0::{
V0CreateCrossCuttingOp, V0DeleteEventOp, V0InsertEventOp, V0OperationEnvelope, V0OperationKind,
V0OperationPayload, V0PositionRemapping, V0RegionTimeModelTag, V0RespellPitchOp,
V0SetUserSystemBreakOp, V0TupletCompensation,
};
use crate::{valuegen, OperationEnvelope};
/// Why a v0 envelope could not be migrated to v1.
#[derive(Clone, PartialEq, Eq, Debug)]
pub enum MigrationError {
/// A value the v1 payload requires cannot be reconstructed from the v0
/// projection plus the provided context (P12-K1). The bundle opens
/// read-only.
Irreversible(&'static str),
}
impl core::fmt::Display for MigrationError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> 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<OperationEnvelope, MigrationError> {
let payload = migrate_payload(&v0.payload, context)?;
Ok(v0.rewrap(payload))
}
fn migrate_payload(
p: &V0OperationPayload,
context: &Score,
) -> Result<OperationPayload, MigrationError> {
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<OperationKind, MigrationError> {
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<CrossCuttingValue, MigrationError> {
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<PitchSpelling, MigrationError> {
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)",
))
}

View File

@ -211,7 +211,6 @@ mod tests {
use crate::support::AuthorId; use crate::support::AuthorId;
use crate::OperationPayload; use crate::OperationPayload;
use epiphany_core::{PitchId, ReplicaId, WallClockTime}; use epiphany_core::{PitchId, ReplicaId, WallClockTime};
use epiphany_determinism::ContentHash;
fn env_with(id: OperationId, spelling: u8) -> OperationEnvelope { fn env_with(id: OperationId, spelling: u8) -> OperationEnvelope {
OperationEnvelope { OperationEnvelope {
@ -222,7 +221,7 @@ mod tests {
transaction: None, transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(ReplicaId(1), 1), pitch: PitchId::new(ReplicaId(1), 1),
spelling: ContentHash([spelling; 32]), spelling: crate::valuegen::spelling(spelling),
})), })),
} }
} }

View File

@ -3,39 +3,53 @@
//! structs the chapter specifies reduction rules for (Chapter 6 §"The Operation //! structs the chapter specifies reduction rules for (Chapter 6 §"The Operation
//! Framework", §"Representative Operations"). //! 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 //! Track B's Operation Catalog (Agent K) shifted these payloads from the v0
//! `Event`, `RespellPitchOp` holds a `PitchSpelling`, and so on. Their *canonical //! *identifier-only projection* (an `InsertEvent` that carried only an `EventId`
//! wire encoding*, however, is deferred to the Binary Format companion (Agent B's //! plus scalars; a `RespellPitch` that carried only a [`ContentHash`] fingerprint
//! `epiphany-core` canonically encodes only identifiers and the scalar time //! of the new spelling) to the **value-typed** form the spec describes:
//! types; the value-type encoding is its Pass 11 candidate P11-4). To keep an //! `InsertEventOp` carries the real [`Event`], `RespellPitchOp` carries the real
//! [`OperationEnvelope`](crate::OperationEnvelope) **hashable today** — the //! [`PitchSpelling`], `CreateCrossCuttingOp` carries the real cross-cutting
//! [`EnvelopeHash`](crate::EnvelopeHash) and slot equivocation both need //! structure, and so on. This is what makes an operation *durable* — replayable
//! canonical bytes — these payloads carry the reduction-relevant *identifiers and //! in a fresh context (a backup restore, a cross-tool round-trip) without the
//! canonical scalar coordinates*, plus a content fingerprint where the reduction //! originating graph.
//! only needs equality (a respelling's [`ContentHash`]). This is faithful to //!
//! everything Chapter 6's reduction rules actually consume, and is recorded as a //! The payloads serialize canonically by embedding each value's
//! Pass 11 candidate (see `DECISIONS.md`): when the companion lands, the structs //! [`CanonicalValue`] bytes behind a `u32` length prefix (the same ratified byte
//! grow back their full value fields without changing the reduction. //! 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 //! 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 //! ~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::{ use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, PitchId, RegionId, StaffInstanceId, TransactionId, Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, MusicalDuration,
TupletId, TypedObjectId, VoiceId, 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::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::support::OperationKindRegistryId;
use crate::undo::UndoTransactionPayload; use crate::undo::UndoTransactionPayload;
/// The full payload of an operation envelope: a primitive, or one of the two /// The full payload of an operation envelope: a primitive, or one of the two
/// meta-operations (Chapter 6 §"Operation Envelopes"). /// 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)] #[derive(Clone, PartialEq, Eq, Debug)]
pub enum OperationPayload { pub enum OperationPayload {
/// A primitive mutation. /// A primitive mutation.
@ -70,6 +84,9 @@ impl CanonicalEncode for OperationPayload {
/// The catalog of primitive operation kinds reduced by this crate (Chapter 6 /// The catalog of primitive operation kinds reduced by this crate (Chapter 6
/// §"Operation Envelopes", representative subset of §6.10). /// §"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)] #[derive(Clone, PartialEq, Eq, Debug)]
pub enum OperationKind { pub enum OperationKind {
/// Insert an event into a voice (position-keyed; voice promotion on /// 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). -------------------- // --- 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 /// `staff_instance` is retained alongside the event so the system-promoted-voice
/// without a containment walk (a full reducer recovers it from the voice's /// derivation is total without a containment walk (a full reducer recovers it
/// container; see `DECISIONS.md`). `position`/`duration` are exact musical /// from the voice's container; see `DECISIONS.md`).
/// rationals, the collision key for voice promotion. `pitches` are tombstoned
/// with the event on delete.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct InsertEventOp { pub struct InsertEventOp {
pub voice: VoiceId,
pub staff_instance: StaffInstanceId, pub staff_instance: StaffInstanceId,
pub event: EventId, pub event: Event,
pub position: MusicalPosition, }
pub duration: MusicalDuration,
pub pitches: Vec<PitchId>, 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<PitchId> {
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 { impl CanonicalEncode for InsertEventOp {
fn encode_canonical(&self, out: &mut Vec<u8>) { fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.voice);
push_canon(out, &self.staff_instance); push_canon(out, &self.staff_instance);
push_canon(out, &self.event); push_lp_bytes(out, &self.event.canonical_bytes());
push_canon(out, &self.position);
push_canon(out, &self.duration);
push_seq(out, &sorted_canonical(self.pitches.clone()));
} }
} }
@ -244,18 +293,14 @@ impl CanonicalEncode for DeleteEventOp {
} }
/// Compensation for a deleted event's tuplet membership (Chapter 6 §6.10). /// Compensation for a deleted event's tuplet membership (Chapter 6 §6.10).
/// The replacement rest is represented by its freshly-minted [`EventId`] and /// The replacement rest carries its full [`Rest`] value (v1, value-typed); the
/// duration (the reducer adds it live and validates the duration); the full /// reducer adds it live and validates the duration.
/// `Rest` value is deferred with the rest of the payload encoding.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub enum TupletCompensation { pub enum TupletCompensation {
/// Target is not in any tuplet. /// Target is not in any tuplet.
NotInTuplet, NotInTuplet,
/// Replace the deleted event with a rest of the same duration. /// Replace the deleted event with a rest of the same duration.
ReplaceWithRest { ReplaceWithRest { rest: Rest },
new_rest: EventId,
duration: MusicalDuration,
},
/// Rewrite the enclosing tuplet(s) to remain consistent. /// Rewrite the enclosing tuplet(s) to remain consistent.
RewriteTuplets { tuplets: Vec<TupletId> }, RewriteTuplets { tuplets: Vec<TupletId> },
/// Cascade-delete the tuplet group(s) containing the target. /// Cascade-delete the tuplet group(s) containing the target.
@ -284,9 +329,8 @@ impl CanonicalEncode for TupletCompensation {
push_tag(out, self.discriminant()); push_tag(out, self.discriminant());
match self { match self {
TupletCompensation::NotInTuplet => {} TupletCompensation::NotInTuplet => {}
TupletCompensation::ReplaceWithRest { new_rest, duration } => { TupletCompensation::ReplaceWithRest { rest } => {
push_canon(out, new_rest); push_lp_bytes(out, &rest.canonical_bytes());
push_canon(out, duration);
} }
TupletCompensation::RewriteTuplets { tuplets } TupletCompensation::RewriteTuplets { tuplets }
| TupletCompensation::CascadeDeleteTuplets { tuplets } => { | TupletCompensation::CascadeDeleteTuplets { tuplets } => {
@ -296,31 +340,31 @@ impl CanonicalEncode for TupletCompensation {
} }
} }
/// Overwrite a pitch's spelling (Chapter 6 §6.10 RespellPitch). The intended /// Overwrite a pitch's spelling (Chapter 6 §6.10 RespellPitch). Carries the full
/// [`PitchSpelling`](epiphany_core::PitchSpelling) is represented by a /// [`PitchSpelling`] value (v1, value-typed); the reduction needs spelling
/// [`ContentHash`] fingerprint: the reduction only needs spelling *equality* /// *equality* (identical concurrent respellings reduce idempotently; differing
/// (identical concurrent respellings reduce idempotently; differing ones /// ones conflict), which is now structural value equality rather than a
/// conflict). /// fingerprint comparison.
#[derive(Copy, Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct RespellPitchOp { pub struct RespellPitchOp {
pub pitch: PitchId, pub pitch: PitchId,
pub spelling: ContentHash, pub spelling: PitchSpelling,
} }
impl CanonicalEncode for RespellPitchOp { impl CanonicalEncode for RespellPitchOp {
fn encode_canonical(&self, out: &mut Vec<u8>) { fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.pitch); 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 /// Create a cross-cutting structure (Chapter 6 §6.10 CreateCrossCutting). Carries
/// structure is identified by its [`TypedObjectId`] (whose variant carries the /// the full typed structure value (v1, value-typed); the set-union reduction and
/// kind — Slur, Tie, Beam, …) and its referenced endpoints; that is everything /// the re-anchoring rule table key on its [`CrossCuttingValue::id`] and
/// the set-union reduction and the re-anchoring rule table consume. /// [`CrossCuttingValue::endpoints`], which it derives from the value.
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct CreateCrossCuttingOp { pub struct CreateCrossCuttingOp {
pub structure: CrossCuttingRef, pub structure: CrossCuttingValue,
} }
impl CanonicalEncode for CreateCrossCuttingOp { 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 /// A cross-cutting structure value (Chapter 5 §"Cross-Cutting Structures"): the
/// objects it references (Chapter 6 §6.5 re-anchoring operands). /// 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)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct CrossCuttingRef { pub enum CrossCuttingValue {
/// The structure's identity; its `TypedObjectId` variant names the kind. Tie(Tie),
pub id: TypedObjectId, Slur(Slur),
/// The objects this structure references (its endpoints/anchors). Beam(Beam),
pub endpoints: Vec<TypedObjectId>, Spanner(Spanner),
} }
impl CanonicalEncode for CrossCuttingRef { 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<TypedObjectId> {
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(),
}
}
}
impl CanonicalEncode for CrossCuttingValue {
fn encode_canonical(&self, out: &mut Vec<u8>) { fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.id); push_tag(out, self.discriminant());
// Endpoint order is meaningful (start before end), so it is NOT sorted. let bytes = match self {
push_seq(out, &self.endpoints); 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). The /// Change a region's time model (Chapter 6 §6.10 ChangeRegionTimeModel). Carries
/// target model is represented by its [`RegionTimeModelTag`]; the prototype's /// the full target [`RegionTimeModel`] value (v1, value-typed); the reduction
/// reducer cannot recompute coordinate-kind compatibility from the not-yet- /// keys coordinate-kind compatibility on the value's *kind*. The authoring layer
/// canonical region contents, so the authoring layer declares any events it /// additionally declares any events it knows to be un-migratable
/// knows to be un-migratable (`declared_incompatible`), which drive the /// (`declared_incompatible`), which drive the `TimeModelMigrationFailure`
/// `TimeModelMigrationFailure` conflict (see `DECISIONS.md`). /// conflict (see `DECISIONS.md`, P11-C6 — graph-aware reduction derives the rest
/// from the region contents).
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct ChangeRegionTimeModelOp { pub struct ChangeRegionTimeModelOp {
pub region: RegionId, pub region: RegionId,
pub new_time_model: RegionTimeModelTag, pub new_time_model: RegionTimeModel,
pub declared_incompatible: Vec<EventId>, pub declared_incompatible: Vec<EventId>,
pub remapping: PositionRemapping, pub remapping: PositionRemapping,
} }
@ -364,39 +464,12 @@ pub struct ChangeRegionTimeModelOp {
impl CanonicalEncode for ChangeRegionTimeModelOp { impl CanonicalEncode for ChangeRegionTimeModelOp {
fn encode_canonical(&self, out: &mut Vec<u8>) { fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.region); 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())); push_seq(out, &sorted_canonical(self.declared_incompatible.clone()));
self.remapping.encode_canonical(out); self.remapping.encode_canonical(out);
} }
} }
/// Which region time model a migration targets (Chapter 3 §"Region Time
/// Models"). The discriminator the reducer needs; the full
/// [`RegionTimeModel`](epiphany_core::RegionTimeModel) value is deferred.
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum RegionTimeModelTag {
Metric,
Proportional,
Aleatoric,
}
impl RegionTimeModelTag {
fn discriminant(&self) -> u8 {
match self {
RegionTimeModelTag::Metric => 0,
RegionTimeModelTag::Proportional => 1,
RegionTimeModelTag::Aleatoric => 2,
}
}
}
impl CanonicalEncode for RegionTimeModelTag {
#[inline]
fn encode_canonical(&self, out: &mut Vec<u8>) {
push_tag(out, self.discriminant());
}
}
/// How event positions are remapped under a time-model change (Chapter 6 /// How event positions are remapped under a time-model change (Chapter 6
/// §6.10). `PreserveTime`'s converter and `Reassign`'s full event positions /// §6.10). `PreserveTime`'s converter and `Reassign`'s full event positions
/// follow the deferred payload encoding; the reducer consumes the event set. /// follow the deferred payload encoding; the reducer consumes the event set.
@ -440,21 +513,37 @@ fn push_len_pairs(out: &mut Vec<u8>, entries: &[(EventId, MusicalPosition)]) {
} }
} }
/// Set a user system-break preference (Chapter 6 §6.10 SetUserSystemBreak). The /// Set a user system-break preference (Chapter 6 §6.10 SetUserSystemBreak).
/// anchor is represented by its resolved [`MusicalPosition`] — the canonical /// Carries the full [`TimeAnchor`] value (v1, value-typed); the reduction's LWW
/// LWW bucketing key; the full [`TimeAnchor`](epiphany_core::TimeAnchor) value /// bucketing key is the anchor's resolved [`MusicalPosition`]
/// is deferred. /// ([`SetUserSystemBreakOp::resolved_position`]).
#[derive(Clone, PartialEq, Eq, Debug)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct SetUserSystemBreakOp { pub struct SetUserSystemBreakOp {
pub region: RegionId, pub region: RegionId,
pub anchor: MusicalPosition, pub anchor: TimeAnchor,
pub present: bool, 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 { impl CanonicalEncode for SetUserSystemBreakOp {
fn encode_canonical(&self, out: &mut Vec<u8>) { fn encode_canonical(&self, out: &mut Vec<u8>) {
push_canon(out, &self.region); push_canon(out, &self.region);
push_canon(out, &self.anchor); push_lp_bytes(out, &self.anchor.canonical_bytes());
push_u8_bool(out, self.present); push_u8_bool(out, self.present);
} }
} }
@ -558,7 +647,7 @@ mod tests {
assert_eq!(k.tag(), OperationKindTag::Registered(reg)); assert_eq!(k.tag(), OperationKindTag::Registered(reg));
let prim = OperationKind::RespellPitch(RespellPitchOp { let prim = OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(ReplicaId(1), 1), pitch: PitchId::new(ReplicaId(1), 1),
spelling: ContentHash([4u8; 32]), spelling: crate::valuegen::spelling(4),
}); });
assert_eq!(prim.tag(), OperationKindTag::RespellPitch); assert_eq!(prim.tag(), OperationKindTag::RespellPitch);
} }
@ -602,17 +691,13 @@ mod tests {
#[test] #[test]
fn cross_cutting_endpoint_order_is_significant() { fn cross_cutting_endpoint_order_is_significant() {
let s = TypedObjectId::Slur(SlurId::new(ReplicaId(1), 1)); // A slur's (start, end) order is meaningful: swapping the endpoints
let a = TypedObjectId::Event(EventId::new(ReplicaId(1), 2)); // changes the canonical bytes.
let b = TypedObjectId::Event(EventId::new(ReplicaId(1), 3)); let s = SlurId::new(ReplicaId(1), 1);
let fwd = CrossCuttingRef { let a = EventId::new(ReplicaId(1), 2);
id: s, let b = EventId::new(ReplicaId(1), 3);
endpoints: vec![a, b], let fwd = CrossCuttingValue::Slur(crate::valuegen::slur(s, a, b));
}; let rev = CrossCuttingValue::Slur(crate::valuegen::slur(s, b, a));
let rev = CrossCuttingRef {
id: s,
endpoints: vec![b, a],
};
assert_ne!(fwd.to_canonical_bytes(), rev.to_canonical_bytes()); assert_ne!(fwd.to_canonical_bytes(), rev.to_canonical_bytes());
} }
} }

View File

@ -31,15 +31,12 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::{ use epiphany_core::{
derive_promoted_voice_id, AcousticPitch, AcousticRealization, AleatoricAnchoringDiscipline, derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId,
AleatoricTimeModel, AnchorOffset, Beam, CmnNominal, Event, EventDuration, EventId, EventPosition, MusicalDuration, MusicalPosition, OperationId, PitchId, PitchSpelling,
EventOrderingDAG, EventPosition, IdentifiedPitch, MetricTimeModel, MusicalDuration, RegionEdge, RegionId, RegionTimeModel, Score, TimeAnchor, TransactionId, TypedObjectId, Voice,
MusicalPosition, OperationId, Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent, VoiceId, VoiceOrigin,
ProportionalTimeModel, RegionEdge, RegionId, RegionTimeModel, Rest, ScalePosition, Score, Slur,
StemConfiguration, Tie, TieClass, TimeAnchor, TransactionId, TuningReference, TypedObjectId,
Voice, VoiceId, VoiceOrigin, WallClockDuration,
}; };
use epiphany_determinism::{CanonicalEncode, ContentHash}; use epiphany_determinism::CanonicalEncode;
use crate::anomaly::{detect_replica_anomalies, IntegrityAnomaly, IntegrityAnomalyKind}; use crate::anomaly::{detect_replica_anomalies, IntegrityAnomaly, IntegrityAnomalyKind};
use crate::conflict::{ use crate::conflict::{
@ -49,12 +46,12 @@ use crate::effect::{
NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, RepairKind, NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, RepairKind,
RepairRecord, TupletCompensationKind, 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::envelope::OperationEnvelope;
use crate::opset::OperationSet; use crate::opset::OperationSet;
use crate::payload::{ use crate::payload::{
CreateCrossCuttingOp, DeleteEventOp, InsertEventOp, OperationKind, OperationPayload, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind,
RespellPitchOp, TupletCompensation, OperationPayload, RespellPitchOp, TupletCompensation,
}; };
use crate::undo::{UndoPolicy, UndoTransactionPayload}; use crate::undo::{UndoPolicy, UndoTransactionPayload};
@ -204,7 +201,7 @@ pub struct MaterializedState {
/// Object existence (live/tombstoned), keyed by `TypedObjectId`. /// Object existence (live/tombstoned), keyed by `TypedObjectId`.
pub objects: BTreeMap<TypedObjectId, ObjectState>, pub objects: BTreeMap<TypedObjectId, ObjectState>,
/// Current resolved spelling per pitch (the `RespellPitch` field). /// Current resolved spelling per pitch (the `RespellPitch` field).
pub spellings: BTreeMap<PitchId, ContentHash>, pub spellings: BTreeMap<PitchId, PitchSpelling>,
/// User system-break preferences (LWW advisory), keyed by region+anchor. /// User system-break preferences (LWW advisory), keyed by region+anchor.
pub breaks: BTreeMap<(RegionId, MusicalPosition), bool>, pub breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
/// Operations held pending, ordered by `OperationId`. /// Operations held pending, ordered by `OperationId`.
@ -252,11 +249,12 @@ impl MaterializedState {
push_canon(&mut out, id); push_canon(&mut out, id);
state.encode_canonical(&mut out); 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()); 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, pitch);
push_canon(&mut out, hash); push_lp_bytes(&mut out, &spelling.canonical_bytes());
} }
// Breaks (region+anchor order). // Breaks (region+anchor order).
push_len(&mut out, self.breaks.len()); push_len(&mut out, self.breaks.len());
@ -309,7 +307,7 @@ struct Reducer<'a> {
op_set: &'a OperationSet, op_set: &'a OperationSet,
// Canonical results. // Canonical results.
objects: BTreeMap<TypedObjectId, ObjectState>, objects: BTreeMap<TypedObjectId, ObjectState>,
spellings: BTreeMap<PitchId, ContentHash>, spellings: BTreeMap<PitchId, PitchSpelling>,
breaks: BTreeMap<(RegionId, MusicalPosition), bool>, breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
conflicts: ConflictRegistry, conflicts: ConflictRegistry,
effects: Vec<(OperationId, OperationEffect)>, effects: Vec<(OperationId, OperationEffect)>,
@ -333,7 +331,7 @@ struct Reducer<'a> {
/// A snapshot of the working state, for atomic transaction rollback. /// A snapshot of the working state, for atomic transaction rollback.
struct WorkingSnapshot { struct WorkingSnapshot {
objects: BTreeMap<TypedObjectId, ObjectState>, objects: BTreeMap<TypedObjectId, ObjectState>,
spellings: BTreeMap<PitchId, ContentHash>, spellings: BTreeMap<PitchId, PitchSpelling>,
breaks: BTreeMap<(RegionId, MusicalPosition), bool>, breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
conflicts: ConflictRegistry, conflicts: ConflictRegistry,
minted_by: BTreeMap<TypedObjectId, OperationId>, minted_by: BTreeMap<TypedObjectId, OperationId>,
@ -363,7 +361,12 @@ fn intervals_overlap(
} }
fn insert_intervals_overlap(a: &InsertEventOp, b: &InsertEventOp) -> bool { 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)> { 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 None
} }
fn placeholder_pitch(id: PitchId) -> IdentifiedPitch { /// The graph value inserted by a value-typed InsertEvent: the carried [`Event`]
IdentifiedPitch { /// itself, with its voice rebound to the (possibly system-promoted) target
id, /// voice. The Operation Catalog (v1) carries the real event, so this is no
pitch: Pitch { /// longer a placeholder reconstruction.
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.
fn graph_event_from_insert(op: &InsertEventOp, target_voice: VoiceId) -> Event { fn graph_event_from_insert(op: &InsertEventOp, target_voice: VoiceId) -> Event {
let position = EventPosition::Musical(op.position.clone()); let mut event = op.event.clone();
let duration = EventDuration::Musical(op.duration.clone()); event.set_voice(target_voice);
if op.pitches.is_empty() { event
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,
})
}
} }
impl<'a> Reducer<'a> { impl<'a> Reducer<'a> {
@ -742,7 +703,7 @@ impl<'a> Reducer<'a> {
if self.graph_insert_precondition(op).is_err() { if self.graph_insert_precondition(op).is_err() {
continue; continue;
} }
buckets.entry(op.voice).or_default().push(env); buckets.entry(op.voice()).or_default().push(env);
} }
} }
for (_, mut bucket) in buckets { for (_, mut bucket) in buckets {
@ -769,7 +730,7 @@ impl<'a> Reducer<'a> {
}); });
if let Some(winner) = collision { if let Some(winner) = collision {
let promoted = 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)); self.promotion.insert(env.id, (promoted, winner.id));
} else { } else {
original_voice.push(env); original_voice.push(env);
@ -785,8 +746,8 @@ impl<'a> Reducer<'a> {
let Some(score) = self.graph.as_ref() else { let Some(score) = self.graph.as_ref() else {
return Ok((0, 0, 0)); return Ok((0, 0, 0));
}; };
let location = let location = graph_voice_location(score, op.voice())
graph_voice_location(score, op.voice).ok_or(PreconditionFailureReason::VoiceMissing)?; .ok_or(PreconditionFailureReason::VoiceMissing)?;
let (region_index, instance_index, _) = location; let (region_index, instance_index, _) = location;
let region = &score.canvas.regions[region_index]; let region = &score.canvas.regions[region_index];
let instance = &region.staff_instances()[instance_index]; let instance = &region.staff_instances()[instance_index];
@ -796,9 +757,10 @@ impl<'a> Reducer<'a> {
if !matches!(region.time_model, epiphany_core::RegionTimeModel::Metric(_)) { if !matches!(region.time_model, epiphany_core::RegionTimeModel::Metric(_)) {
return Err(PreconditionFailureReason::WrongRegionTimeModel); return Err(PreconditionFailureReason::WrongRegionTimeModel);
} }
if score.events.contains(op.event) let event_id = op.event_id();
|| score.tombstoned_events.contains(&op.event) if score.events.contains(event_id)
|| op.pitches.iter().any(|pitch| { || score.tombstoned_events.contains(&event_id)
|| op.pitch_ids().iter().any(|pitch| {
self.objects.contains_key(&TypedObjectId::Pitch(*pitch)) self.objects.contains_key(&TypedObjectId::Pitch(*pitch))
|| score.tombstoned_pitches.contains(pitch) || score.tombstoned_pitches.contains(pitch)
}) })
@ -835,13 +797,13 @@ impl<'a> Reducer<'a> {
.expect("the precondition found this instance"); .expect("the precondition found this instance");
instance.voices.push(Voice { instance.voices.push(Voice {
id: promoted, id: promoted,
events: vec![op.event], events: vec![op.event_id()],
default_stem_direction: None, default_stem_direction: None,
is_primary: false, is_primary: false,
origin: VoiceOrigin::SystemPromoted { origin: VoiceOrigin::SystemPromoted {
winning_operation: winner, winning_operation: winner,
losing_operation: env.id, losing_operation: env.id,
original_voice: op.voice, original_voice: op.voice(),
}, },
}); });
} else { } else {
@ -849,7 +811,7 @@ impl<'a> Reducer<'a> {
.voices[voice_index] .voices[voice_index]
.events .events
.clone(); .clone();
ordered.push(op.event); ordered.push(op.event_id());
ordered.sort_by(|a, b| { ordered.sort_by(|a, b| {
let a_position = score.events.get(*a).map(Event::position); let a_position = score.events.get(*a).map(Event::position);
let b_position = score.events.get(*b).map(Event::position); let b_position = score.events.get(*b).map(Event::position);
@ -894,10 +856,10 @@ impl<'a> Reducer<'a> {
Err(PreconditionFailureReason::TupletCompensationInvalid) Err(PreconditionFailureReason::TupletCompensationInvalid)
} }
TupletCompensation::NotInTuplet => Ok(()), TupletCompensation::NotInTuplet => Ok(()),
TupletCompensation::ReplaceWithRest { new_rest, duration } => { TupletCompensation::ReplaceWithRest { rest } => {
if score.events.contains(*new_rest) if score.events.contains(rest.id)
|| score.tombstoned_events.contains(new_rest) || score.tombstoned_events.contains(&rest.id)
|| event.duration() != &EventDuration::Musical(duration.clone()) || event.duration() != &rest.duration
{ {
Err(PreconditionFailureReason::TupletCompensationInvalid) Err(PreconditionFailureReason::TupletCompensationInvalid)
} else { } else {
@ -957,25 +919,23 @@ impl<'a> Reducer<'a> {
.extend(deleted_pitches.iter().copied()); .extend(deleted_pitches.iter().copied());
match &op.tuplet_compensation { 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 tuplet in &mut score.cross_cutting.tuplets {
for member in &mut tuplet.members { for member in &mut tuplet.members {
if *member == op.event { if *member == op.event {
*member = *new_rest; *member = new_rest;
} }
} }
} }
let replacement = Event::Rest(Rest { // The value-typed payload (v1) carries the replacement Rest; it
id: *new_rest, // is placed at the deleted event's voice and position.
voice: voice_id, let mut replacement = rest.clone();
position: event.position().clone(), replacement.voice = voice_id;
duration: EventDuration::Musical(duration.clone()), replacement.position = event.position().clone();
vertical_position: None,
visible: true,
});
score score
.events .events
.insert(replacement) .insert(Event::Rest(replacement))
.expect("replacement-rest preconditions were checked"); .expect("replacement-rest preconditions were checked");
if let Some((region_index, instance_index, voice_index)) = if let Some((region_index, instance_index, voice_index)) =
graph_voice_location(score, voice_id) graph_voice_location(score, voice_id)
@ -985,11 +945,11 @@ impl<'a> Reducer<'a> {
.staff_instances_mut() .staff_instances_mut()
.expect("an event voice belongs to staff-based content")[instance_index] .expect("an event voice belongs to staff-based content")[instance_index]
.voices[voice_index]; .voices[voice_index];
if !voice.events.contains(new_rest) { if !voice.events.contains(&new_rest) {
let index = removed_event_index let index = removed_event_index
.unwrap_or(voice.events.len()) .unwrap_or(voice.events.len())
.min(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 { let Some(score) = self.graph.as_mut() else {
return Ok(()); return Ok(());
}; };
let event_endpoints: Option<Vec<EventId>> = op // The value-typed payload (v1) carries the real structure with its rich
.structure // fields, so materialization clones it directly rather than rebuilding a
.endpoints // default from the reference-level projection.
.iter() match &op.structure {
.map(|endpoint| match endpoint { CrossCuttingValue::Slur(slur) => score.cross_cutting.slurs.push(slur.clone()),
TypedObjectId::Event(event) => Some(*event), CrossCuttingValue::Beam(beam) => {
_ => None, if beam.events.len() < 2 {
})
.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,
});
}
(TypedObjectId::Beam(id), Some(events)) if events.len() >= 2 => {
score.cross_cutting.beams.push(Beam {
id,
events: events.to_vec(),
level: 1,
});
}
(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); return Err(PreconditionFailureReason::TargetMissing);
} }
// The reference-level prototype payload does not contain the rich score.cross_cutting.beams.push(beam.clone());
// fields needed to instantiate other cross-cutting variants. Their }
// canonical identity/reference projection remains in `state`. CrossCuttingValue::Tie(tie) => score.cross_cutting.ties.push(tie.clone()),
_ => {} CrossCuttingValue::Spanner(spanner) => {
score.cross_cutting.spanners.push(spanner.clone())
}
} }
Ok(()) Ok(())
} }
@ -1199,11 +1133,9 @@ impl<'a> Reducer<'a> {
} }
} }
}; };
let anchor = TimeAnchor::Region { // The value-typed payload (v1) carries the full TimeAnchor, so the
id: op.region, // graph break is the anchor itself rather than a reconstructed one.
edge: RegionEdge::Start, let anchor = op.anchor.clone();
offset: AnchorOffset::Musical(MusicalDuration(op.anchor.0.clone())),
};
if op.present { if op.present {
if !breaks.contains(&anchor) { if !breaks.contains(&anchor) {
breaks.push(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 self.breaks
.insert((op.region, op.anchor.clone()), op.present); .insert((op.region, op.resolved_position()), op.present);
OperationEffect::Applied OperationEffect::Applied
} }
fn insert_event(&mut self, env: &OperationEnvelope, op: &InsertEventOp) -> OperationEffect { 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 { return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction { reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::EventDurationInvalid, reason: PreconditionFailureReason::EventDurationInvalid,
}, },
}; };
} }
let ev_obj = TypedObjectId::Event(op.event); let ev_obj = TypedObjectId::Event(event_id);
match self.objects.get(&ev_obj) { match self.objects.get(&ev_obj) {
Some(ObjectState::Live) => { Some(ObjectState::Live) => {
return OperationEffect::NoOp { 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) { match self.objects.get(&voice_obj) {
Some(ObjectState::Tombstoned { .. }) => { Some(ObjectState::Tombstoned { .. }) => {
return OperationEffect::NoOp { return OperationEffect::NoOp {
@ -1266,13 +1204,13 @@ impl<'a> Reducer<'a> {
} }
let promotion = self.promotion.get(&env.id).copied(); 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 if self
.voice_occupancy .voice_occupancy
.get(&target_voice) .get(&target_voice)
.is_some_and(|events| { .is_some_and(|events| {
events.iter().any(|(position, duration, _)| { 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); self.note_minted(env, pv);
repairs.push(RepairRecord { repairs.push(RepairRecord {
kind: RepairKind::VoicePromoted { kind: RepairKind::VoicePromoted {
from: op.voice, from: orig_voice,
to: promoted, to: promoted,
}, },
target: pv, target: pv,
@ -1310,18 +1248,18 @@ impl<'a> Reducer<'a> {
self.minted_by.insert(ev_obj, env.id); self.minted_by.insert(ev_obj, env.id);
self.note_minted(env, ev_obj); self.note_minted(env, ev_obj);
let mut pitches = Vec::new(); let mut pitches = Vec::new();
for &p in &op.pitches { for p in op.pitch_ids() {
let p_obj = TypedObjectId::Pitch(p); let p_obj = TypedObjectId::Pitch(p);
self.objects.insert(p_obj, ObjectState::Live); self.objects.insert(p_obj, ObjectState::Live);
self.minted_by.insert(p_obj, env.id); self.minted_by.insert(p_obj, env.id);
self.note_minted(env, p_obj); self.note_minted(env, p_obj);
pitches.push(p); 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(( self.voice_occupancy.entry(target_voice).or_default().push((
op.position.clone(), op_position,
op.duration.clone(), op_duration,
op.event, event_id,
)); ));
if repairs.is_empty() { if repairs.is_empty() {
@ -1395,17 +1333,24 @@ impl<'a> Reducer<'a> {
// Tuplet compensation. // Tuplet compensation.
match &op.tuplet_compensation { match &op.tuplet_compensation {
TupletCompensation::NotInTuplet => {} TupletCompensation::NotInTuplet => {}
TupletCompensation::ReplaceWithRest { new_rest, duration } => { TupletCompensation::ReplaceWithRest { rest } => {
let rest_obj = TypedObjectId::Event(*new_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.objects.insert(rest_obj, ObjectState::Live);
self.minted_by.insert(rest_obj, env.id); self.minted_by.insert(rest_obj, env.id);
self.note_minted(env, rest_obj); 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 { if let Some((voice, position, _)) = &deleted_placement {
self.voice_occupancy.entry(*voice).or_default().push(( self.voice_occupancy.entry(*voice).or_default().push((
position.clone(), position.clone(),
duration.clone(), rest_duration,
*new_rest, new_rest,
)); ));
} }
repairs.push(RepairRecord { repairs.push(RepairRecord {
@ -1476,15 +1421,15 @@ impl<'a> Reducer<'a> {
match self.last_respell.get(&op.pitch).copied() { match self.last_respell.get(&op.pitch).copied() {
None => { None => {
self.spellings.insert(op.pitch, op.spelling); self.spellings.insert(op.pitch, op.spelling.clone());
self.last_respell.insert(op.pitch, env.id); self.last_respell.insert(op.pitch, env.id);
OperationEffect::Applied OperationEffect::Applied
} }
Some(prev_op) => { 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); let concurrent = self.concurrent(env.id, prev_op);
if concurrent { if concurrent {
if prev_spelling == Some(op.spelling) { if prev_spelling.as_ref() == Some(&op.spelling) {
// Identical concurrent respelling: idempotent. // Identical concurrent respelling: idempotent.
OperationEffect::NoOp { OperationEffect::NoOp {
reason: NoOpReason::AlreadyApplied, reason: NoOpReason::AlreadyApplied,
@ -1495,7 +1440,7 @@ impl<'a> Reducer<'a> {
// earlier op is recorded as the loser. (Prototype // earlier op is recorded as the loser. (Prototype
// convention: the winner carries the Conflicted effect; // convention: the winner carries the Conflicted effect;
// see DECISIONS.md.) // 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); self.last_respell.insert(op.pitch, env.id);
let conflict = ConflictRecord::new( let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision { ConflictKind::StructuralFieldCollision {
@ -1512,7 +1457,7 @@ impl<'a> Reducer<'a> {
} }
} else { } else {
// Causally-ordered re-respell: intentional overwrite. // 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); self.last_respell.insert(op.pitch, env.id);
OperationEffect::Applied OperationEffect::Applied
} }
@ -1525,7 +1470,8 @@ impl<'a> Reducer<'a> {
env: &OperationEnvelope, env: &OperationEnvelope,
op: &CreateCrossCuttingOp, op: &CreateCrossCuttingOp,
) -> OperationEffect { ) -> OperationEffect {
let sid = op.structure.id; let sid = op.structure.id();
let endpoints = op.structure.endpoints();
match self.objects.get(&sid) { match self.objects.get(&sid) {
Some(ObjectState::Live) => { Some(ObjectState::Live) => {
return OperationEffect::NoOp { return OperationEffect::NoOp {
@ -1540,7 +1486,7 @@ impl<'a> Reducer<'a> {
None => {} None => {}
} }
// Endpoints must exist (live). // Endpoints must exist (live).
for e in &op.structure.endpoints { for e in &endpoints {
if !matches!(self.objects.get(e), Some(ObjectState::Live)) { if !matches!(self.objects.get(e), Some(ObjectState::Live)) {
return OperationEffect::NoOp { return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction { reason: NoOpReason::PreconditionFailedUnderReduction {
@ -1557,7 +1503,7 @@ impl<'a> Reducer<'a> {
self.objects.insert(sid, ObjectState::Live); self.objects.insert(sid, ObjectState::Live);
self.minted_by.insert(sid, env.id); self.minted_by.insert(sid, env.id);
self.note_minted(env, sid); self.note_minted(env, sid);
self.structures.insert(sid, op.structure.endpoints.clone()); self.structures.insert(sid, endpoints);
OperationEffect::Applied OperationEffect::Applied
} }
@ -1617,16 +1563,16 @@ impl<'a> Reducer<'a> {
incompatible_events.insert(*event_id); incompatible_events.insert(*event_id);
continue; continue;
}; };
let compatible = match op.new_time_model { let compatible = match &op.new_time_model {
crate::payload::RegionTimeModelTag::Metric => matches!( RegionTimeModel::Metric(_) => matches!(
(event.position(), event.duration()), (event.position(), event.duration()),
(EventPosition::Musical(_), EventDuration::Musical(_)) (EventPosition::Musical(_), EventDuration::Musical(_))
), ),
crate::payload::RegionTimeModelTag::Proportional => matches!( RegionTimeModel::Proportional(_) => matches!(
(event.position(), event.duration()), (event.position(), event.duration()),
(EventPosition::WallClock(_), EventDuration::WallClock(_)) (EventPosition::WallClock(_), EventDuration::WallClock(_))
), ),
crate::payload::RegionTimeModelTag::Aleatoric => true, RegionTimeModel::Aleatoric(_) => true,
}; };
if !compatible { if !compatible {
incompatible_events.insert(*event_id); incompatible_events.insert(*event_id);
@ -1641,10 +1587,7 @@ impl<'a> Reducer<'a> {
.filter(|event| !mapped.contains(event)) .filter(|event| !mapped.contains(event))
.copied(), .copied(),
); );
if matches!( if matches!(op.new_time_model, RegionTimeModel::Proportional(_)) {
op.new_time_model,
crate::payload::RegionTimeModelTag::Proportional
) {
// Reassign carries musical positions in the current // Reassign carries musical positions in the current
// prototype schema, so it cannot satisfy a proportional // prototype schema, so it cannot satisfy a proportional
// region's wall-clock coordinate discipline. // 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 region = &mut score.canvas.regions[region_index];
let wallclock_duration = region region.time_model = op.new_time_model.clone();
.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),
})
}
};
} }
self.migrated_regions.insert(op.region); self.migrated_regions.insert(op.region);
self.region_migrator.insert(op.region, env.id); self.region_migrator.insert(op.region, env.id);
@ -2307,12 +2230,14 @@ mod tests {
payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
// Voice and staff instance live in a shared (author-independent) // Voice and staff instance live in a shared (author-independent)
// namespace so two authors can target the same voice. // namespace so two authors can target the same voice.
voice: VoiceId::new(ReplicaId(9), voice),
staff_instance: StaffInstanceId::new(ReplicaId(9), 0), staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
event: EventId::new(ReplicaId(replica), event), event: crate::valuegen::insert_event_value(
position: pos(pos_units), EventId::new(ReplicaId(replica), event),
duration: epiphany_core::MusicalDuration::whole(), VoiceId::new(ReplicaId(9), voice),
pitches: vec![], pos(pos_units),
epiphany_core::MusicalDuration::whole(),
&[],
),
})), })),
} }
} }
@ -2370,7 +2295,6 @@ mod tests {
use crate::conflict::{ConflictResolutionState, ResolutionAction}; use crate::conflict::{ConflictResolutionState, ResolutionAction};
use crate::payload::{ResolveConflictPayload, RespellPitchOp}; use crate::payload::{ResolveConflictPayload, RespellPitchOp};
use epiphany_core::PitchId; use epiphany_core::PitchId;
use epiphany_determinism::ContentHash;
let pitch = PitchId::new(ReplicaId(9), 500); let pitch = PitchId::new(ReplicaId(9), 500);
@ -2379,7 +2303,13 @@ mod tests {
if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) =
insert_env.payload 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 // Two concurrent, differing respellings of `pitch`, both causally after
@ -2395,7 +2325,7 @@ mod tests {
transaction: None, transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch, pitch,
spelling: ContentHash([byte; 32]), spelling: crate::valuegen::spelling(byte),
})), })),
} }
}; };

View File

@ -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<TransactionId>,
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<u8>),
}
/// 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<PitchId>,
}
/// 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<TupletId>,
},
CascadeDeleteTuplets {
tuplets: Vec<TupletId>,
},
}
/// 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<TypedObjectId>,
}
/// 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<EventId>,
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,
}
}
}

View File

@ -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<EventId>) -> 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,
}
}

View File

@ -19,9 +19,9 @@ use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId, EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
StaffInstanceId, TransactionId, TypedObjectId, VoiceId, WallClockTime, StaffInstanceId, TransactionId, TypedObjectId, VoiceId, WallClockTime,
}; };
use epiphany_determinism::{fuzz::SplitMix64, ContentHash}; use epiphany_determinism::fuzz::SplitMix64;
use epiphany_ops::{ 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, HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect,
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
PendingReason, PreconditionFailureReason, RespellPitchOp, TransactionDescriptor, PendingReason, PreconditionFailureReason, RespellPitchOp, TransactionDescriptor,
@ -69,19 +69,21 @@ fn insert_span(
duration: RationalTime, duration: RationalTime,
) -> OperationPayload { ) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
voice: VoiceId::new(ReplicaId(9), voice),
staff_instance: StaffInstanceId::new(ReplicaId(9), 0), staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
event: EventId::new(ReplicaId(9), event), event: valuegen::insert_event_value(
position: MusicalPosition(position), EventId::new(ReplicaId(9), event),
duration: MusicalDuration(duration), VoiceId::new(ReplicaId(9), voice),
pitches: vec![PitchId::new(ReplicaId(9), event)], MusicalPosition(position),
MusicalDuration(duration),
&[PitchId::new(ReplicaId(9), event)],
),
})) }))
} }
fn respell(pitch: u64, spelling: u8) -> OperationPayload { fn respell(pitch: u64, spelling: u8) -> OperationPayload {
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(ReplicaId(9), pitch), 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!( assert_eq!(
state.spellings.get(&PitchId::new(ReplicaId(9), 100)), state.spellings.get(&PitchId::new(ReplicaId(9), 100)),
Some(&ContentHash([1; 32])) Some(&valuegen::spelling(1))
); );
assert_eq!(state.conflicts.records().len(), 1); assert_eq!(state.conflicts.records().len(), 1);
assert!(matches!( assert!(matches!(

View File

@ -8,10 +8,10 @@ use epiphany_core::{
WallClockTime, WallClockTime,
}; };
use epiphany_ops::{ use epiphany_ops::{
AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp, valuegen, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp,
CrossCuttingRef, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason, OperationEffect, CrossCuttingValue, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason,
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
PositionRemapping, PreconditionFailureReason, RegionTimeModelTag, SetUserSystemBreakOp, OperationStamp, PositionRemapping, PreconditionFailureReason, SetUserSystemBreakOp,
TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy, TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy,
UndoTransactionPayload, UndoTransactionPayload,
}; };
@ -48,12 +48,14 @@ fn insert(
position: i32, position: i32,
) -> OperationPayload { ) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
voice,
staff_instance, staff_instance,
event: valuegen::insert_event_value(
event, event,
position: MusicalPosition(RationalTime::from_int(position)), voice,
duration: MusicalDuration::whole(), MusicalPosition(RationalTime::from_int(position)),
pitches: vec![pitch], MusicalDuration::whole(),
&[pitch],
),
})) }))
} }
@ -352,7 +354,7 @@ fn system_break_lww_state_is_materialized_in_the_region() {
None, None,
OperationPayload::Primitive(OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { OperationPayload::Primitive(OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
region, region,
anchor: position.clone(), anchor: valuegen::region_start_anchor(region, position.clone()),
present: true, present: true,
})), })),
); );
@ -390,7 +392,7 @@ fn migration_computes_incompatible_events_from_the_graph() {
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp { ChangeRegionTimeModelOp {
region, region,
new_time_model: RegionTimeModelTag::Proportional, new_time_model: valuegen::proportional_model(),
declared_incompatible: Vec::new(), declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime, remapping: PositionRemapping::PreserveTime,
}, },
@ -422,14 +424,7 @@ fn create_cross_cutting_materializes_supported_graph_structures() {
CausalContext::new(), CausalContext::new(),
None, None,
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp { OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingRef { structure: CrossCuttingValue::Slur(valuegen::slur(slur, endpoints[0], endpoints[1])),
id: TypedObjectId::Slur(slur),
endpoints: endpoints
.iter()
.copied()
.map(TypedObjectId::Event)
.collect(),
},
})), })),
); );
let mut set = OperationSet::new(); let mut set = OperationSet::new();
@ -459,7 +454,7 @@ fn causally_ordered_time_migrations_do_not_conflict() {
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp { ChangeRegionTimeModelOp {
region, region,
new_time_model: RegionTimeModelTag::Aleatoric, new_time_model: valuegen::aleatoric_model(),
declared_incompatible: Vec::new(), declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime, remapping: PositionRemapping::PreserveTime,
}, },
@ -474,7 +469,7 @@ fn causally_ordered_time_migrations_do_not_conflict() {
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel( OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
ChangeRegionTimeModelOp { ChangeRegionTimeModelOp {
region, region,
new_time_model: RegionTimeModelTag::Metric, new_time_model: valuegen::metric_model(),
declared_incompatible: Vec::new(), declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime, remapping: PositionRemapping::PreserveTime,
}, },

View File

@ -297,10 +297,9 @@ pub fn materialized_score(seed: u64) -> (Score, Vec<u8>) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use epiphany_core::{PitchId, ReplicaId, WallClockTime}; use epiphany_core::{PitchId, PitchSpelling, ReplicaId, WallClockTime};
use epiphany_determinism::ContentHash;
use epiphany_ops::{ use epiphany_ops::{
AuthorId, CausalContext, HybridLogicalClock, OperationKind, OperationPayload, valuegen, AuthorId, CausalContext, HybridLogicalClock, OperationKind, OperationPayload,
OperationStamp, RespellPitchOp, OperationStamp, RespellPitchOp,
}; };
use std::collections::BTreeMap; use std::collections::BTreeMap;
@ -354,21 +353,21 @@ mod tests {
transaction: None, transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(ReplicaId(0x0B7E_C700), 0), 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 /// A deliberately broken, order-*dependent* reducer: last spelling wins by
/// arrival order. Stands in for the bug the harness must catch. /// arrival order. Stands in for the bug the harness must catch.
fn naive_arrival_order_spelling(envs: &[OperationEnvelope]) -> Option<[u8; 32]> { fn naive_arrival_order_spelling(envs: &[OperationEnvelope]) -> Option<PitchSpelling> {
let mut last: BTreeMap<PitchId, [u8; 32]> = BTreeMap::new(); let mut last: BTreeMap<PitchId, PitchSpelling> = BTreeMap::new();
for e in envs { for e in envs {
if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &e.payload { 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] #[test]

View File

@ -20,9 +20,8 @@ use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId, EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
StaffInstanceId, VoiceId, WallClockTime, StaffInstanceId, VoiceId, WallClockTime,
}; };
use epiphany_determinism::ContentHash;
use epiphany_ops::{ use epiphany_ops::{
AuthorId, CausalContext, HybridLogicalClock, InsertEventOp, MaterializedState, valuegen, AuthorId, CausalContext, HybridLogicalClock, InsertEventOp, MaterializedState,
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationSlot, OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationSlot,
OperationStamp, RespellPitchOp, OperationStamp, RespellPitchOp,
}; };
@ -150,7 +149,7 @@ fn respell_env(id: OperationId, phys: i64, pitch: u64, spelling: u8) -> Operatio
transaction: None, transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(OBJ_REPLICA, pitch), 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(), causal_context: CausalContext::new(),
transaction: None, transaction: None,
payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
voice: VoiceId::new(OBJ_REPLICA, 0),
staff_instance: StaffInstanceId::new(OBJ_REPLICA, 0), staff_instance: StaffInstanceId::new(OBJ_REPLICA, 0),
event: EventId::new(OBJ_REPLICA, event), event: valuegen::insert_event_value(
position: MusicalPosition(RationalTime::from_int(0)), EventId::new(OBJ_REPLICA, event),
duration: MusicalDuration::whole(), VoiceId::new(OBJ_REPLICA, 0),
pitches: vec![PitchId::new(OBJ_REPLICA, pitch)], MusicalPosition(RationalTime::from_int(0)),
MusicalDuration::whole(),
&[PitchId::new(OBJ_REPLICA, pitch)],
),
})), })),
} }
} }

View File

@ -28,20 +28,21 @@ use epiphany_determinism::{
CanonicalEncode, CanonicalF64, ChunkId, ContentHash, DomainTag, QuantizedCoord, Tolerance, CanonicalEncode, CanonicalF64, ChunkId, ContentHash, DomainTag, QuantizedCoord, Tolerance,
ToleranceClass, ToleranceGovernance, ToleranceClass, ToleranceGovernance,
}; };
use epiphany_ops::valuegen;
use epiphany_ops::{ use epiphany_ops::{
AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId, AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId,
ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingRef, DeleteEventOp, ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, IntegrityAnomaly, ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, IntegrityAnomaly,
IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, NoOpReason, ObjectKind, IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, NoOpReason, ObjectKind,
ObjectState, OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, ObjectState, OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId,
OperationPayload, OperationSet, OperationStamp, PendingReason, PositionRemapping, OperationPayload, OperationSet, OperationStamp, PendingReason, PositionRemapping,
PreconditionFailureReason, PreconditionFailureRegistryId, ReanchorReason, PreconditionFailureReason, PreconditionFailureRegistryId, ReanchorReason,
ReanchorReasonRegistryId, ReanchorResult, RegionTimeModelTag, RepairKind, RepairKindRegistryId, ReanchorReasonRegistryId, ReanchorResult, RepairKind, RepairKindRegistryId, RepairRecord,
RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId,
ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, SetUserSystemBreakOp,
SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TupletCompensation, TransactionCategory, TransactionDescriptor, TupletCompensation, TupletCompensationKind,
TupletCompensationKind, UndoPolicy, UndoTransactionPayload, UndoPolicy, UndoTransactionPayload,
}; };
use crate::rng::Rng; 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) { let kind = match rng.below(8) {
0 => OperationKind::InsertEvent(InsertEventOp { 0 => {
voice: VoiceId::new(OBJ_REPLICA, rng.below(4)), let pitches = if rng.boolean() {
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() {
vec![obj_pitch(rng.below(pitches))] vec![obj_pitch(rng.below(pitches))]
} else { } else {
vec![] 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 { 1 => OperationKind::DeleteEvent(DeleteEventOp {
event: obj_event(rng.below(events)), event: obj_event(rng.below(events)),
tuplet_compensation: TupletCompensation::NotInTuplet, tuplet_compensation: TupletCompensation::NotInTuplet,
}), }),
2 => OperationKind::RespellPitch(RespellPitchOp { 2 => OperationKind::RespellPitch(RespellPitchOp {
pitch: obj_pitch(rng.below(pitches)), 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 { 3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingRef { structure: CrossCuttingValue::Slur(valuegen::slur(
id: TypedObjectId::Slur(SlurId::new(OBJ_REPLICA, rng.below(events))), SlurId::new(OBJ_REPLICA, rng.below(events)),
endpoints: vec![ obj_event(rng.below(events)),
TypedObjectId::Event(obj_event(rng.below(events))), obj_event(rng.below(events)),
TypedObjectId::Event(obj_event(rng.below(events))), )),
],
},
}), }),
4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp { 4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
region: RegionId::new(OBJ_REPLICA, 0), 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(), present: rng.boolean(),
}), }),
5 => OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp { 5 => OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp {
region: RegionId::new(OBJ_REPLICA, rng.below(2)), region: RegionId::new(OBJ_REPLICA, rng.below(2)),
new_time_model: *rng.choose(&[ new_time_model: match rng.below(3) {
RegionTimeModelTag::Metric, 0 => valuegen::metric_model(),
RegionTimeModelTag::Proportional, 1 => valuegen::proportional_model(),
RegionTimeModelTag::Aleatoric, _ => valuegen::aleatoric_model(),
]), },
declared_incompatible: Vec::new(), declared_incompatible: Vec::new(),
remapping: PositionRemapping::PreserveTime, 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). /// whole-notes, i.e. two per 4/4 bar).
fn insert_at(instance: u64, voice: u64, event: u64, pitch: u64, index: u64) -> OperationPayload { fn insert_at(instance: u64, voice: u64, event: u64, pitch: u64, index: u64) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
voice: VoiceId::new(OBJ_REPLICA, voice),
staff_instance: StaffInstanceId::new(OBJ_REPLICA, instance), staff_instance: StaffInstanceId::new(OBJ_REPLICA, instance),
event: obj_event(event), event: valuegen::insert_event_value(
position: MusicalPosition(RationalTime::new(index as i64, EVENTS_PER_BAR as i64).unwrap()), obj_event(event),
duration: MusicalDuration(RationalTime::new(1, EVENTS_PER_BAR as i64).unwrap()), VoiceId::new(OBJ_REPLICA, voice),
pitches: vec![obj_pitch(pitch)], 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<OperationEnvelope> {
} else { } else {
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: obj_pitch(rng.below(total)), 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); session.author(rng, r, payload);
@ -953,19 +962,21 @@ fn insert_into(
index: u64, index: u64,
) -> OperationPayload { ) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
voice,
staff_instance, staff_instance,
event: obj_event(event),
// position = GRAPH_SESSION_OFFSET + index/2 (two half-notes per 4/4 bar). // position = GRAPH_SESSION_OFFSET + index/2 (two half-notes per 4/4 bar).
position: MusicalPosition( event: valuegen::insert_event_value(
obj_event(event),
voice,
MusicalPosition(
RationalTime::new( RationalTime::new(
GRAPH_SESSION_OFFSET * EVENTS_PER_BAR as i64 + index as i64, GRAPH_SESSION_OFFSET * EVENTS_PER_BAR as i64 + index as i64,
2, 2,
) )
.unwrap(), .unwrap(),
), ),
duration: MusicalDuration(RationalTime::new(1, 2).unwrap()), MusicalDuration(RationalTime::new(1, 2).unwrap()),
pitches: vec![obj_pitch(pitch)], &[obj_pitch(pitch)],
),
})) }))
} }
@ -1022,7 +1033,7 @@ pub fn graph_edit_session(
} else { } else {
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: obj_pitch(rng.below(total)), 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); session.author(rng, r, payload);
@ -1047,14 +1058,14 @@ pub fn content_mutation_pair() -> (Vec<OperationEnvelope>, Vec<OperationEnvelope
0, 0,
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: obj_pitch(0), pitch: obj_pitch(0),
spelling: ContentHash([0xAA; 32]), spelling: valuegen::spelling(0xAA),
})), })),
); );
let base = session.out; let base = session.out;
let mut mutated = base.clone(); let mut mutated = base.clone();
if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &mut mutated[1].payload { if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &mut mutated[1].payload {
op.spelling = ContentHash([0xBB; 32]); op.spelling = valuegen::spelling(0xBB);
} else { } else {
unreachable!("the second op is the respelling"); unreachable!("the second op is the respelling");
} }
@ -1109,12 +1120,12 @@ pub fn equivocating_twin(env: &OperationEnvelope) -> OperationEnvelope {
let mut twin = env.clone(); let mut twin = env.clone();
twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: obj_pitch(0), pitch: obj_pitch(0),
spelling: ContentHash([0xEE; 32]), spelling: valuegen::spelling(0xEE),
})); }));
if twin.envelope_hash() == env.envelope_hash() { if twin.envelope_hash() == env.envelope_hash() {
twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: obj_pitch(1), pitch: obj_pitch(1),
spelling: ContentHash([0x11; 32]), spelling: valuegen::spelling(0x11),
})); }));
} }
twin twin
@ -1460,7 +1471,7 @@ mod tests {
.insert(typed_object_id(&mut rng), object_state(&mut rng)); .insert(typed_object_id(&mut rng), object_state(&mut rng));
state state
.spellings .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( state.breaks.insert(
(region_id(&mut rng), musical_position(&mut rng)), (region_id(&mut rng), musical_position(&mut rng)),
rng.boolean(), rng.boolean(),

View File

@ -105,6 +105,7 @@ pub mod prepass_harness;
pub mod convergence; pub mod convergence;
pub mod equivocation; pub mod equivocation;
pub mod migration;
pub mod negative; pub mod negative;
pub mod bundle_harness; pub mod bundle_harness;

View File

@ -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<u8> {
let mut set = OperationSet::new();
set.accept_all(envs.iter().cloned());
set.reduce().canonical_bytes()
}
fn migrate_all(v1: &[OperationEnvelope], ctx: &Score) -> Vec<OperationEnvelope> {
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"
);
}

View File

@ -30,9 +30,8 @@ use epiphany_core::{
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId, EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
StaffInstanceId, TransactionId, VoiceId, WallClockTime, StaffInstanceId, TransactionId, VoiceId, WallClockTime,
}; };
use epiphany_determinism::ContentHash;
use epiphany_ops::{ use epiphany_ops::{
canonical_reduction_order, AuthorId, CausalContext, ConflictKind, DeleteEventOp, canonical_reduction_order, valuegen, AuthorId, CausalContext, ConflictKind, DeleteEventOp,
HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect, HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect,
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp, OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
PendingReason, PreconditionFailureReason, RepairKind, RespellPitchOp, TransactionDescriptor, PendingReason, PreconditionFailureReason, RepairKind, RespellPitchOp, TransactionDescriptor,
@ -72,12 +71,14 @@ fn insert_span(
duration: RationalTime, duration: RationalTime,
) -> OperationPayload { ) -> OperationPayload {
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp { OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
voice: VoiceId::new(OBJ, voice),
staff_instance: StaffInstanceId::new(OBJ, 0), staff_instance: StaffInstanceId::new(OBJ, 0),
event: EventId::new(OBJ, event), event: valuegen::insert_event_value(
position: MusicalPosition(position), EventId::new(OBJ, event),
duration: MusicalDuration(duration), VoiceId::new(OBJ, voice),
pitches: vec![PitchId::new(OBJ, event)], 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 { fn respell(pitch: u64, spelling: u8) -> OperationPayload {
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp { OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: PitchId::new(OBJ, pitch), 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!( assert_eq!(
state.spellings.get(&PitchId::new(OBJ, 100)), state.spellings.get(&PitchId::new(OBJ, 100)),
Some(&ContentHash([1; 32])), Some(&valuegen::spelling(1)),
"the pre-transaction spelling must survive the rollback" "the pre-transaction spelling must survive the rollback"
); );
assert_eq!( assert_eq!(

View File

@ -9,8 +9,8 @@
//! module (Agent E has landed). See the crate docs for the harness policy. //! module (Agent E has landed). See the crate docs for the harness policy.
use epiphany_testkit::{ use epiphany_testkit::{
bundle_harness, convergence, equivocation, fixtures, generators, layout_stub, negative, bundle_harness, convergence, equivocation, fixtures, generators, layout_stub, migration,
roundtrip, Rng, negative, roundtrip, Rng,
}; };
/// Criterion 1 — **Convergence (real Score).** Overlapping edits to a real /// Criterion 1 — **Convergence (real Score).** Overlapping edits to a real
@ -228,3 +228,18 @@ fn manifest_selection_harness() {
bundle_harness::run_manifest_selection(seed); 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));
}
}

View File

@ -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-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-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-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 ## Not yet open elsewhere
Agent I (Track A) has contributed P12-I1..I3 above. Track B (K, J) has not yet Agent I (Track A) has contributed P12-I1..I3 above. Track B's Agent K has
contributed items. When they do, append rows; the batch is already open, so they contributed P12-K1 (Operation Catalog M1). Agent J (Binary Format companion) has
join directly (no new threshold). not yet contributed; when it does, append rows — the batch is already open, so it
joins directly (no new threshold).

BIN
spec/operation_catalog.pdf Normal file

Binary file not shown.

686
spec/operation_catalog.tex Normal file
View File

@ -0,0 +1,686 @@
% !TEX program = xelatex
%
% Epiphany --- Operation Catalog (companion specification)
% Companion to the Core Specification. Compile with XeLaTeX.
%
% This document is versioned independently of the Core Specification
% (independent semver; see the Versioning note in the front matter). Its preamble
% is intentionally a self-contained copy of the core specification's preamble so
% the two documents build independently; factoring a shared preamble file is a
% later cleanup, not a v0.1 deliverable.
\documentclass[11pt,letterpaper]{report}
% ---------------------------------------------------------------------------
% Packages
% ---------------------------------------------------------------------------
\usepackage{fontspec}
\usepackage{geometry}
\geometry{
letterpaper,
top=1.05in,
bottom=1.05in,
left=1.15in,
right=1.15in,
headheight=15pt
}
\usepackage[english]{babel}
\usepackage{microtype}
\usepackage{parskip}
\usepackage{xcolor}
\usepackage{hyperref}
\usepackage{enumitem}
\usepackage{titlesec}
\usepackage{fancyhdr}
\usepackage{booktabs}
\usepackage{array}
\usepackage{longtable}
\usepackage{listings}
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{tcolorbox}
\tcbuselibrary{breakable, skins}
% ---------------------------------------------------------------------------
% Color palette (shared with the core specification)
% ---------------------------------------------------------------------------
\definecolor{epiphanyteal}{HTML}{1A4044}
\definecolor{epiphanygold}{HTML}{8E6E2E}
\definecolor{epiphanyink}{HTML}{1F1B16}
\definecolor{epiphanyslate}{HTML}{6B6660}
\definecolor{epiphanycream}{HTML}{F8F4ED}
\definecolor{epiphanymist}{HTML}{ECE8E0}
\definecolor{epiphanycode}{HTML}{2A2520}
\definecolor{epiphanycrimson}{HTML}{7A2424}
\hypersetup{
colorlinks=true,
linkcolor=epiphanyteal,
citecolor=epiphanyteal,
urlcolor=epiphanygold,
pdftitle={Epiphany --- Operation Catalog},
pdfauthor={The Epiphany Project},
pdfsubject={Operation Catalog companion for the Epiphany music notation platform},
pdfkeywords={music notation, operations, CRDT, reduction, serialization},
bookmarksnumbered=true,
bookmarksopen=true
}
% ---------------------------------------------------------------------------
% Typography (shared with the core specification)
% ---------------------------------------------------------------------------
\setmainfont{TeX Gyre Pagella}[Numbers={OldStyle, Proportional}, Ligatures={TeX, Common}]
\setsansfont{TeX Gyre Heros}[Scale=0.94, Ligatures={TeX, Common}]
\setmonofont{TeX Gyre Cursor}[Scale=0.88, Ligatures={TeX}]
\newfontfamily\titlefont{TeX Gyre Pagella}[Numbers={OldStyle}, Ligatures={TeX, Common}]
\newcommand{\tablenums}[1]{{\addfontfeatures{Numbers={Lining,Tabular}}#1}}
\newcommand{\sectionsc}[1]{{\addfontfeatures{Letters=SmallCaps}#1}}
% ---------------------------------------------------------------------------
% Section styling (shared with the core specification)
% ---------------------------------------------------------------------------
\titleformat{\chapter}[display]
{\normalfont\filright}
{\raggedright\color{epiphanygold}\fontsize{14pt}{16pt}\selectfont
\scshape Chapter\ \thechapter}
{16pt}
{\raggedright\color{epiphanyteal}\fontsize{32pt}{36pt}\selectfont\bfseries}
[\vspace{4pt}{\color{epiphanygold}\rule{2in}{0.6pt}}]
\titlespacing*{\chapter}{0pt}{-20pt}{30pt}
\titleformat{\section}
{\normalfont\Large\bfseries\color{epiphanyteal}}
{\color{epiphanygold}\thesection}{1em}{}
\titleformat{\subsection}
{\normalfont\large\bfseries\color{epiphanyteal}}
{\color{epiphanygold}\thesubsection}{1em}{}
\titleformat{\subsubsection}
{\normalfont\normalsize\bfseries\color{epiphanyink}}
{\thesubsubsection}{1em}{}
% ---------------------------------------------------------------------------
% Headers and footers (shared with the core specification)
% ---------------------------------------------------------------------------
\pagestyle{fancy}
\fancyhf{}
\renewcommand{\headrulewidth}{0pt}
\renewcommand{\footrulewidth}{0pt}
\fancyhead[L]{\small\scshape\color{epiphanyslate}Epiphany --- Operation Catalog}
\fancyhead[R]{\small\itshape\color{epiphanyslate}\leftmark}
\fancyfoot[C]{\small\color{epiphanyslate}\thepage}
\renewcommand{\headrule}{
\color{epiphanygold!50}\hrule width\headwidth height 0.4pt
\vspace{1pt}
\color{epiphanygold!30}\hrule width\headwidth height 0.2pt
}
% ---------------------------------------------------------------------------
% Code listing style (shared with the core specification)
% ---------------------------------------------------------------------------
\lstdefinelanguage{Rust}{
keywords={fn,let,mut,pub,struct,enum,impl,trait,for,in,if,else,match,return,
use,mod,crate,self,Self,as,where,move,async,await,const,static,
ref,type,unsafe,extern,dyn,box,break,continue,loop,while},
keywordstyle=\color{epiphanyteal}\bfseries,
ndkeywords={i8,i16,i32,i64,i128,u8,u16,u32,u64,u128,f32,f64,bool,char,str,
String,Vec,Option,Result,Box,Rc,Arc,HashMap,BTreeMap,
NonZeroU16,NonZeroU32,NonZeroU64,Duration,Timestamp},
ndkeywordstyle=\color{epiphanygold}\bfseries,
sensitive=true,
comment=[l]{//},
morecomment=[s]{/*}{*/},
commentstyle=\color{epiphanyslate}\itshape,
stringstyle=\color{epiphanycrimson},
morestring=[b]",
morestring=[b]'
}
\lstset{
basicstyle=\ttfamily\small\color{epiphanycode},
backgroundcolor=\color{epiphanycream},
frame=leftline,
rulecolor=\color{epiphanygold!60},
framesep=8pt,
framerule=1.5pt,
xleftmargin=10pt,
xrightmargin=4pt,
breaklines=true,
showstringspaces=false,
numberstyle=\tiny\color{epiphanyslate},
numbersep=10pt,
captionpos=b,
aboveskip=10pt,
belowskip=10pt,
language=Rust
}
% ---------------------------------------------------------------------------
% Custom environments (shared with the core specification)
% ---------------------------------------------------------------------------
\newtcolorbox{openquestion}[1][]{
enhanced, breakable,
colback=epiphanymist, colframe=epiphanycrimson,
fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Open Question},
coltitle=white, colbacktitle=epiphanycrimson,
arc=1pt, boxrule=0pt, leftrule=2pt,
left=10pt, right=10pt, top=8pt, bottom=8pt,
attach boxed title to top left={xshift=0pt, yshift=0pt},
boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt},
#1
}
\newtcolorbox{rationale}[1][]{
enhanced, breakable,
colback=epiphanymist, colframe=epiphanyteal,
fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Rationale},
coltitle=white, colbacktitle=epiphanyteal,
arc=1pt, boxrule=0pt, leftrule=2pt,
left=10pt, right=10pt, top=8pt, bottom=8pt,
attach boxed title to top left={xshift=0pt, yshift=0pt},
boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt},
#1
}
\newtcolorbox{requirement}[1][]{
enhanced, breakable,
colback=white, colframe=epiphanygold,
fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Requirement},
coltitle=white, colbacktitle=epiphanygold,
arc=1pt, boxrule=0pt, leftrule=2pt,
left=10pt, right=10pt, top=8pt, bottom=8pt,
attach boxed title to top left={xshift=0pt, yshift=0pt},
boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt},
#1
}
\newtcolorbox{nongoal}[1][]{
enhanced, breakable,
colback=epiphanymist, colframe=epiphanyslate,
fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Non-Goal},
coltitle=white, colbacktitle=epiphanyslate,
arc=1pt, boxrule=0pt, leftrule=2pt,
left=10pt, right=10pt, top=8pt, bottom=8pt,
attach boxed title to top left={xshift=0pt, yshift=0pt},
boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt},
#1
}
\newcommand{\MUST}{\textbf{MUST}}
\newcommand{\MUSTNOT}{\textbf{MUST}\nobreak\ \textbf{NOT}}
\newcommand{\SHOULD}{\textbf{SHOULD}}
\newcommand{\SHOULDNOT}{\textbf{SHOULD}\nobreak\ \textbf{NOT}}
\newcommand{\MAY}{\textbf{MAY}}
\setlist[itemize]{topsep=2pt, itemsep=3pt, parsep=0pt}
\setlist[enumerate]{topsep=2pt, itemsep=3pt, parsep=0pt}
\setlist[description]{topsep=2pt, itemsep=5pt, parsep=0pt}
\AtBeginDocument{\color{epiphanyink}}
% ---------------------------------------------------------------------------
% Document
% ---------------------------------------------------------------------------
\begin{document}
\begin{titlepage}
\thispagestyle{empty}
\centering
\vspace*{2.2in}
{\color{epiphanygold}\rule{3in}{0.8pt}}\\[18pt]
{\titlefont\fontsize{34pt}{38pt}\selectfont\color{epiphanyteal}\bfseries Epiphany}\\[10pt]
{\Large\scshape\color{epiphanyslate}Operation Catalog}\\[6pt]
{\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt]
{\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt]
{\normalsize\color{epiphanyink}Version 0.1.0 --- Phase 2 (K0 representative primitives)}\\[4pt]
{\small\color{epiphanyslate}Normative for the operation kinds it defines}
\vfill
\end{titlepage}
\tableofcontents
% ===========================================================================
\chapter{About This Companion}
\label{ch:about}
The \emph{Operation Catalog} is a companion to the Epiphany Core Specification.
It fulfils the open question the core specification raises in its
\emph{Operation Catalog Conformance} section (Chapter~6,
\sectionsc{Semantic Operations and Concurrent Reduction}, the
\texttt{sec:semops:catalog} open question), which states that the catalog
``is normative once published; this specification is non-final until the catalog
is delivered.'' This v0.1 release delivers the catalog \emph{framework} and the
\textbf{K0 representative primitive set} --- the operation kinds the Phase~2
visible slice and binary format actually exercise. The remaining K0 and the full
$60$--$80$-primitive catalog are drafted as framework slots
(Chapter~\ref{ch:k1}) and completed in Phase~3.
\section{Relationship to the Core Specification}
This companion does not restate the operation framework; it \emph{references} it.
The framework --- operation identity and stamps, the hybrid-logical-clock
monotonicity rule, the dotted-version-vector causal context, the
order-independent operation-slot model and equivocation, the canonical reduction
order, the four-phase lifecycle, conflict records and the conflict registry,
re-anchoring, transactions, forward undo, and the LWW discipline --- is the core
specification's Chapter~6. This companion defines, for each operation kind, the
\emph{payload schema} and how that kind \emph{instantiates} the framework's
reduction, conflict, undo, and re-anchoring rules.
It also consumes, rather than re-deriving, the \textbf{ratified byte-convention
baseline} (core specification Chapter~8, \sectionsc{Binary Format Companion},
requirement \texttt{req:format:codec-conventions}, and the
\sectionsc{Canonical Byte-Layout Reference} appendix): little-endian integers, a
single discriminant byte per tagged union, \texttt{u32} length prefixes on every
variable-width leaf, and raw UTF-8 free text. Operation-payload encodings are
expressed in terms of that baseline; the literal wire layout of each payload is
the Binary Format companion's (Agent~J's) to pin, in coordination with this
catalog.
\begin{rationale}
The catalog is versioned \emph{independently} of the core specification
(independent semver). Operation kinds are added over time; the catalog should
evolve --- adding primitives, refining conflict cases --- without forcing a core
specification revision. The core specification changes only when the
\emph{framework} changes.
\end{rationale}
\section{Conformance Profiles}
A \textbf{Phase-2 profile} implementation \MUST{} implement every primitive in
Chapter~\ref{ch:k0} with the schema, reduction rule, conflict cases, undo
semantics, and re-anchoring behaviour defined there. The K1 primitives of
Chapter~\ref{ch:k1} are \emph{unavailable} under the Phase-2 profile: an
implementation \MUST{} reject (not silently ignore) an operation whose kind is a
K1 primitive it does not implement.
% ===========================================================================
\chapter{The Catalog Framework}
\label{ch:framework}
\section{Value-Typed Payloads}
\label{sec:framework:value-typed}
Every operation payload in this catalog is \textbf{value-typed}: it carries the
real graph values its effect introduces, not identifiers that point at values in
some ambient graph. An \texttt{InsertEvent} carries the whole \texttt{Event}; a
\texttt{RespellPitch} carries the whole \texttt{PitchSpelling}; a
\texttt{CreateCrossCutting} carries the whole tie, slur, beam, or spanner.
\begin{rationale}
The v0 prototype carried \emph{identifier-only projections} --- an
\texttt{InsertEvent} held an \texttt{EventId} plus reduction-relevant scalars; a
\texttt{RespellPitch} held a content-hash \emph{fingerprint} of the new spelling.
That was sufficient to make an envelope hashable and to drive reduction, but it
is \textbf{not durable}: an operation replayed in a fresh context --- a backup
restore, a cross-tool round-trip --- needs the full value, which an
identifier-only payload cannot supply. Value-typed payloads are what make an
Epiphany document portable.
\end{rationale}
\begin{requirement}
\label{req:catalog:value-encoding}
A value-typed payload field \MUST{} be encoded by emitting the field's value
under the core specification's canonical value encoding
(\texttt{req:format:codec-conventions}), framed by a \texttt{u32} little-endian
length prefix. Decoding \MUST{} be the exact inverse and \MUST{} reject trailing
bytes within the framed region. The encoding introduces no byte layout beyond the
ratified baseline: a value's bytes here are byte-for-byte the bytes the whole
document codec emits for that value.
\end{requirement}
\section{Per-Primitive Schema Template}
\label{sec:framework:template}
Each catalog primitive is specified under a fixed six-part template. A new
primitive is a schema-fill against this template, not a fresh design:
\begin{description}
\item[Payload schema] The value-typed fields the operation carries, with their
core-specification types.
\item[Canonical encoding] The field order and framing, consuming
Requirement~\ref{req:catalog:value-encoding}. (The literal byte layout is the
Binary Format companion's; this catalog fixes the \emph{field set and order}.)
\item[Reduction rule] How the operation mutates canonical state when it is
reached in canonical reduction order --- the preconditions it checks, the
objects it mints or tombstones, and the bookkeeping it records.
\item[Conflict cases] The conflict records the operation can produce, by
\texttt{ConflictKind}, and which participant materialises.
\item[Undo semantics] The compensating effect of undoing a transaction that
contains the operation, under each \texttt{UndoPolicy}
(\texttt{StrictInverse} / \texttt{BestEffort} / \texttt{Cascade}).
\item[Re-anchoring] The behaviour when an object the operation references is
tombstoned before or concurrently with it.
\end{description}
\section{Reduction-Discipline Coverage}
The K0 representative set is chosen so that, between them, the primitives
exercise \emph{every} reduction discipline the framework defines:
position-keyed insert with system-voice promotion; delete-wins with tombstones,
tuplet compensation, and cross-cutting re-anchoring; field-overwrite with
last-writer-wins and structural-field-collision conflicts; set-union creation;
structural time-model migration; an LWW advisory; atomic transactions with
descriptor precedence; and the two meta-operations (conflict resolution and
forward undo). A primitive added later that reuses one of these disciplines
inherits its reduction, conflict, undo, and re-anchoring treatment.
% ===========================================================================
\chapter{K0 --- Representative Primitives}
\label{ch:k0}
This chapter is normative under the Phase-2 profile. Each primitive's reduction,
conflict, undo, and re-anchoring behaviour is the behaviour the core
specification's Chapter~6 defines for its discipline; the description here states
how the primitive instantiates it. The reference implementation is
\texttt{epiphany-ops} (the \texttt{payload}, \texttt{reduce}, and \texttt{migrate}
modules).
\section{InsertEvent}
\label{sec:k0:insert-event}
\textbf{Payload schema.} \texttt{InsertEventOp \{ staff\_instance:
StaffInstanceId, event: Event \}}. The voice, region-local position, duration,
and pitch identities are read from the \texttt{Event} value; the
\texttt{staff\_instance} is retained alongside it so the system-voice promotion
derivation is total without a containment walk.
\textbf{Canonical encoding.} \texttt{staff\_instance}, then the length-framed
canonical bytes of \texttt{event}.
\textbf{Reduction rule.} A position-keyed insert. Preconditions: the event's
duration is positive; the event id is neither live nor tombstoned; in graph-aware
reduction the target voice exists in a metric region and the event's pitch ids
are fresh. The event and its pitches are minted live; the voice is created on
first use. Concurrent inserts whose half-open duration intervals overlap in the
same voice are resolved by an order-independent promotion pre-pass: the
lower-\texttt{OperationId} insert is retained in the original voice, and each
overlapping loser is promoted to the deterministic system voice
\texttt{derive\_promoted\_voice\_id(staff\_instance, voice, winner, loser)} and
tagged \texttt{VoicePromoted}.
\textbf{Conflict cases.} None at reduction time; promotion is a deterministic
repair, not a conflict.
\textbf{Undo semantics.} Undoing the enclosing transaction tombstones the minted
event, its pitches, and any promoted voice. \texttt{StrictInverse} conflicts if
any was already tombstoned or modified; \texttt{BestEffort} tombstones the
survivors; \texttt{Cascade} is \texttt{StrictInverse} over the same minted set
(dependent-closure undo is a Phase-3 refinement).
\textbf{Re-anchoring.} Not applicable (the operation mints, it does not
reference a pre-existing object that could be tombstoned).
\section{DeleteEvent}
\label{sec:k0:delete-event}
\textbf{Payload schema.} \texttt{DeleteEventOp \{ event: EventId,
tuplet\_compensation: TupletCompensation \}}, where \texttt{TupletCompensation}
is \texttt{NotInTuplet}, \texttt{ReplaceWithRest \{ rest: Rest \}} (value-typed
replacement rest), \texttt{RewriteTuplets \{ tuplets \}}, or
\texttt{CascadeDeleteTuplets \{ tuplets \}}.
\textbf{Canonical encoding.} \texttt{event}, then the tuplet-compensation
discriminant and its payload (a length-framed \texttt{Rest} value for
\texttt{ReplaceWithRest}).
\textbf{Reduction rule.} Delete-wins: the event and its contained pitches are
tombstoned (retaining their identifiers). Tuplet compensation, when present, adds
the replacement rest live or tombstones the cascaded tuplet group. Concurrent
deletes of the same event are idempotent.
\textbf{Conflict cases.} None for the delete itself. Graph-aware reduction
refuses an ill-formed tuplet compensation as a precondition failure (no-op).
\textbf{Undo semantics.} An insert-shaped compensation re-introduces the
tombstoned content; for the prototype's minted-object model this is the inverse
of the tombstone set, with the policy treatment described under InsertEvent.
\textbf{Re-anchoring.} Tombstoning the event runs the framework's re-anchoring
rule table over every cross-cutting structure that referenced it: a tie
cascade-deletes; a comment or analytical annotation orphans (user content is
never silently deleted); a beam truncates while $\geq 2$ members survive and
otherwise cascade-deletes; a slur or spanner re-anchors to the nearest surviving
endpoint while $\geq 1$ survives and otherwise cascade-deletes.
\section{RespellPitch}
\label{sec:k0:respell-pitch}
\textbf{Payload schema.} \texttt{RespellPitchOp \{ pitch: PitchId, spelling:
PitchSpelling \}} --- the full spelling value (v1), not a fingerprint.
\textbf{Canonical encoding.} \texttt{pitch}, then the length-framed canonical
bytes of \texttt{spelling}.
\textbf{Reduction rule.} A last-writer-wins field overwrite, keyed by pitch.
Precondition: the pitch is live. The resolved spelling is the one carried by the
operation latest in canonical order. Two respellings of one pitch that are
causally ordered overwrite intentionally; two \emph{concurrent} respellings with
\emph{equal} spelling reduce idempotently.
\textbf{Conflict cases.} Two concurrent respellings of one pitch with
\emph{differing} spellings produce a \texttt{StructuralFieldCollision} conflict
recording the winner (later in canonical order), the loser, and the field
\texttt{spelling}. The winner materialises and carries the \texttt{Conflicted}
effect tag.
\textbf{Undo semantics.} Undo restores the pre-operation spelling (or removes the
spelling if the operation introduced the first one), under the active policy.
\textbf{Re-anchoring.} If the target pitch is tombstoned, the respelling is a
no-op (\texttt{TargetTombstoned}).
\begin{openquestion}
\textbf{P12-K1.} A v0 \texttt{RespellPitch} carried only a content-hash \emph{fingerprint} of the
spelling. The fingerprint cannot be inverted to a \texttt{PitchSpelling} without
a side table, so the v0$\rightarrow$v1 migration (Chapter~\ref{ch:migration})
recovers the spelling from the score graph context --- an explicit per-pitch
spelling attachment whose canonical bytes hash to the fingerprint --- and, when
the context lacks it, declares the envelope unmigratable (the bundle opens
read-only). This is the one representative payload that is not self-contained
under migration; the disposition (whether a richer v0 corpus, or a documented
read-only fallback, is the long-term answer) is a Pass-12 question.
\end{openquestion}
\section{CreateCrossCutting}
\label{sec:k0:create-cross-cutting}
\textbf{Payload schema.} \texttt{CreateCrossCuttingOp \{ structure:
CrossCuttingValue \}}, where \texttt{CrossCuttingValue} is the typed value of a
\texttt{Tie}, \texttt{Slur}, \texttt{Beam}, or \texttt{Spanner}. Its identity and
referenced endpoints are read from the value.
\textbf{Canonical encoding.} A discriminant for the structure kind, then the
length-framed canonical bytes of the structure value.
\textbf{Reduction rule.} Set-union creation: the structure is minted live if its
id is not already live and every referenced endpoint is live; a second create of
a live id is idempotent. Graph-aware reduction materialises the structure with
its full fields.
\textbf{Conflict cases.} None (all-or-nothing creation; union is deterministic).
\textbf{Undo semantics.} Undo tombstones the minted structure, under the active
policy.
\textbf{Re-anchoring.} The structure participates in the re-anchoring rule table
when one of its endpoints is later tombstoned (see DeleteEvent).
\section{ChangeRegionTimeModel}
\label{sec:k0:change-region-time-model}
\textbf{Payload schema.} \texttt{ChangeRegionTimeModelOp \{ region: RegionId,
new\_time\_model: RegionTimeModel, declared\_incompatible: Vec<EventId>,
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<TransactionCategory> \}}. 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}