From cd998142f563800ffdd6e382c98b4136a3a70d90 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 18:01:08 -0400 Subject: [PATCH] =?UTF-8?q?Pass=2013=20=E2=80=94=20P13-D3:=20reject=20span?= =?UTF-8?q?ners=20anchored=20to=20a=20missing=20region/measure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A spanner anchored to a missing region/measure minted dangling: the CreateCrossCutting mint checked only CrossCuttingValue::endpoints() (events), so a region/measure TimeAnchor with no live target slipped past — the exact sibling of the repeat mint gap fixed in Phase D. Per the user's call ("fix the mint only"): CrossCuttingValue::anchor_object_refs() returns the full anchor object set (events + a spanner's measure/region anchors; wall-clock references nothing), and create_cross_cutting's liveness precondition now checks it, so such a spanner is refused (TargetMissing) rather than minted dangling. Deterministic across both reduction modes (the base seed registers regions/measures in objects). endpoints() stays event-only — it feeds the re-anchoring referent index, and non-event referent re-anchoring stays deferred, ratified events-only (the spanner discipline). Regression: create_cross_cutting_spanner_preconditions_region_measure_anchors (missing region → refused; live measure/region → mints, invariant-clean). 937 tests, convergence/equivocation/conformance green. PASS13_CANDIDATES.md: P13-D3 resolved. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-ops/DECISIONS.md | 18 ++++- crates/epiphany-ops/src/payload.rs | 26 ++++++++ crates/epiphany-ops/src/reduce.rs | 103 ++++++++++++++++++++++++++++- spec/PASS13_CANDIDATES.md | 2 +- 4 files changed, 143 insertions(+), 6 deletions(-) diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index 8044a20..0832e83 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -1102,6 +1102,18 @@ SPANNERS — `CrossCuttingValue::endpoints()` filters to events, so a spanner anchored to a missing region/measure mints dangling past the invariant (`anchor_target_exists` checks spanner anchors at all three kinds), and non-event referent tombstones (a `DeleteRegion` under a region-anchored -spanner or repeat) re-anchor nothing. Pre-existing, ratified-as-implemented -("every referenced endpoint is live" reads events-only in the code); -changing it is a catalog-semantics decision, not a Phase-D fix. +spanner or repeat) re-anchor nothing. + +**P13-D3 resolved (Pass 13, 2026-07-08; user: "fix the mint only").** The MINT +is fixed: `CrossCuttingValue::anchor_object_refs()` returns the full anchor +object set (events + a spanner's measure/region anchors; wall-clock references +nothing), and `create_cross_cutting`'s liveness precondition now checks it — +so a spanner anchored to a missing region/measure is refused +(`TargetMissing`) rather than minted dangling, exactly as the repeat mint's +`anchor_object_refs` fix does. Deterministic across both reduction modes (the +base seed registers regions and measures in `objects`). `endpoints()` stays +event-only — it feeds the re-anchoring referent index, and **non-event +referent re-anchoring stays deferred, ratified events-only** (the spanner +discipline; extending the referent index to region/measure tombstones is the +larger change the user parked). Regression: +`create_cross_cutting_spanner_preconditions_region_measure_anchors`. diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index 5b9a5d7..f2d35f9 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -732,6 +732,32 @@ impl CrossCuttingValue { .collect(), } } + + /// Every object this structure's anchors reference — its endpoint events, + /// and (for a spanner) the measures and regions its [`TimeAnchor`]s name. + /// The mint precondition checks all of these are live, so a spanner + /// anchored to a missing region or measure is rejected rather than minted + /// dangling (P13-D3). Distinct from [`Self::endpoints`], which stays + /// event-only: it feeds the re-anchoring referent index, which repairs + /// event tombstones only (the spanner discipline — non-event referent + /// re-anchoring stays deferred, ratified events-only). + pub fn anchor_object_refs(&self) -> Vec { + match self { + // Event-anchored kinds reference exactly their endpoint events. + CrossCuttingValue::Tie(_) | CrossCuttingValue::Slur(_) | CrossCuttingValue::Beam(_) => { + self.endpoints() + } + CrossCuttingValue::Spanner(s) => [&s.start, &s.end] + .into_iter() + .filter_map(|anchor| match anchor { + TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)), + TimeAnchor::Measure { id, .. } => Some(TypedObjectId::Measure(*id)), + TimeAnchor::Region { id, .. } => Some(TypedObjectId::Region(*id)), + TimeAnchor::WallClock { .. } => None, + }) + .collect(), + } + } } impl CanonicalEncode for CrossCuttingValue { diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 5c78d9b..c2903cc 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -3124,8 +3124,13 @@ impl<'a> Reducer<'a> { } None => {} } - // Endpoints must exist (live). - for e in &endpoints { + // Every anchored object must exist (live) — events for the + // event-anchored kinds, and a spanner's region/measure anchor targets + // too, so a spanner anchored to a missing region/measure is rejected + // rather than minted dangling (P13-D3). Deterministic across both + // reduction modes: the base seed registers regions and measures in + // `objects` (as the repeat mint precondition relies on). + for e in &op.structure.anchor_object_refs() { if !matches!(self.objects.get(e), Some(ObjectState::Live)) { return OperationEffect::NoOp { reason: NoOpReason::PreconditionFailedUnderReduction { @@ -8224,6 +8229,100 @@ mod tests { ); } + #[test] + fn create_cross_cutting_spanner_preconditions_region_measure_anchors() { + // P13-D3 (fix the mint): a spanner anchored to a missing region or + // measure is refused (the mint checks `anchor_object_refs`, not just the + // event endpoints `endpoints()` returns), and a spanner anchored to a + // live region/measure mints. Non-event referent re-anchoring stays + // events-only (ratified — the spanner discipline). + use epiphany_core::generators::valid_score_rich; + use epiphany_core::{MeasurePosition, SpannerId}; + + let base = valid_score_rich(0x0D0E); + let (region_id, measure_id) = base + .canvas + .regions + .iter() + .find_map(|r| { + r.staff_instances() + .iter() + .find_map(|si| si.measures.first().map(|m| (r.id, m.id))) + }) + .expect("the rich fixture has a measure"); + let spanner = |id, start, end| { + OperationKind::CreateCrossCutting(CreateCrossCuttingOp { + structure: CrossCuttingValue::Spanner(epiphany_core::Spanner { + id, + start, + end, + staves: Vec::new(), + kind: Default::default(), + style: Default::default(), + }), + }) + }; + let region_anchor = |id| TimeAnchor::Region { + id, + edge: RegionEdge::End, + offset: AnchorOffset::Zero, + }; + + // Start names a missing region: refused, not minted dangling. + let dangling = SpannerId::new(ReplicaId(9), 810); + let mut set = OperationSet::new(); + set.accept_all(vec![prim_env( + 9, + 0, + 10, + CausalContext::new(), + spanner( + dangling, + TimeAnchor::Region { + id: RegionId::new(ReplicaId(9), 7_777), + edge: RegionEdge::Start, + offset: AnchorOffset::Zero, + }, + region_anchor(region_id), + ), + )]); + assert!( + !set.reduce_onto(&base) + .state + .objects + .contains_key(&TypedObjectId::Spanner(dangling)), + "a spanner anchored to a missing region is refused, not minted dangling" + ); + + // Live measure + live region anchors: mints, invariant-clean. + let live = SpannerId::new(ReplicaId(9), 811); + let mut set = OperationSet::new(); + set.accept_all(vec![prim_env( + 9, + 0, + 10, + CausalContext::new(), + spanner( + live, + TimeAnchor::Measure { + id: measure_id, + position: MeasurePosition::Start, + offset: AnchorOffset::Zero, + }, + region_anchor(region_id), + ), + )]); + let result = set.reduce_onto(&base); + assert!( + matches!( + result.state.objects.get(&TypedObjectId::Spanner(live)), + Some(ObjectState::Live) + ), + "live region/measure anchors mint the spanner" + ); + assert!(epiphany_core::check_invariants(&result.score).is_empty()); + } + #[test] fn deleting_an_event_rewires_a_dal_segno_jump_target() { // The kind's jump targets are anchor SITES like any other: a dead diff --git a/spec/PASS13_CANDIDATES.md b/spec/PASS13_CANDIDATES.md index 70d8541..5f5cf59 100644 --- a/spec/PASS13_CANDIDATES.md +++ b/spec/PASS13_CANDIDATES.md @@ -10,4 +10,4 @@ this file is the index, not the analysis. | 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) | open | | 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) | 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) | open | -| 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) | open | +| 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") |