diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 7371473..fd37030 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -761,3 +761,116 @@ weakened, no wire byte or discriminant touched. **Requirement counts did not move.** No new requirement was added or cited that did not already exist; `crates/epiphany-testkit/tests/requirement_labels.rs`'s 212/282/282 are unchanged. + +## Push 4b tranche 2: the tuning resolver lands, in memory, resolving a pitch to a frequency + +`spec/CONTRACT_PUSH4B_RESOLVER.md`. Same vertical-slice discipline as tranche +1: `TuningSystem`, `TuningResolution`, `TuningOverride`, `TuningScope`, a +partial built-in catalog, and the five-scope resolver land together, proven +with real frequencies (`tet-12` C5 ≈ 523.2511 Hz off A4 = 440; a JI major +third measurably distinct from the equal-tempered one), not `is_ok()`. + +**New module `src/tuning.rs`.** `TuningResolution` is deliberately a +**two**-of-six-variant enum: `EqualTemperament` and `PerPositionRatios` (plus +`PositionRatio`, which the specification's own listing never spells out the +fields of — defined here as a chromatic position plus a +`crate::pitch_space::JiRatio`, reusing rather than inventing a second +rational type). The other four variants (`Function`, `Overlay`, `Imported`, +`Adaptive`) are not transcribed: nothing in this tranche's catalog +constructs them, and their payload subtrees are exactly the unconsumed +surface tranche 1 already declined twice over. + +**The built-in catalog resolves nine of twenty, honestly.** The six `tet-*` +equal temperaments (`tet-12` pairs with `cmn-12`, the default pairing; +`tet-19/22/31/53/72` pair with the matching `edo-*` pitch spaces — forced by +the built-in catalog's cardinalities, not chosen) and the three +`ji-static-5limit-{C,G,D}` just-intonation systems. The latter's twelve +ratios are *computed* — `ji_static_5limit_ratios`, exact integer arithmetic +over the lattice block $\{3^a5^b \mid a\in[-1,2], b\in[-1,1]\}$, octave-reduced +by doubling/halving (never a float comparison) and sorted by cross- +multiplication (never a float division) — not pasted from +`core_spec.tex:4034-4046`'s table. A dedicated test +(`ji_static_5limit_lattice_matches_the_published_construction`) spot-checks +the code's output against that table at all three anchors, proving the two +state the same construction rather than merely agreeing to look similar. + +The remaining eleven (the ten historical temperaments and +`ji-adaptive-5limit`) are real catalog entries whose resolution this tranche +**defers**, distinguished from a genuinely unknown identifier by +`TuningCatalogEntry::{Resolved, Deferred}` — so `resolve_pitch_frequency` +can report "not yet supported, here's why" for a known-but-deferred system +and "not a built-in tuning system" for an unknown one, never the same error +for both, and never a guessed frequency for either. Tranche 2b re-derives the +ten temperaments from their ratified constructions (`core_spec.tex` +§"Temperament Constructions"); `ji-adaptive-5limit` waits on `HarmonicContext`, +out of scope per the spec itself. + +**Anchoring, done as a ratio-of-ratios so the arbitrary anchor cancels.** +`frequency_for_position` places both the target position and the reference +position on one absolute integer coordinate (generalizing +`Pitch::twelve_tet_semitone`'s idea from a fixed 12 to any tuning's own +divisions, and from `Cmn` positions to `Integer` ones for the EDO spaces), +computes each one's frequency ratio relative to coordinate 0 under the +tuning's resolution, and takes `reference.frequency_hz() * ratio(position) / +ratio(reference.position)`. Which position a construction calls "1/1" cancels +out of that quotient — proven the hard way: the first draft of the JI-major- +third test anchored the comparison at A4 = 440 Hz (the score's own default +reference) and asserted the just third would be flatter than tet-12's; it +failed, because JI-static-5limit-C retunes A relative to C differently than +tet-12 does, so comparing frequencies referenced through A silently mixes +"how A retunes" into "how E retunes." The fix anchors the reference at C4 +itself for both systems, isolating the C-to-E interval the test is actually +about — the resolver's arithmetic was correct throughout; the first test +design wasn't. + +**The five-scope walk resolves each of pitch space, tuning system, and +reference independently** (`req:tuning:tuning-resolution-order`), voice then +staff then region then the score default, with an explicit +`TuningReference::Explicit` short-circuiting the tuning-system component at +step 1 (pitch space and reference have no step-1 concept of their own — an +`AcousticPitch` carries no field for either) and `AcousticRealization::AbsoluteHz` +short-circuiting the whole frequency, bypassing the walk and the catalog +entirely. "Each region enclosing the pitch, innermost to outermost" turns out +to be exactly **one** region in this data model: a `Voice` is owned by exactly +one `StaffInstance`, owned by exactly one `Region` (containment, not a +derived time-range query), so there is no nested-region multiplicity to walk. +`TuningScope::Range` is defined (Chapter 4's fourth scope variant) but the +walk never matches it — `req:tuning:tuning-resolution-order` enumerates +exactly five steps and does not mention it, so inventing a sixth would be +exactly the kind of unratified addition this project's process exists to +catch; documented as a scope note, not silently dropped. + +**The compatibility check accepts only exact `pitch_space` equality** +(`req:tuning:tuning-system-compatibility`): no compatibility-mapping registry +exists, matching how tranche 1 left the pitch-space registry unbuilt. A +mismatch (e.g. `tet-19`'s declared `edo-19` against an unchanged `cmn-12` +default) is rejected, not silently resolved — proof-of-life item 4. + +**`ScoreTuningContext` gains `overrides: Vec`, in memory +only.** This is the one wire-adjacent change, and it isn't a wire change: the +type's canonical encoding stays exactly the three fields it always had +(`default_pitch_space`, `default_tuning_system`, `reference`), because adding +a fourth field to a `struct_codec!`-generated type breaks the macro outright +— its generated `dec` ends in a struct literal naming every field it was +given, so a fourth field cannot compile against it. The `struct_codec!` line +is replaced with a hand-written `impl Codec` (`codec.rs`) and `impl TextValue` +(`textvalue_graph.rs`) that encode/project exactly the three wire fields, in +their original order, and construct `overrides: Vec::new()` unconditionally +on decode/parse. Two round-trip tests prove the field never reaches either +canonical surface: `codec::tests::score_tuning_context_overrides_do_not_reach_the_wire` +(a context with a non-empty `overrides` encodes to byte-identical output as +one with empty `overrides`, and decoding either reconstructs `overrides` as +empty) and `textvalue_graph::tests::score_tuning_context_round_trips_and_overrides_do_not_project` +(the same, for the text projection). Field order in the Rust struct is free +(the manual codec fixes the wire order independently); the specification's +eventual major-3 field order puts `overrides` last, after +`accidental_extensions` and `smufl` — that pairing is the wire tranche's +problem, not this one's. + +**No `Codec` impl exists for anything new in `tuning.rs`.** These types are +referenced only by id and by the one in-memory `ScoreTuningContext` field; +they stay free to change once the wire tranche (schema major 3) discovers +something about them. + +**Requirement counts did not move again.** No `.tex` file was touched, no +requirement added; the 212/282/282 counts stay put. diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 1678f17..90263a4 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -1832,11 +1832,37 @@ struct_codec!(BeatGroup { subdivision, accent }); -struct_codec!(ScoreTuningContext { - default_pitch_space, - default_tuning_system, - reference -}); +// `ScoreTuningContext` gained a fourth, in-memory-only field (`overrides`, +// Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`) that must **not** +// reach the wire: schema major 3 has not been opened. `struct_codec!` cannot +// express that — its generated `dec` ends in a struct literal naming every +// field it was given, so a fourth field either goes on the wire (freezing an +// in-memory-only type before it has a consumer) or the macro cannot build the +// value at all. Hand-written instead: encode/decode exactly the three wire +// fields, in their original order, and construct `overrides: Vec::new()` on +// decode. A round-trip test just below +// (`score_tuning_context_overrides_do_not_reach_the_wire`) proves a non-empty +// `overrides` encodes to the same bytes as an empty one; the matching text- +// projection proof is `textvalue_graph.rs`'s +// `score_tuning_context_round_trips_and_overrides_do_not_project`. +impl Codec for ScoreTuningContext { + fn enc(&self, out: &mut Vec) { + self.default_pitch_space.enc(out); + self.default_tuning_system.enc(out); + self.reference.enc(out); + } + fn dec(r: &mut Reader<'_>) -> Result { + let default_pitch_space = Codec::dec(r)?; + let default_tuning_system = Codec::dec(r)?; + let reference = Codec::dec(r)?; + Ok(ScoreTuningContext { + default_pitch_space, + default_tuning_system, + reference, + overrides: Vec::new(), + }) + } +} // Schema major 2: the cross-cutting bodies filled (appended fields); the // frozen prior layouts are read by the `dec_*_v1` sub-decoders. struct_codec!(Slur { @@ -3381,6 +3407,44 @@ mod tests { } } + #[test] + fn score_tuning_context_overrides_do_not_reach_the_wire() { + use crate::graph::ScoreTuningContext; + use crate::ids::{ReplicaId, VoiceId}; + use crate::pitch::TuningSystemId; + use crate::tuning::{TuningOverride, TuningScope}; + + let without_overrides = ScoreTuningContext::default(); + let mut with_overrides = ScoreTuningContext::default(); + with_overrides.overrides.push(TuningOverride { + scope: TuningScope::Voice(VoiceId::new(ReplicaId(1), 1)), + pitch_space: None, + tuning_system: Some(TuningSystemId::new("tet-19")), + reference: None, + }); + // Sanity: the two in-memory values actually differ, so a + // byte-identity assertion below is not vacuous. + assert_ne!( + with_overrides, without_overrides, + "the fixture must actually carry a non-empty override in memory" + ); + + let mut bytes_with = Vec::new(); + with_overrides.enc(&mut bytes_with); + let mut bytes_without = Vec::new(); + without_overrides.enc(&mut bytes_without); + assert_eq!( + bytes_with, bytes_without, + "overrides must not reach canonical bytes (Push 4b tranche 2, Ruling C)" + ); + + // Decoding either stream reconstructs `overrides` as empty — the + // field never round-trips, by construction. + let decoded = ScoreTuningContext::dec(&mut Reader::new(&bytes_with)).expect("decodes"); + assert_eq!(decoded, without_overrides); + assert!(decoded.overrides.is_empty()); + } + #[test] fn generator_scores_round_trip() { for seed in 0..200u64 { diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index 3c984f5..b466cb6 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -1641,12 +1641,31 @@ pub struct ViewDefinition { /// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline: /// the default pitch space, tuning system, and reference pitch every score must -/// declare; per-scope overrides and accidental extensions are deferred. +/// declare; per-scope overrides land here (Push 4b tranche 2), accidental +/// extensions are still deferred. +/// +/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`).** The +/// canonical encoding stays **exactly** `default_pitch_space`, +/// `default_tuning_system`, `reference`, in that order — schema major 3 has +/// not been opened, so `overrides` is *not* on the wire this tranche. See the +/// hand-written `impl Codec` in `codec.rs` and `impl TextValue` in +/// `textvalue_graph.rs` (replacing the `struct_codec!` this type used to use, +/// which named exactly three fields in its generated decoder and so cannot +/// compile against a fourth). Where `overrides` sits in *this* Rust struct is +/// free — the manual codec fixes the wire order independently of field +/// declaration order — but the specification's eventual major-3 field order +/// places `overrides` last, after `accidental_extensions` and `smufl`; adding +/// those two remains the wire tranche's job, not this one's. #[derive(Clone, PartialEq, Eq, Debug)] pub struct ScoreTuningContext { pub default_pitch_space: PitchSpaceId, pub default_tuning_system: TuningSystemId, pub reference: ReferencePitch, + /// Per-scope overrides consulted by the tuning resolver + /// (`crate::tuning::resolve_pitch_frequency`), scopes 2-4 of + /// `req:tuning:tuning-resolution-order`. **In memory only** this + /// tranche — see the struct doc above. + pub overrides: Vec, } impl Default for ScoreTuningContext { @@ -1657,6 +1676,7 @@ impl Default for ScoreTuningContext { default_pitch_space: PitchSpaceId::new("cmn-12"), default_tuning_system: TuningSystemId::new("tet-12"), reference: ReferencePitch::a440(), + overrides: Vec::new(), } } } diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index d00e1de..5d9d57d 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -32,6 +32,13 @@ //! [`TranspositionBehavior`]) and [`built_in_position_structure`], the //! built-in catalog `Pitch::transposed` resolves against. In-memory only — //! 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` +//! (Push 4b Ruling C). //! * `event` — the [`Event`] taxonomy and the [`EventArena`] (Chapter 5 //! §"The Event Arena"). //! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct @@ -65,6 +72,7 @@ mod textvalue_impls; mod textvalue_pitch; mod textvalue_time; mod time; +mod tuning; pub mod fuzz; pub mod generators; @@ -141,6 +149,12 @@ pub use tempo::{ INVERSION_MAX_DENOMINATOR, INVERSION_MAX_ITERATIONS, }; +pub use tuning::{ + built_in_tuning_system, frequency_for_position, resolve_pitch_frequency, resolve_tuning_scope, + PositionRatio, ResolvedTuning, TuningCatalogEntry, TuningOverride, TuningResolution, + TuningResolutionError, TuningScope, TuningSystem, +}; + pub use codec::{CanonicalValue, ScoreDecodeError}; pub use indexes::ScoreIndexes; diff --git a/crates/epiphany-core/src/pitch.rs b/crates/epiphany-core/src/pitch.rs index 65a650f..0df7ac4 100644 --- a/crates/epiphany-core/src/pitch.rs +++ b/crates/epiphany-core/src/pitch.rs @@ -633,12 +633,15 @@ impl Pitch { /// tolerance of any *other* class is a category error and never matches. /// /// Frequency resolution in general depends on the full tuning-system catalog - /// and reference pitch — a separate subsystem (the acoustic engine, - /// Chapter 1; see `DECISIONS.md`), not modeled in this crate. Callers at that - /// layer pass a `resolve` closure mapping a pitch to its frequency in Hertz - /// (`None` if it cannot resolve it). An [`AcousticRealization::AbsoluteHz`] - /// pitch resolves to its own stated frequency without the closure. Returns - /// `false` if either frequency is unavailable. + /// and reference pitch. The deterministic part of that now lives in this + /// crate (`tuning::resolve_pitch_frequency`, Push 4b tranche 2); the full + /// acoustic engine (Chapter 1; see `DECISIONS.md`) remains a separate + /// subsystem. This method stays resolver-agnostic on purpose: callers pass a + /// `resolve` closure mapping a pitch to its frequency in Hertz (`None` if it + /// cannot resolve it), which may delegate to the in-crate resolver or any + /// other source. An [`AcousticRealization::AbsoluteHz`] pitch resolves to its + /// own stated frequency without the closure. Returns `false` if either + /// frequency is unavailable. pub fn sounding_equivalent( &self, other: &Pitch, diff --git a/crates/epiphany-core/src/textvalue_graph.rs b/crates/epiphany-core/src/textvalue_graph.rs index 6e939ad..bcf3a45 100644 --- a/crates/epiphany-core/src/textvalue_graph.rs +++ b/crates/epiphany-core/src/textvalue_graph.rs @@ -26,9 +26,9 @@ use epiphany_determinism::CanonicalF64; use crate::graph::{ AnnotationAnchor, DecompositionSource, EventOrderingDAG, GestureAnchoring, KeySignature, - MetadataValue, RegionContent, RegionTimeModel, RepeatKind, SoundConfiguration, SpaceUnit, - SpannerKind, StaffGroupKind, TieClass, TimeSignature, TimeSignatureDisplay, Timestamp, - TupletRatio, VoiceOrigin, + MetadataValue, RegionContent, RegionTimeModel, RepeatKind, ScoreTuningContext, + SoundConfiguration, SpaceUnit, SpannerKind, StaffGroupKind, TieClass, TimeSignature, + TimeSignatureDisplay, Timestamp, TupletRatio, VoiceOrigin, }; use crate::textvalue::{kebab, Sexp, TextError, TextValue}; use crate::textvalue_impls::class_of; @@ -286,6 +286,47 @@ impl TextValue for TimeSignature { } } +// =========================================================================== +// A struct whose `Codec` is hand-written for a macro-incompatibility reason, +// not a validating constructor. +// =========================================================================== + +/// `(score-tuning-context +/// )` — exactly the three wire fields, in `fn enc` order +/// (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`). +/// +/// `ScoreTuningContext` gained a fourth field, `overrides`, that is +/// deliberately **not** part of this projection: it is in-memory only (no +/// schema major 3 has been opened), so it must never reach the wire, and the +/// text projection is the same canonical surface the binary codec is — a +/// value that omits it here would otherwise silently launder a +/// non-empty-`overrides` context into one indistinguishable from an +/// empty-`overrides` context, which is exactly the intended behavior, not an +/// oversight. `parse` always constructs `overrides: Vec::new()`, mirroring +/// `Codec::dec`. +impl TextValue for ScoreTuningContext { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::Symbol(kebab("ScoreTuningContext")), + self.default_pitch_space.project(), + self.default_tuning_system.project(), + self.reference.project(), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct(&kebab("ScoreTuningContext"), 3)?; + let default_pitch_space = TextValue::parse(&fields[0])?; + let default_tuning_system = TextValue::parse(&fields[1])?; + let reference = TextValue::parse(&fields[2])?; + Ok(ScoreTuningContext { + default_pitch_space, + default_tuning_system, + reference, + overrides: Vec::new(), + }) + } +} + // =========================================================================== // Tagged unions. // =========================================================================== @@ -888,6 +929,33 @@ mod tests { round_trip(EventOrderingDAG::default()); } + #[test] + fn score_tuning_context_round_trips_and_overrides_do_not_project() { + // Empty overrides: ordinary identity round-trip. + round_trip(ScoreTuningContext::default()); + + // Non-empty overrides: the text projection is identical to the + // empty-overrides projection (the field is in-memory only, Push 4b + // tranche 2 / Ruling C), and parsing always reconstructs `overrides` + // as empty. + let mut with_overrides = ScoreTuningContext::default(); + with_overrides + .overrides + .push(crate::tuning::TuningOverride { + scope: crate::tuning::TuningScope::Staff(StaffId::new(ReplicaId(1), 1)), + pitch_space: None, + tuning_system: None, + reference: None, + }); + assert_eq!( + with_overrides.project().render(), + ScoreTuningContext::default().project().render(), + "overrides must not appear in the text projection" + ); + let parsed = ScoreTuningContext::parse(&with_overrides.project()).unwrap(); + assert!(parsed.overrides.is_empty()); + } + #[test] fn tagged_unions_round_trip() { round_trip(SpannerKind::Generic); diff --git a/crates/epiphany-core/src/tuning.rs b/crates/epiphany-core/src/tuning.rs new file mode 100644 index 0000000..d5df347 --- /dev/null +++ b/crates/epiphany-core/src/tuning.rs @@ -0,0 +1,982 @@ +//! Chapter 4 tuning-resolution vocabulary and the resolver +//! (`core_spec.tex` §"Tuning Systems", `sec:tuning:system`, `:3279` onward), +//! Push 4b tranche 2 (`spec/CONTRACT_PUSH4B_RESOLVER.md`). +//! +//! Like [`crate::pitch_space`] this is a **vertical slice that stays in +//! memory**: [`TuningSystem`], [`TuningResolution`], [`TuningOverride`], +//! [`TuningScope`], the built-in catalog, and [`resolve_pitch_frequency`] +//! (the resolver) land together, with a behavioural proof of life (real +//! frequencies asserted, not `is_ok()`) rather than as three separate +//! passes. **No `Codec` impl exists, or may be added, for anything in this +//! module** (Ruling C, `spec/PLAN_PUSH4B_TUNING.md`): these types are +//! referenced only by id from canonical score state (`ScoreTuningContext`'s +//! wire form stays the three fields it always had — see the hand-written +//! codec in `codec.rs`), so they stay free to change once a later tranche +//! discovers they are wrong. +//! +//! ## What this tranche resolves, and what it does not +//! +//! [`TuningResolution`] is a **six**-variant enum in the specification +//! (`core_spec.tex:3309`); this tranche defines only the two variants the +//! built-in catalog below actually constructs — +//! [`TuningResolution::EqualTemperament`] and +//! [`TuningResolution::PerPositionRatios`] — plus [`PositionRatio`]. The +//! other four are transcribed only when a built-in needs them, so their +//! unconstructed payload subtrees (`TuningParameters`, `ImportedTuningData`, +//! `AdaptiveTuningParameters`, …) never become an unconsumed type surface +//! (the `NOTEHEAD_ANCHORS` failure): `Function` waits on Push 4b tranche 2b, +//! which re-derives the ten historical temperaments from their ratified +//! constructions; `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. +//! +//! [`built_in_tuning_system`] resolves **nine** of the twenty catalog +//! identifiers (`req:tuning:builtin-tuning-catalog`): the six `tet-*` equal +//! temperaments and the three `ji-static-5limit-*` just-intonation systems. +//! The ten historical temperaments and `ji-adaptive-5limit` are real catalog +//! entries whose resolution this tranche defers +//! ([`TuningCatalogEntry::Deferred`]) — never a guessed frequency. +//! +//! ## The compatibility check, narrowed the same way tranche 1 narrowed it +//! +//! `req:tuning:tuning-system-compatibility` (`:3581`) allows a resolved tuning +//! system's `pitch_space` to differ from the resolved pitch space when a +//! *registered compatibility mapping* declares them compatible. **No such +//! registry exists** in this tranche (matching how tranche 1 left the +//! pitch-space-mapping registry unbuilt, `spec/PLAN_PUSH4B_TUNING.md` Ruling +//! C): [`resolve_pitch_frequency`] accepts only exact `pitch_space` equality +//! and fails closed on any mismatch, a deliberate deferral, not an oversight. + +use core::num::NonZeroU32; + +use crate::graph::{Score, ScoreTuningContext}; +use crate::ids::{RegionId, StaffId, VoiceId}; +use crate::pitch::{ + AcousticRealization, Pitch, PitchSpaceId, PitchSpacePosition, ReferencePitch, TuningReference, + TuningSystemId, VoiceSelector, +}; +use crate::pitch_space::{built_in_position_structure, JiRatio, PositionStructure}; +use crate::time::TimeAnchor; + +// =========================================================================== +// Types (Chapter 4 §"Tuning Systems" / §"Score Tuning Context and +// Hierarchical Resolution"). +// =========================================================================== + +/// One entry of a [`TuningResolution::PerPositionRatios`] catalog +/// (`core_spec.tex:3314-3316`): "Explicit per-position ratios. Each entry is +/// a ratio relative to the reference position." The specification's own +/// listing writes only `PerPositionRatios(Vec)` and never +/// spells out `PositionRatio`'s fields — nothing in `core_spec.tex` +/// constructs one but the three `ji-static-5limit-*` built-ins — so this +/// tranche defines it as narrowly as those three need: a chromatic position +/// plus the exact ratio-to-anchor at that position, reusing +/// [`JiRatio`](crate::pitch_space::JiRatio) rather than inventing a second +/// rational type. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct PositionRatio { + /// The chromatic position this ratio governs: `0..divisions_per_octave` + /// of the enclosing pitch space's chromatic layer (for the built-ins + /// below, always `0..12`, `cmn-12`'s chromatic layer). + pub position: i32, + /// The exact frequency ratio of `position` relative to the tuning's own + /// 1/1 (its anchor), octave-reduced into `[1, 2)`. + pub ratio: JiRatio, +} + +/// How a tuning system resolves pitch-space positions to frequencies +/// (`core_spec.tex:3309-3348`). **Deliberately partial**: the specification +/// names six variants; this tranche defines the two the built-in catalog +/// constructs. See the module doc for which tranche completes each of the +/// other four. +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum TuningResolution { + /// N-tone equal temperament: each step is the Nth root of the octave + /// ratio. Includes 12-TET when `divisions_per_octave == 12`. + EqualTemperament { divisions_per_octave: u16 }, + /// Explicit per-position ratios, each relative to the reference + /// position. + PerPositionRatios(Vec), +} + +/// A tuning system: a map from pitch-space positions to frequencies, given a +/// reference pitch (`core_spec.tex:3287-3303`). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct TuningSystem { + pub id: TuningSystemId, + /// Human-readable name. + pub name: String, + /// The pitch space whose positions this tuning resolves. + pub pitch_space: PitchSpaceId, + /// How the tuning resolves positions to frequencies, given a reference + /// pitch. + pub resolution: TuningResolution, + /// Optional historical or provenance notes. + pub description: Option, +} + +/// The scope a [`TuningOverride`] applies to (`core_spec.tex:3534-3539`). +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum TuningScope { + Voice(VoiceId), + Staff(StaffId), + Region(RegionId), + Range { + start: TimeAnchor, + end: TimeAnchor, + voices: VoiceSelector, + }, +} + +/// A per-scope override of one or more tuning components +/// (`core_spec.tex:3527-3532`). `None` fields inherit from the next-outer +/// scope, per `req:tuning:tuning-resolution-order`. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct TuningOverride { + pub scope: TuningScope, + pub pitch_space: Option, + pub tuning_system: Option, + pub reference: Option, +} + +// =========================================================================== +// The built-in catalog, as data (partial, honestly) — item 2. +// =========================================================================== + +/// 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 +/// [`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." +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum TuningCatalogEntry { + Resolved(TuningSystem), + /// Why this identifier's resolution is deferred, and to what. + Deferred(&'static str), +} + +/// Looks up a built-in [`TuningSystem`] (Chapter 4 §"Built-in Catalog", +/// `core_spec.tex:3656-3694`, `req:tuning:builtin-tuning-catalog`). +/// +/// Nine of the twenty resolve this tranche: +/// +/// * the six `tet-*` — [`TuningResolution::EqualTemperament`] from the +/// identifier's divisions. `tet-12` pairs with `cmn-12` (the default +/// pairing, `core_spec.tex:4058-4068`); `tet-19/22/31/53/72` pair with the +/// built-in `edo-19/22/31/53/72` pitch spaces — the only built-in pitch +/// spaces whose [`PositionStructure::Chromatic`] cardinality matches, so +/// the pairing is forced by the catalog rather than chosen; +/// * the three `ji-static-5limit-{C,G,D}` — [`TuningResolution::PerPositionRatios`] +/// computed by `ji_static_5limit_ratios` from the lattice block +/// (`req:tuning:ji-static-construction`), over `cmn-12`'s twelve chromatic +/// positions (`core_spec.tex:4019-4021`: "assigned in ascending order to +/// the twelve chromatic positions of `cmn-12`"). +/// +/// The other eleven are real catalog entries whose resolution is deferred, +/// not guessed ([`TuningCatalogEntry::Deferred`]): +/// +/// * the ten historical temperaments (`pythagorean`, three `meantone-*`, +/// `werckmeister-iii`/`-iv`, `vallotti`, `kirnberger-ii`/`-iii`, +/// `young-ii`) — Push 4b tranche 2b re-derives each construction's +/// twelve-fifth closure in code (see `spec/CONTRACT_PUSH4B_RESOLVER.md`'s +/// closing section); +/// * `ji-adaptive-5limit` — needs `HarmonicContext` +/// (`req:tuning:adaptive-default-version`), which does not exist in Rust. +/// +/// `None` for any other identifier: not one of the twenty at all. +pub fn built_in_tuning_system(id: &TuningSystemId) -> Option { + fn tet( + name: &'static str, + pitch_space: &'static str, + divisions: u16, + desc: &'static str, + ) -> TuningCatalogEntry { + TuningCatalogEntry::Resolved(TuningSystem { + id: TuningSystemId::new(name), + name: name.to_owned(), + pitch_space: PitchSpaceId::new(pitch_space), + resolution: TuningResolution::EqualTemperament { + divisions_per_octave: divisions, + }, + description: Some(desc.to_owned()), + }) + } + fn ji_static( + name: &'static str, + anchor_chromatic_degree: i32, + tonic: &'static str, + ) -> TuningCatalogEntry { + TuningCatalogEntry::Resolved(TuningSystem { + id: TuningSystemId::new(name), + name: name.to_owned(), + pitch_space: PitchSpaceId::new("cmn-12"), + resolution: TuningResolution::PerPositionRatios(ji_static_5limit_ratios( + anchor_chromatic_degree, + )), + description: Some(format!( + "Static 5-limit just intonation anchored to {tonic} tonic." + )), + }) + } + const DEFERRED_TEMPERAMENT: &str = "historical temperament; construction re-derivation is \ + Push 4b tranche 2b (spec/CONTRACT_PUSH4B_RESOLVER.md, closing section)"; + const DEFERRED_ADAPTIVE: &str = + "adaptive tuning needs HarmonicContext, which does not exist in Rust (out of scope this tranche)"; + match id.as_str() { + "tet-12" => Some(tet( + "tet-12", + "cmn-12", + 12, + "12-tone equal temperament. The default.", + )), + "tet-19" => Some(tet("tet-19", "edo-19", 19, "19-tone equal temperament.")), + "tet-22" => Some(tet("tet-22", "edo-22", 22, "22-tone equal temperament.")), + "tet-31" => Some(tet("tet-31", "edo-31", 31, "31-tone equal temperament.")), + "tet-53" => Some(tet("tet-53", "edo-53", 53, "53-tone equal temperament.")), + "tet-72" => Some(tet("tet-72", "edo-72", 72, "72-tone equal temperament.")), + "ji-static-5limit-C" => Some(ji_static("ji-static-5limit-C", 0, "C")), + "ji-static-5limit-G" => Some(ji_static("ji-static-5limit-G", 7, "G")), + "ji-static-5limit-D" => Some(ji_static("ji-static-5limit-D", 2, "D")), + "pythagorean" | "meantone-1/4-comma" | "meantone-1/5-comma" | "meantone-1/6-comma" + | "werckmeister-iii" | "werckmeister-iv" | "vallotti" | "kirnberger-ii" + | "kirnberger-iii" | "young-ii" => Some(TuningCatalogEntry::Deferred(DEFERRED_TEMPERAMENT)), + "ji-adaptive-5limit" => Some(TuningCatalogEntry::Deferred(DEFERRED_ADAPTIVE)), + _ => None, + } +} + +/// The greatest common divisor of two positive integers (Euclid's +/// algorithm), used to keep [`ji_static_5limit_ratios`]'s fractions in +/// lowest terms. +fn gcd(a: i64, b: i64) -> i64 { + if b == 0 { + a + } else { + gcd(b, a % b) + } +} + +/// Computes the twelve, anchor-relative ratios of the static 5-limit +/// construction (`req:tuning:ji-static-construction`, `core_spec.tex:4015-4024`, +/// read and verified before citing): the lattice block +/// $\{3^a 5^b \mid a \in [-1,2],\ b \in [-1,1]\}$ — twelve cells, generated +/// by its bounds, nothing selected or discarded — octave-reduced into +/// `[1, 2)` and assigned in ascending order starting from the anchor, which +/// takes the role of `1/1`. Computed here in code, in exact integer +/// arithmetic (never a pasted cents or ratio table), exactly the same +/// construction the specification states in prose. +/// +/// `anchor_chromatic_degree` is the `cmn-12` chromatic position (0 = C, +/// 7 = G, 2 = D, …) playing the role of `1/1`; the returned table's +/// `position` fields are `(anchor_chromatic_degree + step) mod 12` for the +/// construction's ascending step order, so indexing the result by +/// *chromatic position* (not by lattice step) gives each position's +/// ratio-to-anchor directly. +fn ji_static_5limit_ratios(anchor_chromatic_degree: i32) -> Vec { + let mut cells: Vec<(i64, i64)> = Vec::with_capacity(12); + for a in -1..=2i32 { + for b in -1..=1i32 { + let mut num: i64 = 1; + let mut den: i64 = 1; + if a >= 0 { + num *= 3i64.pow(a as u32); + } else { + den *= 3i64.pow((-a) as u32); + } + if b >= 0 { + num *= 5i64.pow(b as u32); + } else { + den *= 5i64.pow((-b) as u32); + } + // Octave-reduce into [1, 2) by exact integer doubling/halving — + // never a float comparison, so the reduction cannot introduce + // rounding error of its own. + while num >= 2 * den { + den *= 2; + } + while num < den { + num *= 2; + } + let g = gcd(num, den); + cells.push((num / g, den / g)); + } + } + // Sort ascending by value, comparing by cross-multiplication so the + // ordering is exact (no float division). + cells.sort_by(|(n1, d1), (n2, d2)| (n1 * d2).cmp(&(n2 * d1))); + cells + .into_iter() + .enumerate() + .map(|(step, (num, den))| PositionRatio { + position: (anchor_chromatic_degree + step as i32).rem_euclid(12), + ratio: JiRatio { + numerator: num as i32, + denominator: NonZeroU32::new(den as u32) + .expect("an octave-reduced denominator is a positive power of two times an odd factor, never zero"), + }, + }) + .collect() +} + +// =========================================================================== +// The frequency resolver (item 3): (position, TuningSystem, ReferencePitch) +// -> Hz. +// =========================================================================== + +/// Why a tuning could not be resolved to a frequency. Every variant is a +/// closed failure, never a fallback frequency +/// (`req:tuning:tuning-resolution-determinism`). +#[derive(Clone, PartialEq, Eq, Debug)] +pub enum TuningResolutionError { + /// `voice` is not the id of any voice reachable from the score's + /// `canvas.regions` — a caller error (an orphaned or foreign + /// [`VoiceId`]), not a tuning failure. + VoiceNotFound(VoiceId), + /// The resolved tuning-system identifier is not in the built-in catalog + /// at all (no registry for score-defined tuning systems exists, Ruling C + /// of `spec/PLAN_PUSH4B_TUNING.md`). + UnknownTuningSystem(TuningSystemId), + /// The resolved tuning-system identifier is a real catalog entry whose + /// resolution this tranche defers (see [`built_in_tuning_system`]); the + /// reason names which tranche completes it. + NotYetSupported { + id: TuningSystemId, + reason: &'static str, + }, + /// The resolved pitch space does not resolve structurally at all + /// ([`built_in_position_structure`] returned `None`) — an unknown + /// identifier or one of the six built-in spaces the specification names + /// but does not structurally determine (`crate::pitch_space`). + UnresolvedPitchSpace(PitchSpaceId), + /// `req:tuning:tuning-system-compatibility`: the resolved tuning + /// system's declared `pitch_space` differs from the resolved pitch + /// space, and no compatibility-mapping registry exists this tranche — + /// a deliberate deferral (see the module doc), not a guess. + IncompatiblePitchSpace { + pitch_space: PitchSpaceId, + tuning_system_pitch_space: PitchSpaceId, + }, + /// The pitch's (or the reference's) position could not be placed on the + /// tuning system's coordinate frame: a structural mismatch between the + /// resolved pitch space's [`PositionStructure`] and the + /// [`PitchSpacePosition`] variant in play, or between its chromatic + /// cardinality and the tuning system's own divisions/table length. + PositionUnavailable, +} + +impl core::fmt::Display for TuningResolutionError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::VoiceNotFound(v) => write!(f, "voice {v:?} is not reachable from the score graph"), + Self::UnknownTuningSystem(id) => write!(f, "'{id}' is not a built-in tuning system"), + Self::NotYetSupported { id, reason } => { + write!(f, "'{id}' is not yet supported: {reason}") + } + Self::UnresolvedPitchSpace(id) => { + write!(f, "'{id}' does not resolve to a structurally determined pitch space") + } + Self::IncompatiblePitchSpace { + pitch_space, + tuning_system_pitch_space, + } => write!( + f, + "resolved pitch space '{pitch_space}' is incompatible with the tuning system's declared \ + pitch space '{tuning_system_pitch_space}' (no compatibility mapping registry exists)" + ), + Self::PositionUnavailable => { + f.write_str("the pitch space position could not be placed on the tuning system's coordinate frame") + } + } + } +} + +impl std::error::Error for TuningResolutionError {} + +/// The absolute tuning-coordinate of `position`, in the coordinate frame a +/// tuning system with `divisions` positions-per-octave uses — the same +/// absolute chromatic-coordinate idea [`Pitch::twelve_tet_semitone`] +/// (`crate::pitch`) uses for `cmn-12`, generalized from a fixed 12 to any +/// `N` and to [`PitchSpacePosition::Integer`] (the EDO pitch spaces' +/// position kind) as well as [`PitchSpacePosition::Cmn`]. +fn absolute_coordinate( + position: &PitchSpacePosition, + structure: &PositionStructure, + divisions: u32, +) -> Option { + match (position, structure) { + ( + PitchSpacePosition::Cmn { + nominal, + alteration, + octave, + }, + PositionStructure::DiatonicOverChromatic { + chromatic_positions_per_octave, + nominal_to_chromatic, + .. + }, + ) if u32::from(*chromatic_positions_per_octave) == divisions => { + let degree = i64::from(nominal_to_chromatic[*nominal as usize]); + Some(i64::from(*octave) * i64::from(divisions) + degree + i64::from(*alteration)) + } + ( + PitchSpacePosition::Integer { space_size, index }, + PositionStructure::Chromatic { + positions_per_octave, + }, + ) if u32::from(*space_size) == divisions + && u32::from(*positions_per_octave) == divisions => + { + Some(i64::from(*index)) + } + _ => None, + } +} + +/// The full-register frequency ratio of absolute coordinate `s`, relative to +/// coordinate `0` under `resolution`. +fn coordinate_ratio(resolution: &TuningResolution, s: i64) -> Option { + match resolution { + TuningResolution::EqualTemperament { + divisions_per_octave, + } => { + if *divisions_per_octave == 0 { + return None; + } + Some(2f64.powf(s as f64 / f64::from(*divisions_per_octave))) + } + TuningResolution::PerPositionRatios(table) => { + let n = i64::try_from(table.len()).ok().filter(|n| *n > 0)?; + let degree = i32::try_from(s.rem_euclid(n)).ok()?; + let octave = i32::try_from(s.div_euclid(n)).ok()?; + let entry = table.iter().find(|pr| pr.position == degree)?; + let base = f64::from(entry.ratio.numerator) / f64::from(entry.ratio.denominator.get()); + Some(base * 2f64.powi(octave)) + } + } +} + +/// Computes the frequency in Hz at which `position` sounds under `system`, +/// anchored by `reference` (Chapter 4 §"Tuning Systems" / §"Reference +/// Pitch"; `req:tuning:tuning-resolution-determinism`). +/// +/// **Anchoring** — the one subtlety `spec/CONTRACT_PUSH4B_RESOLVER.md` +/// singles out: a [`TuningResolution`]'s ratios are relative to the +/// tuning's own 1/1 (its anchor), but `reference` fixes a *different* +/// position's absolute frequency. Both `position` and `reference.position` +/// are placed on the same absolute coordinate frame +/// (`absolute_coordinate`), and the frequency is +/// `reference.frequency_hz() * ratio(position) / ratio(reference.position)`. +/// The arbitrary choice of which position the construction calls "1/1" +/// cancels out of that quotient, so this is correct regardless of anchor — +/// for [`TuningResolution::EqualTemperament`] it reduces exactly to +/// `ref_freq · 2^((position − ref_position)/N)`. +pub fn frequency_for_position( + position: &PitchSpacePosition, + system: &TuningSystem, + reference: &ReferencePitch, +) -> Result { + let structure = built_in_position_structure(&system.pitch_space) + .ok_or_else(|| TuningResolutionError::UnresolvedPitchSpace(system.pitch_space.clone()))?; + let divisions = match &system.resolution { + TuningResolution::EqualTemperament { + divisions_per_octave, + } => u32::from(*divisions_per_octave), + TuningResolution::PerPositionRatios(table) => { + u32::try_from(table.len()).map_err(|_| TuningResolutionError::PositionUnavailable)? + } + }; + let s = absolute_coordinate(position, &structure, divisions) + .ok_or(TuningResolutionError::PositionUnavailable)?; + let s_ref = absolute_coordinate(&reference.position, &structure, divisions) + .ok_or(TuningResolutionError::PositionUnavailable)?; + let ratio_p = coordinate_ratio(&system.resolution, s) + .ok_or(TuningResolutionError::PositionUnavailable)?; + let ratio_ref = coordinate_ratio(&system.resolution, s_ref) + .ok_or(TuningResolutionError::PositionUnavailable)?; + Ok(reference.frequency_hz() * ratio_p / ratio_ref) +} + +// =========================================================================== +// The five-scope resolution walk (item 4). +// =========================================================================== + +/// The independently-resolved pitch space, tuning system, and reference +/// pitch that govern a pitch at a given location (`req:tuning:tuning-resolution-order`). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ResolvedTuning { + pub pitch_space: PitchSpaceId, + pub tuning_system: TuningSystemId, + pub reference: ReferencePitch, +} + +/// The first value `field` supplies from an override whose scope matches +/// `voice`, else `staff`, else `region`, in that priority order (scopes 2-4 +/// of `req:tuning:tuning-resolution-order`) — the same lookup used +/// identically for all three components the walk resolves. Within a tier, +/// overrides are consulted in `context.overrides`' own declared order ("in +/// the order they apply", `core_spec.tex:3523`), skipping past a +/// scope-matching override that leaves this particular component `None` +/// rather than stopping at it, so a later override at the same scope can +/// still supply the value this one didn't. +fn override_value( + context: &ScoreTuningContext, + voice: VoiceId, + staff: StaffId, + region: RegionId, + field: impl Fn(&TuningOverride) -> &Option, +) -> Option { + let tier = |matches_scope: &dyn Fn(&TuningScope) -> bool| -> Option { + context + .overrides + .iter() + .filter(|o| matches_scope(&o.scope)) + .find_map(|o| field(o).clone()) + }; + tier(&|s| matches!(s, TuningScope::Voice(v) if *v == voice)) + .or_else(|| tier(&|s| matches!(s, TuningScope::Staff(st) if *st == staff))) + .or_else(|| { + // Step 4: "each region enclosing the pitch, innermost to + // outermost". In this data model a `Voice` is owned by exactly + // one `StaffInstance`, owned by exactly one `Region` + // (containment, not a derived time-range query) — there is no + // nested-region concept, so "innermost to outermost" is exactly + // this one region. + // + // `TuningScope::Range` is part of the type (Chapter 4 defines + // it) but `req:tuning:tuning-resolution-order` enumerates + // exactly five steps and does not include it; this tranche's + // walk never matches it (see the module doc's scope note). + tier(&|s| matches!(s, TuningScope::Region(r) if *r == region)) + }) +} + +/// The five-scope walk of `req:tuning:tuning-resolution-order` +/// (`core_spec.tex:3549`, read and verified before citing): resolves each of +/// `pitch_space`, `tuning_system`, and `reference` independently, walking +/// from the pitch's own [`crate::pitch::AcousticPitch`] outward through +/// voice, staff, and region overrides to the score default. +/// +/// Step 1 supplies a value only for `tuning_system` (an explicit +/// [`TuningReference::Explicit`] short-circuits) — [`AcousticPitch`] carries +/// no pitch-space or reference field of its own, so those two components +/// always proceed to step 2. (A pitch's [`AcousticRealization::AbsoluteHz`] +/// short-circuits the *whole* frequency, bypassing this walk entirely; see +/// [`resolve_pitch_frequency`].) +/// +/// [`AcousticPitch`]: crate::pitch::AcousticPitch +pub fn resolve_tuning_scope( + pitch: &Pitch, + voice: VoiceId, + staff: StaffId, + region: RegionId, + context: &ScoreTuningContext, +) -> ResolvedTuning { + let tuning_system = match &pitch.acoustic.tuning { + TuningReference::Explicit(id) => id.clone(), + TuningReference::Inherit => { + override_value(context, voice, staff, region, |o| &o.tuning_system) + .unwrap_or_else(|| context.default_tuning_system.clone()) + } + }; + let pitch_space = override_value(context, voice, staff, region, |o| &o.pitch_space) + .unwrap_or_else(|| context.default_pitch_space.clone()); + let reference = override_value(context, voice, staff, region, |o| &o.reference) + .unwrap_or_else(|| context.reference.clone()); + ResolvedTuning { + pitch_space, + tuning_system, + reference, + } +} + +// =========================================================================== +// The top-level pipeline: walk, catalog lookup, compatibility check (item +// 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)> { + 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)); + } + } + } + None +} + +/// 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`. +/// +/// Step 1's other short-circuit — [`AcousticRealization::AbsoluteHz`] — +/// bypasses everything else: the frequency is already fixed, so neither the +/// scope walk nor the built-in catalog is consulted at all. +/// [`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"). +pub fn resolve_pitch_frequency( + score: &Score, + pitch: &Pitch, + voice: VoiceId, +) -> Result { + if let AcousticRealization::AbsoluteHz(hz) = pitch.acoustic.realization { + return Ok(hz.get()); + } + let (region, staff) = + 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 { + TuningCatalogEntry::Resolved(system) => system, + TuningCatalogEntry::Deferred(reason) => { + return Err(TuningResolutionError::NotYetSupported { + id: resolved.tuning_system, + reason, + }); + } + }; + // req:tuning:tuning-system-compatibility (`:3581`): accept only exact + // equality this tranche — see the module doc's compatibility note. + if system.pitch_space != resolved.pitch_space { + return Err(TuningResolutionError::IncompatiblePitchSpace { + pitch_space: resolved.pitch_space, + tuning_system_pitch_space: system.pitch_space, + }); + } + let base = + frequency_for_position(&pitch.scale_position.position, &system, &resolved.reference)?; + match pitch.acoustic.realization { + AcousticRealization::Implicit => Ok(base), + AcousticRealization::CentsOffset(c) => Ok(base * 2f64.powf(c.get() / 1200.0)), + AcousticRealization::AbsoluteHz(_) => unreachable!("handled by the early return above"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::graph::{ + Canvas, MetricTimeModel, Region, RegionContent, RegionTimeModel, StaffBasedContent, + StaffExtent, StaffInstance, TimeExtent, Voice, + }; + use crate::ids::{IdentityContext, ReplicaId, StaffInstanceId}; + use crate::pitch::{AcousticPitch, CmnNominal, ScalePosition}; + use crate::time::WallClockTime; + use epiphany_determinism::{Tolerance, ToleranceClass, ToleranceGovernance}; + + fn cents(c: f64) -> Tolerance { + Tolerance::absolute( + ToleranceClass::AcousticCents, + c, + ToleranceGovernance::Validation, + ) + .unwrap() + } + + /// Absolute cents between two frequencies — the metric the proof-of-life + /// tests assert against, per the contract's own instruction ("a + /// cents-level check is right"). + fn cents_between(a: f64, b: f64) -> f64 { + 1200.0 * (a / b).log2().abs() + } + + fn cmn_pitch(space: &str, nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch { + Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new(space), + position: PitchSpacePosition::Cmn { + nominal, + alteration, + octave, + }, + }, + acoustic: AcousticPitch { + tuning: TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + } + } + + fn wc_extent(a: i64, b: i64) -> TimeExtent { + TimeExtent { + start: TimeAnchor::WallClock { + time: WallClockTime(a), + }, + end: TimeAnchor::WallClock { + time: WallClockTime(b), + }, + } + } + + /// A minimal score: one region, one staff instance, two voices — enough + /// for `locate_voice` and the scope walk, nothing more. + struct Fixture { + score: Score, + voice_a: VoiceId, + voice_b: VoiceId, + } + + fn fixture() -> Fixture { + let r = ReplicaId(1); + let voice_a = VoiceId::new(r, 1); + let voice_b = VoiceId::new(r, 2); + let staff = StaffId::new(r, 1); + let mut instance = StaffInstance::new(StaffInstanceId::new(r, 1), staff); + instance.voices = vec![Voice::user(voice_a), Voice::user(voice_b)]; + let region = Region { + id: RegionId::new(r, 1), + time_model: RegionTimeModel::Metric(MetricTimeModel::default()), + content: RegionContent::StaffBased(StaffBasedContent { + staff_instances: vec![instance], + ..Default::default() + }), + time_extent: wc_extent(0, 1000), + staff_extent: StaffExtent { + staves: vec![staff], + }, + local_tempo_map: None, + permits_spanning_slurs: false, + }; + let mut score = Score::empty(IdentityContext::new(r)); + score.canvas = Canvas { + regions: vec![region], + ..Default::default() + }; + Fixture { + score, + voice_a, + voice_b, + } + } + + // -- Proof of life 1: tet-12, A4 = 440 Hz -> C5. -------------------------- + + #[test] + 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"); + assert!( + cents(0.01).within(cents_between(freq, 523.2511), 0.0), + "expected ~523.2511 Hz, got {freq}" + ); + } + + // -- Proof of life 2: ji-static-5limit-C's major third differs from -- + // -- tet-12's by the syntonic comma (~13.7 c). -- + + #[test] + fn ji_static_5limit_c_major_third_distinct_from_tet12() { + // Anchor the reference *at the tonic itself* (C4), so both systems' + // "1/1" and the comparison point coincide: the ratio this measures + // is then exactly each system's C-to-E interval, with no confound + // from how the two systems otherwise retune A differently (both + // being referenced against the *same* absolute A4 would silently + // mix "how A retunes" into "how E retunes" — a trap this test's + // first draft fell into). + let f = fixture(); + let c4_ref = ReferencePitch::new( + PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave: 4, + }, + 264.0, + ) + .unwrap(); + let mut ji_score = f.score.clone(); + ji_score.tuning_context.default_tuning_system = TuningSystemId::new("ji-static-5limit-C"); + ji_score.tuning_context.reference = c4_ref.clone(); + let mut tet_score = f.score.clone(); + 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) + .expect("ji-static-5limit-C resolves"); + let tet_freq = + resolve_pitch_frequency(&tet_score, &e4, f.voice_a).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!( + ji_freq < tet_freq, + "the just major third ({ji_freq} Hz) must be flatter than the equal-tempered one ({tet_freq} Hz)" + ); + let diff = cents_between(ji_freq, tet_freq); + assert!( + cents(0.05).within(diff, 13.6864), + "expected a ~13.6864 c syntonic-comma difference, got {diff}" + ); + } + + #[test] + fn ji_static_5limit_lattice_matches_the_published_construction() { + // `core_spec.tex:4034-4046`'s table, spot-checked at all three + // anchors: this proves the *code's* lattice-block construction + // reproduces the specification's own worked values, rather than the + // production code and the spec merely agreeing to look similar. + let ratio_at = |system: &str, degree: i32| -> (i32, u32) { + let TuningCatalogEntry::Resolved(sys) = + built_in_tuning_system(&TuningSystemId::new(system)).unwrap() + else { + panic!("{system} must resolve") + }; + let TuningResolution::PerPositionRatios(table) = sys.resolution else { + panic!("{system} must be PerPositionRatios") + }; + let entry = table.iter().find(|pr| pr.position == degree).unwrap(); + (entry.ratio.numerator, entry.ratio.denominator.get()) + }; + // ji-static-5limit-C: anchor at C (chromatic 0). C=1/1, E=5/4 (step 4), G=3/2 (step 7). + assert_eq!(ratio_at("ji-static-5limit-C", 0), (1, 1)); + assert_eq!(ratio_at("ji-static-5limit-C", 4), (5, 4)); + assert_eq!(ratio_at("ji-static-5limit-C", 7), (3, 2)); + // ji-static-5limit-G: anchor at G (chromatic 7), so G itself is 1/1. + assert_eq!(ratio_at("ji-static-5limit-G", 7), (1, 1)); + // ji-static-5limit-D: anchor at D (chromatic 2), so D itself is 1/1. + assert_eq!(ratio_at("ji-static-5limit-D", 2), (1, 1)); + } + + // -- Proof of life 3: scope precedence. ----------------------------------- + + #[test] + fn voice_scope_override_changes_resolution_for_its_voice_but_not_others() { + let mut f = fixture(); + f.score.tuning_context.overrides.push(TuningOverride { + scope: TuningScope::Voice(f.voice_a), + pitch_space: None, + tuning_system: None, + reference: Some( + ReferencePitch::new( + PitchSpacePosition::Cmn { + nominal: CmnNominal::A, + alteration: 0, + octave: 4, + }, + 415.0, + ) + .unwrap(), + ), + }); + 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"); + 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}" + ); + assert!( + cents(0.01).within(cents_between(in_voice_b, 440.0), 0.0), + "voice B must still see the score default (440 Hz): got {in_voice_b}" + ); + } + + // -- Proof of life 4: compatibility rejects a mismatch. ------------------- + + #[test] + fn compatibility_check_rejects_a_pitch_space_mismatch() { + let mut f = fixture(); + // tet-19's declared pitch_space is edo-19; the score's default pitch + // 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) + .expect_err("must reject the mismatch"); + assert!( + matches!(err, TuningResolutionError::IncompatiblePitchSpace { .. }), + "expected IncompatiblePitchSpace, got {err:?}" + ); + } + + // -- Proof of life 5: deferred systems fail closed. ----------------------- + + #[test] + fn deferred_systems_fail_closed_pythagorean_and_ji_adaptive() { + let f = fixture(); + let c5 = cmn_pitch("cmn-12", CmnNominal::C, 0, 5); + for deferred in ["pythagorean", "ji-adaptive-5limit"] { + let mut score = f.score.clone(); + score.tuning_context.default_tuning_system = TuningSystemId::new(deferred); + let err = resolve_pitch_frequency(&score, &c5, f.voice_a) + .expect_err(&format!("{deferred} must not resolve to a frequency")); + assert!( + matches!(err, TuningResolutionError::NotYetSupported { .. }), + "{deferred} must report NotYetSupported (a known-but-deferred identifier), got {err:?}" + ); + } + // A genuinely unknown identifier reports differently, so the two + // failure modes never blur together. + 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) + .expect_err("unknown id must not resolve"); + assert!(matches!(err, TuningResolutionError::UnknownTuningSystem(_))); + } + + // -- Extra: the whole-walk AbsoluteHz short-circuit. ---------------------- + + #[test] + fn absolute_hz_realization_short_circuits_the_whole_walk() { + let mut f = fixture(); + // An unresolvable tuning system: if the shortcut were skipped, this + // would return an error, not 500.0. + 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) + .expect("AbsoluteHz must resolve without consulting the tuning system at all"); + assert_eq!(freq, 500.0); + } + + // -- Extra: an EDO built-in resolves through Integer positions. ----------- + + #[test] + fn tet_19_resolves_edo_integer_positions() { + let mut f = fixture(); + f.score.tuning_context.default_pitch_space = PitchSpaceId::new("edo-19"); + f.score.tuning_context.default_tuning_system = TuningSystemId::new("tet-19"); + f.score.tuning_context.reference = ReferencePitch::new( + PitchSpacePosition::Integer { + space_size: 19, + index: 0, + }, + 440.0, + ) + .unwrap(); + let one_step = Pitch { + scale_position: ScalePosition { + space: PitchSpaceId::new("edo-19"), + position: PitchSpacePosition::Integer { + space_size: 19, + index: 1, + }, + }, + acoustic: AcousticPitch { + tuning: TuningReference::Inherit, + realization: AcousticRealization::Implicit, + }, + }; + let freq = + resolve_pitch_frequency(&f.score, &one_step, f.voice_a).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), + "expected ~{expected} Hz, got {freq}" + ); + } +}