diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index e32f174..550ba0b 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -490,3 +490,23 @@ for zero coverage the codec tests don't already provide; ops-level coverage arrives when Phase D's valuegen builders emit v2 values. `Score::empty` keeps its signature (Timestamp(0) is the ratified unset convention; a creation-time builder waits for a producer, e.g. import). + +## Schema major 2, Phase D — repeat coverage lands; the anchor-site walk gets one home + +The Phase-B promise above ("ops-level coverage arrives when Phase D's +valuegen builders emit v2 values") is discharged: `epiphany-ops::valuegen` +gained `event_anchor`/`repeat_structure`/`volta_repeat`, the op generators +emit the pair, and the decode fuzzer's corpus gains +`valid_score_rich_with_repeats` (DalSegno + voltas) — **corpus-local**, not +in the shared `valid_score_rich`: the shared fixture feeds the render +goldens, and repeat rendering is deliberately E1's churn, not D's (the +zero-golden-churn discipline). + +`RepeatStructure::anchor_sites()`/`anchor_sites_mut()` (graph.rs) are now +THE site-set walk (start/end, kind jump targets, volta spans). Review found +the set hand-rolled in five places across three crates — and a sixth, +`indexes.rs`, silently stale since Phase B (it indexed only start/end, +missing every kind/volta anchor, contradicting its own doc). All flat walks +now consume the method (the classified per-site invariant check keeps its +exhaustive match for message attribution); the index gap is +regression-locked in `indexes_build_and_answer_queries`. diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index b3c514c..377e98c 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -3141,6 +3141,9 @@ canonical_value! { TimeSignature, TempoSegment, StaffLineConfiguration, + // Repeat authoring (schema-major-2 revision) — CreateRepeatStructure + // embeds the full value. + RepeatStructure, } #[cfg(test)] diff --git a/crates/epiphany-core/src/fuzz.rs b/crates/epiphany-core/src/fuzz.rs index b71504b..a514d0a 100644 --- a/crates/epiphany-core/src/fuzz.rs +++ b/crates/epiphany-core/src/fuzz.rs @@ -55,6 +55,54 @@ struct Corpus { v1_scores: Vec>, } +/// The rich fixture plus repeat structures carrying non-default v2 content +/// (a DalSegno kind, voltas), so the decode fuzzer exercises the +/// RepeatKind/Volta wire arms through a whole-`Score` form. Corpus-local — +/// the shared render fixtures deliberately stay repeat-free until the E1 +/// rendering tranche (golden-churn discipline). +fn valid_score_rich_with_repeats(seed: u64) -> Score { + use crate::graph::{RepeatKind, RepeatStructure, Volta}; + use crate::ids::RepeatStructureId; + use crate::time::{AnchorOffset, RegionEdge, TimeAnchor}; + let mut score = valid_score_rich(seed); + let region = score.canvas.regions[0].id; + let span = |edge: RegionEdge| TimeAnchor::Region { + id: region, + edge, + offset: AnchorOffset::Zero, + }; + let replica = crate::ids::ReplicaId(0xF0F0); + score.cross_cutting.repeats.push(RepeatStructure { + id: RepeatStructureId::new(replica, 1), + start: span(RegionEdge::Start), + end: span(RegionEdge::End), + kind: RepeatKind::DalSegno { + segno: span(RegionEdge::Start), + end_target: span(RegionEdge::End), + }, + voltas: Vec::new(), + }); + score.cross_cutting.repeats.push(RepeatStructure { + id: RepeatStructureId::new(replica, 2), + start: span(RegionEdge::Start), + end: span(RegionEdge::End), + kind: RepeatKind::Volta, + voltas: vec![ + Volta { + endings: vec![1], + start: span(RegionEdge::Start), + end: span(RegionEdge::End), + }, + Volta { + endings: vec![2, 3], + start: span(RegionEdge::Start), + end: span(RegionEdge::End), + }, + ], + }); + score +} + fn build_corpus(rng: &mut SplitMix64) -> Corpus { let mut scores = Vec::new(); let mut regions = Vec::new(); @@ -62,10 +110,10 @@ fn build_corpus(rng: &mut SplitMix64) -> Corpus { let mut v1_scores = Vec::new(); for i in 0..12u64 { let seed = rng.next_u64(); - let score = if i % 2 == 0 { - valid_score(seed | 1) - } else { - valid_score_rich(seed) + let score = match i % 3 { + 0 => valid_score(seed | 1), + 1 => valid_score_rich(seed), + _ => valid_score_rich_with_repeats(seed), }; if let Some(region) = score.canvas.regions.first() { regions.push(region.canonical_bytes()); diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index 1433330..8176495 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -1228,6 +1228,54 @@ pub struct RepeatStructure { pub voltas: Vec, } +impl RepeatStructure { + /// Every [`TimeAnchor`] site this structure carries: `start`/`end`, the + /// kind's jump targets (`DaCapo.end_target`, + /// `DalSegno.segno`/`end_target`), and each volta's span. + /// + /// THE single site-set walk: reduction's re-anchoring (rule table row + /// "Repeat structure / Anchor"), the editor's barrier seam, the + /// invariant anchor walk, and the cross-reference index all consume + /// this, so the site set cannot drift between them when a future + /// revision adds an anchor-bearing field (an exhaustive `RepeatKind` + /// match protects only against new *variants*). + pub fn anchor_sites(&self) -> Vec<&TimeAnchor> { + let mut sites = vec![&self.start, &self.end]; + match &self.kind { + RepeatKind::SimpleRepeat { .. } | RepeatKind::Volta => {} + RepeatKind::DaCapo { end_target } => sites.push(end_target), + RepeatKind::DalSegno { segno, end_target } => { + sites.push(segno); + sites.push(end_target); + } + } + for volta in &self.voltas { + sites.push(&volta.start); + sites.push(&volta.end); + } + sites + } + + /// The mutable sibling of [`RepeatStructure::anchor_sites`], for + /// re-anchoring rewrites. + pub fn anchor_sites_mut(&mut self) -> Vec<&mut TimeAnchor> { + let mut sites: Vec<&mut TimeAnchor> = vec![&mut self.start, &mut self.end]; + match &mut self.kind { + RepeatKind::SimpleRepeat { .. } | RepeatKind::Volta => {} + RepeatKind::DaCapo { end_target } => sites.push(end_target), + RepeatKind::DalSegno { segno, end_target } => { + sites.push(segno); + sites.push(end_target); + } + } + for volta in &mut self.voltas { + sites.push(&mut volta.start); + sites.push(&mut volta.end); + } + sites + } +} + /// An analytical annotation (Roman numeral, form label, …) (Chapter 5 /// §"Analytical Annotations"). #[derive(Clone, PartialEq, Eq, Debug)] diff --git a/crates/epiphany-core/src/indexes.rs b/crates/epiphany-core/src/indexes.rs index ce65fc4..95968a8 100644 --- a/crates/epiphany-core/src/indexes.rs +++ b/crates/epiphany-core/src/indexes.rs @@ -152,11 +152,12 @@ impl ScoreIndexes { } } for rp in &cc.repeats { + // The shared site-set walk — this index had gone stale when + // schema major 2 added the kind's jump targets and volta spans + // (it silently indexed only start/end, contradicting the + // module's every-referenced-object claim). let who = TypedObjectId::RepeatStructure(rp.id); - for o in [anchor_object(&rp.start), anchor_object(&rp.end)] - .into_iter() - .flatten() - { + for o in rp.anchor_sites().into_iter().filter_map(anchor_object) { add(o, who); } } @@ -377,6 +378,59 @@ mod tests { .iter() .any(|o| matches!(o, TypedObjectId::Spanner(_)))); + // Cross-cutting index, repeat anchor sites: a DalSegno's jump + // targets and a volta's span are indexed, not just start/end — the + // walk had gone stale at schema major 2 (start/end only), silently + // missing every kind/volta anchor. Regression-locked via a repeat + // whose ONLY reference to a distinct event is a segno target. + { + let mut s2 = s.clone(); + let (e_first, e_second) = { + let (_, _, v) = s2.voices().next().expect("a voice"); + (v.events[0], v.events[1]) + }; + let region_b = s2.canvas.regions[0].id; + let span = |edge: crate::time::RegionEdge| crate::time::TimeAnchor::Region { + id: region_b, + edge, + offset: crate::time::AnchorOffset::Zero, + }; + let rid = crate::ids::RepeatStructureId::new(crate::ids::ReplicaId(0xABCD), 1); + s2.cross_cutting + .repeats + .push(crate::graph::RepeatStructure { + id: rid, + start: span(crate::time::RegionEdge::Start), + end: span(crate::time::RegionEdge::End), + kind: crate::graph::RepeatKind::DalSegno { + segno: crate::time::TimeAnchor::Event { + id: e_first, + offset: crate::time::AnchorOffset::Zero, + }, + end_target: span(crate::time::RegionEdge::End), + }, + voltas: vec![crate::graph::Volta { + endings: vec![1], + start: crate::time::TimeAnchor::Event { + id: e_second, + offset: crate::time::AnchorOffset::Zero, + }, + end: span(crate::time::RegionEdge::End), + }], + }); + let idx2 = ScoreIndexes::build(&s2); + assert!( + idx2.cross_cutting_referencing(TypedObjectId::Event(e_first)) + .contains(&TypedObjectId::RepeatStructure(rid)), + "a DalSegno segno target is an indexed reference" + ); + assert!( + idx2.cross_cutting_referencing(TypedObjectId::Event(e_second)) + .contains(&TypedObjectId::RepeatStructure(rid)), + "a volta span anchor is an indexed reference" + ); + } + // Measure index: the rich score declares measure number 1. assert!(!idx.measures_with_number(1).is_empty()); let mid = idx.measures_with_number(1)[0]; diff --git a/crates/epiphany-core/src/invariants.rs b/crates/epiphany-core/src/invariants.rs index ab4a3e5..de15cbb 100644 --- a/crates/epiphany-core/src/invariants.rs +++ b/crates/epiphany-core/src/invariants.rs @@ -806,22 +806,9 @@ impl<'a> GraphIndex<'a> { anchors.push(&m.anchor); } for rp in &cc.repeats { - anchors.push(&rp.start); - anchors.push(&rp.end); - // Schema major 2: the kind and volta anchors are anchors too. - match &rp.kind { - crate::graph::RepeatKind::DaCapo { end_target } => anchors.push(end_target), - crate::graph::RepeatKind::DalSegno { segno, end_target } => { - anchors.push(segno); - anchors.push(end_target); - } - crate::graph::RepeatKind::SimpleRepeat { .. } | crate::graph::RepeatKind::Volta => { - } - } - for v in &rp.voltas { - anchors.push(&v.start); - anchors.push(&v.end); - } + // The shared site-set walk (start/end, kind jump targets, volta + // spans) — the same set reduction and the index consume. + anchors.extend(rp.anchor_sites()); } for cs in &cc.chord_symbols { anchors.push(&cs.anchor); diff --git a/crates/epiphany-editor-core/src/barriers.rs b/crates/epiphany-editor-core/src/barriers.rs index 673725d..3968863 100644 --- a/crates/epiphany-editor-core/src/barriers.rs +++ b/crates/epiphany-editor-core/src/barriers.rs @@ -215,6 +215,21 @@ fn structure_context(score: &Score, endpoints: &[TypedObjectId]) -> EditContext ctx(location.map(|(r, _)| r), location.map(|(_, si)| si)) } +/// The event references among a repeat structure's anchor sites +/// ([`epiphany_core::RepeatStructure::anchor_sites`] — start/end, jump +/// targets, volta spans; the same site set reduction's re-anchoring rule +/// covers). +fn repeat_event_refs(repeat: &epiphany_core::RepeatStructure) -> Vec { + repeat + .anchor_sites() + .into_iter() + .filter_map(|anchor| match anchor { + epiphany_core::TimeAnchor::Event { id, .. } => Some(TypedObjectId::Event(*id)), + _ => None, + }) + .collect() +} + /// The endpoints of a cross-cutting structure already in the graph, by id. fn graph_structure_endpoints(score: &Score, structure: &TypedObjectId) -> Vec { let events = |ids: Vec| ids.into_iter().map(TypedObjectId::Event).collect(); @@ -257,6 +272,13 @@ fn graph_structure_endpoints(score: &Score, structure: &TypedObjectId) -> Vec score + .cross_cutting + .repeats + .iter() + .find(|r| r.id == *id) + .map(repeat_event_refs) + .unwrap_or_default(), _ => Vec::new(), } } @@ -410,6 +432,17 @@ pub(crate) fn subjects_of(kind: &OperationKind, score: &Score) -> BarrierSubject OperationKind::SetMetadata(_) | OperationKind::DeclareTransaction(_) => { BarrierSubjects::ScoreWide } + OperationKind::CreateRepeatStructure(op) => one( + TypedObjectId::RepeatStructure(op.repeat_structure_id()), + structure_context(score, &repeat_event_refs(&op.repeat)), + ), + OperationKind::DeleteRepeatStructure(op) => { + let sid = TypedObjectId::RepeatStructure(op.repeat); + one( + sid, + structure_context(score, &graph_structure_endpoints(score, &sid)), + ) + } OperationKind::Registered(..) => BarrierSubjects::Unknown, } } diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index 493d276..c0aa089 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -5776,6 +5776,71 @@ mod tests { assert!(!materialized.visible); } + #[test] + fn repeat_authoring_kinds_derive_subjects_and_gate_on_barriers() { + // The repeat pair (schema-major-2 revision) participates in the + // barrier gate: a whole-score barrier naming each tag refuses the + // matching edit before minting, and with no barriers active the + // create/delete pair applies end to end (subjects derive from the + // repeat's anchor sites through `repeat_event_refs`). + let mut session = open_plain(11); + let events: Vec = session + .score() + .voices() + .flat_map(|(_, _, v)| v.events.clone()) + .collect(); + let (e0, e1) = (events[0], events[1]); + let rid = epiphany_core::RepeatStructureId::new(epiphany_core::ReplicaId(0xED), 1); + let create = OperationKind::CreateRepeatStructure(epiphany_ops::CreateRepeatStructureOp { + repeat: epiphany_ops::valuegen::repeat_structure(rid, e0, e1), + }); + let delete = OperationKind::DeleteRepeatStructure(epiphany_ops::DeleteRepeatStructureOp { + repeat: rid, + }); + + for (tag, kind) in [ + (OperationKindTag::CreateRepeatStructure, create.clone()), + (OperationKindTag::DeleteRepeatStructure, delete.clone()), + ] { + session.set_active_extensions(vec![extension_prohibiting(0xE8, tag)]); + assert_eq!( + session.apply(kind), + Err(EditorError::BarrierProhibited { + extension: ExtensionRef(0xE8), + operation: tag, + }), + "a whole-score barrier naming {tag:?} must refuse the edit" + ); + } + assert!(session.applied_operations().is_empty()); + + session.set_active_extensions(vec![]); + session + .apply(create) + .expect("an unbarred repeat create applies"); + assert!( + session + .score() + .cross_cutting + .repeats + .iter() + .any(|r| r.id == rid), + "the mint materializes" + ); + session + .apply(delete) + .expect("an unbarred repeat delete applies"); + assert!( + !session + .score() + .cross_cutting + .repeats + .iter() + .any(|r| r.id == rid), + "the tombstone removes it" + ); + } + #[test] fn an_unsafe_edit_crosses_the_barrier_and_records_the_tombstone_obligation() { let mut session = open_plain(7); diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index cb3d4b1..4b825b1 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -127,6 +127,8 @@ fn payload_label(payload: &OperationPayload) -> &'static str { OperationKind::SetTimeSignature(_) => "SetTimeSignature", OperationKind::SetTempoSegment(_) => "SetTempoSegment", OperationKind::SetStaffLayout(_) => "SetStaffLayout", + OperationKind::CreateRepeatStructure(_) => "CreateRepeatStructure", + OperationKind::DeleteRepeatStructure(_) => "DeleteRepeatStructure", _ => "primitive", }, OperationPayload::ResolveConflict(_) => "ResolveConflict", diff --git a/crates/epiphany-layout-ir/src/barrier.rs b/crates/epiphany-layout-ir/src/barrier.rs index 64bf3f3..b59060b 100644 --- a/crates/epiphany-layout-ir/src/barrier.rs +++ b/crates/epiphany-layout-ir/src/barrier.rs @@ -1100,17 +1100,18 @@ mod tests { tag: 7 }) ); - // Operation-kind tag 28 is one past the v1 vocabulary (the Phase-3 - // ops tranche appended 24..=27; encodings are append-only). + // Operation-kind tag 30 is one past the vocabulary (the Phase-3 ops + // tranche appended 24..=27, the repeat pair 28/29; encodings are + // append-only). let mut bytes = vec![0u8]; bytes.extend(set_blob(&[])); - bytes.extend(set_blob(&[vec![28u8]])); + bytes.extend(set_blob(&[vec![30u8]])); bytes.push(0); assert_eq!( EditBarrier::decode_canonical_bytes(&bytes), Err(BarrierDecodeError::InvalidTag { kind: "OperationKindTag", - tag: 28 + tag: 30 }) ); } diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index ca4b7c0..8d07e48 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -1007,3 +1007,74 @@ ops-decoder fuzzer, MusicXML round-trip tooling), it MUST bring the op-payload migrate-on-read primitive with it — per-type frozen v1 payload decoders keyed by the block's stamped major. Tracked as the standing Phase-C remainder in the Push-2 plan. + +## Schema major 2, Phase D — the repeat-authoring pair (code tranche) + +**Stamping.** The minimal-stamping per-payload table (see "Schema major 2: +minimal stamping" above) gains the pair: `CreateRepeatStructure` ⇒ always +**2** — born at v2; the carried `RepeatStructure`'s `kind`/`voltas` are +unconditional fields, so even the migration-default value has no +lower-major layout — and `DeleteRepeatStructure` ⇒ **0** (a bare +identifier is a major-0 layout; the kind discriminant itself is a +schema-minor vocabulary append, the Phase-3 mechanism — the stamp is +always minimal stamping over the payload, never a property of the append). + +**Set-union without value comparison.** A repeat create of a live id reads +`AlreadyApplied` — the cross-cutting discipline. `RecreateContentMismatch` +stays scoped to CreateStaff + the carried TimeSignature (the two sites +that retain the carried value for comparison); extending it here was +considered and declined: repeats mirror `create_cross_cutting`, whose +family the catalog places them beside. + +**The all-anchors-live precondition** covers every event-referencing +anchor site — start/end, DaCapo/DalSegno jump targets, volta spans — via +`RepeatStructure::anchor_sites()` (core), THE single site-set walk. +Reduction (ledger + graph), the editor barrier seam, the invariant anchor +walk, and core's cross-reference index all consume it, after review found +five hand-rolled copies plus a SIXTH, stale one: `indexes.rs` had never +learned the Phase-B kind/volta anchors (start/end only, contradicting its +own every-referenced-object doc). Fixed with a regression test. Plain +field walks get no exhaustive-match protection; the shared method is the +guard. + +**Survivor selection.** "Nearest surviving anchor" is realized as the +deterministic identifier-order minimum over the structure's surviving +endpoints — for two-endpoint slurs/spanners that was the vacuous +tie-break; for multi-site repeats it is load-bearing, so the rule-table +row and the catalog now say so explicitly, with proximity-aware (four-key) +selection deferred exactly as the spanner row defers per-kind proximity +bounds. + +**Spanner ghost fix (pre-existing).** `materialize_graph_tombstones` never +removed an undone `Spanner` mint from the graph (Slur/Tie/Beam were +handled); the walk gains the Spanner and RepeatStructure arms, +regression-locked in +`undoing_a_repeat_or_spanner_create_removes_it_from_the_graph`. + +**Canonical-base pin, honestly.** The 200-envelope blake3 was re-pinned +(the gen_payload modulus shift moved the whole seeded stream); review +found the seeded corpus's repeat creates all no-op, so that pin cannot +detect a repeat-value leak into the base. The dedicated +`the_canonical_base_embeds_no_repeat_values` test covers the property +surgically: two reductions differing only in the created repeat's v2 +content must produce byte-identical bases. + +**Pass-13 candidates filed (batch now open, see spec/PASS13_CANDIDATES.md):** +P13-D1 — undo-driven event tombstones run the graph-side re-anchor/cascade +(`materialize_graph_tombstones` → `materialize_graph_delete`) but never the +ledger-side `reanchor_for_tombstone`: a structure whose anchors are all +undo-tombstoned leaves the graph while staying `Live` in `objects`, with no +`RepairRecord` — a pre-existing class (slurs/spanners identically) that the +repeat row now also exhibits; contradicts Ch6's same-step RepairRecord MUST +under an undo-driven tombstone. P13-D2 — the cue-cascade recursion re-anchors +against the triggering event before that event's own tombstone lands in +`objects`, so a structure anchored on {X, cue-of-X} can record +`Reanchored{to: X}` and `CascadeDeleted` in one effect (plausible by code +trace, unexecuted). Neither is repeat-specific; neither is fixed here. + +**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 +kinds, now also true of the delete; a reader hitting the unknown +discriminant cannot attribute the failure to version skew from the stamp +alone. A bundle-writer design item for the next bundle tranche. diff --git a/crates/epiphany-ops/src/fuzz.rs b/crates/epiphany-ops/src/fuzz.rs index 62fc42b..538377e 100644 --- a/crates/epiphany-ops/src/fuzz.rs +++ b/crates/epiphany-ops/src/fuzz.rs @@ -22,7 +22,7 @@ use epiphany_core::{ EventId, MusicalDuration, MusicalPosition, OperationId, PitchId, RationalTime, RegionId, - ReplicaId, SlurId, StaffId, StaffInstanceId, TypedObjectId, VoiceId, + RepeatStructureId, ReplicaId, SlurId, StaffId, StaffInstanceId, TypedObjectId, VoiceId, }; use epiphany_determinism::fuzz::SplitMix64; @@ -30,12 +30,13 @@ use crate::causal::CausalContext; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, CreateStaffOp, CreateVoiceOp, - CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, DeleteIdentifiedPitchOp, - DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, - ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, - RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, - SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransposeOp, TupletCompensation, + CreateCrossCuttingOp, CreateRegionOp, CreateRepeatStructureOp, CreateStaffInstanceOp, + CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, + DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteRepeatStructureOp, DeleteStaffInstanceOp, + DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, + ModifyIdentifiedPitchOp, OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp, + SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, + SetUserSystemBreakOp, TransposeOp, TupletCompensation, }; use crate::stamp::{HybridLogicalClock, OperationStamp}; use crate::support::AuthorId; @@ -85,7 +86,7 @@ fn pitch(n: u64) -> PitchId { /// Generates a random payload over the shared id space. fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { - let kind = match rng.below(25) { + let kind = match rng.below(27) { 0 => { let voice = VoiceId::new(ReplicaId(7), rng.below(3)); let position = MusicalPosition(RationalTime::from_int(rng.below(4) as i32)); @@ -247,6 +248,26 @@ fn gen_payload(rng: &mut SplitMix64) -> OperationPayload { }), }) } + // Repeat authoring (schema-major-2 revision) over the shared + // event-id space. + 25 => OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { + repeat: if rng.chance(2) { + valuegen::repeat_structure( + RepeatStructureId::new(ReplicaId(7), rng.below(3)), + event(rng.below(ID_SPACE)), + event(rng.below(ID_SPACE)), + ) + } else { + valuegen::volta_repeat( + RepeatStructureId::new(ReplicaId(7), rng.below(3)), + event(rng.below(ID_SPACE)), + event(rng.below(ID_SPACE)), + ) + }, + }), + 26 => OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { + repeat: RepeatStructureId::new(ReplicaId(7), rng.below(3)), + }), _ => OperationKind::SetStaffLayout(SetStaffLayoutOp { staff_instance: StaffInstanceId::new(ReplicaId(7), rng.below(3)), instrument_override: None, diff --git a/crates/epiphany-ops/src/lib.rs b/crates/epiphany-ops/src/lib.rs index 23b341e..0c09d1c 100644 --- a/crates/epiphany-ops/src/lib.rs +++ b/crates/epiphany-ops/src/lib.rs @@ -116,14 +116,15 @@ pub use envelope::{ pub use migrate::{migrate_v0_envelope, project_v1_to_v0, MigrationError}; pub use opset::{AcceptOutcome, OperationSet}; pub use payload::{ - ChangeRegionTimeModelOp, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, - CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, - DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, - InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, - OperationKind, OperationKindTag, OperationPayload, PositionRemapping, ResolveConflictPayload, - ResolveEquivocationPayload, RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, - SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, - TransactionCategory, TransactionDescriptor, TransposeOp, TupletCompensation, + ChangeRegionTimeModelOp, CreateCrossCuttingOp, CreateRegionOp, CreateRepeatStructureOp, + CreateStaffInstanceOp, CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, + DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteRepeatStructureOp, + DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, + ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationKindTag, + OperationPayload, PositionRemapping, ResolveConflictPayload, ResolveEquivocationPayload, + RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, + SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory, + TransactionDescriptor, TransposeOp, TupletCompensation, }; pub use reduce::{ canonical_reduction_order, GraphMaterialization, MaterializedState, ObjectState, PendingReason, diff --git a/crates/epiphany-ops/src/migrate.rs b/crates/epiphany-ops/src/migrate.rs index e80eb6c..c06a964 100644 --- a/crates/epiphany-ops/src/migrate.rs +++ b/crates/epiphany-ops/src/migrate.rs @@ -172,6 +172,11 @@ fn project_kind(kind: &OperationKind) -> V0OperationKind { OperationKind::SetTimeSignature(op) => V0OperationKind::SetTimeSignature(op.clone()), OperationKind::SetTempoSegment(op) => V0OperationKind::SetTempoSegment(op.clone()), OperationKind::SetStaffLayout(op) => V0OperationKind::SetStaffLayout(op.clone()), + // Repeat authoring (schema-major-2 revision): projected verbatim. + OperationKind::CreateRepeatStructure(op) => { + V0OperationKind::CreateRepeatStructure(op.clone()) + } + OperationKind::DeleteRepeatStructure(op) => V0OperationKind::DeleteRepeatStructure(*op), } } @@ -319,6 +324,11 @@ fn migrate_kind(kind: &V0OperationKind, context: &Score) -> Result OperationKind::SetTimeSignature(op.clone()), V0OperationKind::SetTempoSegment(op) => OperationKind::SetTempoSegment(op.clone()), V0OperationKind::SetStaffLayout(op) => OperationKind::SetStaffLayout(op.clone()), + // Repeat authoring (schema-major-2 revision): identity round-trip. + V0OperationKind::CreateRepeatStructure(op) => { + OperationKind::CreateRepeatStructure(op.clone()) + } + V0OperationKind::DeleteRepeatStructure(op) => OperationKind::DeleteRepeatStructure(*op), }) } diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index af72a1d..5b9a5d7 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -34,9 +34,10 @@ use epiphany_core::{ Beam, CanonicalValue, Event, EventDuration, EventId, EventPosition, IdentifiedPitch, InstrumentId, MetricGrid, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, - PitchSpelling, Region, RegionId, RegionTimeModel, Rest, ScoreMetadata, Slur, Spanner, Staff, - StaffId, StaffInstance, StaffInstanceId, StaffLineConfiguration, TempoSegment, Tie, TimeAnchor, - TimeSignature, TransactionId, TupletId, TypedObjectId, Voice, VoiceId, + PitchSpelling, Region, RegionId, RegionTimeModel, RepeatStructure, RepeatStructureId, Rest, + ScoreMetadata, Slur, Spanner, Staff, StaffId, StaffInstance, StaffInstanceId, + StaffLineConfiguration, TempoSegment, Tie, TimeAnchor, TimeSignature, TransactionId, TupletId, + TypedObjectId, Voice, VoiceId, }; use epiphany_determinism::{sorted_canonical, CanonicalDecode, CanonicalEncode, DecodeError}; @@ -182,6 +183,13 @@ pub enum OperationKind { /// Overwrite a staff instance's inline layout advisories as a unit (LWW /// advisory). SetStaffLayout(SetStaffLayoutOp), + // --- Schema-major-2 revision: repeat authoring (operation_catalog + // §"Repeat Structures"). Discriminants extend additively past 27. --- + /// Mint a repeat structure into the cross-cutting registry (set-union + /// creation; every event-referencing anchor site must resolve live). + CreateRepeatStructure(CreateRepeatStructureOp), + /// Tombstone a repeat structure (delete-wins, idempotent on re-delete). + DeleteRepeatStructure(DeleteRepeatStructureOp), } impl OperationKind { @@ -226,6 +234,13 @@ impl OperationKind { 2 } OperationKind::SetStaffLayout(op) if op.staff_lines_override.is_some() => 2, + // Born at v2: the carried RepeatStructure's v2 fields are + // unconditional (`kind`/`voltas` are not `Option`s), so no + // lower-major layout for this payload exists. The DELETE sibling + // carries a bare identifier — a major-0 layout under a minor + // kind append (the Phase-3 precedent) — and stays in the + // catch-all 0 arm below. + OperationKind::CreateRepeatStructure(_) => 2, _ => 0, } } @@ -261,6 +276,9 @@ impl OperationKind { OperationKind::SetTimeSignature(_) => 25, OperationKind::SetTempoSegment(_) => 26, OperationKind::SetStaffLayout(_) => 27, + // Schema-major-2 revision; appended past the Phase-3 24..=27. + OperationKind::CreateRepeatStructure(_) => 28, + OperationKind::DeleteRepeatStructure(_) => 29, } } @@ -298,6 +316,9 @@ impl OperationKind { OperationKind::SetTimeSignature(_) => OperationKindTag::SetTimeSignature, OperationKind::SetTempoSegment(_) => OperationKindTag::SetTempoSegment, OperationKind::SetStaffLayout(_) => OperationKindTag::SetStaffLayout, + // Name-verbatim projection, as the cross-cutting tags do. + OperationKind::CreateRepeatStructure(_) => OperationKindTag::CreateRepeatStructure, + OperationKind::DeleteRepeatStructure(_) => OperationKindTag::DeleteRepeatStructure, } } } @@ -337,6 +358,8 @@ impl CanonicalEncode for OperationKind { OperationKind::SetTimeSignature(op) => op.encode_canonical(out), OperationKind::SetTempoSegment(op) => op.encode_canonical(out), OperationKind::SetStaffLayout(op) => op.encode_canonical(out), + OperationKind::CreateRepeatStructure(op) => op.encode_canonical(out), + OperationKind::DeleteRepeatStructure(op) => op.encode_canonical(out), } } } @@ -377,6 +400,10 @@ pub enum OperationKindTag { SetTimeSignature, SetTempoSegment, SetStaffLayout, + // Schema-major-2 revision (repeat authoring). Name-verbatim, as the + // cross-cutting tags are. + CreateRepeatStructure, + DeleteRepeatStructure, } impl OperationKindTag { @@ -411,6 +438,9 @@ impl OperationKindTag { OperationKindTag::SetTimeSignature => 25, OperationKindTag::SetTempoSegment => 26, OperationKindTag::SetStaffLayout => 27, + // Schema-major-2 revision; appended past the Phase-3 24..=27. + OperationKindTag::CreateRepeatStructure => 28, + OperationKindTag::DeleteRepeatStructure => 29, } } } @@ -480,6 +510,8 @@ impl CanonicalDecode for OperationKindTag { 25 => OperationKindTag::SetTimeSignature, 26 => OperationKindTag::SetTempoSegment, 27 => OperationKindTag::SetStaffLayout, + 28 => OperationKindTag::CreateRepeatStructure, + 29 => OperationKindTag::DeleteRepeatStructure, _ => return Err(DecodeError::MalformedDomainTag), }) } @@ -1388,6 +1420,44 @@ impl CanonicalEncode for SetStaffLayoutOp { } } +/// Mint a repeat structure (operation_catalog §"Repeat Structures"; Chapter 6 +/// re-anchoring rule table "Repeat structure / Anchor"). Carries the full +/// [`RepeatStructure`] value — identity, `start`/`end` anchors, kind, and +/// voltas. The v2 layout is unconditional (`kind`/`voltas` are not `Option`s), +/// so the create is *born at v2*: see [`OperationKind::schema_major`]. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CreateRepeatStructureOp { + pub repeat: RepeatStructure, +} + +impl CreateRepeatStructureOp { + /// The minted structure's identity (read from the carried value). + pub fn repeat_structure_id(&self) -> RepeatStructureId { + self.repeat.id + } +} + +impl CanonicalEncode for CreateRepeatStructureOp { + fn encode_canonical(&self, out: &mut Vec) { + push_lp_bytes(out, &self.repeat.canonical_bytes()); + } +} + +/// Tombstone a repeat structure (operation_catalog §"Repeat Structures"; +/// delete-wins, idempotent on re-delete). The payload is the bare identifier +/// — a major-0 layout under a minor kind append, so the op stamps major 0 +/// (see [`OperationKind::schema_major`]). +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct DeleteRepeatStructureOp { + pub repeat: RepeatStructureId, +} + +impl CanonicalEncode for DeleteRepeatStructureOp { + fn encode_canonical(&self, out: &mut Vec) { + push_canon(out, &self.repeat); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1398,7 +1468,7 @@ mod tests { // GOLDEN LOCK: the discriminant byte leads every canonically-encoded // primitive payload (operation_catalog §"Value-Typed Payloads"), so the // literal values are normative wire facts. Encodings are append-only: - // new kinds append past 27; the values below never change. + // new kinds append past 29; the values below never change. use crate::valuegen; use epiphany_core::{MusicalDuration, MusicalPosition, TimeSignatureId}; @@ -1424,7 +1494,8 @@ mod tests { let slur_value = || CrossCuttingValue::Slur(valuegen::slur(slur_id, event_a, event_b)); let anchor = || valuegen::region_start_anchor(region, MusicalPosition::origin()); - let table: [(OperationKind, u8); 28] = [ + let repeat_id = RepeatStructureId::new(r, 12); + let table: [(OperationKind, u8); 30] = [ ( OperationKind::InsertEvent(InsertEventOp { staff_instance: instance, @@ -1605,6 +1676,16 @@ mod tests { }), 27, ), + ( + OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { + repeat: valuegen::repeat_structure(repeat_id, event_a, event_b), + }), + 28, + ), + ( + OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { repeat: repeat_id }), + 29, + ), ]; for (kind, expected) in &table { assert_eq!( @@ -1763,6 +1844,8 @@ mod tests { OperationKindTag::SetTimeSignature, OperationKindTag::SetTempoSegment, OperationKindTag::SetStaffLayout, + OperationKindTag::CreateRepeatStructure, + OperationKindTag::DeleteRepeatStructure, ]; let encoded: std::collections::BTreeSet<_> = tags .iter() @@ -1775,14 +1858,14 @@ mod tests { fn operation_kind_tag_decode_mirrors_encode_exactly() { // Every non-registered variant round-trips through its 1-byte form, and // the registered variant through its 17-byte (tag + registry id) form. - let mut tags: Vec = (0u8..28) + let mut tags: Vec = (0u8..30) .filter(|d| *d != 16) .map(|d| OperationKindTag::decode_canonical(&[d]).expect("known discriminant")) .collect(); tags.push(OperationKindTag::Registered(OperationKindRegistryId( 0x0102_0304_0506_0708_090A_0B0C_0D0E_0F10, ))); - assert_eq!(tags.len(), 28, "the full v1 tag vocabulary"); + assert_eq!(tags.len(), 30, "the full tag vocabulary"); for tag in tags { let bytes = tag.to_canonical_bytes(); let decoded = OperationKindTag::decode_canonical(&bytes).expect("round-trips"); @@ -1798,10 +1881,10 @@ mod tests { #[test] fn operation_kind_tag_decode_rejects_malformed_bytes() { use epiphany_determinism::DecodeError; - // Unknown discriminant (28 is one past the v1 vocabulary): rejected, + // Unknown discriminant (30 is one past the vocabulary): rejected, // never normalized. assert_eq!( - OperationKindTag::decode_canonical(&[28]), + OperationKindTag::decode_canonical(&[30]), Err(DecodeError::MalformedDomainTag) ); // Empty input. @@ -1822,6 +1905,9 @@ mod tests { (OperationKindTag::SetTimeSignature, 25), (OperationKindTag::SetTempoSegment, 26), (OperationKindTag::SetStaffLayout, 27), + // Schema-major-2 revision (repeat authoring). + (OperationKindTag::CreateRepeatStructure, 28), + (OperationKindTag::DeleteRepeatStructure, 29), ] { assert_eq!(tag.discriminant(), expected); assert_eq!(tag.to_canonical_bytes(), vec![expected]); diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 80c4000..7104e40 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -55,13 +55,13 @@ use crate::encode::{push_canon, push_len, push_lp_bytes, push_u8_bool}; use crate::envelope::OperationEnvelope; use crate::opset::OperationSet; use crate::payload::{ - resolved_anchor_position, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, - CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, - DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, - InsertIdentifiedPitchOp, ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, - OperationKind, OperationPayload, RespellPitchOp, SetMetadataOp, SetMetricGridOp, - SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, TransposeOp, - TupletCompensation, + resolved_anchor_position, CreateCrossCuttingOp, CreateRegionOp, CreateRepeatStructureOp, + CreateStaffInstanceOp, CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, + DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteRepeatStructureOp, + DeleteStaffInstanceOp, DeleteVoiceOp, InsertEventOp, InsertIdentifiedPitchOp, + ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, OperationKind, OperationPayload, + RespellPitchOp, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, + SetTimeSignatureOp, SetUserPageBreakOp, TransposeOp, TupletCompensation, }; use crate::stamp::StampTuple; use crate::support::{ObjectKind, SerializedCanonicalInputs}; @@ -1563,6 +1563,13 @@ impl<'a> Reducer<'a> { for repeat in &score.cross_cutting.repeats { self.objects .insert(TypedObjectId::RepeatStructure(repeat.id), ObjectState::Live); + // Repeats participate in event re-anchoring across every anchor + // site (rule table "Repeat structure / Anchor"). + let refs = anchor_event_refs(repeat.anchor_sites()); + if !refs.is_empty() { + self.structures + .insert(TypedObjectId::RepeatStructure(repeat.id), refs); + } } for lyric in &score.cross_cutting.lyrics { self.objects @@ -2299,6 +2306,47 @@ impl<'a> Reducer<'a> { .collect(); score.cross_cutting.spanners = kept_spanners; + // Repeat structures follow the same rule across EVERY anchor site + // (start/end, jump targets, volta spans): each dead event anchor + // re-anchors to the nearest surviving event anchor — the + // lexicographically smallest, matching `nearest_survivor`, so the + // graph and the ledger agree on both existence and target — and the + // structure cascade-deletes only when no event anchor survives. + let repeats = std::mem::take(&mut score.cross_cutting.repeats); + let kept_repeats: Vec<_> = repeats + .into_iter() + .filter_map(|mut repeat| { + let hit = repeat + .anchor_sites() + .into_iter() + .any(|a| matches!(a, TimeAnchor::Event { id, .. } if *id == op.event)); + if !hit { + return Some(repeat); + } + let survivor = repeat + .anchor_sites() + .into_iter() + .filter_map(|a| match a { + TimeAnchor::Event { id, .. } + if *id != op.event && score.events.contains(*id) => + { + Some(*id) + } + _ => None, + }) + .min()?; + for site in repeat.anchor_sites_mut() { + if let TimeAnchor::Event { id, .. } = site { + if *id == op.event { + *id = survivor; + } + } + } + Some(repeat) + }) + .collect(); + score.cross_cutting.repeats = kept_repeats; + score.cross_cutting.lyrics.retain_mut(|line| { line.events.retain(|event| *event != op.event); !line.events.is_empty() @@ -2366,6 +2414,15 @@ impl<'a> Reducer<'a> { TypedObjectId::Beam(id) => { score.cross_cutting.beams.retain(|value| value.id != *id); } + // Spanner was missing from this walk (an undone spanner mint + // left a ghost value in the graph) — fixed alongside the + // repeat arm; both mirror the slur/tie/beam removals. + TypedObjectId::Spanner(id) => { + score.cross_cutting.spanners.retain(|value| value.id != *id); + } + TypedObjectId::RepeatStructure(id) => { + score.cross_cutting.repeats.retain(|value| value.id != *id); + } // Phase-3 mints: a tombstoned staff / time signature leaves the // graph (the undo path preconditions no live reference remains). TypedObjectId::Staff(id) => { @@ -2444,6 +2501,8 @@ impl<'a> Reducer<'a> { OperationKind::SetTimeSignature(op) => self.set_time_signature(env, op), OperationKind::SetTempoSegment(op) => self.set_tempo_segment(env, op), OperationKind::SetStaffLayout(op) => self.set_staff_layout(env, op), + OperationKind::CreateRepeatStructure(op) => self.create_repeat_structure(env, op), + OperationKind::DeleteRepeatStructure(op) => self.delete_repeat_structure(env, op), }, OperationPayload::ResolveConflict(op) => self.resolve_conflict(env, op), OperationPayload::UndoTransaction(op) => self.undo_transaction(env, op), @@ -3128,6 +3187,96 @@ impl<'a> Reducer<'a> { OperationEffect::Applied } + fn create_repeat_structure( + &mut self, + env: &OperationEnvelope, + op: &CreateRepeatStructureOp, + ) -> OperationEffect { + let sid = TypedObjectId::RepeatStructure(op.repeat.id); + match self.objects.get(&sid) { + // Set-union: a repeat create of a live id reads AlreadyApplied + // without value comparison (the cross-cutting discipline; the + // RecreateContentMismatch scope stays CreateStaff + carried + // TimeSignature). + Some(ObjectState::Live) => { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + } + } + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::TargetTombstoned, + } + } + None => {} + } + // Every event-referencing anchor site must resolve live — start/end, + // the kind's jump targets, each volta's span (operation_catalog + // §"Repeat Structures": the mint must leave the graph satisfying the + // reference-resolution invariants). + let endpoints = anchor_event_refs(op.repeat.anchor_sites()); + for e in &endpoints { + if !matches!(self.objects.get(e), Some(ObjectState::Live)) { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + }; + } + } + if let Some(score) = self.graph.as_mut() { + score.cross_cutting.repeats.push(op.repeat.clone()); + } + self.objects.insert(sid, ObjectState::Live); + self.minted_by.insert(sid, env.id); + self.note_minted(env, sid); + // Register into the referent index so event tombstones repair the + // structure per the rule table (no write chain: there is no + // ModifyRepeatStructure at this revision). + if !endpoints.is_empty() { + self.structures.insert(sid, endpoints); + } + OperationEffect::Applied + } + + fn delete_repeat_structure( + &mut self, + env: &OperationEnvelope, + op: &DeleteRepeatStructureOp, + ) -> OperationEffect { + let sid = TypedObjectId::RepeatStructure(op.repeat); + let minted_by = match self.objects.get(&sid) { + None => { + return OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TargetMissing, + }, + } + } + // Concurrent same-target deletes are idempotent (delete-wins). + Some(ObjectState::Tombstoned { .. }) => { + return OperationEffect::NoOp { + reason: NoOpReason::AlreadyApplied, + } + } + Some(ObjectState::Live) => self.minted_by.get(&sid).copied().unwrap_or(env.id), + }; + self.objects.insert( + sid, + ObjectState::Tombstoned { + deleted_by: env.id, + minted_by, + }, + ); + // Drop the referent-index entry so a later event tombstone's + // re-anchoring pass never re-processes the deleted structure. + self.structures.remove(&sid); + if let Some(score) = self.graph.as_mut() { + score.cross_cutting.repeats.retain(|r| r.id != op.repeat); + } + OperationEffect::Applied + } + fn modify_cross_cutting( &mut self, env: &OperationEnvelope, @@ -5648,7 +5797,11 @@ impl<'a> Reducer<'a> { }); } } - TypedObjectId::Slur(_) | TypedObjectId::Spanner(_) => { + // Repeat structures follow the spanner row across every + // anchor site (rule table "Repeat structure / Anchor"). + TypedObjectId::Slur(_) + | TypedObjectId::Spanner(_) + | TypedObjectId::RepeatStructure(_) => { let survivors = self.surviving_endpoints(sid, tombstoned); if survivors < 1 { self.cascade_structure(env, sid, repairs); @@ -7728,6 +7881,457 @@ mod tests { )); } + // --- Phase D: repeat authoring (schema-major-2 revision). ---------------- + + fn create_repeat( + rid: epiphany_core::RepeatStructureId, + a: EventId, + b: EventId, + ) -> OperationKind { + OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { + repeat: crate::valuegen::repeat_structure(rid, a, b), + }) + } + + #[test] + fn create_repeat_structure_mints_and_delete_wins_tombstones() { + let e1 = EventId::new(ReplicaId(1), 100); + let e2 = EventId::new(ReplicaId(1), 101); + let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1); + let sid = TypedObjectId::RepeatStructure(rid); + + let mut set = OperationSet::new(); + set.accept_all(vec![ + insert(1, 0, 10, 1, 100, 0), + insert(1, 1, 11, 1, 101, 1), + prim_env(1, 2, 12, seen_r1(1), create_repeat(rid, e1, e2)), + ]); + assert!( + matches!(set.reduce().objects.get(&sid), Some(ObjectState::Live)), + "set-union mint with live anchors" + ); + + // Delete-wins: the tombstone survives a concurrent re-delete + // (idempotent) and a post-delete re-create (TargetTombstoned no-op). + let mut set = OperationSet::new(); + set.accept_all(vec![ + insert(1, 0, 10, 1, 100, 0), + insert(1, 1, 11, 1, 101, 1), + prim_env(1, 2, 12, seen_r1(1), create_repeat(rid, e1, e2)), + prim_env( + 1, + 3, + 13, + seen_r1(2), + OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { repeat: rid }), + ), + prim_env( + 2, + 0, + 14, + seen_r1(3), + OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { repeat: rid }), + ), + prim_env(3, 0, 15, seen_r1(3), create_repeat(rid, e1, e2)), + ]); + assert!( + matches!( + set.reduce().objects.get(&sid), + Some(ObjectState::Tombstoned { .. }) + ), + "the tombstone survives re-delete and re-create (delete-wins)" + ); + } + + #[test] + fn create_repeat_structure_preconditions_every_anchor_site_live() { + // The all-anchors-live precondition covers the kind's jump targets and + // each volta's span, not just start/end (operation_catalog §"Repeat + // Structures"). + let e1 = EventId::new(ReplicaId(1), 100); + let e2 = EventId::new(ReplicaId(1), 101); + let ghost = EventId::new(ReplicaId(1), 6_666); + let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1); + + let mut dal_segno = crate::valuegen::repeat_structure(rid, e1, e2); + dal_segno.kind = epiphany_core::RepeatKind::DalSegno { + segno: crate::valuegen::event_anchor(ghost), + end_target: crate::valuegen::event_anchor(e2), + }; + let mut volta = crate::valuegen::volta_repeat(rid, e1, e2); + volta.voltas[1].end = crate::valuegen::event_anchor(ghost); + + for repeat in [dal_segno, volta] { + let mut set = OperationSet::new(); + set.accept_all(vec![ + insert(1, 0, 10, 1, 100, 0), + insert(1, 1, 11, 1, 101, 1), + prim_env( + 1, + 2, + 12, + seen_r1(1), + OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { repeat }), + ), + ]); + assert!( + !set.reduce() + .objects + .contains_key(&TypedObjectId::RepeatStructure(rid)), + "a dead jump-target/volta anchor is a TargetMissing no-op — nothing minted" + ); + } + } + + #[test] + fn deleting_an_event_reanchors_a_repeat_across_every_anchor_site() { + // The rule table's "Repeat structure / Anchor" row: re-anchor to the + // nearest surviving anchor, across start/end AND volta spans, recorded + // as a RepairRecord — and the graph agrees with the ledger. + use epiphany_core::generators::valid_score; + let mut base = valid_score(0x5EED); + let voice_events = base + .voices() + .map(|(_, _, v)| v.events.clone()) + .next() + .expect("the fixture has a voice"); + let (e0, e1) = (voice_events[0], voice_events[1]); + let rid = epiphany_core::RepeatStructureId::new(ReplicaId(9), 800); + base.cross_cutting + .repeats + .push(epiphany_core::RepeatStructure { + id: rid, + start: crate::valuegen::event_anchor(e0), + end: crate::valuegen::event_anchor(e1), + kind: epiphany_core::RepeatKind::Volta, + voltas: vec![epiphany_core::Volta { + endings: vec![1], + start: crate::valuegen::event_anchor(e0), + end: crate::valuegen::event_anchor(e1), + }], + }); + + let del = prim_env( + 2, + 0, + 10, + CausalContext::new(), + OperationKind::DeleteEvent(DeleteEventOp { + event: e0, + tuplet_compensation: TupletCompensation::NotInTuplet, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![del.clone()]); + let result = set.reduce_onto(&base); + + let effect = result + .state + .effects + .iter() + .find(|(id, _)| *id == del.id) + .map(|(_, e)| e) + .expect("delete effect recorded"); + let OperationEffect::AppliedWithRepair { repairs } = effect else { + panic!("expected AppliedWithRepair, got {effect:?}"); + }; + assert!( + repairs.iter().any(|r| { + r.target == TypedObjectId::RepeatStructure(rid) + && r.kind + == RepairKind::Reanchored { + from: TypedObjectId::Event(e0), + to: TypedObjectId::Event(e1), + reason: ReanchorReason::SameVoiceNearer, + } + }), + "the repeat re-anchor is a recorded repair: {repairs:?}" + ); + let repeat = result + .score + .cross_cutting + .repeats + .iter() + .find(|r| r.id == rid) + .expect("the repeat survives with one live anchor"); + let expect_e1 = + |anchor: &TimeAnchor| matches!(anchor, TimeAnchor::Event { id, .. } if *id == e1); + assert!( + expect_e1(&repeat.start) + && expect_e1(&repeat.end) + && expect_e1(&repeat.voltas[0].start) + && expect_e1(&repeat.voltas[0].end), + "EVERY dead anchor site — start and the volta span — moved to the survivor" + ); + 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 + // segno re-anchors to the surviving event anchor (graph + ledger), + // and a repeat whose ONLY event anchor is its segno cascades when + // that event dies. (No other test exercised event-anchored jump + // targets through the delete path.) + use epiphany_core::generators::valid_score; + let mut base = valid_score(0x5EED); + let voice_events = base + .voices() + .map(|(_, _, v)| v.events.clone()) + .next() + .expect("the fixture has a voice"); + let (e0, e1) = (voice_events[0], voice_events[1]); + let region = base.canvas.regions[0].id; + let region_edge = |edge| TimeAnchor::Region { + id: region, + edge, + offset: AnchorOffset::Zero, + }; + + // Repeat A: start/end/segno all event-anchored; e0 dies -> every dead + // site (including the segno) moves to e1. + let rid_a = epiphany_core::RepeatStructureId::new(ReplicaId(9), 810); + base.cross_cutting + .repeats + .push(epiphany_core::RepeatStructure { + id: rid_a, + start: crate::valuegen::event_anchor(e0), + end: crate::valuegen::event_anchor(e1), + kind: epiphany_core::RepeatKind::DalSegno { + segno: crate::valuegen::event_anchor(e0), + end_target: crate::valuegen::event_anchor(e1), + }, + voltas: Vec::new(), + }); + // Repeat B: the ONLY event anchor is the segno; e0 dies -> cascade. + let rid_b = epiphany_core::RepeatStructureId::new(ReplicaId(9), 811); + base.cross_cutting + .repeats + .push(epiphany_core::RepeatStructure { + id: rid_b, + start: region_edge(epiphany_core::RegionEdge::Start), + end: region_edge(epiphany_core::RegionEdge::End), + kind: epiphany_core::RepeatKind::DalSegno { + segno: crate::valuegen::event_anchor(e0), + end_target: region_edge(epiphany_core::RegionEdge::End), + }, + voltas: Vec::new(), + }); + + let del = prim_env( + 2, + 0, + 10, + CausalContext::new(), + OperationKind::DeleteEvent(DeleteEventOp { + event: e0, + tuplet_compensation: TupletCompensation::NotInTuplet, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![del]); + let result = set.reduce_onto(&base); + + let repeat_a = result + .score + .cross_cutting + .repeats + .iter() + .find(|r| r.id == rid_a) + .expect("repeat A survives on its e1 anchors"); + let segno_moved = matches!( + &repeat_a.kind, + epiphany_core::RepeatKind::DalSegno { segno: TimeAnchor::Event { id, .. }, .. } + if *id == e1 + ); + assert!( + segno_moved, + "the dead segno jump target re-anchored to the survivor: {:?}", + repeat_a.kind + ); + assert!( + matches!(repeat_a.start, TimeAnchor::Event { id, .. } if id == e1), + "start moved with it" + ); + + assert!( + !result + .score + .cross_cutting + .repeats + .iter() + .any(|r| r.id == rid_b), + "a repeat whose only event anchor was its segno cascades" + ); + assert!( + matches!( + result + .state + .objects + .get(&TypedObjectId::RepeatStructure(rid_b)), + Some(ObjectState::Tombstoned { .. }) + ), + "the ledger agrees on the cascade" + ); + assert!(epiphany_core::check_invariants(&result.score).is_empty()); + } + + #[test] + fn deleting_the_only_anchor_event_cascades_the_repeat() { + // No surviving event anchor: cascade-delete, in the ledger (tombstone + // + CascadeDeleted repair) and the graph together. + use epiphany_core::generators::valid_score; + let mut base = valid_score(0x5EED); + let voice_events = base + .voices() + .map(|(_, _, v)| v.events.clone()) + .next() + .expect("the fixture has a voice"); + let e0 = voice_events[0]; + let rid = epiphany_core::RepeatStructureId::new(ReplicaId(9), 801); + base.cross_cutting + .repeats + .push(epiphany_core::RepeatStructure { + id: rid, + start: crate::valuegen::event_anchor(e0), + end: crate::valuegen::event_anchor(e0), + kind: epiphany_core::RepeatKind::SimpleRepeat { count: 2 }, + voltas: Vec::new(), + }); + + let del = prim_env( + 2, + 0, + 10, + CausalContext::new(), + OperationKind::DeleteEvent(DeleteEventOp { + event: e0, + tuplet_compensation: TupletCompensation::NotInTuplet, + }), + ); + let mut set = OperationSet::new(); + set.accept_all(vec![del.clone()]); + let result = set.reduce_onto(&base); + + assert!( + matches!( + result + .state + .objects + .get(&TypedObjectId::RepeatStructure(rid)), + Some(ObjectState::Tombstoned { .. }) + ), + "no surviving anchor: the ledger cascade-tombstones the repeat" + ); + let effect = result + .state + .effects + .iter() + .find(|(id, _)| *id == del.id) + .map(|(_, e)| e) + .expect("delete effect recorded"); + let OperationEffect::AppliedWithRepair { repairs } = effect else { + panic!("expected AppliedWithRepair, got {effect:?}"); + }; + assert!( + repairs.iter().any(|r| { + r.target == TypedObjectId::RepeatStructure(rid) + && r.kind == RepairKind::CascadeDeleted + }), + "the cascade is a recorded repair: {repairs:?}" + ); + assert!( + !result + .score + .cross_cutting + .repeats + .iter() + .any(|r| r.id == rid), + "the graph agrees: the repeat is gone" + ); + assert!(epiphany_core::check_invariants(&result.score).is_empty()); + } + + #[test] + fn undoing_a_repeat_or_spanner_create_removes_it_from_the_graph() { + // Undo tombstones the minted structure AND materializes the graph-side + // removal (materialize_graph_tombstones). The spanner leg regression- + // locks the pre-existing gap fixed alongside Phase D: an undone + // spanner mint used to leave a ghost value in the graph. + use epiphany_core::generators::valid_score; + let base = valid_score(0x5EED); + let voice_events = base + .voices() + .map(|(_, _, v)| v.events.clone()) + .next() + .expect("the fixture has a voice"); + let (e0, e1) = (voice_events[0], voice_events[1]); + let rid = epiphany_core::RepeatStructureId::new(ReplicaId(9), 802); + let spanner_id = epiphany_core::SpannerId::new(ReplicaId(9), 803); + let tx = TransactionId::new(ReplicaId(1), 900); + + let mut set = OperationSet::new(); + set.accept_all(vec![ + declare_transaction(1, 0, 10, CausalContext::new(), tx), + tx_member(1, 1, 11, seen_r1(0), tx, create_repeat(rid, e0, e1)), + tx_member( + 1, + 2, + 12, + seen_r1(1), + tx, + OperationKind::CreateCrossCutting(crate::payload::CreateCrossCuttingOp { + structure: CrossCuttingValue::Spanner(epiphany_core::Spanner { + id: spanner_id, + start: crate::valuegen::event_anchor(e0), + end: crate::valuegen::event_anchor(e1), + staves: Vec::new(), + kind: Default::default(), + style: Default::default(), + }), + }), + ), + undo_env(1, 3, 13, seen_r1(2), tx, UndoPolicy::StrictInverse), + ]); + let result = set.reduce_onto(&base); + + assert!( + matches!( + result + .state + .objects + .get(&TypedObjectId::RepeatStructure(rid)), + Some(ObjectState::Tombstoned { .. }) + ) && matches!( + result + .state + .objects + .get(&TypedObjectId::Spanner(spanner_id)), + Some(ObjectState::Tombstoned { .. }) + ), + "undo tombstones both mints" + ); + assert!( + !result + .score + .cross_cutting + .repeats + .iter() + .any(|r| r.id == rid), + "the undone repeat mint leaves the graph" + ); + assert!( + !result + .score + .cross_cutting + .spanners + .iter() + .any(|sp| sp.id == spanner_id), + "the undone spanner mint leaves the graph (the ghost-value fix)" + ); + assert!(epiphany_core::check_invariants(&result.score).is_empty()); + } + // --- Push-1 spec-compliance fixes (Transpose skip, meta-conflict record, // marker re-anchor repair, system-derived counter collisions). ----------- @@ -7925,7 +8529,74 @@ mod tests { visible: true, }) .schema_major(), - 2 + 2, + "a Some-override layout bears the v2 StaffLineConfiguration" + ); + + // The repeat pair (Phase D): the create is born at v2 — its carried + // RepeatStructure's kind/voltas are unconditional fields, so even the + // migration-default value has no lower-major layout. The delete's + // bare-id payload is a major-0 layout under a minor kind append. + let rid = epiphany_core::RepeatStructureId::new(ReplicaId(9), 6); + assert_eq!( + OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { + repeat: crate::valuegen::repeat_structure( + rid, + EventId::new(ReplicaId(9), 1), + EventId::new(ReplicaId(9), 2), + ), + }) + .schema_major(), + 2, + "CreateRepeatStructure is born at v2 — even for default kind/voltas" + ); + assert_eq!( + OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { repeat: rid }) + .schema_major(), + 0, + "DeleteRepeatStructure carries a bare id — a major-0 layout" + ); + } + + #[test] + fn the_canonical_base_embeds_no_repeat_values() { + // The surgical form of the cross-major byte-identity promise for the + // repeat vocabulary: two reductions identical except for the created + // repeat's v2 content (kind payload) must produce byte-identical + // canonical bases — the base records the mint as a TypedObjectId + // (discriminant 23) plus an Applied effect, never the filled value. + let e1 = EventId::new(ReplicaId(1), 100); + let e2 = EventId::new(ReplicaId(1), 101); + let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1); + let base_bytes = |count: u32| { + let mut repeat = crate::valuegen::repeat_structure(rid, e1, e2); + repeat.kind = epiphany_core::RepeatKind::SimpleRepeat { count }; + let mut set = OperationSet::new(); + set.accept_all(vec![ + insert(1, 0, 10, 1, 100, 0), + insert(1, 1, 11, 1, 101, 1), + prim_env( + 1, + 2, + 12, + seen_r1(1), + OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { repeat }), + ), + ]); + let state = set.reduce(); + assert!( + matches!( + state.objects.get(&TypedObjectId::RepeatStructure(rid)), + Some(ObjectState::Live) + ), + "the create must APPLY for this test to mean anything" + ); + state.canonical_bytes() + }; + assert_eq!( + base_bytes(2), + base_bytes(9), + "differing repeat v2 content must not reach the canonical base" ); } @@ -7938,7 +8609,13 @@ mod tests { // a filled type has leaked into the canonical base — which the majors // promise not to do. (A deliberate change to the base's own vocabulary // — an appended discriminant the seeded corpus emits — re-pins this - // consciously.) + // consciously. Re-pinned at Phase D: `gen_payload` gained the repeat + // pair, discriminants 28/29, which shifted the seeded RNG stream and + // with it the whole corpus. NOTE the seeded corpus's repeat creates + // all no-op (their random anchors miss the live events), so THIS pin + // cannot detect a repeat-value leak into the base — the dedicated + // `the_canonical_base_embeds_no_repeat_values` test below covers + // that with an APPLIED create.) let mut rng = epiphany_determinism::fuzz::SplitMix64::new(0xBA5E); let envelopes = crate::fuzz::gen_envelope_set(&mut rng, 200); let mut set = OperationSet::new(); @@ -7948,7 +8625,7 @@ mod tests { let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect(); assert_eq!( hex, - "65ad7ce56c6e8f37fbbbdab7dca8654507b3c952b0895673b453944623e42070" + "6e47a3113cfc54116af2e4a1b66fae16b9d1b63436fd26d9e6fc332bf501f5ed" ); } diff --git a/crates/epiphany-ops/src/v0.rs b/crates/epiphany-ops/src/v0.rs index 9de3bd3..9c1060f 100644 --- a/crates/epiphany-ops/src/v0.rs +++ b/crates/epiphany-ops/src/v0.rs @@ -97,6 +97,9 @@ pub enum V0OperationKind { SetTimeSignature(crate::payload::SetTimeSignatureOp), SetTempoSegment(crate::payload::SetTempoSegmentOp), SetStaffLayout(crate::payload::SetStaffLayoutOp), + // Repeat authoring (schema-major-2 revision) — round-trip by identity. + CreateRepeatStructure(crate::payload::CreateRepeatStructureOp), + DeleteRepeatStructure(crate::payload::DeleteRepeatStructureOp), } /// v0 `InsertEvent`: the event was a bare [`EventId`] plus the reduction-relevant diff --git a/crates/epiphany-ops/src/validate.rs b/crates/epiphany-ops/src/validate.rs index 9eaf882..70cdd66 100644 --- a/crates/epiphany-ops/src/validate.rs +++ b/crates/epiphany-ops/src/validate.rs @@ -83,7 +83,7 @@ use epiphany_core::{ AnchorOffset, EventDuration, EventId, EventPosition, InstrumentId, MusicalPosition, Region, - RegionEdge, RegionId, Score, SlurId, TimeAnchor, + RegionEdge, RegionId, RepeatStructureId, Score, SlurId, TimeAnchor, }; use crate::payload::{CrossCuttingValue, OperationKind}; @@ -163,6 +163,14 @@ pub enum AdvisoryViolation { /// The instrument whose declared range the event's pitch exceeds. instrument: InstrumentId, }, + /// A `CreateRepeatStructure` volta violates the Chapter-5 well-formedness + /// constraints (operation_catalog §"Repeat Structures", authoring + /// advisory): a volta's `endings` must be non-empty, 1-based, and strictly + /// ascending. Advisory only — reduction never enforces it. + VoltaEndingsIllFormed { + /// The offending repeat structure. + repeat: RepeatStructureId, + }, } /// Checks `kind` against every implemented advisory precondition (the @@ -209,6 +217,20 @@ pub fn advisory_violations(kind: &OperationKind, score: &Score) -> Vec { + // Volta well-formedness (Chapter 5, advisory): endings non-empty, + // 1-based, strictly ascending. One report per structure. + let ill_formed = op.repeat.voltas.iter().any(|volta| { + volta.endings.is_empty() + || volta.endings.first().is_some_and(|first| *first < 1) + || volta.endings.windows(2).any(|pair| pair[0] >= pair[1]) + }); + if ill_formed { + violations.push(AdvisoryViolation::VoltaEndingsIllFormed { + repeat: op.repeat.id, + }); + } + } // Every other implemented kind's spec precondition bucket is entirely // invariant (see the module docs); nothing to check here. _ => {} @@ -623,4 +645,38 @@ mod tests { assert!(ValidationMode::Authoring.enforces_advisory()); assert!(!ValidationMode::Replay.enforces_advisory()); } + + #[test] + fn ill_formed_volta_endings_are_an_advisory_violation() { + // operation_catalog §"Repeat Structures", authoring advisory: endings + // non-empty, 1-based, strictly ascending — advisory only. + let score = valid_score(7); + let e1 = EventId::new(ReplicaId(1), 1); + let e2 = EventId::new(ReplicaId(1), 2); + let rid = epiphany_core::RepeatStructureId::new(ReplicaId(1), 1); + + let well_formed = valuegen::volta_repeat(rid, e1, e2); + let kind = |repeat| { + OperationKind::CreateRepeatStructure(crate::payload::CreateRepeatStructureOp { repeat }) + }; + assert!( + advisory_violations(&kind(well_formed.clone()), &score).is_empty(), + "ascending 1-based endings pass" + ); + + for endings in [vec![], vec![0], vec![2, 2], vec![3, 1]] { + let mut repeat = well_formed.clone(); + repeat.voltas[0].endings = endings.clone(); + assert_eq!( + advisory_violations(&kind(repeat), &score), + vec![AdvisoryViolation::VoltaEndingsIllFormed { repeat: rid }], + "endings {endings:?} must report" + ); + } + + // A simple repeat carries no voltas: vacuous pass. + assert!( + advisory_violations(&kind(valuegen::repeat_structure(rid, e1, e2)), &score).is_empty() + ); + } } diff --git a/crates/epiphany-ops/src/valuegen.rs b/crates/epiphany-ops/src/valuegen.rs index 1c7c911..c9f83f2 100644 --- a/crates/epiphany-ops/src/valuegen.rs +++ b/crates/epiphany-ops/src/valuegen.rs @@ -19,10 +19,11 @@ use epiphany_core::{ AnchorOffset, Beam, BeamId, CmnNominal, Event, EventId, EventOrderingDAG, EventPosition, IdentifiedPitch, MetricTimeModel, MusicalDuration, MusicalPosition, Pitch, PitchId, PitchSpaceId, PitchSpacePosition, PitchSpelling, PitchedEvent, ProportionalTimeModel, Region, - RegionContent, RegionEdge, RegionId, RegionTimeModel, Rest, ScalePosition, Slur, SlurId, - SpellingAttachment, SpellingDirective, SpellingScope, SpellingSource, StaffBasedContent, - StaffExtent, StaffId, StaffInstance, StaffInstanceId, StemConfiguration, Tie, TieClass, TieId, - TimeAnchor, TimeExtent, Voice, VoiceId, VoiceOrigin, WallClockDuration, WallClockTime, + RegionContent, RegionEdge, RegionId, RegionTimeModel, RepeatKind, RepeatStructure, + RepeatStructureId, Rest, ScalePosition, Slur, SlurId, SpellingAttachment, SpellingDirective, + SpellingScope, SpellingSource, StaffBasedContent, StaffExtent, StaffId, StaffInstance, + StaffInstanceId, StemConfiguration, Tie, TieClass, TieId, TimeAnchor, TimeExtent, Voice, + VoiceId, VoiceOrigin, Volta, WallClockDuration, WallClockTime, }; /// A deterministic, fully-specified C4 pitch in the cmn-12 space — the neutral @@ -196,6 +197,50 @@ pub fn region_start_anchor(region: RegionId, offset: MusicalPosition) -> TimeAnc } } +/// A zero-offset event-anchored [`TimeAnchor`] — the anchor form a repeat +/// structure's endpoints use in tests and fuzz corpora. +pub fn event_anchor(event: EventId) -> TimeAnchor { + TimeAnchor::Event { + id: event, + offset: AnchorOffset::Zero, + } +} + +/// A [`RepeatStructure`] over two event-anchored endpoints: the conventional +/// simple x2 repeat, no voltas. +pub fn repeat_structure(id: RepeatStructureId, start: EventId, end: EventId) -> RepeatStructure { + RepeatStructure { + id, + start: event_anchor(start), + end: event_anchor(end), + kind: RepeatKind::SimpleRepeat { count: 2 }, + voltas: Vec::new(), + } +} + +/// A volta-kind [`RepeatStructure`]: a first and a second ending, each +/// spanning the two anchor events. +pub fn volta_repeat(id: RepeatStructureId, start: EventId, end: EventId) -> RepeatStructure { + RepeatStructure { + id, + start: event_anchor(start), + end: event_anchor(end), + kind: RepeatKind::Volta, + voltas: vec![ + Volta { + endings: vec![1], + start: event_anchor(start), + end: event_anchor(end), + }, + Volta { + endings: vec![2], + start: event_anchor(start), + end: event_anchor(end), + }, + ], + } +} + /// The default metric region time model. pub fn metric_model() -> RegionTimeModel { RegionTimeModel::Metric(MetricTimeModel::default()) diff --git a/crates/epiphany-testkit/src/generators.rs b/crates/epiphany-testkit/src/generators.rs index 874b302..2635cf7 100644 --- a/crates/epiphany-testkit/src/generators.rs +++ b/crates/epiphany-testkit/src/generators.rs @@ -32,21 +32,22 @@ use epiphany_ops::valuegen; use epiphany_ops::{ AnomalousReplicaSegment, AuthorId, CausalContext, ChangeRegionTimeModelOp, ConflictId, ConflictKind, ConflictKindRegistryId, ConflictRecord, ConflictRegistry, - ConflictResolutionState, CreateCrossCuttingOp, CreateRegionOp, CreateStaffInstanceOp, - CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, DeleteEventOp, - DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteStaffInstanceOp, DeleteVoiceOp, - ExtensionPreconditionId, FieldPath, HybridLogicalClock, InsertEventOp, InsertIdentifiedPitchOp, - IntegrityAnomaly, IntegrityAnomalyKind, IntegrityAnomalyRegistryId, MaterializedState, - ModifyCrossCuttingOp, ModifyEventOp, ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, - ObjectState, OperationEffect, OperationEnvelope, OperationKind, OperationKindRegistryId, - OperationPayload, OperationSet, OperationStamp, PendingReason, PositionRemapping, - PreconditionFailureReason, PreconditionFailureRegistryId, ReanchorReason, - ReanchorReasonRegistryId, ReanchorResult, RepairKind, RepairKindRegistryId, RepairRecord, - ReplicaAnomalyReason, ReplicaAnomalyRegistryId, ResolutionAction, ResolutionRegistryId, - ResolveConflictPayload, RespellPitchOp, SerializedCanonicalInputs, SetMetadataOp, - SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, SetTimeSignatureOp, SetUserPageBreakOp, - SetUserSystemBreakOp, TransactionCategory, TransactionDescriptor, TransposeOp, - TupletCompensation, TupletCompensationKind, UndoPolicy, UndoTransactionPayload, + ConflictResolutionState, CreateCrossCuttingOp, CreateRegionOp, CreateRepeatStructureOp, + CreateStaffInstanceOp, CreateStaffOp, CreateVoiceOp, CrossCuttingValue, DeleteCrossCuttingOp, + DeleteEventOp, DeleteIdentifiedPitchOp, DeleteRegionOp, DeleteRepeatStructureOp, + DeleteStaffInstanceOp, DeleteVoiceOp, ExtensionPreconditionId, FieldPath, HybridLogicalClock, + InsertEventOp, InsertIdentifiedPitchOp, IntegrityAnomaly, IntegrityAnomalyKind, + IntegrityAnomalyRegistryId, MaterializedState, ModifyCrossCuttingOp, ModifyEventOp, + ModifyIdentifiedPitchOp, NoOpReason, ObjectKind, ObjectState, OperationEffect, + OperationEnvelope, OperationKind, OperationKindRegistryId, OperationPayload, OperationSet, + OperationStamp, PendingReason, PositionRemapping, PreconditionFailureReason, + PreconditionFailureRegistryId, ReanchorReason, ReanchorReasonRegistryId, ReanchorResult, + RepairKind, RepairKindRegistryId, RepairRecord, ReplicaAnomalyReason, ReplicaAnomalyRegistryId, + ResolutionAction, ResolutionRegistryId, ResolveConflictPayload, RespellPitchOp, + SerializedCanonicalInputs, SetMetadataOp, SetMetricGridOp, SetStaffLayoutOp, SetTempoSegmentOp, + SetTimeSignatureOp, SetUserPageBreakOp, SetUserSystemBreakOp, TransactionCategory, + TransactionDescriptor, TransposeOp, TupletCompensation, TupletCompensationKind, UndoPolicy, + UndoTransactionPayload, }; use crate::rng::Rng; @@ -643,7 +644,7 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP } _ => {} } - let kind = match rng.below(28) { + let kind = match rng.below(30) { 0 => { let pitches = if rng.boolean() { vec![obj_pitch(rng.below(pitches))] @@ -825,6 +826,26 @@ pub fn operation_payload(rng: &mut Rng, events: u64, pitches: u64) -> OperationP .then(epiphany_core::StaffLineConfiguration::default), visible: rng.boolean(), }), + // Repeat authoring (schema-major-2 revision) over the shared + // event-id space, so anchors sometimes resolve and sometimes miss. + 27 => OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { + repeat: if rng.boolean() { + valuegen::repeat_structure( + RepeatStructureId::new(OBJ_REPLICA, rng.below(2)), + obj_event(rng.below(events)), + obj_event(rng.below(events)), + ) + } else { + valuegen::volta_repeat( + RepeatStructureId::new(OBJ_REPLICA, rng.below(2)), + obj_event(rng.below(events)), + obj_event(rng.below(events)), + ) + }, + }), + 28 => OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { + repeat: RepeatStructureId::new(OBJ_REPLICA, rng.below(2)), + }), _ => OperationKind::Registered( OperationKindRegistryId(rng.next_u64() as u128), rng.byte_vec(0, 16), @@ -1263,7 +1284,7 @@ pub fn graph_edit_session( // exercises their *graph* materialization (reduce_onto + // check_invariants), not just the bookkeeping projection. Each targets a // live object minted by the phases above (or the base region). - let kind = match rng.below(12) { + let kind = match rng.below(14) { 0 => OperationKind::DeleteEvent(DeleteEventOp { event: obj_event(rng.below(total)), tuplet_compensation: TupletCompensation::NotInTuplet, @@ -1357,6 +1378,20 @@ pub fn graph_edit_session( }), }) } + // Repeat authoring over the session's event space: anchors mostly + // resolve live, so mints land in the graph and later DeleteEvents + // exercise the "Repeat structure / Anchor" re-anchoring row under + // the invariant gate. + 12 => OperationKind::CreateRepeatStructure(CreateRepeatStructureOp { + repeat: valuegen::repeat_structure( + RepeatStructureId::new(OBJ_REPLICA, rng.below(4)), + obj_event(rng.below(total)), + obj_event(rng.below(total)), + ), + }), + 13 => OperationKind::DeleteRepeatStructure(DeleteRepeatStructureOp { + repeat: RepeatStructureId::new(OBJ_REPLICA, rng.below(4)), + }), _ => OperationKind::SetStaffLayout(SetStaffLayoutOp { staff_instance: targets[rng.below(targets.len() as u64) as usize].0, instrument_override: None, diff --git a/crates/epiphany-testkit/src/layout_stub.rs b/crates/epiphany-testkit/src/layout_stub.rs index c718a9b..3d705e0 100644 --- a/crates/epiphany-testkit/src/layout_stub.rs +++ b/crates/epiphany-testkit/src/layout_stub.rs @@ -891,7 +891,7 @@ pub fn gen_vertical_band(rng: &mut Rng) -> VerticalBand { /// An operation-kind tag (every variant Agent C's type provides, including the /// registered form). pub fn gen_operation_kind_tag(rng: &mut Rng) -> OperationKindTag { - match rng.below(24) { + match rng.below(30) { 0 => OperationKindTag::InsertEvent, 1 => OperationKindTag::DeleteEvent, 2 => OperationKindTag::ModifyEvent, @@ -915,6 +915,14 @@ pub fn gen_operation_kind_tag(rng: &mut Rng) -> OperationKindTag { 20 => OperationKindTag::DeleteVoice, 21 => OperationKindTag::SetMetadata, 22 => OperationKindTag::SetMetricGrid, + // The appended vocabulary: the Phase-3 tranche (24..=27) — this + // generator had gone stale at 23 — and the repeat pair (28/29). + 23 => OperationKindTag::InsertStaff, + 24 => OperationKindTag::SetTimeSignature, + 25 => OperationKindTag::SetTempoSegment, + 26 => OperationKindTag::SetStaffLayout, + 27 => OperationKindTag::CreateRepeatStructure, + 28 => OperationKindTag::DeleteRepeatStructure, _ => OperationKindTag::Registered(epiphany_ops::OperationKindRegistryId( rng.next_u64() as u128 )), diff --git a/spec/PASS13_CANDIDATES.md b/spec/PASS13_CANDIDATES.md new file mode 100644 index 0000000..cdc8fa3 --- /dev/null +++ b/spec/PASS13_CANDIDATES.md @@ -0,0 +1,12 @@ +# Pass 13 — candidate ledger + +Ambiguities and cross-cut inconsistencies found since Pass 12 closed, filed +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. + +| 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) | 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 | diff --git a/spec/core_spec.pdf b/spec/core_spec.pdf index 9974575..b449cf3 100644 Binary files a/spec/core_spec.pdf and b/spec/core_spec.pdf differ diff --git a/spec/core_spec.tex b/spec/core_spec.tex index 4910cf4..f19e116 100644 --- a/spec/core_spec.tex +++ b/spec/core_spec.tex @@ -7432,7 +7432,10 @@ table is normative. anchor site of the structure --- \texttt{start}/\texttt{end}, the kind's jump targets (\texttt{DaCapo.end\_target}, \texttt{DalSegno.segno}/\texttt{end\_target}), and each - volta's span (ratified with the repeat-authoring pair, + volta's span. Among multiple surviving candidates ``nearest'' + is currently the deterministic identifier-order minimum; + proximity-aware (four-key) selection is a deferred refinement, + as for spanners (ratified with the repeat-authoring pair, schema-major-2 revision) \\ Marker & Anchor & diff --git a/spec/operation_catalog.pdf b/spec/operation_catalog.pdf index 30118ed..d03dc53 100644 Binary files a/spec/operation_catalog.pdf and b/spec/operation_catalog.pdf differ diff --git a/spec/operation_catalog.tex b/spec/operation_catalog.tex index 3bb7859..50156fa 100644 --- a/spec/operation_catalog.tex +++ b/spec/operation_catalog.tex @@ -900,12 +900,18 @@ minted-object undo does not re-introduce the tombstoned structure table when a referenced event is later tombstoned: re-anchor to the nearest surviving anchor, cascade-delete only when none survives --- as for spanners, spanning \emph{every} anchor site -(\texttt{start}/\texttt{end}, jump targets, volta spans). The rule row is -the core specification's (Chapter~6 re-anchoring rule table, ratified with -this pair). +(\texttt{start}/\texttt{end}, jump targets, volta spans). Among multiple +surviving candidates --- a case slurs and spanners never present, since +their sole other endpoint is the forced survivor --- ``nearest'' is +currently realized as the deterministic identifier-order minimum (the +same tie-break the two-endpoint collapse already used); proximity-aware +(four-key) selection over a repeat's surviving sites is a deferred +refinement, exactly as the spanner row defers per-kind proximity bounds. +The rule row is the core specification's (Chapter~6 re-anchoring rule +table, ratified with this pair). \textbf{Authoring advisory.} The volta well-formedness constraints of -core Chapter~5 (endings non-empty, 1-based, ascending) are +core Chapter~5 (endings non-empty, 1-based, \emph{strictly} ascending) are \emph{advisory}: surfaced at authoring time under interactive validation, never enforced under reduction.