11 KiB
Contract: Push 4b tranche 1 — pitch spaces become structure, and the interim guard dies
Repo root /home/jeans/Repos/active/epiphany. The plan is
spec/PLAN_PUSH4B_TUNING.md; its four rulings are granted and are law here.
Read this file in full before editing anything.
This is the first tranche of Push 4b's implementation. It is deliberately a vertical slice, not a layer: types, the data that fills them, and the consumer that reads them, landing together.
Why it is a slice and not just "the type surface"
The plan lists the type surface, the built-in catalog, and retiring the P13-S2
guard as three steps. They ship as one here, because this repository has twice
been burned by the alternative. Staff::default_clef sat unread while a
bass-clef staff engraved as treble. NOTEHEAD_ANCHORS was hand-written,
unconsumed, wrong in two independent ways, and survived passes because nothing
read it. A Chapter 4 type surface with no consumer is that shape exactly.
So the acceptance test for this tranche is behavioural, not structural: a
cmn-24 pitch transposes end-to-end. If that works, the types are real.
Blast radius
crates/epiphany-core/src/— a new module for the Chapter 4 pitch-space vocabulary, plus changes topitch.rs.crates/epiphany-core/DECISIONS.md— record the tranche.- Test files under
crates/epiphany-core/.
You edit no .tex file. The specification is correct as written and is your
input, not your output. You add no requirement, so all three counts in
crates/epiphany-testkit/tests/requirement_labels.rs must not move — if one
does, stop and report rather than updating it.
The central prohibition: no Codec impls
Do not write a Codec impl for any type you add. Do not add any of them to a
struct_codec!. Do not add a field to Score or ScoreTuningContext.
This is not a style preference, it is Ruling C, and it is the entire reason the
ruling was affordable. req:binfmt:frozen-layout says adding a field is a MAJOR
change regardless of its type — there is no "downgrade to minor" for an
Option. Anything that reaches the wire is frozen there permanently, sight
unseen, before it has ever had a consumer. These types stay in memory precisely
so they remain free to change when the second tranche discovers they are wrong.
An agent that adds a Codec impl to PitchSpace has quietly undone the ruling
that made this tranche cheap. Canonical bytes must be byte-identical before
and after your change; the conformance suite and the fuzz digest will say so.
What to build
1. The pitch-space vocabulary, in memory
From core_spec.tex §"Pitch Spaces" (:2850 onward). At minimum
PositionStructure (:2889, all four variants) and the PitchSpace type that
carries it. Take the shapes from the specification's own listings — field names,
types, and order — and do not improve them.
req:tuning:diatonic-chromatic-mapping (:2932) constrains
DiatonicOverChromatic with three clauses, all of which the constructor must
enforce: 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. Enforce them in a checked constructor,
the way KeySignature::new rejects out-of-range fifths — not in a comment.
(Both built-in mappings satisfy all three, which means a constructor that enforces only two still passes every test built from the catalog. Test the rejections directly.)
Only the types this tranche's consumer needs. TuningSystem,
TuningResolution, the accidental registries, 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 surface
this contract's opening section exists to avoid.
2. The thirteen built-in pitch spaces, as data
core_spec.tex:3598-3646 is the normative table. Seven are fully determined by
it and are transcription:
cmn-12—DiatonicOverChromatic, 7 nominals over 12, mapping[0,2,4,5,7,9,11](given inPositionStructure's own listing at:2889).cmn-24—DiatonicOverChromatic, 7 over 24, mapping[0,4,8,10,14,18,22](given in the table).edo-19,edo-22,edo-31,edo-53,edo-72—Chromatic, withpositions_per_octavefrom the identifier.
Six are not fully determined, and this is the part that matters. The three
ji-* spaces give a limit and a prime basis but JiLattice.generators is
Vec<JiRatio>, whose values the table does not state. maqam-base,
gamelan-slendro, and gamelan-pelog are described in prose that does not
determine a PositionStructure at all.
Do not invent values for these. Represent each honestly as unresolved — whatever mechanism you choose, an unresolved space must fail closed at every consumer, never fall back to a guess. Then report each one in your final report, saying what the specification does and does not determine. A structure you inferred and wrote down as though the spec stated it is the exact defect this project has paid for twice; six half-plausible lattices would be a worse outcome than six honest gaps.
3. Retire the P13-S2 interim guard — replace it, do not preserve it
Pitch::transposed (pitch.rs) currently refuses on a string comparison:
if self.scale_position.space.as_str() != "cmn-12" {
return Err(TransposeRefusal::PitchSpaceUnavailable);
}
twelve_tet_semitone is gated the same way. Both were ratified as explicitly
temporary in PLAN_P13S2_CMN24.md Ruling B, which says the identifier check
"must be replaced—not preserved as policy". req:pitch:space-capability-refusal
is the requirement.
Replace with structural resolution: look the space's PositionStructure up in
the built-in catalog, and
- if it is
DiatonicOverChromatic, transpose using that structure'snominal_to_chromaticandchromatic_positions_per_octave— which is what makescmn-24work, and whatreq:pitch:alteration-unitandreq:pitch:transpositionalready specify space-relatively; - otherwise —
Chromatic,JiLattice,Registered, unknown identifier, or one of the six unresolved entries — refuse, with the existingTransposeRefusal::PitchSpaceUnavailable.
A version that adds structural lookup beside the name check has preserved
the temporary mechanism as policy. The string "cmn-12" must not survive as a
control-flow condition anywhere in pitch.rs.
The fail-closed contract is unchanged. What the core can prove widens from one space to the ones the specification actually determines.
4. Do not rename twelve_tet_semitone
The plan flags that this name "becomes a lie once non-12 spaces resolve", and
suggests renaming now. Do not. The name only becomes a lie if the function
starts answering for non-12-chromatic spaces. Keep it returning None unless
the resolved structure has exactly 12 chromatic positions — then the name
becomes true rather than false, structurally rather than by identifier, and no
breaking change lands in a public API with no consumer waiting for it.
Its six callers across three crates keep working unchanged. If you find a case where the name genuinely misleads after your change, report it rather than renaming.
Proof of life
A test that transposes a cmn-24 pitch and asserts the resulting scale position
— not merely that it returns Ok. This is the tranche's acceptance
criterion. cmn-24's alteration steps are quarter-tones, so a flat is −2 and a
half-flat is −1 (core_spec.tex:3606); a transposition that silently applies
12-chromatic arithmetic to a 24-chromatic space is the defect this whole tranche
exists to prevent, and a test that only checks is_ok() cannot see it.
Also assert the refusals: a JiLattice space, an unknown identifier, and one of
the six unresolved spaces each refuse rather than guess.
Verification
Mutation-verify every new test. For each, re-introduce the bug it exists to
catch, confirm the test fails, then restore. Report the mutation and the test
that died. A test asserting is_ok() on cmn-24 will survive the mutation that
matters, which is how you will know it is the wrong test.
Before substituting, assert the anchor is present — a string replacement that silently matches nothing produces a no-op mutation, and a no-op mutation is indistinguishable from a passing test. This project has been caught by exactly that, when rustfmt had rewrapped the line the replacement targeted.
Then the full gate, reporting actual commands and actual output:
cargo fmt --all --checkcargo clippy --workspace --all-targets→ 0 warningscargo test --workspace→ 0 failedRUSTDOCFLAGS="-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 passed, and the three counts unchanged at 212 / 282 / 282.
Zero golden churn and zero canonical-byte movement are expected. You are adding in-memory types; if any golden file or fuzz digest moves, something reached the wire that should not have. Stop and report — do not re-bless a golden.
CI pins clippy at 1.95.0 and the MSRV at 1.85, and lints run only on the pinned
stable. Local cargo clippy is the same toolchain CI uses, so a clean local run
is meaningful; clippy::incompatible_msrv will catch any API above the 1.85
floor.
Citations
If you cite a requirement in a doc comment, read the requirement first and
confirm it says what your sentence claims. The checker proves only that a
req:* string resolves to a real label; a citation that resolves cleanly while
supporting nothing passes a fully green gate. This is filed as P13-S9 after
three instances in one day, one of them in a contract like this one. The
relevant labels here are req:pitch:space-capability-refusal,
req:pitch:alteration-unit, req:pitch:transposition, and
req:tuning:diatonic-chromatic-mapping.
Do not
- Write a
Codecimpl, extendstruct_codec!, or add a field toScoreorScoreTuningContext. - Edit any
.texfile, or any DECISIONS.md other thanepiphany-core's. - Invent a
PositionStructurefor the six the specification underdetermines. - Keep the
"cmn-12"string comparison alongside the new lookup. - Rename
twelve_tet_semitone. - Re-bless a golden file or update a requirement count to make a test pass.
- Run
cargo fmt --all(use--check; format only what you wrote).
Report
State: the types added and where; how you represented the six underdetermined spaces and what the spec does and does not fix for each; the mutation for every new test and which test died; the actual gate output; whether any golden or digest moved; and anything you chose not to do and why.