From 56950f8d7c7b2a1fd16d026fb3692d691b5a3357 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 24 Jul 2026 10:17:46 -0400 Subject: [PATCH] Push 4b: ji-adaptive-5limit resolves, and the blocker was never HarmonicContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last fail-closed entry in the built-in tuning catalog now resolves, leaving only the compatibility-mapping registry open in Push 4b. The recorded blocker was wrong. Five places in tuning.rs claimed adaptive "needs HarmonicContext, which does not exist in Rust". But req:tuning:adaptive-default-version makes version 1 a pure function of (position, anchor pitch class) — it ignores concurrent, recent, hints, parameters, and mode. And two of HarmonicContext's four specified fields are UNIMPLEMENTABLE: key_context and hints are typed on KeyContext / ContextHint, which core_spec.tex:4111 leaves undefined deliberately, "so defining them now would freeze a type surface on a chapter with no consumer". The minimal one-field shape was not a preference; it was the only implementable one. Each remaining field arrives with the first function that consumes it. The real blocker was structural and small: locate_voice returned (RegionId, StaffId) while key_sequence lives on StaffInstance, so the resolver threw away the only object that could answer "what key is prevailing here". No new pitch math. ji_static_5limit_ratios already took a runtime anchor — the three ji-static-5limit-{C,G,D} built-ins are that one function at anchors 0, 7, 2 — so adaptive v1 is that same call with a derived anchor. Verified as an identity: adaptive at anchor 0 is bit-identical to ji-static-5limit-C and at anchor 7 to ji-static-5limit-G, across all seven naturals. A missing tonal centre is NOT an error. core_spec.tex:3452 mandates C (chromatic position 0) when none is supplied, so that is a defined default, not a fail-closed case; a test pins it against a future "fail closed" refactor. Fail-closed applies to exactly two things: an unregistered AdaptiveTuningFunctionId (hard error, no fallback) and a TimeAnchor that cannot be unambiguously ordered against the onset (AnchorNotOrderable, naming the kind that defeated it). If any KeySignatureChange in a sequence is unorderable the whole derivation fails, rather than risk skipping the true prevailing signature. Anchor arithmetic is (7 * fifths).rem_euclid(12), never %: fifths reaches -7 and % yields a negative pitch class. ChromaticPitchClass is a checked 0..=11 newtype, so the sign bug cannot degrade into a silently wrong anchor — under mutation it panics outright. Verified by hand across the whole -7..=7 range, including the enharmonic collisions (6 and -6 both F#/Gb = 6). Nothing reaches the wire: TuningResolution is catalog-computed and appears in neither codec.rs nor textvalue_graph.rs, so TuningResolution::Adaptive and HarmonicContext are in-memory only and schema major 3 is untouched. Zero Codec impls added; no vector or golden moved. Gate: fmt clean, clippy 0, 1311 passed / 0 failed, doc 0, conformance 8/8, requirement labels 6/6 at 212/282/282. Independently verified: the C default, static systems ignoring context, statelessness across reordered resolution, and the 0..=11 invariant. Two mutations killed — rem_euclid -> % and a silent fallback for an unregistered id. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-core/src/lib.rs | 35 +- crates/epiphany-core/src/pitch.rs | 41 ++ crates/epiphany-core/src/tuning.rs | 790 ++++++++++++++++++++++++++--- spec/CONTRACT_PUSH4B_ADAPTIVE.md | 230 +++++++++ 4 files changed, 997 insertions(+), 99 deletions(-) create mode 100644 spec/CONTRACT_PUSH4B_ADAPTIVE.md diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index 5a31cff..522b52d 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -34,10 +34,11 @@ //! no `Codec` impl exists for anything here (Push 4b Ruling C). //! * `tuning` — the Chapter 4 tuning-resolution vocabulary //! ([`TuningSystem`], [`TuningResolution`], [`TuningOverride`], -//! [`TuningScope`]), [`built_in_tuning_system`] (nine of the twenty -//! catalog identifiers; the rest fail closed), and -//! [`resolve_pitch_frequency`], the five-scope resolver from a pitch to a -//! frequency in Hz. In-memory only, same discipline as `pitch_space` +//! [`TuningScope`], [`HarmonicContext`]), [`built_in_tuning_system`] (all +//! twenty catalog identifiers resolve), [`derive_tonal_centre`] (the +//! score-graph-derived adaptive anchor), and [`resolve_pitch_frequency`], +//! the five-scope resolver from a pitch to a frequency in Hz. In-memory +//! only, same discipline as `pitch_space` //! (Push 4b Ruling C). //! * `accidental` — the Chapter 4 accidental/glyph/engraving vocabulary //! ([`AccidentalDefinition`], [`ScoreAccidentalExtensions`], @@ -106,15 +107,16 @@ pub use time::{ pub use pitch::{ canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalGroupId, AccidentalId, - AccidentalRegistryId, AcousticPitch, AcousticRealization, CmnNominal, CustomGlyphId, - DecompositionAlgorithmId, ForeignFormatId, IdentifiedPitch, IntervalAlgebraRegistryId, - ModificationRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition, - PitchSpelling, PositionRegistryId, PositionStructureRegistryId, ReferencePitch, ScalePosition, - SpellingAlgorithmId, SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal, - SpellingPrecedence, SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope, - SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId, - TransposeRefusal, TranspositionInterval, TranspositionRegistryId, TuningFunctionId, - TuningReference, TuningSystemId, VoiceSelector, + AccidentalRegistryId, AcousticPitch, AcousticRealization, AdaptiveTuningFunctionId, + ChromaticPitchClass, CmnNominal, CustomGlyphId, DecompositionAlgorithmId, ForeignFormatId, + IdentifiedPitch, IntervalAlgebraRegistryId, ModificationRegistryId, NominalRegistryId, Pitch, + PitchRange, PitchSpaceId, PitchSpacePosition, PitchSpelling, PositionRegistryId, + PositionStructureRegistryId, ReferencePitch, ScalePosition, SpellingAlgorithmId, + SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal, SpellingPrecedence, + SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope, SpellingSource, + SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId, TransposeRefusal, + TranspositionInterval, TranspositionRegistryId, TuningFunctionId, TuningReference, + TuningSystemId, VoiceSelector, }; pub use pitch_space::{ @@ -167,9 +169,10 @@ pub use tempo::{ }; pub use tuning::{ - built_in_tuning_system, frequency_for_position, resolve_pitch_frequency, resolve_tuning_scope, - PositionRatio, ResolvedTuning, TuningCatalogEntry, TuningOverride, TuningParameters, - TuningResolution, TuningResolutionError, TuningScope, TuningSystem, + built_in_tuning_system, derive_tonal_centre, frequency_for_position, resolve_pitch_frequency, + resolve_tuning_scope, HarmonicContext, PositionRatio, ResolvedTuning, TuningCatalogEntry, + TuningOverride, TuningParameters, TuningResolution, TuningResolutionError, TuningScope, + TuningSystem, }; pub use codec::{CanonicalValue, ScoreDecodeError}; diff --git a/crates/epiphany-core/src/pitch.rs b/crates/epiphany-core/src/pitch.rs index 3349df1..1bfc56c 100644 --- a/crates/epiphany-core/src/pitch.rs +++ b/crates/epiphany-core/src/pitch.rs @@ -91,6 +91,18 @@ catalog_id!( /// registry for) and fails closed. TuningFunctionId ); +catalog_id!( + /// Identifies a registered adaptive tuning function, consumed by + /// [`crate::tuning::TuningResolution::Adaptive`] (Chapter 4 §"Adaptive + /// Tuning", `req:tuning:adaptive-default-version`). The built-in catalog + /// entry `ji-adaptive-5limit` is bound to `"default-v1"` — the version + /// lives *inside* the identifier string, not as prose beside it, so a + /// future version 2 would mint a new identifier rather than silently + /// changing what `"default-v1"` means. An unregistered id (this tranche + /// registers exactly the one) is a hard error with no silent fallback to + /// a default version. + AdaptiveTuningFunctionId +); catalog_id!( /// Identifies an accidental registry (Chapter 4 §"Accidental Registries"). AccidentalRegistryId @@ -550,6 +562,35 @@ pub struct AcousticPitch { pub realization: AcousticRealization, } +/// A chromatic pitch class in `0..=11` — one of the twelve positions of the +/// `cmn-12` chromatic layer, with no octave. Used where a value must name +/// *which* pitch class rather than a full pitch, such as +/// [`crate::tuning::HarmonicContext`]'s tonal centre +/// (`req:tuning:adaptive-anchor-derivation`). [`Pitch::twelve_tet_class`], +/// just below, already returns a `0..=11` `u8`; this newtype enforces that +/// range in the type itself rather than merely documenting it, following +/// [`crate::graph::KeySignature::new`]'s checked-constructor style — a raw +/// `u8` field a caller could set to `200` is not a chromatic pitch class. +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +pub struct ChromaticPitchClass(u8); + +impl ChromaticPitchClass { + /// Builds a chromatic pitch class, rejecting values outside `0..=11`. + pub const fn new(value: u8) -> Option { + if value <= 11 { + Some(ChromaticPitchClass(value)) + } else { + None + } + } + + /// The underlying `0..=11` value. + #[inline] + pub const fn get(self) -> u8 { + self.0 + } +} + /// A pitch's intrinsic identity: scale position plus acoustic realization /// (Chapter 2 §"The Pitch Type"). Spellings are attached externally. /// diff --git a/crates/epiphany-core/src/tuning.rs b/crates/epiphany-core/src/tuning.rs index 4dc409e..8ade67b 100644 --- a/crates/epiphany-core/src/tuning.rs +++ b/crates/epiphany-core/src/tuning.rs @@ -17,31 +17,37 @@ //! ## What resolves, and what still does not //! //! [`TuningResolution`] is a **six**-variant enum in the specification -//! (`core_spec.tex:3309`); this module defines three — +//! (`core_spec.tex:3309`); this module defines four — //! [`TuningResolution::EqualTemperament`], [`TuningResolution::PerPositionRatios`], -//! and, since Push 4b tranche 2b, [`TuningResolution::Function`] (plus -//! [`PositionRatio`] and the marker [`TuningParameters`]). The other three are -//! transcribed only when a built-in needs them, so their unconstructed payload -//! subtrees (`ImportedTuningData`, `AdaptiveTuningParameters`, …) never become -//! an unconsumed type surface (the `NOTEHEAD_ANCHORS` failure): `Adaptive` -//! waits on `HarmonicContext`, which does not exist in Rust and whose -//! completion `core_spec.tex` puts out of scope; `Overlay` and `Imported` wait -//! on a built-in that needs a split-accidental keyboard or an imported -//! `.scl`/MTS tuning respectively — nothing in the twenty-item catalog -//! constructs either. +//! [`TuningResolution::Function`] (Push 4b tranche 2b), and, since Push 4b's +//! adaptive tranche, [`TuningResolution::Adaptive`] (plus [`PositionRatio`], +//! the marker [`TuningParameters`], and the minimal [`HarmonicContext`]). The +//! other two are transcribed only when a built-in needs them, so their +//! unconstructed payload subtrees (`ImportedTuningData`, …) never become an +//! unconsumed type surface (the `NOTEHEAD_ANCHORS` failure): `Overlay` and +//! `Imported` wait on a built-in that needs a split-accidental keyboard or an +//! imported `.scl`/MTS tuning respectively — nothing in the twenty-item +//! catalog constructs either. //! -//! [`built_in_tuning_system`] resolves **nineteen** of the twenty catalog -//! identifiers (`req:tuning:builtin-tuning-catalog`): the six `tet-*` equal -//! temperaments, the three `ji-static-5limit-*` just-intonation systems, and, -//! since tranche 2b, the ten historical temperaments (`pythagorean`, the three -//! `meantone-*`, `werckmeister-iii`/`-iv`, `vallotti`, `kirnberger-ii`/`-iii`, -//! `young-ii`) — each built from its ratified fifth-tempering construction -//! (`core_spec.tex` §"Temperament Constructions", `:3696`-`4011`) by -//! `temperament_ratios`, never from a pasted cents table. Only -//! `ji-adaptive-5limit` is still a real catalog entry whose resolution this -//! module defers ([`TuningCatalogEntry::Deferred`]) — never a guessed -//! frequency — because it needs `HarmonicContext`, which does not exist in -//! Rust. +//! [`built_in_tuning_system`] resolves **all twenty** catalog identifiers +//! (`req:tuning:builtin-tuning-catalog`): the six `tet-*` equal temperaments, +//! the three `ji-static-5limit-*` just-intonation systems, the ten historical +//! temperaments (`pythagorean`, the three `meantone-*`, +//! `werckmeister-iii`/`-iv`, `vallotti`, `kirnberger-ii`/`-iii`, `young-ii`, +//! Push 4b tranche 2b) — each built from its ratified fifth-tempering +//! construction (`core_spec.tex` §"Temperament Constructions", +//! `:3696`-`4011`) by `temperament_ratios`, never from a pasted cents table — +//! and, finally, `ji-adaptive-5limit`: [`TuningResolution::Adaptive`] bound to +//! the one registered [`crate::pitch::AdaptiveTuningFunctionId`], +//! `"default-v1"`, which is *exactly* `ji_static_5limit_ratios` re-anchored to +//! the harmonic context's tonal centre (C, chromatic position 0, when none is +//! supplied) — the same lattice construction the three static-JI built-ins +//! use, never a second construction or a transcribed cents table. (An earlier +//! draft of this module claimed adaptive resolution "needs `HarmonicContext`, +//! which does not exist in Rust" — that was wrong even at the time: version 1 +//! is a pure function of position and anchor pitch class alone, per +//! `req:tuning:adaptive-default-version`, and needed only the one-field +//! `HarmonicContext` this module now defines.) //! //! ## The compatibility check, narrowed the same way tranche 1 narrowed it //! @@ -55,14 +61,15 @@ use core::num::NonZeroU32; -use crate::graph::{Score, ScoreTuningContext}; +use crate::graph::{KeySignature, Score, ScoreTuningContext, StaffInstance}; use crate::ids::{RegionId, StaffId, VoiceId}; use crate::pitch::{ - AcousticRealization, Pitch, PitchSpaceId, PitchSpacePosition, ReferencePitch, TuningFunctionId, - TuningReference, TuningSystemId, VoiceSelector, + AcousticRealization, AdaptiveTuningFunctionId, ChromaticPitchClass, Pitch, PitchSpaceId, + PitchSpacePosition, ReferencePitch, TuningFunctionId, TuningReference, TuningSystemId, + VoiceSelector, }; use crate::pitch_space::{built_in_position_structure, JiRatio, PositionStructure}; -use crate::time::TimeAnchor; +use crate::time::{EventPosition, MusicalPosition, TimeAnchor}; // =========================================================================== // Types (Chapter 4 §"Tuning Systems" / §"Score Tuning Context and @@ -104,10 +111,36 @@ pub struct PositionRatio { #[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)] pub struct TuningParameters; +/// Harmonic context supplied to adaptive tuning resolution (`core_spec.tex` +/// §"Adaptive Tuning", `:3397-3428`). **Minimal**, not the four-field +/// listing at `core_spec.tex:3411` +/// (`concurrent: Vec`, `recent: Vec<(PitchId, f64)>`, +/// `key_context: Option`, `hints: Vec`). Two of +/// those fields are unimplementable by construction: `key_context` and +/// `hints` are typed on `KeyContext` and `ContextHint`, which the +/// specification leaves undefined on purpose (Forward References, +/// `core_spec.tex:4111`: "defining them now would freeze a type surface on a +/// chapter with no consumer"). The other two, `concurrent` and `recent`, are +/// ignored by version 1 of the one adaptive function this module registers +/// (`req:tuning:adaptive-default-version`: "`concurrent`, `recent`, `hints`, +/// `parameters`, and mode are ignored by version 1") — carrying them here +/// would mint exactly the unconsumed type surface the module doc's +/// `NOTEHEAD_ANCHORS` note warns against. Each field arrives with the first +/// function that actually consumes it. +#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)] +pub struct HarmonicContext { + /// The active tonal centre as a chromatic pitch class (`0..=11`), if + /// known. `None` when no tonal centre could be determined (e.g. + /// [`derive_tonal_centre`] found no applicable key-signature change) — + /// the resolver then defaults to C (`req:tuning:adaptive-default-version`), + /// a defined default, never an error. + pub tonal_centre: Option, +} + /// How a tuning system resolves pitch-space positions to frequencies /// (`core_spec.tex:3309-3348`). **Deliberately partial**: the specification -/// names six variants; this module defines three. See the module doc for -/// which tranche completes each of the other three. +/// names six variants; this module defines four. See the module doc for +/// which tranche completes each of the other two. #[derive(Clone, PartialEq, Eq, Debug)] pub enum TuningResolution { /// N-tone equal temperament: each step is the Nth root of the octave @@ -132,6 +165,17 @@ pub enum TuningResolution { function: TuningFunctionId, parameters: TuningParameters, }, + /// Adaptive resolution: frequency from position *plus* [`HarmonicContext`] + /// (`core_spec.tex:3397-3472`). The one built-in, + /// `"default-v1"` (`req:tuning:adaptive-default-version`), is a pure + /// function of (position, anchor pitch class): the position resolves + /// through `ji_static_5limit_ratios`, transposed so the harmonic + /// context's tonal centre takes the role of `1/1` (C, chromatic position + /// 0, when no context or no tonal centre is supplied — a defined + /// default, not a fallback). An unregistered `function` is a hard error + /// with no silent fallback, exactly as an unreserved `Function` + /// [`TuningFunctionId`] fails closed above. + Adaptive { function: AdaptiveTuningFunctionId }, } /// A tuning system: a map from pitch-space positions to frequencies, given a @@ -180,12 +224,16 @@ pub struct TuningOverride { /// A built-in catalog lookup result: a real, resolved [`TuningSystem`], or a /// real catalog identifier (`req:tuning:builtin-tuning-catalog` still -/// requires it to resolve *eventually*) whose resolution this tranche -/// defers. Distinguishing this from "not a built-in identifier at all" -/// ([`built_in_tuning_system`] returning `None`) is what lets +/// requires it to resolve *eventually*) whose resolution some tranche has +/// not yet built. Distinguishing this from "not a built-in identifier at +/// all" ([`built_in_tuning_system`] returning `None`) is what lets /// [`resolve_pitch_frequency`] report a genuinely unknown identifier and a /// known-but-deferred one differently, per the contract's "a clear 'not yet -/// supported' error, never a fallback frequency." +/// supported' error, never a fallback frequency." As of Push 4b's adaptive +/// tranche, [`built_in_tuning_system`] constructs no `Deferred` entry — all +/// twenty catalog identifiers resolve — but the variant stays: a future +/// built-in (the 21st) could still land as a real, honestly-deferred entry +/// rather than a silent guess. #[derive(Clone, PartialEq, Eq, Debug)] pub enum TuningCatalogEntry { Resolved(TuningSystem), @@ -196,7 +244,7 @@ pub enum TuningCatalogEntry { /// Looks up a built-in [`TuningSystem`] (Chapter 4 §"Built-in Catalog", /// `core_spec.tex:3656-3694`, `req:tuning:builtin-tuning-catalog`). /// -/// Nineteen of the twenty resolve: +/// All twenty resolve: /// /// * the six `tet-*` — [`TuningResolution::EqualTemperament`] from the /// identifier's divisions. `tet-12` pairs with `cmn-12` (the default @@ -215,13 +263,13 @@ pub enum TuningCatalogEntry { /// built by `temperament_ratios` from its ratified fifth-tempering /// construction (`core_spec.tex` §"Temperament Constructions", /// `:3696`-`4011`), over `cmn-12`'s twelve chromatic positions exactly as -/// the static-JI systems are. -/// -/// The remaining one is a real catalog entry whose resolution is deferred, -/// not guessed ([`TuningCatalogEntry::Deferred`]): -/// -/// * `ji-adaptive-5limit` — needs `HarmonicContext` -/// (`req:tuning:adaptive-default-version`), which does not exist in Rust. +/// the static-JI systems are; +/// * `ji-adaptive-5limit` — [`TuningResolution::Adaptive`] bound to the one +/// registered [`AdaptiveTuningFunctionId`], `"default-v1"` +/// (`req:tuning:adaptive-default-version`), over `cmn-12`'s twelve +/// chromatic positions exactly as the static-JI systems. Resolving it to a +/// frequency needs a harmonic context (or defaults to C without one) — see +/// [`resolve_pitch_frequency`]. /// /// `None` for any other identifier: not one of the twenty at all. pub fn built_in_tuning_system(id: &TuningSystemId) -> Option { @@ -275,8 +323,26 @@ pub fn built_in_tuning_system(id: &TuningSystemId) -> Option description: Some(desc.to_owned()), }) } - const DEFERRED_ADAPTIVE: &str = - "adaptive tuning needs HarmonicContext, which does not exist in Rust (out of scope this tranche)"; + /// `ji-adaptive-5limit`'s catalog entry: [`TuningResolution::Adaptive`] + /// bound to the one registered [`AdaptiveTuningFunctionId`], + /// `"default-v1"` (`req:tuning:adaptive-default-version`), over + /// `cmn-12`'s twelve chromatic positions exactly as the static-JI + /// systems. + fn ji_adaptive() -> TuningCatalogEntry { + TuningCatalogEntry::Resolved(TuningSystem { + id: TuningSystemId::new("ji-adaptive-5limit"), + name: "ji-adaptive-5limit".to_owned(), + pitch_space: PitchSpaceId::new("cmn-12"), + resolution: TuningResolution::Adaptive { + function: AdaptiveTuningFunctionId::new("default-v1"), + }, + description: Some( + "Adaptive 5-limit just intonation: the static 5-limit construction, \ + re-anchored per resolution to the prevailing tonal centre (C by default)." + .to_owned(), + ), + }) + } match id.as_str() { "tet-12" => Some(tet( "tet-12", @@ -323,7 +389,7 @@ pub fn built_in_tuning_system(id: &TuningSystemId) -> Option "young-ii", "Thomas Young's second temperament.", )), - "ji-adaptive-5limit" => Some(TuningCatalogEntry::Deferred(DEFERRED_ADAPTIVE)), + "ji-adaptive-5limit" => Some(ji_adaptive()), _ => None, } } @@ -802,6 +868,27 @@ pub enum TuningResolutionError { /// [`PitchSpacePosition`] variant in play, or between its chromatic /// cardinality and the tuning system's own divisions/table length. PositionUnavailable, + /// The resolved tuning system is [`TuningResolution::Adaptive`] naming an + /// [`AdaptiveTuningFunctionId`] other than the one registered built-in, + /// `"default-v1"` (`req:tuning:adaptive-default-version`'s final clause: + /// "An unregistered or unknown `AdaptiveTuningFunctionId` MUST be a hard + /// error; there is no silent fallback"). Never falls back to the C + /// default. + UnregisteredAdaptiveFunction(AdaptiveTuningFunctionId), + /// A [`crate::graph::KeySignatureChange`]'s anchor could not be ordered + /// against a pitch's onset while deriving an adaptive tonal centre + /// ([`derive_tonal_centre`], `req:tuning:adaptive-anchor-derivation`) — + /// either the two live on clocks that cannot be compared (a wall-clock + /// anchor against a musical onset, or vice versa), or the anchor is an + /// indirect form (`Event`/`Measure`/`Region`) the caller's injected + /// resolver could not place. A **deliberate deferral**, like + /// [`Self::IncompatiblePitchSpace`]: it says "this score uses an anchor + /// form the adaptive resolver does not order yet," never "your key + /// signature is wrong" — so it is never confused with a malformed score. + AnchorNotOrderable { + anchor: TimeAnchor, + reason: &'static str, + }, } impl core::fmt::Display for TuningResolutionError { @@ -826,6 +913,14 @@ impl core::fmt::Display for TuningResolutionError { Self::PositionUnavailable => { f.write_str("the pitch space position could not be placed on the tuning system's coordinate frame") } + Self::UnregisteredAdaptiveFunction(id) => write!( + f, + "'{id}' is not a registered adaptive tuning function (only \"default-v1\" is built in)" + ), + Self::AnchorNotOrderable { anchor, reason } => write!( + f, + "cannot order the key-signature-change anchor {anchor:?} against the pitch's onset: {reason}" + ), } } } @@ -925,6 +1020,17 @@ fn coordinate_ratio(resolution: &TuningResolution, s: i64) -> Option { let octave = i32::try_from(s.div_euclid(12)).ok()?; Some(ratios[degree as usize] * 2f64.powi(octave)) } + TuningResolution::Adaptive { .. } => { + // An `Adaptive` resolution carries no anchor of its own: the + // anchor comes from a `HarmonicContext` this function is never + // given. `resolve_pitch_frequency` always converts `Adaptive` to + // a concrete `PerPositionRatios` (via `ji_static_5limit_ratios`) + // before reaching this layer; one arriving here unconverted (a + // caller invoking `frequency_for_position` directly) has no + // anchor to consult, so this fails closed exactly like an + // unregistered `Function` id, never guessing C. + None + } } } @@ -963,6 +1069,12 @@ pub fn frequency_for_position( // (`spec/CONTRACT_PUSH4B_TEMPERAMENTS.md` item 1). TuningResolution::Function { .. } => chromatic_cardinality(&structure) .ok_or(TuningResolutionError::PositionUnavailable)?, + // Same reasoning as `Function`: `Adaptive` borrows the pitch + // space's chromatic cardinality rather than carrying its own. + // (In practice `resolve_pitch_frequency` never leaves `Adaptive` + // unconverted this far — see `coordinate_ratio`.) + TuningResolution::Adaptive { .. } => chromatic_cardinality(&structure) + .ok_or(TuningResolutionError::PositionUnavailable)?, }; let s = absolute_coordinate(position, &structure, divisions) .ok_or(TuningResolutionError::PositionUnavailable)?; @@ -1073,25 +1185,172 @@ pub fn resolve_tuning_scope( // 5), frequency. // =========================================================================== -/// Locates the region and staff that structurally own `voice`: a `Voice` -/// belongs to exactly one `StaffInstance`, which belongs to exactly one -/// `Region` (Chapter 5's containment tree — ownership, not a derived -/// time-range query). `None` if no region in `score.canvas.regions` owns a -/// voice with this id. -fn locate_voice(score: &Score, voice: VoiceId) -> Option<(RegionId, StaffId)> { +/// Locates the region, staff, and **staff instance** that structurally own +/// `voice`: a `Voice` belongs to exactly one `StaffInstance`, which belongs +/// to exactly one `Region` (Chapter 5's containment tree — ownership, not a +/// derived time-range query). `None` if no region in `score.canvas.regions` +/// owns a voice with this id. +/// +/// Returns the instance itself, not just its id: `key_sequence` +/// (`req:tuning:adaptive-anchor-derivation`) lives on the *instance* +/// (`StaffInstance::key_sequence`), not on the underlying `Staff`. An earlier +/// version of this function returned only `(RegionId, StaffId)`, discarding +/// the instance it already had in hand — the one structural blocker that +/// made adaptive resolution impossible before [`derive_tonal_centre`] existed. +fn locate_voice(score: &Score, voice: VoiceId) -> Option<(RegionId, StaffId, &StaffInstance)> { for region in &score.canvas.regions { for instance in region.staff_instances() { if instance.voices.iter().any(|v| v.id == voice) { - return Some((region.id, instance.staff)); + return Some((region.id, instance.staff, instance)); } } } None } +/// A [`TimeAnchor`] or [`EventPosition`] placed on one common, directly +/// comparable timeline — either absolute wall-clock nanoseconds or a +/// region-relative [`MusicalPosition`]. [`derive_tonal_centre`] only ever +/// compares two `Coordinate`s built from the *same* onset (see +/// `anchor_coordinate`), so the two variants are never compared against each +/// other in practice. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)] +enum Coordinate { + WallClock(i64), + Musical(MusicalPosition), +} + +/// Places `anchor` on the same clock as `onset`, or fails closed with a +/// distinct, reported [`TuningResolutionError::AnchorNotOrderable`] — never +/// a guessed ordering (`req:tuning:adaptive-anchor-derivation`). +/// +/// Two shapes are unambiguously determinable: a [`TimeAnchor::WallClock`] +/// anchor against a [`EventPosition::WallClock`] onset compares directly by +/// nanosecond; any other anchor against a [`EventPosition::Musical`] onset is +/// placed via the caller-injected `resolve` — the same `impl +/// Fn(&TimeAnchor) -> Option` shape +/// `TempoMap::musical_to_wallclock_with` injects (`tempo.rs:331`), reused +/// here rather than reinvented so this module stays out of the time-model +/// business. Everything else — a wall-clock anchor against a musical onset +/// or vice versa (cross-clock), or an indirect anchor the injected resolver +/// declines to place — fails closed rather than guessing. +fn anchor_coordinate( + anchor: &TimeAnchor, + onset: &EventPosition, + resolve: &impl Fn(&TimeAnchor) -> Option, +) -> Result { + match (anchor, onset) { + (TimeAnchor::WallClock { time }, EventPosition::WallClock(_)) => { + Ok(Coordinate::WallClock(time.0)) + } + (TimeAnchor::WallClock { .. }, EventPosition::Musical(_)) => { + Err(TuningResolutionError::AnchorNotOrderable { + anchor: anchor.clone(), + reason: "a wall-clock key-signature anchor cannot be ordered against a \ + musical-time onset (cross-clock comparison)", + }) + } + (_, EventPosition::WallClock(_)) => Err(TuningResolutionError::AnchorNotOrderable { + anchor: anchor.clone(), + reason: "a non-wall-clock key-signature anchor cannot be ordered against a \ + wall-clock onset (cross-clock comparison)", + }), + (_, EventPosition::Musical(_)) => { + resolve(anchor).map(Coordinate::Musical).ok_or_else(|| { + TuningResolutionError::AnchorNotOrderable { + anchor: anchor.clone(), + reason: "the key-signature anchor is an indirect form (Event/Measure/Region) \ + the injected resolver could not place on the musical timeline", + } + }) + } + } +} + +/// Derives the adaptive tonal centre from the score graph +/// (`req:tuning:adaptive-anchor-derivation`, `core_spec.tex:3474-3489`): the +/// prevailing key signature on the staff instance containing `voice` — +/// specifically, the *latest* `KeySignatureChange` in that instance's +/// `key_sequence` whose anchor is at or before `onset` — converted to a +/// chromatic pitch class by `anchor_pc = (7 * fifths).rem_euclid(12)` (never +/// `%`: `fifths` runs negative for flat keys, `KeySignature::MIN_FIFTHS == +/// -7`, and `%` would yield a negative pitch class). Mode is **not** +/// consulted: `KeySignature` carries no mode field at all, so a signature of +/// 0 anchors at C whether the prevailing key is C major or A minor — +/// structurally guaranteed, not merely by omission. +/// +/// `resolve` places any non-wall-clock `KeySignatureChange` anchor on the +/// musical timeline for comparison against a musical `onset` — see +/// `anchor_coordinate`, which this function uses for every entry in +/// `key_sequence`; if *any* entry's anchor cannot be ordered against +/// `onset`, this fails closed rather than risk skipping past the one that +/// would have been the true prevailing signature. +/// +/// `Ok(None)` when no key-signature change applies at or before `onset` — +/// the resolver then defaults to C (`req:tuning:adaptive-default-version`); +/// that default is applied in [`resolve_pitch_frequency`], not here, since a +/// missing tonal centre is a defined default, not this function's failure to +/// report. `Err` only for a caller error (`voice` unreachable from the score +/// graph) or an anchor this function cannot order. +pub fn derive_tonal_centre( + score: &Score, + voice: VoiceId, + onset: &EventPosition, + resolve: impl Fn(&TimeAnchor) -> Option, +) -> Result, TuningResolutionError> { + let (_, _, instance) = + locate_voice(score, voice).ok_or(TuningResolutionError::VoiceNotFound(voice))?; + let onset_coordinate = match onset { + EventPosition::WallClock(t) => Coordinate::WallClock(t.0), + EventPosition::Musical(p) => Coordinate::Musical(p.clone()), + }; + let mut prevailing: Option<(Coordinate, KeySignature)> = None; + for change in &instance.key_sequence { + let coordinate = anchor_coordinate(&change.anchor, onset, &resolve)?; + if coordinate <= onset_coordinate { + let is_later = match &prevailing { + Some((best, _)) => coordinate > *best, + None => true, + }; + if is_later { + prevailing = Some((coordinate, change.key)); + } + } + } + Ok(prevailing.map(|(_, key)| { + let anchor_pc = (7 * i32::from(key.fifths())).rem_euclid(12); + ChromaticPitchClass::new(anchor_pc as u8) + .expect("(7 * fifths).rem_euclid(12) is always in 0..=11") + })) +} + +/// The anchor pitch class for adaptive resolution: a hard error if `function` +/// is not the one reserved built-in, `"default-v1"` +/// (`req:tuning:adaptive-default-version`'s final clause — no silent +/// fallback to C), else `context`'s tonal centre when supplied, else C +/// (chromatic position 0 — `core_spec.tex:3452-3453`'s spec-mandated +/// default, not a fail-closed case). +fn adaptive_anchor( + function: &AdaptiveTuningFunctionId, + context: Option<&HarmonicContext>, +) -> Result { + if function.as_str() != "default-v1" { + return Err(TuningResolutionError::UnregisteredAdaptiveFunction( + function.clone(), + )); + } + Ok(context + .and_then(|c| c.tonal_centre) + .map(|pc| i32::from(pc.get())) + .unwrap_or(0)) +} + /// The full resolver: walks the five scopes, checks compatibility, and /// computes the frequency in Hz at which `pitch` sounds, given its location -/// (`voice`) in `score`. +/// (`voice`) in `score`. `context` supplies the harmonic context adaptive +/// tuning consumes (`core_spec.tex:3426-3428`: "The harmonic context is +/// constructed by the audio engine ... and passed to the tuning resolution +/// function"); static systems ignore it entirely. /// /// Step 1's other short-circuit — [`AcousticRealization::AbsoluteHz`] — /// bypasses everything else: the frequency is already fixed, so neither the @@ -1099,21 +1358,30 @@ fn locate_voice(score: &Score, voice: VoiceId) -> Option<(RegionId, StaffId)> { /// [`AcousticRealization::CentsOffset`] is applied multiplicatively on top /// of the resolved base frequency, per its own documented semantics ("an /// explicit offset in cents from the tuning system's result"). +/// +/// Resolution of [`TuningResolution::Adaptive`]: an unregistered `function` +/// is a hard error (see `adaptive_anchor`); otherwise the anchor is +/// `context`'s tonal centre, or C when none is supplied, and the position +/// resolves through `ji_static_5limit_ratios(anchor)` — *exactly* the call +/// the three static `ji-static-5limit-*` built-ins make, transposed to a +/// runtime anchor rather than one of the three fixed ones. No second lattice +/// construction, no cents table. pub fn resolve_pitch_frequency( score: &Score, pitch: &Pitch, voice: VoiceId, + context: Option<&HarmonicContext>, ) -> Result { if let AcousticRealization::AbsoluteHz(hz) = pitch.acoustic.realization { return Ok(hz.get()); } - let (region, staff) = + let (region, staff, _instance) = locate_voice(score, voice).ok_or(TuningResolutionError::VoiceNotFound(voice))?; let resolved = resolve_tuning_scope(pitch, voice, staff, region, &score.tuning_context); let entry = built_in_tuning_system(&resolved.tuning_system).ok_or_else(|| { TuningResolutionError::UnknownTuningSystem(resolved.tuning_system.clone()) })?; - let system = match entry { + let mut system = match entry { TuningCatalogEntry::Resolved(system) => system, TuningCatalogEntry::Deferred(reason) => { return Err(TuningResolutionError::NotYetSupported { @@ -1130,6 +1398,13 @@ pub fn resolve_pitch_frequency( tuning_system_pitch_space: system.pitch_space, }); } + if let TuningResolution::Adaptive { function } = &system.resolution { + let anchor = adaptive_anchor(function, context)?; + // Reuse the static-JI construction verbatim, re-anchored at runtime + // — see `ji_static_5limit_ratios`'s own doc for why this is the same + // call the three static built-ins make, not a second construction. + system.resolution = TuningResolution::PerPositionRatios(ji_static_5limit_ratios(anchor)); + } let base = frequency_for_position(&pitch.scale_position.position, &system, &resolved.reference)?; match pitch.acoustic.realization { @@ -1143,8 +1418,8 @@ pub fn resolve_pitch_frequency( mod tests { use super::*; use crate::graph::{ - Canvas, MetricTimeModel, Region, RegionContent, RegionTimeModel, StaffBasedContent, - StaffExtent, StaffInstance, TimeExtent, Voice, + Canvas, KeySignatureChange, MetricTimeModel, Region, RegionContent, RegionTimeModel, + StaffBasedContent, StaffExtent, StaffInstance, TimeExtent, Voice, }; use crate::ids::{IdentityContext, ReplicaId, StaffInstanceId}; use crate::pitch::{AcousticPitch, CmnNominal, ScalePosition}; @@ -1195,6 +1470,24 @@ mod tests { } } + /// A `KeySignatureChange` anchored at wall-clock nanosecond `t`, for the + /// adaptive-anchor-derivation tests below. + fn key_change(t: i64, fifths: i8) -> KeySignatureChange { + KeySignatureChange { + anchor: TimeAnchor::WallClock { + time: WallClockTime(t), + }, + key: KeySignature::new(fifths).expect("fifths within -7..=7"), + } + } + + /// A resolver that never places a `TimeAnchor` on the musical timeline -- + /// sufficient for the wall-clock-only fixtures below, where + /// `derive_tonal_centre` never needs it. + fn no_musical_resolve(_: &TimeAnchor) -> Option { + None + } + /// A minimal score: one region, one staff instance, two voices — enough /// for `locate_voice` and the scope walk, nothing more. struct Fixture { @@ -1242,7 +1535,8 @@ mod tests { fn tet12_a4_440_resolves_c5_to_523_2511_hz() { let f = fixture(); let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5); - let freq = resolve_pitch_frequency(&f.score, &c5, f.voice_a).expect("tet-12 resolves"); + let freq = + resolve_pitch_frequency(&f.score, &c5, f.voice_a, None).expect("tet-12 resolves"); assert!( cents(0.01).within(cents_between(freq, 523.2511), 0.0), "expected ~523.2511 Hz, got {freq}" @@ -1278,10 +1572,10 @@ mod tests { tet_score.tuning_context.reference = c4_ref; let e4 = cmn_pitch("cmn-12", CmnNominal::E, 0, 4); - let ji_freq = resolve_pitch_frequency(&ji_score, &e4, f.voice_a) + let ji_freq = resolve_pitch_frequency(&ji_score, &e4, f.voice_a, None) .expect("ji-static-5limit-C resolves"); let tet_freq = - resolve_pitch_frequency(&tet_score, &e4, f.voice_a).expect("tet-12 resolves"); + resolve_pitch_frequency(&tet_score, &e4, f.voice_a, None).expect("tet-12 resolves"); // Just major third 5/4 (386.31 c) vs equal-tempered (400 c): the just // third is *flatter*, by the syntonic comma (~13.7 c). assert!( @@ -1345,10 +1639,10 @@ mod tests { ), }); let a4 = cmn_pitch("cmn-12", CmnNominal::A, 0, 4); - let in_voice_a = - resolve_pitch_frequency(&f.score, &a4, f.voice_a).expect("resolves under the override"); - let in_voice_b = - resolve_pitch_frequency(&f.score, &a4, f.voice_b).expect("resolves under the default"); + let in_voice_a = resolve_pitch_frequency(&f.score, &a4, f.voice_a, None) + .expect("resolves under the override"); + let in_voice_b = resolve_pitch_frequency(&f.score, &a4, f.voice_b, None) + .expect("resolves under the default"); assert!( cents(0.01).within(cents_between(in_voice_a, 415.0), 0.0), "voice A's own reference override must apply: got {in_voice_a}" @@ -1368,7 +1662,7 @@ mod tests { // space stays cmn-12 (unchanged) — a genuine, catchable mismatch. f.score.tuning_context.default_tuning_system = TuningSystemId::new("tet-19"); let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5); - let err = resolve_pitch_frequency(&f.score, &c5, f.voice_a) + let err = resolve_pitch_frequency(&f.score, &c5, f.voice_a, None) .expect_err("must reject the mismatch"); assert!( matches!(err, TuningResolutionError::IncompatiblePitchSpace { .. }), @@ -1376,30 +1670,33 @@ mod tests { ); } - // -- Proof of life 5: a deferred system fails closed. --------------------- + // -- Proof of life 5: `ji-adaptive-5limit` now resolves; a genuinely ----- + // -- unknown identifier still fails closed, distinctly. --- #[test] - fn deferred_ji_adaptive_fails_closed() { - // As of Push 4b tranche 2b, `pythagorean` (and the other nine - // historical temperaments) resolve — see the temperament tests - // below. `ji-adaptive-5limit` is the one remaining catalog entry - // whose resolution is still deferred (it needs `HarmonicContext`, - // which does not exist in Rust). + fn ji_adaptive_5limit_resolves_and_unknown_ids_still_fail_closed() { + // Push 4b's adaptive tranche: `ji-adaptive-5limit` is no longer + // deferred (`TuningCatalogEntry::Deferred`) -- it resolves like every + // other built-in, closing the last entry of the twenty-item catalog. + // This test used to prove the opposite (a `NotYetSupported` error); + // it inverts rather than being deleted, per the contract. let f = fixture(); let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5); let mut score = f.score.clone(); score.tuning_context.default_tuning_system = TuningSystemId::new("ji-adaptive-5limit"); - let err = resolve_pitch_frequency(&score, &c5, f.voice_a) - .expect_err("ji-adaptive-5limit must not resolve to a frequency"); + let freq = resolve_pitch_frequency(&score, &c5, f.voice_a, None) + .expect("ji-adaptive-5limit must now resolve to a frequency"); assert!( - matches!(err, TuningResolutionError::NotYetSupported { .. }), - "ji-adaptive-5limit must report NotYetSupported (a known-but-deferred identifier), got {err:?}" + freq.is_finite() && freq > 0.0, + "expected a real, positive frequency, got {freq}" ); - // A genuinely unknown identifier reports differently, so the two - // failure modes never blur together. + + // A genuinely unknown identifier still reports differently, so the + // two failure modes this test used to distinguish don't blur now + // that the catalog's one deferred entry is gone. let mut score = f.score.clone(); score.tuning_context.default_tuning_system = TuningSystemId::new("not-a-built-in-system"); - let err = resolve_pitch_frequency(&score, &c5, f.voice_a) + let err = resolve_pitch_frequency(&score, &c5, f.voice_a, None) .expect_err("unknown id must not resolve"); assert!(matches!(err, TuningResolutionError::UnknownTuningSystem(_))); } @@ -1414,7 +1711,7 @@ mod tests { f.score.tuning_context.default_tuning_system = TuningSystemId::new("not-a-built-in-system"); let mut pinned = cmn_pitch("cmn-12", CmnNominal::C, 0, 5); pinned.acoustic.realization = AcousticRealization::absolute_hz(500.0).unwrap(); - let freq = resolve_pitch_frequency(&f.score, &pinned, f.voice_a) + let freq = resolve_pitch_frequency(&f.score, &pinned, f.voice_a, None) .expect("AbsoluteHz must resolve without consulting the tuning system at all"); assert_eq!(freq, 500.0); } @@ -1448,7 +1745,7 @@ mod tests { }, }; let freq = - resolve_pitch_frequency(&f.score, &one_step, f.voice_a).expect("tet-19 resolves"); + resolve_pitch_frequency(&f.score, &one_step, f.voice_a, None).expect("tet-19 resolves"); let expected = 440.0 * 2f64.powf(1.0 / 19.0); assert!( cents(0.01).within(cents_between(freq, expected), 0.0), @@ -1617,7 +1914,7 @@ mod tests { for id in TEN { let mut score = f.score.clone(); score.tuning_context.default_tuning_system = TuningSystemId::new(id); - let freq = resolve_pitch_frequency(&score, &c5, f.voice_a) + let freq = resolve_pitch_frequency(&score, &c5, f.voice_a, None) .unwrap_or_else(|e| panic!("{id} must resolve to a frequency, got error: {e}")); assert!( freq.is_finite() && freq > 0.0, @@ -1633,10 +1930,10 @@ mod tests { let mut wm_score = f.score.clone(); wm_score.tuning_context.default_tuning_system = TuningSystemId::new("werckmeister-iii"); // `f.score` already defaults to tet-12. - let wm_freq = resolve_pitch_frequency(&wm_score, &c_sharp, f.voice_a) + let wm_freq = resolve_pitch_frequency(&wm_score, &c_sharp, f.voice_a, None) .expect("werckmeister-iii resolves"); let tet_freq = - resolve_pitch_frequency(&f.score, &c_sharp, f.voice_a).expect("tet-12 resolves"); + resolve_pitch_frequency(&f.score, &c_sharp, f.voice_a, None).expect("tet-12 resolves"); let diff = cents_between(wm_freq, tet_freq); assert!( diff > 0.5, @@ -1680,4 +1977,331 @@ mod tests { "expected PositionUnavailable, got {err:?}" ); } + + // ========================================================================= + // Push 4b's adaptive tranche: `ji-adaptive-5limit`, `HarmonicContext`, and + // `derive_tonal_centre`. Every assertion below either recomputes its + // expected value from the arithmetic (`req:tuning:adaptive-anchor-derivation`'s + // `(7 * fifths).rem_euclid(12)`) or cross-checks against the already-tested + // static-JI built-ins, never a hardcoded frequency copied from nowhere. + // ========================================================================= + + /// An `Adaptive` `frequency_for_position` reached directly (never through + /// `resolve_pitch_frequency`, which always converts `Adaptive` to a + /// concrete `PerPositionRatios` first) has no anchor to consult and must + /// fail closed, not guess C -- the same shape as + /// `unknown_tuning_function_id_fails_closed`. + #[test] + fn adaptive_resolution_reaching_frequency_for_position_directly_fails_closed() { + assert_eq!( + coordinate_ratio( + &TuningResolution::Adaptive { + function: AdaptiveTuningFunctionId::new("default-v1"), + }, + 0, + ), + None + ); + + let system = TuningSystem { + id: TuningSystemId::new("bogus-adaptive"), + name: "bogus-adaptive".to_owned(), + pitch_space: PitchSpaceId::new("cmn-12"), + resolution: TuningResolution::Adaptive { + function: AdaptiveTuningFunctionId::new("default-v1"), + }, + description: None, + }; + let c5_position = PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave: 5, + }; + let err = frequency_for_position(&c5_position, &system, &ReferencePitch::a440()) + .expect_err("an unconverted Adaptive resolution must never resolve to a frequency"); + assert!( + matches!(err, TuningResolutionError::PositionUnavailable), + "expected PositionUnavailable, got {err:?}" + ); + } + + /// `req:tuning:adaptive-default-version`'s final clause: an unregistered + /// `AdaptiveTuningFunctionId` is a hard error, never a silent fallback to + /// the C default. + #[test] + fn unregistered_adaptive_function_id_fails_closed_with_no_fallback_to_c() { + let bogus = AdaptiveTuningFunctionId::new("not-default-v1"); + let err = adaptive_anchor(&bogus, None) + .expect_err("an unregistered adaptive function id must be a hard error"); + assert!( + matches!(err, TuningResolutionError::UnregisteredAdaptiveFunction(_)), + "expected UnregisteredAdaptiveFunction, got {err:?}" + ); + } + + /// The spec-mandated default (`core_spec.tex:3452-3453`): no context, or + /// a context whose `tonal_centre` is `None`, resolves at anchor C -- + /// bit-identical to `ji-static-5limit-C`'s result for the same position. + /// This pins the default against a future "fail closed when context is + /// missing" refactor -- a missing tonal centre is not an error. + #[test] + fn adaptive_with_no_context_defaults_to_c_matching_ji_static_5limit_c() { + let f = fixture(); + let e4 = cmn_pitch("cmn-12", CmnNominal::E, 0, 4); + + let mut adaptive_score = f.score.clone(); + adaptive_score.tuning_context.default_tuning_system = + TuningSystemId::new("ji-adaptive-5limit"); + let mut static_score = f.score.clone(); + static_score.tuning_context.default_tuning_system = + TuningSystemId::new("ji-static-5limit-C"); + + let no_context = resolve_pitch_frequency(&adaptive_score, &e4, f.voice_a, None) + .expect("adaptive resolves with no context at all"); + let empty_tonal_centre = resolve_pitch_frequency( + &adaptive_score, + &e4, + f.voice_a, + Some(&HarmonicContext { tonal_centre: None }), + ) + .expect("adaptive resolves with an empty tonal centre"); + let static_c = resolve_pitch_frequency(&static_score, &e4, f.voice_a, None) + .expect("ji-static-5limit-C resolves"); + + assert_eq!( + no_context.to_bits(), + static_c.to_bits(), + "no context: {no_context} != ji-static-5limit-C's {static_c}" + ); + assert_eq!( + empty_tonal_centre.to_bits(), + static_c.to_bits(), + "empty tonal centre: {empty_tonal_centre} != ji-static-5limit-C's {static_c}" + ); + } + + /// The transposition identity the reviewer will check by hand: adaptive + /// at anchor 0 (C) matches `ji-static-5limit-C` position-for-position, + /// and at anchor 7 (G) matches `ji-static-5limit-G` -- because adaptive + /// resolution *is* `ji_static_5limit_ratios` called with a runtime + /// anchor, not a second construction. + #[test] + fn adaptive_transposition_identity_matches_static_c_and_g() { + let f = fixture(); + let naturals = [ + CmnNominal::C, + CmnNominal::D, + CmnNominal::E, + CmnNominal::F, + CmnNominal::G, + CmnNominal::A, + CmnNominal::B, + ]; + for (anchor_pc, static_id) in [(0u8, "ji-static-5limit-C"), (7u8, "ji-static-5limit-G")] { + let mut adaptive_score = f.score.clone(); + adaptive_score.tuning_context.default_tuning_system = + TuningSystemId::new("ji-adaptive-5limit"); + let mut static_score = f.score.clone(); + static_score.tuning_context.default_tuning_system = TuningSystemId::new(static_id); + let ctx = HarmonicContext { + tonal_centre: ChromaticPitchClass::new(anchor_pc), + }; + for nominal in naturals { + let pitch = cmn_pitch("cmn-12", nominal, 0, 4); + let adaptive_freq = + resolve_pitch_frequency(&adaptive_score, &pitch, f.voice_a, Some(&ctx)) + .unwrap_or_else(|e| panic!("adaptive must resolve {nominal:?}: {e}")); + let static_freq = resolve_pitch_frequency(&static_score, &pitch, f.voice_a, None) + .unwrap_or_else(|e| panic!("{static_id} must resolve {nominal:?}: {e}")); + assert_eq!( + adaptive_freq.to_bits(), + static_freq.to_bits(), + "anchor {anchor_pc}, {nominal:?}: adaptive {adaptive_freq} != {static_id} {static_freq}" + ); + } + } + } + + /// `req:tuning:adaptive-default-version`'s closing clause: "No adjustment + /// is ever carried forward from a previous resolution." Resolve the same + /// position repeatedly, interleaved with other resolutions in between -- + /// a stateful (comma-drifting) implementation would let those leak in; + /// this one never does, by construction (every call recomputes from the + /// anchor and reference alone). + #[test] + fn adaptive_resolution_is_comma_drift_free_by_shape() { + let f = fixture(); + let mut score = f.score.clone(); + score.tuning_context.default_tuning_system = TuningSystemId::new("ji-adaptive-5limit"); + let e4 = cmn_pitch("cmn-12", CmnNominal::E, 0, 4); + let c4 = cmn_pitch("cmn-12", CmnNominal::C, 0, 4); + let g4 = cmn_pitch("cmn-12", CmnNominal::G, 0, 4); + let ctx = HarmonicContext { + tonal_centre: ChromaticPitchClass::new(0), + }; + + let first = + resolve_pitch_frequency(&score, &e4, f.voice_a, Some(&ctx)).expect("adaptive resolves"); + let _ = + resolve_pitch_frequency(&score, &c4, f.voice_a, Some(&ctx)).expect("adaptive resolves"); + let _ = + resolve_pitch_frequency(&score, &g4, f.voice_a, Some(&ctx)).expect("adaptive resolves"); + let second = + resolve_pitch_frequency(&score, &e4, f.voice_a, Some(&ctx)).expect("adaptive resolves"); + let _ = + resolve_pitch_frequency(&score, &g4, f.voice_a, Some(&ctx)).expect("adaptive resolves"); + let _ = + resolve_pitch_frequency(&score, &c4, f.voice_a, Some(&ctx)).expect("adaptive resolves"); + let third = + resolve_pitch_frequency(&score, &e4, f.voice_a, Some(&ctx)).expect("adaptive resolves"); + + assert_eq!(first.to_bits(), second.to_bits()); + assert_eq!(second.to_bits(), third.to_bits()); + } + + /// `req:tuning:adaptive-anchor-derivation`: mode is not consulted. + /// `KeySignature` carries no mode field at all, so `fifths = 0` anchors + /// at C whether the prevailing key is conceived as C major or A minor -- + /// there is no mode input `derive_tonal_centre` could consult even if it + /// tried. This pins the arithmetic for that no-mode-information case. + #[test] + fn key_signature_zero_fifths_anchors_at_c_regardless_of_mode() { + let mut f = fixture(); + { + let region = &mut f.score.canvas.regions[0]; + let instances = region.content.staff_instances_mut().unwrap(); + instances[0].key_sequence = vec![key_change(0, 0)]; + } + let onset = EventPosition::WallClock(WallClockTime(500)); + let pc = derive_tonal_centre(&f.score, f.voice_a, &onset, no_musical_resolve) + .expect("must resolve") + .expect("a prevailing key exists"); + assert_eq!( + pc.get(), + 0, + "fifths=0 must anchor at C (pc 0), got {}", + pc.get() + ); + } + + /// `req:tuning:adaptive-anchor-derivation`'s sign-bug trap: `fifths = -1` + /// (one flat) gives `(7 * -1).rem_euclid(12) = 5` (F). A buggy `%` + /// implementation would yield `-7`, an invalid pitch class -- this test + /// dies under that mutation. + #[test] + fn flat_key_signature_anchor_uses_rem_euclid_not_percent() { + let mut f = fixture(); + { + let region = &mut f.score.canvas.regions[0]; + let instances = region.content.staff_instances_mut().unwrap(); + instances[0].key_sequence = vec![key_change(0, -1)]; + } + let onset = EventPosition::WallClock(WallClockTime(100)); + let pc = derive_tonal_centre(&f.score, f.voice_a, &onset, no_musical_resolve) + .expect("must resolve") + .expect("a prevailing key exists"); + assert_eq!(pc.get(), 5, "expected F (pc 5), got {}", pc.get()); + } + + /// `req:tuning:adaptive-anchor-derivation`'s "prevailing" selection: with + /// two `KeySignatureChange`s on a staff, a pitch between them takes the + /// earlier; a pitch after both takes the later. + #[test] + fn prevailing_key_signature_selects_the_latest_at_or_before_onset() { + let mut f = fixture(); + { + let region = &mut f.score.canvas.regions[0]; + let instances = region.content.staff_instances_mut().unwrap(); + instances[0].key_sequence = vec![ + key_change(0, 0), // C major/A minor at t=0. + key_change(500, 1), // G major/E minor at t=500. + ]; + } + + let onset_between = EventPosition::WallClock(WallClockTime(250)); + let pc = derive_tonal_centre(&f.score, f.voice_a, &onset_between, no_musical_resolve) + .expect("must resolve") + .expect("a prevailing key exists"); + assert_eq!( + pc.get(), + 0, + "a pitch between the two changes takes the earlier (C)" + ); + + let onset_after = EventPosition::WallClock(WallClockTime(600)); + let pc = derive_tonal_centre(&f.score, f.voice_a, &onset_after, no_musical_resolve) + .expect("must resolve") + .expect("a prevailing key exists"); + assert_eq!( + pc.get(), + 7, + "a pitch after both changes takes the later (G)" + ); + } + + /// No key-signature change at all ⇒ no tonal centre -- `Ok(None)`, never + /// an error. `resolve_pitch_frequency` is the one that defaults to C; + /// `derive_tonal_centre` just reports what it found. + #[test] + fn no_key_signature_change_yields_no_tonal_centre() { + let f = fixture(); + let onset = EventPosition::WallClock(WallClockTime(0)); + let centre = derive_tonal_centre(&f.score, f.voice_a, &onset, no_musical_resolve) + .expect("must resolve (absence of a tonal centre is not an error)"); + assert_eq!(centre, None); + } + + /// Cross-clock and indirect anchors fail closed with a distinct, + /// reported error rather than a guessed ordering. + #[test] + fn anchor_ordering_fails_closed_on_cross_clock_and_indirect_anchors() { + let mut f = fixture(); + { + let region = &mut f.score.canvas.regions[0]; + let instances = region.content.staff_instances_mut().unwrap(); + // A wall-clock-anchored key change, but the pitch's onset is + // musical: cross-clock, unorderable. + instances[0].key_sequence = vec![key_change(0, 0)]; + } + let musical_onset = EventPosition::Musical(MusicalPosition::origin()); + let err = derive_tonal_centre(&f.score, f.voice_a, &musical_onset, no_musical_resolve) + .expect_err("a wall-clock anchor against a musical onset must not silently order"); + assert!( + matches!(err, TuningResolutionError::AnchorNotOrderable { .. }), + "expected AnchorNotOrderable, got {err:?}" + ); + + // An indirect (Event-anchored) key change, with a musical onset and + // a resolver that declines to place it. + let mut f2 = fixture(); + { + let region = &mut f2.score.canvas.regions[0]; + let instances = region.content.staff_instances_mut().unwrap(); + instances[0].key_sequence = vec![KeySignatureChange { + anchor: TimeAnchor::Event { + id: crate::ids::EventId::new(ReplicaId(1), 1), + offset: crate::time::AnchorOffset::Zero, + }, + key: KeySignature::new(0).unwrap(), + }]; + } + let err = derive_tonal_centre(&f2.score, f2.voice_a, &musical_onset, no_musical_resolve) + .expect_err("an indirect anchor the resolver declines must not silently order"); + assert!( + matches!(err, TuningResolutionError::AnchorNotOrderable { .. }), + "expected AnchorNotOrderable, got {err:?}" + ); + } + + /// `derive_tonal_centre` reports the same caller error + /// (`VoiceNotFound`) as `resolve_pitch_frequency` for an orphaned voice. + #[test] + fn derive_tonal_centre_reports_voice_not_found_for_an_orphaned_voice() { + let f = fixture(); + let orphan = VoiceId::new(ReplicaId(1), 999); + let onset = EventPosition::WallClock(WallClockTime(0)); + let err = derive_tonal_centre(&f.score, orphan, &onset, no_musical_resolve) + .expect_err("an orphaned voice must not resolve"); + assert!(matches!(err, TuningResolutionError::VoiceNotFound(_))); + } } diff --git a/spec/CONTRACT_PUSH4B_ADAPTIVE.md b/spec/CONTRACT_PUSH4B_ADAPTIVE.md new file mode 100644 index 0000000..b3f5301 --- /dev/null +++ b/spec/CONTRACT_PUSH4B_ADAPTIVE.md @@ -0,0 +1,230 @@ +# CONTRACT — Push 4b: `ji-adaptive-5limit`, the last fail-closed tuning system + +**Status:** dispatch-ready. Closes the final deferred entry in the built-in +tuning catalog (`req:tuning:builtin-tuning-catalog`), leaving only the +compatibility-mapping registry open in Push 4b. + +**Ratified by the user 2026-07-23:** +1. **A minimal `HarmonicContext`** carrying only the tonal centre — not the full + four-field spec listing, and not "no struct at all". +2. **One resolver with an optional context parameter** — static systems ignore + it, adaptive consumes it. This is the spec's own stated API shape. +3. **Constrain and fail closed** on anchor ordering: order what is + unambiguously determinable, return a distinct reported error otherwise. + Never guess. + +**Nothing in this tranche reaches the wire.** `TuningResolution` appears nowhere +in `codec.rs` or `textvalue_graph.rs` — the score stores only *identifiers* +(`default_tuning_system`, `overrides`). `TuningResolution::Adaptive` and +`HarmonicContext` are in-memory only and fully reversible. Do **not** add a +`Codec` for anything here, and do not touch schema major 3. + +--- + +## Correct the recorded blocker first + +`tuning.rs` says in three places that adaptive "needs `HarmonicContext`, which +does not exist in Rust". That framing is wrong and should be fixed as you go: + +- `req:tuning:adaptive-default-version` (`core_spec.tex:3438`) makes version 1 + **"a pure function of (position, anchor pitch class)"**. It explicitly + *ignores* `concurrent`, `recent`, `hints`, `parameters`, and mode. +- **Two of `HarmonicContext`'s four spec'd fields are unimplementable by + construction**: `key_context: Option` and `hints: Vec` + name types the specification deliberately leaves undefined + (`core_spec.tex:4111`, Forward References: "defining them now would freeze a + type surface on a chapter with no consumer"). + +So the minimal shape is not merely preferable — it is the only implementable +one. What v1 needs is a single chromatic pitch class. + +## What already exists — do not rebuild it + +`ji_static_5limit_ratios(anchor_chromatic_degree: i32) -> Vec` +(`tuning.rs:358`) **already takes a runtime anchor**. The three +`ji-static-5limit-{C,G,D}` entries are that one function at anchors 0, 7, and 2. + +`req:tuning:adaptive-default-version`'s first clause — "the position resolves +through the construction of `req:tuning:ji-static-construction`, transposed so +the anchor takes the role of 1/1" — **is exactly that call with a derived +anchor**. Reuse it. Do not write a second lattice construction, and do not +transcribe a cents table. + +## The surface + +### 1. `AdaptiveTuningFunctionId` + +Mint via the existing `catalog_id!` macro in `pitch.rs` (beside +`TuningFunctionId`, `pitch.rs:92`), with a doc comment stating that +`ji-adaptive-5limit` is bound to `"default-v1"`, that the version lives *inside* +the identifier string, and that an unregistered id is a hard error with no +silent fallback (`req:tuning:adaptive-default-version`, final clause). + +### 2. `TuningResolution::Adaptive` + +Add the variant the specification names: + +```rust +Adaptive { function: AdaptiveTuningFunctionId }, +``` + +In-memory only. Follow the `Function` variant's doc style (`tuning.rs:119-134`): +say what resolves it, and that an unregistered id fails closed. + +### 3. `HarmonicContext` — minimal, and say why + +```rust +pub struct HarmonicContext { + /// The active tonal centre as a chromatic pitch class (0..=11), if known. + pub tonal_centre: Option, +} +``` + +The doc comment MUST record why this is not the four-field listing at +`core_spec.tex:3411`: `key_context` and `hints` are typed on `KeyContext` / +`ContextHint`, which the specification leaves undefined on purpose; `concurrent` +and `recent` are ignored by version 1, so carrying them would mint an unconsumed +type surface (the `NOTEHEAD_ANCHORS` failure the module doc at `tuning.rs:25` +already cites). Each field arrives with the first function that consumes it. + +For the pitch class, enforce the `0..=11` invariant in the type rather than +merely documenting it — follow `KeySignature::new`'s checked-constructor style +(`graph.rs:156`, returns `Option`). A raw `u8` field a caller can set to 200 is +not acceptable. (`Pitch::chromatic_pitch_class`, `pitch.rs:585`, already returns +a `0..=11` `u8`; a small checked newtype in `pitch.rs` beside it is the natural +home, and lets that method's contract be stated in the type.) + +### 4. The anchor derivation — `req:tuning:adaptive-anchor-derivation` + +A public function deriving the tonal centre from the score graph. Per the +requirement (`core_spec.tex:3474-3489`): + +- `anchor_pc = (7 × fifths).rem_euclid(12)`, reading `fifths` as the **major + tonic**. **Mode is not consulted** — a signature of 0 anchors at C whether the + prevailing key is C major or A minor. Use `rem_euclid`, never `%`: `fifths` is + negative for flat keys (`KeySignature::MIN_FIFTHS == -7`) and `%` would yield a + negative pitch class. +- The *prevailing* signature is the one **on the staff containing the pitch**, + from that staff instance's `key_sequence` (`StaffInstance.key_sequence`, + `graph.rs:607`), at the latest `KeySignatureChange` whose anchor is **at or + before the pitch's onset**. +- No applicable change ⇒ no tonal centre (the resolver then defaults to C; see + §5). + +**`locate_voice` (`tuning.rs:1081`) currently returns `(RegionId, StaffId)` and +throws away the `StaffInstance` it already has in hand.** `key_sequence` lives on +the *instance*, not on `Staff`. Widen it (or add a sibling) to return the +instance. This is the single structural blocker that made adaptive resolution +impossible before. + +**Ordering — constrain and fail closed.** `KeySignatureChange.anchor` is a +`TimeAnchor` (Event / Measure / Region / WallClock, `time.rs:592`) and an event's +onset is an `EventPosition` (`Musical` | `WallClock`, `time.rs:628`). Order the +cases that are unambiguously determinable, and return a **distinct, reported +error** for the rest (cross-clock comparison, or an indirect anchor you cannot +resolve) rather than guessing an ordering. Precedent for anchor resolution, which +you should follow rather than reinvent: `tempo.rs:331/356/376` inject a +`resolve: impl Fn(&TimeAnchor) -> Option` closure, and +`invariants.rs:407` walks anchors depth-bounded. Reusing `tempo.rs`'s injected +shape is preferred — it keeps this module out of the time-model business. + +Name the error for what it is (e.g. `AnchorNotOrderable { .. }`), document that +it is a *deliberate deferral* like `IncompatiblePitchSpace`, and make the message +say which anchor kind defeated it. A caller must be able to tell "this score +uses a form I don't order yet" from "your key signature is wrong". + +### 5. The resolver seam + +```rust +pub fn resolve_pitch_frequency( + score: &Score, + pitch: &Pitch, + voice: VoiceId, + context: Option<&HarmonicContext>, +) -> Result +``` + +`resolve_pitch_frequency` has **no production callers** — only its own tests +(verified across the workspace), so this signature change is nearly free. Update +the five in-module test call sites and the `lib.rs:170` re-export docs. + +Resolution of `TuningResolution::Adaptive`: +1. If `function` is not the registered `"default-v1"` ⇒ **hard error**. No + fallback (`req:tuning:adaptive-default-version`). +2. Anchor = `context`'s `tonal_centre` when supplied; **otherwise C, chromatic + position 0**. This is spec-mandated (`core_spec.tex:3452-3453`: "C (chromatic + position 0) when no tonal centre is supplied") — it is a *defined default*, + **not** a fail-closed case. Do not turn a missing context into an error. +3. Resolve the position through `ji_static_5limit_ratios(anchor)`. + +Static systems ignore `context` entirely — that is the spec's shape +(`core_spec.tex:3426-3428`). + +### 6. Catalog entry + +`built_in_tuning_system` (`tuning.rs:326`) currently returns +`TuningCatalogEntry::Deferred(DEFERRED_ADAPTIVE)` for `"ji-adaptive-5limit"`. +It becomes `Resolved(..)` with `resolution: Adaptive { function: "default-v1" }` +over pitch space `cmn-12`. Remove `DEFERRED_ADAPTIVE` and retire the stale +"needs HarmonicContext" notes at `tuning.rs:27`, `:43`, `:223`, `:279`, `:1386`. + +The test `deferred_ji_adaptive_fails_closed` (`tuning.rs:1382`) **inverts**: it +must now prove the system resolves. Rename it accordingly. Do not delete it. + +## Tests that would fail against the bugs this invites + +- **Comma-drift-free by shape.** `req:tuning:adaptive-default-version`'s last + clause: "No adjustment is ever carried forward from a previous resolution." + Resolve the same position repeatedly, and in varying orders, and assert + bit-identical results — the property that would catch a stateful implementation. +- **Mode-blindness.** A signature of 0 anchors at C. Assert a relative-minor + reading is *not* applied. +- **Flat keys.** `fifths = -1` ⇒ `(7 × -1).rem_euclid(12) = 5` (F). A `%` + implementation yields `-7` — assert the correct value so the sign bug cannot + survive. +- **The default.** No context, or a context with `tonal_centre: None`, resolves + at anchor C — equal to `ji-static-5limit-C`'s result for the same position. + This pins the spec's mandated default against a future "fail closed" refactor. +- **Unregistered function id is a hard error**, with no fallback to C. +- **Prevailing selection.** With two `KeySignatureChange`s on a staff, a pitch + between them takes the earlier; a pitch after both takes the later. + +## Do NOT + +- Define `KeyContext`, `ContextHint`, or `AdaptiveTuningParameters` — the + specification leaves all three undefined deliberately. +- Add `concurrent` / `recent` to `HarmonicContext`. +- Add any `Codec`, touch schema major 3, or alter `spec/vectors/`. +- Build a general `TimeAnchor` resolution layer — constrain and fail closed. +- Touch the editor track (`spec/PLAN_EDITOR_APP.md`, + `spec/CONTRACT_EDITOR_T1A_GOLDENS.md`, `spec/CONTRACT_EDITOR_T2_SELECTION.md`, + `crates/epiphany-editor-gui/goldens/`) or `.claude/worktrees/`. + +## Spec + +The two requirements are already ratified and need **no change**. If a +`core_spec.tex` touch is genuinely needed (e.g. a note that the implementation's +`HarmonicContext` carries only the tonal centre until a consumer needs more), add +it as prose or a `rationale` — **no new `\label{req:...}`**. Requirement counts +MUST stay **212 / 282 / 282**. + +## The gate (report exact numbers) + +- `cargo fmt --all --check` +- `cargo clippy --workspace --all-targets` → 0 warnings +- `cargo test --workspace` → 0 failed (3b-ii landed at 1283; report the count) +- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` → 0 +- `cargo run -q -p epiphany-testkit --example conformance_suite` → 8/8 +- `cargo test -p epiphany-testkit --test requirement_labels` → 6/6, counts + **212 / 282 / 282** + +## What the reviewer will verify independently — build to survive it + +- The anchor arithmetic by hand across the full `-7..=7` range, especially flat + keys, against `(7 × fifths).rem_euclid(12)`. +- That adaptive at anchor 0 equals `ji-static-5limit-C` position-for-position, + and at anchor 7 equals `ji-static-5limit-G` — the transposition identity. +- That the missing-context default is C and is **not** an error. +- Mutation: break `rem_euclid` to `%` and confirm the flat-key test dies; make + the unregistered-id path fall back to C and confirm the hard-error test dies. +- That no `Codec` was added and canonical bytes are unmoved.