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:
parent
ac7c076e73
commit
4556ede9f0
|
|
@ -139,6 +139,27 @@ companion lands, reconcile this crate's `CanonicalEncode`/`CanonicalDecode` and
|
|||
the whole-score `codec` with it (a failing cross-crate round-trip test would be
|
||||
the trigger, per the QUICKSTART process notes).
|
||||
|
||||
**Phase 2 — Agent K (Operation Catalog): the `CanonicalValue` seam.** Track B's
|
||||
Operation Catalog shifts `epiphany-ops` from identifier-only operation payloads
|
||||
to *value-typed* ones (an `InsertEvent` carrying the real `Event`, a
|
||||
`RespellPitch` carrying the real `PitchSpelling`). Those payloads must serialize
|
||||
canonically (an envelope stays hashable across an implementation boundary), so
|
||||
`epiphany-ops` needs to canonically encode/decode core value types. The internal
|
||||
`Codec` trait and its `Reader` cursor stay `pub(crate)` (they are composition
|
||||
machinery, not a stable surface); instead `src/codec.rs` exposes a thin **public
|
||||
`CanonicalValue` trait** (`canonical_bytes` / `decode_canonical`) implemented —
|
||||
via a macro that *delegates to the existing `Codec` impls* — for exactly the
|
||||
value types operation payloads embed (`Event`, `Rest`, `PitchSpelling`, `Tie`,
|
||||
`Slur`, `Beam`, `Spanner`, `RegionTimeModel`, `TimeAnchor`). This introduces **no
|
||||
new byte layout**: a `value_codec` test asserts each value's `CanonicalValue`
|
||||
bytes equal the bytes the whole-score codec already embeds for it, so all
|
||||
existing goldens / criterion 4 stay byte-for-byte green. This is the **K↔J
|
||||
coordination seam**: value-type wire encoding is nominally the Binary Format
|
||||
companion's (Agent J) to formalize, but K needs the surface now and J inherits
|
||||
core's ratified conventions (Pass 11 item 1.8, `req:format:codec-conventions`)
|
||||
rather than reconciling a second codec. Rejected alternative: a parallel value
|
||||
codec inside `epiphany-ops` (two sources of truth for one byte layout).
|
||||
|
||||
### P11-5 — Scope boundary: the Chapter 4 tuning catalog is referenced, not defined here
|
||||
|
||||
`epiphany-core` (Agent B) owns the score graph and the pitch/time primitives. It
|
||||
|
|
|
|||
|
|
@ -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(®ion.time_model);
|
||||
}
|
||||
for tie in &s.cross_cutting.ties {
|
||||
assert_value_round_trips(tie);
|
||||
}
|
||||
for slur in &s.cross_cutting.slurs {
|
||||
assert_value_round_trips(slur);
|
||||
}
|
||||
for beam in &s.cross_cutting.beams {
|
||||
assert_value_round_trips(beam);
|
||||
}
|
||||
for spanner in &s.cross_cutting.spanners {
|
||||
assert_value_round_trips(spanner);
|
||||
}
|
||||
}
|
||||
for seed in 0..200u64 {
|
||||
let s = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1));
|
||||
for ev in s.events.iter() {
|
||||
assert_value_round_trips(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ pub use tempo::{
|
|||
INVERSION_MAX_ITERATIONS, INVERSION_TOLERANCE_WHOLE_NOTES,
|
||||
};
|
||||
|
||||
pub use codec::ScoreDecodeError;
|
||||
pub use codec::{CanonicalValue, ScoreDecodeError};
|
||||
|
||||
pub use indexes::ScoreIndexes;
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,27 @@ that graph. The base-free `reduce()` remains the operation-set convergence API.
|
|||
|
||||
### P11-C1 — operation payload schemas are deferred; we carry identifiers + fingerprints
|
||||
|
||||
> **RESOLVED (Phase 2, Agent K — Operation Catalog, M1).** The representative
|
||||
> payloads are now **value-typed**: `InsertEventOp { staff_instance, event:
|
||||
> Event }`, `RespellPitchOp { pitch, spelling: PitchSpelling }`,
|
||||
> `CreateCrossCuttingOp { structure: CrossCuttingValue }`,
|
||||
> `ChangeRegionTimeModelOp { …, new_time_model: RegionTimeModel }`,
|
||||
> `SetUserSystemBreakOp { …, anchor: TimeAnchor }`, and
|
||||
> `TupletCompensation::ReplaceWithRest { rest: Rest }`. They serialize by framing
|
||||
> each value's `epiphany_core::CanonicalValue` bytes behind a `u32` length prefix
|
||||
> — the ratified byte-convention baseline (Pass 11 item 1.8,
|
||||
> `req:format:codec-conventions`), introducing no new layout (the K↔J seam; see
|
||||
> `epiphany-core/DECISIONS.md`). Graph-aware reduction now materializes the
|
||||
> **real** event/structure rather than the C4 placeholder described below.
|
||||
> `reduce_onto`'s reduction rules are unchanged — only the field read-sites moved
|
||||
> onto the value. The v0 identifier-only shapes are frozen in `src/v0.rs` as the
|
||||
> migration regression guard, and `src/migrate.rs` lifts a v0 envelope to v1
|
||||
> deterministically and equivalence-preservingly (`migrate_v0_envelope(v0,
|
||||
> context: &Score)`; `epiphany-testkit::migration` is the merge gate). The full
|
||||
> K0 set + the literal wire layout (Binary Format companion, Agent J) follow; the
|
||||
> remainder of this entry is the historical v0 rationale. See `P12-K1` below and
|
||||
> `spec/operation_catalog.tex`.
|
||||
|
||||
Chapter 6's payload structs embed rich graph values (`InsertEventOp { event:
|
||||
Event }`, `RespellPitchOp { new_spelling: PitchSpelling }`, …), but the
|
||||
*canonical wire encoding* of those graph value types is itself deferred to the
|
||||
|
|
@ -190,6 +211,26 @@ a single `ResolveConflictPayload { target, action }`. This crate maps every
|
|||
applied resolve to `Resolved { action }`; `Dismissed` is reachable as a state but
|
||||
is not authored by a representative op. **For the spec:** define how a
|
||||
`ResolveConflict` selects Dismissed (a distinct action, or a separate payload).
|
||||
*Phase 2 update: the Operation Catalog (Ch. ResolveConflict) records that
|
||||
`ResolutionAction::Dismiss` is the action that selects `Dismissed`; resolved.*
|
||||
|
||||
## Pass 12 candidates (Agent K — Operation Catalog)
|
||||
|
||||
### P12-K1 — a v0 `RespellPitch` fingerprint is not invertible to a spelling
|
||||
|
||||
The v0 `RespellPitchOp` carried a `ContentHash` *fingerprint* of the new
|
||||
spelling, not the `PitchSpelling`. The v0→v1 migration (`src/migrate.rs`) must
|
||||
reconstruct the value, but a fingerprint cannot be inverted without a side table.
|
||||
The migration recovers the spelling from the score-graph **context** — an
|
||||
explicit per-pitch spelling attachment (`SpellingScope::Pitch` +
|
||||
`SpellingDirective::Explicit`) whose canonical bytes hash to the fingerprint —
|
||||
and, when the context lacks it, returns `MigrationError::Irreversible` (the
|
||||
bundle opens read-only, per the QUICKSTART migration contract). This is the one
|
||||
representative payload that is not self-contained under migration. **For Pass
|
||||
12:** confirm the read-only fallback is the intended long-term disposition (vs.
|
||||
requiring a richer v0 corpus that preserves spelling pre-images). Recorded in
|
||||
`spec/PASS12_BATCH.md` and `spec/operation_catalog.tex`
|
||||
(§RespellPitch, §Migration).
|
||||
|
||||
## Provisional canonical encoding (mirrors Agent B's P11-4)
|
||||
|
||||
|
|
|
|||
|
|
@ -283,7 +283,6 @@ mod tests {
|
|||
use crate::support::AuthorId;
|
||||
use crate::OperationPayload;
|
||||
use epiphany_core::{PitchId, WallClockTime};
|
||||
use epiphany_determinism::ContentHash;
|
||||
|
||||
fn env(replica: u64, counter: u64, physical: i64, logical: u32) -> OperationEnvelope {
|
||||
let id = OperationId::new(ReplicaId(replica), counter);
|
||||
|
|
@ -298,7 +297,7 @@ mod tests {
|
|||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(ReplicaId(replica), counter),
|
||||
spelling: ContentHash([1u8; 32]),
|
||||
spelling: crate::valuegen::spelling(1),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@
|
|||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use epiphany_core::{MusicalPosition, OperationId, PitchId, RegionId, TypedObjectId};
|
||||
use epiphany_determinism::{CanonicalDecode, ContentHash};
|
||||
use epiphany_core::{
|
||||
CanonicalValue, MusicalPosition, OperationId, PitchId, PitchSpelling, RegionId, TypedObjectId,
|
||||
};
|
||||
use epiphany_determinism::CanonicalDecode;
|
||||
|
||||
use crate::{
|
||||
ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
|
||||
|
|
@ -516,8 +518,11 @@ pub(crate) fn decode_materialized_state(bytes: &[u8]) -> Result<MaterializedStat
|
|||
let mut spellings = BTreeMap::new();
|
||||
for _ in 0..spelling_count {
|
||||
let pitch = fixed::<PitchId>(&mut reader, 16, "PitchId")?;
|
||||
let hash = fixed::<ContentHash>(&mut reader, 32, "ContentHash")?;
|
||||
spellings.insert(pitch, hash);
|
||||
// The value is the full PitchSpelling (v1), encoded behind a u32 length
|
||||
// prefix via its canonical value bytes.
|
||||
let spelling = PitchSpelling::decode_canonical(reader.lp_bytes()?)
|
||||
.map_err(|_| MaterializedDecodeError::InvalidValue("PitchSpelling"))?;
|
||||
spellings.insert(pitch, spelling);
|
||||
}
|
||||
|
||||
let break_count = reader.len()?;
|
||||
|
|
|
|||
|
|
@ -195,7 +195,6 @@ mod tests {
|
|||
use crate::payload::{OperationKind, RespellPitchOp};
|
||||
use crate::stamp::HybridLogicalClock;
|
||||
use epiphany_core::{PitchId, ReplicaId, WallClockTime};
|
||||
use epiphany_determinism::ContentHash;
|
||||
|
||||
fn env(id: OperationId, stamp_id: OperationId) -> OperationEnvelope {
|
||||
OperationEnvelope {
|
||||
|
|
@ -206,7 +205,7 @@ mod tests {
|
|||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(ReplicaId(1), 1),
|
||||
spelling: ContentHash([7u8; 32]),
|
||||
spelling: crate::valuegen::spelling(7),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -257,7 +256,7 @@ mod tests {
|
|||
let ha = a.envelope_hash();
|
||||
a.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(ReplicaId(1), 1),
|
||||
spelling: ContentHash([9u8; 32]), // different spelling
|
||||
spelling: crate::valuegen::spelling(9), // different spelling
|
||||
}));
|
||||
assert_ne!(ha, a.envelope_hash());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,19 +22,20 @@
|
|||
|
||||
use epiphany_core::{
|
||||
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId,
|
||||
ReplicaId, SlurId, StaffInstanceId, TypedObjectId, VoiceId,
|
||||
ReplicaId, SlurId, StaffInstanceId, VoiceId,
|
||||
};
|
||||
use epiphany_determinism::{fuzz::SplitMix64, ContentHash};
|
||||
use epiphany_determinism::fuzz::SplitMix64;
|
||||
|
||||
use crate::causal::CausalContext;
|
||||
use crate::envelope::OperationEnvelope;
|
||||
use crate::opset::OperationSet;
|
||||
use crate::payload::{
|
||||
CreateCrossCuttingOp, CrossCuttingRef, DeleteEventOp, InsertEventOp, OperationKind,
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind,
|
||||
OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TupletCompensation,
|
||||
};
|
||||
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
||||
use crate::support::AuthorId;
|
||||
use crate::valuegen;
|
||||
use crate::IntegrityAnomalyKind;
|
||||
|
||||
/// Number of replicas the generator draws authors from.
|
||||
|
|
@ -81,38 +82,46 @@ fn pitch(n: u64) -> PitchId {
|
|||
/// Generates a random payload over the shared id space.
|
||||
fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
|
||||
let kind = match rng.below(5) {
|
||||
0 => OperationKind::InsertEvent(InsertEventOp {
|
||||
voice: VoiceId::new(ReplicaId(7), rng.below(3)),
|
||||
staff_instance: StaffInstanceId::new(ReplicaId(7), 0),
|
||||
event: event(rng.below(ID_SPACE)),
|
||||
position: MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
|
||||
duration: MusicalDuration::whole(),
|
||||
pitches: if rng.chance(2) {
|
||||
0 => {
|
||||
let voice = VoiceId::new(ReplicaId(7), rng.below(3));
|
||||
let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32));
|
||||
let pitches = if rng.chance(2) {
|
||||
vec![pitch(rng.below(ID_SPACE))]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
}),
|
||||
};
|
||||
OperationKind::InsertEvent(InsertEventOp {
|
||||
staff_instance: StaffInstanceId::new(ReplicaId(7), 0),
|
||||
event: valuegen::insert_event_value(
|
||||
event(rng.below(ID_SPACE)),
|
||||
voice,
|
||||
position,
|
||||
MusicalDuration::whole(),
|
||||
&pitches,
|
||||
),
|
||||
})
|
||||
}
|
||||
1 => OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event: event(rng.below(ID_SPACE)),
|
||||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||
}),
|
||||
2 => OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: pitch(rng.below(ID_SPACE)),
|
||||
spelling: ContentHash([(rng.below(4) as u8) + 1; 32]),
|
||||
spelling: valuegen::spelling(rng.below(4) as u8 + 1),
|
||||
}),
|
||||
3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: CrossCuttingRef {
|
||||
id: TypedObjectId::Slur(SlurId::new(ReplicaId(7), rng.below(ID_SPACE))),
|
||||
endpoints: vec![
|
||||
TypedObjectId::Event(event(rng.below(ID_SPACE))),
|
||||
TypedObjectId::Event(event(rng.below(ID_SPACE))),
|
||||
],
|
||||
},
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(
|
||||
SlurId::new(ReplicaId(7), rng.below(ID_SPACE)),
|
||||
event(rng.below(ID_SPACE)),
|
||||
event(rng.below(ID_SPACE)),
|
||||
)),
|
||||
}),
|
||||
_ => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
||||
region: RegionId::new(ReplicaId(7), 0),
|
||||
anchor: MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
|
||||
anchor: valuegen::region_start_anchor(
|
||||
RegionId::new(ReplicaId(7), 0),
|
||||
MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
|
||||
),
|
||||
present: rng.chance(2),
|
||||
}),
|
||||
};
|
||||
|
|
@ -218,7 +227,7 @@ pub fn gen_envelope_set(rng: &mut SplitMix64, n: usize) -> Vec<OperationEnvelope
|
|||
let mut twin = victim.clone();
|
||||
twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: pitch(rng.below(ID_SPACE)),
|
||||
spelling: ContentHash([0xEE; 32]),
|
||||
spelling: valuegen::spelling(6),
|
||||
}));
|
||||
if twin.envelope_hash() != victim.envelope_hash() {
|
||||
envs.push(twin);
|
||||
|
|
@ -279,7 +288,7 @@ pub fn run_equivocation_fuzz(iters: u64, seed: u64) {
|
|||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: pitch(0),
|
||||
spelling: ContentHash([spelling; 32]),
|
||||
spelling: valuegen::spelling(spelling),
|
||||
})),
|
||||
};
|
||||
let a = mk(&mut rng, 1);
|
||||
|
|
|
|||
|
|
@ -84,12 +84,15 @@ mod decode;
|
|||
mod effect;
|
||||
mod encode;
|
||||
mod envelope;
|
||||
mod migrate;
|
||||
mod opset;
|
||||
mod payload;
|
||||
mod reduce;
|
||||
mod slot;
|
||||
mod stamp;
|
||||
mod support;
|
||||
mod v0;
|
||||
pub mod valuegen;
|
||||
|
||||
pub mod fuzz;
|
||||
|
||||
|
|
@ -107,12 +110,13 @@ pub use effect::{
|
|||
RepairKind, RepairRecord, TupletCompensationKind,
|
||||
};
|
||||
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 payload::{
|
||||
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingRef, DeleteEventOp, InsertEventOp,
|
||||
OperationKind, OperationKindTag, OperationPayload, PositionRemapping, RegionTimeModelTag,
|
||||
ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory,
|
||||
TransactionDescriptor, TupletCompensation,
|
||||
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp,
|
||||
OperationKind, OperationKindTag, OperationPayload, PositionRemapping, ResolveConflictPayload,
|
||||
RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
|
||||
TupletCompensation,
|
||||
};
|
||||
pub use reduce::{
|
||||
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,
|
||||
|
|
@ -127,5 +131,6 @@ pub use support::{
|
|||
};
|
||||
|
||||
pub use undo::{UndoPolicy, UndoTransactionPayload};
|
||||
pub use v0::V0OperationEnvelope;
|
||||
|
||||
mod undo;
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
))
|
||||
}
|
||||
|
|
@ -211,7 +211,6 @@ mod tests {
|
|||
use crate::support::AuthorId;
|
||||
use crate::OperationPayload;
|
||||
use epiphany_core::{PitchId, ReplicaId, WallClockTime};
|
||||
use epiphany_determinism::ContentHash;
|
||||
|
||||
fn env_with(id: OperationId, spelling: u8) -> OperationEnvelope {
|
||||
OperationEnvelope {
|
||||
|
|
@ -222,7 +221,7 @@ mod tests {
|
|||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(ReplicaId(1), 1),
|
||||
spelling: ContentHash([spelling; 32]),
|
||||
spelling: crate::valuegen::spelling(spelling),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,39 +3,53 @@
|
|||
//! structs the chapter specifies reduction rules for (Chapter 6 §"The Operation
|
||||
//! Framework", §"Representative Operations").
|
||||
//!
|
||||
//! ## Why these payloads carry identifiers, not whole graph objects
|
||||
//! ## These payloads are value-typed (Operation Catalog, v1)
|
||||
//!
|
||||
//! The spec's payload structs embed rich graph values — `InsertEventOp` holds an
|
||||
//! `Event`, `RespellPitchOp` holds a `PitchSpelling`, and so on. Their *canonical
|
||||
//! wire encoding*, however, is deferred to the Binary Format companion (Agent B's
|
||||
//! `epiphany-core` canonically encodes only identifiers and the scalar time
|
||||
//! types; the value-type encoding is its Pass 11 candidate P11-4). To keep an
|
||||
//! [`OperationEnvelope`](crate::OperationEnvelope) **hashable today** — the
|
||||
//! [`EnvelopeHash`](crate::EnvelopeHash) and slot equivocation both need
|
||||
//! canonical bytes — these payloads carry the reduction-relevant *identifiers and
|
||||
//! canonical scalar coordinates*, plus a content fingerprint where the reduction
|
||||
//! only needs equality (a respelling's [`ContentHash`]). This is faithful to
|
||||
//! everything Chapter 6's reduction rules actually consume, and is recorded as a
|
||||
//! Pass 11 candidate (see `DECISIONS.md`): when the companion lands, the structs
|
||||
//! grow back their full value fields without changing the reduction.
|
||||
//! Track B's Operation Catalog (Agent K) shifted these payloads from the v0
|
||||
//! *identifier-only projection* (an `InsertEvent` that carried only an `EventId`
|
||||
//! plus scalars; a `RespellPitch` that carried only a [`ContentHash`] fingerprint
|
||||
//! of the new spelling) to the **value-typed** form the spec describes:
|
||||
//! `InsertEventOp` carries the real [`Event`], `RespellPitchOp` carries the real
|
||||
//! [`PitchSpelling`], `CreateCrossCuttingOp` carries the real cross-cutting
|
||||
//! structure, and so on. This is what makes an operation *durable* — replayable
|
||||
//! in a fresh context (a backup restore, a cross-tool round-trip) without the
|
||||
//! originating graph.
|
||||
//!
|
||||
//! The payloads serialize canonically by embedding each value's
|
||||
//! [`CanonicalValue`] bytes behind a `u32` length prefix (the same ratified byte
|
||||
//! layout `epiphany-core`'s whole-score codec uses — Pass 11 item 1.8,
|
||||
//! `req:format:codec-conventions`), so an [`OperationEnvelope`](crate::OperationEnvelope)
|
||||
//! stays hashable ([`EnvelopeHash`](crate::EnvelopeHash) / slot equivocation). The
|
||||
//! v0 shapes are retained in [`crate::v0`] solely as the migration regression
|
||||
//! guard; the [`migrate_v0_envelope`](crate::migrate_v0_envelope) path lifts a v0
|
||||
//! envelope to this v1 form (deterministically, preserving canonical reduction
|
||||
//! state). See `DECISIONS.md` (P11-C1 resolved; P12-K1).
|
||||
//!
|
||||
//! The *set* of kinds here is the representative selection of §6.10, not the full
|
||||
//! ~60–80-primitive catalog (an explicit open question, §6.11). Together they
|
||||
//! exercise every reduction discipline the chapter defines.
|
||||
//! exercise every reduction discipline the chapter defines. The Operation Catalog
|
||||
//! companion (`spec/operation_catalog.tex`) is the normative schema for these
|
||||
//! payloads.
|
||||
|
||||
use epiphany_core::{
|
||||
EventId, MusicalDuration, MusicalPosition, PitchId, RegionId, StaffInstanceId, TransactionId,
|
||||
TupletId, TypedObjectId, VoiceId,
|
||||
Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, MusicalDuration,
|
||||
MusicalPosition, PitchId, PitchSpelling, RegionId, RegionTimeModel, Rest, Slur, Spanner,
|
||||
StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, VoiceId,
|
||||
};
|
||||
use epiphany_determinism::{sorted_canonical, CanonicalEncode, ContentHash};
|
||||
use epiphany_determinism::{sorted_canonical, CanonicalEncode};
|
||||
|
||||
use crate::conflict::{ConflictId, ResolutionAction};
|
||||
use crate::encode::{push_canon, push_seq, push_str, push_tag, push_u8_bool};
|
||||
use crate::encode::{push_canon, push_lp_bytes, push_seq, push_str, push_tag, push_u8_bool};
|
||||
use crate::support::OperationKindRegistryId;
|
||||
use crate::undo::UndoTransactionPayload;
|
||||
|
||||
/// The full payload of an operation envelope: a primitive, or one of the two
|
||||
/// meta-operations (Chapter 6 §"Operation Envelopes").
|
||||
// v1 payloads carry whole graph values, so the `Primitive` variant is
|
||||
// intentionally larger than the meta-operations — the durability the catalog
|
||||
// requires. Operation payloads are not packed in a hot path, so the size
|
||||
// disparity is acceptable rather than worth a `Box` indirection on every match.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum OperationPayload {
|
||||
/// A primitive mutation.
|
||||
|
|
@ -70,6 +84,9 @@ impl CanonicalEncode for OperationPayload {
|
|||
|
||||
/// The catalog of primitive operation kinds reduced by this crate (Chapter 6
|
||||
/// §"Operation Envelopes", representative subset of §6.10).
|
||||
// `InsertEvent` carries a whole `Event`, so this variant is intentionally larger
|
||||
// than the others (see `OperationPayload`); inline values are the v1 design.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum OperationKind {
|
||||
/// Insert an event into a voice (position-keyed; voice promotion on
|
||||
|
|
@ -201,31 +218,63 @@ impl CanonicalEncode for OperationKindTag {
|
|||
|
||||
// --- Representative operation payloads (Chapter 6 §6.10). --------------------
|
||||
|
||||
/// Insert an event into a voice (Chapter 6 §6.10 InsertEvent).
|
||||
/// Insert an event into a voice (Chapter 6 §6.10 InsertEvent). Carries the full
|
||||
/// [`Event`] value (v1, value-typed); the voice, position, duration, and pitch
|
||||
/// identities the reduction keys on are read from it via the accessors below.
|
||||
///
|
||||
/// The `staff_instance` makes the system-promoted-voice derivation total
|
||||
/// without a containment walk (a full reducer recovers it from the voice's
|
||||
/// container; see `DECISIONS.md`). `position`/`duration` are exact musical
|
||||
/// rationals, the collision key for voice promotion. `pitches` are tombstoned
|
||||
/// with the event on delete.
|
||||
/// `staff_instance` is retained alongside the event so the system-promoted-voice
|
||||
/// derivation is total without a containment walk (a full reducer recovers it
|
||||
/// from the voice's container; see `DECISIONS.md`).
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct InsertEventOp {
|
||||
pub voice: VoiceId,
|
||||
pub staff_instance: StaffInstanceId,
|
||||
pub event: EventId,
|
||||
pub position: MusicalPosition,
|
||||
pub duration: MusicalDuration,
|
||||
pub pitches: Vec<PitchId>,
|
||||
pub event: Event,
|
||||
}
|
||||
|
||||
impl InsertEventOp {
|
||||
/// The voice the event is inserted into (the bucketing / promotion key).
|
||||
pub fn voice(&self) -> VoiceId {
|
||||
self.event.voice()
|
||||
}
|
||||
|
||||
/// The inserted event's identifier.
|
||||
pub fn event_id(&self) -> EventId {
|
||||
self.event.id()
|
||||
}
|
||||
|
||||
/// The pitch identities the event embeds (minted live with the event;
|
||||
/// tombstoned with it on delete).
|
||||
pub fn pitch_ids(&self) -> Vec<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 {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
push_canon(out, &self.voice);
|
||||
push_canon(out, &self.staff_instance);
|
||||
push_canon(out, &self.event);
|
||||
push_canon(out, &self.position);
|
||||
push_canon(out, &self.duration);
|
||||
push_seq(out, &sorted_canonical(self.pitches.clone()));
|
||||
push_lp_bytes(out, &self.event.canonical_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -244,18 +293,14 @@ impl CanonicalEncode for DeleteEventOp {
|
|||
}
|
||||
|
||||
/// Compensation for a deleted event's tuplet membership (Chapter 6 §6.10).
|
||||
/// The replacement rest is represented by its freshly-minted [`EventId`] and
|
||||
/// duration (the reducer adds it live and validates the duration); the full
|
||||
/// `Rest` value is deferred with the rest of the payload encoding.
|
||||
/// The replacement rest carries its full [`Rest`] value (v1, value-typed); the
|
||||
/// reducer adds it live and validates the duration.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum TupletCompensation {
|
||||
/// Target is not in any tuplet.
|
||||
NotInTuplet,
|
||||
/// Replace the deleted event with a rest of the same duration.
|
||||
ReplaceWithRest {
|
||||
new_rest: EventId,
|
||||
duration: MusicalDuration,
|
||||
},
|
||||
ReplaceWithRest { rest: Rest },
|
||||
/// Rewrite the enclosing tuplet(s) to remain consistent.
|
||||
RewriteTuplets { tuplets: Vec<TupletId> },
|
||||
/// Cascade-delete the tuplet group(s) containing the target.
|
||||
|
|
@ -284,9 +329,8 @@ impl CanonicalEncode for TupletCompensation {
|
|||
push_tag(out, self.discriminant());
|
||||
match self {
|
||||
TupletCompensation::NotInTuplet => {}
|
||||
TupletCompensation::ReplaceWithRest { new_rest, duration } => {
|
||||
push_canon(out, new_rest);
|
||||
push_canon(out, duration);
|
||||
TupletCompensation::ReplaceWithRest { rest } => {
|
||||
push_lp_bytes(out, &rest.canonical_bytes());
|
||||
}
|
||||
TupletCompensation::RewriteTuplets { tuplets }
|
||||
| TupletCompensation::CascadeDeleteTuplets { tuplets } => {
|
||||
|
|
@ -296,31 +340,31 @@ impl CanonicalEncode for TupletCompensation {
|
|||
}
|
||||
}
|
||||
|
||||
/// Overwrite a pitch's spelling (Chapter 6 §6.10 RespellPitch). The intended
|
||||
/// [`PitchSpelling`](epiphany_core::PitchSpelling) is represented by a
|
||||
/// [`ContentHash`] fingerprint: the reduction only needs spelling *equality*
|
||||
/// (identical concurrent respellings reduce idempotently; differing ones
|
||||
/// conflict).
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
/// Overwrite a pitch's spelling (Chapter 6 §6.10 RespellPitch). Carries the full
|
||||
/// [`PitchSpelling`] value (v1, value-typed); the reduction needs spelling
|
||||
/// *equality* (identical concurrent respellings reduce idempotently; differing
|
||||
/// ones conflict), which is now structural value equality rather than a
|
||||
/// fingerprint comparison.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct RespellPitchOp {
|
||||
pub pitch: PitchId,
|
||||
pub spelling: ContentHash,
|
||||
pub spelling: PitchSpelling,
|
||||
}
|
||||
|
||||
impl CanonicalEncode for RespellPitchOp {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
push_canon(out, &self.pitch);
|
||||
push_canon(out, &self.spelling);
|
||||
push_lp_bytes(out, &self.spelling.canonical_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a cross-cutting structure (Chapter 6 §6.10 CreateCrossCutting). The
|
||||
/// structure is identified by its [`TypedObjectId`] (whose variant carries the
|
||||
/// kind — Slur, Tie, Beam, …) and its referenced endpoints; that is everything
|
||||
/// the set-union reduction and the re-anchoring rule table consume.
|
||||
/// Create a cross-cutting structure (Chapter 6 §6.10 CreateCrossCutting). Carries
|
||||
/// the full typed structure value (v1, value-typed); the set-union reduction and
|
||||
/// the re-anchoring rule table key on its [`CrossCuttingValue::id`] and
|
||||
/// [`CrossCuttingValue::endpoints`], which it derives from the value.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct CreateCrossCuttingOp {
|
||||
pub structure: CrossCuttingRef,
|
||||
pub structure: CrossCuttingValue,
|
||||
}
|
||||
|
||||
impl CanonicalEncode for CreateCrossCuttingOp {
|
||||
|
|
@ -329,34 +373,90 @@ impl CanonicalEncode for CreateCrossCuttingOp {
|
|||
}
|
||||
}
|
||||
|
||||
/// A reference-level view of a cross-cutting structure: its identity and the
|
||||
/// objects it references (Chapter 6 §6.5 re-anchoring operands).
|
||||
/// A cross-cutting structure value (Chapter 5 §"Cross-Cutting Structures"): the
|
||||
/// representative event-anchored family the reduction materializes. The reduction
|
||||
/// keys only on the [`id`](CrossCuttingValue::id) and
|
||||
/// [`endpoints`](CrossCuttingValue::endpoints); the rich per-kind fields (a tie's
|
||||
/// class, a beam's level) carry through to the graph materialization.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct CrossCuttingRef {
|
||||
/// The structure's identity; its `TypedObjectId` variant names the kind.
|
||||
pub id: TypedObjectId,
|
||||
/// The objects this structure references (its endpoints/anchors).
|
||||
pub endpoints: Vec<TypedObjectId>,
|
||||
pub enum CrossCuttingValue {
|
||||
Tie(Tie),
|
||||
Slur(Slur),
|
||||
Beam(Beam),
|
||||
Spanner(Spanner),
|
||||
}
|
||||
|
||||
impl CanonicalEncode for CrossCuttingRef {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
push_canon(out, &self.id);
|
||||
// Endpoint order is meaningful (start before end), so it is NOT sorted.
|
||||
push_seq(out, &self.endpoints);
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change a region's time model (Chapter 6 §6.10 ChangeRegionTimeModel). The
|
||||
/// target model is represented by its [`RegionTimeModelTag`]; the prototype's
|
||||
/// reducer cannot recompute coordinate-kind compatibility from the not-yet-
|
||||
/// canonical region contents, so the authoring layer declares any events it
|
||||
/// knows to be un-migratable (`declared_incompatible`), which drive the
|
||||
/// `TimeModelMigrationFailure` conflict (see `DECISIONS.md`).
|
||||
impl CanonicalEncode for CrossCuttingValue {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
push_tag(out, self.discriminant());
|
||||
let bytes = match self {
|
||||
CrossCuttingValue::Tie(t) => t.canonical_bytes(),
|
||||
CrossCuttingValue::Slur(s) => s.canonical_bytes(),
|
||||
CrossCuttingValue::Beam(b) => b.canonical_bytes(),
|
||||
CrossCuttingValue::Spanner(s) => s.canonical_bytes(),
|
||||
};
|
||||
push_lp_bytes(out, &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Change a region's time model (Chapter 6 §6.10 ChangeRegionTimeModel). Carries
|
||||
/// the full target [`RegionTimeModel`] value (v1, value-typed); the reduction
|
||||
/// keys coordinate-kind compatibility on the value's *kind*. The authoring layer
|
||||
/// additionally declares any events it knows to be un-migratable
|
||||
/// (`declared_incompatible`), which drive the `TimeModelMigrationFailure`
|
||||
/// conflict (see `DECISIONS.md`, P11-C6 — graph-aware reduction derives the rest
|
||||
/// from the region contents).
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ChangeRegionTimeModelOp {
|
||||
pub region: RegionId,
|
||||
pub new_time_model: RegionTimeModelTag,
|
||||
pub new_time_model: RegionTimeModel,
|
||||
pub declared_incompatible: Vec<EventId>,
|
||||
pub remapping: PositionRemapping,
|
||||
}
|
||||
|
|
@ -364,39 +464,12 @@ pub struct ChangeRegionTimeModelOp {
|
|||
impl CanonicalEncode for ChangeRegionTimeModelOp {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
push_canon(out, &self.region);
|
||||
self.new_time_model.encode_canonical(out);
|
||||
push_lp_bytes(out, &self.new_time_model.canonical_bytes());
|
||||
push_seq(out, &sorted_canonical(self.declared_incompatible.clone()));
|
||||
self.remapping.encode_canonical(out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Which region time model a migration targets (Chapter 3 §"Region Time
|
||||
/// Models"). The discriminator the reducer needs; the full
|
||||
/// [`RegionTimeModel`](epiphany_core::RegionTimeModel) value is deferred.
|
||||
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||
pub enum RegionTimeModelTag {
|
||||
Metric,
|
||||
Proportional,
|
||||
Aleatoric,
|
||||
}
|
||||
|
||||
impl RegionTimeModelTag {
|
||||
fn discriminant(&self) -> u8 {
|
||||
match self {
|
||||
RegionTimeModelTag::Metric => 0,
|
||||
RegionTimeModelTag::Proportional => 1,
|
||||
RegionTimeModelTag::Aleatoric => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for RegionTimeModelTag {
|
||||
#[inline]
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
push_tag(out, self.discriminant());
|
||||
}
|
||||
}
|
||||
|
||||
/// How event positions are remapped under a time-model change (Chapter 6
|
||||
/// §6.10). `PreserveTime`'s converter and `Reassign`'s full event positions
|
||||
/// follow the deferred payload encoding; the reducer consumes the event set.
|
||||
|
|
@ -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
|
||||
/// anchor is represented by its resolved [`MusicalPosition`] — the canonical
|
||||
/// LWW bucketing key; the full [`TimeAnchor`](epiphany_core::TimeAnchor) value
|
||||
/// is deferred.
|
||||
/// Set a user system-break preference (Chapter 6 §6.10 SetUserSystemBreak).
|
||||
/// Carries the full [`TimeAnchor`] value (v1, value-typed); the reduction's LWW
|
||||
/// bucketing key is the anchor's resolved [`MusicalPosition`]
|
||||
/// ([`SetUserSystemBreakOp::resolved_position`]).
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct SetUserSystemBreakOp {
|
||||
pub region: RegionId,
|
||||
pub anchor: MusicalPosition,
|
||||
pub anchor: TimeAnchor,
|
||||
pub present: bool,
|
||||
}
|
||||
|
||||
impl SetUserSystemBreakOp {
|
||||
/// The anchor's resolved musical position — the canonical LWW bucketing key.
|
||||
/// A region-relative musical offset resolves to that offset; any other anchor
|
||||
/// shape resolves to the region origin (the break still applies to the
|
||||
/// region, the prototype LWW key is coarse — see `DECISIONS.md`).
|
||||
pub fn resolved_position(&self) -> MusicalPosition {
|
||||
match &self.anchor {
|
||||
TimeAnchor::Region {
|
||||
offset: epiphany_core::AnchorOffset::Musical(d),
|
||||
..
|
||||
} => MusicalPosition(d.0.clone()),
|
||||
_ => MusicalPosition::origin(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CanonicalEncode for SetUserSystemBreakOp {
|
||||
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||
push_canon(out, &self.region);
|
||||
push_canon(out, &self.anchor);
|
||||
push_lp_bytes(out, &self.anchor.canonical_bytes());
|
||||
push_u8_bool(out, self.present);
|
||||
}
|
||||
}
|
||||
|
|
@ -558,7 +647,7 @@ mod tests {
|
|||
assert_eq!(k.tag(), OperationKindTag::Registered(reg));
|
||||
let prim = OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(ReplicaId(1), 1),
|
||||
spelling: ContentHash([4u8; 32]),
|
||||
spelling: crate::valuegen::spelling(4),
|
||||
});
|
||||
assert_eq!(prim.tag(), OperationKindTag::RespellPitch);
|
||||
}
|
||||
|
|
@ -602,17 +691,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn cross_cutting_endpoint_order_is_significant() {
|
||||
let s = TypedObjectId::Slur(SlurId::new(ReplicaId(1), 1));
|
||||
let a = TypedObjectId::Event(EventId::new(ReplicaId(1), 2));
|
||||
let b = TypedObjectId::Event(EventId::new(ReplicaId(1), 3));
|
||||
let fwd = CrossCuttingRef {
|
||||
id: s,
|
||||
endpoints: vec![a, b],
|
||||
};
|
||||
let rev = CrossCuttingRef {
|
||||
id: s,
|
||||
endpoints: vec![b, a],
|
||||
};
|
||||
// A slur's (start, end) order is meaningful: swapping the endpoints
|
||||
// changes the canonical bytes.
|
||||
let s = SlurId::new(ReplicaId(1), 1);
|
||||
let a = EventId::new(ReplicaId(1), 2);
|
||||
let b = EventId::new(ReplicaId(1), 3);
|
||||
let fwd = CrossCuttingValue::Slur(crate::valuegen::slur(s, a, b));
|
||||
let rev = CrossCuttingValue::Slur(crate::valuegen::slur(s, b, a));
|
||||
assert_ne!(fwd.to_canonical_bytes(), rev.to_canonical_bytes());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,15 +31,12 @@
|
|||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use epiphany_core::{
|
||||
derive_promoted_voice_id, AcousticPitch, AcousticRealization, AleatoricAnchoringDiscipline,
|
||||
AleatoricTimeModel, AnchorOffset, Beam, CmnNominal, Event, EventDuration, EventId,
|
||||
EventOrderingDAG, EventPosition, IdentifiedPitch, MetricTimeModel, MusicalDuration,
|
||||
MusicalPosition, OperationId, Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchedEvent,
|
||||
ProportionalTimeModel, RegionEdge, RegionId, RegionTimeModel, Rest, ScalePosition, Score, Slur,
|
||||
StemConfiguration, Tie, TieClass, TimeAnchor, TransactionId, TuningReference, TypedObjectId,
|
||||
Voice, VoiceId, VoiceOrigin, WallClockDuration,
|
||||
derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId,
|
||||
EventPosition, MusicalDuration, MusicalPosition, OperationId, PitchId, PitchSpelling,
|
||||
RegionEdge, RegionId, RegionTimeModel, Score, TimeAnchor, TransactionId, TypedObjectId, Voice,
|
||||
VoiceId, VoiceOrigin,
|
||||
};
|
||||
use epiphany_determinism::{CanonicalEncode, ContentHash};
|
||||
use epiphany_determinism::CanonicalEncode;
|
||||
|
||||
use crate::anomaly::{detect_replica_anomalies, IntegrityAnomaly, IntegrityAnomalyKind};
|
||||
use crate::conflict::{
|
||||
|
|
@ -49,12 +46,12 @@ use crate::effect::{
|
|||
NoOpReason, OperationEffect, PreconditionFailureReason, ReanchorReason, RepairKind,
|
||||
RepairRecord, TupletCompensationKind,
|
||||
};
|
||||
use crate::encode::{push_canon, push_len, push_u8_bool};
|
||||
use crate::encode::{push_canon, push_len, push_lp_bytes, push_u8_bool};
|
||||
use crate::envelope::OperationEnvelope;
|
||||
use crate::opset::OperationSet;
|
||||
use crate::payload::{
|
||||
CreateCrossCuttingOp, DeleteEventOp, InsertEventOp, OperationKind, OperationPayload,
|
||||
RespellPitchOp, TupletCompensation,
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind,
|
||||
OperationPayload, RespellPitchOp, TupletCompensation,
|
||||
};
|
||||
use crate::undo::{UndoPolicy, UndoTransactionPayload};
|
||||
|
||||
|
|
@ -204,7 +201,7 @@ pub struct MaterializedState {
|
|||
/// Object existence (live/tombstoned), keyed by `TypedObjectId`.
|
||||
pub objects: BTreeMap<TypedObjectId, ObjectState>,
|
||||
/// 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.
|
||||
pub breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
|
||||
/// Operations held pending, ordered by `OperationId`.
|
||||
|
|
@ -252,11 +249,12 @@ impl MaterializedState {
|
|||
push_canon(&mut out, id);
|
||||
state.encode_canonical(&mut out);
|
||||
}
|
||||
// Spellings (PitchId order).
|
||||
// Spellings (PitchId order). The value is the full PitchSpelling (v1),
|
||||
// encoded behind a u32 length prefix via its canonical value bytes.
|
||||
push_len(&mut out, self.spellings.len());
|
||||
for (pitch, hash) in &self.spellings {
|
||||
for (pitch, spelling) in &self.spellings {
|
||||
push_canon(&mut out, pitch);
|
||||
push_canon(&mut out, hash);
|
||||
push_lp_bytes(&mut out, &spelling.canonical_bytes());
|
||||
}
|
||||
// Breaks (region+anchor order).
|
||||
push_len(&mut out, self.breaks.len());
|
||||
|
|
@ -309,7 +307,7 @@ struct Reducer<'a> {
|
|||
op_set: &'a OperationSet,
|
||||
// Canonical results.
|
||||
objects: BTreeMap<TypedObjectId, ObjectState>,
|
||||
spellings: BTreeMap<PitchId, ContentHash>,
|
||||
spellings: BTreeMap<PitchId, PitchSpelling>,
|
||||
breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
|
||||
conflicts: ConflictRegistry,
|
||||
effects: Vec<(OperationId, OperationEffect)>,
|
||||
|
|
@ -333,7 +331,7 @@ struct Reducer<'a> {
|
|||
/// A snapshot of the working state, for atomic transaction rollback.
|
||||
struct WorkingSnapshot {
|
||||
objects: BTreeMap<TypedObjectId, ObjectState>,
|
||||
spellings: BTreeMap<PitchId, ContentHash>,
|
||||
spellings: BTreeMap<PitchId, PitchSpelling>,
|
||||
breaks: BTreeMap<(RegionId, MusicalPosition), bool>,
|
||||
conflicts: ConflictRegistry,
|
||||
minted_by: BTreeMap<TypedObjectId, OperationId>,
|
||||
|
|
@ -363,7 +361,12 @@ fn intervals_overlap(
|
|||
}
|
||||
|
||||
fn insert_intervals_overlap(a: &InsertEventOp, b: &InsertEventOp) -> bool {
|
||||
intervals_overlap(&a.position, &a.duration, &b.position, &b.duration)
|
||||
intervals_overlap(
|
||||
&a.musical_position(),
|
||||
&a.musical_duration(),
|
||||
&b.musical_position(),
|
||||
&b.musical_duration(),
|
||||
)
|
||||
}
|
||||
|
||||
fn graph_voice_location(score: &Score, voice: VoiceId) -> Option<(usize, usize, usize)> {
|
||||
|
|
@ -381,56 +384,14 @@ fn graph_voice_location(score: &Score, voice: VoiceId) -> Option<(usize, usize,
|
|||
None
|
||||
}
|
||||
|
||||
fn placeholder_pitch(id: PitchId) -> IdentifiedPitch {
|
||||
IdentifiedPitch {
|
||||
id,
|
||||
pitch: Pitch {
|
||||
scale_position: ScalePosition {
|
||||
space: PitchSpaceId::new("cmn-12"),
|
||||
position: PitchSpacePosition::Cmn {
|
||||
nominal: CmnNominal::C,
|
||||
alteration: 0,
|
||||
octave: 4,
|
||||
},
|
||||
},
|
||||
acoustic: AcousticPitch {
|
||||
tuning: TuningReference::Inherit,
|
||||
realization: AcousticRealization::Implicit,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the graph value represented by the prototype's identifier-only
|
||||
/// InsertEvent payload. Rich event payloads remain gated on the Operation
|
||||
/// Catalog encoding; until then, pitched inserts use deterministic C4 values
|
||||
/// and pitchless inserts use a visible rest.
|
||||
/// The graph value inserted by a value-typed InsertEvent: the carried [`Event`]
|
||||
/// itself, with its voice rebound to the (possibly system-promoted) target
|
||||
/// voice. The Operation Catalog (v1) carries the real event, so this is no
|
||||
/// longer a placeholder reconstruction.
|
||||
fn graph_event_from_insert(op: &InsertEventOp, target_voice: VoiceId) -> Event {
|
||||
let position = EventPosition::Musical(op.position.clone());
|
||||
let duration = EventDuration::Musical(op.duration.clone());
|
||||
if op.pitches.is_empty() {
|
||||
Event::Rest(Rest {
|
||||
id: op.event,
|
||||
voice: target_voice,
|
||||
position,
|
||||
duration,
|
||||
vertical_position: None,
|
||||
visible: true,
|
||||
})
|
||||
} else {
|
||||
Event::Pitched(PitchedEvent {
|
||||
id: op.event,
|
||||
voice: target_voice,
|
||||
position,
|
||||
duration,
|
||||
pitches: op.pitches.iter().copied().map(placeholder_pitch).collect(),
|
||||
articulations: Vec::new(),
|
||||
dynamic: None,
|
||||
ornaments: Vec::new(),
|
||||
stem: StemConfiguration,
|
||||
grace: None,
|
||||
})
|
||||
}
|
||||
let mut event = op.event.clone();
|
||||
event.set_voice(target_voice);
|
||||
event
|
||||
}
|
||||
|
||||
impl<'a> Reducer<'a> {
|
||||
|
|
@ -742,7 +703,7 @@ impl<'a> Reducer<'a> {
|
|||
if self.graph_insert_precondition(op).is_err() {
|
||||
continue;
|
||||
}
|
||||
buckets.entry(op.voice).or_default().push(env);
|
||||
buckets.entry(op.voice()).or_default().push(env);
|
||||
}
|
||||
}
|
||||
for (_, mut bucket) in buckets {
|
||||
|
|
@ -769,7 +730,7 @@ impl<'a> Reducer<'a> {
|
|||
});
|
||||
if let Some(winner) = collision {
|
||||
let promoted =
|
||||
derive_promoted_voice_id(op.staff_instance, op.voice, winner.id, env.id);
|
||||
derive_promoted_voice_id(op.staff_instance, op.voice(), winner.id, env.id);
|
||||
self.promotion.insert(env.id, (promoted, winner.id));
|
||||
} else {
|
||||
original_voice.push(env);
|
||||
|
|
@ -785,8 +746,8 @@ impl<'a> Reducer<'a> {
|
|||
let Some(score) = self.graph.as_ref() else {
|
||||
return Ok((0, 0, 0));
|
||||
};
|
||||
let location =
|
||||
graph_voice_location(score, op.voice).ok_or(PreconditionFailureReason::VoiceMissing)?;
|
||||
let location = graph_voice_location(score, op.voice())
|
||||
.ok_or(PreconditionFailureReason::VoiceMissing)?;
|
||||
let (region_index, instance_index, _) = location;
|
||||
let region = &score.canvas.regions[region_index];
|
||||
let instance = ®ion.staff_instances()[instance_index];
|
||||
|
|
@ -796,9 +757,10 @@ impl<'a> Reducer<'a> {
|
|||
if !matches!(region.time_model, epiphany_core::RegionTimeModel::Metric(_)) {
|
||||
return Err(PreconditionFailureReason::WrongRegionTimeModel);
|
||||
}
|
||||
if score.events.contains(op.event)
|
||||
|| score.tombstoned_events.contains(&op.event)
|
||||
|| op.pitches.iter().any(|pitch| {
|
||||
let event_id = op.event_id();
|
||||
if score.events.contains(event_id)
|
||||
|| score.tombstoned_events.contains(&event_id)
|
||||
|| op.pitch_ids().iter().any(|pitch| {
|
||||
self.objects.contains_key(&TypedObjectId::Pitch(*pitch))
|
||||
|| score.tombstoned_pitches.contains(pitch)
|
||||
})
|
||||
|
|
@ -835,13 +797,13 @@ impl<'a> Reducer<'a> {
|
|||
.expect("the precondition found this instance");
|
||||
instance.voices.push(Voice {
|
||||
id: promoted,
|
||||
events: vec![op.event],
|
||||
events: vec![op.event_id()],
|
||||
default_stem_direction: None,
|
||||
is_primary: false,
|
||||
origin: VoiceOrigin::SystemPromoted {
|
||||
winning_operation: winner,
|
||||
losing_operation: env.id,
|
||||
original_voice: op.voice,
|
||||
original_voice: op.voice(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
|
|
@ -849,7 +811,7 @@ impl<'a> Reducer<'a> {
|
|||
.voices[voice_index]
|
||||
.events
|
||||
.clone();
|
||||
ordered.push(op.event);
|
||||
ordered.push(op.event_id());
|
||||
ordered.sort_by(|a, b| {
|
||||
let a_position = score.events.get(*a).map(Event::position);
|
||||
let b_position = score.events.get(*b).map(Event::position);
|
||||
|
|
@ -894,10 +856,10 @@ impl<'a> Reducer<'a> {
|
|||
Err(PreconditionFailureReason::TupletCompensationInvalid)
|
||||
}
|
||||
TupletCompensation::NotInTuplet => Ok(()),
|
||||
TupletCompensation::ReplaceWithRest { new_rest, duration } => {
|
||||
if score.events.contains(*new_rest)
|
||||
|| score.tombstoned_events.contains(new_rest)
|
||||
|| event.duration() != &EventDuration::Musical(duration.clone())
|
||||
TupletCompensation::ReplaceWithRest { rest } => {
|
||||
if score.events.contains(rest.id)
|
||||
|| score.tombstoned_events.contains(&rest.id)
|
||||
|| event.duration() != &rest.duration
|
||||
{
|
||||
Err(PreconditionFailureReason::TupletCompensationInvalid)
|
||||
} else {
|
||||
|
|
@ -957,25 +919,23 @@ impl<'a> Reducer<'a> {
|
|||
.extend(deleted_pitches.iter().copied());
|
||||
|
||||
match &op.tuplet_compensation {
|
||||
TupletCompensation::ReplaceWithRest { new_rest, duration } => {
|
||||
TupletCompensation::ReplaceWithRest { rest } => {
|
||||
let new_rest = rest.id;
|
||||
for tuplet in &mut score.cross_cutting.tuplets {
|
||||
for member in &mut tuplet.members {
|
||||
if *member == op.event {
|
||||
*member = *new_rest;
|
||||
*member = new_rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
let replacement = Event::Rest(Rest {
|
||||
id: *new_rest,
|
||||
voice: voice_id,
|
||||
position: event.position().clone(),
|
||||
duration: EventDuration::Musical(duration.clone()),
|
||||
vertical_position: None,
|
||||
visible: true,
|
||||
});
|
||||
// The value-typed payload (v1) carries the replacement Rest; it
|
||||
// is placed at the deleted event's voice and position.
|
||||
let mut replacement = rest.clone();
|
||||
replacement.voice = voice_id;
|
||||
replacement.position = event.position().clone();
|
||||
score
|
||||
.events
|
||||
.insert(replacement)
|
||||
.insert(Event::Rest(replacement))
|
||||
.expect("replacement-rest preconditions were checked");
|
||||
if let Some((region_index, instance_index, voice_index)) =
|
||||
graph_voice_location(score, voice_id)
|
||||
|
|
@ -985,11 +945,11 @@ impl<'a> Reducer<'a> {
|
|||
.staff_instances_mut()
|
||||
.expect("an event voice belongs to staff-based content")[instance_index]
|
||||
.voices[voice_index];
|
||||
if !voice.events.contains(new_rest) {
|
||||
if !voice.events.contains(&new_rest) {
|
||||
let index = removed_event_index
|
||||
.unwrap_or(voice.events.len())
|
||||
.min(voice.events.len());
|
||||
voice.events.insert(index, *new_rest);
|
||||
voice.events.insert(index, new_rest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1096,47 +1056,21 @@ impl<'a> Reducer<'a> {
|
|||
let Some(score) = self.graph.as_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
let event_endpoints: Option<Vec<EventId>> = op
|
||||
.structure
|
||||
.endpoints
|
||||
.iter()
|
||||
.map(|endpoint| match endpoint {
|
||||
TypedObjectId::Event(event) => Some(*event),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
match (op.structure.id, event_endpoints.as_deref()) {
|
||||
(TypedObjectId::Slur(id), Some([start, end])) => {
|
||||
score.cross_cutting.slurs.push(Slur {
|
||||
id,
|
||||
start_event: *start,
|
||||
end_event: *end,
|
||||
});
|
||||
// The value-typed payload (v1) carries the real structure with its rich
|
||||
// fields, so materialization clones it directly rather than rebuilding a
|
||||
// default from the reference-level projection.
|
||||
match &op.structure {
|
||||
CrossCuttingValue::Slur(slur) => score.cross_cutting.slurs.push(slur.clone()),
|
||||
CrossCuttingValue::Beam(beam) => {
|
||||
if beam.events.len() < 2 {
|
||||
return Err(PreconditionFailureReason::TargetMissing);
|
||||
}
|
||||
score.cross_cutting.beams.push(beam.clone());
|
||||
}
|
||||
(TypedObjectId::Beam(id), Some(events)) if events.len() >= 2 => {
|
||||
score.cross_cutting.beams.push(Beam {
|
||||
id,
|
||||
events: events.to_vec(),
|
||||
level: 1,
|
||||
});
|
||||
CrossCuttingValue::Tie(tie) => score.cross_cutting.ties.push(tie.clone()),
|
||||
CrossCuttingValue::Spanner(spanner) => {
|
||||
score.cross_cutting.spanners.push(spanner.clone())
|
||||
}
|
||||
(TypedObjectId::Tie(id), Some([start, end])) => {
|
||||
score.cross_cutting.ties.push(Tie {
|
||||
id,
|
||||
start_event: *start,
|
||||
end_event: *end,
|
||||
pitch_pairing: None,
|
||||
class: TieClass::LaissezVibrer,
|
||||
});
|
||||
}
|
||||
(TypedObjectId::Slur(_) | TypedObjectId::Beam(_) | TypedObjectId::Tie(_), _) => {
|
||||
return Err(PreconditionFailureReason::TargetMissing);
|
||||
}
|
||||
// The reference-level prototype payload does not contain the rich
|
||||
// fields needed to instantiate other cross-cutting variants. Their
|
||||
// canonical identity/reference projection remains in `state`.
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -1199,11 +1133,9 @@ impl<'a> Reducer<'a> {
|
|||
}
|
||||
}
|
||||
};
|
||||
let anchor = TimeAnchor::Region {
|
||||
id: op.region,
|
||||
edge: RegionEdge::Start,
|
||||
offset: AnchorOffset::Musical(MusicalDuration(op.anchor.0.clone())),
|
||||
};
|
||||
// The value-typed payload (v1) carries the full TimeAnchor, so the
|
||||
// graph break is the anchor itself rather than a reconstructed one.
|
||||
let anchor = op.anchor.clone();
|
||||
if op.present {
|
||||
if !breaks.contains(&anchor) {
|
||||
breaks.push(anchor);
|
||||
|
|
@ -1213,20 +1145,26 @@ impl<'a> Reducer<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
// The LWW bucketing key is the anchor's resolved musical position.
|
||||
self.breaks
|
||||
.insert((op.region, op.anchor.clone()), op.present);
|
||||
.insert((op.region, op.resolved_position()), op.present);
|
||||
OperationEffect::Applied
|
||||
}
|
||||
|
||||
fn insert_event(&mut self, env: &OperationEnvelope, op: &InsertEventOp) -> OperationEffect {
|
||||
if !op.duration.is_positive() {
|
||||
// The reduction keys are read from the carried event value (v1).
|
||||
let event_id = op.event_id();
|
||||
let orig_voice = op.voice();
|
||||
let op_position = op.musical_position();
|
||||
let op_duration = op.musical_duration();
|
||||
if !op_duration.is_positive() {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::EventDurationInvalid,
|
||||
},
|
||||
};
|
||||
}
|
||||
let ev_obj = TypedObjectId::Event(op.event);
|
||||
let ev_obj = TypedObjectId::Event(event_id);
|
||||
match self.objects.get(&ev_obj) {
|
||||
Some(ObjectState::Live) => {
|
||||
return OperationEffect::NoOp {
|
||||
|
|
@ -1248,7 +1186,7 @@ impl<'a> Reducer<'a> {
|
|||
}
|
||||
}
|
||||
};
|
||||
let voice_obj = TypedObjectId::Voice(op.voice);
|
||||
let voice_obj = TypedObjectId::Voice(orig_voice);
|
||||
match self.objects.get(&voice_obj) {
|
||||
Some(ObjectState::Tombstoned { .. }) => {
|
||||
return OperationEffect::NoOp {
|
||||
|
|
@ -1266,13 +1204,13 @@ impl<'a> Reducer<'a> {
|
|||
}
|
||||
|
||||
let promotion = self.promotion.get(&env.id).copied();
|
||||
let target_voice = promotion.map(|(voice, _)| voice).unwrap_or(op.voice);
|
||||
let target_voice = promotion.map(|(voice, _)| voice).unwrap_or(orig_voice);
|
||||
if self
|
||||
.voice_occupancy
|
||||
.get(&target_voice)
|
||||
.is_some_and(|events| {
|
||||
events.iter().any(|(position, duration, _)| {
|
||||
intervals_overlap(position, duration, &op.position, &op.duration)
|
||||
intervals_overlap(position, duration, &op_position, &op_duration)
|
||||
})
|
||||
})
|
||||
{
|
||||
|
|
@ -1299,7 +1237,7 @@ impl<'a> Reducer<'a> {
|
|||
self.note_minted(env, pv);
|
||||
repairs.push(RepairRecord {
|
||||
kind: RepairKind::VoicePromoted {
|
||||
from: op.voice,
|
||||
from: orig_voice,
|
||||
to: promoted,
|
||||
},
|
||||
target: pv,
|
||||
|
|
@ -1310,18 +1248,18 @@ impl<'a> Reducer<'a> {
|
|||
self.minted_by.insert(ev_obj, env.id);
|
||||
self.note_minted(env, ev_obj);
|
||||
let mut pitches = Vec::new();
|
||||
for &p in &op.pitches {
|
||||
for p in op.pitch_ids() {
|
||||
let p_obj = TypedObjectId::Pitch(p);
|
||||
self.objects.insert(p_obj, ObjectState::Live);
|
||||
self.minted_by.insert(p_obj, env.id);
|
||||
self.note_minted(env, p_obj);
|
||||
pitches.push(p);
|
||||
}
|
||||
self.event_pitches.insert(op.event, pitches);
|
||||
self.event_pitches.insert(event_id, pitches);
|
||||
self.voice_occupancy.entry(target_voice).or_default().push((
|
||||
op.position.clone(),
|
||||
op.duration.clone(),
|
||||
op.event,
|
||||
op_position,
|
||||
op_duration,
|
||||
event_id,
|
||||
));
|
||||
|
||||
if repairs.is_empty() {
|
||||
|
|
@ -1395,17 +1333,24 @@ impl<'a> Reducer<'a> {
|
|||
// Tuplet compensation.
|
||||
match &op.tuplet_compensation {
|
||||
TupletCompensation::NotInTuplet => {}
|
||||
TupletCompensation::ReplaceWithRest { new_rest, duration } => {
|
||||
let rest_obj = TypedObjectId::Event(*new_rest);
|
||||
TupletCompensation::ReplaceWithRest { rest } => {
|
||||
let new_rest = rest.id;
|
||||
let rest_duration = match &rest.duration {
|
||||
EventDuration::Musical(d) => d.clone(),
|
||||
EventDuration::WallClock(_) | EventDuration::Indeterminate(_) => {
|
||||
MusicalDuration::zero()
|
||||
}
|
||||
};
|
||||
let rest_obj = TypedObjectId::Event(new_rest);
|
||||
self.objects.insert(rest_obj, ObjectState::Live);
|
||||
self.minted_by.insert(rest_obj, env.id);
|
||||
self.note_minted(env, rest_obj);
|
||||
self.event_pitches.insert(*new_rest, Vec::new());
|
||||
self.event_pitches.insert(new_rest, Vec::new());
|
||||
if let Some((voice, position, _)) = &deleted_placement {
|
||||
self.voice_occupancy.entry(*voice).or_default().push((
|
||||
position.clone(),
|
||||
duration.clone(),
|
||||
*new_rest,
|
||||
rest_duration,
|
||||
new_rest,
|
||||
));
|
||||
}
|
||||
repairs.push(RepairRecord {
|
||||
|
|
@ -1476,15 +1421,15 @@ impl<'a> Reducer<'a> {
|
|||
|
||||
match self.last_respell.get(&op.pitch).copied() {
|
||||
None => {
|
||||
self.spellings.insert(op.pitch, op.spelling);
|
||||
self.spellings.insert(op.pitch, op.spelling.clone());
|
||||
self.last_respell.insert(op.pitch, env.id);
|
||||
OperationEffect::Applied
|
||||
}
|
||||
Some(prev_op) => {
|
||||
let prev_spelling = self.spellings.get(&op.pitch).copied();
|
||||
let prev_spelling = self.spellings.get(&op.pitch).cloned();
|
||||
let concurrent = self.concurrent(env.id, prev_op);
|
||||
if concurrent {
|
||||
if prev_spelling == Some(op.spelling) {
|
||||
if prev_spelling.as_ref() == Some(&op.spelling) {
|
||||
// Identical concurrent respelling: idempotent.
|
||||
OperationEffect::NoOp {
|
||||
reason: NoOpReason::AlreadyApplied,
|
||||
|
|
@ -1495,7 +1440,7 @@ impl<'a> Reducer<'a> {
|
|||
// earlier op is recorded as the loser. (Prototype
|
||||
// convention: the winner carries the Conflicted effect;
|
||||
// see DECISIONS.md.)
|
||||
self.spellings.insert(op.pitch, op.spelling);
|
||||
self.spellings.insert(op.pitch, op.spelling.clone());
|
||||
self.last_respell.insert(op.pitch, env.id);
|
||||
let conflict = ConflictRecord::new(
|
||||
ConflictKind::StructuralFieldCollision {
|
||||
|
|
@ -1512,7 +1457,7 @@ impl<'a> Reducer<'a> {
|
|||
}
|
||||
} else {
|
||||
// Causally-ordered re-respell: intentional overwrite.
|
||||
self.spellings.insert(op.pitch, op.spelling);
|
||||
self.spellings.insert(op.pitch, op.spelling.clone());
|
||||
self.last_respell.insert(op.pitch, env.id);
|
||||
OperationEffect::Applied
|
||||
}
|
||||
|
|
@ -1525,7 +1470,8 @@ impl<'a> Reducer<'a> {
|
|||
env: &OperationEnvelope,
|
||||
op: &CreateCrossCuttingOp,
|
||||
) -> OperationEffect {
|
||||
let sid = op.structure.id;
|
||||
let sid = op.structure.id();
|
||||
let endpoints = op.structure.endpoints();
|
||||
match self.objects.get(&sid) {
|
||||
Some(ObjectState::Live) => {
|
||||
return OperationEffect::NoOp {
|
||||
|
|
@ -1540,7 +1486,7 @@ impl<'a> Reducer<'a> {
|
|||
None => {}
|
||||
}
|
||||
// Endpoints must exist (live).
|
||||
for e in &op.structure.endpoints {
|
||||
for e in &endpoints {
|
||||
if !matches!(self.objects.get(e), Some(ObjectState::Live)) {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
|
|
@ -1557,7 +1503,7 @@ impl<'a> Reducer<'a> {
|
|||
self.objects.insert(sid, ObjectState::Live);
|
||||
self.minted_by.insert(sid, env.id);
|
||||
self.note_minted(env, sid);
|
||||
self.structures.insert(sid, op.structure.endpoints.clone());
|
||||
self.structures.insert(sid, endpoints);
|
||||
OperationEffect::Applied
|
||||
}
|
||||
|
||||
|
|
@ -1617,16 +1563,16 @@ impl<'a> Reducer<'a> {
|
|||
incompatible_events.insert(*event_id);
|
||||
continue;
|
||||
};
|
||||
let compatible = match op.new_time_model {
|
||||
crate::payload::RegionTimeModelTag::Metric => matches!(
|
||||
let compatible = match &op.new_time_model {
|
||||
RegionTimeModel::Metric(_) => matches!(
|
||||
(event.position(), event.duration()),
|
||||
(EventPosition::Musical(_), EventDuration::Musical(_))
|
||||
),
|
||||
crate::payload::RegionTimeModelTag::Proportional => matches!(
|
||||
RegionTimeModel::Proportional(_) => matches!(
|
||||
(event.position(), event.duration()),
|
||||
(EventPosition::WallClock(_), EventDuration::WallClock(_))
|
||||
),
|
||||
crate::payload::RegionTimeModelTag::Aleatoric => true,
|
||||
RegionTimeModel::Aleatoric(_) => true,
|
||||
};
|
||||
if !compatible {
|
||||
incompatible_events.insert(*event_id);
|
||||
|
|
@ -1641,10 +1587,7 @@ impl<'a> Reducer<'a> {
|
|||
.filter(|event| !mapped.contains(event))
|
||||
.copied(),
|
||||
);
|
||||
if matches!(
|
||||
op.new_time_model,
|
||||
crate::payload::RegionTimeModelTag::Proportional
|
||||
) {
|
||||
if matches!(op.new_time_model, RegionTimeModel::Proportional(_)) {
|
||||
// Reassign carries musical positions in the current
|
||||
// prototype schema, so it cannot satisfy a proportional
|
||||
// region's wall-clock coordinate discipline.
|
||||
|
|
@ -1693,31 +1636,11 @@ impl<'a> Reducer<'a> {
|
|||
}
|
||||
}
|
||||
}
|
||||
// The value-typed payload (v1) carries the real target model, so the
|
||||
// region adopts it directly rather than rebuilding a default from a
|
||||
// discriminator tag.
|
||||
let region = &mut score.canvas.regions[region_index];
|
||||
let wallclock_duration = region
|
||||
.time_extent
|
||||
.as_wallclock()
|
||||
.and_then(|(start, end)| end.checked_sub(start))
|
||||
.filter(|duration| *duration > 0)
|
||||
.unwrap_or(1);
|
||||
region.time_model = match op.new_time_model {
|
||||
crate::payload::RegionTimeModelTag::Metric => {
|
||||
RegionTimeModel::Metric(MetricTimeModel::default())
|
||||
}
|
||||
crate::payload::RegionTimeModelTag::Proportional => {
|
||||
RegionTimeModel::Proportional(ProportionalTimeModel {
|
||||
duration: WallClockDuration(wallclock_duration),
|
||||
})
|
||||
}
|
||||
crate::payload::RegionTimeModelTag::Aleatoric => {
|
||||
RegionTimeModel::Aleatoric(AleatoricTimeModel {
|
||||
ordering: EventOrderingDAG::default(),
|
||||
anchoring: AleatoricAnchoringDiscipline::FreelyMixed,
|
||||
bounds: BTreeMap::new(),
|
||||
duration_hint: WallClockDuration(wallclock_duration),
|
||||
})
|
||||
}
|
||||
};
|
||||
region.time_model = op.new_time_model.clone();
|
||||
}
|
||||
self.migrated_regions.insert(op.region);
|
||||
self.region_migrator.insert(op.region, env.id);
|
||||
|
|
@ -2307,12 +2230,14 @@ mod tests {
|
|||
payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||||
// Voice and staff instance live in a shared (author-independent)
|
||||
// namespace so two authors can target the same voice.
|
||||
voice: VoiceId::new(ReplicaId(9), voice),
|
||||
staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
|
||||
event: EventId::new(ReplicaId(replica), event),
|
||||
position: pos(pos_units),
|
||||
duration: epiphany_core::MusicalDuration::whole(),
|
||||
pitches: vec![],
|
||||
event: crate::valuegen::insert_event_value(
|
||||
EventId::new(ReplicaId(replica), event),
|
||||
VoiceId::new(ReplicaId(9), voice),
|
||||
pos(pos_units),
|
||||
epiphany_core::MusicalDuration::whole(),
|
||||
&[],
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -2370,7 +2295,6 @@ mod tests {
|
|||
use crate::conflict::{ConflictResolutionState, ResolutionAction};
|
||||
use crate::payload::{ResolveConflictPayload, RespellPitchOp};
|
||||
use epiphany_core::PitchId;
|
||||
use epiphany_determinism::ContentHash;
|
||||
|
||||
let pitch = PitchId::new(ReplicaId(9), 500);
|
||||
|
||||
|
|
@ -2379,7 +2303,13 @@ mod tests {
|
|||
if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) =
|
||||
insert_env.payload
|
||||
{
|
||||
op.pitches = vec![pitch];
|
||||
op.event = crate::valuegen::insert_event_value(
|
||||
op.event_id(),
|
||||
op.voice(),
|
||||
op.musical_position(),
|
||||
op.musical_duration(),
|
||||
&[pitch],
|
||||
);
|
||||
}
|
||||
|
||||
// Two concurrent, differing respellings of `pitch`, both causally after
|
||||
|
|
@ -2395,7 +2325,7 @@ mod tests {
|
|||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch,
|
||||
spelling: ContentHash([byte; 32]),
|
||||
spelling: crate::valuegen::spelling(byte),
|
||||
})),
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -19,9 +19,9 @@ use epiphany_core::{
|
|||
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
|
||||
StaffInstanceId, TransactionId, TypedObjectId, VoiceId, WallClockTime,
|
||||
};
|
||||
use epiphany_determinism::{fuzz::SplitMix64, ContentHash};
|
||||
use epiphany_determinism::fuzz::SplitMix64;
|
||||
use epiphany_ops::{
|
||||
canonical_reduction_order, well_formed, AuthorId, CausalContext, ConflictKind,
|
||||
canonical_reduction_order, valuegen, well_formed, AuthorId, CausalContext, ConflictKind,
|
||||
HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect,
|
||||
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
|
||||
PendingReason, PreconditionFailureReason, RespellPitchOp, TransactionDescriptor,
|
||||
|
|
@ -69,19 +69,21 @@ fn insert_span(
|
|||
duration: RationalTime,
|
||||
) -> OperationPayload {
|
||||
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||||
voice: VoiceId::new(ReplicaId(9), voice),
|
||||
staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
|
||||
event: EventId::new(ReplicaId(9), event),
|
||||
position: MusicalPosition(position),
|
||||
duration: MusicalDuration(duration),
|
||||
pitches: vec![PitchId::new(ReplicaId(9), event)],
|
||||
event: valuegen::insert_event_value(
|
||||
EventId::new(ReplicaId(9), event),
|
||||
VoiceId::new(ReplicaId(9), voice),
|
||||
MusicalPosition(position),
|
||||
MusicalDuration(duration),
|
||||
&[PitchId::new(ReplicaId(9), event)],
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
fn respell(pitch: u64, spelling: u8) -> OperationPayload {
|
||||
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(ReplicaId(9), pitch),
|
||||
spelling: ContentHash([spelling; 32]),
|
||||
spelling: valuegen::spelling(spelling),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -370,7 +372,7 @@ fn failed_transaction_rolls_back_member_generated_conflicts() {
|
|||
|
||||
assert_eq!(
|
||||
state.spellings.get(&PitchId::new(ReplicaId(9), 100)),
|
||||
Some(&ContentHash([1; 32]))
|
||||
Some(&valuegen::spelling(1))
|
||||
);
|
||||
assert_eq!(state.conflicts.records().len(), 1);
|
||||
assert!(matches!(
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ use epiphany_core::{
|
|||
WallClockTime,
|
||||
};
|
||||
use epiphany_ops::{
|
||||
AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp,
|
||||
CrossCuttingRef, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason, OperationEffect,
|
||||
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
|
||||
PositionRemapping, PreconditionFailureReason, RegionTimeModelTag, SetUserSystemBreakOp,
|
||||
valuegen, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictKind, CreateCrossCuttingOp,
|
||||
CrossCuttingValue, DeleteEventOp, HybridLogicalClock, InsertEventOp, NoOpReason,
|
||||
OperationEffect, OperationEnvelope, OperationKind, OperationPayload, OperationSet,
|
||||
OperationStamp, PositionRemapping, PreconditionFailureReason, SetUserSystemBreakOp,
|
||||
TransactionCategory, TransactionDescriptor, TupletCompensation, UndoPolicy,
|
||||
UndoTransactionPayload,
|
||||
};
|
||||
|
|
@ -48,12 +48,14 @@ fn insert(
|
|||
position: i32,
|
||||
) -> OperationPayload {
|
||||
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||||
voice,
|
||||
staff_instance,
|
||||
event,
|
||||
position: MusicalPosition(RationalTime::from_int(position)),
|
||||
duration: MusicalDuration::whole(),
|
||||
pitches: vec![pitch],
|
||||
event: valuegen::insert_event_value(
|
||||
event,
|
||||
voice,
|
||||
MusicalPosition(RationalTime::from_int(position)),
|
||||
MusicalDuration::whole(),
|
||||
&[pitch],
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -352,7 +354,7 @@ fn system_break_lww_state_is_materialized_in_the_region() {
|
|||
None,
|
||||
OperationPayload::Primitive(OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
||||
region,
|
||||
anchor: position.clone(),
|
||||
anchor: valuegen::region_start_anchor(region, position.clone()),
|
||||
present: true,
|
||||
})),
|
||||
);
|
||||
|
|
@ -390,7 +392,7 @@ fn migration_computes_incompatible_events_from_the_graph() {
|
|||
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
|
||||
ChangeRegionTimeModelOp {
|
||||
region,
|
||||
new_time_model: RegionTimeModelTag::Proportional,
|
||||
new_time_model: valuegen::proportional_model(),
|
||||
declared_incompatible: Vec::new(),
|
||||
remapping: PositionRemapping::PreserveTime,
|
||||
},
|
||||
|
|
@ -422,14 +424,7 @@ fn create_cross_cutting_materializes_supported_graph_structures() {
|
|||
CausalContext::new(),
|
||||
None,
|
||||
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: CrossCuttingRef {
|
||||
id: TypedObjectId::Slur(slur),
|
||||
endpoints: endpoints
|
||||
.iter()
|
||||
.copied()
|
||||
.map(TypedObjectId::Event)
|
||||
.collect(),
|
||||
},
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(slur, endpoints[0], endpoints[1])),
|
||||
})),
|
||||
);
|
||||
let mut set = OperationSet::new();
|
||||
|
|
@ -459,7 +454,7 @@ fn causally_ordered_time_migrations_do_not_conflict() {
|
|||
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
|
||||
ChangeRegionTimeModelOp {
|
||||
region,
|
||||
new_time_model: RegionTimeModelTag::Aleatoric,
|
||||
new_time_model: valuegen::aleatoric_model(),
|
||||
declared_incompatible: Vec::new(),
|
||||
remapping: PositionRemapping::PreserveTime,
|
||||
},
|
||||
|
|
@ -474,7 +469,7 @@ fn causally_ordered_time_migrations_do_not_conflict() {
|
|||
OperationPayload::Primitive(OperationKind::ChangeRegionTimeModel(
|
||||
ChangeRegionTimeModelOp {
|
||||
region,
|
||||
new_time_model: RegionTimeModelTag::Metric,
|
||||
new_time_model: valuegen::metric_model(),
|
||||
declared_incompatible: Vec::new(),
|
||||
remapping: PositionRemapping::PreserveTime,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -297,10 +297,9 @@ pub fn materialized_score(seed: u64) -> (Score, Vec<u8>) {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use epiphany_core::{PitchId, ReplicaId, WallClockTime};
|
||||
use epiphany_determinism::ContentHash;
|
||||
use epiphany_core::{PitchId, PitchSpelling, ReplicaId, WallClockTime};
|
||||
use epiphany_ops::{
|
||||
AuthorId, CausalContext, HybridLogicalClock, OperationKind, OperationPayload,
|
||||
valuegen, AuthorId, CausalContext, HybridLogicalClock, OperationKind, OperationPayload,
|
||||
OperationStamp, RespellPitchOp,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
|
@ -354,21 +353,21 @@ mod tests {
|
|||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(ReplicaId(0x0B7E_C700), 0),
|
||||
spelling: ContentHash([spelling; 32]),
|
||||
spelling: valuegen::spelling(spelling),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// A deliberately broken, order-*dependent* reducer: last spelling wins by
|
||||
/// arrival order. Stands in for the bug the harness must catch.
|
||||
fn naive_arrival_order_spelling(envs: &[OperationEnvelope]) -> Option<[u8; 32]> {
|
||||
let mut last: BTreeMap<PitchId, [u8; 32]> = BTreeMap::new();
|
||||
fn naive_arrival_order_spelling(envs: &[OperationEnvelope]) -> Option<PitchSpelling> {
|
||||
let mut last: BTreeMap<PitchId, PitchSpelling> = BTreeMap::new();
|
||||
for e in envs {
|
||||
if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &e.payload {
|
||||
last.insert(op.pitch, op.spelling.0);
|
||||
last.insert(op.pitch, op.spelling.clone());
|
||||
}
|
||||
}
|
||||
last.values().next().copied()
|
||||
last.values().next().cloned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -20,9 +20,8 @@ use epiphany_core::{
|
|||
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
|
||||
StaffInstanceId, VoiceId, WallClockTime,
|
||||
};
|
||||
use epiphany_determinism::ContentHash;
|
||||
use epiphany_ops::{
|
||||
AuthorId, CausalContext, HybridLogicalClock, InsertEventOp, MaterializedState,
|
||||
valuegen, AuthorId, CausalContext, HybridLogicalClock, InsertEventOp, MaterializedState,
|
||||
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationSlot,
|
||||
OperationStamp, RespellPitchOp,
|
||||
};
|
||||
|
|
@ -150,7 +149,7 @@ fn respell_env(id: OperationId, phys: i64, pitch: u64, spelling: u8) -> Operatio
|
|||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(OBJ_REPLICA, pitch),
|
||||
spelling: ContentHash([spelling; 32]),
|
||||
spelling: valuegen::spelling(spelling),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
@ -165,12 +164,14 @@ fn insert_pitch_env(id: OperationId, phys: i64, event: u64, pitch: u64) -> Opera
|
|||
causal_context: CausalContext::new(),
|
||||
transaction: None,
|
||||
payload: OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||||
voice: VoiceId::new(OBJ_REPLICA, 0),
|
||||
staff_instance: StaffInstanceId::new(OBJ_REPLICA, 0),
|
||||
event: EventId::new(OBJ_REPLICA, event),
|
||||
position: MusicalPosition(RationalTime::from_int(0)),
|
||||
duration: MusicalDuration::whole(),
|
||||
pitches: vec![PitchId::new(OBJ_REPLICA, pitch)],
|
||||
event: valuegen::insert_event_value(
|
||||
EventId::new(OBJ_REPLICA, event),
|
||||
VoiceId::new(OBJ_REPLICA, 0),
|
||||
MusicalPosition(RationalTime::from_int(0)),
|
||||
MusicalDuration::whole(),
|
||||
&[PitchId::new(OBJ_REPLICA, pitch)],
|
||||
),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,20 +28,21 @@ use epiphany_determinism::{
|
|||
CanonicalEncode, CanonicalF64, ChunkId, ContentHash, DomainTag, QuantizedCoord, Tolerance,
|
||||
ToleranceClass, ToleranceGovernance,
|
||||
};
|
||||
use epiphany_ops::valuegen;
|
||||
use epiphany_ops::{
|
||||
AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId,
|
||||
ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
|
||||
ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingRef, DeleteEventOp,
|
||||
ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
|
||||
ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, IntegrityAnomaly,
|
||||
IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, NoOpReason, ObjectKind,
|
||||
ObjectState, OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId,
|
||||
OperationPayload, OperationSet, OperationStamp, PendingReason, PositionRemapping,
|
||||
PreconditionFailureReason, PreconditionFailureRegistryId, ReanchorReason,
|
||||
ReanchorReasonRegistryId, ReanchorResult, RegionTimeModelTag, RepairKind, RepairKindRegistryId,
|
||||
RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction,
|
||||
ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs,
|
||||
SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TupletCompensation,
|
||||
TupletCompensationKind, UndoPolicy, UndoTransactionPayload,
|
||||
ReanchorReasonRegistryId, ReanchorResult, RepairKind, RepairKindRegistryId, RepairRecord,
|
||||
ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId,
|
||||
ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, SetUserSystemBreakOp,
|
||||
TransactionCategory, TransactionDescriptor, TupletCompensation, TupletCompensationKind,
|
||||
UndoPolicy, UndoTransactionPayload,
|
||||
};
|
||||
|
||||
use crate::rng::Rng;
|
||||
|
|
@ -634,47 +635,53 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP
|
|||
_ => {}
|
||||
}
|
||||
let kind = match rng.below(8) {
|
||||
0 => OperationKind::InsertEvent(InsertEventOp {
|
||||
voice: VoiceId::new(OBJ_REPLICA, rng.below(4)),
|
||||
staff_instance: StaffInstanceId::new(OBJ_REPLICA, rng.below(2)),
|
||||
event: obj_event(rng.below(events)),
|
||||
position: MusicalPosition(RationalTime::from_int(rng.below(events) as i32)),
|
||||
duration: MusicalDuration::whole(),
|
||||
pitches: if rng.boolean() {
|
||||
0 => {
|
||||
let pitches = if rng.boolean() {
|
||||
vec![obj_pitch(rng.below(pitches))]
|
||||
} else {
|
||||
vec![]
|
||||
},
|
||||
}),
|
||||
};
|
||||
OperationKind::InsertEvent(InsertEventOp {
|
||||
staff_instance: StaffInstanceId::new(OBJ_REPLICA, rng.below(2)),
|
||||
event: valuegen::insert_event_value(
|
||||
obj_event(rng.below(events)),
|
||||
VoiceId::new(OBJ_REPLICA, rng.below(4)),
|
||||
MusicalPosition(RationalTime::from_int(rng.below(events) as i32)),
|
||||
MusicalDuration::whole(),
|
||||
&pitches,
|
||||
),
|
||||
})
|
||||
}
|
||||
1 => OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event: obj_event(rng.below(events)),
|
||||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||
}),
|
||||
2 => OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: obj_pitch(rng.below(pitches)),
|
||||
spelling: ContentHash([(rng.below(4) as u8) + 1; 32]),
|
||||
spelling: valuegen::spelling(rng.below(4) as u8 + 1),
|
||||
}),
|
||||
3 => OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
|
||||
structure: CrossCuttingRef {
|
||||
id: TypedObjectId::Slur(SlurId::new(OBJ_REPLICA, rng.below(events))),
|
||||
endpoints: vec![
|
||||
TypedObjectId::Event(obj_event(rng.below(events))),
|
||||
TypedObjectId::Event(obj_event(rng.below(events))),
|
||||
],
|
||||
},
|
||||
structure: CrossCuttingValue::Slur(valuegen::slur(
|
||||
SlurId::new(OBJ_REPLICA, rng.below(events)),
|
||||
obj_event(rng.below(events)),
|
||||
obj_event(rng.below(events)),
|
||||
)),
|
||||
}),
|
||||
4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
||||
region: RegionId::new(OBJ_REPLICA, 0),
|
||||
anchor: MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
|
||||
anchor: valuegen::region_start_anchor(
|
||||
RegionId::new(OBJ_REPLICA, 0),
|
||||
MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
|
||||
),
|
||||
present: rng.boolean(),
|
||||
}),
|
||||
5 => OperationKind::ChangeRegionTimeModel(ChangeRegionTimeModelOp {
|
||||
region: RegionId::new(OBJ_REPLICA, rng.below(2)),
|
||||
new_time_model: *rng.choose(&[
|
||||
RegionTimeModelTag::Metric,
|
||||
RegionTimeModelTag::Proportional,
|
||||
RegionTimeModelTag::Aleatoric,
|
||||
]),
|
||||
new_time_model: match rng.below(3) {
|
||||
0 => valuegen::metric_model(),
|
||||
1 => valuegen::proportional_model(),
|
||||
_ => valuegen::aleatoric_model(),
|
||||
},
|
||||
declared_incompatible: Vec::new(),
|
||||
remapping: PositionRemapping::PreserveTime,
|
||||
}),
|
||||
|
|
@ -853,12 +860,14 @@ pub const TWO_STAFF_EVENTS_PER_STAFF: u64 = TWO_STAFF_BARS * EVENTS_PER_BAR;
|
|||
/// whole-notes, i.e. two per 4/4 bar).
|
||||
fn insert_at(instance: u64, voice: u64, event: u64, pitch: u64, index: u64) -> OperationPayload {
|
||||
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||||
voice: VoiceId::new(OBJ_REPLICA, voice),
|
||||
staff_instance: StaffInstanceId::new(OBJ_REPLICA, instance),
|
||||
event: obj_event(event),
|
||||
position: MusicalPosition(RationalTime::new(index as i64, EVENTS_PER_BAR as i64).unwrap()),
|
||||
duration: MusicalDuration(RationalTime::new(1, EVENTS_PER_BAR as i64).unwrap()),
|
||||
pitches: vec![obj_pitch(pitch)],
|
||||
event: valuegen::insert_event_value(
|
||||
obj_event(event),
|
||||
VoiceId::new(OBJ_REPLICA, voice),
|
||||
MusicalPosition(RationalTime::new(index as i64, EVENTS_PER_BAR as i64).unwrap()),
|
||||
MusicalDuration(RationalTime::new(1, EVENTS_PER_BAR as i64).unwrap()),
|
||||
&[obj_pitch(pitch)],
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -892,7 +901,7 @@ pub fn two_staff_edit_session(rng: &mut Rng) -> Vec<OperationEnvelope> {
|
|||
} else {
|
||||
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: obj_pitch(rng.below(total)),
|
||||
spelling: ContentHash([(rng.below(4) as u8) + 1; 32]),
|
||||
spelling: valuegen::spelling(rng.below(4) as u8 + 1),
|
||||
}))
|
||||
};
|
||||
session.author(rng, r, payload);
|
||||
|
|
@ -953,19 +962,21 @@ fn insert_into(
|
|||
index: u64,
|
||||
) -> OperationPayload {
|
||||
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||||
voice,
|
||||
staff_instance,
|
||||
event: obj_event(event),
|
||||
// position = GRAPH_SESSION_OFFSET + index/2 (two half-notes per 4/4 bar).
|
||||
position: MusicalPosition(
|
||||
RationalTime::new(
|
||||
GRAPH_SESSION_OFFSET * EVENTS_PER_BAR as i64 + index as i64,
|
||||
2,
|
||||
)
|
||||
.unwrap(),
|
||||
event: valuegen::insert_event_value(
|
||||
obj_event(event),
|
||||
voice,
|
||||
MusicalPosition(
|
||||
RationalTime::new(
|
||||
GRAPH_SESSION_OFFSET * EVENTS_PER_BAR as i64 + index as i64,
|
||||
2,
|
||||
)
|
||||
.unwrap(),
|
||||
),
|
||||
MusicalDuration(RationalTime::new(1, 2).unwrap()),
|
||||
&[obj_pitch(pitch)],
|
||||
),
|
||||
duration: MusicalDuration(RationalTime::new(1, 2).unwrap()),
|
||||
pitches: vec![obj_pitch(pitch)],
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -1022,7 +1033,7 @@ pub fn graph_edit_session(
|
|||
} else {
|
||||
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: obj_pitch(rng.below(total)),
|
||||
spelling: ContentHash([(rng.below(4) as u8) + 1; 32]),
|
||||
spelling: valuegen::spelling(rng.below(4) as u8 + 1),
|
||||
}))
|
||||
};
|
||||
session.author(rng, r, payload);
|
||||
|
|
@ -1047,14 +1058,14 @@ pub fn content_mutation_pair() -> (Vec<OperationEnvelope>, Vec<OperationEnvelope
|
|||
0,
|
||||
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: obj_pitch(0),
|
||||
spelling: ContentHash([0xAA; 32]),
|
||||
spelling: valuegen::spelling(0xAA),
|
||||
})),
|
||||
);
|
||||
let base = session.out;
|
||||
|
||||
let mut mutated = base.clone();
|
||||
if let OperationPayload::Primitive(OperationKind::RespellPitch(op)) = &mut mutated[1].payload {
|
||||
op.spelling = ContentHash([0xBB; 32]);
|
||||
op.spelling = valuegen::spelling(0xBB);
|
||||
} else {
|
||||
unreachable!("the second op is the respelling");
|
||||
}
|
||||
|
|
@ -1109,12 +1120,12 @@ pub fn equivocating_twin(env: &OperationEnvelope) -> OperationEnvelope {
|
|||
let mut twin = env.clone();
|
||||
twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: obj_pitch(0),
|
||||
spelling: ContentHash([0xEE; 32]),
|
||||
spelling: valuegen::spelling(0xEE),
|
||||
}));
|
||||
if twin.envelope_hash() == env.envelope_hash() {
|
||||
twin.payload = OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: obj_pitch(1),
|
||||
spelling: ContentHash([0x11; 32]),
|
||||
spelling: valuegen::spelling(0x11),
|
||||
}));
|
||||
}
|
||||
twin
|
||||
|
|
@ -1460,7 +1471,7 @@ mod tests {
|
|||
.insert(typed_object_id(&mut rng), object_state(&mut rng));
|
||||
state
|
||||
.spellings
|
||||
.insert(pitch_id(&mut rng), content_hash(&mut rng));
|
||||
.insert(pitch_id(&mut rng), valuegen::spelling(rng.below(7) as u8));
|
||||
state.breaks.insert(
|
||||
(region_id(&mut rng), musical_position(&mut rng)),
|
||||
rng.boolean(),
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ pub mod prepass_harness;
|
|||
|
||||
pub mod convergence;
|
||||
pub mod equivocation;
|
||||
pub mod migration;
|
||||
pub mod negative;
|
||||
|
||||
pub mod bundle_harness;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
@ -30,9 +30,8 @@ use epiphany_core::{
|
|||
EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, ReplicaId,
|
||||
StaffInstanceId, TransactionId, VoiceId, WallClockTime,
|
||||
};
|
||||
use epiphany_determinism::ContentHash;
|
||||
use epiphany_ops::{
|
||||
canonical_reduction_order, AuthorId, CausalContext, ConflictKind, DeleteEventOp,
|
||||
canonical_reduction_order, valuegen, AuthorId, CausalContext, ConflictKind, DeleteEventOp,
|
||||
HybridLogicalClock, InsertEventOp, IntegrityAnomalyKind, NoOpReason, OperationEffect,
|
||||
OperationEnvelope, OperationKind, OperationPayload, OperationSet, OperationStamp,
|
||||
PendingReason, PreconditionFailureReason, RepairKind, RespellPitchOp, TransactionDescriptor,
|
||||
|
|
@ -72,12 +71,14 @@ fn insert_span(
|
|||
duration: RationalTime,
|
||||
) -> OperationPayload {
|
||||
OperationPayload::Primitive(OperationKind::InsertEvent(InsertEventOp {
|
||||
voice: VoiceId::new(OBJ, voice),
|
||||
staff_instance: StaffInstanceId::new(OBJ, 0),
|
||||
event: EventId::new(OBJ, event),
|
||||
position: MusicalPosition(position),
|
||||
duration: MusicalDuration(duration),
|
||||
pitches: vec![PitchId::new(OBJ, event)],
|
||||
event: valuegen::insert_event_value(
|
||||
EventId::new(OBJ, event),
|
||||
VoiceId::new(OBJ, voice),
|
||||
MusicalPosition(position),
|
||||
MusicalDuration(duration),
|
||||
&[PitchId::new(OBJ, event)],
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +94,7 @@ fn insert(voice: u64, event: u64, pos: i64) -> OperationPayload {
|
|||
fn respell(pitch: u64, spelling: u8) -> OperationPayload {
|
||||
OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
|
||||
pitch: PitchId::new(OBJ, pitch),
|
||||
spelling: ContentHash([spelling; 32]),
|
||||
spelling: valuegen::spelling(spelling),
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +256,7 @@ pub fn assert_failed_transaction_rolls_back_member_conflicts() {
|
|||
|
||||
assert_eq!(
|
||||
state.spellings.get(&PitchId::new(OBJ, 100)),
|
||||
Some(&ContentHash([1; 32])),
|
||||
Some(&valuegen::spelling(1)),
|
||||
"the pre-transaction spelling must survive the rollback"
|
||||
);
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@
|
|||
//! module (Agent E has landed). See the crate docs for the harness policy.
|
||||
|
||||
use epiphany_testkit::{
|
||||
bundle_harness, convergence, equivocation, fixtures, generators, layout_stub, negative,
|
||||
roundtrip, Rng,
|
||||
bundle_harness, convergence, equivocation, fixtures, generators, layout_stub, migration,
|
||||
negative, roundtrip, Rng,
|
||||
};
|
||||
|
||||
/// Criterion 1 — **Convergence (real Score).** Overlapping edits to a real
|
||||
|
|
@ -228,3 +228,18 @@ fn manifest_selection_harness() {
|
|||
bundle_harness::run_manifest_selection(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Agent K — Operation Catalog v0→v1 migration.** The shift from
|
||||
/// identifier-only payloads to value-typed payloads ships a one-time migration;
|
||||
/// this is its merge gate (QUICKSTART Agent K acceptance: deterministic and
|
||||
/// equivalence-preserving migration). For each random corpus, the v1 envelopes
|
||||
/// project to their v0 wire shape and migrate back, reducing to byte-identical
|
||||
/// canonical state — and the gate's non-vacuity guard proves a wrong migration
|
||||
/// would be caught. See [`migration`].
|
||||
#[test]
|
||||
fn agent_k_migration_equivalence_gate() {
|
||||
migration::assert_migration_gate_is_not_vacuous();
|
||||
for seed in 0..32u64 {
|
||||
migration::run_migration_equivalence(48, seed.wrapping_mul(0x9E37_79B9).wrapping_add(5));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ code instead is the failure mode this batch exists to prevent.
|
|||
| P12-I1 | `epiphany-layout-ir` / `engrave` / `render-svg` I | The v0 `to_logical`/`to_constrained` pipeline is a **structural placeholder**: each layout object becomes one *arbitrary* glyph (`BRAVURA_METRICS[discriminant % N]`) at `y = 0`, not real notation. Chapter 7 says the *logical* stage has "engraving decisions made"; the spec should clarify which engraving decisions (glyph-by-duration selection, pitch→staff-position, clef/key/meter/barline realization, stems/beams) are core-IR construction versus solver work, so the real-notation engraving has a defined home before it is built next phase. Consequence: the QUICKSTART human visual-acceptance gate ("the SVG visually parses as standard notation") is a **next-phase** gate, *not* met by stub output — this phase's gate is renderer correctness/faithfulness. | G / Pass 12 (Ch 7 engraving boundary) |
|
||||
| P12-I2 | `epiphany-render-svg` / `engrave` I | Stable layout-object id derivation (`MUSCLOID`, Pass-11 item 2.6, deferred to I) is still unwired: the frozen `epiphany-determinism` exposes no `MUSCLOID` tag, so provenance is traced via the provisional `stable_id`. Wiring the ratified derivation is Track A work (already noted in `layout-ir/DECISIONS.md`). | G (determinism tag) / Track A |
|
||||
| P12-I3 | `epiphany-layout-ir` I | The bundled `BRAVURA_METRICS` are *approximations* that disagree with the genuine Bravura outlines the renderer now extracts from the font (e.g. `timeSig4`: metrics bbox `[40,0,1240,2048]` vs real outline ≈ `[0.08,-1.0,1.8,1.004]` staff spaces). Real spacing needs exact metrics; regenerate the metrics table from the font or reconcile it with the outline source. | G / Pass 12 (glyph metrics) |
|
||||
| P12-K1 | `epiphany-ops` K | A v0 `RespellPitch` carried a `ContentHash` *fingerprint* of the spelling, not the `PitchSpelling`. The v0→v1 migration (Operation Catalog, M1) cannot invert a fingerprint, so it recovers the spelling from the score-graph context (an explicit per-pitch spelling attachment whose canonical bytes hash to the fingerprint) and returns `MigrationError::Irreversible` (bundle opens read-only) when the context lacks it. Every other representative payload migrates self-contained; this is the lone exception. Confirm the read-only fallback is the intended disposition vs. requiring a v0 corpus that preserves spelling pre-images. | G / Pass 12 (migration) |
|
||||
|
||||
## Not yet open elsewhere
|
||||
|
||||
Agent I (Track A) has contributed P12-I1..I3 above. Track B (K, J) has not yet
|
||||
contributed items. When they do, append rows; the batch is already open, so they
|
||||
join directly (no new threshold).
|
||||
Agent I (Track A) has contributed P12-I1..I3 above. Track B's Agent K has
|
||||
contributed P12-K1 (Operation Catalog M1). Agent J (Binary Format companion) has
|
||||
not yet contributed; when it does, append rows — the batch is already open, so it
|
||||
joins directly (no new threshold).
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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}
|
||||
Loading…
Reference in New Issue