Agent K M2a (Group 1): event & pitch leaf-field operations
First broad-K0 subsystem group — five new value-typed ops reusing M1's proven
disciplines (additive: new OperationKind variants 8–12, new apply arms +
reduction methods; framework frozen):
- ModifyEvent { event: Event } — field-overwrite LWW by EventId; concurrent
differing ⇒ StructuralFieldCollision.
- Transpose { targets, chromatic_steps } — order-dependent; pitch ids preserved;
canonical footprint = effect-log entry; reduce_onto applies a minimal CMN
alteration shift (rich interval algebra deferred — P12-K2).
- InsertIdentifiedPitch / DeleteIdentifiedPitch — pitch-within-event mint /
delete-wins tombstone.
- ModifyIdentifiedPitch { pitch, value: Pitch } — field-overwrite LWW (the pitch
VALUE, distinct from RespellPitch's spelling-only overwrite).
Design (honesty rule): the modify/transpose ops record effect + conflict
canonically — the resolved values live in the graph (reduce_onto), since
MaterializedState is bookkeeping, not a second graph; respell stays special
because spelling is a bookkeeping-owned annotation. LWW diff uses new
`last_event_modify`/`last_pitch_modify` working maps (synced through
WorkingSnapshot/snapshot/restore).
- core: expose Pitch + IdentifiedPitch via CanonicalValue (no new layout).
- The five kinds are v1-native (no lossy v0 predecessor): project/migrate them by
identity; only the original kinds reconstruct from a lossy v0 form.
- Generators (testkit operation_payload, ops fuzz gen_payload) now emit the new
kinds, so the convergence / determinism / migration-equivalence gates exercise
them at scale; plus targeted migrate identity + reduce LWW/mint/delete tests.
Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (ops lib
51 tests); conformance_suite scale 1 passes. Catalog sections + DECISIONS for
these ops land in M2e per the staged plan.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
c9cafe9290
commit
1658fd18f3
|
|
@ -2084,6 +2084,8 @@ macro_rules! canonical_value {
|
||||||
canonical_value! {
|
canonical_value! {
|
||||||
Event,
|
Event,
|
||||||
Rest,
|
Rest,
|
||||||
|
Pitch,
|
||||||
|
IdentifiedPitch,
|
||||||
PitchSpelling,
|
PitchSpelling,
|
||||||
Tie,
|
Tie,
|
||||||
Slur,
|
Slur,
|
||||||
|
|
@ -2142,6 +2144,12 @@ mod value_codec_tests {
|
||||||
let s = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1));
|
let s = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1));
|
||||||
for ev in s.events.iter() {
|
for ev in s.events.iter() {
|
||||||
assert_value_round_trips(ev);
|
assert_value_round_trips(ev);
|
||||||
|
let mut ips = Vec::new();
|
||||||
|
ev.collect_identified_pitches(&mut ips);
|
||||||
|
for ip in ips {
|
||||||
|
assert_value_round_trips(ip);
|
||||||
|
assert_value_round_trips(&ip.pitch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,9 @@ use crate::causal::CausalContext;
|
||||||
use crate::envelope::OperationEnvelope;
|
use crate::envelope::OperationEnvelope;
|
||||||
use crate::opset::OperationSet;
|
use crate::opset::OperationSet;
|
||||||
use crate::payload::{
|
use crate::payload::{
|
||||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind,
|
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp,
|
||||||
OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TupletCompensation,
|
InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind,
|
||||||
|
OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation,
|
||||||
};
|
};
|
||||||
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
||||||
use crate::support::AuthorId;
|
use crate::support::AuthorId;
|
||||||
|
|
@ -81,7 +82,7 @@ fn pitch(n: u64) -> PitchId {
|
||||||
|
|
||||||
/// Generates a random payload over the shared id space.
|
/// Generates a random payload over the shared id space.
|
||||||
fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
|
fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
|
||||||
let kind = match rng.below(5) {
|
let kind = match rng.below(10) {
|
||||||
0 => {
|
0 => {
|
||||||
let voice = VoiceId::new(ReplicaId(7), rng.below(3));
|
let voice = VoiceId::new(ReplicaId(7), rng.below(3));
|
||||||
let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32));
|
let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32));
|
||||||
|
|
@ -116,7 +117,7 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
|
||||||
event(rng.below(ID_SPACE)),
|
event(rng.below(ID_SPACE)),
|
||||||
)),
|
)),
|
||||||
}),
|
}),
|
||||||
_ => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
||||||
region: RegionId::new(ReplicaId(7), 0),
|
region: RegionId::new(ReplicaId(7), 0),
|
||||||
anchor: valuegen::region_start_anchor(
|
anchor: valuegen::region_start_anchor(
|
||||||
RegionId::new(ReplicaId(7), 0),
|
RegionId::new(ReplicaId(7), 0),
|
||||||
|
|
@ -124,6 +125,31 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
|
||||||
),
|
),
|
||||||
present: rng.chance(2),
|
present: rng.chance(2),
|
||||||
}),
|
}),
|
||||||
|
// Group 1 (M2): event & pitch leaf-field ops over the shared id space.
|
||||||
|
5 => OperationKind::ModifyEvent(ModifyEventOp {
|
||||||
|
event: valuegen::insert_event_value(
|
||||||
|
event(rng.below(ID_SPACE)),
|
||||||
|
VoiceId::new(ReplicaId(7), rng.below(3)),
|
||||||
|
MusicalPosition(RationalTime::from_int(rng.below(4) as i32)),
|
||||||
|
MusicalDuration::whole(),
|
||||||
|
&[pitch(rng.below(ID_SPACE))],
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
6 => OperationKind::Transpose(TransposeOp {
|
||||||
|
targets: vec![pitch(rng.below(ID_SPACE))],
|
||||||
|
chromatic_steps: rng.below(5) as i32 - 2,
|
||||||
|
}),
|
||||||
|
7 => OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
|
||||||
|
event: event(rng.below(ID_SPACE)),
|
||||||
|
pitch: valuegen::identified_pitch(pitch(rng.below(ID_SPACE))),
|
||||||
|
}),
|
||||||
|
8 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp {
|
||||||
|
pitch: pitch(rng.below(ID_SPACE)),
|
||||||
|
}),
|
||||||
|
_ => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
||||||
|
pitch: pitch(rng.below(ID_SPACE)),
|
||||||
|
value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
OperationPayload::Primitive(kind)
|
OperationPayload::Primitive(kind)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,10 +113,11 @@ pub use envelope::{well_formed, EnvelopeHash, OperationEnvelope, WellFormednessE
|
||||||
pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError};
|
pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError};
|
||||||
pub use opset::OperationSet;
|
pub use opset::OperationSet;
|
||||||
pub use payload::{
|
pub use payload::{
|
||||||
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp,
|
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
|
||||||
OperationKind, OperationKindTag, OperationPayload, PositionRemapping, ResolveConflictPayload,
|
DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp,
|
||||||
RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
|
ModifyIdentifiedPitchOp, OperationKind, OperationKindTag, OperationPayload, PositionRemapping,
|
||||||
TupletCompensation,
|
ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory,
|
||||||
|
TransactionDescriptor, TransposeOp, TupletCompensation,
|
||||||
};
|
};
|
||||||
pub use reduce::{
|
pub use reduce::{
|
||||||
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,
|
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,
|
||||||
|
|
|
||||||
|
|
@ -141,6 +141,16 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind {
|
||||||
V0OperationKind::DeclareTransaction(desc.clone())
|
V0OperationKind::DeclareTransaction(desc.clone())
|
||||||
}
|
}
|
||||||
OperationKind::Registered(id, bytes) => V0OperationKind::Registered(*id, bytes.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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -256,6 +266,16 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result<OperationKind
|
||||||
OperationKind::DeclareTransaction(desc.clone())
|
OperationKind::DeclareTransaction(desc.clone())
|
||||||
}
|
}
|
||||||
V0OperationKind::Registered(id, bytes) => OperationKind::Registered(*id, bytes.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())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -510,4 +530,42 @@ mod tests {
|
||||||
Err(MigrationError::Irreversible(_))
|
Err(MigrationError::Irreversible(_))
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group1_kinds_round_trip_by_identity() {
|
||||||
|
// The Group-1 (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),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
for kind in kinds {
|
||||||
|
let e = env(primitive(kind));
|
||||||
|
assert_eq!(round_trip(&e), Ok(e.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,10 @@
|
||||||
//! payloads.
|
//! payloads.
|
||||||
|
|
||||||
use epiphany_core::{
|
use epiphany_core::{
|
||||||
Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, MusicalDuration,
|
Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch,
|
||||||
MusicalPosition, PitchId, PitchSpelling, RegionId, RegionTimeModel, Rest, Slur, Spanner,
|
MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, RegionId, RegionTimeModel,
|
||||||
StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, VoiceId,
|
Rest, Slur, Spanner, StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId,
|
||||||
|
VoiceId,
|
||||||
};
|
};
|
||||||
use epiphany_determinism::{sorted_canonical, CanonicalEncode};
|
use epiphany_determinism::{sorted_canonical, CanonicalEncode};
|
||||||
|
|
||||||
|
|
@ -106,6 +107,18 @@ pub enum OperationKind {
|
||||||
DeclareTransaction(TransactionDescriptor),
|
DeclareTransaction(TransactionDescriptor),
|
||||||
/// An extension-defined primitive operation (opaque serialized payload).
|
/// An extension-defined primitive operation (opaque serialized payload).
|
||||||
Registered(OperationKindRegistryId, Vec<u8>),
|
Registered(OperationKindRegistryId, Vec<u8>),
|
||||||
|
// --- Group 1 (M2): event & pitch leaf-field ops. Discriminants extend the
|
||||||
|
// stable v1 wire form (0..=7 above) additively. ---
|
||||||
|
/// Overwrite a live event's value (later-in-canonical-order wins).
|
||||||
|
ModifyEvent(ModifyEventOp),
|
||||||
|
/// Transpose live pitches by an interval (order-dependent; ids preserved).
|
||||||
|
Transpose(TransposeOp),
|
||||||
|
/// Add a pitch to a live event (mint).
|
||||||
|
InsertIdentifiedPitch(InsertIdentifiedPitchOp),
|
||||||
|
/// Tombstone a live pitch (delete-wins).
|
||||||
|
DeleteIdentifiedPitch(DeleteIdentifiedPitchOp),
|
||||||
|
/// Overwrite a live pitch's value (later-in-canonical-order wins).
|
||||||
|
ModifyIdentifiedPitch(ModifyIdentifiedPitchOp),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OperationKind {
|
impl OperationKind {
|
||||||
|
|
@ -119,6 +132,11 @@ impl OperationKind {
|
||||||
OperationKind::SetUserSystemBreak(_) => 5,
|
OperationKind::SetUserSystemBreak(_) => 5,
|
||||||
OperationKind::DeclareTransaction(_) => 6,
|
OperationKind::DeclareTransaction(_) => 6,
|
||||||
OperationKind::Registered(..) => 7,
|
OperationKind::Registered(..) => 7,
|
||||||
|
OperationKind::ModifyEvent(_) => 8,
|
||||||
|
OperationKind::Transpose(_) => 9,
|
||||||
|
OperationKind::InsertIdentifiedPitch(_) => 10,
|
||||||
|
OperationKind::DeleteIdentifiedPitch(_) => 11,
|
||||||
|
OperationKind::ModifyIdentifiedPitch(_) => 12,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -135,6 +153,11 @@ impl OperationKind {
|
||||||
OperationKind::SetUserSystemBreak(_) => OperationKindTag::SetUserSystemBreak,
|
OperationKind::SetUserSystemBreak(_) => OperationKindTag::SetUserSystemBreak,
|
||||||
OperationKind::DeclareTransaction(_) => OperationKindTag::DeclareTransaction,
|
OperationKind::DeclareTransaction(_) => OperationKindTag::DeclareTransaction,
|
||||||
OperationKind::Registered(id, _) => OperationKindTag::Registered(*id),
|
OperationKind::Registered(id, _) => OperationKindTag::Registered(*id),
|
||||||
|
OperationKind::ModifyEvent(_) => OperationKindTag::ModifyEvent,
|
||||||
|
OperationKind::Transpose(_) => OperationKindTag::Transpose,
|
||||||
|
OperationKind::InsertIdentifiedPitch(_) => OperationKindTag::InsertIdentifiedPitch,
|
||||||
|
OperationKind::DeleteIdentifiedPitch(_) => OperationKindTag::DeleteIdentifiedPitch,
|
||||||
|
OperationKind::ModifyIdentifiedPitch(_) => OperationKindTag::ModifyIdentifiedPitch,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -154,6 +177,11 @@ impl CanonicalEncode for OperationKind {
|
||||||
push_canon(out, id);
|
push_canon(out, id);
|
||||||
crate::encode::push_lp_bytes(out, bytes);
|
crate::encode::push_lp_bytes(out, bytes);
|
||||||
}
|
}
|
||||||
|
OperationKind::ModifyEvent(op) => op.encode_canonical(out),
|
||||||
|
OperationKind::Transpose(op) => op.encode_canonical(out),
|
||||||
|
OperationKind::InsertIdentifiedPitch(op) => op.encode_canonical(out),
|
||||||
|
OperationKind::DeleteIdentifiedPitch(op) => op.encode_canonical(out),
|
||||||
|
OperationKind::ModifyIdentifiedPitch(op) => op.encode_canonical(out),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -181,6 +209,9 @@ pub enum OperationKindTag {
|
||||||
SetUserPageBreak,
|
SetUserPageBreak,
|
||||||
DeclareTransaction,
|
DeclareTransaction,
|
||||||
Registered(OperationKindRegistryId),
|
Registered(OperationKindRegistryId),
|
||||||
|
InsertIdentifiedPitch,
|
||||||
|
DeleteIdentifiedPitch,
|
||||||
|
ModifyIdentifiedPitch,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OperationKindTag {
|
impl OperationKindTag {
|
||||||
|
|
@ -203,6 +234,9 @@ impl OperationKindTag {
|
||||||
OperationKindTag::SetUserPageBreak => 14,
|
OperationKindTag::SetUserPageBreak => 14,
|
||||||
OperationKindTag::DeclareTransaction => 15,
|
OperationKindTag::DeclareTransaction => 15,
|
||||||
OperationKindTag::Registered(_) => 16,
|
OperationKindTag::Registered(_) => 16,
|
||||||
|
OperationKindTag::InsertIdentifiedPitch => 17,
|
||||||
|
OperationKindTag::DeleteIdentifiedPitch => 18,
|
||||||
|
OperationKindTag::ModifyIdentifiedPitch => 19,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -621,6 +655,99 @@ impl CanonicalEncode for ResolveConflictPayload {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Group 1 (M2): event & pitch leaf-field ops (Chapter 6 §6.10). -----------
|
||||||
|
|
||||||
|
/// Overwrite a live event's value (Chapter 6 §6.10 ModifyEvent). Carries the
|
||||||
|
/// full replacement [`Event`] (v1, value-typed). Field-overwrite discipline:
|
||||||
|
/// later-in-canonical-order wins; concurrent differing modifications conflict.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct ModifyEventOp {
|
||||||
|
pub event: Event,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModifyEventOp {
|
||||||
|
/// The modified event's identifier (the LWW key).
|
||||||
|
pub fn event_id(&self) -> EventId {
|
||||||
|
self.event.id()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanonicalEncode for ModifyEventOp {
|
||||||
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||||
|
push_lp_bytes(out, &self.event.canonical_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transpose live pitches by a chromatic interval (Chapter 6 §6.10 Transpose).
|
||||||
|
/// Pitch identifiers are preserved; reduction is order-dependent (transpositions
|
||||||
|
/// do not commute). `chromatic_steps` is a minimal interval (a CMN alteration
|
||||||
|
/// shift); rich interval algebra is deferred (Chapter 4 tuning catalog; P12-K2).
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct TransposeOp {
|
||||||
|
pub targets: Vec<PitchId>,
|
||||||
|
pub chromatic_steps: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanonicalEncode for TransposeOp {
|
||||||
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||||
|
push_seq(out, &sorted_canonical(self.targets.clone()));
|
||||||
|
out.extend_from_slice(&self.chromatic_steps.to_le_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a pitch to a live event (Chapter 6 §6.10 InsertIdentifiedPitch). Carries
|
||||||
|
/// the full [`IdentifiedPitch`] (v1). Mint discipline.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct InsertIdentifiedPitchOp {
|
||||||
|
pub event: EventId,
|
||||||
|
pub pitch: IdentifiedPitch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InsertIdentifiedPitchOp {
|
||||||
|
/// The minted pitch's identifier.
|
||||||
|
pub fn pitch_id(&self) -> PitchId {
|
||||||
|
self.pitch.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanonicalEncode for InsertIdentifiedPitchOp {
|
||||||
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||||
|
push_canon(out, &self.event);
|
||||||
|
push_lp_bytes(out, &self.pitch.canonical_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tombstone a live pitch (Chapter 6 §6.10 DeleteIdentifiedPitch). Delete-wins
|
||||||
|
/// discipline; the identifier is retained as a tombstone.
|
||||||
|
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct DeleteIdentifiedPitchOp {
|
||||||
|
pub pitch: PitchId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanonicalEncode for DeleteIdentifiedPitchOp {
|
||||||
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||||
|
push_canon(out, &self.pitch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overwrite a live pitch's value (Chapter 6 §6.10 ModifyIdentifiedPitch).
|
||||||
|
/// Carries the full replacement [`Pitch`] (v1) — its acoustic / scale-position,
|
||||||
|
/// distinct from [`RespellPitchOp`] which overwrites only the *spelling*.
|
||||||
|
/// Field-overwrite discipline (later-in-canonical-order wins; concurrent
|
||||||
|
/// differing conflict).
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
|
pub struct ModifyIdentifiedPitchOp {
|
||||||
|
pub pitch: PitchId,
|
||||||
|
pub value: Pitch,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CanonicalEncode for ModifyIdentifiedPitchOp {
|
||||||
|
fn encode_canonical(&self, out: &mut Vec<u8>) {
|
||||||
|
push_canon(out, &self.pitch);
|
||||||
|
push_lp_bytes(out, &self.value.canonical_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -671,6 +798,9 @@ mod tests {
|
||||||
OperationKindTag::SetUserSystemBreak,
|
OperationKindTag::SetUserSystemBreak,
|
||||||
OperationKindTag::SetUserPageBreak,
|
OperationKindTag::SetUserPageBreak,
|
||||||
OperationKindTag::DeclareTransaction,
|
OperationKindTag::DeclareTransaction,
|
||||||
|
OperationKindTag::InsertIdentifiedPitch,
|
||||||
|
OperationKindTag::DeleteIdentifiedPitch,
|
||||||
|
OperationKindTag::ModifyIdentifiedPitch,
|
||||||
];
|
];
|
||||||
let encoded: std::collections::BTreeSet<_> = tags
|
let encoded: std::collections::BTreeSet<_> = tags
|
||||||
.iter()
|
.iter()
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use epiphany_core::{
|
use epiphany_core::{
|
||||||
derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId,
|
derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId,
|
||||||
EventPosition, MusicalDuration, MusicalPosition, OperationId, PitchId, PitchSpelling,
|
EventPosition, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling,
|
||||||
RegionEdge, RegionId, RegionTimeModel, Score, TimeAnchor, TransactionId, TypedObjectId, Voice,
|
RegionEdge, RegionId, RegionTimeModel, Score, TimeAnchor, TransactionId, TypedObjectId, Voice,
|
||||||
VoiceId, VoiceOrigin,
|
VoiceId, VoiceOrigin,
|
||||||
};
|
};
|
||||||
|
|
@ -50,8 +50,9 @@ use crate::encode::{push_canon, push_len, push_lp_bytes, push_u8_bool};
|
||||||
use crate::envelope::OperationEnvelope;
|
use crate::envelope::OperationEnvelope;
|
||||||
use crate::opset::OperationSet;
|
use crate::opset::OperationSet;
|
||||||
use crate::payload::{
|
use crate::payload::{
|
||||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind,
|
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp,
|
||||||
OperationPayload, RespellPitchOp, TupletCompensation,
|
InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind,
|
||||||
|
OperationPayload, RespellPitchOp, TransposeOp, TupletCompensation,
|
||||||
};
|
};
|
||||||
use crate::undo::{UndoPolicy, UndoTransactionPayload};
|
use crate::undo::{UndoPolicy, UndoTransactionPayload};
|
||||||
|
|
||||||
|
|
@ -317,6 +318,11 @@ struct Reducer<'a> {
|
||||||
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
|
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
|
||||||
voice_occupancy: BTreeMap<VoiceId, Vec<(MusicalPosition, MusicalDuration, EventId)>>,
|
voice_occupancy: BTreeMap<VoiceId, Vec<(MusicalPosition, MusicalDuration, EventId)>>,
|
||||||
last_respell: BTreeMap<PitchId, OperationId>,
|
last_respell: BTreeMap<PitchId, OperationId>,
|
||||||
|
// LWW working state for the Group-1 field-overwrite ops: the last modifier
|
||||||
|
// and the value it wrote, used to detect concurrent *differing* modifications
|
||||||
|
// (the resolved value itself lives in the graph, not in MaterializedState).
|
||||||
|
last_event_modify: BTreeMap<EventId, (OperationId, Event)>,
|
||||||
|
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
|
||||||
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
||||||
migrated_regions: BTreeSet<RegionId>,
|
migrated_regions: BTreeSet<RegionId>,
|
||||||
region_migrator: BTreeMap<RegionId, OperationId>,
|
region_migrator: BTreeMap<RegionId, OperationId>,
|
||||||
|
|
@ -338,6 +344,8 @@ struct WorkingSnapshot {
|
||||||
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
|
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
|
||||||
voice_occupancy: BTreeMap<VoiceId, Vec<(MusicalPosition, MusicalDuration, EventId)>>,
|
voice_occupancy: BTreeMap<VoiceId, Vec<(MusicalPosition, MusicalDuration, EventId)>>,
|
||||||
last_respell: BTreeMap<PitchId, OperationId>,
|
last_respell: BTreeMap<PitchId, OperationId>,
|
||||||
|
last_event_modify: BTreeMap<EventId, (OperationId, Event)>,
|
||||||
|
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
|
||||||
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
||||||
migrated_regions: BTreeSet<RegionId>,
|
migrated_regions: BTreeSet<RegionId>,
|
||||||
region_migrator: BTreeMap<RegionId, OperationId>,
|
region_migrator: BTreeMap<RegionId, OperationId>,
|
||||||
|
|
@ -408,6 +416,8 @@ impl<'a> Reducer<'a> {
|
||||||
event_pitches: BTreeMap::new(),
|
event_pitches: BTreeMap::new(),
|
||||||
voice_occupancy: BTreeMap::new(),
|
voice_occupancy: BTreeMap::new(),
|
||||||
last_respell: BTreeMap::new(),
|
last_respell: BTreeMap::new(),
|
||||||
|
last_event_modify: BTreeMap::new(),
|
||||||
|
last_pitch_modify: BTreeMap::new(),
|
||||||
structures: BTreeMap::new(),
|
structures: BTreeMap::new(),
|
||||||
migrated_regions: BTreeSet::new(),
|
migrated_regions: BTreeSet::new(),
|
||||||
region_migrator: BTreeMap::new(),
|
region_migrator: BTreeMap::new(),
|
||||||
|
|
@ -1093,6 +1103,11 @@ impl<'a> Reducer<'a> {
|
||||||
// Extension-defined primitive: opaque to the core; recorded as
|
// Extension-defined primitive: opaque to the core; recorded as
|
||||||
// applied (the extension realizes its own effect).
|
// applied (the extension realizes its own effect).
|
||||||
OperationKind::Registered(_, _) => OperationEffect::Applied,
|
OperationKind::Registered(_, _) => OperationEffect::Applied,
|
||||||
|
OperationKind::ModifyEvent(op) => self.modify_event(env, op),
|
||||||
|
OperationKind::Transpose(op) => self.transpose(env, op),
|
||||||
|
OperationKind::InsertIdentifiedPitch(op) => self.insert_identified_pitch(env, op),
|
||||||
|
OperationKind::DeleteIdentifiedPitch(op) => self.delete_identified_pitch(env, op),
|
||||||
|
OperationKind::ModifyIdentifiedPitch(op) => self.modify_identified_pitch(env, op),
|
||||||
},
|
},
|
||||||
OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op),
|
OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op),
|
||||||
OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op),
|
OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op),
|
||||||
|
|
@ -1784,6 +1799,308 @@ impl<'a> Reducer<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Group 1 (M2): event & pitch leaf-field ops. ------------------------
|
||||||
|
//
|
||||||
|
// The modify/transpose ops follow respell's field-overwrite discipline but
|
||||||
|
// do NOT store the resolved value in MaterializedState (an Event/Pitch is a
|
||||||
|
// graph object, not a bookkeeping-owned annotation like a spelling): their
|
||||||
|
// canonical footprint is the effect-log entry plus, on a concurrent differing
|
||||||
|
// write, a StructuralFieldCollision. The resolved value is materialized in the
|
||||||
|
// graph by reduce_onto. The LWW key/diff uses `last_*_modify` working state.
|
||||||
|
|
||||||
|
fn modify_event(&mut self, env: &OperationEnvelope, op: &ModifyEventOp) -> OperationEffect {
|
||||||
|
let event_id = op.event_id();
|
||||||
|
let ev_obj = TypedObjectId::Event(event_id);
|
||||||
|
match self.objects.get(&ev_obj) {
|
||||||
|
None => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||||
|
reason: PreconditionFailureReason::TargetMissing,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ObjectState::Tombstoned { .. }) => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::TargetTombstoned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ObjectState::Live) => {}
|
||||||
|
}
|
||||||
|
let prev = self
|
||||||
|
.last_event_modify
|
||||||
|
.get(&event_id)
|
||||||
|
.map(|(o, e)| (*o, e.clone()));
|
||||||
|
let effect = match prev {
|
||||||
|
Some((prev_op, prev_event)) if self.concurrent(env.id, prev_op) => {
|
||||||
|
if prev_event == op.event {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::AlreadyApplied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Later in canonical order wins and materializes; the earlier op
|
||||||
|
// is the loser. (Winner carries the Conflicted tag; see DECISIONS.)
|
||||||
|
let conflict = ConflictRecord::new(
|
||||||
|
ConflictKind::StructuralFieldCollision {
|
||||||
|
winner: env.id,
|
||||||
|
loser: prev_op,
|
||||||
|
field: FieldPath("event".to_string()),
|
||||||
|
},
|
||||||
|
vec![env.id, prev_op],
|
||||||
|
vec![ev_obj],
|
||||||
|
);
|
||||||
|
let cid = conflict.id;
|
||||||
|
self.conflicts.insert(conflict);
|
||||||
|
OperationEffect::Conflicted { conflict: cid }
|
||||||
|
}
|
||||||
|
// First modify, or a causally-ordered intentional overwrite.
|
||||||
|
_ => OperationEffect::Applied,
|
||||||
|
};
|
||||||
|
self.last_event_modify
|
||||||
|
.insert(event_id, (env.id, op.event.clone()));
|
||||||
|
self.graph_replace_event(&op.event);
|
||||||
|
effect
|
||||||
|
}
|
||||||
|
|
||||||
|
fn transpose(&mut self, _env: &OperationEnvelope, op: &TransposeOp) -> OperationEffect {
|
||||||
|
// Precondition: every target pitch is live. Transpose is order-dependent
|
||||||
|
// (transpositions do not commute); its canonical footprint is the
|
||||||
|
// effect-log entry. The transposed values are materialized in the graph.
|
||||||
|
for pitch in &op.targets {
|
||||||
|
match self.objects.get(&TypedObjectId::Pitch(*pitch)) {
|
||||||
|
Some(ObjectState::Live) => {}
|
||||||
|
Some(ObjectState::Tombstoned { .. }) => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::TargetTombstoned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||||
|
reason: PreconditionFailureReason::TargetMissing,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pitch in &op.targets {
|
||||||
|
self.graph_transpose_pitch(*pitch, op.chromatic_steps);
|
||||||
|
}
|
||||||
|
OperationEffect::Applied
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_identified_pitch(
|
||||||
|
&mut self,
|
||||||
|
env: &OperationEnvelope,
|
||||||
|
op: &InsertIdentifiedPitchOp,
|
||||||
|
) -> OperationEffect {
|
||||||
|
let pitch_id = op.pitch_id();
|
||||||
|
let p_obj = TypedObjectId::Pitch(pitch_id);
|
||||||
|
// The target event must be live; the pitch id must be fresh.
|
||||||
|
if !matches!(
|
||||||
|
self.objects.get(&TypedObjectId::Event(op.event)),
|
||||||
|
Some(ObjectState::Live)
|
||||||
|
) {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||||
|
reason: PreconditionFailureReason::TargetMissing,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match self.objects.get(&p_obj) {
|
||||||
|
Some(ObjectState::Live) => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::AlreadyApplied,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ObjectState::Tombstoned { .. }) => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::TargetTombstoned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
self.objects.insert(p_obj, ObjectState::Live);
|
||||||
|
self.minted_by.insert(p_obj, env.id);
|
||||||
|
self.note_minted(env, p_obj);
|
||||||
|
self.event_pitches
|
||||||
|
.entry(op.event)
|
||||||
|
.or_default()
|
||||||
|
.push(pitch_id);
|
||||||
|
self.graph_insert_pitch(op.event, &op.pitch);
|
||||||
|
OperationEffect::Applied
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_identified_pitch(
|
||||||
|
&mut self,
|
||||||
|
env: &OperationEnvelope,
|
||||||
|
op: &DeleteIdentifiedPitchOp,
|
||||||
|
) -> OperationEffect {
|
||||||
|
let p_obj = TypedObjectId::Pitch(op.pitch);
|
||||||
|
let minted_by = match self.objects.get(&p_obj) {
|
||||||
|
None => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||||
|
reason: PreconditionFailureReason::TargetMissing,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ObjectState::Tombstoned { .. }) => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::AlreadyApplied,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ObjectState::Live) => self.minted_by.get(&p_obj).copied().unwrap_or(env.id),
|
||||||
|
};
|
||||||
|
self.objects.insert(
|
||||||
|
p_obj,
|
||||||
|
ObjectState::Tombstoned {
|
||||||
|
deleted_by: env.id,
|
||||||
|
minted_by,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
for pitches in self.event_pitches.values_mut() {
|
||||||
|
pitches.retain(|p| *p != op.pitch);
|
||||||
|
}
|
||||||
|
self.graph_delete_pitch(op.pitch);
|
||||||
|
OperationEffect::Applied
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modify_identified_pitch(
|
||||||
|
&mut self,
|
||||||
|
env: &OperationEnvelope,
|
||||||
|
op: &ModifyIdentifiedPitchOp,
|
||||||
|
) -> OperationEffect {
|
||||||
|
let p_obj = TypedObjectId::Pitch(op.pitch);
|
||||||
|
match self.objects.get(&p_obj) {
|
||||||
|
None => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||||
|
reason: PreconditionFailureReason::TargetMissing,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ObjectState::Tombstoned { .. }) => {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::TargetTombstoned,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ObjectState::Live) => {}
|
||||||
|
}
|
||||||
|
let prev = self
|
||||||
|
.last_pitch_modify
|
||||||
|
.get(&op.pitch)
|
||||||
|
.map(|(o, v)| (*o, v.clone()));
|
||||||
|
let effect = match prev {
|
||||||
|
Some((prev_op, prev_value)) if self.concurrent(env.id, prev_op) => {
|
||||||
|
if prev_value == op.value {
|
||||||
|
return OperationEffect::NoOp {
|
||||||
|
reason: NoOpReason::AlreadyApplied,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let conflict = ConflictRecord::new(
|
||||||
|
ConflictKind::StructuralFieldCollision {
|
||||||
|
winner: env.id,
|
||||||
|
loser: prev_op,
|
||||||
|
field: FieldPath("pitch".to_string()),
|
||||||
|
},
|
||||||
|
vec![env.id, prev_op],
|
||||||
|
vec![p_obj],
|
||||||
|
);
|
||||||
|
let cid = conflict.id;
|
||||||
|
self.conflicts.insert(conflict);
|
||||||
|
OperationEffect::Conflicted { conflict: cid }
|
||||||
|
}
|
||||||
|
_ => OperationEffect::Applied,
|
||||||
|
};
|
||||||
|
self.last_pitch_modify
|
||||||
|
.insert(op.pitch, (env.id, op.value.clone()));
|
||||||
|
self.graph_modify_pitch(op.pitch, &op.value);
|
||||||
|
effect
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Group 1 graph mutations (reduce_onto only; no-op when graph is None). --
|
||||||
|
|
||||||
|
fn graph_replace_event(&mut self, new_event: &Event) {
|
||||||
|
let Some(score) = self.graph.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(existing) = score.events.get_mut(new_event.id()) {
|
||||||
|
// Preserve the original voice membership (placement is owned by the
|
||||||
|
// voice lists; ModifyEvent overwrites the event's fields, not its
|
||||||
|
// container). Re-sorting on a position change is deferred.
|
||||||
|
let voice = existing.voice();
|
||||||
|
let mut replacement = new_event.clone();
|
||||||
|
replacement.set_voice(voice);
|
||||||
|
*existing = replacement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_event_of_pitch(score: &Score, pitch: PitchId) -> Option<EventId> {
|
||||||
|
score.events.iter().find_map(|event| {
|
||||||
|
let mut ips = Vec::new();
|
||||||
|
event.collect_identified_pitches(&mut ips);
|
||||||
|
ips.iter().any(|ip| ip.id == pitch).then(|| event.id())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_insert_pitch(&mut self, event: EventId, pitch: &epiphany_core::IdentifiedPitch) {
|
||||||
|
let Some(score) = self.graph.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(Event::Pitched(pe)) = score.events.get_mut(event) {
|
||||||
|
if !pe.pitches.iter().any(|ip| ip.id == pitch.id) {
|
||||||
|
pe.pitches.push(pitch.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_delete_pitch(&mut self, pitch: PitchId) {
|
||||||
|
let Some(score) = self.graph.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(event) = Self::graph_event_of_pitch(score, pitch) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(Event::Pitched(pe)) = score.events.get_mut(event) {
|
||||||
|
pe.pitches.retain(|ip| ip.id != pitch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_modify_pitch(&mut self, pitch: PitchId, value: &Pitch) {
|
||||||
|
let Some(score) = self.graph.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(event) = Self::graph_event_of_pitch(score, pitch) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(Event::Pitched(pe)) = score.events.get_mut(event) {
|
||||||
|
if let Some(ip) = pe.pitches.iter_mut().find(|ip| ip.id == pitch) {
|
||||||
|
ip.pitch = value.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn graph_transpose_pitch(&mut self, pitch: PitchId, chromatic_steps: i32) {
|
||||||
|
let Some(score) = self.graph.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(event) = Self::graph_event_of_pitch(score, pitch) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let Some(Event::Pitched(pe)) = score.events.get_mut(event) {
|
||||||
|
if let Some(ip) = pe.pitches.iter_mut().find(|ip| ip.id == pitch) {
|
||||||
|
// Minimal interval: shift the CMN alteration. Full interval
|
||||||
|
// algebra (Chapter 4 tuning) is deferred — P12-K2.
|
||||||
|
if let epiphany_core::PitchSpacePosition::Cmn { alteration, .. } =
|
||||||
|
&mut ip.pitch.scale_position.position
|
||||||
|
{
|
||||||
|
let shifted = (*alteration as i32).saturating_add(chromatic_steps);
|
||||||
|
*alteration = shifted.clamp(i8::MIN as i32, i8::MAX as i32) as i8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Re-anchoring (Chapter 6 §6.5 rule table, representative subset). ----
|
// --- Re-anchoring (Chapter 6 §6.5 rule table, representative subset). ----
|
||||||
|
|
||||||
fn reanchor_for_tombstone(
|
fn reanchor_for_tombstone(
|
||||||
|
|
@ -2009,6 +2326,8 @@ impl<'a> Reducer<'a> {
|
||||||
event_pitches: self.event_pitches.clone(),
|
event_pitches: self.event_pitches.clone(),
|
||||||
voice_occupancy: self.voice_occupancy.clone(),
|
voice_occupancy: self.voice_occupancy.clone(),
|
||||||
last_respell: self.last_respell.clone(),
|
last_respell: self.last_respell.clone(),
|
||||||
|
last_event_modify: self.last_event_modify.clone(),
|
||||||
|
last_pitch_modify: self.last_pitch_modify.clone(),
|
||||||
structures: self.structures.clone(),
|
structures: self.structures.clone(),
|
||||||
migrated_regions: self.migrated_regions.clone(),
|
migrated_regions: self.migrated_regions.clone(),
|
||||||
region_migrator: self.region_migrator.clone(),
|
region_migrator: self.region_migrator.clone(),
|
||||||
|
|
@ -2027,6 +2346,8 @@ impl<'a> Reducer<'a> {
|
||||||
self.event_pitches = s.event_pitches;
|
self.event_pitches = s.event_pitches;
|
||||||
self.voice_occupancy = s.voice_occupancy;
|
self.voice_occupancy = s.voice_occupancy;
|
||||||
self.last_respell = s.last_respell;
|
self.last_respell = s.last_respell;
|
||||||
|
self.last_event_modify = s.last_event_modify;
|
||||||
|
self.last_pitch_modify = s.last_pitch_modify;
|
||||||
self.structures = s.structures;
|
self.structures = s.structures;
|
||||||
self.migrated_regions = s.migrated_regions;
|
self.migrated_regions = s.migrated_regions;
|
||||||
self.region_migrator = s.region_migrator;
|
self.region_migrator = s.region_migrator;
|
||||||
|
|
@ -2384,4 +2705,112 @@ mod tests {
|
||||||
"Dismiss action must select the Dismissed state"
|
"Dismiss action must select the Dismissed state"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Group 1 (M2) behavior. --------------------------------------------
|
||||||
|
|
||||||
|
fn prim_env(
|
||||||
|
replica: u64,
|
||||||
|
counter: u64,
|
||||||
|
physical: i64,
|
||||||
|
ctx: CausalContext,
|
||||||
|
kind: OperationKind,
|
||||||
|
) -> OperationEnvelope {
|
||||||
|
let id = OperationId::new(ReplicaId(replica), counter);
|
||||||
|
OperationEnvelope {
|
||||||
|
id,
|
||||||
|
author: AuthorId(0),
|
||||||
|
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(physical), 0), id),
|
||||||
|
causal_context: ctx,
|
||||||
|
transaction: None,
|
||||||
|
payload: OperationPayload::Primitive(kind),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn modify_event_of(event: EventId, pitch: PitchId) -> OperationKind {
|
||||||
|
OperationKind::ModifyEvent(crate::payload::ModifyEventOp {
|
||||||
|
event: crate::valuegen::insert_event_value(
|
||||||
|
event,
|
||||||
|
VoiceId::new(ReplicaId(9), 1),
|
||||||
|
pos(0),
|
||||||
|
epiphany_core::MusicalDuration::whole(),
|
||||||
|
&[pitch],
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_differing_modify_event_conflicts() {
|
||||||
|
let event = EventId::new(ReplicaId(1), 100);
|
||||||
|
let seen = CausalContext::new().with_seen(ReplicaId(1), 0);
|
||||||
|
// Insert the event (replica 1), then two concurrent differing modifies.
|
||||||
|
let insert = insert(1, 0, 10, 1, 100, 0);
|
||||||
|
let mod_a = prim_env(
|
||||||
|
2,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
seen.clone(),
|
||||||
|
modify_event_of(event, PitchId::new(ReplicaId(1), 1)),
|
||||||
|
);
|
||||||
|
let mod_b = prim_env(
|
||||||
|
3,
|
||||||
|
0,
|
||||||
|
20,
|
||||||
|
seen,
|
||||||
|
modify_event_of(event, PitchId::new(ReplicaId(1), 2)),
|
||||||
|
);
|
||||||
|
let mut set = OperationSet::new();
|
||||||
|
set.accept_all(vec![insert, mod_a, mod_b]);
|
||||||
|
let state = set.reduce();
|
||||||
|
assert_eq!(
|
||||||
|
state.conflicts.records().len(),
|
||||||
|
1,
|
||||||
|
"concurrent differing ModifyEvent must record exactly one conflict"
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
state.conflicts.records()[0].kind,
|
||||||
|
ConflictKind::StructuralFieldCollision { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_then_delete_identified_pitch_tombstones() {
|
||||||
|
let event = EventId::new(ReplicaId(1), 100);
|
||||||
|
let pitch = PitchId::new(ReplicaId(1), 7);
|
||||||
|
let insert = insert(1, 0, 10, 1, 100, 0);
|
||||||
|
let add = prim_env(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
20,
|
||||||
|
CausalContext::new().with_seen(ReplicaId(1), 0),
|
||||||
|
OperationKind::InsertIdentifiedPitch(crate::payload::InsertIdentifiedPitchOp {
|
||||||
|
event,
|
||||||
|
pitch: crate::valuegen::identified_pitch(pitch),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let del = prim_env(
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
30,
|
||||||
|
CausalContext::new().with_seen(ReplicaId(1), 1),
|
||||||
|
OperationKind::DeleteIdentifiedPitch(crate::payload::DeleteIdentifiedPitchOp { pitch }),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut after_add = OperationSet::new();
|
||||||
|
after_add.accept_all(vec![insert.clone(), add.clone()]);
|
||||||
|
assert_eq!(
|
||||||
|
after_add.reduce().objects.get(&TypedObjectId::Pitch(pitch)),
|
||||||
|
Some(&ObjectState::Live),
|
||||||
|
"InsertIdentifiedPitch mints the pitch live"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut after_del = OperationSet::new();
|
||||||
|
after_del.accept_all(vec![insert, add, del]);
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
after_del.reduce().objects.get(&TypedObjectId::Pitch(pitch)),
|
||||||
|
Some(ObjectState::Tombstoned { .. })
|
||||||
|
),
|
||||||
|
"DeleteIdentifiedPitch tombstones the pitch"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ pub struct V0OperationEnvelope {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frozen v0 payload union (pre-catalog).
|
/// Frozen v0 payload union (pre-catalog).
|
||||||
|
// Mirrors the live payload's size profile (the v1-native Group-1 kinds carry
|
||||||
|
// whole values); inline values are the design (see `payload::OperationKind`).
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
pub enum V0OperationPayload {
|
pub enum V0OperationPayload {
|
||||||
Primitive(V0OperationKind),
|
Primitive(V0OperationKind),
|
||||||
|
|
@ -49,6 +52,7 @@ pub enum V0OperationPayload {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Frozen v0 primitive kinds (the representative §6.10 set).
|
/// Frozen v0 primitive kinds (the representative §6.10 set).
|
||||||
|
#[allow(clippy::large_enum_variant)]
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
pub enum V0OperationKind {
|
pub enum V0OperationKind {
|
||||||
InsertEvent(V0InsertEventOp),
|
InsertEvent(V0InsertEventOp),
|
||||||
|
|
@ -61,6 +65,15 @@ pub enum V0OperationKind {
|
||||||
DeclareTransaction(TransactionDescriptor),
|
DeclareTransaction(TransactionDescriptor),
|
||||||
/// Opaque extension payload; unchanged in v1.
|
/// Opaque extension payload; unchanged in v1.
|
||||||
Registered(OperationKindRegistryId, Vec<u8>),
|
Registered(OperationKindRegistryId, Vec<u8>),
|
||||||
|
// --- Group 1 (M2) kinds. These are v1-native: they had no identifier-only
|
||||||
|
// v0 predecessor, so their "v0 form" carries the v1 payload verbatim and the
|
||||||
|
// migration round-trips them by identity (only the original kinds above have
|
||||||
|
// a lossy v0 projection to reconstruct). ---
|
||||||
|
ModifyEvent(crate::payload::ModifyEventOp),
|
||||||
|
Transpose(crate::payload::TransposeOp),
|
||||||
|
InsertIdentifiedPitch(crate::payload::InsertIdentifiedPitchOp),
|
||||||
|
DeleteIdentifiedPitch(crate::payload::DeleteIdentifiedPitchOp),
|
||||||
|
ModifyIdentifiedPitch(crate::payload::ModifyIdentifiedPitchOp),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant
|
/// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,35 @@ pub fn identified_pitch(id: PitchId) -> IdentifiedPitch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A distinct CMN [`Pitch`] per `nth` (injective over the `u8`, like
|
||||||
|
/// [`spelling`]): nominal = `nth % 7`, octave = `nth / 7`. Lets a harness make
|
||||||
|
/// concurrent `ModifyIdentifiedPitch`es agree or conflict deterministically.
|
||||||
|
pub fn pitch_value_nth(nth: u8) -> Pitch {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
Pitch {
|
||||||
|
scale_position: ScalePosition {
|
||||||
|
space: PitchSpaceId::new("cmn-12"),
|
||||||
|
position: PitchSpacePosition::Cmn {
|
||||||
|
nominal,
|
||||||
|
alteration: 0,
|
||||||
|
octave: (nth / 7) as i8,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
acoustic: AcousticPitch {
|
||||||
|
tuning: epiphany_core::TuningReference::Inherit,
|
||||||
|
realization: AcousticRealization::Implicit,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The event an InsertEvent inserts: a pitched event when `pitch_ids` is
|
/// The event an InsertEvent inserts: a pitched event when `pitch_ids` is
|
||||||
/// non-empty, otherwise a visible rest. Mirrors the prototype's
|
/// non-empty, otherwise a visible rest. Mirrors the prototype's
|
||||||
/// pitched-or-rest split, now as a real value.
|
/// pitched-or-rest split, now as a real value.
|
||||||
|
|
|
||||||
|
|
@ -33,16 +33,16 @@ use epiphany_ops::{
|
||||||
AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId,
|
AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId,
|
||||||
ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
|
ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
|
||||||
ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
|
ConflictResolutionState, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
|
||||||
ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, IntegrityAnomaly,
|
DeleteIdentifiedPitchOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp,
|
||||||
IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, NoOpReason, ObjectKind,
|
InsertIdentifiedPitchOp, IntegrityAnomaly, IntegrityAnomalyKind, IntegrityAnomalyRegistryId,
|
||||||
ObjectState, OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId,
|
MaterializedState, ModifyEventOp, ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState,
|
||||||
OperationPayload, OperationSet, OperationStamp, PendingReason, PositionRemapping,
|
OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload,
|
||||||
PreconditionFailureReason, PreconditionFailureRegistryId, ReanchorReason,
|
OperationSet, OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason,
|
||||||
ReanchorReasonRegistryId, ReanchorResult, RepairKind, RepairKindRegistryId, RepairRecord,
|
PreconditionFailureRegistryId, ReanchorReason, ReanchorReasonRegistryId, ReanchorResult,
|
||||||
ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId,
|
RepairKind, RepairKindRegistryId, RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId,
|
||||||
ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, SetUserSystemBreakOp,
|
ResolutionAction, ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp,
|
||||||
TransactionCategory, TransactionDescriptor, TupletCompensation, TupletCompensationKind,
|
SerializedCanonicalInputs, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
|
||||||
UndoPolicy, UndoTransactionPayload,
|
TransposeOp, TupletCompensation, TupletCompensationKind, UndoPolicy, UndoTransactionPayload,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::rng::Rng;
|
use crate::rng::Rng;
|
||||||
|
|
@ -634,7 +634,7 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
let kind = match rng.below(8) {
|
let kind = match rng.below(13) {
|
||||||
0 => {
|
0 => {
|
||||||
let pitches = if rng.boolean() {
|
let pitches = if rng.boolean() {
|
||||||
vec![obj_pitch(rng.below(pitches))]
|
vec![obj_pitch(rng.below(pitches))]
|
||||||
|
|
@ -694,6 +694,31 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP
|
||||||
TransactionCategory::Layout,
|
TransactionCategory::Layout,
|
||||||
])),
|
])),
|
||||||
}),
|
}),
|
||||||
|
// Group 1 (M2): event & pitch leaf-field ops over the shared id space.
|
||||||
|
7 => OperationKind::ModifyEvent(ModifyEventOp {
|
||||||
|
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(),
|
||||||
|
&[obj_pitch(rng.below(pitches))],
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
8 => OperationKind::Transpose(TransposeOp {
|
||||||
|
targets: vec![obj_pitch(rng.below(pitches))],
|
||||||
|
chromatic_steps: rng.below(5) as i32 - 2,
|
||||||
|
}),
|
||||||
|
9 => OperationKind::InsertIdentifiedPitch(InsertIdentifiedPitchOp {
|
||||||
|
event: obj_event(rng.below(events)),
|
||||||
|
pitch: valuegen::identified_pitch(obj_pitch(rng.below(pitches))),
|
||||||
|
}),
|
||||||
|
10 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp {
|
||||||
|
pitch: obj_pitch(rng.below(pitches)),
|
||||||
|
}),
|
||||||
|
11 => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
||||||
|
pitch: obj_pitch(rng.below(pitches)),
|
||||||
|
value: valuegen::pitch_value_nth(rng.below(4) as u8 + 1),
|
||||||
|
}),
|
||||||
_ => OperationKind::Registered(
|
_ => OperationKind::Registered(
|
||||||
OperationKindRegistryId(rng.next_u64() as u128),
|
OperationKindRegistryId(rng.next_u64() as u128),
|
||||||
rng.byte_vec(0, 16),
|
rng.byte_vec(0, 16),
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ code instead is the failure mode this batch exists to prevent.
|
||||||
| P12-I2 | `epiphany-render-svg` / `engrave` I | Stable layout-object id derivation (`MUSCLOID`, Pass-11 item 2.6, deferred to I) is still unwired: the frozen `epiphany-determinism` exposes no `MUSCLOID` tag, so provenance is traced via the provisional `stable_id`. Wiring the ratified derivation is Track A work (already noted in `layout-ir/DECISIONS.md`). | G (determinism tag) / Track A |
|
| P12-I2 | `epiphany-render-svg` / `engrave` I | Stable layout-object id derivation (`MUSCLOID`, Pass-11 item 2.6, deferred to I) is still unwired: the frozen `epiphany-determinism` exposes no `MUSCLOID` tag, so provenance is traced via the provisional `stable_id`. Wiring the ratified derivation is Track A work (already noted in `layout-ir/DECISIONS.md`). | G (determinism tag) / Track A |
|
||||||
| P12-I3 | `epiphany-layout-ir` I | The bundled `BRAVURA_METRICS` are *approximations* that disagree with the genuine Bravura outlines the renderer now extracts from the font (e.g. `timeSig4`: metrics bbox `[40,0,1240,2048]` vs real outline ≈ `[0.08,-1.0,1.8,1.004]` staff spaces). Real spacing needs exact metrics; regenerate the metrics table from the font or reconcile it with the outline source. | G / Pass 12 (glyph metrics) |
|
| P12-I3 | `epiphany-layout-ir` I | The bundled `BRAVURA_METRICS` are *approximations* that disagree with the genuine Bravura outlines the renderer now extracts from the font (e.g. `timeSig4`: metrics bbox `[40,0,1240,2048]` vs real outline ≈ `[0.08,-1.0,1.8,1.004]` staff spaces). Real spacing needs exact metrics; regenerate the metrics table from the font or reconcile it with the outline source. | G / Pass 12 (glyph metrics) |
|
||||||
| P12-K1 | `epiphany-ops` K | A v0 `RespellPitch` carried a `ContentHash` *fingerprint* of the spelling, not the `PitchSpelling`. The v0→v1 migration (Operation Catalog, M1) cannot invert a fingerprint, so it recovers the spelling from the score-graph context (an explicit per-pitch spelling attachment whose canonical bytes hash to the fingerprint) and returns `MigrationError::Irreversible` (bundle opens read-only) when the context lacks it. Every other representative payload migrates self-contained; this is the lone exception. Confirm the read-only fallback is the intended disposition vs. requiring a v0 corpus that preserves spelling pre-images. | G / Pass 12 (migration) |
|
| 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) |
|
||||||
|
| P12-K2 | `epiphany-ops` K | The `Transpose` op (Operation Catalog, M2 Group 1) carries a minimal `chromatic_steps: i32` interval and `reduce_onto` applies it as a CMN *alteration* shift only. Faithful interval algebra (diatonic vs. chromatic intervals, octave/nominal renormalization, transposition in non-CMN pitch spaces) is the deferred Chapter 4 tuning-catalog territory. Pin the interval representation and transposition semantics when the tuning catalog lands. | G / Pass 12 (tuning) |
|
||||||
|
|
||||||
## Not yet open elsewhere
|
## Not yet open elsewhere
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue