Cross-seam review fixes: respell→pre-pass visibility, profile enforcement, canonical fingerprint, catalog reconciliation

Addresses four findings spanning the H (pre-pass) and K (reduction) seams plus
the Operation Catalog.

1. [High] A reduced RespellPitch is now visible to the pre-pass. The reducer
   stored overrides only in MaterializedState.spellings, but Agent H's
   derive_annotations resolves authored spellings from score.spelling_attachments
   — so a real respelling accepted by reduce_onto was lost before annotation
   derivation, violating manual-override precedence. respell_pitch now upserts a
   user-chosen explicit SpellingAttachment into the materialized graph
   (materialize_respell / graph_respell_pitch); DeleteIdentifiedPitch drops that
   attachment (graph_delete_pitch) so none dangles (it does NOT tombstone the
   pitch — the event survives a pitch delete and a later ModifyEvent may reuse
   the id, which would make it both live and tombstoned). New testkit gate
   assert_reduced_respell_is_honored reduces a real RespellPitch and proves
   derive_annotations honors it as Authored(UserChosen); wired into run_all.

2. [Medium] PrePassProfile algorithm ids are now enforced, not just recorded.
   derive_annotations ran the default logic and labeled the result with the
   requested algorithm. It now runs each pre-pass only when its requested id is
   the implemented "default"; an unknown/future id yields no annotations for that
   pre-pass (the requested id stays in the result profile), so a future algorithm
   can no longer silently alias the default in a derivation cache. Test:
   unknown_algorithm_ids_are_not_honored.

3. [Medium/Low] The determinism gate now fingerprints canonical bytes, not Debug.
   DerivedAnnotations gains canonical_fingerprint(): embedded graph values
   (PitchSpelling, DecompositionAttachment, SpellingSourceKind — the latter two
   added to the CanonicalValue surface) use their ratified bytes; counts/ids are
   little-endian, length-framed. The pre-pass harness fingerprints with it. A
   discrimination check confirms it is not a degenerate constant.

4. [Low] operation_catalog.tex K1 chapter reconciled with the implemented M2
   work: the now-dispatched ops (event/pitch leaf-field, cross-cutting CRUD,
   structural container CRUD) are listed as implemented-since-M2 (available under
   the Phase-2 profile), and the "MUST reject" scope is narrowed to the genuinely
   deferred slots (create score/canvas/staff, set metadata, metric-grid/time-sig/
   tempo, layout/page-break). PDF rebuilt clean (0 undefined refs).

Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green (criterion
1 + the pre-pass and convergence gates); conformance scale 1 passes. The unrelated
Agent-I working tree is left uncommitted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-06-25 18:52:34 -04:00
parent a207077cd7
commit 7a94814ba3
7 changed files with 344 additions and 44 deletions

View File

@ -2097,6 +2097,10 @@ canonical_value! {
Region,
StaffInstance,
Voice,
// Pre-pass annotation leaves — give DerivedAnnotations a canonical byte
// fingerprint (a normative surface, vs. its Debug form).
DecompositionAttachment,
SpellingSourceKind,
}
#[cfg(test)]

View File

@ -144,6 +144,77 @@ pub struct DerivedAnnotations {
pub profile: PrePassProfile,
}
impl DerivedAnnotations {
/// A canonical byte fingerprint of the derivation. Deterministic and
/// order-independent — the maps are `BTreeMap`s in canonical key order, the
/// embedded graph values (`PitchSpelling`, `DecompositionAttachment`,
/// `SpellingSourceKind`) use their ratified [`CanonicalValue`] bytes, and
/// counts/ids are little-endian, length-framed where variable. Two
/// byte-equal fingerprints imply byte-identical derivations. This is the
/// normative serialization surface the determinism gate compares, rather
/// than the (non-normative) `Debug` form.
///
/// [`CanonicalValue`]: crate::CanonicalValue
pub fn canonical_fingerprint(&self) -> Vec<u8> {
use crate::CanonicalValue;
let mut out = Vec::new();
let lp = |out: &mut Vec<u8>, bytes: &[u8]| {
out.extend_from_slice(&(bytes.len() as u64).to_le_bytes());
out.extend_from_slice(bytes);
};
out.extend_from_slice(&(self.spellings.len() as u64).to_le_bytes());
for (pid, rs) in &self.spellings {
out.extend_from_slice(&pid.canonical_bytes());
lp(&mut out, &rs.spelling.canonical_bytes());
match &rs.provenance {
SpellingProvenance::Inferred => out.push(0),
SpellingProvenance::Authored(kind) => {
out.push(1);
lp(&mut out, &kind.canonical_bytes());
}
}
}
out.extend_from_slice(&(self.decompositions.len() as u64).to_le_bytes());
for (eid, dec) in &self.decompositions {
out.extend_from_slice(&eid.canonical_bytes());
lp(&mut out, &dec.canonical_bytes());
}
let t = &self.taxonomy;
for count in [
t.pitched_events,
t.unpitched_events,
t.rest_events,
t.trajectory_events,
t.graphic_events,
t.indeterminate_events,
t.cue_events,
t.spellings_inferred,
t.spellings_authored,
t.spelling_unavailable,
t.decompositions_inferred,
t.decomposition_skipped_nonmusical,
t.decomposition_deferred_nonmetric,
t.decomposition_inapplicable,
t.decomposition_ungriddable,
] {
out.extend_from_slice(&(count as u64).to_le_bytes());
}
lp(
&mut out,
self.profile.spelling_algorithm.as_str().as_bytes(),
);
lp(
&mut out,
self.profile.decomposition_algorithm.as_str().as_bytes(),
);
out
}
}
// ===========================================================================
// Entry point
// ===========================================================================
@ -160,21 +231,37 @@ pub fn derive_annotations(score: &Score, profile: &PrePassProfile) -> DerivedAnn
// position-sorted (invariant 3), so the per-voice order is canonical.
let layout = ScoreLayout::build(score);
// The pre-passes implement exactly one algorithm each (the registered
// `"default"` id). A profile requesting any other id is **not honored**:
// rather than return default output labeled as the requested algorithm —
// which would let an unimplemented/future algorithm silently alias the
// default in a derivation cache — that pre-pass derives nothing. The
// requested id stays in the result's `profile`, so the cache key is honest.
let spelling_supported = profile.spelling_algorithm == SpellingAlgorithmId::default_id();
let decomposition_supported =
profile.decomposition_algorithm == DecompositionAlgorithmId::default_id();
// --- Spelling pre-pass. ---
let inferred = infer_spellings(score, &layout, &mut taxonomy);
let precedence = &score.spelling_precedence;
let mut spellings = BTreeMap::new();
for (pid, inferred_spelling) in inferred {
let resolved = resolve_spelling(score, pid, inferred_spelling, precedence);
match resolved.provenance {
SpellingProvenance::Inferred => taxonomy.spellings_inferred += 1,
SpellingProvenance::Authored(_) => taxonomy.spellings_authored += 1,
if spelling_supported {
let inferred = infer_spellings(score, &layout, &mut taxonomy);
let precedence = &score.spelling_precedence;
for (pid, inferred_spelling) in inferred {
let resolved = resolve_spelling(score, pid, inferred_spelling, precedence);
match resolved.provenance {
SpellingProvenance::Inferred => taxonomy.spellings_inferred += 1,
SpellingProvenance::Authored(_) => taxonomy.spellings_authored += 1,
}
spellings.insert(pid, resolved);
}
spellings.insert(pid, resolved);
}
// --- Decomposition pre-pass. ---
let decompositions = infer_decompositions(score, &layout, &mut taxonomy);
let decompositions = if decomposition_supported {
infer_decompositions(score, &layout, &mut taxonomy)
} else {
BTreeMap::new()
};
DerivedAnnotations {
spellings,

View File

@ -1018,3 +1018,76 @@ fn decomposition_components_sum_to_event_duration() {
assert_eq!(total, dur, "components sum to the event duration");
}
}
#[test]
fn unknown_algorithm_ids_are_not_honored() {
// A profile requesting an algorithm the pre-pass does not implement must not
// receive default output labeled as that algorithm; the unhonored pre-pass
// derives nothing while the requested id stays in the result profile.
let score = metric_score(|idc, voice| {
let eid = idc.mint();
let pid = idc.mint::<PitchId>();
let ev = pitched(
eid,
voice,
r(0, 1),
r(1, 4),
vec![IdentifiedPitch {
id: pid,
pitch: integer_pitch(48),
}],
);
(vec![ev], vec![])
});
let default = derive_annotations(&score, &PrePassProfile::default());
assert!(!default.spellings.is_empty(), "the default profile spells");
assert!(
!default.decompositions.is_empty(),
"the default profile decomposes"
);
// Unknown spelling algorithm: no spellings; decomposition (still default) runs.
let unknown_spelling = PrePassProfile {
spelling_algorithm: SpellingAlgorithmId::new("future-v2"),
decomposition_algorithm: DecompositionAlgorithmId::default_id(),
};
let a = derive_annotations(&score, &unknown_spelling);
assert!(
a.spellings.is_empty(),
"an unknown spelling algorithm is not honored (no default output)"
);
assert_eq!(
a.decompositions.len(),
default.decompositions.len(),
"the supported decomposition pre-pass still runs"
);
assert_eq!(
a.profile.spelling_algorithm,
SpellingAlgorithmId::new("future-v2"),
"the requested id is preserved in the result profile"
);
// The canonical fingerprint distinguishes differing derivations (it is not a
// degenerate constant the determinism gate would pass vacuously).
assert_ne!(
a.canonical_fingerprint(),
default.canonical_fingerprint(),
"the canonical fingerprint discriminates differing annotations"
);
// Unknown decomposition algorithm: no decompositions; spelling runs.
let unknown_decomp = PrePassProfile {
spelling_algorithm: SpellingAlgorithmId::default_id(),
decomposition_algorithm: DecompositionAlgorithmId::new("future-v2"),
};
let b = derive_annotations(&score, &unknown_decomp);
assert!(
b.decompositions.is_empty(),
"an unknown decomposition algorithm is not honored"
);
assert_eq!(
b.spellings.len(),
default.spellings.len(),
"the supported spelling pre-pass still runs"
);
}

View File

@ -33,8 +33,9 @@ use std::collections::{BTreeMap, BTreeSet};
use epiphany_core::{
derive_promoted_voice_id, AnchorOffset, CanonicalValue, Event, EventDuration, EventId,
EventPosition, MusicalDuration, MusicalPosition, OperationId, Pitch, PitchId, PitchSpelling,
RegionEdge, RegionId, RegionTimeModel, Score, StaffInstance, StaffInstanceId, TimeAnchor,
TransactionId, TypedObjectId, Voice, VoiceId, VoiceOrigin,
RegionEdge, RegionId, RegionTimeModel, Score, SpellingAttachment, SpellingDirective,
SpellingScope, SpellingSource, StaffInstance, StaffInstanceId, TimeAnchor, TransactionId,
TypedObjectId, Voice, VoiceId, VoiceOrigin,
};
use epiphany_determinism::CanonicalEncode;
@ -1559,8 +1560,7 @@ impl<'a> Reducer<'a> {
match self.last_respell.get(&op.pitch).copied() {
None => {
self.spellings.insert(op.pitch, op.spelling.clone());
self.last_respell.insert(op.pitch, env.id);
self.materialize_respell(env, op);
OperationEffect::Applied
}
Some(prev_op) => {
@ -1578,8 +1578,7 @@ impl<'a> Reducer<'a> {
// earlier op is recorded as the loser. (Prototype
// convention: the winner carries the Conflicted effect;
// see DECISIONS.md.)
self.spellings.insert(op.pitch, op.spelling.clone());
self.last_respell.insert(op.pitch, env.id);
self.materialize_respell(env, op);
let conflict = ConflictRecord::new(
ConflictKind::StructuralFieldCollision {
winner: env.id,
@ -1595,14 +1594,49 @@ impl<'a> Reducer<'a> {
}
} else {
// Causally-ordered re-respell: intentional overwrite.
self.spellings.insert(op.pitch, op.spelling.clone());
self.last_respell.insert(op.pitch, env.id);
self.materialize_respell(env, op);
OperationEffect::Applied
}
}
}
}
/// Records a winning respell: the canonical bookkeeping spelling, the LWW
/// marker, and — for graph-aware reduction — an explicit user-chosen
/// [`SpellingAttachment`] so the spelling/decomposition pre-passes (Agent H's
/// `derive_annotations`) resolve the override with manual-override precedence.
/// Without the graph attachment a reduced `RespellPitch` would be visible only
/// in `MaterializedState.spellings` and lost before annotation derivation.
fn materialize_respell(&mut self, env: &OperationEnvelope, op: &RespellPitchOp) {
self.spellings.insert(op.pitch, op.spelling.clone());
self.last_respell.insert(op.pitch, env.id);
self.graph_respell_pitch(op.pitch, &op.spelling);
}
fn graph_respell_pitch(&mut self, pitch: PitchId, spelling: &PitchSpelling) {
let Some(score) = self.graph.as_mut() else {
return;
};
// Upsert the user-chosen explicit override (one per pitch), keeping the
// attachment list's canonical order stable for the resolver's tie-break.
if let Some(existing) = score.spelling_attachments.iter_mut().find(|a| {
a.layer.is_none()
&& matches!(a.source, SpellingSource::UserChosen)
&& matches!(&a.scope, SpellingScope::Pitch(p) if *p == pitch)
&& matches!(a.directive, SpellingDirective::Explicit(_))
}) {
existing.directive = SpellingDirective::Explicit(spelling.clone());
} else {
score.spelling_attachments.push(SpellingAttachment {
scope: SpellingScope::Pitch(pitch),
directive: SpellingDirective::Explicit(spelling.clone()),
source: SpellingSource::UserChosen,
priority: 0,
layer: None,
});
}
}
fn create_cross_cutting(
&mut self,
env: &OperationEnvelope,
@ -2692,6 +2726,19 @@ impl<'a> Reducer<'a> {
let Some(score) = self.graph.as_mut() else {
return;
};
// Drop any user-chosen respell override for the deleted pitch (the dual
// of `graph_respell_pitch`), so no spelling attachment is left targeting a
// pitch that is no longer present (Chapter 5 SpellingScopeResolves). The
// pitch is not added to `tombstoned_pitches` here: unlike a whole-event
// delete the event survives, and a later ModifyEvent may legitimately
// reintroduce the id — tombstoning it would make it both live and
// tombstoned (invariant 11).
score.spelling_attachments.retain(|a| {
!(a.layer.is_none()
&& matches!(a.source, SpellingSource::UserChosen)
&& matches!(&a.scope, SpellingScope::Pitch(p) if *p == pitch)
&& matches!(a.directive, SpellingDirective::Explicit(_)))
});
let Some(event) = Self::graph_event_of_pitch(score, pitch) else {
return;
};

View File

@ -40,13 +40,13 @@ fn profile() -> PrePassProfile {
PrePassProfile::default()
}
/// A canonical fingerprint of derived annotations. `DerivedAnnotations` has no
/// codec by design (it is never serialized into canonical `Score` bytes), so the
/// fingerprint is its `Debug` form — deterministic because the `spellings` and
/// `decompositions` maps are `BTreeMap`s (canonical key order) and the taxonomy
/// is plain counts. Equal fingerprints ⇒ byte-identical derivations.
pub fn fingerprint(ann: &DerivedAnnotations) -> String {
format!("{ann:?}")
/// A canonical byte fingerprint of derived annotations
/// ([`DerivedAnnotations::canonical_fingerprint`]): the embedded graph values use
/// their ratified `CanonicalValue` bytes, counts/ids are little-endian. Equal
/// fingerprints ⇒ byte-identical derivations — a normative serialization surface,
/// rather than the (non-normative) `Debug` form the gate used previously.
pub fn fingerprint(ann: &DerivedAnnotations) -> Vec<u8> {
ann.canonical_fingerprint()
}
// ===========================================================================
@ -385,6 +385,77 @@ pub fn assert_respell_precedence() {
);
}
/// The H↔K seam: a **real reduced** `RespellPitch` (not a hand-pushed
/// attachment) is honored by [`derive_annotations`]. The reducer must surface
/// the override into the materialized graph; otherwise a respelling accepted by
/// `reduce_onto` is lost before the pre-pass runs, silently violating the
/// manual-override precedence requirement.
pub fn assert_reduced_respell_is_honored() {
use epiphany_core::{check_invariants, OperationId, ReplicaId, WallClockTime};
use epiphany_ops::{
AuthorId, CausalContext, HybridLogicalClock, OperationEnvelope, OperationKind,
OperationPayload, OperationSet, OperationStamp, RespellPitchOp,
};
let (score, pid) = corpus::override_probe();
// Inferred baseline (no override).
let inferred = derive_annotations(&score, &profile())
.spellings
.get(&pid)
.expect("probe pitch is spelled")
.spelling
.clone();
// A clearly distinct authored spelling (Db4), engraved-layer UserChosen.
let override_spelling = PitchSpelling {
nominal: SpellingNominal::Cmn(CmnNominal::D),
accidentals: vec![AccidentalId::new("flat")],
octave: 4,
render_hints: Default::default(),
};
assert_ne!(
override_spelling, inferred,
"the override fixture must differ from the inferred spelling to be meaningful"
);
// Reduce a real RespellPitch onto the score, then derive annotations on the
// *materialized* graph the reducer produced.
let id = OperationId::new(ReplicaId(0x5EE11), 0);
let respell = OperationEnvelope {
id,
author: AuthorId(0),
stamp: OperationStamp::new(HybridLogicalClock::new(WallClockTime(1), 0), id),
causal_context: CausalContext::new(),
transaction: None,
payload: OperationPayload::Primitive(OperationKind::RespellPitch(RespellPitchOp {
pitch: pid,
spelling: override_spelling.clone(),
})),
};
let mut set = OperationSet::new();
set.accept(respell);
let reduced = set.reduce_onto(&score);
let ann = derive_annotations(&reduced.score, &profile());
let rs = ann
.spellings
.get(&pid)
.expect("the respelled pitch is spelled");
assert_eq!(
rs.spelling, override_spelling,
"a reduced RespellPitch must be honored by derive_annotations (not lost before the pre-pass)"
);
assert_eq!(
rs.provenance,
SpellingProvenance::Authored(SpellingSourceKind::UserChosen),
"a reduced RespellPitch should resolve with manual-override precedence"
);
assert!(
check_invariants(&reduced.score).is_empty(),
"the reduced score carrying the surfaced override is invariant-clean"
);
}
// ===========================================================================
// Spelling correctness vs. published tonal expectations
// ===========================================================================
@ -674,8 +745,10 @@ pub fn run_all(scale: u64) {
assert_decompositions_reconstruct(&score, &ann);
}
// Authored-override precedence.
// Authored-override precedence (hand-pushed attachment) ...
assert_respell_precedence();
// ... and a real reduced RespellPitch surfaced through the K↔H seam.
assert_reduced_respell_is_honored();
// Spelling correctness on published tonal cases.
assert_spelling_matches_published_expectations();

Binary file not shown.

View File

@ -652,24 +652,43 @@ plus a non-vacuity guard.
\chapter{K1 --- Framework Slots (Phase 3)}
\label{ch:k1}
The following K0 catalogue bullet items are \emph{drafted} here as framework
slots and completed in Phase~3. Under the Phase-2 profile they are
\textbf{unavailable}: an implementation \MUST{} reject an operation of one of
these kinds. Each is a schema-fill against the template of
Chapter~\ref{ch:framework}; adding one is not a fresh design.
This chapter drafted the remaining catalogue items as framework slots. Since the
Phase-2 \textbf{M2} expansion (the broad-K0 groups in \texttt{epiphany-ops}),
several have been \emph{implemented} and are available under the Phase-2 profile;
their per-primitive \S{}schema joins Chapter~\ref{ch:k0} with the M2e catalogue
expansion. The genuinely Phase-3 slots that remain \textbf{unavailable} are listed
second: an implementation \MUST{} reject an operation of one of \emph{those} kinds
(and \MUST{} \emph{not} reject the implemented kinds below). Each remaining slot is
a schema-fill against the template of Chapter~\ref{ch:framework}; adding one is not
a fresh design.
\section*{Implemented since M2 (available under the Phase-2 profile)}
\begin{description}
\item[Create score / canvas / region / staff / staff instance / voice]
Structural mint operations. Discipline: set-union creation
(Section~\ref{sec:k0:create-cross-cutting}); undo tombstones the mint;
re-anchoring is not applicable.
\item[ModifyEvent]
Field overwrite on an event's non-identity fields (articulations, dynamics,
stem). Discipline: last-writer-wins with structural-field-collision
(Section~\ref{sec:k0:respell-pitch}).
\item[Modify event; transpose]
Field overwrite on an event's non-identity fields, and an order-dependent
chromatic transpose (M2 Group~1). Discipline: last-writer-wins with
structural-field-collision (Section~\ref{sec:k0:respell-pitch}).
\item[Insert / delete / modify identified pitch]
Pitch-level mint, tombstone, and field overwrite within an event. Disciplines
as for the event-level analogues.
Pitch-level mint, tombstone, and field overwrite within an event (M2
Group~1). Disciplines as for the event-level analogues.
\item[Delete / update tie / slur / beam / spanner]
Cross-cutting tombstone and field overwrite (M2 Group~2). Disciplines:
delete-wins with re-anchoring (Section~\ref{sec:k0:delete-event}) and field
overwrite.
\item[Create / delete region / staff instance / voice]
Structural container mint and \emph{empty-only} delete (M2 Group~3).
Disciplines: set-union creation (Section~\ref{sec:k0:create-cross-cutting})
and a delete-wins tombstone that is a precondition no-op unless the container
has no live children.
\end{description}
\section*{Remaining framework slots (Phase 3 --- unavailable, MUST reject)}
\begin{description}
\item[Create score / canvas / staff]
The remaining structural mints (the document root, the canvas, and global
staves) the Phase-2 slice does not exercise. Discipline: set-union creation.
\item[Set metadata (title / composer / lyricist / copyright)]
Field overwrite on score metadata. Discipline: LWW advisory
(Section~\ref{sec:k0:set-user-system-break}).
@ -677,10 +696,7 @@ Chapter~\ref{ch:framework}; adding one is not a fresh design.
Structural field overwrite on a region's metric model. Discipline:
last-writer-wins, with a structural-field-collision on concurrent differing
grids.
\item[Delete / update tie / slur / beam / spanner]
Cross-cutting tombstone and field overwrite. Disciplines: delete-wins with
re-anchoring (Section~\ref{sec:k0:delete-event}) and field overwrite.
\item[Set layout / system-break advisory]
\item[Set layout / system- and page-break advisory]
The page/layout advisory companion to
Section~\ref{sec:k0:set-user-system-break}.
\end{description}