From 6c9d84f6f663ffaebec0aa038e075c8bc0d14a70 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 13:53:52 -0400 Subject: [PATCH] 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) --- spec/CONTRACT_PUSH4B_ACCIDENTALS.md | 185 ++++++++++++++++++++++++++++ spec/PASS13_CANDIDATES.md | 15 ++- 2 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 spec/CONTRACT_PUSH4B_ACCIDENTALS.md diff --git a/spec/CONTRACT_PUSH4B_ACCIDENTALS.md b/spec/CONTRACT_PUSH4B_ACCIDENTALS.md new file mode 100644 index 0000000..2509307 --- /dev/null +++ b/spec/CONTRACT_PUSH4B_ACCIDENTALS.md @@ -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, +pub smufl: SmuflVersionRequirement, +``` + +Then extend **only the hand codec's `dec`** to default these two as well +(`accidental_extensions: Vec::new()`, `smufl: `). **`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, overrides: Vec }` (`:3228`) +* `AccidentalDefinition { id: AccidentalId, name: String, glyph: GlyphReference, modification: PitchSpaceModification, engraving: AccidentalEngraving, combination: AccidentalCombination }` (`:3073`) +* `GlyphReference` (`:3244`) — `enum { Smufl(u32), Custom(CustomGlyphId), Composite(Vec) }`. **Recursive**, and **Chapter 4's own** — do NOT unify with or import layout-ir's `struct GlyphReference(Cow)`, 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 } }` +* `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. diff --git a/spec/PASS13_CANDIDATES.md b/spec/PASS13_CANDIDATES.md index 33ce8ff..ac4d4e7 100644 --- a/spec/PASS13_CANDIDATES.md +++ b/spec/PASS13_CANDIDATES.md @@ -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.** S10–S12 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; S10–S12 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) |