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! {
|
||||
Event,
|
||||
Rest,
|
||||
Pitch,
|
||||
IdentifiedPitch,
|
||||
PitchSpelling,
|
||||
Tie,
|
||||
Slur,
|
||||
|
|
@ -2142,6 +2144,12 @@ mod value_codec_tests {
|
|||
let s = valid_score(seed.wrapping_mul(0x9E37_79B9).wrapping_add(1));
|
||||
for ev in s.events.iter() {
|
||||
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::opset::OperationSet;
|
||||
use crate::payload::{
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind,
|
||||
OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TupletCompensation,
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp,
|
||||
InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind,
|
||||
OperationPayload, RespellPitchOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation,
|
||||
};
|
||||
use crate::stamp::{HybridLogicalClock, OperationStamp};
|
||||
use crate::support::AuthorId;
|
||||
|
|
@ -81,7 +82,7 @@ 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) {
|
||||
let kind = match rng.below(10) {
|
||||
0 => {
|
||||
let voice = VoiceId::new(ReplicaId(7), rng.below(3));
|
||||
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)),
|
||||
)),
|
||||
}),
|
||||
_ => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
||||
4 => OperationKind::SetUserSystemBreak(SetUserSystemBreakOp {
|
||||
region: RegionId::new(ReplicaId(7), 0),
|
||||
anchor: valuegen::region_start_anchor(
|
||||
RegionId::new(ReplicaId(7), 0),
|
||||
|
|
@ -124,6 +125,31 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload {
|
|||
),
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 opset::OperationSet;
|
||||
pub use payload::{
|
||||
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp,
|
||||
OperationKind, OperationKindTag, OperationPayload, PositionRemapping, ResolveConflictPayload,
|
||||
RespellPitchOp, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
|
||||
TupletCompensation,
|
||||
ChangeRegionTimeModelOp, CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp,
|
||||
DeleteIdentifiedPitchOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyEventOp,
|
||||
ModifyIdentifiedPitchOp, OperationKind, OperationKindTag, OperationPayload, PositionRemapping,
|
||||
ResolveConflictPayload, RespellPitchOp, SetUserSystemBreakOp, TransactionCategory,
|
||||
TransactionDescriptor, TransposeOp, TupletCompensation,
|
||||
};
|
||||
pub use reduce::{
|
||||
canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason,
|
||||
|
|
|
|||
|
|
@ -141,6 +141,16 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind {
|
|||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -256,6 +266,16 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result<OperationKind
|
|||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -510,4 +530,42 @@ mod tests {
|
|||
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.
|
||||
|
||||
use epiphany_core::{
|
||||
Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, MusicalDuration,
|
||||
MusicalPosition, PitchId, PitchSpelling, RegionId, RegionTimeModel, Rest, Slur, Spanner,
|
||||
StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId, VoiceId,
|
||||
Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch,
|
||||
MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpelling, RegionId, RegionTimeModel,
|
||||
Rest, Slur, Spanner, StaffInstanceId, Tie, TimeAnchor, TransactionId, TupletId, TypedObjectId,
|
||||
VoiceId,
|
||||
};
|
||||
use epiphany_determinism::{sorted_canonical, CanonicalEncode};
|
||||
|
||||
|
|
@ -106,6 +107,18 @@ pub enum OperationKind {
|
|||
DeclareTransaction(TransactionDescriptor),
|
||||
/// An extension-defined primitive operation (opaque serialized payload).
|
||||
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 {
|
||||
|
|
@ -119,6 +132,11 @@ impl OperationKind {
|
|||
OperationKind::SetUserSystemBreak(_) => 5,
|
||||
OperationKind::DeclareTransaction(_) => 6,
|
||||
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::DeclareTransaction(_) => OperationKindTag::DeclareTransaction,
|
||||
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);
|
||||
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,
|
||||
DeclareTransaction,
|
||||
Registered(OperationKindRegistryId),
|
||||
InsertIdentifiedPitch,
|
||||
DeleteIdentifiedPitch,
|
||||
ModifyIdentifiedPitch,
|
||||
}
|
||||
|
||||
impl OperationKindTag {
|
||||
|
|
@ -203,6 +234,9 @@ impl OperationKindTag {
|
|||
OperationKindTag::SetUserPageBreak => 14,
|
||||
OperationKindTag::DeclareTransaction => 15,
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -671,6 +798,9 @@ mod tests {
|
|||
OperationKindTag::SetUserSystemBreak,
|
||||
OperationKindTag::SetUserPageBreak,
|
||||
OperationKindTag::DeclareTransaction,
|
||||
OperationKindTag::InsertIdentifiedPitch,
|
||||
OperationKindTag::DeleteIdentifiedPitch,
|
||||
OperationKindTag::ModifyIdentifiedPitch,
|
||||
];
|
||||
let encoded: std::collections::BTreeSet<_> = tags
|
||||
.iter()
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ use std::collections::{BTreeMap, BTreeSet};
|
|||
|
||||
use epiphany_core::{
|
||||
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,
|
||||
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::opset::OperationSet;
|
||||
use crate::payload::{
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, InsertEventOp, OperationKind,
|
||||
OperationPayload, RespellPitchOp, TupletCompensation,
|
||||
CreateCrossCuttingOp, CrossCuttingValue, DeleteEventOp, DeleteIdentifiedPitchOp, InsertEventOp,
|
||||
InsertIdentifiedPitchOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind,
|
||||
OperationPayload, RespellPitchOp, TransposeOp, TupletCompensation,
|
||||
};
|
||||
use crate::undo::{UndoPolicy, UndoTransactionPayload};
|
||||
|
||||
|
|
@ -317,6 +318,11 @@ struct Reducer<'a> {
|
|||
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
|
||||
voice_occupancy: BTreeMap<VoiceId, Vec<(MusicalPosition, MusicalDuration, EventId)>>,
|
||||
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>>,
|
||||
migrated_regions: BTreeSet<RegionId>,
|
||||
region_migrator: BTreeMap<RegionId, OperationId>,
|
||||
|
|
@ -338,6 +344,8 @@ struct WorkingSnapshot {
|
|||
event_pitches: BTreeMap<EventId, Vec<PitchId>>,
|
||||
voice_occupancy: BTreeMap<VoiceId, Vec<(MusicalPosition, MusicalDuration, EventId)>>,
|
||||
last_respell: BTreeMap<PitchId, OperationId>,
|
||||
last_event_modify: BTreeMap<EventId, (OperationId, Event)>,
|
||||
last_pitch_modify: BTreeMap<PitchId, (OperationId, Pitch)>,
|
||||
structures: BTreeMap<TypedObjectId, Vec<TypedObjectId>>,
|
||||
migrated_regions: BTreeSet<RegionId>,
|
||||
region_migrator: BTreeMap<RegionId, OperationId>,
|
||||
|
|
@ -408,6 +416,8 @@ impl<'a> Reducer<'a> {
|
|||
event_pitches: BTreeMap::new(),
|
||||
voice_occupancy: BTreeMap::new(),
|
||||
last_respell: BTreeMap::new(),
|
||||
last_event_modify: BTreeMap::new(),
|
||||
last_pitch_modify: BTreeMap::new(),
|
||||
structures: BTreeMap::new(),
|
||||
migrated_regions: BTreeSet::new(),
|
||||
region_migrator: BTreeMap::new(),
|
||||
|
|
@ -1093,6 +1103,11 @@ impl<'a> Reducer<'a> {
|
|||
// Extension-defined primitive: opaque to the core; recorded as
|
||||
// applied (the extension realizes its own effect).
|
||||
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::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). ----
|
||||
|
||||
fn reanchor_for_tombstone(
|
||||
|
|
@ -2009,6 +2326,8 @@ impl<'a> Reducer<'a> {
|
|||
event_pitches: self.event_pitches.clone(),
|
||||
voice_occupancy: self.voice_occupancy.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(),
|
||||
migrated_regions: self.migrated_regions.clone(),
|
||||
region_migrator: self.region_migrator.clone(),
|
||||
|
|
@ -2027,6 +2346,8 @@ impl<'a> Reducer<'a> {
|
|||
self.event_pitches = s.event_pitches;
|
||||
self.voice_occupancy = s.voice_occupancy;
|
||||
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.migrated_regions = s.migrated_regions;
|
||||
self.region_migrator = s.region_migrator;
|
||||
|
|
@ -2384,4 +2705,112 @@ mod tests {
|
|||
"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).
|
||||
// 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)]
|
||||
pub enum V0OperationPayload {
|
||||
Primitive(V0OperationKind),
|
||||
|
|
@ -49,6 +52,7 @@ pub enum V0OperationPayload {
|
|||
}
|
||||
|
||||
/// Frozen v0 primitive kinds (the representative §6.10 set).
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub enum V0OperationKind {
|
||||
InsertEvent(V0InsertEventOp),
|
||||
|
|
@ -61,6 +65,15 @@ pub enum V0OperationKind {
|
|||
DeclareTransaction(TransactionDescriptor),
|
||||
/// Opaque extension payload; unchanged in v1.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// non-empty, otherwise a visible rest. Mirrors the prototype's
|
||||
/// pitched-or-rest split, now as a real value.
|
||||
|
|
|
|||
|
|
@ -33,16 +33,16 @@ use epiphany_ops::{
|
|||
AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId,
|
||||
ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry,
|
||||
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, RepairKind, RepairKindRegistryId, RepairRecord,
|
||||
ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId,
|
||||
ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, SetUserSystemBreakOp,
|
||||
TransactionCategory, TransactionDescriptor, TupletCompensation, TupletCompensationKind,
|
||||
UndoPolicy, UndoTransactionPayload,
|
||||
DeleteIdentifiedPitchOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp,
|
||||
InsertIdentifiedPitchOp, IntegrityAnomaly, IntegrityAnomalyKind, IntegrityAnomalyRegistryId,
|
||||
MaterializedState, ModifyEventOp, ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState,
|
||||
OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload,
|
||||
OperationSet, OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason,
|
||||
PreconditionFailureRegistryId, ReanchorReason, ReanchorReasonRegistryId, ReanchorResult,
|
||||
RepairKind, RepairKindRegistryId, RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId,
|
||||
ResolutionAction, ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp,
|
||||
SerializedCanonicalInputs, SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor,
|
||||
TransposeOp, TupletCompensation, TupletCompensationKind, UndoPolicy, UndoTransactionPayload,
|
||||
};
|
||||
|
||||
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 => {
|
||||
let pitches = if rng.boolean() {
|
||||
vec![obj_pitch(rng.below(pitches))]
|
||||
|
|
@ -694,6 +694,31 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP
|
|||
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(
|
||||
OperationKindRegistryId(rng.next_u64() as u128),
|
||||
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-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-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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue