From 93f3dfde93389e500db2ce7d0ad55931f1a26103 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 7 Jul 2026 11:32:05 -0400 Subject: [PATCH] G-pass follow-up: unsupported pre-pass algorithm ids error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-commit review finding: req:pitch:spelling-algorithm / req:time:decomposition-algorithm ratified MUST-error for unregistered algorithm ids, but derive_annotations kept the pre-ratification derive-nothing-under-honest-profile behavior (and a test locked it). The spec text stands; the code moves: derive_annotations returns Result, rejecting unregistered ids up front — a silently-empty derivation is indistinguishable from a legitimately empty score, and would silently disagree with an implementation that does support the requested id. All production callers use the default profile (.expect); the stale lock test is rewritten as unknown_algorithm_ids_error; PrePassError re-exported. Full gate green: fmt, clippy -D warnings, rustdoc -D warnings, 30 workspace suites, conformance scale 1 (8/8). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd --- crates/epiphany-core/DECISIONS.md | 19 +++ crates/epiphany-core/src/lib.rs | 3 +- crates/epiphany-core/src/prepass.rs | 80 +++++++++--- crates/epiphany-core/src/prepass/tests.rs | 115 +++++++++--------- crates/epiphany-editor-core/src/lib.rs | 3 +- crates/epiphany-layout-ir/src/logical.rs | 3 +- crates/epiphany-ops/tests/graph_reduction.rs | 3 +- crates/epiphany-testkit/src/corpus.rs | 3 +- .../epiphany-testkit/src/prepass_harness.rs | 40 ++++-- spec/PASS12_RATIFICATION_LOG.md | 10 ++ 10 files changed, 191 insertions(+), 88 deletions(-) diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 65bb1cd..7c8c344 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -438,3 +438,22 @@ code tranche (authored-only resolution paths + distinct taxonomy buckets in both pre-passes). Also ratified here: system-derived intrinsic content is immutable under reduction (P12-K3; core Ch5 states it, the catalog pins the precondition, `epiphany-ops` implements). + +### G-pass follow-up (2026-07-07): unsupported algorithm ids now ERROR + +A post-commit review found the ratified requirement and the implementation +disagreeing: `req:pitch:spelling-algorithm` / `req:time:decomposition-algorithm` +say a profile requesting an unregistered id MUST **error**, but +`derive_annotations` still implemented the pre-ratification behavior (that +pre-pass "derives nothing" while the requested id stays in the result profile +— the honest-cache-key rationale), and a test locked it. The MUST-error +contract is the right one and stands: a silently-empty derivation is +indistinguishable from a legitimately empty score, and an implementation that +*does* support the requested algorithm would produce real annotations while +this one quietly produced none — two "successful" results that disagree. +Fixed: `derive_annotations` returns `Result` +(`UnsupportedSpellingAlgorithm` / `UnsupportedDecompositionAlgorithm`, +rejected up front); every production caller uses the default profile and +`.expect`s; the stale test is rewritten as `unknown_algorithm_ids_error`. +CONFORMANCE.md's long-standing "errors" claim is now true rather than +aspirational. diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index 465865d..a3dea70 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -87,7 +87,8 @@ pub use pitch::{ pub use prepass::{ derive_annotations, resolve_decomposition, resolve_spelling, simplest_spelling, - DerivedAnnotations, PrePassProfile, ResolvedSpelling, SpellingProvenance, TaxonomyReport, + DerivedAnnotations, PrePassError, PrePassProfile, ResolvedSpelling, SpellingProvenance, + TaxonomyReport, }; pub use event::{ diff --git a/crates/epiphany-core/src/prepass.rs b/crates/epiphany-core/src/prepass.rs index acc5655..5156ec7 100644 --- a/crates/epiphany-core/src/prepass.rs +++ b/crates/epiphany-core/src/prepass.rs @@ -245,11 +245,71 @@ impl DerivedAnnotations { // Entry point // =========================================================================== +/// Why a derivation was refused: the profile names an algorithm this +/// implementation does not provide. Ratified Pass 12 +/// (`req:pitch:spelling-algorithm`, `req:time:decomposition-algorithm`): a +/// profile requesting an unregistered identifier MUST **error** — neither +/// silently substituting the default nor returning an empty derivation a +/// caller cannot distinguish from a legitimately empty score. Erroring also +/// fails loudly where silence would diverge: an implementation that *does* +/// support the requested algorithm produces real annotations, so a quietly +/// empty result here would disagree with it while both looked successful. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum PrePassError { + /// The profile's spelling algorithm id is not a registered algorithm. + UnsupportedSpellingAlgorithm(SpellingAlgorithmId), + /// The profile's decomposition algorithm id is not a registered algorithm. + UnsupportedDecompositionAlgorithm(DecompositionAlgorithmId), +} + +impl std::fmt::Display for PrePassError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PrePassError::UnsupportedSpellingAlgorithm(id) => { + write!(f, "unsupported spelling algorithm id {:?}", id.as_str()) + } + PrePassError::UnsupportedDecompositionAlgorithm(id) => { + write!( + f, + "unsupported decomposition algorithm id {:?}", + id.as_str() + ) + } + } + } +} + +impl std::error::Error for PrePassError {} + /// Computes the [`DerivedAnnotations`] for `score` under `profile`. Pure and /// deterministic: byte-identical `(score, profile)` yields byte-identical /// output, independent of iteration incidentals (the derivation walks the graph /// in canonical id/position order). -pub fn derive_annotations(score: &Score, profile: &PrePassProfile) -> DerivedAnnotations { +/// +/// # Errors +/// +/// A profile naming an algorithm this implementation does not register errors +/// with [`PrePassError`] (ratified Pass 12; see the type's documentation) — +/// nothing is silently substituted, and no empty-but-successful derivation is +/// produced. The default profile always succeeds. +pub fn derive_annotations( + score: &Score, + profile: &PrePassProfile, +) -> Result { + // The pre-passes implement exactly one algorithm each (the registered + // `"default"` id); any other id refuses up front per the ratified + // requirement. + if profile.spelling_algorithm != SpellingAlgorithmId::default_id() { + return Err(PrePassError::UnsupportedSpellingAlgorithm( + profile.spelling_algorithm.clone(), + )); + } + if profile.decomposition_algorithm != DecompositionAlgorithmId::default_id() { + return Err(PrePassError::UnsupportedDecompositionAlgorithm( + profile.decomposition_algorithm.clone(), + )); + } + let mut taxonomy = TaxonomyReport::default(); // Index regions: event -> (is the owning region metric?), and the event's @@ -257,19 +317,9 @@ 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 mut spellings = BTreeMap::new(); - if spelling_supported { + { let inferred = infer_spellings(score, &layout, &mut taxonomy); let precedence = &score.spelling_precedence; for (pid, inferred_spelling) in inferred { @@ -313,7 +363,7 @@ pub fn derive_annotations(score: &Score, profile: &PrePassProfile) -> DerivedAnn // --- Decomposition pre-pass. --- let mut decompositions = BTreeMap::new(); - if decomposition_supported { + { let inferred = infer_decompositions(score, &layout, &mut taxonomy); for (eid, inferred_attachment) in inferred { let resolved = resolve_decomposition(score, eid, inferred_attachment); @@ -342,12 +392,12 @@ pub fn derive_annotations(score: &Score, profile: &PrePassProfile) -> DerivedAnn } } - DerivedAnnotations { + Ok(DerivedAnnotations { spellings, decompositions, taxonomy, profile: profile.clone(), - } + }) } // =========================================================================== diff --git a/crates/epiphany-core/src/prepass/tests.rs b/crates/epiphany-core/src/prepass/tests.rs index 6b478d9..b91c53d 100644 --- a/crates/epiphany-core/src/prepass/tests.rs +++ b/crates/epiphany-core/src/prepass/tests.rs @@ -564,7 +564,8 @@ fn derive_counts_sub_sixtyfourth_event_as_ungriddable() { (vec![ev], vec![]) }); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!(ann.spellings.len(), 1, "pitch still spells"); assert_eq!( ann.decompositions.len(), @@ -598,7 +599,8 @@ fn off_grid_position_is_ungriddable_not_downbeat_aligned() { (vec![ev], vec![]) }); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!(ann.spellings.len(), 1, "pitch still spells"); assert_eq!( ann.decompositions.len(), @@ -653,7 +655,8 @@ fn derive_spells_and_decomposes_a_simple_metric_score() { (events, vec![]) }); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); // Every pitch got a spelling; every event a single-quarter decomposition. assert_eq!(ann.spellings.len(), 4); assert_eq!(ann.decompositions.len(), 4); @@ -705,7 +708,8 @@ fn taxonomy_classifies_each_kind_and_counts_ineligible_explicitly() { (vec![p, rst, unp, ji], vec![]) }); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); let t = &ann.taxonomy; assert_eq!(t.pitched_events, 2); assert_eq!(t.rest_events, 1); @@ -793,7 +797,8 @@ fn nonmetric_region_defers_decomposition_but_still_spells() { ..Default::default() }; - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!(ann.spellings.len(), 1, "pitch is still spelled"); assert_eq!( ann.decompositions.len(), @@ -962,8 +967,10 @@ fn derivation_is_deterministic_across_runs() { }) }; // Same identity seed → identical scores → identical annotations, twice. - let a = derive_annotations(&build(), &PrePassProfile::default()); - let b = derive_annotations(&build(), &PrePassProfile::default()); + let a = derive_annotations(&build(), &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); + let b = derive_annotations(&build(), &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!(a, b); } @@ -999,7 +1006,8 @@ fn tuplet_member_event_decomposes_with_tuplet_membership() { (events, vec![tuplet]) }); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!(ann.decompositions.len(), 3); for dec in ann.decompositions.values() { assert_eq!(dec.components.len(), 1); @@ -1110,7 +1118,8 @@ fn authored_decomposition_outranks_the_inferred_default() { let authored = tied_quarters(e1, DecompositionSource::UserChosen); score.decomposition_attachments.push(authored.clone()); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); // The authored attachment is the effective decomposition for its event; no // derived (single-half-note) decomposition shadows it. assert_eq!(ann.decompositions[&e1], authored, "authored override wins"); @@ -1139,7 +1148,8 @@ fn inferred_source_attachment_does_not_outrank_the_prepass() { .decomposition_attachments .push(tied_quarters(e1, DecompositionSource::Inferred)); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); let dec = &ann.decompositions[&e1]; assert_eq!(dec.components.len(), 1, "the pre-pass's half note stands"); assert_eq!(dec.components[0].base_value, NoteValue::Half); @@ -1184,7 +1194,8 @@ fn authored_decomposition_surfaces_for_an_ungriddable_event_p12_h7() { }; score.decomposition_attachments.push(authored.clone()); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!( ann.decompositions[&eid], authored, "the authored attachment surfaces on its own" @@ -1243,7 +1254,8 @@ fn inferred_source_attachment_does_not_surface_for_ineligible_events_p12_h7() { source: DecompositionSource::Inferred, }); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert!( !ann.decompositions.contains_key(&eid), "an Inferred-source attachment does not surface" @@ -1300,7 +1312,8 @@ fn authored_spelling_surfaces_for_an_unavailable_pitch_p12_h7() { layer: None, }); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); let resolved = ann .spellings .get(&pid) @@ -1319,7 +1332,8 @@ fn authored_spelling_surfaces_for_an_unavailable_pitch_p12_h7() { !ann.spellings.contains_key(&dangling), "an attachment on an absent pitch surfaces nothing" ); - let again = derive_annotations(&score, &PrePassProfile::default()); + let again = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!( ann.canonical_fingerprint(), again.canonical_fingerprint(), @@ -1341,7 +1355,8 @@ fn decomposition_precedence_ranks_sources_then_canonical_order() { let user = tied_quarters(e1, DecompositionSource::UserChosen); score.decomposition_attachments.push(imported); // listed first — must lose score.decomposition_attachments.push(user.clone()); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!( ann.decompositions[&e1], user, "UserChosen outranks Imported regardless of attachment order" @@ -1355,7 +1370,8 @@ fn decomposition_precedence_ranks_sources_then_canonical_order() { let second = dotted_quarter_eighth(f1, DecompositionSource::UserChosen); score2.decomposition_attachments.push(first.clone()); score2.decomposition_attachments.push(second); - let ann2 = derive_annotations(&score2, &PrePassProfile::default()); + let ann2 = derive_annotations(&score2, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!( ann2.decompositions[&f1], first, "rank ties keep the earlier attachment in canonical order" @@ -1374,13 +1390,16 @@ fn derivation_with_authored_decomposition_is_deterministic() { .push(tied_quarters(e1, DecompositionSource::UserChosen)); score }; - let a = derive_annotations(&build(), &PrePassProfile::default()); - let b = derive_annotations(&build(), &PrePassProfile::default()); + let a = derive_annotations(&build(), &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); + let b = derive_annotations(&build(), &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!(a, b); assert_eq!(a.canonical_fingerprint(), b.canonical_fingerprint()); let (plain, _, _) = two_half_note_score(); - let c = derive_annotations(&plain, &PrePassProfile::default()); + let c = derive_annotations(&plain, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_ne!( a.canonical_fingerprint(), c.canonical_fingerprint(), @@ -1417,7 +1436,8 @@ fn authored_attachment_on_an_ungriddable_event_surfaces_p12_h7() { let authored = tied_quarters(ids[0], DecompositionSource::UserChosen); score.decomposition_attachments.push(authored.clone()); - let ann = derive_annotations(&score, &PrePassProfile::default()); + let ann = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); assert_eq!( ann.decompositions.get(&ids[0]), Some(&authored), @@ -1430,10 +1450,14 @@ fn authored_attachment_on_an_ungriddable_event_surfaces_p12_h7() { } #[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. +fn unknown_algorithm_ids_error() { + // Ratified Pass 12 (`req:pitch:spelling-algorithm`, + // `req:time:decomposition-algorithm`): a profile requesting an algorithm + // this implementation does not register MUST error — neither silently + // substituting the default nor returning an empty-but-successful + // derivation a caller cannot tell apart from a legitimately empty score. + // (This supersedes the pre-ratification "derives nothing under the + // requested profile" behavior this test previously locked.) let score = metric_score(|idc, voice| { let eid = idc.mint(); let pid = idc.mint::(); @@ -1450,54 +1474,35 @@ fn unknown_algorithm_ids_are_not_honored() { (vec![ev], vec![]) }); - let default = derive_annotations(&score, &PrePassProfile::default()); + let default = derive_annotations(&score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); 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" + derive_annotations(&score, &unknown_spelling), + Err(PrePassError::UnsupportedSpellingAlgorithm( + SpellingAlgorithmId::new("future-v2") + )), + "an unknown spelling algorithm errors; nothing is substituted" ); - // 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" + derive_annotations(&score, &unknown_decomp), + Err(PrePassError::UnsupportedDecompositionAlgorithm( + DecompositionAlgorithmId::new("future-v2") + )), + "an unknown decomposition algorithm errors; nothing is substituted" ); } diff --git a/crates/epiphany-editor-core/src/lib.rs b/crates/epiphany-editor-core/src/lib.rs index e2c9f46..493d276 100644 --- a/crates/epiphany-editor-core/src/lib.rs +++ b/crates/epiphany-editor-core/src/lib.rs @@ -1458,7 +1458,8 @@ impl EditorSession { /// inferred respelling is accounted for. Falls back to a note's raw pitch position /// when it has no resolved CMN spelling. `None` if the event has no CMN note. fn rendered_top_of_event(&self, event: EventId) -> Option<(Pitch, i64)> { - let annotations = derive_annotations(&self.score, &PrePassProfile::default()); + let annotations = derive_annotations(&self.score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); let mut buf: Vec<&IdentifiedPitch> = Vec::new(); self.score .events diff --git a/crates/epiphany-layout-ir/src/logical.rs b/crates/epiphany-layout-ir/src/logical.rs index 26a4034..d87b857 100644 --- a/crates/epiphany-layout-ir/src/logical.rs +++ b/crates/epiphany-layout-ir/src/logical.rs @@ -409,7 +409,8 @@ pub fn to_logical(score: &Score) -> LogicalLayoutIR { // (Agent H's pre-pass): which notehead a note draws, where its pitches sit, // and which accidentals its spelling carries. Recomputed deterministically // from the score with the default profile. - let annotations = derive_annotations(score, &PrePassProfile::default()); + let annotations = derive_annotations(score, &PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); // The last region index that manifests each staff, so a measure can tell a // mid-staff region boundary (continuation) from the true final barline. let mut staff_last_region: BTreeMap = BTreeMap::new(); diff --git a/crates/epiphany-ops/tests/graph_reduction.rs b/crates/epiphany-ops/tests/graph_reduction.rs index 1b9837f..d52d150 100644 --- a/crates/epiphany-ops/tests/graph_reduction.rs +++ b/crates/epiphany-ops/tests/graph_reduction.rs @@ -3031,7 +3031,8 @@ fn a_mid_region_meter_change_reduces_cleanly_p12_c5() { assert!(check_invariants(&result.score).is_empty()); // The pre-pass tolerates the multi-meter grid (P12-H4 owns honoring it). let _ = - epiphany_core::derive_annotations(&result.score, &epiphany_core::PrePassProfile::default()); + epiphany_core::derive_annotations(&result.score, &epiphany_core::PrePassProfile::default()) + .expect("the default pre-pass algorithms are supported"); } #[test] diff --git a/crates/epiphany-testkit/src/corpus.rs b/crates/epiphany-testkit/src/corpus.rs index 57184d7..5c064b4 100644 --- a/crates/epiphany-testkit/src/corpus.rs +++ b/crates/epiphany-testkit/src/corpus.rs @@ -1231,7 +1231,8 @@ pub fn classify_corpus() -> CorpusReport { ); // 2. Derive H's annotations + taxonomy. - let ann = derive_annotations(&score, &profile); + let ann = derive_annotations(&score, &profile) + .expect("the default pre-pass algorithms are supported"); let t = &ann.taxonomy; // 3. Cross-check H's event-kind counts against an independent walk. diff --git a/crates/epiphany-testkit/src/prepass_harness.rs b/crates/epiphany-testkit/src/prepass_harness.rs index 50739f1..ecc474a 100644 --- a/crates/epiphany-testkit/src/prepass_harness.rs +++ b/crates/epiphany-testkit/src/prepass_harness.rs @@ -56,8 +56,10 @@ pub fn fingerprint(ann: &DerivedAnnotations) -> Vec { /// The same score derives identically twice (pure function), by structural /// equality and by fingerprint. pub fn assert_derivation_deterministic(score: &Score) { - let a = derive_annotations(score, &profile()); - let b = derive_annotations(score, &profile()); + let a = derive_annotations(score, &profile()) + .expect("the default pre-pass algorithms are supported"); + let b = derive_annotations(score, &profile()) + .expect("the default pre-pass algorithms are supported"); assert_eq!(a, b, "derivation is not a pure function of the score"); assert_eq!( fingerprint(&a), @@ -71,8 +73,10 @@ pub fn assert_derivation_deterministic(score: &Score) { /// pre-pass annotations" property at corpus scale. pub fn assert_corpus_deterministic() { for f in corpus::corpus() { - let a = derive_annotations(&(f.build)(), &profile()); - let b = derive_annotations(&(f.build)(), &profile()); + let a = derive_annotations(&(f.build)(), &profile()) + .expect("the default pre-pass algorithms are supported"); + let b = derive_annotations(&(f.build)(), &profile()) + .expect("the default pre-pass algorithms are supported"); assert_eq!(a, b, "fixture `{}` derivation not deterministic", f.name); assert_eq!( fingerprint(&a), @@ -280,7 +284,8 @@ pub fn assert_respell_precedence() { let replica = score.identity.replica_id; // Baseline: no override → the algorithm's inferred spelling stands. - let base = derive_annotations(&score, &profile()); + let base = derive_annotations(&score, &profile()) + .expect("the default pre-pass algorithms are supported"); let base_rs = base.spellings.get(&pid).expect("probe pitch is spelled"); assert!( matches!(base_rs.provenance, SpellingProvenance::Inferred), @@ -304,7 +309,8 @@ pub fn assert_respell_precedence() { priority: 0, layer: None, }); - let ann2 = derive_annotations(&s2, &profile()); + let ann2 = + derive_annotations(&s2, &profile()).expect("the default pre-pass algorithms are supported"); let rs2 = ann2 .spellings .get(&pid) @@ -332,7 +338,8 @@ pub fn assert_respell_precedence() { priority: 0, layer: Some(AnalysisLayerId::new(replica, 1)), }); - let ann3 = derive_annotations(&s3, &profile()); + let ann3 = + derive_annotations(&s3, &profile()).expect("the default pre-pass algorithms are supported"); let rs3 = ann3.spellings.get(&pid).expect("pitch is spelled"); assert_eq!( rs3.spelling, inferred, @@ -375,7 +382,8 @@ pub fn assert_respell_precedence() { priority: 9, // second, higher priority — must win layer: None, }); - let ann4 = derive_annotations(&s4, &profile()); + let ann4 = + derive_annotations(&s4, &profile()).expect("the default pre-pass algorithms are supported"); let rs4 = ann4.spellings.get(&pid).expect("pitch is spelled"); assert_eq!( rs4.spelling, fs4, @@ -400,7 +408,8 @@ pub fn assert_respell_precedence() { priority: 100, layer: None, }); - let ann5 = derive_annotations(&s5, &profile()); + let ann5 = + derive_annotations(&s5, &profile()).expect("the default pre-pass algorithms are supported"); let rs5 = ann5.spellings.get(&pid).expect("pitch is spelled"); assert_eq!( rs5.spelling, inferred, @@ -428,6 +437,7 @@ pub fn assert_reduced_respell_is_honored() { // Inferred baseline (no override). let inferred = derive_annotations(&score, &profile()) + .expect("the default pre-pass algorithms are supported") .spellings .get(&pid) .expect("probe pitch is spelled") @@ -463,7 +473,8 @@ pub fn assert_reduced_respell_is_honored() { set.accept(respell); let reduced = set.reduce_onto(&score); - let ann = derive_annotations(&reduced.score, &profile()); + let ann = derive_annotations(&reduced.score, &profile()) + .expect("the default pre-pass algorithms are supported"); let rs = ann .spellings .get(&pid) @@ -513,7 +524,8 @@ fn build_named(name: &str) -> Score { /// `BTreeMap` value order is melodic), the inferred spellings match the expected /// `(nominal, accidental)` sequence. fn assert_line(name: &str, expected: &[(CmnNominal, &str)]) { - let ann = derive_annotations(&build_named(name), &profile()); + let ann = derive_annotations(&build_named(name), &profile()) + .expect("the default pre-pass algorithms are supported"); let got: Vec<(CmnNominal, String)> = ann .spellings .values() @@ -739,7 +751,8 @@ pub fn assert_deterministic_in_materialization_pipeline(scale: u64) { seed.wrapping_mul(0x9E37_79B9).wrapping_add(101), ); assert_derivation_deterministic(&score); - let ann = derive_annotations(&score, &profile()); + let ann = derive_annotations(&score, &profile()) + .expect("the default pre-pass algorithms are supported"); if !ann.spellings.is_empty() { spelled_anywhere = true; } @@ -767,7 +780,8 @@ pub fn run_all(scale: u64) { // Eligibility + reconstruction over every fixture. for f in corpus::corpus() { let score = (f.build)(); - let ann = derive_annotations(&score, &profile()); + let ann = derive_annotations(&score, &profile()) + .expect("the default pre-pass algorithms are supported"); assert_eligible_pitches_spelled(&score, &ann); assert_decompositions_reconstruct(&score, &ann); } diff --git a/spec/PASS12_RATIFICATION_LOG.md b/spec/PASS12_RATIFICATION_LOG.md index 95d6655..adf36ad 100644 --- a/spec/PASS12_RATIFICATION_LOG.md +++ b/spec/PASS12_RATIFICATION_LOG.md @@ -167,3 +167,13 @@ references. buckets (`prepass.rs`); K3 `SystemDerivedContentImmutable` (12); K9 `RecreateContentMismatch` (13); C4 `SameCanvasNearer` (6) — each with regression tests and wire goldens. + +### G-pass addendum (2026-07-07): pre-pass error contract aligned + +A post-commit review caught a spec↔code disagreement introduced by the H1/H4 +ratifications: the requirement text (MUST error on an unregistered algorithm +id) had been adopted from CONFORMANCE.md's claim, but the implementation still +returned a successful empty derivation under the requested profile. Resolution: +the **spec text stands**; the code moved — `derive_annotations` now returns +`Result` and rejects unregistered ids up front (`PrePassError`). No spec +change; core DECISIONS records the reasoning.