Pass 12 G-pass: code tranche (K3/K9/C4/H7) + review fixes
The decided code work behind aff1809, review-hardened (high-effort
multi-agent review; 3 correctness findings fixed pre-commit, all
regression-locked).
epiphany-ops:
- K3: SystemDerivedContentImmutable (12) — modify_event /
modify_identified_pitch refuse intrinsic-content rewrites of
SYSTEM_DERIVED pitches, checked against the system_mints registry
(base-seeded + op mints, so reduce()/reduce_onto() agree).
REVIEW FIX: Transpose now *skips* system-derived targets like
tombstoned ones (an in-place alteration shift desynchronized content
from the derivation registry -> checkpoint-dependent verdicts); an
all-system transpose reduces as the K3 no-op. Catalog Transpose
updated.
- K9: RecreateContentMismatch (13) replaces the TargetMissing misnomer
at both value-retaining re-create sites (CreateStaff, carried
TimeSignature). Catalog corrected: container creates are plain
set-union (no value comparison), not K9 sites.
- C4: ReanchorReason::SameCanvasNearer (6); rank-4 recording routes
through rank_reason. REVIEW FIX: an *unestablished* rank 4
(unresolvable placement fallthrough in containment_rank) keeps the
honest ExplicitFallback — selection order unchanged.
- Filed P13-K1 (pre-existing checkpoint asymmetry for ModifyEvent-
introduced system pitches) in DECISIONS rather than improvising.
epiphany-core (H7, req:pitch:authored-uninferred):
- Authored attachments surface for inference-ineligible targets in
both pre-passes (best_authored_spelling/_decomposition refactor,
candidates-first liveness via the existing Score::live_pitch_ids);
two new taxonomy buckets enter the derivation fingerprint. The old
test locking the pre-decision mirror is flipped to the ratified
behavior.
epiphany-testkit (review fixes):
- prepass_harness: spelling-unavailable pitches may carry *authored*
surfacings (inferred still forbidden); decomposition reconstruction
invariants scoped to algorithm output (authored entries are
invariant-15's graph-level jurisdiction); accounting identity gains
the authored-uninferred bucket.
- generators: the three appended discriminants (12/13/6) now generate.
Full gate green: fmt, clippy -D warnings, rustdoc -D warnings, 30
workspace suites, conformance scale 1 (8/8). Wire goldens + decode
bounds for the appended discriminants; 12 new regression tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
parent
aff1809421
commit
e4edea6a3e
|
|
@ -116,6 +116,11 @@ pub struct TaxonomyReport {
|
|||
/// Pitches whose pitch space declares spelling unavailable (non-`cmn-12`-
|
||||
/// determinable positions: JI vectors, non-12 EDOs, registered grammars).
|
||||
pub spelling_unavailable: usize,
|
||||
/// Pitches the algorithm produced *no* inferred spelling for whose
|
||||
/// annotation is an authored attachment surfacing on its own
|
||||
/// (`req:pitch:authored-uninferred`, Pass 12 P12-H7). Counted distinctly
|
||||
/// from `spellings_authored` (an authored *override* of inferred output).
|
||||
pub spellings_authored_uninferred: usize,
|
||||
|
||||
// --- Decomposition outcomes (over events). ---
|
||||
/// Eligible events whose effective decomposition is the pre-pass's inferred
|
||||
|
|
@ -142,6 +147,12 @@ pub struct TaxonomyReport {
|
|||
/// skipped rather than mis-rendered. Expected to be zero for well-formed
|
||||
/// metric input.
|
||||
pub decomposition_ungriddable: usize,
|
||||
/// Events the pre-pass produced *no* inferred decomposition for
|
||||
/// (ungriddable, non-metric, non-musical, or an inapplicable kind) whose
|
||||
/// annotation is an authored attachment surfacing on its own
|
||||
/// (`req:pitch:authored-uninferred`, Pass 12 P12-H7). Counted distinctly
|
||||
/// from `decompositions_authored` (an authored *override*).
|
||||
pub decompositions_authored_uninferred: usize,
|
||||
}
|
||||
|
||||
/// The canonical derived annotations for a score under a profile: the effective
|
||||
|
|
@ -206,12 +217,14 @@ impl DerivedAnnotations {
|
|||
t.spellings_inferred,
|
||||
t.spellings_authored,
|
||||
t.spelling_unavailable,
|
||||
t.spellings_authored_uninferred,
|
||||
t.decompositions_inferred,
|
||||
t.decompositions_authored,
|
||||
t.decomposition_skipped_nonmusical,
|
||||
t.decomposition_deferred_nonmetric,
|
||||
t.decomposition_inapplicable,
|
||||
t.decomposition_ungriddable,
|
||||
t.decompositions_authored_uninferred,
|
||||
] {
|
||||
out.extend_from_slice(&(count as u64).to_le_bytes());
|
||||
}
|
||||
|
|
@ -267,6 +280,35 @@ pub fn derive_annotations(score: &Score, profile: &PrePassProfile) -> DerivedAnn
|
|||
}
|
||||
spellings.insert(pid, resolved);
|
||||
}
|
||||
// Authored spellings for pitches the algorithm produced *no* inferred
|
||||
// spelling for (`req:pitch:authored-uninferred`, Pass 12 P12-H7): the
|
||||
// winning authored attachment surfaces on its own — an authored
|
||||
// attachment is precisely how a user notates what the algorithm
|
||||
// cannot infer, and the derived-annotation surface must not render it
|
||||
// invisible. Only pitches that exist in the score surface (an
|
||||
// attachment in tombstoned-target state stays diagnostic-only).
|
||||
// Candidates come from the attachments first, so the common
|
||||
// no-attachments case never walks the arena for the liveness set.
|
||||
let mut uninferred: BTreeSet<PitchId> = BTreeSet::new();
|
||||
for att in &score.spelling_attachments {
|
||||
if let SpellingScope::Pitch(p) = &att.scope {
|
||||
if !spellings.contains_key(p) {
|
||||
uninferred.insert(*p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !uninferred.is_empty() {
|
||||
let live_pitches = score.live_pitch_ids();
|
||||
for pid in uninferred {
|
||||
if !live_pitches.contains(&pid) {
|
||||
continue;
|
||||
}
|
||||
if let Some(resolved) = best_authored_spelling(score, pid, precedence) {
|
||||
taxonomy.spellings_authored_uninferred += 1;
|
||||
spellings.insert(pid, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Decomposition pre-pass. ---
|
||||
|
|
@ -281,6 +323,23 @@ pub fn derive_annotations(score: &Score, profile: &PrePassProfile) -> DerivedAnn
|
|||
}
|
||||
decompositions.insert(eid, resolved);
|
||||
}
|
||||
// Authored decompositions for events the pre-pass produced *no*
|
||||
// inferred decomposition for — ungriddable, non-metric, non-musical,
|
||||
// or an inapplicable kind (`req:pitch:authored-uninferred`, Pass 12
|
||||
// P12-H7). Mirrors the spelling surfacing above; only events that
|
||||
// exist in the score surface.
|
||||
let mut uninferred: BTreeSet<EventId> = BTreeSet::new();
|
||||
for att in &score.decomposition_attachments {
|
||||
if !decompositions.contains_key(&att.target) && score.events.get(att.target).is_some() {
|
||||
uninferred.insert(att.target);
|
||||
}
|
||||
}
|
||||
for eid in uninferred {
|
||||
if let Some(resolved) = best_authored_decomposition(score, eid) {
|
||||
taxonomy.decompositions_authored_uninferred += 1;
|
||||
decompositions.insert(eid, resolved);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DerivedAnnotations {
|
||||
|
|
@ -722,12 +781,30 @@ pub fn resolve_spelling(
|
|||
inferred: PitchSpelling,
|
||||
precedence: &SpellingPrecedence,
|
||||
) -> ResolvedSpelling {
|
||||
match best_authored_spelling(score, pitch, precedence) {
|
||||
Some(resolved) => resolved,
|
||||
None => ResolvedSpelling {
|
||||
spelling: inferred,
|
||||
provenance: SpellingProvenance::Inferred,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The winning authored spelling for `pitch`, if any: engraved layer,
|
||||
/// pitch-scoped, explicit, source outranking [`SpellingSourceKind::Inferred`]
|
||||
/// in `precedence`. Among candidates, lowest precedence rank wins, then
|
||||
/// highest priority; a remaining tie keeps the first candidate in the score's
|
||||
/// `spelling_attachments` order, which is canonical (codec-fixed), so the
|
||||
/// resolution is deterministic across replicas. Shared by
|
||||
/// [`resolve_spelling`] (authored *override* of an inferred spelling) and the
|
||||
/// authored-only surfacing path for inference-ineligible pitches
|
||||
/// (`req:pitch:authored-uninferred`, Pass 12 P12-H7).
|
||||
fn best_authored_spelling(
|
||||
score: &Score,
|
||||
pitch: PitchId,
|
||||
precedence: &SpellingPrecedence,
|
||||
) -> Option<ResolvedSpelling> {
|
||||
let inferred_rank = precedence.rank(SpellingSourceKind::Inferred);
|
||||
// The best authored override: engraved layer, pitch-scoped, explicit, source
|
||||
// outranking Inferred. Among candidates, lowest precedence rank wins, then
|
||||
// highest priority; a remaining tie keeps the first candidate in the score's
|
||||
// `spelling_attachments` order, which is canonical (codec-fixed), so the
|
||||
// resolution is deterministic across replicas.
|
||||
let mut best: Option<(usize, i32, &SpellingAttachment)> = None;
|
||||
for att in &score.spelling_attachments {
|
||||
if att.layer.is_some() {
|
||||
|
|
@ -760,22 +837,16 @@ pub fn resolve_spelling(
|
|||
};
|
||||
}
|
||||
|
||||
match best {
|
||||
Some((_, _, att)) => {
|
||||
if let SpellingDirective::Explicit(spelling) = &att.directive {
|
||||
ResolvedSpelling {
|
||||
spelling: spelling.clone(),
|
||||
provenance: SpellingProvenance::Authored(att.source.kind()),
|
||||
}
|
||||
} else {
|
||||
unreachable!("filtered to Explicit directives")
|
||||
best.map(|(_, _, att)| {
|
||||
if let SpellingDirective::Explicit(spelling) = &att.directive {
|
||||
ResolvedSpelling {
|
||||
spelling: spelling.clone(),
|
||||
provenance: SpellingProvenance::Authored(att.source.kind()),
|
||||
}
|
||||
} else {
|
||||
unreachable!("filtered to Explicit directives")
|
||||
}
|
||||
None => ResolvedSpelling {
|
||||
spelling: inferred,
|
||||
provenance: SpellingProvenance::Inferred,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
|
@ -1195,6 +1266,17 @@ pub fn resolve_decomposition(
|
|||
event: EventId,
|
||||
inferred: DecompositionAttachment,
|
||||
) -> DecompositionAttachment {
|
||||
best_authored_decomposition(score, event).unwrap_or(inferred)
|
||||
}
|
||||
|
||||
/// The winning authored decomposition for `event`, if any: source outranking
|
||||
/// [`DecompositionSource::Inferred`] under the fixed default order (ratified
|
||||
/// Pass 12, P12-H6 — decomposition precedence is not configurable); lowest
|
||||
/// rank wins; a full tie keeps the earlier attachment in the score's
|
||||
/// canonical `decomposition_attachments` order. Shared by
|
||||
/// [`resolve_decomposition`] (authored *override*) and the authored-only
|
||||
/// surfacing path for inference-ineligible events (Pass 12 P12-H7).
|
||||
fn best_authored_decomposition(score: &Score, event: EventId) -> Option<DecompositionAttachment> {
|
||||
let inferred_rank = decomposition_source_rank(&DecompositionSource::Inferred);
|
||||
let mut best: Option<(usize, &DecompositionAttachment)> = None;
|
||||
for att in &score.decomposition_attachments {
|
||||
|
|
@ -1218,10 +1300,7 @@ pub fn resolve_decomposition(
|
|||
}
|
||||
};
|
||||
}
|
||||
match best {
|
||||
Some((_, att)) => att.clone(),
|
||||
None => inferred,
|
||||
}
|
||||
best.map(|(_, att)| att.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -1148,6 +1148,185 @@ fn inferred_source_attachment_does_not_outrank_the_prepass() {
|
|||
assert_eq!(ann.taxonomy.decompositions_inferred, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_decomposition_surfaces_for_an_ungriddable_event_p12_h7() {
|
||||
// req:pitch:authored-uninferred (Pass 12, P12-H7): an authored attachment
|
||||
// targeting an event the pre-pass cannot infer for (off-grid position →
|
||||
// ungriddable) surfaces as the resolved annotation on its own, counted in
|
||||
// the dedicated authored-uninferred bucket.
|
||||
let mut captured = None;
|
||||
let mut score = metric_score(|idc, voice| {
|
||||
let eid = idc.mint();
|
||||
captured = Some(eid);
|
||||
let pid = idc.mint::<PitchId>();
|
||||
let ev = pitched(
|
||||
eid,
|
||||
voice,
|
||||
r(1, 3), // off-grid (non-dyadic) position → nothing inferred
|
||||
r(1, 4),
|
||||
vec![IdentifiedPitch {
|
||||
id: pid,
|
||||
pitch: integer_pitch(48),
|
||||
}],
|
||||
);
|
||||
(vec![ev], vec![])
|
||||
});
|
||||
let eid = captured.expect("event minted");
|
||||
let authored = DecompositionAttachment {
|
||||
target: eid,
|
||||
components: vec![NotatedComponent {
|
||||
base_value: NoteValue::Quarter,
|
||||
dots: 0,
|
||||
tuplet: None,
|
||||
tied_to_next: false,
|
||||
}],
|
||||
source: DecompositionSource::UserChosen,
|
||||
};
|
||||
score.decomposition_attachments.push(authored.clone());
|
||||
|
||||
let ann = derive_annotations(&score, &PrePassProfile::default());
|
||||
assert_eq!(
|
||||
ann.decompositions[&eid], authored,
|
||||
"the authored attachment surfaces on its own"
|
||||
);
|
||||
assert_eq!(
|
||||
ann.taxonomy.decomposition_ungriddable, 1,
|
||||
"the event is still honestly classified ungriddable"
|
||||
);
|
||||
assert_eq!(ann.taxonomy.decompositions_authored_uninferred, 1);
|
||||
assert_eq!(
|
||||
ann.taxonomy.decompositions_authored, 0,
|
||||
"not an override — there was nothing inferred to override"
|
||||
);
|
||||
assert_eq!(
|
||||
ann.decompositions.len(),
|
||||
ann.taxonomy.decompositions_inferred
|
||||
+ ann.taxonomy.decompositions_authored
|
||||
+ ann.taxonomy.decompositions_authored_uninferred,
|
||||
"the harness accounting identity holds"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferred_source_attachment_does_not_surface_for_ineligible_events_p12_h7() {
|
||||
// The rank gate holds on the authored-only path too: a stored attachment
|
||||
// whose source is `Inferred` does not outrank the pre-pass, so it does
|
||||
// not surface for an inference-ineligible event either.
|
||||
let mut captured = None;
|
||||
let mut score = metric_score(|idc, voice| {
|
||||
let eid = idc.mint();
|
||||
captured = Some(eid);
|
||||
let pid = idc.mint::<PitchId>();
|
||||
let ev = pitched(
|
||||
eid,
|
||||
voice,
|
||||
r(1, 3),
|
||||
r(1, 4),
|
||||
vec![IdentifiedPitch {
|
||||
id: pid,
|
||||
pitch: integer_pitch(48),
|
||||
}],
|
||||
);
|
||||
(vec![ev], vec![])
|
||||
});
|
||||
let eid = captured.expect("event minted");
|
||||
score
|
||||
.decomposition_attachments
|
||||
.push(DecompositionAttachment {
|
||||
target: eid,
|
||||
components: vec![NotatedComponent {
|
||||
base_value: NoteValue::Quarter,
|
||||
dots: 0,
|
||||
tuplet: None,
|
||||
tied_to_next: false,
|
||||
}],
|
||||
source: DecompositionSource::Inferred,
|
||||
});
|
||||
|
||||
let ann = derive_annotations(&score, &PrePassProfile::default());
|
||||
assert!(
|
||||
!ann.decompositions.contains_key(&eid),
|
||||
"an Inferred-source attachment does not surface"
|
||||
);
|
||||
assert_eq!(ann.taxonomy.decompositions_authored_uninferred, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_spelling_surfaces_for_an_unavailable_pitch_p12_h7() {
|
||||
// The spelling mirror of the surfacing rule: an authored explicit
|
||||
// attachment on a pitch whose pitch space declares spelling unavailable
|
||||
// (JI) surfaces with authored provenance; an attachment targeting a pitch
|
||||
// absent from the score stays diagnostic-only. The derivation stays
|
||||
// deterministic (byte-equal fingerprints).
|
||||
let mut captured = None;
|
||||
let mut score = metric_score(|idc, voice| {
|
||||
let eid = idc.mint();
|
||||
let pid = idc.mint::<PitchId>();
|
||||
captured = Some(pid);
|
||||
let ev = pitched(
|
||||
eid,
|
||||
voice,
|
||||
r(0, 4),
|
||||
r(1, 4),
|
||||
vec![IdentifiedPitch {
|
||||
id: pid,
|
||||
pitch: ji_pitch(),
|
||||
}],
|
||||
);
|
||||
(vec![ev], vec![])
|
||||
});
|
||||
let pid = captured.expect("pitch minted");
|
||||
let authored_spelling = PitchSpelling {
|
||||
nominal: SpellingNominal::Cmn(CmnNominal::D),
|
||||
accidentals: vec![AccidentalId::new("flat")],
|
||||
octave: 4,
|
||||
render_hints: Default::default(),
|
||||
};
|
||||
score.spelling_attachments.push(SpellingAttachment {
|
||||
scope: SpellingScope::Pitch(pid),
|
||||
directive: SpellingDirective::Explicit(authored_spelling.clone()),
|
||||
source: SpellingSource::UserChosen,
|
||||
priority: 0,
|
||||
layer: None,
|
||||
});
|
||||
// A second attachment targeting a pitch that exists nowhere in the score:
|
||||
// preserved canonical state, but it surfaces no annotation.
|
||||
let dangling = PitchId::new(ReplicaId(0xD00D), 1);
|
||||
score.spelling_attachments.push(SpellingAttachment {
|
||||
scope: SpellingScope::Pitch(dangling),
|
||||
directive: SpellingDirective::Explicit(authored_spelling.clone()),
|
||||
source: SpellingSource::UserChosen,
|
||||
priority: 0,
|
||||
layer: None,
|
||||
});
|
||||
|
||||
let ann = derive_annotations(&score, &PrePassProfile::default());
|
||||
let resolved = ann
|
||||
.spellings
|
||||
.get(&pid)
|
||||
.expect("the authored spelling surfaces for the unavailable pitch");
|
||||
assert_eq!(resolved.spelling, authored_spelling);
|
||||
assert_eq!(
|
||||
resolved.provenance,
|
||||
SpellingProvenance::Authored(crate::pitch::SpellingSourceKind::UserChosen)
|
||||
);
|
||||
assert_eq!(ann.taxonomy.spellings_authored_uninferred, 1);
|
||||
assert_eq!(
|
||||
ann.taxonomy.spelling_unavailable, 1,
|
||||
"the pitch is still honestly classified unavailable"
|
||||
);
|
||||
assert!(
|
||||
!ann.spellings.contains_key(&dangling),
|
||||
"an attachment on an absent pitch surfaces nothing"
|
||||
);
|
||||
let again = derive_annotations(&score, &PrePassProfile::default());
|
||||
assert_eq!(
|
||||
ann.canonical_fingerprint(),
|
||||
again.canonical_fingerprint(),
|
||||
"the surfacing path is deterministic"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decomposition_precedence_ranks_sources_then_canonical_order() {
|
||||
// UserChosen outranks Imported regardless of attachment order (the spec's
|
||||
|
|
@ -1210,14 +1389,14 @@ fn derivation_with_authored_decomposition_is_deterministic() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn authored_attachment_on_an_ungriddable_event_does_not_surface() {
|
||||
// The resolution step mirrors the spelling side: it layers authored
|
||||
// overrides above the pre-pass's *inferred* output. An event the pre-pass
|
||||
// cannot grid emits nothing to override, so an authored attachment for it
|
||||
// stays in canonical score state without surfacing as a derived annotation,
|
||||
// and the event stays honestly counted as ungriddable. (Whether authored
|
||||
// decompositions should surface for events the algorithm cannot infer for
|
||||
// is a Pass-12 question — see DECISIONS.md.)
|
||||
fn authored_attachment_on_an_ungriddable_event_surfaces_p12_h7() {
|
||||
// The Pass-12 question this test used to park is DECIDED
|
||||
// (`req:pitch:authored-uninferred`, P12-H7): an authored attachment on an
|
||||
// event the pre-pass cannot grid SURFACES as the resolved annotation on
|
||||
// its own — the pre-decision behavior (staying canonical-but-invisible)
|
||||
// is the one this revision reverses. The event stays honestly counted
|
||||
// ungriddable; the surfacing is counted in the authored-uninferred
|
||||
// bucket, not as an override.
|
||||
let mut ids = Vec::new();
|
||||
let mut score = metric_score(|idc, voice| {
|
||||
let eid = idc.mint();
|
||||
|
|
@ -1235,13 +1414,17 @@ fn authored_attachment_on_an_ungriddable_event_does_not_surface() {
|
|||
);
|
||||
(vec![ev], vec![])
|
||||
});
|
||||
score
|
||||
.decomposition_attachments
|
||||
.push(tied_quarters(ids[0], DecompositionSource::UserChosen));
|
||||
let authored = tied_quarters(ids[0], DecompositionSource::UserChosen);
|
||||
score.decomposition_attachments.push(authored.clone());
|
||||
|
||||
let ann = derive_annotations(&score, &PrePassProfile::default());
|
||||
assert!(ann.decompositions.is_empty());
|
||||
assert_eq!(
|
||||
ann.decompositions.get(&ids[0]),
|
||||
Some(&authored),
|
||||
"the authored attachment surfaces (P12-H7)"
|
||||
);
|
||||
assert_eq!(ann.taxonomy.decomposition_ungriddable, 1);
|
||||
assert_eq!(ann.taxonomy.decompositions_authored_uninferred, 1);
|
||||
assert_eq!(ann.taxonomy.decompositions_authored, 0);
|
||||
assert_eq!(ann.taxonomy.decompositions_inferred, 0);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -940,3 +940,38 @@ selection function). Decided with code to land in the G-pass code tranche:
|
|||
already `DeclaredByExtension`). **K8** retired: genesis is outside the
|
||||
operation set (catalog K1 slots removed, core Ch5 states it). Catalog
|
||||
0.5.0 → 0.6.0; Binary Format 0.3.0 → 0.4.0.
|
||||
|
||||
### G-pass code-tranche review findings (2026-07-07, all fixed before commit)
|
||||
|
||||
A high-effort review of the code tranche confirmed three correctness findings,
|
||||
each fixed with a regression test:
|
||||
|
||||
1. **Transpose bypassed P12-K3** — it shifted a `SYSTEM_DERIVED` pitch's
|
||||
alteration in place, desynchronizing content from the id's derivation
|
||||
inputs and making the K3 verdict depend on where a snapshot was cut
|
||||
(a full replay's registry holds the mint content; a post-transpose
|
||||
snapshot's holds the shifted content). Fixed: system-derived targets are
|
||||
**skipped** like tombstoned ones (id-namespace filter, so base-free and
|
||||
graph-aware reduction agree); an all-system-derived transpose reduces as
|
||||
`SystemDerivedContentImmutable`. Catalog §Transpose updated.
|
||||
2. **Unestablished rank-4 re-anchors were relabeled `SameCanvasNearer`** —
|
||||
`containment_rank` returns 4 both for the established same-canvas case and
|
||||
as the fallthrough when a voice's placement is unresolvable; the P12-C4
|
||||
append must not launder the latter into a positive proximity claim. Fixed:
|
||||
recording routes through `rank_reason`, which downgrades an unestablished
|
||||
4 to the honest `ExplicitFallback`; **selection order is unchanged** (the
|
||||
rank still compares as 4).
|
||||
3. **Vocabulary generators lagged the appends** — testkit
|
||||
`precondition_failure_reason`/`reanchor_reason` never emitted discriminants
|
||||
12/13/6, so fuzz gates could not catch a renumbering regression. Fixed.
|
||||
|
||||
**Pass-13 candidate (P13-K1, filed not fixed):** the K3 verdict for a system
|
||||
pitch *introduced by a ModifyEvent replacement value* (never minted — the
|
||||
collision pre-walk deliberately excludes ModifyEvent) differs across a
|
||||
snapshot cut: in-session the pitch is not Live (`TargetMissing`); after a
|
||||
snapshot re-seeds objects + registry from the base graph, the same modify
|
||||
reads `SystemDerivedContentImmutable`. The asymmetry **predates K3** (the
|
||||
same split previously read `TargetMissing` vs a silent `Applied` rewrite) and
|
||||
is a ModifyEvent-introduction question — whether a replacement value may
|
||||
introduce never-minted pitch ids at all — batched for Pass 13, not
|
||||
improvised here.
|
||||
|
|
|
|||
|
|
@ -221,6 +221,8 @@ fn precondition_reason(reader: &mut Reader<'_>) -> Result<PreconditionFailureRea
|
|||
)?)),
|
||||
10 => Ok(PreconditionFailureReason::ContainerNotEmpty),
|
||||
11 => Ok(PreconditionFailureReason::TempoMapMalformed),
|
||||
12 => Ok(PreconditionFailureReason::SystemDerivedContentImmutable),
|
||||
13 => Ok(PreconditionFailureReason::RecreateContentMismatch),
|
||||
tag => Err(MaterializedDecodeError::InvalidTag {
|
||||
kind: "PreconditionFailureReason",
|
||||
tag,
|
||||
|
|
@ -257,6 +259,7 @@ fn reanchor_reason(reader: &mut Reader<'_>) -> Result<ReanchorReason> {
|
|||
reader,
|
||||
ReanchorReasonRegistryId,
|
||||
)?)),
|
||||
6 => Ok(ReanchorReason::SameCanvasNearer),
|
||||
tag => Err(MaterializedDecodeError::InvalidTag {
|
||||
kind: "ReanchorReason",
|
||||
tag,
|
||||
|
|
@ -600,6 +603,25 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass12_appended_discriminants_decode() {
|
||||
assert_eq!(
|
||||
exact(&[12], precondition_reason).unwrap(),
|
||||
PreconditionFailureReason::SystemDerivedContentImmutable
|
||||
);
|
||||
assert_eq!(
|
||||
exact(&[13], precondition_reason).unwrap(),
|
||||
PreconditionFailureReason::RecreateContentMismatch
|
||||
);
|
||||
assert_eq!(
|
||||
exact(&[6], reanchor_reason).unwrap(),
|
||||
ReanchorReason::SameCanvasNearer
|
||||
);
|
||||
// The vocabularies stay bounded: one past the append rejects.
|
||||
assert!(exact(&[14], precondition_reason).is_err());
|
||||
assert!(exact(&[7], reanchor_reason).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoder_rejects_truncation_and_trailing_bytes() {
|
||||
let bytes = MaterializedState::default().canonical_bytes();
|
||||
|
|
|
|||
|
|
@ -161,6 +161,16 @@ pub enum PreconditionFailureReason {
|
|||
ExtensionPrecondition(ExtensionPreconditionId),
|
||||
/// A registered precondition code from a versioned registry.
|
||||
Registered(PreconditionFailureRegistryId),
|
||||
/// A modify would rewrite the intrinsic content of a `SYSTEM_DERIVED`-
|
||||
/// namespace object in place, invalidating its content derivation
|
||||
/// (Pass 12, P12-K3; core spec Ch5 §System-Derived Identifiers). The
|
||||
/// sanctioned path is minting a replacement object.
|
||||
SystemDerivedContentImmutable,
|
||||
/// A create re-carried a live id with *differing* content
|
||||
/// (operation_catalog §CreateStaff): the target is not missing, its
|
||||
/// content disagrees (Pass 12, P12-K9 — replaces the `TargetMissing`
|
||||
/// misnomer at the value-retaining re-create sites).
|
||||
RecreateContentMismatch,
|
||||
}
|
||||
|
||||
impl PreconditionFailureReason {
|
||||
|
|
@ -180,6 +190,9 @@ impl PreconditionFailureReason {
|
|||
PreconditionFailureReason::ContainerNotEmpty => 10,
|
||||
// Additive (Phase-3 tranche, SetTempoSegment); appended past 10.
|
||||
PreconditionFailureReason::TempoMapMalformed => 11,
|
||||
// Additive (Pass-12 G-pass, P12-K3/P12-K9); appended past 11.
|
||||
PreconditionFailureReason::SystemDerivedContentImmutable => 12,
|
||||
PreconditionFailureReason::RecreateContentMismatch => 13,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -291,6 +304,11 @@ pub enum ReanchorReason {
|
|||
SameRegionNearer,
|
||||
ExplicitFallback,
|
||||
DeclaredByExtension(ReanchorReasonRegistryId),
|
||||
/// A rank-4 (same-canvas) proximity survivor (Pass 12, P12-C4).
|
||||
/// Semantically the next rung after `SameRegionNearer`; its wire
|
||||
/// discriminant is 6 because `DeclaredByExtension` already owned 5
|
||||
/// when it was appended.
|
||||
SameCanvasNearer,
|
||||
}
|
||||
|
||||
impl ReanchorReason {
|
||||
|
|
@ -302,6 +320,8 @@ impl ReanchorReason {
|
|||
ReanchorReason::SameRegionNearer => 3,
|
||||
ReanchorReason::ExplicitFallback => 4,
|
||||
ReanchorReason::DeclaredByExtension(_) => 5,
|
||||
// Additive (Pass-12 G-pass, P12-C4); appended past 5.
|
||||
ReanchorReason::SameCanvasNearer => 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -397,6 +417,32 @@ mod tests {
|
|||
assert_ne!(a.to_canonical_bytes(), b.to_canonical_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass12_appended_discriminants_are_locked() {
|
||||
// Pass-12 G-pass appends (binary_format 0.4.0): the wire bytes are
|
||||
// the appended discriminants, nothing prior moved.
|
||||
assert_eq!(
|
||||
PreconditionFailureReason::SystemDerivedContentImmutable.to_canonical_bytes(),
|
||||
vec![12]
|
||||
);
|
||||
assert_eq!(
|
||||
PreconditionFailureReason::RecreateContentMismatch.to_canonical_bytes(),
|
||||
vec![13]
|
||||
);
|
||||
assert_eq!(
|
||||
ReanchorReason::SameCanvasNearer.to_canonical_bytes(),
|
||||
vec![6]
|
||||
);
|
||||
assert_eq!(
|
||||
PreconditionFailureReason::TempoMapMalformed.to_canonical_bytes(),
|
||||
vec![11]
|
||||
);
|
||||
assert_eq!(
|
||||
ReanchorReason::ExplicitFallback.to_canonical_bytes(),
|
||||
vec![4]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn voice_promotion_repair_round_trips_shape() {
|
||||
let r = RepairRecord {
|
||||
|
|
|
|||
|
|
@ -1088,17 +1088,21 @@ struct ReferentContext {
|
|||
/// staff instance are excluded from "nearest".
|
||||
const PROXIMITY_SAME_STAFF_INSTANCE: u8 = 1;
|
||||
|
||||
/// Maps an achieved containment-proximity rank (k1 of the "nearest" ordering)
|
||||
/// to the ratified [`ReanchorReason`] vocabulary. Rank 4 (same canvas) has no
|
||||
/// ratified reason variant, so a beyond-region survivor is recorded as the
|
||||
/// explicit fallback rather than appending a new discriminant (see
|
||||
/// DECISIONS.md — a spec-vocabulary question, batched for Pass 12).
|
||||
/// Maps an *established* containment-proximity rank (k1 of the "nearest"
|
||||
/// ordering) to the ratified [`ReanchorReason`] vocabulary. Rank 4 (same
|
||||
/// canvas) records the appended `SameCanvasNearer` (Pass 12, P12-C4; wire
|
||||
/// discriminant 6 — `DeclaredByExtension` already owned 5). Callers with a
|
||||
/// possibly-*unestablished* rank 4 — `containment_rank` also returns 4 when a
|
||||
/// voice's placement is unresolvable, a selection-order tie the recording
|
||||
/// must not launder into a positive proximity claim — route through
|
||||
/// [`Reduction::rank_reason`], which downgrades those to `ExplicitFallback`.
|
||||
fn reason_for_rank(rank: u8) -> ReanchorReason {
|
||||
match rank {
|
||||
0 => ReanchorReason::SameVoiceNearer,
|
||||
1 => ReanchorReason::SameStaffInstanceNearer,
|
||||
2 => ReanchorReason::SameStaffNearer,
|
||||
3 => ReanchorReason::SameRegionNearer,
|
||||
4 => ReanchorReason::SameCanvasNearer,
|
||||
_ => ReanchorReason::ExplicitFallback,
|
||||
}
|
||||
}
|
||||
|
|
@ -3475,7 +3479,7 @@ impl<'a> Reducer<'a> {
|
|||
} else {
|
||||
OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
reason: PreconditionFailureReason::RecreateContentMismatch,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
|
@ -3544,7 +3548,7 @@ impl<'a> Reducer<'a> {
|
|||
} else {
|
||||
Err(OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
reason: PreconditionFailureReason::RecreateContentMismatch,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -4985,6 +4989,37 @@ impl<'a> Reducer<'a> {
|
|||
// write, a StructuralFieldCollision. The resolved value is materialized in the
|
||||
// graph by reduce_onto. The LWW key/diff uses `last_*_modify` working state.
|
||||
|
||||
/// A `SYSTEM_DERIVED` pitch's intrinsic content is immutable under
|
||||
/// reduction (Pass 12, P12-K3; core spec Ch5 §System-Derived
|
||||
/// Identifiers): its identifier is content-derived, so an in-place
|
||||
/// rewrite would invalidate the derivation. The check compares the
|
||||
/// replacement value's canonical pitch bytes against the id's
|
||||
/// *registered derivation inputs* — the same `system_mints` registry the
|
||||
/// collision pre-walk maintains (base-seeded occupants plus op mints),
|
||||
/// so `reduce()` and `reduce_onto()` agree wherever the registry holds
|
||||
/// the entry. An unregistered id (base-free reduction over a pitch the
|
||||
/// base seeded) is unverifiable and passes, like the other
|
||||
/// graph-aware-only preconditions.
|
||||
///
|
||||
/// Known residue (filed as a Pass-13 candidate; see DECISIONS.md): a
|
||||
/// system pitch *introduced into the graph by a ModifyEvent replacement*
|
||||
/// (never minted — the pre-walk deliberately excludes ModifyEvent) gets
|
||||
/// this verdict only after a snapshot re-seeds the registry from the
|
||||
/// base graph; in-session it reads `TargetMissing` instead. That
|
||||
/// checkpoint-cut asymmetry for ModifyEvent-introduced content predates
|
||||
/// this precondition (pre-K3 the same split read `TargetMissing` vs a
|
||||
/// silent `Applied` rewrite) and is a ModifyEvent-introduction question,
|
||||
/// not a K3 one.
|
||||
fn system_derived_rewrite(&self, id: PitchId, value: &Pitch) -> bool {
|
||||
if id.replica() != ReplicaId::SYSTEM_DERIVED {
|
||||
return false;
|
||||
}
|
||||
match self.system_mints.get(&(ObjectKind::Pitch, id.counter())) {
|
||||
Some((inputs, _)) => inputs.0 != canonical_pitch_bytes(value),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn modify_event(&mut self, env: &OperationEnvelope, op: &ModifyEventOp) -> OperationEffect {
|
||||
let event_id = op.event_id();
|
||||
let ev_obj = TypedObjectId::Event(event_id);
|
||||
|
|
@ -5003,6 +5038,21 @@ impl<'a> Reducer<'a> {
|
|||
}
|
||||
Some(ObjectState::Live) => {}
|
||||
}
|
||||
// Identity precondition (P12-K3) before the placement precondition:
|
||||
// a replacement value that rewrites a system-derived pitch's
|
||||
// intrinsic content in place is refused outright.
|
||||
let mut carried = Vec::new();
|
||||
op.event.collect_identified_pitches(&mut carried);
|
||||
if carried
|
||||
.iter()
|
||||
.any(|ip| self.system_derived_rewrite(ip.id, &ip.pitch))
|
||||
{
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
|
||||
},
|
||||
};
|
||||
}
|
||||
// A `ModifyEvent` that moves a metric event's span (a trim or move) is now
|
||||
// materialized, but it must keep invariant 3 (`VoiceEventsSortedNonOverlap`):
|
||||
// refuse a move onto another live event in the voice, or one with a
|
||||
|
|
@ -5207,7 +5257,26 @@ impl<'a> Reducer<'a> {
|
|||
reason: NoOpReason::TargetTombstoned,
|
||||
};
|
||||
}
|
||||
for pitch in live {
|
||||
// P12-K3: a SYSTEM_DERIVED pitch's intrinsic content is immutable —
|
||||
// an in-place alteration shift would desynchronize the content from
|
||||
// the id's derivation inputs (and from the `system_mints` registry).
|
||||
// System-derived targets are *skipped* like tombstoned ones (the
|
||||
// shift still applies to the remaining targets); a transpose whose
|
||||
// live targets are all system-derived reduces as a precondition
|
||||
// no-op. The filter reads only the id's namespace, so base-free and
|
||||
// graph-aware reduction agree.
|
||||
let mutable: Vec<PitchId> = live
|
||||
.into_iter()
|
||||
.filter(|pitch| pitch.replica() != ReplicaId::SYSTEM_DERIVED)
|
||||
.collect();
|
||||
if mutable.is_empty() {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
|
||||
},
|
||||
};
|
||||
}
|
||||
for pitch in mutable {
|
||||
self.graph_transpose_pitch(pitch, op.chromatic_steps);
|
||||
}
|
||||
OperationEffect::Applied
|
||||
|
|
@ -5317,6 +5386,15 @@ impl<'a> Reducer<'a> {
|
|||
}
|
||||
Some(ObjectState::Live) => {}
|
||||
}
|
||||
// Identity precondition (P12-K3): a system-derived pitch's intrinsic
|
||||
// content is immutable in place.
|
||||
if self.system_derived_rewrite(op.pitch, &op.value) {
|
||||
return OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
|
||||
},
|
||||
};
|
||||
}
|
||||
let prev = self
|
||||
.pitch_modify_chain
|
||||
.get(&op.pitch)
|
||||
|
|
@ -5586,9 +5664,11 @@ impl<'a> Reducer<'a> {
|
|||
_ => None,
|
||||
};
|
||||
let reason = match (referent_voice, survivor_voice) {
|
||||
(Some(referent), Some(survivor)) => {
|
||||
reason_for_rank(self.containment_rank(referent, survivor))
|
||||
}
|
||||
(Some(referent), Some(survivor)) => self.rank_reason(
|
||||
self.containment_rank(referent, survivor),
|
||||
referent,
|
||||
Some(survivor),
|
||||
),
|
||||
// No indexed placement for either side (a
|
||||
// non-metric endpoint): the pre-four-key default.
|
||||
_ => ReanchorReason::SameVoiceNearer,
|
||||
|
|
@ -5703,6 +5783,45 @@ impl<'a> Reducer<'a> {
|
|||
/// same staff instance 1, same staff 2, same region 3, same canvas 4.
|
||||
/// Computed from the base-free ledger indices, so `reduce()` and
|
||||
/// `reduce_onto()` rank identically wherever both represent the scenario.
|
||||
/// Whether a rank-4 verdict from [`Self::containment_rank`] was
|
||||
/// *established* (both voices' placements resolve through instance and
|
||||
/// region, so "same canvas" is a proven fact of the singleton canvas)
|
||||
/// rather than the unresolvable-placement fallthrough. Recording reads
|
||||
/// this so an unestablished 4 stays the honest `ExplicitFallback`
|
||||
/// (Pass 12, P12-C4) — selection order is unaffected either way.
|
||||
fn canvas_rank_established(&self, referent_voice: VoiceId, candidate_voice: VoiceId) -> bool {
|
||||
let (Some(referent), Some(candidate)) = (
|
||||
self.voice_instance(referent_voice),
|
||||
self.voice_instance(candidate_voice),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
self.instance_region_of(referent).is_some() && self.instance_region_of(candidate).is_some()
|
||||
}
|
||||
|
||||
/// The [`ReanchorReason`] to *record* for an achieved rank: ranks 0–3 map
|
||||
/// directly; rank 4 records `SameCanvasNearer` only when the proximity
|
||||
/// was established ([`Self::canvas_rank_established`]), else the honest
|
||||
/// `ExplicitFallback`. `survivor_voice` is `None` when the winning
|
||||
/// candidate's voice is itself unresolvable — never established.
|
||||
fn rank_reason(
|
||||
&self,
|
||||
rank: u8,
|
||||
referent_voice: VoiceId,
|
||||
survivor_voice: Option<VoiceId>,
|
||||
) -> ReanchorReason {
|
||||
if rank == 4 {
|
||||
let established = survivor_voice
|
||||
.is_some_and(|survivor| self.canvas_rank_established(referent_voice, survivor));
|
||||
return if established {
|
||||
ReanchorReason::SameCanvasNearer
|
||||
} else {
|
||||
ReanchorReason::ExplicitFallback
|
||||
};
|
||||
}
|
||||
reason_for_rank(rank)
|
||||
}
|
||||
|
||||
fn containment_rank(&self, referent_voice: VoiceId, candidate_voice: VoiceId) -> u8 {
|
||||
if referent_voice == candidate_voice {
|
||||
return 0;
|
||||
|
|
@ -7729,6 +7848,69 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transpose_skips_system_derived_targets_p12_k3() {
|
||||
// Review finding on P12-K3: Transpose must not rewrite a
|
||||
// SYSTEM_DERIVED pitch's intrinsic content in place — that would
|
||||
// desynchronize the content from the id's derivation inputs (and from
|
||||
// the `system_mints` registry the modify preconditions consult),
|
||||
// making the K3 verdict depend on where a snapshot was cut. System
|
||||
// targets are skipped like tombstoned ones; all-system degenerates to
|
||||
// the K3 precondition no-op.
|
||||
use epiphany_core::derive_system_pitch_id;
|
||||
let content = crate::valuegen::pitch_value_nth(1);
|
||||
let system_id = derive_system_pitch_id(&content);
|
||||
let normal = PitchId::new(ReplicaId(9), 501);
|
||||
|
||||
let a = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content);
|
||||
let b = insert_with_pitch_content(1, 1, 11, 2, 101, 0, normal, &content);
|
||||
let after_inserts = CausalContext::new().with_seen(ReplicaId(1), 1);
|
||||
// Mixed normal/system targets: the system pitch is skipped, the
|
||||
// normal one shifts, the op applies.
|
||||
let t_mixed = prim_env(
|
||||
3,
|
||||
0,
|
||||
30,
|
||||
after_inserts.clone(),
|
||||
OperationKind::Transpose(TransposeOp {
|
||||
targets: vec![system_id, normal],
|
||||
chromatic_steps: 2,
|
||||
}),
|
||||
);
|
||||
// All targets system-derived: the K3 precondition no-op.
|
||||
let t_system = prim_env(
|
||||
4,
|
||||
0,
|
||||
31,
|
||||
after_inserts,
|
||||
OperationKind::Transpose(TransposeOp {
|
||||
targets: vec![system_id],
|
||||
chromatic_steps: 2,
|
||||
}),
|
||||
);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(vec![a, b, t_mixed.clone(), t_system.clone()]);
|
||||
let state = set.reduce();
|
||||
let effect = |id: OperationId| {
|
||||
state
|
||||
.effects
|
||||
.iter()
|
||||
.find(|(e, _)| *e == id)
|
||||
.map(|(_, eff)| eff)
|
||||
.expect("effect recorded")
|
||||
};
|
||||
assert_eq!(effect(t_mixed.id), &OperationEffect::Applied);
|
||||
assert_eq!(
|
||||
effect(t_system.id),
|
||||
&OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
|
||||
},
|
||||
},
|
||||
"an all-system-derived transpose refuses under P12-K3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn differing_concurrent_resolves_name_both_resolvers_in_the_meta_conflict() {
|
||||
// Chapter 6 §Conflict Resolution Operations: a later differing resolve
|
||||
|
|
@ -8156,6 +8338,187 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_system_derived_pitch_content_rewrite_is_refused_p12_k3() {
|
||||
// Pass 12 (P12-K3): a SYSTEM_DERIVED pitch's intrinsic content is
|
||||
// immutable — an in-place rewrite would invalidate the id's content
|
||||
// derivation. A modify carrying the *registered* derivation content
|
||||
// still applies (nothing is rewritten).
|
||||
use epiphany_core::derive_system_pitch_id;
|
||||
let content = crate::valuegen::pitch_value_nth(1);
|
||||
let rewritten = crate::valuegen::pitch_value_nth(2);
|
||||
let system_id = derive_system_pitch_id(&content);
|
||||
|
||||
let mint = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content);
|
||||
let rewrite = prim_env(
|
||||
1,
|
||||
1,
|
||||
20,
|
||||
seen_r1(0),
|
||||
OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
||||
pitch: system_id,
|
||||
value: rewritten.clone(),
|
||||
}),
|
||||
);
|
||||
let same = prim_env(
|
||||
1,
|
||||
2,
|
||||
30,
|
||||
seen_r1(1),
|
||||
OperationKind::ModifyIdentifiedPitch(ModifyIdentifiedPitchOp {
|
||||
pitch: system_id,
|
||||
value: content.clone(),
|
||||
}),
|
||||
);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(vec![mint.clone(), rewrite.clone(), same.clone()]);
|
||||
let state = set.reduce();
|
||||
|
||||
let effect_of = |id: OperationId| {
|
||||
state
|
||||
.effects
|
||||
.iter()
|
||||
.find(|(e, _)| *e == id)
|
||||
.map(|(_, eff)| eff)
|
||||
};
|
||||
assert_eq!(
|
||||
effect_of(rewrite.id),
|
||||
Some(&OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
|
||||
},
|
||||
}),
|
||||
"rewriting a system-derived pitch's intrinsic content is refused"
|
||||
);
|
||||
assert_eq!(
|
||||
effect_of(same.id),
|
||||
Some(&OperationEffect::Applied),
|
||||
"a modify carrying the registered derivation content passes"
|
||||
);
|
||||
// Determinism: any permutation reduces to identical bytes.
|
||||
let mut reversed = OperationSet::new();
|
||||
reversed.accept_all(vec![same, rewrite, mint]);
|
||||
assert_eq!(state.canonical_bytes(), reversed.reduce().canonical_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modify_event_rewriting_a_system_pitch_is_refused_p12_k3() {
|
||||
// The same identity precondition through the ModifyEvent path: the
|
||||
// replacement event carries the system pitch with rewritten content.
|
||||
use epiphany_core::derive_system_pitch_id;
|
||||
let content = crate::valuegen::pitch_value_nth(1);
|
||||
let rewritten = crate::valuegen::pitch_value_nth(2);
|
||||
let system_id = derive_system_pitch_id(&content);
|
||||
|
||||
let mint = insert_with_pitch_content(1, 0, 10, 1, 100, 0, system_id, &content);
|
||||
let mut replacement = crate::valuegen::insert_event_value(
|
||||
EventId::new(ReplicaId(1), 100),
|
||||
VoiceId::new(ReplicaId(9), 1),
|
||||
pos(0),
|
||||
epiphany_core::MusicalDuration::whole(),
|
||||
&[system_id],
|
||||
);
|
||||
if let Event::Pitched(pe) = &mut replacement {
|
||||
pe.pitches[0].pitch = rewritten;
|
||||
}
|
||||
let modify = prim_env(
|
||||
1,
|
||||
1,
|
||||
20,
|
||||
seen_r1(0),
|
||||
OperationKind::ModifyEvent(ModifyEventOp { event: replacement }),
|
||||
);
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(vec![mint, modify.clone()]);
|
||||
let state = set.reduce();
|
||||
|
||||
assert_eq!(
|
||||
state
|
||||
.effects
|
||||
.iter()
|
||||
.find(|(e, _)| *e == modify.id)
|
||||
.map(|(_, eff)| eff),
|
||||
Some(&OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::SystemDerivedContentImmutable,
|
||||
},
|
||||
}),
|
||||
"a ModifyEvent rewriting a carried system pitch is refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rank_four_reanchor_records_same_canvas_nearer_p12_c4() {
|
||||
// Pass 12 (P12-C4): the rank-4 (same-canvas) proximity survivor has
|
||||
// its own appended reason; ExplicitFallback stays the beyond-ladder
|
||||
// recording only.
|
||||
assert_eq!(reason_for_rank(4), ReanchorReason::SameCanvasNearer);
|
||||
assert_eq!(reason_for_rank(3), ReanchorReason::SameRegionNearer);
|
||||
assert_eq!(reason_for_rank(5), ReanchorReason::ExplicitFallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unestablished_rank_four_reanchor_keeps_the_explicit_fallback() {
|
||||
// Review finding on P12-C4: `containment_rank` also returns 4 when a
|
||||
// voice's staff-instance placement is *unresolvable* (here: base-free
|
||||
// reduction, where inserted voices have no op-created instance), and
|
||||
// that conflated 4 must NOT be recorded as a positive
|
||||
// `SameCanvasNearer` claim — the honest recording stays
|
||||
// `ExplicitFallback`. Selection order is unchanged either way.
|
||||
let e1 = EventId::new(ReplicaId(1), 100);
|
||||
let e2 = EventId::new(ReplicaId(1), 101);
|
||||
let slur = epiphany_core::SlurId::new(ReplicaId(1), 1);
|
||||
let create = prim_env(
|
||||
1,
|
||||
2,
|
||||
12,
|
||||
CausalContext::new().with_seen(ReplicaId(1), 1),
|
||||
create_slur(slur, e1, e2),
|
||||
);
|
||||
let del = prim_env(
|
||||
1,
|
||||
3,
|
||||
13,
|
||||
CausalContext::new().with_seen(ReplicaId(1), 2),
|
||||
OperationKind::DeleteEvent(DeleteEventOp {
|
||||
event: e1,
|
||||
tuplet_compensation: TupletCompensation::NotInTuplet,
|
||||
}),
|
||||
);
|
||||
// Different voices on different staff instances, neither instance
|
||||
// op-created in a region: instance→region never resolves, so
|
||||
// containment_rank falls through to its unestablished 4.
|
||||
let a = insert(1, 0, 10, 1, 100, 0);
|
||||
let mut b = insert(1, 1, 11, 2, 101, 1);
|
||||
if let OperationPayload::Primitive(OperationKind::InsertEvent(ref mut op)) = b.payload {
|
||||
op.staff_instance = StaffInstanceId::new(ReplicaId(9), 1);
|
||||
}
|
||||
let mut set = OperationSet::new();
|
||||
set.accept_all(vec![a, b, create, del.clone()]);
|
||||
let state = set.reduce();
|
||||
let repair = state
|
||||
.effects
|
||||
.iter()
|
||||
.find(|(id, _)| *id == del.id)
|
||||
.and_then(|(_, eff)| match eff {
|
||||
OperationEffect::AppliedWithRepair { repairs } => repairs
|
||||
.iter()
|
||||
.find(|r| r.target == TypedObjectId::Slur(slur))
|
||||
.cloned(),
|
||||
_ => None,
|
||||
})
|
||||
.expect("the slur endpoint deletion records a re-anchor repair");
|
||||
assert_eq!(
|
||||
repair.kind,
|
||||
RepairKind::Reanchored {
|
||||
from: TypedObjectId::Event(e1),
|
||||
to: TypedObjectId::Event(e2),
|
||||
reason: ReanchorReason::ExplicitFallback,
|
||||
},
|
||||
"an unresolvable-placement rank 4 records the honest fallback"
|
||||
);
|
||||
}
|
||||
|
||||
// --- ResolveEquivocation (operation_catalog §"ResolveEquivocation"). -----
|
||||
|
||||
/// A `RespellPitch` envelope at `id` with an explicit causal context.
|
||||
|
|
@ -8614,10 +8977,10 @@ mod tests {
|
|||
effect_of(differing.id),
|
||||
Some(&OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
reason: PreconditionFailureReason::RecreateContentMismatch,
|
||||
},
|
||||
}),
|
||||
"a differing value under a live id is a precondition no-op"
|
||||
"a differing value under a live id is a precondition no-op (P12-K9)"
|
||||
);
|
||||
assert!(matches!(
|
||||
state.objects.get(&TypedObjectId::Staff(staff_id)),
|
||||
|
|
@ -8756,10 +9119,10 @@ mod tests {
|
|||
.map(|(_, eff)| eff),
|
||||
Some(&OperationEffect::NoOp {
|
||||
reason: NoOpReason::PreconditionFailedUnderReduction {
|
||||
reason: PreconditionFailureReason::TargetMissing,
|
||||
reason: PreconditionFailureReason::RecreateContentMismatch,
|
||||
},
|
||||
}),
|
||||
"a differing value under a live signature id is a precondition no-op"
|
||||
"a differing value under a live signature id is a precondition no-op (P12-K9)"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -410,7 +410,7 @@ pub fn conflict_registry(rng: &mut Rng) -> ConflictRegistry {
|
|||
|
||||
/// A typed precondition failure (every core and registered variant).
|
||||
pub fn precondition_failure_reason(rng: &mut Rng) -> PreconditionFailureReason {
|
||||
match rng.below(12) {
|
||||
match rng.below(14) {
|
||||
0 => PreconditionFailureReason::TargetMissing,
|
||||
1 => PreconditionFailureReason::TargetTombstoned,
|
||||
2 => PreconditionFailureReason::WrongRegionTimeModel,
|
||||
|
|
@ -421,7 +421,9 @@ pub fn precondition_failure_reason(rng: &mut Rng) -> PreconditionFailureReason {
|
|||
7 => PreconditionFailureReason::VoiceMissing,
|
||||
8 => PreconditionFailureReason::ContainerNotEmpty,
|
||||
9 => PreconditionFailureReason::TempoMapMalformed,
|
||||
10 => PreconditionFailureReason::ExtensionPrecondition(ExtensionPreconditionId(
|
||||
10 => PreconditionFailureReason::SystemDerivedContentImmutable,
|
||||
11 => PreconditionFailureReason::RecreateContentMismatch,
|
||||
12 => PreconditionFailureReason::ExtensionPrecondition(ExtensionPreconditionId(
|
||||
rng.next_u64() as u128,
|
||||
)),
|
||||
_ => PreconditionFailureReason::Registered(PreconditionFailureRegistryId(
|
||||
|
|
@ -447,12 +449,13 @@ pub fn no_op_reason(rng: &mut Rng) -> NoOpReason {
|
|||
|
||||
/// A re-anchor reason covering every variant.
|
||||
pub fn reanchor_reason(rng: &mut Rng) -> ReanchorReason {
|
||||
match rng.below(6) {
|
||||
match rng.below(7) {
|
||||
0 => ReanchorReason::SameVoiceNearer,
|
||||
1 => ReanchorReason::SameStaffInstanceNearer,
|
||||
2 => ReanchorReason::SameStaffNearer,
|
||||
3 => ReanchorReason::SameRegionNearer,
|
||||
4 => ReanchorReason::ExplicitFallback,
|
||||
5 => ReanchorReason::SameCanvasNearer,
|
||||
_ => ReanchorReason::DeclaredByExtension(ReanchorReasonRegistryId(rng.next_u64() as u128)),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@
|
|||
//! 5 keeps passing with non-trivial pre-pass outputs downstream.
|
||||
|
||||
use epiphany_core::{
|
||||
derive_annotations, AccidentalId, AnalysisLayerId, CmnNominal, DerivedAnnotations, Event,
|
||||
EventDuration, MusicalDuration, PitchSpelling, PrePassProfile, RationalTime, Score,
|
||||
SpellingAttachment, SpellingDirective, SpellingNominal, SpellingProvenance, SpellingScope,
|
||||
SpellingSource, SpellingSourceKind,
|
||||
derive_annotations, AccidentalId, AnalysisLayerId, CmnNominal, DecompositionSource,
|
||||
DerivedAnnotations, Event, EventDuration, MusicalDuration, PitchSpelling, PrePassProfile,
|
||||
RationalTime, Score, SpellingAttachment, SpellingDirective, SpellingNominal,
|
||||
SpellingProvenance, SpellingScope, SpellingSource, SpellingSourceKind,
|
||||
};
|
||||
|
||||
use crate::corpus;
|
||||
|
|
@ -119,18 +119,26 @@ fn spelling_pitch_class(s: &PitchSpelling) -> Option<i32> {
|
|||
}
|
||||
|
||||
/// Every *eligible* embedded pitch carries a non-trivial spelling that realizes
|
||||
/// its 12-TET pitch class; every spelling-unavailable pitch is left unspelled.
|
||||
/// its 12-TET pitch class. A spelling-unavailable pitch is never spelled by the
|
||||
/// *algorithm*, but an authored attachment MAY surface for it
|
||||
/// (`req:pitch:authored-uninferred`, Pass 12 P12-H7).
|
||||
pub fn assert_eligible_pitches_spelled(score: &Score, ann: &DerivedAnnotations) {
|
||||
for e in score.events.iter() {
|
||||
let mut pitches = Vec::new();
|
||||
e.collect_identified_pitches(&mut pitches);
|
||||
for ip in pitches {
|
||||
match ip.pitch.twelve_tet_class() {
|
||||
None => assert!(
|
||||
!ann.spellings.contains_key(&ip.id),
|
||||
"spelling-unavailable pitch {:?} was spelled anyway",
|
||||
ip.id
|
||||
),
|
||||
None => {
|
||||
// P12-H7: an authored spelling may surface for an
|
||||
// unavailable pitch; an *inferred* one is still a bug.
|
||||
if let Some(rs) = ann.spellings.get(&ip.id) {
|
||||
assert!(
|
||||
matches!(rs.provenance, SpellingProvenance::Authored(_)),
|
||||
"spelling-unavailable pitch {:?} was inferred-spelled anyway",
|
||||
ip.id
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(class) => {
|
||||
let rs = ann.spellings.get(&ip.id).unwrap_or_else(|| {
|
||||
panic!(
|
||||
|
|
@ -175,9 +183,15 @@ pub fn assert_eligible_pitches_spelled(score: &Score, ann: &DerivedAnnotations)
|
|||
}
|
||||
}
|
||||
|
||||
/// Every decomposition H emits targets a decomposable event, has a consistent
|
||||
/// tie chain, and its components' **sounding** durations reconstruct the event's
|
||||
/// musical duration exactly (Chapter 3 invariant 15), recomputed independently.
|
||||
/// Every decomposition H's *algorithm* emits targets a decomposable event, has
|
||||
/// a consistent tie chain, and its components' **sounding** durations
|
||||
/// reconstruct the event's musical duration exactly (Chapter 3 invariant 15),
|
||||
/// recomputed independently. Authored entries (overrides and the P12-H7
|
||||
/// authored-uninferred surfacings) are exempt from the algorithm-output
|
||||
/// invariants: P12-H7 deliberately admits inference-ineligible targets
|
||||
/// (non-decomposable kinds, non-musical durations, ungriddable spans), and
|
||||
/// authored well-formedness is invariant 15's graph-level jurisdiction, not
|
||||
/// the pre-pass gate's.
|
||||
pub fn assert_decompositions_reconstruct(score: &Score, ann: &DerivedAnnotations) {
|
||||
for (eid, d) in &ann.decompositions {
|
||||
assert_eq!(
|
||||
|
|
@ -191,6 +205,12 @@ pub fn assert_decompositions_reconstruct(score: &Score, ann: &DerivedAnnotations
|
|||
.events
|
||||
.get(*eid)
|
||||
.unwrap_or_else(|| panic!("decomposition targets non-live event {eid:?}"));
|
||||
if !matches!(d.source, DecompositionSource::Inferred) {
|
||||
// Authored entry: surfaced under precedence (override) or P12-H7
|
||||
// (authored-uninferred). The remaining checks are algorithm-output
|
||||
// guarantees and do not apply.
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
matches!(ev, Event::Pitched(_) | Event::Unpitched(_) | Event::Rest(_)),
|
||||
"decomposition targets a non-decomposable event kind ({eid:?})"
|
||||
|
|
@ -232,10 +252,14 @@ pub fn assert_decompositions_reconstruct(score: &Score, ann: &DerivedAnnotations
|
|||
// Map and taxonomy counts agree: the effective map is exactly the inferred
|
||||
// plus authored-override outcomes (an authored `DecompositionAttachment`
|
||||
// outranking `Inferred` replaces the derived one and is counted
|
||||
// distinctly, mirroring the spelling buckets).
|
||||
// distinctly, mirroring the spelling buckets), plus authored-only
|
||||
// surfacings for inference-ineligible events (Pass 12 P12-H7,
|
||||
// `req:pitch:authored-uninferred`).
|
||||
assert_eq!(
|
||||
ann.decompositions.len(),
|
||||
ann.taxonomy.decompositions_inferred + ann.taxonomy.decompositions_authored,
|
||||
ann.taxonomy.decompositions_inferred
|
||||
+ ann.taxonomy.decompositions_authored
|
||||
+ ann.taxonomy.decompositions_authored_uninferred,
|
||||
"decomposition map size disagrees with the taxonomy counts"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -619,7 +619,11 @@ undo (Section~\ref{sec:k0:undo}) does not negate it; an inverse-interval undo is
|
|||
Phase-3 refinement (P11-C8).
|
||||
|
||||
\textbf{Re-anchoring.} Tombstoned targets are skipped (the transpose applies only
|
||||
to live pitches).
|
||||
to live pitches). \texttt{SYSTEM\_DERIVED}-namespace targets are likewise
|
||||
\emph{skipped} (ratified Pass~12, P12-K3): their intrinsic content is immutable,
|
||||
and an in-place alteration shift would desynchronize the content from the id's
|
||||
derivation inputs. A transpose whose live targets are \emph{all} system-derived
|
||||
reduces as a precondition no-op (\texttt{SystemDerivedContentImmutable}).
|
||||
|
||||
\section{CreateCrossCutting}
|
||||
\label{sec:k0:create-cross-cutting}
|
||||
|
|
@ -811,9 +815,12 @@ precondition no-op with the appended reason
|
|||
\texttt{RecreateContentMismatch} (discriminant 13; ratified Pass~12, closing
|
||||
P12-K9 --- the former \texttt{TargetMissing} reuse misnamed the situation:
|
||||
the target is not missing, its content disagrees). The same reason applies
|
||||
to every differing-value re-create: the carried \texttt{TimeSignature}
|
||||
(Section~\ref{sec:k0:meter-tempo}) and the structural-container creates
|
||||
(Section~\ref{sec:k0:structural-containers}). Graph-aware reduction additionally preconditions that the
|
||||
to the carried \texttt{TimeSignature} (Section~\ref{sec:k0:meter-tempo}),
|
||||
the other re-create site at which the reducer retains the carried value for
|
||||
comparison. The structural-container creates
|
||||
(Section~\ref{sec:k0:structural-containers}) are plain set-union --- any
|
||||
repeat create of a live id reads \texttt{AlreadyApplied} without value
|
||||
comparison, since the carried value is preconditioned empty of children. Graph-aware reduction additionally preconditions that the
|
||||
referenced instrument is live and, when \texttt{group} is present, that the
|
||||
staff group resolves --- the mint must leave the graph satisfying the
|
||||
reference-resolution invariants.
|
||||
|
|
|
|||
Loading…
Reference in New Issue