Push 4b tranche 1: contract for the pitch-space slice

Types, the catalog data that fills them, and the consumer that reads them --
dispatched as one vertical slice rather than the plan's three steps. A Chapter 4
type surface with no consumer is the shape that produced Staff::default_clef and
NOTEHEAD_ANCHORS, so the acceptance test is behavioural: a cmn-24 pitch
transposes end-to-end.

Six of the thirteen built-in pitch spaces are underdetermined by the
specification -- the three ji-* generators and all three of maqam-base,
gamelan-slendro and gamelan-pelog. The contract requires they be represented as
unresolved and reported, never inferred.

Writing it also caught an incomplete paraphrase of
req:tuning:diatonic-chromatic-mapping in the contract's own text: it has three
clauses, not two, and both built-in mappings satisfy all three -- so a
constructor enforcing only the first two would pass every test built from the
catalog. That is P13-S9's discipline applied to the contract that cites it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-22 17:08:00 -04:00
parent 600cc31be4
commit 4a2ee312f7
1 changed files with 220 additions and 0 deletions

View File

@ -0,0 +1,220 @@
# 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 to `pitch.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 in `PositionStructure`'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`, with
`positions_per_octave` from 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**:
```rust
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's**
`nominal_to_chromatic` and `chromatic_positions_per_octave` — which is what
makes `cmn-24` work, and what `req:pitch:alteration-unit` and
`req:pitch:transposition` already specify space-relatively;
* otherwise — `Chromatic`, `JiLattice`, `Registered`, unknown identifier, or one
of the six unresolved entries — **refuse**, with the existing
`TransposeRefusal::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:
1. `cargo fmt --all --check`
2. `cargo clippy --workspace --all-targets` → **0 warnings**
3. `cargo test --workspace` → 0 failed
4. `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` → 0
5. `cargo run -q -p epiphany-testkit --example conformance_suite` → **8/8**
6. `cargo 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 `Codec` impl, extend `struct_codec!`, or add a field to `Score` or
`ScoreTuningContext`.
* Edit any `.tex` file, or any DECISIONS.md other than `epiphany-core`'s.
* Invent a `PositionStructure` for 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.