221 lines
11 KiB
Markdown
221 lines
11 KiB
Markdown
# 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.
|