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:
Levi Neuwirth 2026-06-24 13:56:01 -04:00
parent c9cafe9290
commit 1658fd18f3
10 changed files with 745 additions and 25 deletions

View File

@ -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);
}
}
}
}

View File

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

View File

@ -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,

View File

@ -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()));
}
}
}

View File

@ -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()

View File

@ -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"
);
}
}

View File

@ -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

View File

@ -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.

View File

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

View File

@ -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