ops: ModifyEvent materializes metric placement changes (trim/move)

The make-room enabler: a ModifyEvent that moves a metric event's span
(different Musical position or duration) is now applied to the graph and the
owning voice re-sorted by ascending position (id-tiebroken, the order an
insert maintains), so invariant 3 (VoiceEventsSortedNonOverlap) holds. The
deferral documented in DECISIONS.md is lifted.

modify_event computes one sanction — the move is a valid metric move
(PlacementVerdict::Moved) and the replacement is well-formed — and uses it to
gate both the graph mutation (graph_replace_event) and the voice_occupancy
update, so the canonical index and the graph never diverge. The verdict is
read from voice_occupancy, the graph-independent placement index, so reduce()
and reduce_onto() agree on it; a move with a non-positive span or one that
would overlap a live sibling is refused as a clean NoOp(EventDurationInvalid)
rather than skipped silently. A non-metric move stays deferred, and a
malformed (empty) pitched replacement is neither materialized nor allowed to
move occupancy.

Tests: trim frees the voice slot (a later insert fits); a move onto a sibling
is refused; a trim materializes in the graph (reduce_onto, invariants hold);
a non-metric event is not rewritten onto the musical grid; a malformed move
does not free the slot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
Levi Neuwirth 2026-06-30 10:43:10 -04:00
parent 1ab3e5f298
commit b3403c1d6a
2 changed files with 487 additions and 35 deletions

View File

@ -97,16 +97,26 @@ targeted `reduce_onto` tests in `tests/graph_reduction.rs`.
Catalog §"Insert/Delete identified pitch" (M2e) ratifies this note↔rest
equivalence normatively.
- **`ModifyEvent` defers placement changes in the graph.** A `ModifyEvent` whose
payload moves the event (different position or duration) is *not* applied to
the graph: re-sorting a voice's event list on a placement change is deferred,
and applying a move via `get_mut` would break invariant 3
(`VoiceEventsSortedNonOverlap`). Same-placement field edits apply, preserving
the existing voice membership (owned by the voice list); a malformed (empty)
pitched replacement is likewise skipped. The LWW bookkeeping records the modify
either way. **For the spec:** the catalog §ModifyEvent (M2e) states the
placement-change deferral as the prototype boundary (a full re-sort/move op is
a later refinement).
- **`ModifyEvent` materializes metric placement changes (trim/move).** A
`ModifyEvent` whose payload moves a *metric* event (different `Musical` position
or duration) is now applied to the graph and the owning voice is **re-sorted**
by ascending position (id-tiebroken — the same order an insert maintains), so
invariant 3 (`VoiceEventsSortedNonOverlap`) is preserved. This is the make-room
enabler: a "pencil" insert trims the events it overlaps. To keep invariant 3,
`modify_event` first checks a **placement precondition** read from
`voice_occupancy` (the canonical, graph-independent placement index, so
`reduce()` and `reduce_onto()` agree): a move with a non-positive span, or one
that would overlap another live event in the voice, is **refused** as a clean
`NoOp(EventDurationInvalid)` rather than skipped silently (which would log a
clean op that never took effect). A materialized move updates `voice_occupancy`
too, so a later insert sees the freed/changed span. A *non-metric* placement
change is still deferred (re-sorting a non-metric voice is out of scope), and a
malformed (empty) pitched replacement is still skipped; same-placement field
edits apply as before, preserving voice membership. The LWW bookkeeping records
the modify in every case. **For the spec:** the catalog §ModifyEvent (M2e)
prototype boundary moves from "placement-change deferral" to "metric
placement-change materialization with an invariant-3 precondition"; partial
trimming of a tuplet member, and non-metric moves, remain later refinements.
## DeleteEvent re-anchoring: the graph follows the ledger

View File

@ -460,6 +460,24 @@ fn graph_voice_location(score: &Score, voice: VoiceId) -> Option<(usize, usize,
None
}
/// The verdict on a [`ModifyEvent`](OperationKind::ModifyEvent)'s placement: whether
/// it moves the target event's metric span, and if so whether the move keeps
/// invariant 3 (`VoiceEventsSortedNonOverlap`). A non-metric or same-placement modify
/// is [`Unchanged`](PlacementVerdict::Unchanged) — handled by the existing field-edit
/// path.
enum PlacementVerdict {
/// Same placement, or a non-metric event: nothing to materialize or refuse.
Unchanged,
/// A metric move that would overlap a sibling, or carries a non-positive span.
Refused,
/// A valid metric move to `position`/`duration` within `voice`.
Moved {
voice: VoiceId,
position: MusicalPosition,
duration: MusicalDuration,
},
}
/// The graph value inserted by a value-typed InsertEvent: the carried [`Event`]
/// itself, with its voice rebound to the (possibly system-promoted) target
/// voice. The Operation Catalog (v1) carries the real event, so this is no
@ -2642,6 +2660,20 @@ impl<'a> Reducer<'a> {
}
Some(ObjectState::Live) => {}
}
// A `ModifyEvent` that moves a metric event's span (a trim or move) is now
// materialized, but it must keep invariant 3 (`VoiceEventsSortedNonOverlap`):
// refuse a move onto another live event in the voice, or one with a
// non-positive span, rather than skip it silently (which would log a clean op
// that never took effect). The verdict reads `voice_occupancy`, the
// graph-independent index, so `reduce()` and `reduce_onto()` agree on it.
let placement = self.metric_placement_verdict(&op.event);
if matches!(placement, PlacementVerdict::Refused) {
return OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::EventDurationInvalid,
},
};
}
let prev = self
.last_event_modify
.get(&event_id)
@ -2673,10 +2705,115 @@ impl<'a> Reducer<'a> {
};
self.last_event_modify
.insert(event_id, (env.id, op.event.clone()));
self.graph_replace_event(&op.event);
// Materialize a move only when it is a sanctioned metric move (`Moved`) *and*
// the replacement is well-formed — so the graph and the occupancy index move
// together. A malformed (empty) pitched replacement is not materialized in the
// graph (`graph_replace_event` skips it), so it must not move occupancy either.
let materialize_move = matches!(placement, PlacementVerdict::Moved { .. })
&& !matches!(&op.event, Event::Pitched(pe) if !pe.is_well_formed());
self.graph_replace_event(&op.event, materialize_move);
// Keep the voice-occupancy index in step with a materialized move, so a later
// insert sees the freed/changed span (the same index its overlap check reads).
if materialize_move {
if let PlacementVerdict::Moved {
voice,
position,
duration,
} = placement
{
if let Some(events) = self.voice_occupancy.get_mut(&voice) {
for slot in events.iter_mut().filter(|slot| slot.2 == event_id) {
slot.0 = position.clone();
slot.1 = duration.clone();
}
}
}
}
effect
}
/// The verdict on a [`ModifyEvent`](OperationKind::ModifyEvent)'s placement: does
/// it move the event's metric span, and if so does the move keep invariant 3
/// (`VoiceEventsSortedNonOverlap`)? Read from `voice_occupancy` — the canonical,
/// graph-independent placement index — so the verdict is identical with or without
/// a base graph.
fn metric_placement_verdict(&self, new_event: &Event) -> PlacementVerdict {
let event_id = new_event.id();
let Some((voice, current_position, current_duration)) =
self.voice_occupancy.iter().find_map(|(voice, events)| {
events
.iter()
.find(|(_, _, event)| *event == event_id)
.map(|(position, duration, _)| (*voice, position.clone(), duration.clone()))
})
else {
// The event has no metric occupancy entry (untracked or non-metric):
// nothing to materialize or refuse here.
return PlacementVerdict::Unchanged;
};
let (EventPosition::Musical(new_position), EventDuration::Musical(new_duration)) =
(new_event.position(), new_event.duration())
else {
// A non-metric placement is left deferred, neither moved nor refused.
return PlacementVerdict::Unchanged;
};
if *new_position == current_position && *new_duration == current_duration {
return PlacementVerdict::Unchanged;
}
if !new_duration.is_positive() {
return PlacementVerdict::Refused;
}
let overlaps = self.voice_occupancy.get(&voice).is_some_and(|events| {
events.iter().any(|(position, duration, event)| {
*event != event_id
&& intervals_overlap(new_position, new_duration, position, duration)
})
});
if overlaps {
PlacementVerdict::Refused
} else {
PlacementVerdict::Moved {
voice,
position: new_position.clone(),
duration: new_duration.clone(),
}
}
}
/// Re-sorts `voice`'s graph event list by ascending position (id-tiebroken), the
/// same order an insert maintains — run after a materialized placement change so
/// the voice stays sorted (invariant 3). A no-op when the graph is absent.
fn resort_voice(&mut self, voice: VoiceId) {
let Some(score) = self.graph.as_mut() else {
return;
};
let Some((region_index, instance_index, voice_index)) = graph_voice_location(score, voice)
else {
return;
};
let mut ordered = score.canvas.regions[region_index].staff_instances()[instance_index]
.voices[voice_index]
.events
.clone();
ordered.sort_by(|a, b| {
let a_position = score.events.get(*a).map(Event::position);
let b_position = score.events.get(*b).map(Event::position);
match (a_position, b_position) {
(
Some(EventPosition::Musical(a_position)),
Some(EventPosition::Musical(b_position)),
) => a_position.cmp(b_position).then_with(|| a.cmp(b)),
_ => a.cmp(b),
}
});
score.canvas.regions[region_index]
.content
.staff_instances_mut()
.expect("the voice was located in a staff-based instance")[instance_index]
.voices[voice_index]
.events = ordered;
}
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
@ -2836,36 +2973,49 @@ impl<'a> Reducer<'a> {
// --- 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;
};
// A ModifyEvent carrying a malformed (empty) pitched event must not
// corrupt the arena: `get_mut` bypasses `insert`'s well-formedness guard,
// so an empty chord would only be caught later by `check_invariants`.
// Skip the graph replace in that case (bookkeeping still records it).
if let Event::Pitched(pe) = new_event {
if !pe.is_well_formed() {
/// Applies a `ModifyEvent`'s value to the graph. `materialize_move` is the caller's
/// sanction (from [`Self::metric_placement_verdict`]) that a *placement* change is a
/// valid metric move and should be applied + the voice re-sorted; when it is false a
/// placement change is deferred (a non-metric move, a no-occupancy event, or a
/// refused move), leaving only same-placement field edits to apply. Keeping
/// materialization gated on this single sanction is what holds the graph and the
/// `voice_occupancy` index in agreement.
fn graph_replace_event(&mut self, new_event: &Event, materialize_move: bool) {
let placement_changed;
let voice;
{
let Some(score) = self.graph.as_mut() else {
return;
};
// A ModifyEvent carrying a malformed (empty) pitched event must not
// corrupt the arena: `get_mut` bypasses `insert`'s well-formedness guard,
// so an empty chord would only be caught later by `check_invariants`.
// Skip the graph replace in that case (bookkeeping still records it).
if let Event::Pitched(pe) = new_event {
if !pe.is_well_formed() {
return;
}
}
let Some(existing) = score.events.get_mut(new_event.id()) else {
return;
};
placement_changed = new_event.position() != existing.position()
|| new_event.duration() != existing.duration();
// A placement change is materialized (and the voice re-sorted below) only
// when the caller sanctioned it as a valid metric move; otherwise it is
// deferred, leaving the LWW bookkeeping to record the modify. Same-placement
// field edits always apply, preserving the original voice membership.
if placement_changed && !materialize_move {
return;
}
}
if let Some(existing) = score.events.get_mut(new_event.id()) {
// Re-sorting a voice on a placement change is deferred, so a
// ModifyEvent that moves the event (different position or duration)
// is not applied to the graph yet: doing so via `get_mut` would
// break invariant 3 (voice events sorted, non-overlapping). The LWW
// bookkeeping still records it; same-placement field edits apply,
// preserving the original voice membership (owned by the voice list).
if new_event.position() != existing.position()
|| new_event.duration() != existing.duration()
{
return;
}
let voice = existing.voice();
voice = existing.voice();
let mut replacement = new_event.clone();
replacement.set_voice(voice);
*existing = replacement;
}
if placement_changed {
self.resort_voice(voice);
}
}
fn graph_event_of_pitch(score: &Score, pitch: PitchId) -> Option<EventId> {
@ -3675,6 +3825,298 @@ mod tests {
));
}
// --- ModifyEvent placement changes (trim/move): the make-room enabler. ------
fn musical(numerator: i64, denominator: i64) -> MusicalDuration {
MusicalDuration(RationalTime::new(numerator, denominator).unwrap())
}
fn position_of(numerator: i64, denominator: i64) -> MusicalPosition {
MusicalPosition(RationalTime::new(numerator, denominator).unwrap())
}
/// An InsertEvent of a rest at an explicit metric span.
fn insert_at(
replica: u64,
counter: u64,
event: u64,
voice: u64,
position: MusicalPosition,
duration: MusicalDuration,
ctx: CausalContext,
) -> OperationEnvelope {
prim_env(
replica,
counter,
(counter as i64 + 1) * 10,
ctx,
OperationKind::InsertEvent(InsertEventOp {
staff_instance: StaffInstanceId::new(ReplicaId(9), 0),
event: crate::valuegen::insert_event_value(
EventId::new(ReplicaId(replica), event),
VoiceId::new(ReplicaId(9), voice),
position,
duration,
&[],
),
}),
)
}
/// A ModifyEvent that re-places `event` (a rest) at a new metric span.
fn modify_to(
replica: u64,
counter: u64,
event: u64,
voice: u64,
position: MusicalPosition,
duration: MusicalDuration,
ctx: CausalContext,
) -> OperationEnvelope {
prim_env(
replica,
counter,
(counter as i64 + 1) * 10,
ctx,
OperationKind::ModifyEvent(crate::payload::ModifyEventOp {
event: crate::valuegen::insert_event_value(
EventId::new(ReplicaId(replica), event),
VoiceId::new(ReplicaId(9), voice),
position,
duration,
&[],
),
}),
)
}
fn effect_at(state: &MaterializedState, counter: u64) -> Option<&OperationEffect> {
state
.effects
.iter()
.find(|(id, _)| *id == OperationId::new(ReplicaId(1), counter))
.map(|(_, effect)| effect)
}
#[test]
fn modify_event_trim_frees_the_voice_slot() {
// e1 fills [0, 1). Trim it to [0, 1/2), then insert e2 into the freed [1/2, 1):
// the insert only fits if the trim updated the voice-occupancy index.
let e1 = insert(1, 0, 10, 1, 100, 0);
let trim = modify_to(
1,
1,
100,
1,
position_of(0, 1),
musical(1, 2),
CausalContext::new().with_seen(ReplicaId(1), 0),
);
let e2 = insert_at(
1,
2,
101,
1,
position_of(1, 2),
musical(1, 2),
CausalContext::new().with_seen(ReplicaId(1), 1),
);
let mut set = OperationSet::new();
set.accept_all(vec![e1, trim, e2]);
let state = set.reduce();
assert!(
matches!(effect_at(&state, 1), Some(OperationEffect::Applied)),
"the trim applies"
);
assert!(
matches!(effect_at(&state, 2), Some(OperationEffect::Applied)),
"the insert fits the span the trim freed (occupancy was updated)"
);
}
#[test]
fn modify_event_move_onto_a_sibling_is_refused() {
// e1 [0, 1), e2 [1, 2). Moving e1 onto e2's span would break invariant 3, so
// it is refused (a clean NoOp), not silently skipped.
let e1 = insert(1, 0, 10, 1, 100, 0);
let e2 = insert(1, 1, 20, 1, 101, 1);
let onto_sibling = modify_to(
1,
2,
100,
1,
position_of(1, 1),
musical(1, 1),
CausalContext::new().with_seen(ReplicaId(1), 1),
);
let mut set = OperationSet::new();
set.accept_all(vec![e1, e2, onto_sibling]);
let state = set.reduce();
assert!(
matches!(
effect_at(&state, 2),
Some(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction {
reason: PreconditionFailureReason::EventDurationInvalid,
},
})
),
"a move onto a live sibling is refused"
);
assert!(
state.is_clean(),
"a refused move records no conflict/anomaly (like an insert overlap no-op)"
);
}
#[test]
fn modify_event_trim_materializes_in_the_graph() {
use epiphany_core::check_invariants;
use epiphany_core::generators::valid_score;
// Shrink the first metric event's duration; the change must now reach the
// graph (it was previously deferred) and leave the voice invariant-3 valid.
let base = valid_score(0x5EED);
let (event_id, duration) = base
.voices()
.flat_map(|(_, _, v)| v.events.clone())
.find_map(|eid| {
let ev = base.events.get(eid)?;
match (ev.position(), ev.duration()) {
(EventPosition::Musical(_), EventDuration::Musical(d)) => {
Some((eid, d.clone()))
}
_ => None,
}
})
.expect("the fixture has a metric event");
let half = MusicalDuration(duration.0.mul(&RationalTime::new(1, 2).unwrap()));
let mut shrunk = base.events.get(event_id).unwrap().clone();
match &mut shrunk {
Event::Pitched(pe) => pe.duration = EventDuration::Musical(half.clone()),
Event::Rest(rest) => rest.duration = EventDuration::Musical(half.clone()),
_ => panic!("a metric event is pitched or a rest"),
}
let modify = prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::ModifyEvent(crate::payload::ModifyEventOp { event: shrunk }),
);
let mut set = OperationSet::new();
set.accept_all(vec![modify]);
let result = set.reduce_onto(&base);
assert_eq!(
result.score.events.get(event_id).map(Event::duration),
Some(&EventDuration::Musical(half)),
"the trimmed duration is materialized in the graph"
);
assert!(
check_invariants(&result.score).is_empty(),
"the trimmed voice stays sorted and non-overlapping"
);
}
#[test]
fn modify_event_does_not_rewrite_a_non_metric_event_as_metric() {
use epiphany_core::check_invariants;
use epiphany_core::generators::valid_score_rich;
// A ModifyEvent that rewrites a wall-clock event (a proportional region) into a
// metric one must stay deferred — materializing it would re-place a non-metric
// event onto a musical grid, breaking the region's time-model invariant.
let base = valid_score_rich(0x5EED);
let event_id = base
.voices()
.flat_map(|(_, _, v)| v.events.clone())
.find(|eid| {
matches!(
base.events.get(*eid).map(Event::position),
Some(EventPosition::WallClock(_))
)
})
.expect("the fixture has a proportional region with a wall-clock event");
let voice = base.events.get(event_id).unwrap().voice();
let as_metric = crate::valuegen::insert_event_value(
event_id,
voice,
position_of(0, 1),
musical(1, 2),
&[],
);
let modify = prim_env(
2,
0,
10,
CausalContext::new(),
OperationKind::ModifyEvent(crate::payload::ModifyEventOp { event: as_metric }),
);
let mut set = OperationSet::new();
set.accept_all(vec![modify]);
let result = set.reduce_onto(&base);
assert!(
matches!(
result.score.events.get(event_id).map(Event::position),
Some(EventPosition::WallClock(_))
),
"the non-metric event is left as-is, not rewritten onto the musical grid"
);
assert!(
check_invariants(&result.score).is_empty(),
"the graph stays invariant-valid"
);
}
#[test]
fn modify_event_malformed_move_does_not_free_the_slot() {
// A ModifyEvent rewriting e1 (filling [0, 1)) to an *empty* (malformed) pitched
// event at [0, 1/2) is not materialized in the graph — so it must not move the
// occupancy index either. A later insert into [1/2, 1) is therefore refused,
// since e1 still occupies [0, 1).
let e1 = insert(1, 0, 10, 1, 100, 0);
let empty = Event::Pitched(epiphany_core::PitchedEvent {
id: EventId::new(ReplicaId(1), 100),
voice: VoiceId::new(ReplicaId(9), 1),
position: EventPosition::Musical(position_of(0, 1)),
duration: EventDuration::Musical(musical(1, 2)),
pitches: vec![],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: epiphany_core::StemConfiguration,
grace: None,
});
let malformed = prim_env(
1,
1,
20,
CausalContext::new().with_seen(ReplicaId(1), 0),
OperationKind::ModifyEvent(crate::payload::ModifyEventOp { event: empty }),
);
let e2 = insert_at(
1,
2,
101,
1,
position_of(1, 2),
musical(1, 2),
CausalContext::new().with_seen(ReplicaId(1), 1),
);
let mut set = OperationSet::new();
set.accept_all(vec![e1, malformed, e2]);
let state = set.reduce();
assert!(
matches!(
effect_at(&state, 2),
Some(OperationEffect::NoOp {
reason: NoOpReason::PreconditionFailedUnderReduction { .. },
})
),
"the malformed trim did not free the slot, so the later insert is refused"
);
}
#[test]
fn insert_then_delete_identified_pitch_tombstones() {
let event = EventId::new(ReplicaId(1), 100);