Schema major 2 Phase D: the repeat-authoring pair (code tranche)
CreateRepeatStructure / DeleteRepeatStructure land end-to-end,
review-hardened (five-dimension workflow review; 19 findings triaged —
12 verifier agents lost to a session limit were verified by hand).
epiphany-ops:
- CreateRepeatStructureOp { repeat } (lp(RepeatStructure), wire 28,
BORN AT V2: schema_major always 2 — kind/voltas are unconditional
fields) and DeleteRepeatStructureOp { repeat } (bare id, wire 29,
major 0: minimal stamping over a major-0 layout; the discriminant a
schema-minor append). OperationKindTag 28/29 name-verbatim with
validating decode; golden tables grown to 30; migrate/v0 identity
arms; lib re-exports.
- Reduction: set-union mint (live-id => AlreadyApplied without value
comparison — the cross-cutting discipline; RecreateContentMismatch
scope unchanged) with the ALL-anchor-sites-live precondition;
delete-wins tombstone; the "Repeat structure / Anchor" rule-table
row wired into BOTH the ledger (reanchor_for_tombstone arm, min-id
survivor, cascade when dry) and the graph (every dead site rewrites
to the same survivor), agreement asserted by tests incl. the
DalSegno jump-target case.
- Undo: materialize_graph_tombstones gains RepeatStructure — and the
pre-existing SPANNER GHOST fix (an undone spanner mint never left
the graph), regression-locked.
- validate.rs: VoltaEndingsIllFormed advisory (non-empty, 1-based,
strictly ascending; interactive-only).
- Canonical-base honesty: blake3 re-pinned for the corpus stream
shift, its comment corrected (the seeded repeat creates all no-op),
and the property covered surgically by
the_canonical_base_embeds_no_repeat_values (two reductions
differing only in repeat v2 content => byte-identical bases).
epiphany-core:
- RepeatStructure::anchor_sites()/anchor_sites_mut(): THE single
site-set walk (start/end, jump targets, volta spans), consumed by
reduction, the editor barrier seam, the invariant walk, and the
cross-reference index — replacing five hand-rolled copies and
fixing a SIXTH, silently-stale one (indexes.rs had never learned
the Phase-B kind/volta anchors; regression-locked).
- RepeatStructure joins the CanonicalValue seam (strict per-value
decode); decode-fuzz corpus gains valid_score_rich_with_repeats
(DalSegno + voltas) CORPUS-LOCAL — shared render fixtures stay
repeat-free until E1 (zero golden churn).
editor-core / gui / testkit / layout-ir:
- Barrier seam: repeat_event_refs + subjects_of arms +
graph_structure_endpoints arm, gated end-to-end by
repeat_authoring_kinds_derive_subjects_and_gate_on_barriers.
- Generators: operation_payload -> below(30); graph-session gen ->
below(14); ops fuzz gen_payload -> below(27); layout_stub
gen_operation_kind_tag unstaled 24..=29 (the Phase-3 tags were
already missing); GUI name table.
Spec touch-ups (review findings): catalog says "strictly ascending";
the survivor tie-break is now explicit in the core rule-table row and
catalog (identifier-order minimum among multiple survivors;
proximity-aware four-key selection deferred, as for spanners).
Trail: ops + core DECISIONS entries; spec/PASS13_CANDIDATES.md opens
the Pass-13 ledger at three candidates (P13-K1; P13-D1 undo-driven
tombstones bypass ledger re-anchoring — pre-existing class; P13-D2
cue-cascade repair-trail ordering).
Full gate: fmt, clippy -D warnings, rustdoc -D warnings, 30 workspace
suites, conformance scale 1 (8/8).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
parent
b816144a43
commit
9b5339f8f3
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -3141,6 +3141,9 @@ canonical_value! {
|
|||
TimeSignature,
|
||||
TempoSegment,
|
||||
StaffLineConfiguration,
|
||||
// Repeat authoring (schema-major-2 revision) — CreateRepeatStructure
|
||||
// embeds the full value.
|
||||
RepeatStructure,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -55,6 +55,54 @@ struct Corpus {
|
|||
v1_scores: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// 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());
|
||||
|
|
|
|||
|
|
@ -1228,6 +1228,54 @@ pub struct RepeatStructure {
|
|||
pub voltas: Vec<Volta>,
|
||||
}
|
||||
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<TypedObjectId> {
|
||||
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<TypedObjectId> {
|
||||
let events = |ids: Vec<EventId>| ids.into_iter().map(TypedObjectId::Event).collect();
|
||||
|
|
@ -257,6 +272,13 @@ fn graph_structure_endpoints(score: &Score, structure: &TypedObjectId) -> Vec<Ty
|
|||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
TypedObjectId::RepeatStructure(id) => 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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<epiphany_core::EventId> = 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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|||
V0OperationKind::SetTimeSignature(op) => 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),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u8>) {
|
||||
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<u8>) {
|
||||
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<OperationKindTag> = (0u8..28)
|
||||
let mut tags: Vec<OperationKindTag> = (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]);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<AdvisoryV
|
|||
}
|
||||
}
|
||||
}
|
||||
OperationKind::CreateRepeatStructure(op) => {
|
||||
// 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)),
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
Binary file not shown.
|
|
@ -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 &
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue