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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-23 09:41:18 -04:00
parent 4a2ee312f7
commit 2cf2dae31b
6 changed files with 843 additions and 43 deletions

View File

@ -682,3 +682,82 @@ errors, but the compiler reports only the *frontier* — `AnchorOffset`,
`VoiceSelector`, `PowerOfTwo`, `OctaveOffset` and `NonZeroU16` were each hidden `VoiceSelector`, `PowerOfTwo`, `OctaveOffset` and `NonZeroU16` were each hidden
behind a type that had not compiled yet. The list has to be iterated to a behind a type that had not compiled yet. The list has to be iterated to a
fixpoint, never taken once. 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<PositionStructure>`, 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.

View File

@ -27,6 +27,11 @@
//! * `pitch` — [`Pitch`], [`ScalePosition`], [`IdentifiedPitch`], //! * `pitch` — [`Pitch`], [`ScalePosition`], [`IdentifiedPitch`],
//! [`PitchSpelling`], and the spelling-attachment subsystem (Chapter 2; //! [`PitchSpelling`], and the spelling-attachment subsystem (Chapter 2;
//! Chapter 4 for the tuning/pitch-space registry identifiers it references). //! 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 //! * `event` — the [`Event`] taxonomy and the [`EventArena`] (Chapter 5
//! §"The Event Arena"). //! §"The Event Arena").
//! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct //! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct
@ -52,6 +57,7 @@ mod ids;
mod indexes; mod indexes;
mod invariants; mod invariants;
mod pitch; mod pitch;
mod pitch_space;
mod tempo; mod tempo;
mod textvalue_event; mod textvalue_event;
mod textvalue_graph; mod textvalue_graph;
@ -83,12 +89,18 @@ pub use time::{
pub use pitch::{ pub use pitch::{
canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId, canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId,
AcousticPitch, AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId, AcousticPitch, AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId,
IdentifiedPitch, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition, IdentifiedPitch, IntervalAlgebraRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId,
PitchSpelling, PositionRegistryId, ReferencePitch, ScalePosition, SpellingAlgorithmId, PitchSpacePosition, PitchSpelling, PositionRegistryId, PositionStructureRegistryId,
SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal, SpellingPrecedence, ReferencePitch, ScalePosition, SpellingAlgorithmId, SpellingAttachment, SpellingContext,
SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope, SpellingSource, SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingRenderHints, SpellingRule,
SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId, TransposeRefusal, SpellingRuleSetId, SpellingScope, SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId,
TranspositionInterval, TuningReference, TuningSystemId, VoiceSelector, 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::{ pub use prepass::{

View File

@ -127,6 +127,21 @@ catalog_id!(
/// spelling/decomposition provenance tag. /// spelling/decomposition provenance tag.
ForeignFormatId 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 { impl SpellingAlgorithmId {
/// The Phase-2 default spelling algorithm, registered under the id /// The Phase-2 default spelling algorithm, registered under the id
@ -242,9 +257,14 @@ pub enum TransposeRefusal {
NonCmnPosition, NonCmnPosition,
/// The enclosing pitch space's chromatic layer or nominal mapping cannot /// The enclosing pitch space's chromatic layer or nominal mapping cannot
/// be established, so applying the interval would require guessed /// be established, so applying the interval would require guessed
/// arithmetic (`req:pitch:space-capability-refusal`). The pre-registry /// arithmetic (`req:pitch:space-capability-refusal`). Structural: the
/// implementation recognizes only built-in `cmn-12`; Push 4b replaces that /// space's [`PositionStructure`](crate::pitch_space::PositionStructure)
/// identifier check with structural pitch-space resolution. /// 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, PitchSpaceUnavailable,
/// The pitch's [`AcousticRealization::AbsoluteHz`] overrides the tuning /// The pitch's [`AcousticRealization::AbsoluteHz`] overrides the tuning
/// system, so moving the scale position would move the notehead without /// system, so moving the scale position would move the notehead without
@ -254,28 +274,60 @@ pub enum TransposeRefusal {
OutOfRange, 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<u16>)> {
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 { impl Pitch {
/// Transposes this pitch by `interval`, per Chapter 2 /// Transposes this pitch by `interval`, per Chapter 2
/// `req:pitch:transposition`. /// `req:pitch:transposition`.
/// ///
/// With `n` the nominal's normative discriminant and, for the proven /// With `n` the nominal's normative discriminant, `C` the enclosing
/// `cmn-12` structure, the absolute semitone /// space's `chromatic_positions_per_octave`, and `m` its
/// `s = nominal.chromatic() + alteration + 12*octave`, transposing by /// `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 /// `{ d, c }` yields
/// ///
/// ```text /// ```text
/// nominal' = CmnNominal((n + d).rem_euclid(7)) /// nominal' = CmnNominal((n + d).rem_euclid(7))
/// octave' = octave + (n + d).div_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 /// `C` and `m` are resolved by looking `self.scale_position.space` up in
/// position outside built-in `cmn-12` is refused rather than interpreted /// [`crate::pitch_space::built_in_position_structure`]: when it resolves
/// with this 12-chromatic map. /// 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 /// The diatonic component alone selects the nominal and octave; the
/// alteration absorbs exactly the residue. So `C4 + (7, 12)` is `C5`, not /// alteration absorbs exactly the residue. So in `cmn-12`, `C4 + (7, 12)`
/// "C with twelve sharps", and `C4 + (0, 1)` is `C#4`. /// is `C5`, not "C with twelve sharps", and `C4 + (0, 1)` is `C#4`.
/// ///
/// Refuses rather than saturating, clamping, or approximating: a /// Refuses rather than saturating, clamping, or approximating: a
/// transposition that silently produces a pitch nobody asked for reports /// transposition that silently produces a pitch nobody asked for reports
@ -295,29 +347,32 @@ impl Pitch {
else { else {
return Err(TransposeRefusal::NonCmnPosition); return Err(TransposeRefusal::NonCmnPosition);
}; };
if self.scale_position.space.as_str() != "cmn-12" { let (chromatic_card, nominal_to_chromatic) =
return Err(TransposeRefusal::PitchSpaceUnavailable); diatonic_over_chromatic_structure(&self.scale_position.space)
} .ok_or(TransposeRefusal::PitchSpaceUnavailable)?;
// Widen to `i64` before any arithmetic. The `i8` bound is a *result* // Widen to `i64` before any arithmetic. The `i8` bound is a *result*
// constraint, not an intermediate one, so an octave that overflows on // constraint, not an intermediate one, so an octave that overflows on
// the way to a value that fits would be a spurious refusal — but // 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` // `i32` is not wide enough to hold the intermediates for an `i32`
// interval, and the previous version of this function panicked on // interval, and the previous version of this function panicked on
// `diatonic_steps = i32::MAX` at `12 * new_octave`. Refusing is the // `diatonic_steps = i32::MAX` at `chromatic_card * new_octave`.
// contract; panicking on a value the public type admits is not. // 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 // `i64` is amply wide: `step` is bounded by `6 + 2^31`, so
// `new_octave` by `2^31/7 + 127`, and the largest intermediate // `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 n = i64::from(nominal as u8);
let semitone = let semitone = chromatic_of(nominal) + i64::from(alteration) + c * i64::from(octave);
i64::from(nominal.chromatic()) + i64::from(alteration) + 12 * i64::from(octave);
let step = n + i64::from(interval.diatonic_steps); let step = n + i64::from(interval.diatonic_steps);
let new_nominal = CmnNominal::from_index(step.rem_euclid(7) as i32); let new_nominal = CmnNominal::from_index(step.rem_euclid(7) as i32);
let new_octave = i64::from(octave) + step.div_euclid(7); let new_octave = i64::from(octave) + step.div_euclid(7);
let new_alteration = (semitone + i64::from(interval.chromatic_steps)) 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 octave = i8::try_from(new_octave).map_err(|_| TransposeRefusal::OutOfRange)?;
let alteration = i8::try_from(new_alteration).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 /// 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 /// its 12-chromatic structure is established: `Cmn` positions whose space
/// `cmn-12` and 12-EDO integer positions. Returns `None` for unresolved CMN /// resolves structurally to a `DiatonicOverChromatic` structure with
/// spaces and positions whose 12-TET class is not determinable without a /// exactly 12 chromatic positions (built-in `cmn-12`, at this tranche's
/// tuning resolver (JI vectors, non-12 EDOs, registered grammars). /// catalog), and 12-EDO integer positions. Returns `None` for CMN
/// Octave-blind — for sounding comparison use /// positions in spaces that do not resolve that way (e.g. `cmn-24`,
/// [`Pitch::twelve_tet_semitone`]. /// 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<u8> { pub fn twelve_tet_class(&self) -> Option<u8> {
self.twelve_tet_semitone().map(|s| s.rem_euclid(12) as u8) self.twelve_tet_semitone().map(|s| s.rem_euclid(12) as u8)
} }
/// The *absolute* 12-TET semitone of this pitch's scale position, octave /// The *absolute* 12-TET semitone of this pitch's scale position, octave
/// included, when its 12-chromatic structure is established. For built-in /// included, when its 12-chromatic structure is established. For a `Cmn`
/// `cmn-12` this is /// position this is `octave*C + m(nominal) + alteration`, where `C` and
/// `octave*12 + nominal.chromatic() + alteration` (so C4 and C5 differ by a /// `m` come from the space's structural resolution
/// full octave); for 12-EDO integer positions it is the absolute `index`. /// ([`crate::pitch_space::built_in_position_structure`]) — but only when
/// `None` for unresolved CMN spaces /// `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 /// (`req:pitch:space-capability-refusal`) and positions not determinable
/// without a tuning resolver. /// without a tuning resolver.
/// ///
@ -515,8 +581,14 @@ impl Pitch {
nominal, nominal,
alteration, alteration,
octave, 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), PitchSpacePosition::Integer { space_size, index } if *space_size == 12 => Some(*index),
_ => None, _ => None,
@ -1036,9 +1108,13 @@ mod tests {
use super::*; use super::*;
fn cmn(nominal: CmnNominal, alteration: i8, octave: i8) -> Pitch { 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 { Pitch {
scale_position: ScalePosition { scale_position: ScalePosition {
space: PitchSpaceId::new("cmn-12"), space: PitchSpaceId::new(space),
position: PitchSpacePosition::Cmn { position: PitchSpacePosition::Cmn {
nominal, nominal,
alteration, alteration,
@ -1286,6 +1362,11 @@ mod tests {
#[test] #[test]
fn unresolved_cmn_space_refuses_transposition_and_twelve_tet_conversion() { 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); let mut p = cmn(CmnNominal::E, -1, 4);
p.scale_position.space = PitchSpaceId::new("edo-31"); p.scale_position.space = PitchSpaceId::new("edo-31");
@ -1297,6 +1378,85 @@ mod tests {
assert_eq!(p.twelve_tet_class(), None); 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] #[test]
fn a_transposition_refuses_a_pitch_pinned_to_a_frequency() { fn a_transposition_refuses_a_pitch_pinned_to_a_frequency() {
// AbsoluteHz overrides the tuning system: moving the scale position // AbsoluteHz overrides the tuning system: moving the scale position

View File

@ -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<u16>,
},
/// 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<JiRatio>,
},
/// 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<u16>,
) -> Option<Self> {
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<String>,
/// 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<PositionStructure> {
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<JiRatio>` (`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
);
}
}

View File

@ -1667,3 +1667,41 @@ that locks it, and it fails under that mutation.
`OperationId::new(ReplicaId(7), 1)` have *identical canonical bytes* — typed ids `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 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. 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.

View File

@ -5897,7 +5897,6 @@ impl<'a> Reducer<'a> {
let Some(score) = self.graph.as_ref() else { let Some(score) = self.graph.as_ref() else {
return Some(Vec::new()); return Some(Vec::new());
}; };
let semitone = transposed.twelve_tet_semitone()?;
let mut out = Vec::new(); let mut out = Vec::new();
for (index, att) in score.spelling_attachments.iter().enumerate() { for (index, att) in score.spelling_attachments.iter().enumerate() {
if att.layer.is_some() || matches!(att.source, SpellingSource::Propagated { .. }) { 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 { let SpellingDirective::Explicit(spelling) = &att.directive else {
continue; 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)?)); out.push((index, spelling.transposed(interval, semitone)?));
} }
Some(out) Some(out)
@ -9463,8 +9475,20 @@ mod tests {
#[test] #[test]
fn unresolved_cmn_space_maps_to_canonical_pitch_space_mismatch() { 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); 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 expected = unresolved.clone();
let (base, pid) = base_with_pitch(unresolved); let (base, pid) = base_with_pitch(unresolved);
@ -9483,6 +9507,53 @@ mod tests {
assert_eq!(pitch_of(&score, pid), expected); 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] #[test]
fn transpose_interval_propagates_the_spelling_it_determined() { fn transpose_interval_propagates_the_spelling_it_determined() {
// req:opcat:transpose-interval-spelling. Core Ch2: "editing operations // req:opcat:transpose-interval-spelling. Core Ch2: "editing operations