Push 4b: ji-adaptive-5limit resolves, and the blocker was never HarmonicContext

The last fail-closed entry in the built-in tuning catalog now resolves, leaving
only the compatibility-mapping registry open in Push 4b.

The recorded blocker was wrong. Five places in tuning.rs claimed adaptive
"needs HarmonicContext, which does not exist in Rust". But
req:tuning:adaptive-default-version makes version 1 a pure function of
(position, anchor pitch class) — it ignores concurrent, recent, hints,
parameters, and mode. And two of HarmonicContext's four specified fields are
UNIMPLEMENTABLE: key_context and hints are typed on KeyContext / ContextHint,
which core_spec.tex:4111 leaves undefined deliberately, "so defining them now
would freeze a type surface on a chapter with no consumer". The minimal
one-field shape was not a preference; it was the only implementable one. Each
remaining field arrives with the first function that consumes it.

The real blocker was structural and small: locate_voice returned
(RegionId, StaffId) while key_sequence lives on StaffInstance, so the resolver
threw away the only object that could answer "what key is prevailing here".

No new pitch math. ji_static_5limit_ratios already took a runtime anchor — the
three ji-static-5limit-{C,G,D} built-ins are that one function at anchors
0, 7, 2 — so adaptive v1 is that same call with a derived anchor. Verified as
an identity: adaptive at anchor 0 is bit-identical to ji-static-5limit-C and at
anchor 7 to ji-static-5limit-G, across all seven naturals.

A missing tonal centre is NOT an error. core_spec.tex:3452 mandates C
(chromatic position 0) when none is supplied, so that is a defined default, not
a fail-closed case; a test pins it against a future "fail closed" refactor.
Fail-closed applies to exactly two things: an unregistered
AdaptiveTuningFunctionId (hard error, no fallback) and a TimeAnchor that cannot
be unambiguously ordered against the onset (AnchorNotOrderable, naming the kind
that defeated it). If any KeySignatureChange in a sequence is unorderable the
whole derivation fails, rather than risk skipping the true prevailing signature.

Anchor arithmetic is (7 * fifths).rem_euclid(12), never %: fifths reaches -7 and
% yields a negative pitch class. ChromaticPitchClass is a checked 0..=11 newtype,
so the sign bug cannot degrade into a silently wrong anchor — under mutation it
panics outright. Verified by hand across the whole -7..=7 range, including the
enharmonic collisions (6 and -6 both F#/Gb = 6).

Nothing reaches the wire: TuningResolution is catalog-computed and appears in
neither codec.rs nor textvalue_graph.rs, so TuningResolution::Adaptive and
HarmonicContext are in-memory only and schema major 3 is untouched. Zero Codec
impls added; no vector or golden moved.

Gate: fmt clean, clippy 0, 1311 passed / 0 failed, doc 0, conformance 8/8,
requirement labels 6/6 at 212/282/282. Independently verified: the C default,
static systems ignoring context, statelessness across reordered resolution, and
the 0..=11 invariant. Two mutations killed — rem_euclid -> % and a silent
fallback for an unregistered id.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-24 10:17:46 -04:00
parent 1dfa8b4f3a
commit 56950f8d7c
4 changed files with 997 additions and 99 deletions

View File

@ -34,10 +34,11 @@
//! no `Codec` impl exists for anything here (Push 4b Ruling C).
//! * `tuning` — the Chapter 4 tuning-resolution vocabulary
//! ([`TuningSystem`], [`TuningResolution`], [`TuningOverride`],
//! [`TuningScope`]), [`built_in_tuning_system`] (nine of the twenty
//! catalog identifiers; the rest fail closed), and
//! [`resolve_pitch_frequency`], the five-scope resolver from a pitch to a
//! frequency in Hz. In-memory only, same discipline as `pitch_space`
//! [`TuningScope`], [`HarmonicContext`]), [`built_in_tuning_system`] (all
//! twenty catalog identifiers resolve), [`derive_tonal_centre`] (the
//! score-graph-derived adaptive anchor), and [`resolve_pitch_frequency`],
//! the five-scope resolver from a pitch to a frequency in Hz. In-memory
//! only, same discipline as `pitch_space`
//! (Push 4b Ruling C).
//! * `accidental` — the Chapter 4 accidental/glyph/engraving vocabulary
//! ([`AccidentalDefinition`], [`ScoreAccidentalExtensions`],
@ -106,15 +107,16 @@ pub use time::{
pub use pitch::{
canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalGroupId, AccidentalId,
AccidentalRegistryId, AcousticPitch, AcousticRealization, CmnNominal, CustomGlyphId,
DecompositionAlgorithmId, ForeignFormatId, IdentifiedPitch, IntervalAlgebraRegistryId,
ModificationRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition,
PitchSpelling, PositionRegistryId, PositionStructureRegistryId, ReferencePitch, ScalePosition,
SpellingAlgorithmId, SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal,
SpellingPrecedence, SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope,
SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId,
TransposeRefusal, TranspositionInterval, TranspositionRegistryId, TuningFunctionId,
TuningReference, TuningSystemId, VoiceSelector,
AccidentalRegistryId, AcousticPitch, AcousticRealization, AdaptiveTuningFunctionId,
ChromaticPitchClass, CmnNominal, CustomGlyphId, DecompositionAlgorithmId, ForeignFormatId,
IdentifiedPitch, IntervalAlgebraRegistryId, ModificationRegistryId, NominalRegistryId, Pitch,
PitchRange, PitchSpaceId, PitchSpacePosition, PitchSpelling, PositionRegistryId,
PositionStructureRegistryId, ReferencePitch, ScalePosition, SpellingAlgorithmId,
SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal, SpellingPrecedence,
SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope, SpellingSource,
SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId, TransposeRefusal,
TranspositionInterval, TranspositionRegistryId, TuningFunctionId, TuningReference,
TuningSystemId, VoiceSelector,
};
pub use pitch_space::{
@ -167,9 +169,10 @@ pub use tempo::{
};
pub use tuning::{
built_in_tuning_system, frequency_for_position, resolve_pitch_frequency, resolve_tuning_scope,
PositionRatio, ResolvedTuning, TuningCatalogEntry, TuningOverride, TuningParameters,
TuningResolution, TuningResolutionError, TuningScope, TuningSystem,
built_in_tuning_system, derive_tonal_centre, frequency_for_position, resolve_pitch_frequency,
resolve_tuning_scope, HarmonicContext, PositionRatio, ResolvedTuning, TuningCatalogEntry,
TuningOverride, TuningParameters, TuningResolution, TuningResolutionError, TuningScope,
TuningSystem,
};
pub use codec::{CanonicalValue, ScoreDecodeError};

View File

@ -91,6 +91,18 @@ catalog_id!(
/// registry for) and fails closed.
TuningFunctionId
);
catalog_id!(
/// Identifies a registered adaptive tuning function, consumed by
/// [`crate::tuning::TuningResolution::Adaptive`] (Chapter 4 §"Adaptive
/// Tuning", `req:tuning:adaptive-default-version`). The built-in catalog
/// entry `ji-adaptive-5limit` is bound to `"default-v1"` — the version
/// lives *inside* the identifier string, not as prose beside it, so a
/// future version 2 would mint a new identifier rather than silently
/// changing what `"default-v1"` means. An unregistered id (this tranche
/// registers exactly the one) is a hard error with no silent fallback to
/// a default version.
AdaptiveTuningFunctionId
);
catalog_id!(
/// Identifies an accidental registry (Chapter 4 §"Accidental Registries").
AccidentalRegistryId
@ -550,6 +562,35 @@ pub struct AcousticPitch {
pub realization: AcousticRealization,
}
/// A chromatic pitch class in `0..=11` — one of the twelve positions of the
/// `cmn-12` chromatic layer, with no octave. Used where a value must name
/// *which* pitch class rather than a full pitch, such as
/// [`crate::tuning::HarmonicContext`]'s tonal centre
/// (`req:tuning:adaptive-anchor-derivation`). [`Pitch::twelve_tet_class`],
/// just below, already returns a `0..=11` `u8`; this newtype enforces that
/// range in the type itself rather than merely documenting it, following
/// [`crate::graph::KeySignature::new`]'s checked-constructor style — a raw
/// `u8` field a caller could set to `200` is not a chromatic pitch class.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct ChromaticPitchClass(u8);
impl ChromaticPitchClass {
/// Builds a chromatic pitch class, rejecting values outside `0..=11`.
pub const fn new(value: u8) -> Option<Self> {
if value <= 11 {
Some(ChromaticPitchClass(value))
} else {
None
}
}
/// The underlying `0..=11` value.
#[inline]
pub const fn get(self) -> u8 {
self.0
}
}
/// A pitch's intrinsic identity: scale position plus acoustic realization
/// (Chapter 2 §"The Pitch Type"). Spellings are attached externally.
///

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,230 @@
# CONTRACT — Push 4b: `ji-adaptive-5limit`, the last fail-closed tuning system
**Status:** dispatch-ready. Closes the final deferred entry in the built-in
tuning catalog (`req:tuning:builtin-tuning-catalog`), leaving only the
compatibility-mapping registry open in Push 4b.
**Ratified by the user 2026-07-23:**
1. **A minimal `HarmonicContext`** carrying only the tonal centre — not the full
four-field spec listing, and not "no struct at all".
2. **One resolver with an optional context parameter** — static systems ignore
it, adaptive consumes it. This is the spec's own stated API shape.
3. **Constrain and fail closed** on anchor ordering: order what is
unambiguously determinable, return a distinct reported error otherwise.
Never guess.
**Nothing in this tranche reaches the wire.** `TuningResolution` appears nowhere
in `codec.rs` or `textvalue_graph.rs` — the score stores only *identifiers*
(`default_tuning_system`, `overrides`). `TuningResolution::Adaptive` and
`HarmonicContext` are in-memory only and fully reversible. Do **not** add a
`Codec` for anything here, and do not touch schema major 3.
---
## Correct the recorded blocker first
`tuning.rs` says in three places that adaptive "needs `HarmonicContext`, which
does not exist in Rust". That framing is wrong and should be fixed as you go:
- `req:tuning:adaptive-default-version` (`core_spec.tex:3438`) makes version 1
**"a pure function of (position, anchor pitch class)"**. It explicitly
*ignores* `concurrent`, `recent`, `hints`, `parameters`, and mode.
- **Two of `HarmonicContext`'s four spec'd fields are unimplementable by
construction**: `key_context: Option<KeyContext>` and `hints: Vec<ContextHint>`
name types the specification deliberately leaves undefined
(`core_spec.tex:4111`, Forward References: "defining them now would freeze a
type surface on a chapter with no consumer").
So the minimal shape is not merely preferable — it is the only implementable
one. What v1 needs is a single chromatic pitch class.
## What already exists — do not rebuild it
`ji_static_5limit_ratios(anchor_chromatic_degree: i32) -> Vec<PositionRatio>`
(`tuning.rs:358`) **already takes a runtime anchor**. The three
`ji-static-5limit-{C,G,D}` entries are that one function at anchors 0, 7, and 2.
`req:tuning:adaptive-default-version`'s first clause — "the position resolves
through the construction of `req:tuning:ji-static-construction`, transposed so
the anchor takes the role of 1/1" — **is exactly that call with a derived
anchor**. Reuse it. Do not write a second lattice construction, and do not
transcribe a cents table.
## The surface
### 1. `AdaptiveTuningFunctionId`
Mint via the existing `catalog_id!` macro in `pitch.rs` (beside
`TuningFunctionId`, `pitch.rs:92`), with a doc comment stating that
`ji-adaptive-5limit` is bound to `"default-v1"`, that the version lives *inside*
the identifier string, and that an unregistered id is a hard error with no
silent fallback (`req:tuning:adaptive-default-version`, final clause).
### 2. `TuningResolution::Adaptive`
Add the variant the specification names:
```rust
Adaptive { function: AdaptiveTuningFunctionId },
```
In-memory only. Follow the `Function` variant's doc style (`tuning.rs:119-134`):
say what resolves it, and that an unregistered id fails closed.
### 3. `HarmonicContext` — minimal, and say why
```rust
pub struct HarmonicContext {
/// The active tonal centre as a chromatic pitch class (0..=11), if known.
pub tonal_centre: Option<ChromaticPitchClass>,
}
```
The doc comment MUST record why this is not the four-field listing at
`core_spec.tex:3411`: `key_context` and `hints` are typed on `KeyContext` /
`ContextHint`, which the specification leaves undefined on purpose; `concurrent`
and `recent` are ignored by version 1, so carrying them would mint an unconsumed
type surface (the `NOTEHEAD_ANCHORS` failure the module doc at `tuning.rs:25`
already cites). Each field arrives with the first function that consumes it.
For the pitch class, enforce the `0..=11` invariant in the type rather than
merely documenting it — follow `KeySignature::new`'s checked-constructor style
(`graph.rs:156`, returns `Option`). A raw `u8` field a caller can set to 200 is
not acceptable. (`Pitch::chromatic_pitch_class`, `pitch.rs:585`, already returns
a `0..=11` `u8`; a small checked newtype in `pitch.rs` beside it is the natural
home, and lets that method's contract be stated in the type.)
### 4. The anchor derivation — `req:tuning:adaptive-anchor-derivation`
A public function deriving the tonal centre from the score graph. Per the
requirement (`core_spec.tex:3474-3489`):
- `anchor_pc = (7 × fifths).rem_euclid(12)`, reading `fifths` as the **major
tonic**. **Mode is not consulted** — a signature of 0 anchors at C whether the
prevailing key is C major or A minor. Use `rem_euclid`, never `%`: `fifths` is
negative for flat keys (`KeySignature::MIN_FIFTHS == -7`) and `%` would yield a
negative pitch class.
- The *prevailing* signature is the one **on the staff containing the pitch**,
from that staff instance's `key_sequence` (`StaffInstance.key_sequence`,
`graph.rs:607`), at the latest `KeySignatureChange` whose anchor is **at or
before the pitch's onset**.
- No applicable change ⇒ no tonal centre (the resolver then defaults to C; see
§5).
**`locate_voice` (`tuning.rs:1081`) currently returns `(RegionId, StaffId)` and
throws away the `StaffInstance` it already has in hand.** `key_sequence` lives on
the *instance*, not on `Staff`. Widen it (or add a sibling) to return the
instance. This is the single structural blocker that made adaptive resolution
impossible before.
**Ordering — constrain and fail closed.** `KeySignatureChange.anchor` is a
`TimeAnchor` (Event / Measure / Region / WallClock, `time.rs:592`) and an event's
onset is an `EventPosition` (`Musical` | `WallClock`, `time.rs:628`). Order the
cases that are unambiguously determinable, and return a **distinct, reported
error** for the rest (cross-clock comparison, or an indirect anchor you cannot
resolve) rather than guessing an ordering. Precedent for anchor resolution, which
you should follow rather than reinvent: `tempo.rs:331/356/376` inject a
`resolve: impl Fn(&TimeAnchor) -> Option<MusicalPosition>` closure, and
`invariants.rs:407` walks anchors depth-bounded. Reusing `tempo.rs`'s injected
shape is preferred — it keeps this module out of the time-model business.
Name the error for what it is (e.g. `AnchorNotOrderable { .. }`), document that
it is a *deliberate deferral* like `IncompatiblePitchSpace`, and make the message
say which anchor kind defeated it. A caller must be able to tell "this score
uses a form I don't order yet" from "your key signature is wrong".
### 5. The resolver seam
```rust
pub fn resolve_pitch_frequency(
score: &Score,
pitch: &Pitch,
voice: VoiceId,
context: Option<&HarmonicContext>,
) -> Result<f64, TuningResolutionError>
```
`resolve_pitch_frequency` has **no production callers** — only its own tests
(verified across the workspace), so this signature change is nearly free. Update
the five in-module test call sites and the `lib.rs:170` re-export docs.
Resolution of `TuningResolution::Adaptive`:
1. If `function` is not the registered `"default-v1"`**hard error**. No
fallback (`req:tuning:adaptive-default-version`).
2. Anchor = `context`'s `tonal_centre` when supplied; **otherwise C, chromatic
position 0**. This is spec-mandated (`core_spec.tex:3452-3453`: "C (chromatic
position 0) when no tonal centre is supplied") — it is a *defined default*,
**not** a fail-closed case. Do not turn a missing context into an error.
3. Resolve the position through `ji_static_5limit_ratios(anchor)`.
Static systems ignore `context` entirely — that is the spec's shape
(`core_spec.tex:3426-3428`).
### 6. Catalog entry
`built_in_tuning_system` (`tuning.rs:326`) currently returns
`TuningCatalogEntry::Deferred(DEFERRED_ADAPTIVE)` for `"ji-adaptive-5limit"`.
It becomes `Resolved(..)` with `resolution: Adaptive { function: "default-v1" }`
over pitch space `cmn-12`. Remove `DEFERRED_ADAPTIVE` and retire the stale
"needs HarmonicContext" notes at `tuning.rs:27`, `:43`, `:223`, `:279`, `:1386`.
The test `deferred_ji_adaptive_fails_closed` (`tuning.rs:1382`) **inverts**: it
must now prove the system resolves. Rename it accordingly. Do not delete it.
## Tests that would fail against the bugs this invites
- **Comma-drift-free by shape.** `req:tuning:adaptive-default-version`'s last
clause: "No adjustment is ever carried forward from a previous resolution."
Resolve the same position repeatedly, and in varying orders, and assert
bit-identical results — the property that would catch a stateful implementation.
- **Mode-blindness.** A signature of 0 anchors at C. Assert a relative-minor
reading is *not* applied.
- **Flat keys.** `fifths = -1``(7 × -1).rem_euclid(12) = 5` (F). A `%`
implementation yields `-7` — assert the correct value so the sign bug cannot
survive.
- **The default.** No context, or a context with `tonal_centre: None`, resolves
at anchor C — equal to `ji-static-5limit-C`'s result for the same position.
This pins the spec's mandated default against a future "fail closed" refactor.
- **Unregistered function id is a hard error**, with no fallback to C.
- **Prevailing selection.** With two `KeySignatureChange`s on a staff, a pitch
between them takes the earlier; a pitch after both takes the later.
## Do NOT
- Define `KeyContext`, `ContextHint`, or `AdaptiveTuningParameters` — the
specification leaves all three undefined deliberately.
- Add `concurrent` / `recent` to `HarmonicContext`.
- Add any `Codec`, touch schema major 3, or alter `spec/vectors/`.
- Build a general `TimeAnchor` resolution layer — constrain and fail closed.
- Touch the editor track (`spec/PLAN_EDITOR_APP.md`,
`spec/CONTRACT_EDITOR_T1A_GOLDENS.md`, `spec/CONTRACT_EDITOR_T2_SELECTION.md`,
`crates/epiphany-editor-gui/goldens/`) or `.claude/worktrees/`.
## Spec
The two requirements are already ratified and need **no change**. If a
`core_spec.tex` touch is genuinely needed (e.g. a note that the implementation's
`HarmonicContext` carries only the tonal centre until a consumer needs more), add
it as prose or a `rationale`**no new `\label{req:...}`**. Requirement counts
MUST stay **212 / 282 / 282**.
## The gate (report exact numbers)
- `cargo fmt --all --check`
- `cargo clippy --workspace --all-targets` → 0 warnings
- `cargo test --workspace` → 0 failed (3b-ii landed at 1283; report the count)
- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` → 0
- `cargo run -q -p epiphany-testkit --example conformance_suite` → 8/8
- `cargo test -p epiphany-testkit --test requirement_labels` → 6/6, counts
**212 / 282 / 282**
## What the reviewer will verify independently — build to survive it
- The anchor arithmetic by hand across the full `-7..=7` range, especially flat
keys, against `(7 × fifths).rem_euclid(12)`.
- That adaptive at anchor 0 equals `ji-static-5limit-C` position-for-position,
and at anchor 7 equals `ji-static-5limit-G` — the transposition identity.
- That the missing-context default is C and is **not** an error.
- Mutation: break `rem_euclid` to `%` and confirm the flat-key test dies; make
the unregistered-id path fall back to C and confirm the hard-error test dies.
- That no `Codec` was added and canonical bytes are unmoved.