186 lines
12 KiB
Markdown
186 lines
12 KiB
Markdown
# Contract: Push 4b tranche 3a — the accidental vocabulary, in memory
|
||
|
||
Repo root `/home/jeans/Repos/active/epiphany`. The plan is
|
||
`spec/PLAN_PUSH4B_TUNING.md`, Ruling D. Read this file in full before editing.
|
||
|
||
Tranche 3 completes `ScoreTuningContext` — its three unencoded spec fields
|
||
(`accidental_extensions`, `smufl`, `overrides`) go on the wire, which opens
|
||
schema major 3 and freezes the layout permanently. Because that freeze is
|
||
irreversible, the work is split: **3a (this tranche) builds the type surface in
|
||
memory with a real consumer, so the shapes are exercised while still free to
|
||
change; 3b freezes them on the wire.** This is the same reversible-first
|
||
discipline tranches 1 and 2 used, and it has caught real bugs twice.
|
||
|
||
So this tranche adds **no `Codec`, no wire movement** — canonical bytes stay
|
||
byte-identical. It is entirely `epiphany-core`, in memory.
|
||
|
||
## Blast radius
|
||
|
||
* `crates/epiphany-core/src/` — a new module for the accidental/glyph/engraving
|
||
vocabulary; small changes to `graph.rs` (`ScoreTuningContext`), `codec.rs` (the
|
||
hand codec's `dec` only — see below), `pitch.rs` (new id newtypes), `lib.rs`
|
||
(re-exports); the compatibility consumer (likely `invariants.rs`).
|
||
* `crates/epiphany-core/DECISIONS.md`.
|
||
* Test files under `crates/epiphany-core/`.
|
||
|
||
You edit **no `.tex`**, add **no requirement** (counts stay 212/282/282), and
|
||
touch **no other crate**. In particular **do not touch `epiphany-layout-ir`** —
|
||
see the `SmuflVersion` note below — and stay clear of `crates/epiphany-editor-gui/`,
|
||
`spec/PLAN_EDITOR_APP.md`, `spec/CONTRACT_EDITOR_T1A_GOLDENS.md` (concurrent
|
||
parallel work).
|
||
|
||
## The central prohibition: no `Codec`, no wire movement
|
||
|
||
Same as tranches 1/2/2b. Do not write a `Codec` impl for any new type, do not put
|
||
anything new on the wire. Canonical bytes MUST be byte-identical before and after
|
||
— if any golden or fuzz digest moves, stop and report.
|
||
|
||
### The `ScoreTuningContext` codec, exactly as far as it must go
|
||
|
||
`ScoreTuningContext` (`graph.rs:1659`) currently has four Rust fields
|
||
(`default_pitch_space`, `default_tuning_system`, `reference`, `overrides`), and a
|
||
**hand-written** `Codec` (`codec.rs:1848`) that encodes the first three and
|
||
defaults `overrides` on decode. Add the two missing spec fields **in memory**:
|
||
|
||
```rust
|
||
pub accidental_extensions: Vec<ScoreAccidentalExtensions>,
|
||
pub smufl: SmuflVersionRequirement,
|
||
```
|
||
|
||
Then extend **only the hand codec's `dec`** to default these two as well
|
||
(`accidental_extensions: Vec::new()`, `smufl: <the default below>`). **`enc` is
|
||
unchanged** — it still writes exactly the three wire fields, in the same order.
|
||
The three new fields are in-memory-only this tranche; 3b encodes them.
|
||
|
||
Prove it: a round-trip test that a `ScoreTuningContext` (or a `Score`) with
|
||
non-empty `accidental_extensions`/`smufl`/`overrides` encodes to **byte-identical**
|
||
output as one with all three empty/default, and decoding either reconstructs the
|
||
three as empty/default. This is the direct analogue of tranche 2's
|
||
`overrides_do_not_reach_the_wire`, extended to all three. The hand codec's
|
||
rationale comment (`codec.rs:1835`) should be updated to say all three fields stay
|
||
off the wire until 3b.
|
||
|
||
`SmuflVersionRequirement` needs a `Default` for the decode path. Use
|
||
**`{ minimum: SmuflVersion(1.4), authored_against: SmuflVersion(1.4) }`** — 1.4 is
|
||
the SMuFL version the repo already targets (`layout-ir/glyph.rs:120`), so the
|
||
default aligns with what 3b will unify against.
|
||
|
||
## What to build
|
||
|
||
### 1. The types, transcribed from Chapter 4 with three ratified corrections
|
||
|
||
From `core_spec.tex` §"Accidental Registries" (`3054`–`3234`) and §"Glyph
|
||
References and SMuFL" (`3235`–`3277`). Transcribe field-for-field, in spec order,
|
||
**except** the three corrections below (P13-S10/S11/S12, ratified 2026-07-23):
|
||
|
||
* `ScoreAccidentalExtensions { base: AccidentalRegistryId, additions: Vec<AccidentalDefinition>, overrides: Vec<AccidentalDefinition> }` (`:3228`)
|
||
* `AccidentalDefinition { id: AccidentalId, name: String, glyph: GlyphReference, modification: PitchSpaceModification, engraving: AccidentalEngraving, combination: AccidentalCombination }` (`:3073`)
|
||
* `GlyphReference` (`:3244`) — `enum { Smufl(u32), Custom(CustomGlyphId), Composite(Vec<GlyphReference>) }`. **Recursive**, and **Chapter 4's own** — do NOT unify with or import layout-ir's `struct GlyphReference(Cow<str>)`, a different concept sharing the name (the homonym the plan's Ruling D §"correction" calls out).
|
||
* `PitchSpaceModification` (`:3097`) — `enum { CmnChromatic(i8), EdoSteps(i16), JiRatio { numerator: i32, denominator: NonZeroU32 }, Cents(CanonicalF64), Registered(ModificationRegistryId) }`. **S10 correction: `Cents(CanonicalF64)`, not `Cents(f64)`** — a raw `f64` is unencodable in canonical state (`serialize.rs:110` decodes floats only through `CanonicalF64::from_le_bytes → NonFiniteFloat`; there is no `Codec for f64`). Reuse `CanonicalF64`/`SpaceUnit`'s existing pattern.
|
||
* `AccidentalEngraving { bounding_box: EngravingBoundingBox, anchor: AnchorPoint, advance_width: SpaceUnit, stacking_order: i32, default_parenthesized: bool }` (`:3159`)
|
||
* `EngravingBoundingBox { left: SpaceUnit, right: SpaceUnit, top: SpaceUnit, bottom: SpaceUnit }` (`:3150`) — Ruling D's canonical-safe box; `SpaceUnit` already exists (`graph.rs:895`).
|
||
* `AnchorPoint { x: SpaceUnit, y: SpaceUnit }` — **S11 correction: it is undefined in the spec** (`:3166` references it, nothing defines it), and core cannot depend on layout-ir. Define it core-native over `SpaceUnit`, and give it a doc comment pinning the frame per the ratification: **x/y in canonical space units, y-up, relative to the glyph's coordinate origin** (the box at `:3160` is "relative to the glyph's anchor point", so the anchor needs an unambiguous origin).
|
||
* `AccidentalCombination` (`:3210`) — `enum { Solitary, Stacking { compatible_groups: Vec<AccidentalGroupId> } }`
|
||
* `SmuflVersion` — **S12 correction, read this carefully.** `{ major: u16, minor_centi: u16 }`, where `minor_centi` is the version's fractional part **normalized to hundredths**: SMuFL versions are decimal fractions, so 1.12→`(1,12)`, 1.18→`(1,18)`, 1.20→`(1,20)`, **1.3→`(1,30)`**, **1.4→`(1,40)`**. Derived `Ord` on `(major, minor_centi)` is then correct across SMuFL's real release order (1.12 < 1.18 < 1.20 < 1.3 < 1.4); literal-minor storage would sort 1.3 and 1.4 *before* 1.12. Give it a constructor or documented mapping that performs the normalization (interpret the fractional digits as a decimal, express in hundredths: one digit ×10, two digits as-is), so a caller cannot accidentally pass a literal minor. **Do NOT touch `layout-ir`'s existing `SmuflVersion`** (`glyph.rs:29`, literal-minor, load-bearing for `GlyphCatalogIdentity`): unifying the two and moving `GlyphCatalogIdentity` is 3b's job, done deliberately with golden regen. For this tranche `core::SmuflVersion` and layout-ir's are a bounded homonym — and core cannot import layout-ir's, so within core there is no ambiguity. Leave a comment saying 3b unifies them.
|
||
* `SmuflVersionRequirement { minimum: SmuflVersion, authored_against: SmuflVersion }` (`:3269`)
|
||
|
||
New id newtypes (`catalog_id!` in `pitch.rs`, beside the others): `CustomGlyphId`,
|
||
`ModificationRegistryId`, `AccidentalGroupId`. `AccidentalRegistryId` and
|
||
`AccidentalId` already exist.
|
||
|
||
**No `Codec` for any of these.** They are in-memory only.
|
||
|
||
### 2. The consumer — this is what keeps the surface from being NOTEHEAD_ANCHORS
|
||
|
||
A type surface with no reader is the trap this project has paid for. Build two
|
||
in-core consumers that read the new types:
|
||
|
||
**(a) Accidental resolution.** A function that resolves an `AccidentalId` against a
|
||
score's `accidental_extensions`, honoring the spec's precedence (`:3224`:
|
||
"Extensions are stored on the score and override or augment the base registry"):
|
||
an `overrides` entry wins over an `additions` entry wins over the base registry.
|
||
Returns the resolved `AccidentalDefinition` (so it reads the whole structure —
|
||
`base`, `additions`, `overrides`, and the definition's fields).
|
||
|
||
**(b) The modification-compatibility invariant** —
|
||
`req:tuning:accidental-modification-compatibility` (`core_spec.tex`, the
|
||
requirement label at `:3120`): an accidental's `PitchSpaceModification` MUST be
|
||
expressible in the interval algebra of every pitch space referencing its
|
||
registry, and an implementation MUST reject a score that violates this. The
|
||
requirement's rules: `CmnChromatic` is valid only in `DiatonicOverChromatic` (or
|
||
compatible) spaces; `EdoSteps` only in `Chromatic` (or `Registered`); extend the
|
||
same shape to `JiRatio`↔`JiLattice`. Add this as a `check_invariants` violation
|
||
(`invariants.rs:221`), reading `built_in_position_structure` (tranche 1) for each
|
||
referenced space. Fail closed on a genuinely unknown space, as tranche 1 did.
|
||
|
||
Glyph and engraving metadata (`GlyphReference`, `AccidentalEngraving`,
|
||
`AccidentalCombination`) are carried by these consumers but their deep consumer is
|
||
the engraver, out of core — a later tranche. Say so in your report; do not
|
||
fabricate an in-core engraving consumer to manufacture coverage.
|
||
|
||
## Proof of life — assert behaviour and the ratified corrections
|
||
|
||
1. **S12 ordering** — `SmuflVersion` constructed from the real release sequence
|
||
orders correctly: 1.12 < 1.18 < 1.20 < 1.3 < 1.4 (i.e. `(1,12) < (1,18) <
|
||
(1,20) < (1,30) < (1,40)`). A test that would FAIL under literal-minor storage
|
||
(where 1.3 sorts before 1.12). This directly locks S12.
|
||
2. **S10** — a `PitchSpaceModification::Cents` round-trips a finite value and
|
||
cannot be constructed with a non-finite one (the `CanonicalF64` guard).
|
||
3. **Compatibility invariant** — a `CmnChromatic` accidental in a `cmn-12`
|
||
(`DiatonicOverChromatic`) space passes; the same accidental in an `edo-31`
|
||
(`Chromatic`) space is rejected with the violation. A test that only checks the
|
||
accept case would miss the reject — assert both.
|
||
4. **Resolution precedence** — an `overrides` entry for an id shadows an
|
||
`additions` entry for the same id; a base-registry id with no extension
|
||
resolves to the base.
|
||
5. **The wire invariant** — the byte-identity round-trip from §"the codec"
|
||
(all three new fields off the wire).
|
||
|
||
## Verification
|
||
|
||
**Mutation-verify every new test.** Assert the anchor is present before
|
||
substituting. **Restore by reversing the exact substitution — never `git
|
||
checkout`, which discards uncommitted work.** Mutate meaningfully — e.g. store
|
||
`SmuflVersion`'s minor literally instead of centi-normalized and confirm the S12
|
||
ordering test dies; weaken the compatibility check to accept everything and
|
||
confirm the reject test dies. Report each mutation and the test that died.
|
||
|
||
Then the full gate, actual commands and output:
|
||
|
||
1. `cargo fmt --all --check`
|
||
2. `cargo clippy --workspace --all-targets` → 0
|
||
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, counts
|
||
212/282/282.
|
||
|
||
Zero golden churn and zero canonical-byte movement expected.
|
||
|
||
## Citations
|
||
|
||
Read any `req:*` you cite and confirm it says what your sentence claims (P13-S9).
|
||
Labels here: `req:tuning:accidental-modification-compatibility`,
|
||
`req:tuning:smufl-version-fallback`, `req:determinism:canonical-floating-point`,
|
||
`req:pitch:alteration-unit`.
|
||
|
||
## Do not
|
||
|
||
* Write a `Codec` impl, or move anything onto the wire.
|
||
* Edit `layout-ir` (the `SmuflVersion` unification and `GlyphCatalogIdentity` move
|
||
are 3b), any other crate, any `.tex`, or the parallel editor-app files.
|
||
* Use `Cents(f64)`, literal-minor `SmuflVersion`, or an `AnchorPoint` without the
|
||
frame doc.
|
||
* Unify Chapter 4's `GlyphReference` with layout-ir's — they are homonyms.
|
||
* Restore a mutation with `git checkout`. Re-bless a golden or move a count.
|
||
|
||
## Report
|
||
|
||
State: the types added and where; the three ratified corrections as you
|
||
implemented them (Cents→CanonicalF64, AnchorPoint frame, SmuflVersion
|
||
minor_centi); the extended hand codec and the byte-identity proof that all three
|
||
fields stay off the wire; the two consumers and exactly which fields each reads
|
||
(and which fields are carried-but-not-consumed-in-core, honestly); the mutation
|
||
for every test and which died; the actual gate output; whether any golden moved;
|
||
and anything you chose not to do and why.
|