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

678 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

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

//! 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),
// v1-native (no v0 predecessor): projected verbatim, like Group 14.
OperationPayload::ResolveEquivocation(re) => V0OperationPayload::ResolveEquivocation(*re),
}
}
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()),
// v1-native (Group 1): no lossy v0 form — projected verbatim.
OperationKind::ModifyEvent(op) => V0OperationKind::ModifyEvent(op.clone()),
OperationKind::Transpose(op) => V0OperationKind::Transpose(op.clone()),
OperationKind::InsertIdentifiedPitch(op) => {
V0OperationKind::InsertIdentifiedPitch(op.clone())
}
OperationKind::DeleteIdentifiedPitch(op) => V0OperationKind::DeleteIdentifiedPitch(*op),
OperationKind::ModifyIdentifiedPitch(op) => {
V0OperationKind::ModifyIdentifiedPitch(op.clone())
}
// v1-native (Group 2): projected verbatim.
OperationKind::DeleteCrossCutting(op) => V0OperationKind::DeleteCrossCutting(*op),
OperationKind::ModifyCrossCutting(op) => V0OperationKind::ModifyCrossCutting(op.clone()),
// v1-native (Group 3): projected verbatim.
OperationKind::CreateRegion(op) => V0OperationKind::CreateRegion(op.clone()),
OperationKind::DeleteRegion(op) => V0OperationKind::DeleteRegion(*op),
OperationKind::CreateStaffInstance(op) => V0OperationKind::CreateStaffInstance(op.clone()),
OperationKind::DeleteStaffInstance(op) => V0OperationKind::DeleteStaffInstance(*op),
OperationKind::CreateVoice(op) => V0OperationKind::CreateVoice(op.clone()),
OperationKind::DeleteVoice(op) => V0OperationKind::DeleteVoice(*op),
// v1-native (Group 4): projected verbatim.
OperationKind::SetMetadata(op) => V0OperationKind::SetMetadata(op.clone()),
OperationKind::SetMetricGrid(op) => V0OperationKind::SetMetricGrid(op.clone()),
OperationKind::SetUserPageBreak(op) => V0OperationKind::SetUserPageBreak(op.clone()),
// v1-native (Phase-3 first tranche): projected verbatim.
OperationKind::CreateStaff(op) => V0OperationKind::CreateStaff(op.clone()),
OperationKind::SetTimeSignature(op) => V0OperationKind::SetTimeSignature(op.clone()),
OperationKind::SetTempoSegment(op) => V0OperationKind::SetTempoSegment(op.clone()),
OperationKind::SetStaffLayout(op) => V0OperationKind::SetStaffLayout(op.clone()),
// Repeat authoring (schema-major-2 revision): projected verbatim.
OperationKind::CreateRepeatStructure(op) => {
V0OperationKind::CreateRepeatStructure(op.clone())
}
OperationKind::DeleteRepeatStructure(op) => V0OperationKind::DeleteRepeatStructure(*op),
// Push 4a: born past v0; projected verbatim.
OperationKind::TransposeInterval(op) => V0OperationKind::TransposeInterval(op.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),
// v1-native: round-trips by identity (deterministic and trivially
// equivalence-preserving; no context needed).
V0OperationPayload::ResolveEquivocation(re) => OperationPayload::ResolveEquivocation(*re),
})
}
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()),
// v1-native (Group 1): identity round-trip (no lossy reconstruction).
V0OperationKind::ModifyEvent(op) => OperationKind::ModifyEvent(op.clone()),
V0OperationKind::Transpose(op) => OperationKind::Transpose(op.clone()),
V0OperationKind::InsertIdentifiedPitch(op) => {
OperationKind::InsertIdentifiedPitch(op.clone())
}
V0OperationKind::DeleteIdentifiedPitch(op) => OperationKind::DeleteIdentifiedPitch(*op),
V0OperationKind::ModifyIdentifiedPitch(op) => {
OperationKind::ModifyIdentifiedPitch(op.clone())
}
// v1-native (Group 2): identity round-trip.
V0OperationKind::DeleteCrossCutting(op) => OperationKind::DeleteCrossCutting(*op),
V0OperationKind::ModifyCrossCutting(op) => OperationKind::ModifyCrossCutting(op.clone()),
// v1-native (Group 3): identity round-trip.
V0OperationKind::CreateRegion(op) => OperationKind::CreateRegion(op.clone()),
V0OperationKind::DeleteRegion(op) => OperationKind::DeleteRegion(*op),
V0OperationKind::CreateStaffInstance(op) => OperationKind::CreateStaffInstance(op.clone()),
V0OperationKind::DeleteStaffInstance(op) => OperationKind::DeleteStaffInstance(*op),
V0OperationKind::CreateVoice(op) => OperationKind::CreateVoice(op.clone()),
V0OperationKind::DeleteVoice(op) => OperationKind::DeleteVoice(*op),
// v1-native (Group 4): identity round-trip.
V0OperationKind::SetMetadata(op) => OperationKind::SetMetadata(op.clone()),
V0OperationKind::SetMetricGrid(op) => OperationKind::SetMetricGrid(op.clone()),
V0OperationKind::SetUserPageBreak(op) => OperationKind::SetUserPageBreak(op.clone()),
// v1-native (Phase-3 first tranche): identity round-trip.
V0OperationKind::CreateStaff(op) => OperationKind::CreateStaff(op.clone()),
V0OperationKind::SetTimeSignature(op) => OperationKind::SetTimeSignature(op.clone()),
V0OperationKind::SetTempoSegment(op) => OperationKind::SetTempoSegment(op.clone()),
V0OperationKind::SetStaffLayout(op) => OperationKind::SetStaffLayout(op.clone()),
// Repeat authoring (schema-major-2 revision): identity round-trip.
V0OperationKind::CreateRepeatStructure(op) => {
OperationKind::CreateRepeatStructure(op.clone())
}
V0OperationKind::TransposeInterval(op) => OperationKind::TransposeInterval(op.clone()),
V0OperationKind::DeleteRepeatStructure(op) => OperationKind::DeleteRepeatStructure(*op),
})
}
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)",
))
}
#[cfg(test)]
mod tests {
//! Direct coverage of the reconstruction branches the corpus-driven
//! equivalence gate (`epiphany-testkit::migration`) does not reach: its
//! generator only emits `NotInTuplet` and a `Slur` create, so the Tie/Beam,
//! tuplet-compensation, Spanner-irreversible, and respell paths are tested
//! here against `project_v1_to_v0` + `migrate_v0_envelope` directly.
use super::*;
use crate::stamp::HybridLogicalClock;
use crate::{CausalContext, OperationStamp};
use epiphany_core::{
BeamId, EventId, IdentityContext, MusicalDuration, PitchId, ReplicaId, Score, SlurId,
Spanner, SpannerId, StaffId, TieId, TimeAnchor, VoiceId,
};
fn env(payload: OperationPayload) -> OperationEnvelope {
let id = epiphany_core::OperationId::new(ReplicaId(3), 0);
OperationEnvelope {
id,
author: crate::support::AuthorId(0),
stamp: OperationStamp::new(
HybridLogicalClock::new(epiphany_core::WallClockTime(1), 0),
id,
),
causal_context: CausalContext::new(),
transaction: None,
payload,
}
}
fn ev(n: u64) -> EventId {
EventId::new(ReplicaId(3), n)
}
/// Round-trip `migrate(project(env))` against an empty context.
fn round_trip(env: &OperationEnvelope) -> Result<OperationEnvelope, MigrationError> {
let ctx = Score::empty(IdentityContext::new(ReplicaId(1)));
migrate_v0_envelope(project_v1_to_v0(env), &ctx)
}
fn primitive(kind: OperationKind) -> OperationPayload {
OperationPayload::Primitive(kind)
}
#[test]
fn tie_and_beam_creates_round_trip_exactly() {
for kind in [
CrossCuttingValue::Tie(valuegen::tie(TieId::new(ReplicaId(3), 1), ev(1), ev(2))),
CrossCuttingValue::Beam(valuegen::beam(
BeamId::new(ReplicaId(3), 1),
vec![ev(1), ev(2), ev(3)],
)),
CrossCuttingValue::Slur(valuegen::slur(SlurId::new(ReplicaId(3), 1), ev(1), ev(2))),
] {
let e = env(primitive(OperationKind::CreateCrossCutting(
CreateCrossCuttingOp { structure: kind },
)));
assert_eq!(round_trip(&e), Ok(e.clone()));
}
}
#[test]
fn spanner_create_is_irreversible() {
// A spanner is anchored by TimeAnchors, not a fixed event pair, so its
// value is not reconstructable from the v0 event-reference projection
// (documented in operation_catalog.tex §CreateCrossCutting).
let spanner = Spanner {
id: SpannerId::new(ReplicaId(3), 1),
start: TimeAnchor::Event {
id: ev(1),
offset: epiphany_core::AnchorOffset::Zero,
},
end: TimeAnchor::Event {
id: ev(2),
offset: epiphany_core::AnchorOffset::Zero,
},
staves: vec![StaffId::new(ReplicaId(3), 0)],
kind: Default::default(),
style: Default::default(),
};
let e = env(primitive(OperationKind::CreateCrossCutting(
CreateCrossCuttingOp {
structure: CrossCuttingValue::Spanner(spanner),
},
)));
assert!(matches!(
round_trip(&e),
Err(MigrationError::Irreversible(_))
));
}
#[test]
fn tuplet_compensation_variants_migrate() {
let voice = VoiceId::new(ReplicaId(3), 0);
// NotInTuplet, RewriteTuplets, CascadeDeleteTuplets round-trip exactly.
for comp in [
TupletCompensation::NotInTuplet,
TupletCompensation::RewriteTuplets {
tuplets: vec![epiphany_core::TupletId::new(ReplicaId(3), 1)],
},
TupletCompensation::CascadeDeleteTuplets {
tuplets: vec![epiphany_core::TupletId::new(ReplicaId(3), 2)],
},
] {
let e = env(primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: ev(1),
tuplet_compensation: comp,
})));
assert_eq!(round_trip(&e), Ok(e.clone()));
}
// ReplaceWithRest preserves the rest's id and duration; its voice is the
// one v0 dropped (recovered from the deleted event's placement at
// reduction, not from the rest value), so the round-trip is not exact —
// it is reduction-faithful (finding #3).
let dur = MusicalDuration::whole();
let e = env(primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: ev(1),
tuplet_compensation: TupletCompensation::ReplaceWithRest {
rest: valuegen::rest_value(ev(9), voice, dur.clone()),
},
})));
let migrated = round_trip(&e).expect("ReplaceWithRest migrates");
let OperationPayload::Primitive(OperationKind::DeleteEvent(op)) = &migrated.payload else {
panic!("expected DeleteEvent");
};
let TupletCompensation::ReplaceWithRest { rest } = &op.tuplet_compensation else {
panic!("expected ReplaceWithRest");
};
assert_eq!(rest.id, ev(9));
assert_eq!(rest.duration, epiphany_core::EventDuration::Musical(dur));
}
#[test]
fn respell_recovers_from_context_else_irreversible() {
let pitch = PitchId::new(ReplicaId(3), 1);
let spelling = valuegen::spelling(42);
let e = env(primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch,
spelling: spelling.clone(),
})));
// With the spelling attached to the context, the respell round-trips.
let mut ctx = Score::empty(IdentityContext::new(ReplicaId(1)));
ctx.spelling_attachments
.push(valuegen::explicit_spelling_attachment(pitch, spelling));
assert_eq!(
migrate_v0_envelope(project_v1_to_v0(&e), &ctx),
Ok(e.clone())
);
// Without it, the fingerprint cannot be inverted: Irreversible (P12-K1).
let empty = Score::empty(IdentityContext::new(ReplicaId(1)));
assert!(matches!(
migrate_v0_envelope(project_v1_to_v0(&e), &empty),
Err(MigrationError::Irreversible(_))
));
}
#[test]
fn group1_and_group2_kinds_round_trip_by_identity() {
// The Group-1/2 (M2) kinds are v1-native: they had no lossy v0 form, so
// project+migrate is the identity (no context needed), and the round-trip
// is exact for every one of them.
let voice = VoiceId::new(ReplicaId(3), 0);
let kinds = [
OperationKind::ModifyEvent(crate::payload::ModifyEventOp {
event: valuegen::insert_event_value(
ev(1),
voice,
epiphany_core::MusicalPosition::origin(),
MusicalDuration::whole(),
&[PitchId::new(ReplicaId(3), 1)],
),
}),
OperationKind::Transpose(crate::payload::TransposeOp {
targets: vec![PitchId::new(ReplicaId(3), 1)],
chromatic_steps: -2,
}),
OperationKind::InsertIdentifiedPitch(crate::payload::InsertIdentifiedPitchOp {
event: ev(1),
pitch: valuegen::identified_pitch(PitchId::new(ReplicaId(3), 2)),
}),
OperationKind::DeleteIdentifiedPitch(crate::payload::DeleteIdentifiedPitchOp {
pitch: PitchId::new(ReplicaId(3), 2),
}),
OperationKind::ModifyIdentifiedPitch(crate::payload::ModifyIdentifiedPitchOp {
pitch: PitchId::new(ReplicaId(3), 1),
value: valuegen::pitch_value_nth(3),
}),
OperationKind::DeleteCrossCutting(crate::payload::DeleteCrossCuttingOp {
structure: epiphany_core::TypedObjectId::Slur(SlurId::new(ReplicaId(3), 5)),
}),
OperationKind::ModifyCrossCutting(crate::payload::ModifyCrossCuttingOp {
structure: CrossCuttingValue::Slur(valuegen::slur(
SlurId::new(ReplicaId(3), 5),
ev(1),
ev(2),
)),
}),
OperationKind::CreateRegion(crate::payload::CreateRegionOp {
region: valuegen::region(epiphany_core::RegionId::new(ReplicaId(3), 7)),
}),
OperationKind::DeleteRegion(crate::payload::DeleteRegionOp {
region: epiphany_core::RegionId::new(ReplicaId(3), 7),
}),
OperationKind::CreateStaffInstance(crate::payload::CreateStaffInstanceOp {
region: epiphany_core::RegionId::new(ReplicaId(3), 7),
instance: valuegen::staff_instance(
epiphany_core::StaffInstanceId::new(ReplicaId(3), 8),
StaffId::new(ReplicaId(3), 1),
),
}),
OperationKind::DeleteStaffInstance(crate::payload::DeleteStaffInstanceOp {
staff_instance: epiphany_core::StaffInstanceId::new(ReplicaId(3), 8),
}),
OperationKind::CreateVoice(crate::payload::CreateVoiceOp {
staff_instance: epiphany_core::StaffInstanceId::new(ReplicaId(3), 8),
voice: valuegen::voice(VoiceId::new(ReplicaId(3), 9)),
}),
OperationKind::DeleteVoice(crate::payload::DeleteVoiceOp {
voice: VoiceId::new(ReplicaId(3), 9),
}),
OperationKind::SetMetadata(crate::payload::SetMetadataOp {
metadata: valuegen::score_metadata(2),
}),
OperationKind::SetMetricGrid(crate::payload::SetMetricGridOp {
region: epiphany_core::RegionId::new(ReplicaId(3), 7),
grid: Some(valuegen::metric_grid()),
}),
OperationKind::SetUserPageBreak(crate::payload::SetUserPageBreakOp {
region: epiphany_core::RegionId::new(ReplicaId(3), 7),
anchor: valuegen::region_start_anchor(
epiphany_core::RegionId::new(ReplicaId(3), 7),
epiphany_core::MusicalPosition::origin(),
),
present: true,
}),
];
for kind in kinds {
let e = env(primitive(kind));
assert_eq!(round_trip(&e), Ok(e.clone()));
}
}
}