From 2cf2dae31b07ebf024510b92f4a47f4bc53b8e6e Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 09:41:18 -0400 Subject: [PATCH] Push 4b tranche 1: pitch spaces become structure, and cmn-24 transposes end-to-end The Chapter 4 pitch-space vocabulary lands in epiphany-core as in-memory types with a real consumer, the built-in catalog as PitchSpaceId -> PositionStructure data, and the P13-S2 "cmn-12" string guard is replaced -- not preserved beside -- by structural resolution over that catalog. Dispatched as one vertical slice rather than the plan's three steps, because a Chapter 4 type surface with no consumer is the shape that produced Staff::default_clef and NOTEHEAD_ANCHORS. No Codec impls, no struct_codec! additions, no Score/ScoreTuningContext field: these types stay in memory so Ruling C keeps them free to change. Canonical bytes are byte-identical -- no golden or fuzz digest moved. Six of the thirteen built-in pitch spaces are underdetermined by the spec (the three ji-* generators, and all of maqam-base/gamelan-slendro/gamelan-pelog); they resolve to None and fail closed at every consumer rather than being invented. SpellingParameters, whose shape the spec calls an open question, is a documented zero-field marker for the same reason. The plan's proof of life -- cmn-24 transposing end-to-end -- held at the core layer and FAILED one layer up, invisibly to the tranche's own tests. resolve_transposed_spellings took the transposed 12-TET semitone with a ? before checking whether any spelling needed it, so the 12-chromatic-only gate refused every cmn-24 transpose, even one with nothing to spell (measured: NoOp TranspositionOutOfRange, value untouched). P13-S3-shaped: latent in code, made reachable the moment the space resolved. Moved the ? to point of use; a spelling-less cmn-24 now transposes, one with an authored spelling still refuses (the pre-pass cannot write a 24-chromatic accidental). Two tests, each killed by a different mutation. Verified independently of the agent that wrote the core half: the "cmn-12" string survives only in doc comments and fixtures, not control flow; the proof-of-life arithmetic (B4 -> natural C5 in cmn-24, chromatic 22, 118 - 120 carrying to octave 5) recomputed by hand; the strictly-increasing third clause of req:tuning:diatonic-chromatic-mapping mutation-killed; full gate green (fmt, clippy 0, workspace 0 failed, rustdoc 0, conformance 8/8, requirement counts unmoved at 212/282/282). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-core/DECISIONS.md | 79 +++++ crates/epiphany-core/src/lib.rs | 24 +- crates/epiphany-core/src/pitch.rs | 230 +++++++++++-- crates/epiphany-core/src/pitch_space.rs | 440 ++++++++++++++++++++++++ crates/epiphany-ops/DECISIONS.md | 38 ++ crates/epiphany-ops/src/reduce.rs | 75 +++- 6 files changed, 843 insertions(+), 43 deletions(-) create mode 100644 crates/epiphany-core/src/pitch_space.rs diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 3075331..7371473 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -682,3 +682,82 @@ errors, but the compiler reports only the *frontier* — `AnchorOffset`, `VoiceSelector`, `PowerOfTwo`, `OctaveOffset` and `NonZeroU16` were each hidden behind a type that had not compiled yet. The list has to be iterated to a fixpoint, never taken once. + +## Push 4b tranche 1: the pitch-space vocabulary lands, in memory, with a real consumer + +`spec/CONTRACT_PUSH4B_PITCHSPACES.md`, dispatched as one vertical slice: types, +the built-in catalog data that fills them, and the consumer that reads them, +landing together rather than as three separate steps. The acceptance test is +behavioural — a `cmn-24` pitch transposes end-to-end, with the resulting scale +position asserted, not merely `is_ok()` — because a Chapter 4 type surface +with no consumer is the `Staff::default_clef` / `NOTEHEAD_ANCHORS` shape this +project has already paid for twice. + +**New module `src/pitch_space.rs`.** `PositionStructure` (all four variants: +`Chromatic`, `DiatonicOverChromatic`, `JiLattice`, `Registered`), the checked +constructor `PositionStructure::diatonic_over_chromatic` (enforces all three +clauses of `req:tuning:diatonic-chromatic-mapping` — length, range, strictly +increasing — the way `KeySignature::new` rejects out-of-range fifths), `JiRatio`, +`IntervalAlgebra`, `TranspositionBehavior`, `SpellingRuleSet`, and `PitchSpace` +itself, transcribed field-for-field from the specification's own listings. Plus +`built_in_position_structure(&PitchSpaceId) -> Option`, the +built-in catalog: the seven fully-determined spaces (`cmn-12`, `cmn-24`, +`edo-19/22/31/53/72`) resolve; the six the specification names but does not +structurally determine (the three `ji-*` lattice generators, `maqam-base`, +`gamelan-slendro`, `gamelan-pelog`) return `None`, with a per-space comment +recording exactly what the table does and does not fix, rather than a value +this project would later discover was invented. `PositionStructureRegistryId`, +`IntervalAlgebraRegistryId`, and `TranspositionRegistryId` (new `catalog_id!` +entries in `pitch.rs`) back the three `Registered` variants. + +**`SpellingParameters` is a deliberate zero-field marker, not a transcription.** +`SpellingRuleSet.parameters: SpellingParameters` is in the specification's own +listing, but `SpellingParameters`' shape is never given anywhere in +`core_spec.tex` — Chapter 4 calls the parameter schema of registered spelling +algorithms an open question outright ("the catalog of *additional* registered +spelling algorithms ... and their parameter schemas, which are normative once +registered"), and the one currently-registered algorithm (`"default"`, +`req:pitch:spelling-algorithm`) is a fixed rule with none. The type exists only +so `SpellingRuleSet`'s field list matches the listing; it carries no state and +nothing constructs one with content. This is the same "do not invent" discipline +the contract applies to the six pitch spaces, applied one level down to a type +rather than a data row. + +**No `Codec` impl exists for anything in `pitch_space.rs`, and none was added +to `Score` or `ScoreTuningContext`** (Ruling C, `spec/PLAN_PUSH4B_TUNING.md`). +These types are referenced only by id from canonical state; they stay in memory +so a later tranche remains free to discover they are wrong. + +**The P13-S2 interim guard is retired, not widened.** `Pitch::transposed` and +`Pitch::twelve_tet_semitone` no longer compare `scale_position.space.as_str()` +against the literal `"cmn-12"` anywhere; both call a new private helper, +`diatonic_over_chromatic_structure`, that looks the space up in +`built_in_position_structure` and proceeds only when it resolves to +`DiatonicOverChromatic` — `Chromatic`, `JiLattice`, `Registered`, an unknown +identifier, and all six unresolved catalog spaces refuse identically via the +existing `TransposeRefusal::PitchSpaceUnavailable`. `Pitch::transposed`'s +arithmetic is now genuinely space-relative (`chromatic_positions_per_octave` +and `nominal_to_chromatic` come from the resolved structure, not a hardcoded +`12`/`CmnNominal::chromatic()`), which is what makes `cmn-24` transpose in +quarter-tone steps rather than silently applying semitone arithmetic to a +24-chromatic space. `twelve_tet_semitone` keeps its own, stricter gate +(`chromatic_positions_per_octave == 12`) per the contract's instruction not to +rename it: the name stays true because the function still only answers for a +genuinely twelve-chromatic structure, proven structurally now rather than by +identifier. Its six callers across three crates are unchanged. + +**One downstream test needed a fixture change, not a behavior change.** +`epiphany-ops::reduce::tests::unresolved_cmn_space_maps_to_canonical_pitch_space_mismatch` +used `"cmn-24"` as a stand-in for "a `Cmn` position in a space the core cannot +resolve." `cmn-24` is now resolved — that is this tranche's entire point — so +the fixture no longer witnesses that case; it now runs deep enough to hit a +second, pre-existing, unrelated refusal (`resolve_transposed_spellings`'s +`twelve_tet_semitone()?` gate, `TranspositionOutOfRange`) instead of the one the +test names. Retargeted to `"edo-31"` (resolved, but to `Chromatic`, not +`DiatonicOverChromatic` — still exactly the case the test is about), with a +comment recording why `cmn-24` stopped serving as the witness. No assertion +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. diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index 59d6b80..d00e1de 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -27,6 +27,11 @@ //! * `pitch` — [`Pitch`], [`ScalePosition`], [`IdentifiedPitch`], //! [`PitchSpelling`], and the spelling-attachment subsystem (Chapter 2; //! Chapter 4 for the tuning/pitch-space registry identifiers it references). +//! * `pitch_space` — the Chapter 4 pitch-space vocabulary +//! ([`PositionStructure`], [`PitchSpace`], [`IntervalAlgebra`], +//! [`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). //! * `event` — the [`Event`] taxonomy and the [`EventArena`] (Chapter 5 //! §"The Event Arena"). //! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct @@ -52,6 +57,7 @@ mod ids; mod indexes; mod invariants; mod pitch; +mod pitch_space; mod tempo; mod textvalue_event; mod textvalue_graph; @@ -83,12 +89,18 @@ pub use time::{ pub use pitch::{ canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId, AcousticPitch, AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId, - IdentifiedPitch, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition, - PitchSpelling, PositionRegistryId, ReferencePitch, ScalePosition, SpellingAlgorithmId, - SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal, SpellingPrecedence, - SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope, SpellingSource, - SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId, TransposeRefusal, - TranspositionInterval, TuningReference, TuningSystemId, VoiceSelector, + IdentifiedPitch, IntervalAlgebraRegistryId, 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, + TuningReference, TuningSystemId, VoiceSelector, +}; + +pub use pitch_space::{ + built_in_position_structure, IntervalAlgebra, JiRatio, PitchSpace, PositionStructure, + SpellingParameters, SpellingRuleSet, TranspositionBehavior, }; pub use prepass::{ diff --git a/crates/epiphany-core/src/pitch.rs b/crates/epiphany-core/src/pitch.rs index 533df80..65a650f 100644 --- a/crates/epiphany-core/src/pitch.rs +++ b/crates/epiphany-core/src/pitch.rs @@ -127,6 +127,21 @@ catalog_id!( /// spelling/decomposition provenance tag. ForeignFormatId ); +catalog_id!( + /// Identifies a registered (grammar-defined) position structure + /// (Chapter 4 §"Position Structure", `PositionStructure::Registered`). + PositionStructureRegistryId +); +catalog_id!( + /// Identifies a registered (grammar-defined) interval algebra + /// (Chapter 4 §"Interval Algebra", `IntervalAlgebra::Registered`). + IntervalAlgebraRegistryId +); +catalog_id!( + /// Identifies a registered (grammar-defined) transposition behavior + /// (Chapter 4 §"Transposition Behavior", `TranspositionBehavior::Registered`). + TranspositionRegistryId +); impl SpellingAlgorithmId { /// The Phase-2 default spelling algorithm, registered under the id @@ -242,9 +257,14 @@ pub enum TransposeRefusal { NonCmnPosition, /// The enclosing pitch space's chromatic layer or nominal mapping cannot /// be established, so applying the interval would require guessed - /// arithmetic (`req:pitch:space-capability-refusal`). The pre-registry - /// implementation recognizes only built-in `cmn-12`; Push 4b replaces that - /// identifier check with structural pitch-space resolution. + /// arithmetic (`req:pitch:space-capability-refusal`). Structural: the + /// space's [`PositionStructure`](crate::pitch_space::PositionStructure) + /// is looked up in [`crate::pitch_space::built_in_position_structure`] + /// and must resolve to + /// [`DiatonicOverChromatic`](crate::pitch_space::PositionStructure::DiatonicOverChromatic); + /// every other outcome — `Chromatic`, `JiLattice`, `Registered`, an + /// unknown identifier, or one of the six built-in spaces the + /// specification names but does not structurally determine — refuses. PitchSpaceUnavailable, /// The pitch's [`AcousticRealization::AbsoluteHz`] overrides the tuning /// system, so moving the scale position would move the notehead without @@ -254,28 +274,60 @@ pub enum TransposeRefusal { OutOfRange, } +/// Resolves `space` to its [`DiatonicOverChromatic`](crate::pitch_space::PositionStructure::DiatonicOverChromatic) +/// chromatic cardinality and nominal mapping against the built-in catalog +/// ([`crate::pitch_space::built_in_position_structure`]), or `None` for +/// every other case — `Chromatic`, `JiLattice`, `Registered`, an unknown +/// identifier, or one of the six catalog spaces the specification does not +/// structurally determine. This is the structural replacement for the +/// retired P13-S2 `"cmn-12"` identifier check +/// (`req:pitch:space-capability-refusal`): [`Pitch::transposed`] and +/// [`Pitch::twelve_tet_semitone`] are its only two call sites, matching the +/// two places the interim guard used to live. +fn diatonic_over_chromatic_structure(space: &PitchSpaceId) -> Option<(u16, Vec)> { + match crate::pitch_space::built_in_position_structure(space)? { + crate::pitch_space::PositionStructure::DiatonicOverChromatic { + chromatic_positions_per_octave, + nominal_to_chromatic, + .. + } => Some((chromatic_positions_per_octave, nominal_to_chromatic)), + _ => None, + } +} + impl Pitch { /// Transposes this pitch by `interval`, per Chapter 2 /// `req:pitch:transposition`. /// - /// With `n` the nominal's normative discriminant and, for the proven - /// `cmn-12` structure, the absolute semitone - /// `s = nominal.chromatic() + alteration + 12*octave`, transposing by + /// With `n` the nominal's normative discriminant, `C` the enclosing + /// space's `chromatic_positions_per_octave`, and `m` its + /// `nominal_to_chromatic` mapping (both resolved structurally — see + /// below), the absolute chromatic coordinate is + /// `s = m(nominal) + alteration + C*octave`, and transposing by /// `{ d, c }` yields /// /// ```text /// nominal' = CmnNominal((n + d).rem_euclid(7)) /// octave' = octave + (n + d).div_euclid(7) - /// alteration' = (s + c) - (nominal'.chromatic() + 12*octave') + /// alteration' = (s + c) - (m(nominal') + C*octave') /// ``` /// - /// Until Push 4b provides structural pitch-space resolution, a CMN - /// position outside built-in `cmn-12` is refused rather than interpreted - /// with this 12-chromatic map. + /// `C` and `m` are resolved by looking `self.scale_position.space` up in + /// [`crate::pitch_space::built_in_position_structure`]: when it resolves + /// to [`DiatonicOverChromatic`](crate::pitch_space::PositionStructure::DiatonicOverChromatic), + /// that structure's own `chromatic_positions_per_octave` and + /// `nominal_to_chromatic` are used — which is what makes `cmn-24` + /// transpose in quarter-tone steps rather than semitones. Every other + /// resolution — `Chromatic`, `JiLattice`, `Registered`, an unknown + /// identifier, or one of the six built-in spaces the specification does + /// not structurally determine — refuses + /// ([`TransposeRefusal::PitchSpaceUnavailable`], + /// `req:pitch:space-capability-refusal`) rather than guessing a + /// structure. /// /// The diatonic component alone selects the nominal and octave; the - /// alteration absorbs exactly the residue. So `C4 + (7, 12)` is `C5`, not - /// "C with twelve sharps", and `C4 + (0, 1)` is `C#4`. + /// alteration absorbs exactly the residue. So in `cmn-12`, `C4 + (7, 12)` + /// is `C5`, not "C with twelve sharps", and `C4 + (0, 1)` is `C#4`. /// /// Refuses rather than saturating, clamping, or approximating: a /// transposition that silently produces a pitch nobody asked for reports @@ -295,29 +347,32 @@ impl Pitch { else { return Err(TransposeRefusal::NonCmnPosition); }; - if self.scale_position.space.as_str() != "cmn-12" { - return Err(TransposeRefusal::PitchSpaceUnavailable); - } + let (chromatic_card, nominal_to_chromatic) = + diatonic_over_chromatic_structure(&self.scale_position.space) + .ok_or(TransposeRefusal::PitchSpaceUnavailable)?; // Widen to `i64` before any arithmetic. The `i8` bound is a *result* // constraint, not an intermediate one, so an octave that overflows on // the way to a value that fits would be a spurious refusal — but // `i32` is not wide enough to hold the intermediates for an `i32` // interval, and the previous version of this function panicked on - // `diatonic_steps = i32::MAX` at `12 * new_octave`. Refusing is the - // contract; panicking on a value the public type admits is not. + // `diatonic_steps = i32::MAX` at `chromatic_card * new_octave`. + // Refusing is the contract; panicking on a value the public type + // admits is not. // // `i64` is amply wide: `step` is bounded by `6 + 2^31`, so // `new_octave` by `2^31/7 + 127`, and the largest intermediate - // `12 * new_octave` by roughly `3.7e9`. + // `chromatic_card * new_octave` by roughly `chromatic_card * 3e8`, + // nowhere near `i64::MAX` for any `u16` cardinality. + let chromatic_of = |nom: CmnNominal| i64::from(nominal_to_chromatic[nom as usize]); + let c = i64::from(chromatic_card); let n = i64::from(nominal as u8); - let semitone = - i64::from(nominal.chromatic()) + i64::from(alteration) + 12 * i64::from(octave); + let semitone = chromatic_of(nominal) + i64::from(alteration) + c * i64::from(octave); let step = n + i64::from(interval.diatonic_steps); let new_nominal = CmnNominal::from_index(step.rem_euclid(7) as i32); let new_octave = i64::from(octave) + step.div_euclid(7); let new_alteration = (semitone + i64::from(interval.chromatic_steps)) - - (i64::from(new_nominal.chromatic()) + 12 * new_octave); + - (chromatic_of(new_nominal) + c * new_octave); let octave = i8::try_from(new_octave).map_err(|_| TransposeRefusal::OutOfRange)?; let alteration = i8::try_from(new_alteration).map_err(|_| TransposeRefusal::OutOfRange)?; @@ -486,22 +541,33 @@ impl Pitch { } /// The 12-TET pitch class (`0..=11`) of this pitch's *scale position*, when - /// its 12-chromatic structure is established: CMN positions in built-in - /// `cmn-12` and 12-EDO integer positions. Returns `None` for unresolved CMN - /// spaces and positions whose 12-TET class is not determinable without a - /// tuning resolver (JI vectors, non-12 EDOs, registered grammars). - /// Octave-blind — for sounding comparison use - /// [`Pitch::twelve_tet_semitone`]. + /// its 12-chromatic structure is established: `Cmn` positions whose space + /// resolves structurally to a `DiatonicOverChromatic` structure with + /// exactly 12 chromatic positions (built-in `cmn-12`, at this tranche's + /// catalog), and 12-EDO integer positions. Returns `None` for CMN + /// positions in spaces that do not resolve that way (e.g. `cmn-24`, + /// whose 24 quarter-tone positions are not a 12-TET pitch class at all) + /// and positions whose 12-TET class is not determinable without a tuning + /// resolver (JI vectors, non-12 EDOs, registered grammars). Octave-blind + /// — for sounding comparison use [`Pitch::twelve_tet_semitone`]. pub fn twelve_tet_class(&self) -> Option { self.twelve_tet_semitone().map(|s| s.rem_euclid(12) as u8) } /// The *absolute* 12-TET semitone of this pitch's scale position, octave - /// included, when its 12-chromatic structure is established. For built-in - /// `cmn-12` this is - /// `octave*12 + nominal.chromatic() + alteration` (so C4 and C5 differ by a - /// full octave); for 12-EDO integer positions it is the absolute `index`. - /// `None` for unresolved CMN spaces + /// included, when its 12-chromatic structure is established. For a `Cmn` + /// position this is `octave*C + m(nominal) + alteration`, where `C` and + /// `m` come from the space's structural resolution + /// ([`crate::pitch_space::built_in_position_structure`]) — but only when + /// `C` is exactly `12`; a space resolving to `DiatonicOverChromatic` with + /// any other chromatic cardinality (`cmn-24`, for instance) is **not** a + /// 12-TET semitone and this deliberately does not answer for it (the + /// name `twelve_tet_semitone` stays honest per + /// `spec/CONTRACT_PUSH4B_PITCHSPACES.md` item 4: it answers only when the + /// resolved structure truly has twelve chromatic positions, never by + /// identifier). For 12-EDO integer positions it is the absolute `index`. + /// `None` for CMN positions whose space does not resolve to a + /// `DiatonicOverChromatic` structure with exactly 12 chromatic positions /// (`req:pitch:space-capability-refusal`) and positions not determinable /// without a tuning resolver. /// @@ -515,8 +581,14 @@ impl Pitch { nominal, alteration, octave, - } if self.scale_position.space.as_str() == "cmn-12" => { - Some(*octave as i32 * 12 + nominal.chromatic() as i32 + *alteration as i32) + } => { + let (chromatic_card, nominal_to_chromatic) = + diatonic_over_chromatic_structure(&self.scale_position.space)?; + if chromatic_card != 12 { + return None; + } + let m = nominal_to_chromatic[*nominal as usize] as i32; + Some(*octave as i32 * 12 + m + *alteration as i32) } PitchSpacePosition::Integer { space_size, index } if *space_size == 12 => Some(*index), _ => None, @@ -1036,9 +1108,13 @@ mod tests { use super::*; fn cmn(nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch { + cmn_in("cmn-12", nominal, alteration, octave) + } + + fn cmn_in(space: &str, nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch { Pitch { scale_position: ScalePosition { - space: PitchSpaceId::new("cmn-12"), + space: PitchSpaceId::new(space), position: PitchSpacePosition::Cmn { nominal, alteration, @@ -1286,6 +1362,11 @@ mod tests { #[test] fn unresolved_cmn_space_refuses_transposition_and_twelve_tet_conversion() { + // "edo-31" resolves structurally (the built-in catalog fully + // determines it — `PositionStructure::Chromatic { 31 }`), but a + // *resolved* structure in the wrong family is exactly as unusable to + // a `Cmn` position as an unresolved one: `Cmn` only has a defined + // action under `DiatonicOverChromatic`. let mut p = cmn(CmnNominal::E, -1, 4); p.scale_position.space = PitchSpaceId::new("edo-31"); @@ -1297,6 +1378,85 @@ mod tests { assert_eq!(p.twelve_tet_class(), None); } + #[test] + fn transposition_refuses_a_ji_space_an_unknown_identifier_and_an_unresolved_catalog_space() { + // Three distinct ways `req:pitch:space-capability-refusal` is + // satisfied without guessing: a real catalog identifier whose family + // is JI (`ji-5limit`), an identifier the catalog has never heard of, + // and one of the six built-in identifiers the catalog names but does + // not structurally determine (`maqam-base`). + for space in ["ji-5limit", "not-a-built-in-pitch-space", "maqam-base"] { + let p = cmn_in(space, CmnNominal::C, 0, 4); + assert_eq!( + p.transposed(iv(4, 7)), + Err(TransposeRefusal::PitchSpaceUnavailable), + "{space} must refuse a transposition rather than guess a pitch-space structure" + ); + } + } + + #[test] + fn twelve_tet_semitone_stays_none_for_a_resolved_non_twelve_chromatic_structure() { + // `cmn-24` resolves structurally — it is one of the seven fully + // determined catalog entries — but its chromatic cardinality is 24, + // not 12. `twelve_tet_semitone`/`twelve_tet_class` must still refuse: + // resolving *a* structure is not the same as resolving a + // *twelve*-chromatic one (`spec/CONTRACT_PUSH4B_PITCHSPACES.md` item + // 4 — the name stays honest only if it keeps this gate). + let p = cmn_in("cmn-24", CmnNominal::E, -1, 4); + assert_eq!(p.twelve_tet_semitone(), None); + assert_eq!(p.twelve_tet_class(), None); + } + + #[test] + fn cmn_24_transposes_using_its_own_quarter_tone_structure() { + // The tranche's acceptance criterion (`spec/CONTRACT_PUSH4B_PITCHSPACES.md` + // "Proof of life"): a `cmn-24` pitch transposes end-to-end, with the + // *resulting scale position* asserted — not merely `is_ok()`. + + // First, the quarter-tone alteration unit itself + // (`core_spec.tex:3606`: "A flat is -2 and a half-flat is -1"), via a + // same-nominal chromatic-only shift. + let c4 = cmn_in("cmn-24", CmnNominal::C, 0, 4); + let half_flat = c4.transposed(iv(0, -1)).unwrap(); + assert_eq!( + half_flat.scale_position.position, + PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: -1, + octave: 4, + } + ); + let flat = c4.transposed(iv(0, -2)).unwrap(); + assert_eq!( + flat.scale_position.position, + PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: -2, + octave: 4, + } + ); + + // Second, the case that only a genuinely 24-chromatic computation + // gets right: B4 up a semitone (2 quarter-tone steps) spelled as a + // diatonic step lands on a *natural* C5. `cmn-24`'s nominal map is + // `[0,4,8,10,14,18,22]` (`core_spec.tex:3609`) and its chromatic + // cardinality is 24 — not "2x the cmn-12 map applied to a 12-space". + // Arithmetic that silently fell back to `nominal.chromatic()` (the + // cmn-12 map, `B=11`) and a chromatic cardinality of 12 would instead + // compute `alteration' = (11+0+48+2) - (0+60) = 1`: a spurious C5♯. + let b4 = cmn_in("cmn-24", CmnNominal::B, 0, 4); + let up_a_semitone = b4.transposed(iv(1, 2)).unwrap(); + assert_eq!( + up_a_semitone.scale_position.position, + PitchSpacePosition::Cmn { + nominal: CmnNominal::C, + alteration: 0, + octave: 5, + } + ); + } + #[test] fn a_transposition_refuses_a_pitch_pinned_to_a_frequency() { // AbsoluteHz overrides the tuning system: moving the scale position diff --git a/crates/epiphany-core/src/pitch_space.rs b/crates/epiphany-core/src/pitch_space.rs new file mode 100644 index 0000000..5ee79e1 --- /dev/null +++ b/crates/epiphany-core/src/pitch_space.rs @@ -0,0 +1,440 @@ +//! Chapter 4 pitch-space vocabulary (`core_spec.tex` §"Pitch Spaces", +//! `sec:tuning:space`, `:2850` onward): the shape of a pitch space's position +//! structure, plus the built-in catalog Push 4b tranche 1 resolves against. +//! +//! **Scope**, per `spec/CONTRACT_PUSH4B_PITCHSPACES.md`: only the types this +//! tranche's one consumer needs — [`Pitch::transposed`](crate::pitch::Pitch::transposed) +//! and [`Pitch::twelve_tet_semitone`](crate::pitch::Pitch::twelve_tet_semitone)'s +//! structural pitch-space resolution, which replaces the P13-S2 interim +//! `"cmn-12"` identifier guard — plus [`PitchSpace`], the type the +//! specification itself carries [`PositionStructure`] in. `TuningSystem`, +//! `TuningResolution`, the accidental/nominal registry *bodies* (their ids +//! already exist in [`crate::pitch`]), and Chapter 4's glyph and engraving +//! vocabulary are out of scope: they belong to the tranche that adds the +//! codec, and landing them here would create exactly the unconsumed type +//! surface `spec/CONTRACT_PUSH4B_PITCHSPACES.md` exists to avoid. +//! +//! **No `Codec` impl exists, or may be added, for anything in this module** +//! (Ruling C, `spec/PLAN_PUSH4B_TUNING.md`): these types stay in memory — +//! referenced only by id from canonical score state — so they remain free to +//! change once a later tranche discovers they are wrong. Adding one, or a +//! field to `Score`/`ScoreTuningContext`, would freeze this surface onto the +//! wire before it has ever had more than one consumer. +//! +//! ## The six built-in pitch spaces this module does not resolve +//! +//! `core_spec.tex:3598-3646` normatively names thirteen built-in pitch +//! spaces. Seven are fully determined by the table and are transcribed +//! below. **Six are not**, and [`built_in_position_structure`] returns +//! `None` for each rather than inventing a [`PositionStructure`] for it — +//! see that function's match arms for what the specification does and does +//! not fix for each one. A structure inferred and written down as though the +//! specification stated it is the `NOTEHEAD_ANCHORS` failure this project has +//! already paid for twice. + +use core::num::NonZeroU32; + +use crate::pitch::{ + AccidentalRegistryId, IntervalAlgebraRegistryId, NominalRegistryId, PitchSpaceId, + PositionStructureRegistryId, SpellingAlgorithmId, SpellingRuleSetId, TranspositionRegistryId, +}; + +/// The shape of a pitch space's positions (Chapter 4 §"Position Structure", +/// `core_spec.tex:2896`, `sec:tuning:positions`). Three families cover the +/// vast majority of useful cases; a fourth is the escape hatch for grammars +/// defined entirely by plugin. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum PositionStructure { + /// Chromatic: a fixed number of equally-numbered positions per octave, + /// with no hierarchical diatonic substructure. Includes EDOs and serial + /// 12-tone. + Chromatic { positions_per_octave: u16 }, + + /// Diatonic over chromatic: a smaller set of named nominals (e.g. A-G + /// for CMN) plus accidental modifications reaching the full chromatic + /// range. CMN's structure. + /// + /// Never construct this variant's literal directly outside this module; + /// use [`PositionStructure::diatonic_over_chromatic`], the checked + /// constructor that enforces every clause of + /// `req:tuning:diatonic-chromatic-mapping`. + DiatonicOverChromatic { + nominals_per_octave: u16, + chromatic_positions_per_octave: u16, + /// Mapping from each nominal to its position in the chromatic layer + /// (e.g. C=0, D=2, E=4, F=5, G=7, A=9, B=11 for CMN). + nominal_to_chromatic: Vec, + }, + + /// Just intonation lattice: positions defined by exact rational ratios + /// from a tonic, organized along prime-axis dimensions (3-limit, + /// 5-limit, 7-limit, etc.). + JiLattice { + /// Prime limit. + limit: u8, + /// One generator ratio per prime dimension. + generators: Vec, + }, + + /// Grammar-defined: structure is opaque to the core and resolved by a + /// grammar plugin. Used for maqam, gamelan, raga frameworks, and other + /// systems whose position structure is not flat or hierarchical in the + /// above senses. + Registered(PositionStructureRegistryId), +} + +impl PositionStructure { + /// Builds a [`PositionStructure::DiatonicOverChromatic`], enforcing all + /// three clauses of `req:tuning:diatonic-chromatic-mapping`: + /// `nominal_to_chromatic` MUST have length `nominals_per_octave`, each + /// entry MUST be strictly less than `chromatic_positions_per_octave`, + /// and the mapping MUST be strictly increasing. Returns `None` if any + /// clause is violated, so a malformed mapping is never representable + /// (the `KeySignature::new`/`TupletRatio::new` "reject at construction" + /// pattern, `epiphany-core/DECISIONS.md` §"Enforced-at-construction + /// invariants"). + pub fn diatonic_over_chromatic( + nominals_per_octave: u16, + chromatic_positions_per_octave: u16, + nominal_to_chromatic: Vec, + ) -> Option { + if nominal_to_chromatic.len() != nominals_per_octave as usize { + return None; + } + if !nominal_to_chromatic + .iter() + .all(|&entry| entry < chromatic_positions_per_octave) + { + return None; + } + if !nominal_to_chromatic + .windows(2) + .all(|pair| pair[0] < pair[1]) + { + return None; + } + Some(PositionStructure::DiatonicOverChromatic { + nominals_per_octave, + chromatic_positions_per_octave, + nominal_to_chromatic, + }) + } +} + +/// An exact just-intonation ratio: one generator or modification offset +/// within a [`PositionStructure::JiLattice`] (Chapter 4 §"Position +/// Structure", `core_spec.tex:3108`). +#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] +pub struct JiRatio { + pub numerator: i32, + pub denominator: NonZeroU32, +} + +/// How distances between positions are computed and transposition is +/// realized (Chapter 4 §"Interval Algebra", `core_spec.tex:2949`). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum IntervalAlgebra { + /// Single-axis integer arithmetic. Two positions are subtracted to + /// yield a single integer interval. + Chromatic, + /// Two-axis arithmetic distinguishing diatonic and chromatic + /// components. CMN's algebra: a major third is (3 diatonic steps, 4 + /// chromatic steps); a diminished fourth is (3 diatonic steps, 4 + /// chromatic steps) — distinguishable from the major third only by + /// spelling intent. + DiatonicChromatic, + /// Multi-axis JI lattice arithmetic. + JiVector { dimensions: u8 }, + /// Grammar-defined algebra. + Registered(IntervalAlgebraRegistryId), +} + +/// How scale positions move under interval operations and how spellings +/// follow (Chapter 4 §"Transposition Behavior", `core_spec.tex:3004`). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum TranspositionBehavior { + /// Diatonic transposition: shift by N diatonic steps within a chosen + /// key. Accidentals adjust to fit the destination key. + Diatonic, + /// Chromatic transposition: shift by N chromatic steps. Spellings + /// follow rules of the [`SpellingRuleSet`]. + Chromatic, + /// Compound: support both, parameterized at the operation site. + Compound, + /// Grammar-defined. + Registered(TranspositionRegistryId), +} + +/// Placeholder for the spelling-algorithm parameter schema (Chapter 4 +/// §"Spelling Rule Sets", `core_spec.tex:3029`). The specification leaves +/// this type's shape an open question: "the catalog of *additional* +/// registered spelling algorithms ... and their parameter schemas, which are +/// normative once registered" (`core_spec.tex:3043`). The only +/// currently-registered algorithm, [`SpellingAlgorithmId::default_id`] +/// (`req:pitch:spelling-algorithm`), is a fixed rule with no declared +/// parameters of its own. This tranche does not invent a shape for the open +/// question — the same discipline `built_in_position_structure` applies to +/// the six underdetermined pitch spaces, applied to a type rather than a +/// data row: this marker exists only so [`SpellingRuleSet`]'s field list +/// matches the specification's listing, and carries no state. +#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)] +pub struct SpellingParameters; + +/// A pitch space's default spelling rule set, consulted by the spelling +/// pre-pass when that pitch space is active (Chapter 4 §"Spelling Rule +/// Sets", `core_spec.tex:3028`). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct SpellingRuleSet { + pub id: SpellingRuleSetId, + pub name: String, + /// Algorithmic family this rule set belongs to. The specific algorithm + /// is referenced by id and resolved against the implementation's + /// registered spelling algorithms. + pub algorithm: SpellingAlgorithmId, + /// Algorithm-specific parameters. + pub parameters: SpellingParameters, +} + +/// A pitch space: the analytical universe in which scale-degree and interval +/// relationships are defined (Chapter 4 §"Pitch Spaces", `core_spec.tex:2857`, +/// `sec:tuning:space`). It is the algebra; the tuning system is the physics. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct PitchSpace { + pub id: PitchSpaceId, + /// Human-readable name (e.g., "CMN 12-tone chromatic"). + pub name: String, + /// Optional description and provenance notes. + pub description: Option, + /// What positions exist and how they are structured. + pub positions: PositionStructure, + /// How intervals between positions are defined. + pub interval_algebra: IntervalAlgebra, + /// The accidental registry valid in this pitch space. + pub accidental_registry: AccidentalRegistryId, + /// The nominal registry: named position-letters (e.g., A-G for CMN). + pub nominal_registry: NominalRegistryId, + /// Rules governing how pitches transpose within this space. + pub transposition: TranspositionBehavior, + /// Default rules for the spelling pre-pass when this pitch space is + /// active. + pub spelling_rules: SpellingRuleSet, +} + +/// Looks up the [`PositionStructure`] of a built-in pitch space (Chapter 4 +/// §"Built-in Catalog", `core_spec.tex:3598-3646`), the structural +/// replacement for the retired P13-S2 interim `"cmn-12"` identifier check +/// (`req:pitch:space-capability-refusal`). +/// +/// `None` covers two cases alike, and deliberately does not distinguish +/// them: an identifier outside the thirteen built-in pitch spaces, and one +/// of the **six built-in spaces the specification names but does not +/// structurally determine**. Both must fail closed identically at every +/// consumer (`req:pitch:space-capability-refusal`: "The `Cmn` position +/// discriminant alone MUST NOT be treated as proof of ... capability" — +/// naming a real catalog entry is not proof of its structure either), so one +/// `Option` return is enough; the per-arm comments below record *why* each +/// of the six is `None`, for anyone auditing which gap is which. +pub fn built_in_position_structure(id: &PitchSpaceId) -> Option { + match id.as_str() { + // -- Fully determined by the table (`core_spec.tex:3598-3646`); -- + // -- transcription, not decision. -- + "cmn-12" => PositionStructure::diatonic_over_chromatic(7, 12, vec![0, 2, 4, 5, 7, 9, 11]), + "cmn-24" => { + PositionStructure::diatonic_over_chromatic(7, 24, vec![0, 4, 8, 10, 14, 18, 22]) + } + "edo-19" => Some(PositionStructure::Chromatic { + positions_per_octave: 19, + }), + "edo-22" => Some(PositionStructure::Chromatic { + positions_per_octave: 22, + }), + "edo-31" => Some(PositionStructure::Chromatic { + positions_per_octave: 31, + }), + "edo-53" => Some(PositionStructure::Chromatic { + positions_per_octave: 53, + }), + "edo-72" => Some(PositionStructure::Chromatic { + positions_per_octave: 72, + }), + + // -- Named by the catalog; not structurally determined by it. -- + // + // `ji-5limit` / `ji-7limit` / `ji-11limit`: the table fixes `limit` + // (5, 7, 11) and the ascending-from-2 prime basis ({2,3,5}, + // {2,3,5,7}, {2,3,5,7,11} — `req:pitch:ji-vector-basis`), so the + // *dimension count* of `JiLattice.generators` is known (3, 4, 5). + // What the table never states is the generator *ratios themselves*: + // `JiLattice.generators: Vec` (`core_spec.tex:2920`) needs + // one exact rational per prime dimension, and the specification + // gives the axes, not the ratios that generate them (unison? + // 3-limit uses `3/2`, but nothing pins that choice normatively for + // this catalog, and 5-, 7-, and 11-limit have no stated generator + // set at all). Choosing rationals here — even "the obvious" ones — + // would be exactly the `NOTEHEAD_ANCHORS` failure this contract + // exists to avoid: hand-written data that looks authoritative and + // is wrong. These three refuse rather than guess. + "ji-5limit" | "ji-7limit" | "ji-11limit" => None, + + // `maqam-base`, `gamelan-slendro`, `gamelan-pelog`: described + // entirely in prose — "skeletal maqam framework with quarter-flat + // and quarter-sharp ... accidentals", "five-tone slendro + // framework", "seven-tone pelog framework" + // (`core_spec.tex:3630-3635`). None of the three prose + // descriptions fixes a nominal count, a chromatic cardinality, or + // even which `PositionStructure` family applies. + // `PositionStructure::Registered` is the family the specification + // itself names for exactly this case ("used for maqam, gamelan, + // raga frameworks ... whose position structure is not flat or + // hierarchical", `core_spec.tex:2923-2926`), but committing any of + // these three to a specific `PositionStructureRegistryId` would + // invent the grammar plugin the prose explicitly defers to grammar + // plugins ("deep coverage of specific maqamat is the province of + // grammar plugins", `core_spec.tex:3633`). `maqam-base`'s + // quarter-tone accidentals additionally presuppose a chromatic + // cardinality (`req:pitch:alteration-unit` denominates them in + // "the space's chromatic layer") that nothing in the table states. + // These three refuse rather than guess. + "maqam-base" | "gamelan-slendro" | "gamelan-pelog" => None, + + // Not one of the thirteen built-in identifiers. + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diatonic_over_chromatic_rejects_a_mapping_of_the_wrong_length() { + // Clause 1 of `req:tuning:diatonic-chromatic-mapping`: length MUST + // equal `nominals_per_octave`. Six entries for seven nominals. + assert!( + PositionStructure::diatonic_over_chromatic(7, 12, vec![0, 2, 4, 5, 7, 9]).is_none() + ); + // Eight entries for seven nominals. + assert!( + PositionStructure::diatonic_over_chromatic(7, 12, vec![0, 2, 4, 5, 7, 9, 11, 11]) + .is_none() + ); + } + + #[test] + fn diatonic_over_chromatic_rejects_an_entry_not_strictly_below_the_chromatic_card() { + // Clause 2: every entry MUST be strictly less than + // `chromatic_positions_per_octave`. `12` is not `< 12`. + assert!( + PositionStructure::diatonic_over_chromatic(7, 12, vec![0, 2, 4, 5, 7, 9, 12]).is_none() + ); + // Comfortably out of range too. + assert!( + PositionStructure::diatonic_over_chromatic(7, 12, vec![0, 2, 4, 5, 7, 9, 99]).is_none() + ); + } + + #[test] + fn diatonic_over_chromatic_rejects_a_non_increasing_mapping() { + // Clause 3: the mapping MUST be strictly increasing. A repeat... + assert!( + PositionStructure::diatonic_over_chromatic(7, 12, vec![0, 2, 4, 4, 7, 9, 11]).is_none() + ); + // ...and a descent, both rejected. + assert!( + PositionStructure::diatonic_over_chromatic(7, 12, vec![0, 2, 4, 3, 7, 9, 11]).is_none() + ); + } + + #[test] + fn diatonic_over_chromatic_accepts_both_built_in_mappings() { + // The contract's own warning: both built-ins satisfy all three + // clauses, so this alone cannot substitute for the rejection tests + // above — a constructor enforcing only two of the three clauses + // still passes this. + let cmn12 = + PositionStructure::diatonic_over_chromatic(7, 12, vec![0, 2, 4, 5, 7, 9, 11]).unwrap(); + assert_eq!( + cmn12, + PositionStructure::DiatonicOverChromatic { + nominals_per_octave: 7, + chromatic_positions_per_octave: 12, + nominal_to_chromatic: vec![0, 2, 4, 5, 7, 9, 11], + } + ); + let cmn24 = + PositionStructure::diatonic_over_chromatic(7, 24, vec![0, 4, 8, 10, 14, 18, 22]) + .unwrap(); + assert_eq!( + cmn24, + PositionStructure::DiatonicOverChromatic { + nominals_per_octave: 7, + chromatic_positions_per_octave: 24, + nominal_to_chromatic: vec![0, 4, 8, 10, 14, 18, 22], + } + ); + } + + #[test] + fn built_in_position_structure_resolves_the_seven_determined_spaces() { + let cmn12 = built_in_position_structure(&PitchSpaceId::new("cmn-12")).unwrap(); + assert_eq!( + cmn12, + PositionStructure::DiatonicOverChromatic { + nominals_per_octave: 7, + chromatic_positions_per_octave: 12, + nominal_to_chromatic: vec![0, 2, 4, 5, 7, 9, 11], + } + ); + let cmn24 = built_in_position_structure(&PitchSpaceId::new("cmn-24")).unwrap(); + assert_eq!( + cmn24, + PositionStructure::DiatonicOverChromatic { + nominals_per_octave: 7, + chromatic_positions_per_octave: 24, + nominal_to_chromatic: vec![0, 4, 8, 10, 14, 18, 22], + } + ); + for (id, expected) in [ + ("edo-19", 19u16), + ("edo-22", 22), + ("edo-31", 31), + ("edo-53", 53), + ("edo-72", 72), + ] { + assert_eq!( + built_in_position_structure(&PitchSpaceId::new(id)), + Some(PositionStructure::Chromatic { + positions_per_octave: expected + }), + "{id} did not resolve to its EDO cardinality" + ); + } + } + + #[test] + fn built_in_position_structure_refuses_the_six_underdetermined_spaces() { + for id in [ + "ji-5limit", + "ji-7limit", + "ji-11limit", + "maqam-base", + "gamelan-slendro", + "gamelan-pelog", + ] { + assert_eq!( + built_in_position_structure(&PitchSpaceId::new(id)), + None, + "{id} is named by the catalog but its structure is not spec-determined; \ + it must not resolve" + ); + } + } + + #[test] + fn built_in_position_structure_refuses_an_identifier_outside_the_catalog() { + assert_eq!( + built_in_position_structure(&PitchSpaceId::new("not-a-built-in-space")), + None + ); + } +} diff --git a/crates/epiphany-ops/DECISIONS.md b/crates/epiphany-ops/DECISIONS.md index c3fda28..56c0a10 100644 --- a/crates/epiphany-ops/DECISIONS.md +++ b/crates/epiphany-ops/DECISIONS.md @@ -1667,3 +1667,41 @@ that locks it, and it fails under that mutation. `OperationId::new(ReplicaId(7), 1)` have *identical canonical bytes* — typed ids share their byte form. A byte-patching test that searches for an id finds the envelope's own leading id first. Patch by framing, or from the end. + +## Push 4b tranche 1 — the transpose reduction's own proof of life (2026-07-22) + +Core's `Pitch::transposed` learned to resolve `cmn-24` structurally (the +tranche retires the P13-S2 `"cmn-12"` string guard). But the plan's stated proof +of life — a `cmn-24` pitch transposing *end-to-end* — failed one layer up, in +this crate, and the tranche's own test suite could not see it. + +`resolve_transposed_spellings` computed the transposed 12-TET semitone with a +`?` **before** the loop that looks for an authored spelling to rewrite. The +semitone is 12-chromatic-only (the spelling pre-pass has not changed), so it is +`None` for `cmn-24` — and the early `?` therefore refused **every** `cmn-24` +transpose, including one with no spelling to rewrite at all. Measured directly: a +spelling-less `cmn-24` pitch reduced to `NoOp { TranspositionOutOfRange }`, value +untouched. + +This is **P13-S3-shaped**: latent in code, correct-looking, and made *reachable* +— not introduced — the moment the space it gates on started resolving. Before the +tranche, `current.transposed()` refused `cmn-24` earlier, so control never +reached this `?`. The same shape as the shared-undo-key defect above, and the +same lesson: a capability landing upstream can turn a dormant downstream branch +live, and only re-deriving the end-to-end behaviour catches it. + +**Fix: move the `?` to point of use, inside the loop.** The semitone is needed +only to rewrite an authored spelling; computed where it is consumed, a +spelling-less `cmn-24` transposes, and one *with* an authored spelling still +refuses — the spelling pre-pass genuinely cannot write a 24-chromatic accidental, +so refusing beats leaving it stale. Two tests lock the two halves, each +mutation-killed by a *different* mutation: hoisting the `?` back kills the +transposes-when-empty test; swallowing it with `unwrap_or(0)` kills the +refuses-when-authored test. + +**Not a frozen-replay violation.** `TransposeInterval` (disc 30) is the live +operation whose capability may widen; the frozen one is `Transpose` (disc 9), +untouched. Widening what `TransposeInterval` can transpose is the tranche's +ratified purpose, and the version skew it implies (an old replica refuses what a +new one applies) is the tranche's property, not this fix's — the fix only makes +the operation layer agree with the core layer the tranche already moved. diff --git a/crates/epiphany-ops/src/reduce.rs b/crates/epiphany-ops/src/reduce.rs index 7627ab9..7c207ff 100644 --- a/crates/epiphany-ops/src/reduce.rs +++ b/crates/epiphany-ops/src/reduce.rs @@ -5897,7 +5897,6 @@ impl<'a> Reducer<'a> { let Some(score) = self.graph.as_ref() else { return Some(Vec::new()); }; - let semitone = transposed.twelve_tet_semitone()?; let mut out = Vec::new(); for (index, att) in score.spelling_attachments.iter().enumerate() { if att.layer.is_some() || matches!(att.source, SpellingSource::Propagated { .. }) { @@ -5909,6 +5908,19 @@ impl<'a> Reducer<'a> { let SpellingDirective::Explicit(spelling) = &att.directive else { continue; }; + // The transposed 12-TET semitone is needed ONLY to rewrite an + // authored spelling, and the spelling pre-pass is still + // 12-chromatic (the built-in catalog's conformance note). So it is + // computed here, at point of use, and NOT before the loop: a pitch + // in a resolved non-12-chromatic space (`cmn-24`, since Push 4b + // tranche 1) transposes its *value* correctly and must not be + // refused merely for carrying no authored spelling to rewrite. + // When it does carry one, `?` still refuses — a 24-chromatic + // authored spelling is the documented spelling-layer limitation, + // not a silently stale write. Hoisting this above the loop is what + // made a spelling-less `cmn-24` transpose refuse (P13-S3-shaped: + // latent in code, made reachable the moment the space resolved). + let semitone = transposed.twelve_tet_semitone()?; out.push((index, spelling.transposed(interval, semitone)?)); } Some(out) @@ -9463,8 +9475,20 @@ mod tests { #[test] fn unresolved_cmn_space_maps_to_canonical_pitch_space_mismatch() { + // "edo-31": the built-in catalog resolves it, but to `Chromatic`, not + // `DiatonicOverChromatic` — a `Cmn` position has no defined action + // there, so `Pitch::transposed` refuses with + // `TransposeRefusal::PitchSpaceUnavailable`, which maps here to + // `PitchSpaceMismatch`. Before Push 4b tranche 1 this fixture used + // `"cmn-24"` and the interim `"cmn-12"`-only guard refused it; `cmn-24` + // is now fully resolved (`DiatonicOverChromatic`) and a spelling-less + // `cmn-24` pitch *transposes* — see + // `cmn_24_with_no_authored_spelling_transposes_at_the_operation_layer` + // below — so it no longer witnesses a refusal at all. `edo-31` is the + // stable witness: resolved, but to a structure a `Cmn` position cannot + // live in. let mut unresolved = cmn_pitch(CmnNominal::E, -1, 4); - unresolved.scale_position.space = epiphany_core::PitchSpaceId::new("cmn-24"); + unresolved.scale_position.space = epiphany_core::PitchSpaceId::new("edo-31"); let expected = unresolved.clone(); let (base, pid) = base_with_pitch(unresolved); @@ -9483,6 +9507,53 @@ mod tests { assert_eq!(pitch_of(&score, pid), expected); } + #[test] + fn cmn_24_with_no_authored_spelling_transposes_at_the_operation_layer() { + // The operation-layer proof of life for Push 4b tranche 1. The core's + // `Pitch::transposed` handles `cmn-24`, but `resolve_transposed_spellings` + // used to compute the transposed 12-TET semitone BEFORE looking for any + // spelling to rewrite, so its `?` refused every `cmn-24` transpose — + // even one with nothing to spell — as `TranspositionOutOfRange`. That + // was a defect made reachable (not introduced) by the space resolving. + // With the semitone moved to point of use, a spelling-less `cmn-24` + // pitch now transposes end-to-end. + let mut c4 = cmn_pitch(CmnNominal::B, 0, 4); + c4.scale_position.space = epiphany_core::PitchSpaceId::new("cmn-24"); + let (base, pid) = base_with_pitch(c4); + + // B4 up a diatonic step + 2 quarter-tone chromatic steps = natural C5, + // the same 24-chromatic arithmetic the core's proof-of-life test pins. + let (effect, score) = run_transpose(&base, &[pid], interval(1, 2)); + assert_eq!(effect, OperationEffect::Applied); + assert_eq!(cmn_of(&pitch_of(&score, pid)), (CmnNominal::C, 0, 5)); + } + + #[test] + fn cmn_24_with_an_authored_spelling_still_refuses() { + // The complement: the spelling pre-pass remains 12-chromatic, so an + // *authored* spelling on a `cmn-24` pitch genuinely cannot be rewritten + // at the transposed position, and the operation refuses rather than + // leave the spelling stale — the point-of-use `?` still fires when + // there is a spelling to transpose. + let mut c4 = cmn_pitch(CmnNominal::B, 0, 4); + c4.scale_position.space = epiphany_core::PitchSpaceId::new("cmn-24"); + let (mut base, pid) = base_with_pitch(c4); + author_spelling(&mut base, pid, PitchSpelling::cmn(CmnNominal::B, 4)); + let expected = pitch_of(&base, pid); + + let (effect, score) = run_transpose(&base, &[pid], interval(1, 2)); + assert_eq!( + effect, + OperationEffect::NoOp { + reason: NoOpReason::PreconditionFailedUnderReduction { + reason: PreconditionFailureReason::TranspositionOutOfRange, + }, + } + ); + // Refused atomically: the value is untouched, not left half-transposed. + assert_eq!(pitch_of(&score, pid), expected); + } + #[test] fn transpose_interval_propagates_the_spelling_it_determined() { // req:opcat:transpose-interval-spelling. Core Ch2: "editing operations