From 0f1b209e54c24308961b5847665ac465598240f2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 25 Jun 2026 17:23:11 -0400 Subject: [PATCH] =?UTF-8?q?Agent=20K:=20DeleteEvent=20re-anchoring=20?= =?UTF-8?q?=E2=80=94=20make=20the=20graph=20follow=20the=20ledger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/epiphany-ops/DECISIONS.md | 34 +++++++ crates/epiphany-ops/src/reduce.rs | 98 ++++++++++++++++++-- crates/epiphany-ops/tests/graph_reduction.rs | 89 ++++++++++++++++++ crates/epiphany-testkit/src/generators.rs | 45 ++++++++- 4 files changed, 255 insertions(+), 11 deletions(-) diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index ea20a44..c03e6da 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -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 diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index e647622..a52614e 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -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() diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index d492615..b06f2c9 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -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()); +} diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index 45debde..031a645 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -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)); }