diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index b9bf4a6..f30e707 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -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)] diff --git a/crates/epiphany-core/src/prepass.rs b/crates/epiphany-core/src/prepass.rs index 92fb390..8cbf847 100644 --- a/crates/epiphany-core/src/prepass.rs +++ b/crates/epiphany-core/src/prepass.rs @@ -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 { + use crate::CanonicalValue; + let mut out = Vec::new(); + let lp = |out: &mut Vec, 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, diff --git a/crates/epiphany-core/src/prepass/tests.rs b/crates/epiphany-core/src/prepass/tests.rs index b683bf9..f2cf4b4 100644 --- a/crates/epiphany-core/src/prepass/tests.rs +++ b/crates/epiphany-core/src/prepass/tests.rs @@ -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::(); + 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" + ); +} diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 93821fd..a032b33 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -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; }; diff --git a/crates/epiphany-testkit/src/prepass_harness.rs b/crates/epiphany-testkit/src/prepass_harness.rs index 0c593d6..f02d980 100644 --- a/crates/epiphany-testkit/src/prepass_harness.rs +++ b/crates/epiphany-testkit/src/prepass_harness.rs @@ -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 { + 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(); diff --git a/spec/operation_catalog.pdf b/spec/operation_catalog.pdf index e5734dd..573c0a4 100644 Binary files a/spec/operation_catalog.pdf and b/spec/operation_catalog.pdf differ diff --git a/spec/operation_catalog.tex b/spec/operation_catalog.tex index a873ae5..db0dabf 100644 --- a/spec/operation_catalog.tex +++ b/spec/operation_catalog.tex @@ -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}