11 KiB
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:
- A minimal
HarmonicContextcarrying only the tonal centre — not the full four-field spec listing, and not "no struct at all". - One resolver with an optional context parameter — static systems ignore it, adaptive consumes it. This is the spec's own stated API shape.
- 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 ignoresconcurrent,recent,hints,parameters, and mode.- Two of
HarmonicContext's four spec'd fields are unimplementable by construction:key_context: Option<KeyContext>andhints: 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:
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
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), readingfifthsas the major tonic. Mode is not consulted — a signature of 0 anchors at C whether the prevailing key is C major or A minor. Userem_euclid, never%:fifthsis 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 latestKeySignatureChangewhose 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
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:
- If
functionis not the registered"default-v1"⇒ hard error. No fallback (req:tuning:adaptive-default-version). - Anchor =
context'stonal_centrewhen 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. - 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 toji-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
KeySignatureChanges on a staff, a pitch between them takes the earlier; a pitch after both takes the later.
Do NOT
- Define
KeyContext,ContextHint, orAdaptiveTuningParameters— the specification leaves all three undefined deliberately. - Add
concurrent/recenttoHarmonicContext. - Add any
Codec, touch schema major 3, or alterspec/vectors/. - Build a general
TimeAnchorresolution 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 --checkcargo clippy --workspace --all-targets→ 0 warningscargo test --workspace→ 0 failed (3b-ii landed at 1283; report the count)RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps→ 0cargo run -q -p epiphany-testkit --example conformance_suite→ 8/8cargo 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..=7range, especially flat keys, against(7 × fifths).rem_euclid(12). - That adaptive at anchor 0 equals
ji-static-5limit-Cposition-for-position, and at anchor 7 equalsji-static-5limit-G— the transposition identity. - That the missing-context default is C and is not an error.
- Mutation: break
rem_euclidto%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
Codecwas added and canonical bytes are unmoved.