From 430214d6198cd860deee9bf998e5dc1108b62c85 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 18:25:37 -0400 Subject: [PATCH] =?UTF-8?q?Pass=2013=20=E2=80=94=20P13-D2:=20tombstone=20b?= =?UTF-8?q?efore=20the=20graph=20delete,=20closing=20the=20cue-cascade=20t?= =?UTF-8?q?rail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- crates/epiphany-ops/DECISIONS.md | 15 ++++ crates/epiphany-ops/src/reduce.rs | 110 +++++++++++++++++++++++++++++- spec/PASS13_CANDIDATES.md | 8 ++- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index 87fc436..4ef8b44 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -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 diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index b33e72f..00f57f4 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -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 diff --git a/spec/PASS13_CANDIDATES.md b/spec/PASS13_CANDIDATES.md index 65fad17..03a573c 100644 --- a/spec/PASS13_CANDIDATES.md +++ b/spec/PASS13_CANDIDATES.md @@ -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") |