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:
parent
4a2ee312f7
commit
2cf2dae31b
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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::{
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue