12 KiB
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 tograph.rs(ScoreTuningContext),codec.rs(the hand codec'sdeconly — see below),pitch.rs(new id newtypes),lib.rs(re-exports); the compatibility consumer (likelyinvariants.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:
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'sstruct 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), notCents(f64)— a rawf64is unencodable in canonical state (serialize.rs:110decodes floats only throughCanonicalF64::from_le_bytes → NonFiniteFloat; there is noCodec for f64). ReuseCanonicalF64/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;SpaceUnitalready exists (graph.rs:895).AnchorPoint { x: SpaceUnit, y: SpaceUnit }— S11 correction: it is undefined in the spec (:3166references it, nothing defines it), and core cannot depend on layout-ir. Define it core-native overSpaceUnit, 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:3160is "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 }, whereminor_centiis 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). DerivedOrdon(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 touchlayout-ir's existingSmuflVersion(glyph.rs:29, literal-minor, load-bearing forGlyphCatalogIdentity): unifying the two and movingGlyphCatalogIdentityis 3b's job, done deliberately with golden regen. For this tranchecore::SmuflVersionand 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
- S12 ordering —
SmuflVersionconstructed 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. - S10 — a
PitchSpaceModification::Centsround-trips a finite value and cannot be constructed with a non-finite one (theCanonicalF64guard). - Compatibility invariant — a
CmnChromaticaccidental in acmn-12(DiatonicOverChromatic) space passes; the same accidental in anedo-31(Chromatic) space is rejected with the violation. A test that only checks the accept case would miss the reject — assert both. - Resolution precedence — an
overridesentry for an id shadows anadditionsentry for the same id; a base-registry id with no extension resolves to the base. - 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:
cargo fmt --all --checkcargo clippy --workspace --all-targets→ 0cargo 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, 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
Codecimpl, or move anything onto the wire. - Edit
layout-ir(theSmuflVersionunification andGlyphCatalogIdentitymove are 3b), any other crate, any.tex, or the parallel editor-app files. - Use
Cents(f64), literal-minorSmuflVersion, or anAnchorPointwithout the frame doc. - Unify Chapter 4's
GlyphReferencewith 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.