Agent K: DeleteEvent re-anchoring — make the graph follow the ledger

Standalone follow-up to the M2b review: close the pre-existing reanchor /
graph-delete divergence, which unlocks at-scale criterion-1 coverage for the
Group-2 cross-cutting CRUD ops.

The divergence: a DeleteEvent tombstoning a slur/spanner endpoint re-anchored the
structure in the bookkeeping ledger (object stays Live) but materialize_graph_delete
removed it from the graph unconditionally — so the object was Live in
MaterializedState yet gone from the Score. Latent only because graph_edit_session
never created cross-cutting structures.

Fix (graph-materialization only; bookkeeping/convergence unchanged):
- materialize_graph_delete now mirrors reanchor_for_tombstone for slurs and
  spanners: an endpoint-deleted structure re-anchors onto its surviving endpoint
  (stays in the graph) and is removed only when no endpoint survives. A
  two-endpoint structure collapses onto the survivor (degenerate (B,B), but
  reference-clean — the cross-cutting invariant requires only live endpoints;
  proximity-aware target deferred, P11-C5). Ties (cascade) and beams
  (truncate-while->=2) were already consistent and are unchanged. This also fixes
  a latent dangling-spanner bug (spanners weren't handled on event delete at all).
- seed_from_graph records each base-score spanner's event-anchored endpoints in
  `structures`, so a seeded spanner re-anchors through the same rule as a created
  one.

Coverage:
- New reduce_onto tests: deleting one slur endpoint re-anchors in both graph and
  ledger (slur Live + collapsed onto survivor); deleting both cascades in both
  (slur Tombstoned + removed).
- graph_edit_session now creates slurs over replica-0 events and emits
  DeleteCrossCutting / ModifyCrossCutting, so criterion 1 (reduce_onto +
  check_invariants, across delivery permutations) exercises cross-cutting CRUD and
  slur re-anchoring at scale.

Docs: DECISIONS.md records the graph-follows-ledger re-anchoring decision and the
degenerate-collapse / P11-C5 deferral.

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (ops
graph_reduction 20; criterion 1 green with cross-cutting wired in); conformance
scale 1 passes. The unrelated Agent-I working tree is left uncommitted; this
commit stages only ops/testkit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-25 17:23:11 -04:00
parent f62f5d4276
commit 0f1b209e54
4 changed files with 255 additions and 11 deletions

View File

@ -108,6 +108,40 @@ targeted `reduce_onto` tests in `tests/graph_reduction.rs`.
placement-change deferral as the prototype boundary (a full re-sort/move op is
a later refinement).
## DeleteEvent re-anchoring: the graph follows the ledger
A `DeleteEvent` that tombstones an event runs the re-anchoring rule table
(Chapter 6 §6.5) over the cross-cutting structures that referenced it. The
*ledger* decision (`reanchor_for_tombstone`) and the *graph* mutation
(`materialize_graph_delete`) MUST agree on whether a structure survives — else an
object is `Live` in `MaterializedState` but gone from the graph (or vice versa),
a faithfulness gap the graph convergence gate (criterion 1) would surface.
- **Slurs and spanners re-anchor, not unconditionally drop.** The ledger keeps a
slur/spanner `Live` while ≥1 endpoint survives (re-anchored) and cascade-deletes
only when none does. `materialize_graph_delete` now mirrors that exactly:
an endpoint-deleted slur/spanner re-anchors onto its surviving endpoint (the
structure stays in the graph) and is removed only when no endpoint survives.
Previously the graph removed *any* slur touching the deleted event regardless of
the ledger's re-anchor — the divergence this entry fixes. Ties (cascade) and
beams (truncate-while-≥2-members, else cascade) were already consistent and are
unchanged.
- **A re-anchored two-endpoint structure collapses onto the survivor.** With only
two endpoints, the sole survivor *is* the other endpoint, so re-anchoring sets
both to it (a degenerate `(B, B)` slur / spanner). This is reference-clean — the
cross-cutting invariant requires only that endpoints reference *live* events, not
that they differ — but musically a stand-in. A proximity-aware target (the note
that took the deleted one's place) needs resolved positions and is deferred
(P11-C5; `nearest_survivor` is the lexicographic stand-in).
- **Base-score spanners are now indexed for re-anchoring.** `seed_from_graph`
records each seeded spanner's event-anchored endpoints in `structures` (as it
already did for slurs/ties/beams), so a base spanner re-anchors through the same
rule as a created one rather than being left dangling.
This consistency is what lets `graph_edit_session` create cross-cutting structures
and delete their endpoints, giving the Group-2 CRUD ops (and slur re-anchoring)
real at-scale coverage under criterion 1 + `check_invariants`.
## Pass 11 candidates (ambiguities for the spec, not resolved in code)
### P11-C1 — operation payload schemas are deferred; we carry identifiers + fingerprints

View File

@ -571,8 +571,21 @@ impl<'a> Reducer<'a> {
);
}
for spanner in &score.cross_cutting.spanners {
self.objects
.insert(TypedObjectId::Spanner(spanner.id), ObjectState::Live);
let id = TypedObjectId::Spanner(spanner.id);
self.objects.insert(id, ObjectState::Live);
// Record the spanner's event-anchored endpoints so a later event
// tombstone re-anchors it through the same rule table as a created
// spanner (keeping the graph and ledger consistent on delete).
self.structures.insert(
id,
[&spanner.start, &spanner.end]
.into_iter()
.filter_map(|anchor| match anchor {
TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)),
_ => None,
})
.collect(),
);
}
for marker in &score.cross_cutting.markers {
self.objects
@ -990,10 +1003,83 @@ impl<'a> Reducer<'a> {
beam.events.retain(|event| *event != op.event);
beam.events.len() >= 2
});
score
.cross_cutting
.slurs
.retain(|slur| slur.start_event != op.event && slur.end_event != op.event);
// Slurs and spanners follow the bookkeeping re-anchoring rule: an
// endpoint-deleted structure re-anchors to its surviving endpoint while
// one survives, and cascade-deletes only when none does (Chapter 6 §6.5;
// matching `reanchor_for_tombstone`, so the graph and the ledger agree on
// the structure's existence). A re-anchored two-endpoint structure
// collapses onto the survivor — degenerate but reference-clean (both
// endpoints stay live); proximity-aware re-anchoring is deferred (P11-C5).
let slurs = std::mem::take(&mut score.cross_cutting.slurs);
let kept_slurs: Vec<_> = slurs
.into_iter()
.filter_map(|mut slur| {
let start_hit = slur.start_event == op.event;
let end_hit = slur.end_event == op.event;
if !start_hit && !end_hit {
return Some(slur);
}
let survivor = if start_hit {
slur.end_event
} else {
slur.start_event
};
if survivor != op.event && score.events.contains(survivor) {
slur.start_event = if start_hit {
survivor
} else {
slur.start_event
};
slur.end_event = if end_hit { survivor } else { slur.end_event };
Some(slur)
} else {
None
}
})
.collect();
score.cross_cutting.slurs = kept_slurs;
let spanners = std::mem::take(&mut score.cross_cutting.spanners);
let kept_spanners: Vec<_> = spanners
.into_iter()
.filter_map(|mut spanner| {
let start_hit =
matches!(spanner.start, TimeAnchor::Event { id, .. } if id == op.event);
let end_hit = matches!(spanner.end, TimeAnchor::Event { id, .. } if id == op.event);
if !start_hit && !end_hit {
return Some(spanner);
}
// The survivor is the *other* anchor's event, if it is an event
// anchor on a live event; otherwise the spanner cascade-deletes.
let other = if start_hit {
&spanner.end
} else {
&spanner.start
};
let survivor = match other {
TimeAnchor::Event { id, .. }
if *id != op.event && score.events.contains(*id) =>
{
Some(*id)
}
_ => None,
};
let to = survivor?;
if start_hit {
if let TimeAnchor::Event { id, .. } = &mut spanner.start {
*id = to;
}
}
if end_hit {
if let TimeAnchor::Event { id, .. } = &mut spanner.end {
*id = to;
}
}
Some(spanner)
})
.collect();
score.cross_cutting.spanners = kept_spanners;
score.cross_cutting.lyrics.retain_mut(|line| {
line.events.retain(|event| *event != op.event);
!line.events.is_empty()

View File

@ -941,3 +941,92 @@ fn modify_cross_cutting_rejects_an_undersized_beam() {
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn deleting_a_slur_endpoint_reanchors_in_both_graph_and_ledger() {
// Deleting one endpoint of a slur re-anchors it onto the survivor: the slur
// stays Live in the ledger AND present in the graph (collapsed onto the
// surviving endpoint), so the two never disagree on its existence.
let base = epiphany_core::generators::valid_score(100);
let slur = SlurId::new(ReplicaId(70), 9);
let (e1, e2, _e3, create, inserts) = cross_cutting_fixture(&base, |a, b, _| {
CrossCuttingValue::Slur(valuegen::slur(slur, a, b))
});
let delete_e1 = after_create(OperationPayload::Primitive(OperationKind::DeleteEvent(
DeleteEventOp {
event: e1,
tuplet_compensation: TupletCompensation::NotInTuplet,
},
)));
let mut set = OperationSet::new();
set.accept_all(inserts.into_iter().chain([create, delete_e1]));
let result = set.reduce_onto(&base);
assert_eq!(
result.state.objects.get(&TypedObjectId::Slur(slur)),
Some(&epiphany_ops::ObjectState::Live),
"a re-anchored slur stays live in the ledger"
);
let materialized = result
.score
.cross_cutting
.slurs
.iter()
.find(|s| s.id == slur)
.expect("the re-anchored slur is still present in the graph");
assert_eq!(
(materialized.start_event, materialized.end_event),
(e2, e2),
"the slur collapses onto the surviving endpoint"
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn deleting_both_slur_endpoints_cascades_in_both_graph_and_ledger() {
// With no endpoint surviving, the slur cascade-deletes: tombstoned in the
// ledger AND removed from the graph (the other side of the same coin).
let base = epiphany_core::generators::valid_score(100);
let slur = SlurId::new(ReplicaId(70), 9);
let (e1, e2, _e3, create, inserts) = cross_cutting_fixture(&base, |a, b, _| {
CrossCuttingValue::Slur(valuegen::slur(slur, a, b))
});
let delete_e1 = after_create(OperationPayload::Primitive(OperationKind::DeleteEvent(
DeleteEventOp {
event: e1,
tuplet_compensation: TupletCompensation::NotInTuplet,
},
)));
let delete_e2 = envelope(
70,
5,
16,
CausalContext::new().with_seen(ReplicaId(70), 4),
None,
OperationPayload::Primitive(OperationKind::DeleteEvent(DeleteEventOp {
event: e2,
tuplet_compensation: TupletCompensation::NotInTuplet,
})),
);
let mut set = OperationSet::new();
set.accept_all(inserts.into_iter().chain([create, delete_e1, delete_e2]));
let result = set.reduce_onto(&base);
assert!(
matches!(
result.state.objects.get(&TypedObjectId::Slur(slur)),
Some(epiphany_ops::ObjectState::Tombstoned { .. })
),
"a slur with no surviving endpoint is tombstoned in the ledger"
);
assert!(
!result
.score
.cross_cutting
.slurs
.iter()
.any(|s| s.id == slur),
"the cascaded slur is removed from the graph"
);
assert!(check_invariants(&result.score).is_empty());
}

View File

@ -1060,13 +1060,33 @@ pub fn graph_edit_session(
}
let total = targets.len() as u64 * n;
// Create a handful of slurs over replica-0 events (the even object indices of
// the first target voice — replica 0 authored and therefore sees them), so the
// Group-2 cross-cutting CRUD below has live structures to delete and modify,
// and the DeleteEvent edits exercise slur re-anchoring on the real graph.
let n_slurs = (n / 4).clamp(1, 6);
for k in 0..n_slurs {
session.author(
rng,
0,
OperationPayload::Primitive(OperationKind::CreateCrossCutting(CreateCrossCuttingOp {
structure: CrossCuttingValue::Slur(valuegen::slur(
SlurId::new(OBJ_REPLICA, k),
obj_event(4 * k),
obj_event(4 * k + 2),
)),
})),
);
}
for _ in 0..80 {
let r = rng.below(2) as usize;
// Mix the original edit kinds with the Group-1 (M2) leaf-field ops so the
// real-Score gate exercises their *graph* materialization (reduce_onto +
// Mix the original edit kinds with the Group-1/2 (M2) ops so the real-Score
// gate exercises their *graph* materialization (reduce_onto +
// check_invariants), not just the bookkeeping projection. Each targets a
// live object minted by the insert phase above.
let kind = match rng.below(7) {
// live object minted by the phases above.
let kind = match rng.below(9) {
0 => OperationKind::DeleteEvent(DeleteEventOp {
event: obj_event(rng.below(total)),
tuplet_compensation: TupletCompensation::NotInTuplet,
@ -1111,10 +1131,25 @@ pub fn graph_edit_session(
5 => OperationKind::DeleteIdentifiedPitch(DeleteIdentifiedPitchOp {
pitch: obj_pitch(rng.below(total)),
}),
_ => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
6 => OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
pitch: obj_pitch(rng.below(total)),
value: valuegen::pitch_value_nth(rng.below(7) as u8),
}),
// Group 2 (M2): cross-cutting CRUD over the slurs created above.
7 => OperationKind::DeleteCrossCutting(DeleteCrossCuttingOp {
structure: TypedObjectId::Slur(SlurId::new(OBJ_REPLICA, rng.below(n_slurs))),
}),
_ => {
let k = rng.below(n_slurs);
OperationKind::ModifyCrossCutting(ModifyCrossCuttingOp {
// Re-point the slur's end to another even (replica-0) event.
structure: CrossCuttingValue::Slur(valuegen::slur(
SlurId::new(OBJ_REPLICA, k),
obj_event(4 * k),
obj_event(4 * ((k + 1) % n_slurs)),
)),
})
}
};
session.author(rng, r, OperationPayload::Primitive(kind));
}