epiphany/crates
Levi Neuwirth 27c80a0d83 Pin the interval algebra on the type that already carried it
req:pitch:transposition, implemented as Pitch::transposed. With n the
nominal's normative discriminant and s = nominal.chromatic() + alteration +
12*octave:

    nominal'    = CmnNominal((n + d).rem_euclid(7))
    octave'     = octave + (n + d).div_euclid(7)
    alteration' = (s + c) - (nominal'.chromatic() + 12*octave')

The diatonic component alone picks the nominal and octave; the alteration
absorbs the residue. C4 + (7, 12) is C5, not C with twelve sharps. C4 + (0, 1)
is C#4, so the editor's sharpen is unchanged. A perfect fifth (4, 7) and a
diminished sixth (5, 7) from C4 give G4 and A-double-flat-4: same sound,
different spelling, which is the whole reason the interval is a pair.

TranspositionInterval moves from graph.rs to pitch.rs. It already existed --
Instrument.transposition has carried it since schema major 2, already codec'd,
already exported -- and it is byte-for-byte the pair this needs. The spec now
declares it once, in Chapter 2 where transposition lives, so the struct sits
where its algebra does. Wire form and codec are untouched.

Its doc claimed it was "ADVISORY until the Chapter 4 tuning catalog pins
interval algebra". That was the P12-K2 false coupling repeated a third time.
The field stays advisory because nothing applies it at the written/sounding
boundary -- for that reason, not for want of algebra, which needs no catalog.

Refusal, never saturation: TransposeRefusal::{NonCmnPosition, AcousticPinned,
OutOfRange}. An AbsoluteHz pitch overrides the tuning system, so transposing
it would move the notehead and leave the sound behind; the mutation that drops
that guard returns Ok(G4 ... AbsoluteHz(261.6)), a notehead reading G4 that
still sounds middle C. TranspositionInterval::inverse is exact precisely
because nothing saturates.

Six tests, four mutations verified: the old alteration-shift semantics, a
clamp in place of the refusal, a truncating `/` in place of div_euclid (which
breaks C4 down a step to B3), and dropping the AbsoluteHz guard.

One process note. The AbsoluteHz mutation appeared to survive on the first
run. It had not: rustfmt had wrapped the matches! across three lines, so the
string replacement silently matched nothing and the test was never challenged.
A mutation that no-ops looks exactly like a test that passes. Mutations now
assert their anchor before applying.

Gate: clippy 0, core 165+5 passed / 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 15:29:44 -04:00
..
epiphany-bundle Phase B follow-up: real acceleration-snapshot path + base role bound 2026-07-07 16:54:06 -04:00
epiphany-core Pin the interval algebra on the type that already carried it 2026-07-09 15:29:44 -04:00
epiphany-determinism P12-I2: wire the ratified MUSCLOID layout-object id derivation 2026-06-27 12:46:19 -04:00
epiphany-editor-core P13-I2: Staff::default_clef is the fallback, not decoration 2026-07-09 14:47:42 -04:00
epiphany-editor-gui Schema major 2 Phase E2: slur curves + cubic-bézier primitive 2026-07-08 15:33:00 -04:00
epiphany-engrave P13-I2: Staff::default_clef is the fallback, not decoration 2026-07-09 14:47:42 -04:00
epiphany-layout-ir P13-I3: delete the hand-written notehead anchors; teach the extractor 2026-07-09 14:53:47 -04:00
epiphany-ops Push 4a design gate: pin the transpose algebra, freeze the old operation 2026-07-09 15:25:27 -04:00
epiphany-render-svg P13-I3: delete the hand-written notehead anchors; teach the extractor 2026-07-09 14:53:47 -04:00
epiphany-testkit P13-I2: Staff::default_clef is the fallback, not decoration 2026-07-09 14:47:42 -04:00