Transposition must refuse an extreme interval, not panic on it

Audit finding 1, reproduced and fixed. Pitch::transposed did its arithmetic in
i32 while the interval's own components are i32, so intermediates overflowed:
diatonic_steps = i32::MAX panicked at `12 * new_octave`, chromatic_steps =
i32::MAX at `semitone + c`. TranspositionInterval::inverse negated i32::MIN.
The comment above the arithmetic even said "widen before arithmetic" -- it
widened i8 to i32, which is exactly not wide enough. All of it now widens to
i64, where the largest intermediate is bounded by ~3.7e9.

Refusing is the contract. Panicking on a value the public type admits is not.

I checked whether this was worse than a panic. The workspace sets
overflow-checks = true in release, but epiphany-core is a library and a
consumer's default release profile has them off, where these expressions wrap.
A 10.5M-case sweep of wrapping-vs-exact arithmetic (175 base pitches x 60225
interval pairs, edges plus random) found ZERO inputs where wrapping produced a
wrong Ok rather than a refusal. So this was a panic, not silent corruption, and
the audit's characterisation was exactly right.

inverse() now returns Option: -i32::MIN is not an i32. An interval whose
inverse cannot be written down is a fact about the type, and a caller composing
undo out of inverses must see it. Both regressions mutation-verified by
restoring the i32 arithmetic and the bare negation.

Also in this commit, two documentation corrections:

- The reducer's test-harness comment claimed both old transpose_* tests "now
  reduce ONTO a base and assert the pitch value". They do not, and should not;
  DECISIONS.md already said so. The comment now matches.

- The catalog's undo semantics for BOTH transpose kinds claimed value-restoring
  undo recovers the pre-transpose pitch from the write chain. Neither kind
  records into that chain, so this was false. The frozen Transpose reverts to
  its honest pre-Push-4a statement -- undo does not negate it (P11-C8) -- and,
  per the freeze doctrine, that is now pinned: making it record would change
  what a stored {Transpose, UndoTransaction} history replays to. TransposeInterval's
  paragraph states the requirement the next commit implements.

Gate: clippy 0, 30 targets / 985 passed / 0 failed, docs 0 under -D warnings,
conformance 8/8, zero golden churn, catalog rebuilds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-09 16:33:42 -04:00
parent 582b0ca234
commit 4228320f88
5 changed files with 125 additions and 32 deletions

View File

@ -543,8 +543,19 @@ interval at the written/sounding boundary — nothing respells a written part
into a sounding one — so `Instrument.transposition` stays advisory, for that into a sounding one — so `Instrument.transposition` stays advisory, for that
reason and not the stated one. reason and not the stated one.
**Refusal, not saturation.** `alteration` and `octave` are `i8`. A **Refusal, not saturation, and never a panic.** All arithmetic widens to `i64`
transposition whose result does not fit refuses; so does one against a first. `i32` is not wide enough to hold the intermediates of an `i32` interval:
the first version of `transposed` panicked on `diatonic_steps = i32::MAX` at
`12 * new_octave`, and `inverse()` panicked on `i32::MIN`. Refusing is the
contract; panicking on a value the public type admits is not. (Under
`overflow-checks = false` — any downstream release build — those expressions
wrapped instead. A 10.5M-case sweep of wrapping-vs-exact found *no* input where
wrapping produced a wrong `Ok` rather than a refusal, so the defect was a panic,
not silent corruption. `inverse()` now returns `Option`, because an interval
whose inverse is not representable is a fact about the type.)
`alteration` and `octave` are `i8`. A transposition whose result does not fit
refuses; so does one against a
non-`Cmn` position (no nominal to move) or an `AcousticRealization::AbsoluteHz` non-`Cmn` position (no nominal to move) or an `AcousticRealization::AbsoluteHz`
pitch (which overrides the tuning system, so moving the scale position moves pitch (which overrides the tuning system, so moving the scale position moves
the notehead without moving the sound). Saturation is the worst possible the notehead without moving the sound). Saturation is the worst possible

View File

@ -217,13 +217,18 @@ pub struct TranspositionInterval {
} }
impl TranspositionInterval { impl TranspositionInterval {
/// The interval that undoes this one. Exact, because [`Pitch::transposed`] /// The interval that undoes this one, or `None` when it is not
/// never saturates — it refuses instead. /// representable: `i32::MIN` has no `i32` negation. Exact where it exists,
pub fn inverse(self) -> Self { /// because [`Pitch::transposed`] never saturates — it refuses instead.
TranspositionInterval { ///
diatonic_steps: -self.diatonic_steps, /// The non-representable case is explicit rather than a panic or a wrap:
chromatic_steps: -self.chromatic_steps, /// an interval whose inverse cannot be written down is a fact about the
} /// type, and a caller composing undo out of inverses must see it.
pub fn inverse(self) -> Option<Self> {
Some(TranspositionInterval {
diatonic_steps: self.diatonic_steps.checked_neg()?,
chromatic_steps: self.chromatic_steps.checked_neg()?,
})
} }
} }
@ -280,17 +285,25 @@ impl Pitch {
return Err(TransposeRefusal::NonCmnPosition); return Err(TransposeRefusal::NonCmnPosition);
}; };
// Widen before arithmetic: the i8 bound is a *result* constraint, not // Widen to `i64` before any arithmetic. The `i8` bound is a *result*
// an intermediate one, so an octave that overflows on the way to a // constraint, not an intermediate one, so an octave that overflows on
// value that fits would be a spurious refusal. // the way to a value that fits would be a spurious refusal — but
let n = nominal as i32; // `i32` is not wide enough to hold the intermediates for an `i32`
// interval, and the previous version of this function panicked on
// `diatonic_steps = i32::MAX` at `12 * new_octave`. Refusing is the
// contract; panicking on a value the public type admits is not.
//
// `i64` is amply wide: `step` is bounded by `6 + 2^31`, so
// `new_octave` by `2^31/7 + 127`, and the largest intermediate
// `12 * new_octave` by roughly `3.7e9`.
let n = i64::from(nominal as u8);
let semitone = let semitone =
i32::from(nominal.chromatic()) + i32::from(alteration) + 12 * i32::from(octave); i64::from(nominal.chromatic()) + i64::from(alteration) + 12 * i64::from(octave);
let step = n + interval.diatonic_steps; let step = n + i64::from(interval.diatonic_steps);
let new_nominal = CmnNominal::from_index(step.rem_euclid(7)); let new_nominal = CmnNominal::from_index(step.rem_euclid(7) as i32);
let new_octave = i32::from(octave) + step.div_euclid(7); let new_octave = i64::from(octave) + step.div_euclid(7);
let new_alteration = (semitone + interval.chromatic_steps) let new_alteration = (semitone + i64::from(interval.chromatic_steps))
- (i32::from(new_nominal.chromatic()) + 12 * new_octave); - (i64::from(new_nominal.chromatic()) + 12 * 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)?;
@ -1046,7 +1059,9 @@ mod tests {
let start = cmn(CmnNominal::E, -1, 3); let start = cmn(CmnNominal::E, -1, 3);
for interval in [iv(4, 7), iv(-2, -3), iv(7, 12), iv(0, 1), iv(5, 7)] { for interval in [iv(4, 7), iv(-2, -3), iv(7, 12), iv(0, 1), iv(5, 7)] {
let there = start.transposed(interval).unwrap(); let there = start.transposed(interval).unwrap();
let back = there.transposed(interval.inverse()).unwrap(); let back = there
.transposed(interval.inverse().expect("these inverses exist"))
.unwrap();
assert_eq!( assert_eq!(
position(&back), position(&back),
position(&start), position(&start),
@ -1078,6 +1093,45 @@ mod tests {
assert_eq!(position(&high), (CmnNominal::C, 0, 127)); assert_eq!(position(&high), (CmnNominal::C, 0, 127));
} }
#[test]
fn an_extreme_interval_refuses_instead_of_panicking() {
// The public type admits any `i32`. Every one of these overflowed an
// `i32` intermediate and panicked (or, with overflow-checks off, wrapped
// through unspecified arithmetic) before the widening to `i64`.
let p = cmn(CmnNominal::C, 0, 4);
for (d, c) in [
(i32::MAX, 0),
(i32::MIN, 0),
(0, i32::MAX),
(0, i32::MIN),
(i32::MAX, i32::MAX),
(i32::MIN, i32::MIN),
(i32::MAX, i32::MIN),
] {
assert_eq!(
p.transposed(iv(d, c)),
Err(TransposeRefusal::OutOfRange),
"({d}, {c}) must refuse, not panic"
);
}
// The boundary either side of a representable octave shift.
assert!(cmn(CmnNominal::C, 0, 126).transposed(iv(7, 12)).is_ok());
assert_eq!(
cmn(CmnNominal::C, 0, 127).transposed(iv(7, 12)),
Err(TransposeRefusal::OutOfRange)
);
}
#[test]
fn the_inverse_of_the_unrepresentable_interval_is_none() {
// `-i32::MIN` is not an `i32`. Negating it panicked.
assert_eq!(iv(i32::MIN, 0).inverse(), None);
assert_eq!(iv(0, i32::MIN).inverse(), None);
assert_eq!(iv(i32::MIN, i32::MIN).inverse(), None);
assert_eq!(iv(i32::MIN + 1, 0).inverse(), Some(iv(i32::MAX, 0)));
assert_eq!(iv(4, 7).inverse(), Some(iv(-4, -7)));
}
#[test] #[test]
fn a_transposition_refuses_a_non_cmn_position() { fn a_transposition_refuses_a_non_cmn_position() {
let mut p = cmn(CmnNominal::C, 0, 4); let mut p = cmn(CmnNominal::C, 0, 4);

View File

@ -8940,8 +8940,14 @@ mod tests {
// The two `transpose_*` tests below were FALSE LOCKS until Push 4a: they // The two `transpose_*` tests below were FALSE LOCKS until Push 4a: they
// reduced base-free, where `graph` is `None` and `graph_transpose_pitch` // reduced base-free, where `graph` is `None` and `graph_transpose_pitch`
// never runs, and they asserted only `OperationEffect`. Gutting the // never runs, and they asserted only `OperationEffect`. Gutting the
// transpose entirely left both green. They now reduce ONTO a base and // transpose entirely left both green.
// assert the pitch value, which is the thing the operation exists to change. //
// They still reduce base-free, and should: what they assert — skip
// tombstoned, skip system-derived, refuse missing — *is* effect-log
// behaviour. Only the first one's NAME was a lie (it claimed to check the
// live target "shifts"). It was renamed. The shift itself is locked by the
// `the_frozen_transpose_*` tests, which reduce ONTO a base and assert the
// pitch value.
/// Every `(event, pitch)` in canonical id order — `events.iter()` walks a /// Every `(event, pitch)` in canonical id order — `events.iter()` walks a
/// slotmap, whose order is an implementation detail. /// slotmap, whose order is an implementation detail.

Binary file not shown.

View File

@ -665,9 +665,20 @@ non-system target \emph{occurrence} in \texttt{targets}, the target's \texttt{Cm
(a deterministic repair, not a conflict). (a deterministic repair, not a conflict).
\textbf{Undo semantics.} Transpose mints nothing, so the prototype's minted-object \textbf{Undo semantics.} Transpose mints nothing, so the prototype's minted-object
undo (Section~\ref{sec:k0:undo}) does not negate it; value-restoring undo recovers undo (Section~\ref{sec:k0:undo}) does not negate it, and it records no write into
the pre-transpose pitch from the write chain rather than by applying an inverse the pitch's value chain, so \emph{value-restoring undo does not restore it
interval --- which, under saturation, would not exist. either}: undoing a transaction containing a \texttt{Transpose} leaves the shifted
pitch shifted. An inverse-interval undo does not exist (under saturation the
inverse is not a function) and remains a deferred refinement (P11-C8).
\textbf{This too is frozen.} Making \texttt{Transpose} record into the value
chain would not change its own reduction rule, but it \emph{would} change what a
stored history $\{\texttt{Transpose}, \texttt{UndoTransaction}\}$ replays to ---
from "the pitch stays shifted" to "the pitch returns" --- which is a change in
what an existing document means. \texttt{TransposeInterval} records its write and
\emph{is} undoable (Section~\ref{sec:k0:transpose-interval}); the frozen
operation is permanently not. That asymmetry is another reason never to author
it.
\textbf{Re-anchoring.} Tombstoned targets are skipped (the transpose applies only \textbf{Re-anchoring.} Tombstoned targets are skipped (the transpose applies only
to live pitches). \texttt{SYSTEM\_DERIVED}-namespace targets are likewise to live pitches). \texttt{SYSTEM\_DERIVED}-namespace targets are likewise
@ -745,10 +756,18 @@ is transposed by \texttt{interval}.
\textbf{Conflict cases.} None --- composition is deterministic in canonical order. \textbf{Conflict cases.} None --- composition is deterministic in canonical order.
\textbf{Undo semantics.} As \texttt{Transpose}: nothing is minted, and \textbf{Undo semantics.} Nothing is minted. \texttt{TransposeInterval}
value-restoring undo recovers the pre-transpose pitch from the write chain. Here \MUST{} record each transposed pitch's new value into that pitch's value chain,
an inverse interval does exist ($(-d, -c)$), because the reduction never and each spelling attachment it rewrote into the corresponding spelling chain, so
saturates; undo does not rely on it. that value-restoring undo recovers the pre-transpose pitch \emph{and} its
pre-transpose spelling. Restoring the pitch alone would leave a notehead spelled
for a pitch that no longer exists. This is where \texttt{TransposeInterval}
departs from the frozen \texttt{Transpose}, which records nothing and is
therefore not undoable.
An inverse interval usually exists ($(-d, -c)$), because the reduction never
saturates --- but not always: $\texttt{i32::MIN}$ has no negation. Undo does not
rely on it.
\textbf{Re-anchoring.} As \texttt{Transpose}. \textbf{Re-anchoring.} As \texttt{Transpose}.
@ -1366,9 +1385,12 @@ undo) design, which subsumes this question.
identifier derivation the ratified closed tag set does not yet include); and identifier derivation the ratified closed tag set does not yet include); and
\texttt{Cascade}'s dependent-closure computation --- \texttt{Cascade} remains \texttt{Cascade}'s dependent-closure computation --- \texttt{Cascade} remains
\texttt{StrictInverse} over the same set. \texttt{Transpose} inversion is no \texttt{StrictInverse} over the same set. \texttt{Transpose} inversion is no
longer listed: \texttt{TransposeInterval} has an exact inverse $(-d, -c)$ longer listed: \texttt{TransposeInterval} is undone by value restoration, not by
because it never saturates (Push~4a, closing P12-K2), though value-restoring applying an inverse interval (Push~4a, closing P12-K2). An inverse
undo does not need one. The frozen \texttt{Transpose} still has none. $(-d, -c)$ does exist wherever both components are negatable --- the reduction
never saturates --- but $\texttt{i32::MIN}$ has none, so the inverse is partial
and undo does not depend on it. The frozen \texttt{Transpose} is undone by
neither.
% =========================================================================== % ===========================================================================
\chapter{v0 \texorpdfstring{$\rightarrow$}{->} v1 Payload Migration} \chapter{v0 \texorpdfstring{$\rightarrow$}{->} v1 Payload Migration}