Push 4b tranche 3a: contract for the accidental vocabulary, and S10-S12 filed

Tranche 3 completes ScoreTuningContext onto the wire -- schema major 3, the only
irreversible tranche. Split reversible-first: 3a builds the accidental / glyph /
engraving type surface in memory with a real consumer (so the shapes are
exercised while free to change), 3b freezes them. 3a adds no Codec, no wire
movement; the hand codec keeps encoding exactly three wire fields while
accidental_extensions and smufl join overrides as in-memory-only.

Scoping the subtree surfaced three types that freeze forever and the spec does
not pin -- filed as S10-S12, ratified before dispatch (the S5/S7-before-tranche-1
pattern):

- S10: PitchSpaceModification::Cents(f64) is a raw f64 in canonical state, which
  the byte layer cannot encode -- serialize.rs decodes floats only through
  CanonicalF64::from_le_bytes. Ratified Cents(CanonicalF64), the same maneuver
  Ruling D applied to the bounding box.
- S11: AnchorPoint is referenced (:3166) and defined nowhere; core cannot depend
  on layout-ir. Ratified core-native { x, y: SpaceUnit } plus a normative frame
  sentence, since the box it anchors is "relative to the glyph's anchor point".
- S12: the catch. SmuflVersion is NOT undefined -- it exists in layout-ir with
  LITERAL minor ({1,4}) and derived Ord, so 1.3 sorts before 1.12 today, and it
  is a field of GlyphCatalogIdentity (conformance identity). Ratified
  fraction-normalized minor_centi (1.4 -> 40); core defines it in 3a, and 3b
  unifies layout-ir onto it and moves GlyphCatalogIdentity deliberately.

Every citation and struct line-ref verified against core_spec.tex before commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-23 13:53:52 -04:00
parent c68f317e47
commit 6c9d84f6f6
2 changed files with 199 additions and 1 deletions

View File

@ -0,0 +1,185 @@
# 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.

View File

@ -60,7 +60,17 @@ outside the index that is supposed to be the index. S8 and S9 are new. S9 is
the third instance of one pattern in a single day and, notably, **S4 is itself
an instance of it**, which is what moved it from incident to candidate.
S4, S8, and S9 are open — three, so the pass stays open.
**Filed 2026-07-23, third round.** S10S12 are three underdetermined Chapter 4
types standing in front of Push 4b tranche 3 (the schema-major-3 wire freeze) —
the same shape as S5/S7 before tranche 1. Scoping the accidental subtree that
`accidental_extensions` drags onto the wire surfaced a raw `f64` that the byte
layer cannot encode, a leaf type referenced but never defined, and a version type
whose obvious representation orders SMuFL's real release history backwards. All
three are ratified; each freezes forever once tranche 3b lands, so they were
resolved before dispatch, not after.
S4, S8, and S9 remain open; S10S12 are resolved on ratification. The pass stays
open.
| Id | One-line statement | Filed in | Status |
|---|---|---|---|
@ -73,3 +83,6 @@ S4, S8, and S9 are open — three, so the pass stays open.
| P13-S7 | **`ScalePosition` referenced a score pitch-space registry that does not exist.** The listing comment said the space "References an entry in **the score's pitch-space registry**", and `req:pitch:default-pitch-space` said every score MUST **define** at least one pitch space and MAY define more. No such registry exists anywhere: `Score` has thirteen fields and none is one, and `ScoreTuningContext` carries ids plus accidental extensions — never space or system definitions. So the specification required an act (defining a pitch space) that the data model gives a score no way to perform | `spec/PLAN_PUSH4B_TUNING.md` (Ruling C); `spec/CONTRACT_PUSH4B_SPEC1.md` | **resolved** (Wave 1a, 5dcaa58: ratified **the catalog is closed**. Both "define"s become *select*, from the built-in catalog; the `ScalePosition` comment names the built-in catalog; score-local definition is deferred to a later schema major, because the Chapter 4 type surface has never had a consumer and `req:binfmt:frozen-layout` would freeze it permanently on first encode. **No struct gained a registry field — that was the point of the ruling.** Filed here retroactively: the id was minted in the plan, ratified, and implemented without ever entering this ledger) |
| P13-S8 | **Canonical state admits two encodings of one musical fact, and nothing normalizes them.** `TempoShape::Constant` (`core_spec.tex:2230`) says `end_tempo` MUST be `None` **or** equal to `start_tempo` — explicitly legalizing two spellings of an identical constant-tempo segment. `TempoSegment` encodes `end_tempo` positionally through `struct_codec!` (`codec.rs:1510`) and `TempoMap` is canonical state on `Score` (`graph.rs:1685`, `codec.rs:1517`), so the two forms produce different canonical bytes: **measured at 336 vs 363 for otherwise identical scores.** `invariants.rs:1335` merely permits both; no normalization runs anywhere. The project has ruled the other way everywhere else it looked — `req:determinism:unicode-canonicalization` applies NFC precisely so that "two texts whose NFC byte representations are equal are canonically equal", and `req:determinism:canonical-collection-order` pins iteration to a total order — but tempo has no equivalent, so two musically identical scores hash differently | this file (measured 2026-07-22 by encoding both forms) | **open** (ratification: normalize on encode to one form — `None` is the smaller and the one every generator already emits — or state that both are canonical and accept divergent hashes for identical music. Found sideways: the branch is also **untested**, since flipping `is_none_or` to `is_some_and` at `invariants.rs:1335` kills no test, so nothing in the suite constructs a `Constant` segment with `end_tempo: None`) |
| P13-S9 | **The citation checker enforces cited→defined, never cited→relevant.** `requirement_labels.rs` proves every `req:*` string in the repo resolves to a real label and nothing further, so a citation that resolves cleanly but does not support the sentence it is attached to is invisible to a fully green gate. Three instances in one day: a Push-4b spec agent justified "resolved layout is non-canonical" with `req:layoutir:staff-space-coordinates`, which mandates staff-space units and `f32` and says nothing about canonicality; a dispatch contract asserted "Chapter 10 defines `KeySignature`" when the Score Graph is Chapter 5 (caught only because the agent used `\ref{ch:graph}` and reported the discrepancy rather than hardcoding either number); and **P13-S4 is itself an instance** — an agent "corrected" two dangling citations to the ownership requirements, replacing a visibly broken pointer with a silently wrong one | this file | **open** (relevance is not mechanically checkable, so the ratification is *what discipline replaces a check*: e.g. requiring the quoted sentence alongside any newly-added citation so review compares text to text, or treating agent-authored citations as unverified until a human reads the cited requirement. Note the asymmetry that makes this dangerous — the broken-pointer case is loud and the wrong-pointer case is silent, so **fixing the loud one without reading the target converts a caught defect into an uncaught one**) |
| P13-S10 | **`PitchSpaceModification::Cents(f64)` puts a raw `f64` in canonical state, which the byte layer cannot encode.** `core_spec.tex:3112` declares `Cents(f64)`; `PitchSpaceModification` is reached from canonical score state through `AccidentalDefinition``ScoreAccidentalExtensions``ScoreTuningContext`. `req:determinism:canonical-floating-point` requires canonical stored floats to be finite IEEE 754 binary64, and the byte layer enforces it with no escape hatch: `serialize.rs:110` decodes floats *only* through `CanonicalF64::from_le_bytes → NonFiniteFloat`, so there is no `Codec for f64`. A raw `f64` is therefore not merely risky in canonical state — it is unencodable without inventing a new unvalidated codec. The same chapter already resolved this exact tension forty lines later: `EngravingBoundingBox` (`:3150`) carries `SpaceUnit` edges with a rationale (`:3191`) invoking the same requirement | this file (verified 2026-07-23 against `core_spec.tex:3112`, `:3150-3197`, and `crates/epiphany-determinism/src/serialize.rs:110`) | **resolved** (ratified: `Cents(CanonicalF64)`. A one-line spec correction, the only shape consistent with the existing byte layer — the identical maneuver Ruling D applied to the bounding box. Freezes in tranche 3b) |
| P13-S11 | **`AnchorPoint`, referenced by `AccidentalEngraving.anchor`, is defined nowhere.** `core_spec.tex:3166` names `pub anchor: AnchorPoint`; no struct or enum of that name exists in the spec, and `epiphany-core` (whose `Cargo.toml` has no `epiphany-layout-ir` dependency) cannot borrow any layout-ir type even if one shared the name — the same core-native requirement that forced `EngravingBoundingBox`. An undefined leaf frozen onto the wire is the `KeyContext`-shaped gap. Compounding it: the bounding box is documented "relative to the glyph's anchor point" (`:3160`), so freezing an anchor with no defined coordinate frame freezes a point with an undefined origin | this file (verified 2026-07-23; `AnchorPoint` appears once in `core_spec.tex`, defined nowhere; `epiphany-core/Cargo.toml` lists no layout-ir) | **resolved** (ratified: core-native `AnchorPoint { x: SpaceUnit, y: SpaceUnit }`, over the same `SpaceUnit` as `advance_width` and `EngravingBoundingBox`. **Plus one normative sentence pinning the frame**: x/y in canonical space units, y-up, relative to the glyph's coordinate origin — matching the repo's existing Bravura outline convention — so the anchor and the box it anchors share an unambiguous origin. Freezes in tranche 3b) |
| P13-S12 | **`SmuflVersion` is undefined, and its obvious representation orders SMuFL's real history backwards.** `SmuflVersionRequirement` (`core_spec.tex:3269`) carries `minimum`/`authored_against` of type `SmuflVersion`, which Chapter 4 references but does not define (it exists only as a **Chapter 7 / layout-ir** type, `glyph.rs:29`, with literal-minor encoding — see the resolution). Ordering is load-bearing (`SmuflVersionRequirement.minimum`, `:3271`, gates the fallback of `req:tuning:smufl-version-fallback`), and the type is dual-purpose — it also anchors Chapter 9's `GlyphCatalog::smufl_version()` / `GlyphCatalogIdentity` (`:10420`, `:10460`), so this freeze touches layout-conformance identity, not just tuning. The trap: SMuFL versions are decimal fractions — 1.12 (2015), 1.18, 1.20 (2016), 1.3 (2019), 1.4 (2021) — that succeed by fraction (0.12 < 0.18 < 0.20 < 0.30 < 0.40). A `{ major: u16, minor: u16 }` storing literal digits with derived `Ord` orders the minors 3 < 4 < 12 < 18 < 20, placing 1.3 and 1.4 **before** 1.12; old fonts declaring 1.18-era versions exist forever | this file (verified 2026-07-23 against `core_spec.tex:3269-3274` and SMuFL's published release history) | **resolved** (ratified shape `SmuflVersion { major: u16, minor_centi: u16 }`, **literal-minor storage rejected**: the minor is stored **fraction-normalized to hundredths** 1.12→(1,12), 1.18→(1,18), 1.20→(1,20), 1.3→(1,30), 1.4→(1,40), rule normative, release table a note. Derived `Ord` is then correct across the whole real history and collapses the 1.2/1.20 ambiguity (both (1,20)). **Correction on filing: the leaf is NOT undefined — it exists in `epiphany-layout-ir/src/glyph.rs:29` as `SmuflVersion { major, minor }` with LITERAL minor (`{1,4}`) and derived `Ord`, so the backwards-ordering bug is LIVE there today (1.3 < 1.12), and it is a direct field of `GlyphCatalogIdentity` — conformance identity.** `epiphany-core` cannot depend on layout-ir, and `SmuflVersionRequirement` is core, so the type MUST be defined in core and layout-ir must reuse it a **unification**, not a fresh definition, which moves `GlyphCatalogIdentity` (`{1,4}`→`minor_centi 40`). Tranche 3a defines `core::SmuflVersion` for the tuning use and leaves layout-ir's alone (a bounded, core-invisible homonym since core can't import layout-ir's); tranche 3b performs the unification and the deliberate `GlyphCatalogIdentity` move with golden/vector regen. The hundredths scale is blocking for 3b, free to adjust in 3a) |