Pass 13 — P13-D2: tombstone before the graph delete, closing the cue-cascade trail

delete_event tombstoned the event in objects AFTER materialize_graph_delete —
but that graph pass cascades a cue among the event's referents, running
reanchor_for_tombstone over the cue's referents while the source event is still
Live. A slur bridging {X, cue-of-X} therefore re-anchored onto X
(Reanchored{to: X}) and then cascade-deleted when X's tombstone landed a line
later: a contradictory same-effect trail (candidate was "plausible by code
trace, unexecuted" — now executed; reverting the fix reproduces exactly that
two-record trail).

Fix: tombstone the event in objects BEFORE the graph delete, matching the
conventions cascade_cue and tombstone_undo_targets already follow (both
tombstone before their graph delete — which is why the undo path never had this
bug). The bridging slur now sees X already dead during the cue cascade and
cascades once.

Regression: deleting_a_cue_source_does_not_leave_a_contradictory_repair_for_a_
bridging_slur_p13_d2 (built on a cue-bearing base with a bridging slur; asserts
exactly one CascadeDeleted, no Reanchored). Verified to fail without the fix.
940 tests, convergence/conformance green.

PASS13_CANDIDATES.md: P13-D2 resolved — batch CLOSED (all four candidates done).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-08 18:25:37 -04:00
parent 79c8e1da2e
commit 430214d619
3 changed files with 130 additions and 3 deletions

View File

@ -1098,6 +1098,21 @@ simply never exercised — no existing test broke). Locked by
`undo_orphaning_a_pre_existing_slur_cascades_it_in_the_ledger_p13_d1`
(cascade + recorded repair + order-independent convergence).
**P13-D2 resolved (Pass 13, 2026-07-08).** `delete_event` tombstoned the event
in `objects` *after* `materialize_graph_delete` — but that graph pass cascades
a cue among the event's referents, running `reanchor_for_tombstone` over the
cue's own referents while the source event is still `Live`. A slur bridging
{X, cue-of-X} therefore re-anchored onto X (recording `Reanchored{to: X}`) and
then cascade-deleted when X's tombstone landed a line later — a contradictory
same-effect trail (candidate: "plausible by code trace, unexecuted"; now
executed — reverting the fix reproduces exactly that two-record trail). Fixed
by tombstoning the event in `objects` **before** the graph delete, matching the
conventions `cascade_cue` and `tombstone_undo_targets` already follow (both
tombstone before their graph delete — which is why the undo path never had this
bug). The bridging slur now sees X already dead during the cue cascade and
cascades once. Locked by
`deleting_a_cue_source_does_not_leave_a_contradictory_repair_for_a_bridging_slur_p13_d2`.
**Noted, not implemented:** no writer path derives a chunk schema *minor*
from appended kind discriminants (a major-0 block carrying discriminant 29
stamps the same fixed minor as always) — pre-existing for the Phase-3

View File

@ -2897,8 +2897,15 @@ impl<'a> Reducer<'a> {
.and_then(|score| score.events.get(op.event))
.map(Event::voice)
});
let graph_repairs = self.materialize_graph_delete(env, op);
// Tombstone the event in `objects` BEFORE the graph delete (P13-D2).
// `materialize_graph_delete` re-anchors the deleted event's referents,
// and a cue among them cascades — running `reanchor_for_tombstone` over
// the cue's own referents. A structure anchored on {this event,
// cue-of-this-event} must see this event as already dead there, or it
// would re-anchor onto it (recording `Reanchored{to: X}`) only to
// cascade when X's tombstone lands a line later — a contradictory
// same-effect trail. Tombstoning first matches `cascade_cue` and the
// undo path, which already tombstone before their graph delete.
let minter = self.minted_by.get(&ev_obj).copied().unwrap_or(env.id);
self.objects.insert(
ev_obj,
@ -2907,6 +2914,7 @@ impl<'a> Reducer<'a> {
minted_by: minter,
},
);
let graph_repairs = self.materialize_graph_delete(env, op);
for events in self.voice_occupancy.values_mut() {
events.retain(|(_, _, event)| *event != op.event);
}
@ -9705,6 +9713,104 @@ mod tests {
);
}
#[test]
fn deleting_a_cue_source_does_not_leave_a_contradictory_repair_for_a_bridging_slur_p13_d2() {
// P13-D2: a slur Z anchored on {X, cue-of-X}. Deleting X cascades the
// cue (a cue depends on its source event). The cue-cascade's ledger
// re-anchor must NOT re-anchor Z onto X — X is itself being deleted —
// which would record `Reanchored{to: X}` for Z and then `CascadeDeleted`
// in the same effect (a contradictory trail). With X tombstoned before
// the graph delete, Z sees no live survivor and cascades once.
use epiphany_core::generators::valid_score;
use epiphany_core::{
check_invariants, CueEvent, CueRendering, Event, EventDuration, EventPosition,
MusicalDuration, MusicalPosition, RationalTime, SlurId,
};
let mut base = valid_score(0x5EED);
let (voice_id, x, count) = {
let v = &base.canvas.regions[0].staff_instances()[0].voices[0];
(v.id, v.events[0], v.events.len() as i64)
};
let cue = EventId::new(ReplicaId(9), 9_001);
let slur_id = SlurId::new(ReplicaId(9), 9_002);
let sid = TypedObjectId::Slur(slur_id);
// A cue sourced on X, placed just past the voice's last event, and a
// slur bridging X and its cue.
base.events
.insert(Event::Cue(CueEvent {
id: cue,
voice: voice_id,
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(count, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
source: vec![x],
rendering: CueRendering,
}))
.expect("fresh cue id");
base.canvas.regions[0]
.content
.staff_instances_mut()
.expect("staff-based content")[0]
.voices[0]
.events
.push(cue);
base.cross_cutting
.slurs
.push(crate::valuegen::slur(slur_id, x, cue));
assert!(
check_invariants(&base).is_empty(),
"the cue + bridging-slur base is well-formed"
);
let del = prim_env(
2,
0,
20,
CausalContext::new(),
OperationKind::DeleteEvent(DeleteEventOp {
event: x,
tuplet_compensation: TupletCompensation::NotInTuplet,
}),
);
let mut set = OperationSet::new();
set.accept_all(vec![del.clone()]);
let result = set.reduce_onto(&base);
// The bridging slur cascaded (both its anchors — X and cue-of-X — died).
assert!(
matches!(
result.state.objects.get(&sid),
Some(ObjectState::Tombstoned { .. })
),
"the bridging slur cascades"
);
// …recorded exactly ONCE as a cascade, never a contradictory
// `Reanchored{to: X}` onto the event being deleted.
let repairs = match result
.state
.effects
.iter()
.find(|(id, _)| *id == del.id)
.map(|(_, e)| e)
{
Some(OperationEffect::AppliedWithRepair { repairs }) => repairs.clone(),
other => panic!("expected AppliedWithRepair, got {other:?}"),
};
let slur_repairs: Vec<_> = repairs.iter().filter(|r| r.target == sid).collect();
assert_eq!(
slur_repairs.len(),
1,
"the bridging slur is repaired exactly once: {slur_repairs:?}"
);
assert!(
matches!(slur_repairs[0].kind, RepairKind::CascadeDeleted),
"the one record is a cascade, not a contradictory reanchor: {:?}",
slur_repairs[0].kind
);
assert!(check_invariants(&result.score).is_empty());
}
#[test]
fn a_rank_four_reanchor_records_same_canvas_nearer_p12_c4() {
// Pass 12 (P12-C4): the rank-4 (same-canvas) proximity survivor has

View File

@ -5,9 +5,15 @@ per the house rule (a batch pass opens at ≥3 candidates; this file opened
when P13-D1/D2 joined P13-K1). Each entry names the owning DECISIONS record;
this file is the index, not the analysis.
**Batch CLOSED (2026-07-08).** All four candidates are resolved (worked down in
order): P13-D3 and P13-K1 by the user's ratified calls ("fix the mint only" /
"reject the introduction"), P13-D1 and P13-D2 as correctness fixes with
convergence-locked / execute-then-fix regressions. No open Pass-13 candidates
remain; a future ≥3-candidate batch reopens the pass.
| Id | One-line statement | Filed in | Status |
|---|---|---|---|
| P13-K1 | The K3 verdict for a system pitch introduced by a ModifyEvent replacement value differs across a snapshot cut (in-session `TargetMissing` vs post-snapshot `SystemDerivedContentImmutable`); really "may ModifyEvent introduce never-minted pitch ids" | `crates/epiphany-ops/DECISIONS.md` (Pass-12 G-pass code tranche) | **resolved** (Pass 13: reject the introduction — modify_event refuses a never-minted system-derived pitch, verdict now snapshot-cut-invariant; user "reject the introduction") |
| P13-D1 | Undo-driven event tombstones run graph-side re-anchor/cascade but never ledger-side `reanchor_for_tombstone`: structures leave the graph while staying `Live`, no `RepairRecord` — Ch6's same-step recording MUST is unmet for undo-driven tombstones (pre-existing class: slurs/spanners; repeats now too) | `crates/epiphany-ops/DECISIONS.md` (Schema major 2, Phase D) | **resolved** (Pass 13: `tombstone_undo_targets` runs the ledger re-anchor per event target; liveness guard; convergence-locked) |
| P13-D2 | Cue-cascade recursion re-anchors against the triggering event before its tombstone lands in `objects`: a structure anchored on {X, cue-of-X} can record `Reanchored{to: X}` then `CascadeDeleted` in one effect (contradictory repair trail; plausible by code trace, unexecuted) | `crates/epiphany-ops/DECISIONS.md` (Schema major 2, Phase D) | open |
| P13-D2 | Cue-cascade recursion re-anchors against the triggering event before its tombstone lands in `objects`: a structure anchored on {X, cue-of-X} can record `Reanchored{to: X}` then `CascadeDeleted` in one effect (contradictory repair trail; plausible by code trace, unexecuted) | `crates/epiphany-ops/DECISIONS.md` (Schema major 2, Phase D) | **resolved** (Pass 13: `delete_event` tombstones before the graph delete, matching `cascade_cue`/undo; repro executed then fixed) |
| P13-D3 | `CreateCrossCutting` validates only event endpoints (`CrossCuttingValue::endpoints()`), so a SPANNER anchored to a missing region/measure mints dangling past `anchor_target_exists`; and non-event referent tombstones (`DeleteRegion` under a region-anchored spanner/repeat) re-anchor nothing — "every referenced endpoint is live" is events-only as implemented | `crates/epiphany-ops/DECISIONS.md` (Phase D follow-up) | **resolved** (Pass 13: mint fixed via `anchor_object_refs`; non-event referent re-anchoring ratified events-only, user "fix the mint only") |