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:
parent
f62f5d4276
commit
0f1b209e54
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue