Push 4b tranche 3b-ii: one SmuflVersion, and a live ordering bug dies with the homonym

Closes P13-S12. epiphany-layout-ir defined its own SmuflVersion storing the
minor LITERALLY ({1,4} for SMuFL 1.4), so derived Ord sorted 1.3 and 1.4
BEFORE 1.12 — backwards versus SMuFL's real release order — on a direct field
of GlyphCatalogIdentity, which is layout-conformance identity. That bug was
live. The crate graph fixes the direction: layout-ir depends on core, so
core's fraction-normalized minor_centi type is necessarily the survivor.
layout-ir deletes its own and re-exports core's, keeping
epiphany_layout_ir::SmuflVersion resolving for downstream at zero churn.

No wire change: no schema major, no Codec, no decode vector. LayoutCache is a
regenerable major-0 role. The one line that moves bytes is encode_catalog's
.minor -> .minor_centi (04 00 -> 28 00 at offset 2, verified by encoding a
default identity: 60 bytes, unchanged elsewhere).

The tranche also closes S12's OTHER half, which had gone unnoticed: the
ratified shape reached Rust and the binary companion but core_spec.tex never
defined SmuflVersion at all — the exact "undefined leaf" S12 was filed about.
It is now declared once, with the hundredths rule as normative prose and the
release table as a rationale note, cross-referenced from both Chapter 9 sites
so the glyph catalog's version and the tuning context's are visibly one type.
No new req: label; counts stay 212/282/282.

Two ledger claims corrected, both verified false before dispatch rather than
after: PASS13_CANDIDATES.md's S12 entry and epiphany-core/DECISIONS.md's S12
bullet both promised this move would land "with golden regen". Nothing is
pinned to the catalog identity — every ResolvedLayoutIR::canonical_bytes()
assertion is RELATIVE (stability, determinism, and a sensitivity check that
mutates metrics_hash, never smufl_version), and the committed SVG/PNG goldens
embed no identity, only a fixed comment string. There was nothing to
regenerate, and conformance gate [9] never moved.

The fix is locked by a test that names the bug: layout-ir asserts the real
release order through GlyphCatalogIdentity.smufl_version. Mutation-verified
independently by making from_decimal store one-digit minors literally — it
fails with "minor_centi: 20 did not sort before minor_centi: 3", which is
precisely the deleted type's behaviour.

Gate: fmt clean, clippy 0, 1283 passed / 0 failed (+1, the new test), doc 0,
conformance 8/8, requirement labels 6/6 at 212/282/282. No golden, vector,
baseline, or editor-track file changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-23 19:30:52 -04:00
parent 68b08adb5e
commit 51f7ac9f61
11 changed files with 362 additions and 35 deletions

View File

@ -1022,10 +1022,22 @@ until the shapes have had a consumer.
sort before 1.12) would not lock S12 at all; see the mutation below, which sort before 1.12) would not lock S12 at all; see the mutation below, which
reproduces exactly that failure and confirms this test catches it. **Not** reproduces exactly that failure and confirms this test catches it. **Not**
`epiphany_layout_ir::SmuflVersion` (`glyph.rs:29`, literal-minor, `epiphany_layout_ir::SmuflVersion` (`glyph.rs:29`, literal-minor,
load-bearing for `GlyphCatalogIdentity`) — that type is untouched; the two load-bearing for `GlyphCatalogIdentity`) — at this tranche that type was
are a deliberate, bounded homonym (`epiphany-core` cannot depend on untouched, the two a deliberate, bounded homonym (`epiphany-core` cannot
`epiphany-layout-ir` in any case) until a later tranche unifies them and depend on `epiphany-layout-ir` in any case), pending a later unification.
moves `GlyphCatalogIdentity`, with golden regen.
**Superseded by tranche 3b-ii (2026-07-23), which corrects two forward-looking
claims made here.** The homonym is gone: layout-ir's type is deleted and
re-exports this one, so `GlyphCatalogIdentity` now carries the normalized
shape and its backwards ordering (live at the time this was written) is
fixed. And the anticipated **"golden regen" never happened — there was
nothing to regenerate.** No golden, baseline, or vector anywhere in the
workspace is pinned to the catalog identity: every assertion on
`ResolvedLayoutIR::canonical_bytes()` is *relative* (stability, determinism,
and a sensitivity check that mutates `metrics_hash`, never `smufl_version`),
and the committed SVG/PNG goldens do not embed it. The encoded minor moved
`0x04``0x28` with no committed bytes pinning it. See
`epiphany-layout-ir/DECISIONS.md` for the unification itself.
Also new: `CustomGlyphId`, `ModificationRegistryId`, `AccidentalGroupId` Also new: `CustomGlyphId`, `ModificationRegistryId`, `AccidentalGroupId`
(`catalog_id!` entries in `pitch.rs`, beside `AccidentalRegistryId`/ (`catalog_id!` entries in `pitch.rs`, beside `AccidentalRegistryId`/

View File

@ -35,11 +35,9 @@
//! pinned frame — see its doc comment. //! pinned frame — see its doc comment.
//! * **S12** — [`SmuflVersion`] stores its minor fraction-normalized to //! * **S12** — [`SmuflVersion`] stores its minor fraction-normalized to
//! hundredths (`minor_centi`), not literally, so derived `Ord` agrees with //! hundredths (`minor_centi`), not literally, so derived `Ord` agrees with
//! SMuFL's real release order. **This is not** //! SMuFL's real release order. `epiphany_layout_ir` defined its own,
//! `epiphany_layout_ir`'s existing, differently-shaped `SmuflVersion` //! differently-shaped, literal-minor `SmuflVersion` until Push 4b tranche
//! (`glyph.rs:29`, literal-minor) — see this type's doc comment for why the //! 3b-ii unified the two onto this type — see this type's doc comment.
//! two are a deliberate, bounded homonym until Push 4b tranche 3b unifies
//! them.
use core::num::NonZeroU32; use core::num::NonZeroU32;
@ -248,14 +246,10 @@ pub struct ScoreAccidentalExtensions {
/// otherwise look interchangeable and are not: the former is 1.3, the latter /// otherwise look interchangeable and are not: the former is 1.3, the latter
/// is nonsensical). /// is nonsensical).
/// ///
/// **This is not** `epiphany_layout_ir::SmuflVersion` (`glyph.rs:29`, /// This is also `epiphany_layout_ir::SmuflVersion`: Push 4b tranche 3b-ii
/// `{ major: u16, minor: u16 }`, literal-minor, load-bearing for /// (P13-S12) unified layout-ir's former literal-minor homonym onto this type
/// `GlyphCatalogIdentity`). That type is Chapter 7's own and stays untouched: /// (`glyph.rs` re-exports it), so the glyph catalog's version and the tuning
/// unifying the two, and moving `GlyphCatalogIdentity` onto the normalized /// context's version are the same type.
/// shape, is Push 4b tranche 3b's job, done deliberately with golden regen.
/// `epiphany-core` cannot depend on `epiphany-layout-ir` in any case, so
/// within this crate there is no ambiguity — the two are a deliberate,
/// bounded homonym until then.
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
pub struct SmuflVersion { pub struct SmuflVersion {
pub major: u16, pub major: u16,

View File

@ -874,3 +874,59 @@ end, where it tucked a full notehead width to the *left* of the final note.
**Deferred (Standard tier):** breaking a slur that clearance would make absurdly **Deferred (Standard tier):** breaking a slur that clearance would make absurdly
tall, per-kind appearance (`SlurKind::Phrase` etc.), and shaping against ledger tall, per-kind appearance (`SlurKind::Phrase` etc.), and shaping against ledger
lines and accidentals rather than notehead boxes. lines and accidentals rather than notehead boxes.
## `SmuflVersion` unification: layout-ir's literal-minor homonym is deleted (Push 4b tranche 3b-ii, P13-S12, 2026-07-23)
`epiphany-layout-ir` had its own `SmuflVersion { major: u16, minor: u16 }`
(`glyph.rs:29`), storing the minor **literally**. Derived `Ord` therefore
sorted 1.3 and 1.4 **before** 1.12 — backwards versus SMuFL's real release
order (1.12 → 1.18 → 1.20 → 1.3 → 1.4) — and the type was a direct field of
`GlyphCatalogIdentity`, layout-conformance identity. **That bug was live.**
Tranche 3a (P13-S10/S11/S12, ratified together) defined the correct
fraction-normalized type in `epiphany_core::accidental::SmuflVersion`
(`minor_centi`, e.g. `from_decimal(1, "4")``40`) for the tuning-context
use, deliberately leaving layout-ir's alone as a bounded homonym — core
cannot depend on layout-ir, so there was no way to share the type from that
side. This tranche closes the other half: layout-ir deletes its own type and
re-exports core's (`glyph.rs`: `pub use epiphany_core::SmuflVersion;`), so
`epiphany_layout_ir::SmuflVersion` keeps resolving for every downstream user
with zero call-site churn beyond the three literal-construction sites
(`GlyphCatalogIdentity::default`, `BravuraCatalog::smufl_version`, and the
in-crate test catalog), which now go through `SmuflVersion::from_decimal`
rather than a hand-written `minor_centi`, per that field's own doc (`4` and
`40` look interchangeable at a glance and are not).
**Blast radius: no wire change.** `ChunkKind::LayoutCache` is a regenerable
major-0 role; this is a discard-and-regenerate identity, not a decoded one.
The one line that changes emitted bytes is `resolved.rs`'s `encode_catalog`
(`.minor` → `.minor_centi`); `solver.rs`'s `forged_catalog_metadata_is_rejected`
mutates the same field (`+= 1`) to build a still-nonsense version, unchanged
in intent. The PASS13_CANDIDATES.md P13-S12 entry's claim that the move
lands "with golden/vector regen" was verified false and corrected in
place — no golden, baseline, or vector is pinned to the catalog identity
anywhere in the workspace (every `canonical_bytes()` assertion is relative).
**The generator regains meaning too**: `epiphany-testkit`'s
`gen_smufl_version` used to draw `minor: rng.range(0, 6)`, which under the
now-shared fraction-normalized field would denote 1.001.05 — versions SMuFL
never released. It now samples a minor-digit string from the real release
set (`"12", "18", "20", "3", "4"`) through `SmuflVersion::from_decimal`, so
it can never emit a value the type's own invariant forbids.
**Locked by** `catalog_identity_smufl_version_orders_the_real_release_sequence`
(`glyph.rs`), which builds five `GlyphCatalogIdentity`s from the real release
digits and asserts `1.12 < 1.18 < 1.20 < 1.3 < 1.4`. Mutation-verified by
hand: reverting the identities' construction to store the digit strings
literally in `minor_centi` (simulating the deleted type's storage) fails the
test at the first backwards pair (`1.20` sorting after `1.3`), confirming
the assertion is not vacuous.
**Spec**: `core_spec.tex` gains the `SmuflVersion` type listing near
`SmuflVersionRequirement` (§"SMuFL Versioning", Chapter 4) that P13-S12
reached Rust and the wire but never the core specification for — a type
listing with normative prose (fraction-normalized minor, the real release
table as a non-normative note) and no new `\label{req:...}` (counts stay
212/282/282). Both Chapter 9 usages (`GlyphCatalog::smufl_version()`,
`GlyphCatalogIdentity.smufl_version`) gain a cross-reference sentence back to
this single definition.

View File

@ -25,11 +25,16 @@ use epiphany_determinism::{DomainTag, Preimage};
use crate::spatial::{BoundingBox, Point}; use crate::spatial::{BoundingBox, Point};
/// The SMuFL version a catalog targets (Chapter 7: `SmuflVersion`). /// The SMuFL version a catalog targets (Chapter 7: `SmuflVersion`).
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] ///
pub struct SmuflVersion { /// This is [`epiphany_core::SmuflVersion`] (Chapter 4 §"SMuFL Versioning"):
pub major: u16, /// the glyph catalog's version and the tuning context's declared SMuFL
pub minor: u16, /// version are **the same type**, unified here in Push 4b tranche 3b-ii
} /// (P13-S12). Layout-ir previously defined its own `{ major: u16, minor: u16
/// }` storing the minor *literally*, whose derived `Ord` sorted 1.3/1.4
/// before 1.12 — backwards versus SMuFL's real release order. See
/// [`epiphany_core::SmuflVersion`]'s doc comment for the fraction-normalized
/// `minor_centi` shape that fixes it.
pub use epiphany_core::SmuflVersion;
/// Identifier of a specific SMuFL font (Chapter 7: `FontId`). /// Identifier of a specific SMuFL font (Chapter 7: `FontId`).
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
@ -117,7 +122,8 @@ impl Default for GlyphCatalogIdentity {
/// one over only the glyphs it references (the solve's true inputs). /// one over only the glyphs it references (the solve's true inputs).
fn default() -> Self { fn default() -> Self {
GlyphCatalogIdentity { GlyphCatalogIdentity {
smufl_version: SmuflVersion { major: 1, minor: 4 }, smufl_version: SmuflVersion::from_decimal(1, "4")
.expect("1.4 is a valid SMuFL version"),
font_id: FontId::BRAVURA, font_id: FontId::BRAVURA,
font_version: Some(BRAVURA_VERSION), font_version: Some(BRAVURA_VERSION),
metrics_hash: metrics_hash_for(BRAVURA_METRICS.iter().map(|m| m.name.as_ref())), metrics_hash: metrics_hash_for(BRAVURA_METRICS.iter().map(|m| m.name.as_ref())),
@ -329,7 +335,7 @@ impl GlyphCatalog for BravuraCatalog {
} }
fn smufl_version(&self) -> SmuflVersion { fn smufl_version(&self) -> SmuflVersion {
SmuflVersion { major: 1, minor: 4 } SmuflVersion::from_decimal(1, "4").expect("1.4 is a valid SMuFL version")
} }
fn identity(&self, consulted: &[&str]) -> GlyphCatalogIdentity { fn identity(&self, consulted: &[&str]) -> GlyphCatalogIdentity {
@ -423,6 +429,39 @@ mod tests {
); );
} }
/// P13-S12 / Push 4b tranche 3b-ii: the live bug this tranche fixes.
/// Layout-ir used to define its own `SmuflVersion { major: u16, minor:
/// u16 }` storing the minor digit *literally*, so derived `Ord` sorted
/// 1.3 and 1.4 **before** 1.12 — backwards versus SMuFL's real release
/// order (1.12 → 1.18 → 1.20 → 1.3 → 1.4). Exercised through
/// [`GlyphCatalogIdentity::smufl_version`], not the bare type, because
/// that field is the layout-conformance identity the bug actually lived
/// on.
///
/// Non-vacuous: reverting `crate::SmuflVersion` to the deleted literal-minor
/// shape (`{ major: u16, minor: u16 }` with derived `Ord`) and rebuilding
/// each identity as `{ major: 1, minor: <the literal digits> }` makes this
/// test fail — `{1,3}` and `{1,4}` sort *before* `{1,12}`, breaking the
/// third assertion in the loop below. Verified by hand against the
/// pre-unification type before it was deleted.
#[test]
fn catalog_identity_smufl_version_orders_the_real_release_sequence() {
let identity_with = |minor_digits: &str| GlyphCatalogIdentity {
smufl_version: SmuflVersion::from_decimal(1, minor_digits)
.expect("valid SMuFL version"),
..GlyphCatalogIdentity::default()
};
let sequence = ["12", "18", "20", "3", "4"].map(identity_with);
for pair in sequence.windows(2) {
assert!(
pair[0].smufl_version < pair[1].smufl_version,
"{:?} did not sort before {:?} in SMuFL's real release order",
pair[0].smufl_version,
pair[1].smufl_version
);
}
}
#[test] #[test]
fn metrics_hash_is_order_and_duplicate_independent() { fn metrics_hash_is_order_and_duplicate_independent() {
let a = metrics_hash_for(["gClef", "noteheadBlack", "accidentalSharp"]); let a = metrics_hash_for(["gClef", "noteheadBlack", "accidentalSharp"]);
@ -516,7 +555,7 @@ mod tests {
None None
} }
fn smufl_version(&self) -> SmuflVersion { fn smufl_version(&self) -> SmuflVersion {
SmuflVersion { major: 1, minor: 4 } SmuflVersion::from_decimal(1, "4").expect("1.4 is a valid SMuFL version")
} }
fn identity(&self, _consulted: &[&str]) -> GlyphCatalogIdentity { fn identity(&self, _consulted: &[&str]) -> GlyphCatalogIdentity {
GlyphCatalogIdentity { GlyphCatalogIdentity {

View File

@ -361,7 +361,7 @@ fn encode_decision(out: &mut Vec<u8>, d: &EngravingDecision) {
fn encode_catalog(out: &mut Vec<u8>, c: &GlyphCatalogIdentity) { fn encode_catalog(out: &mut Vec<u8>, c: &GlyphCatalogIdentity) {
out.extend_from_slice(&c.smufl_version.major.to_le_bytes()); out.extend_from_slice(&c.smufl_version.major.to_le_bytes());
out.extend_from_slice(&c.smufl_version.minor.to_le_bytes()); out.extend_from_slice(&c.smufl_version.minor_centi.to_le_bytes());
let font = c.font_id.0.as_bytes(); let font = c.font_id.0.as_bytes();
push_len(out, font.len()); push_len(out, font.len());
out.extend_from_slice(font); out.extend_from_slice(font);

View File

@ -766,7 +766,7 @@ mod tests {
assert!(!report.satisfied_hard_constraints); assert!(!report.satisfied_hard_constraints);
let mut input = constrained(vec![glyph("noteheadBlack")]); let mut input = constrained(vec![glyph("noteheadBlack")]);
input.catalog.smufl_version.minor += 1; input.catalog.smufl_version.minor_centi += 1;
assert_eq!( assert_eq!(
StubSolver.solve(&input, &SolverConfig::default()).status, StubSolver.solve(&input, &SolverConfig::default()).status,
SolveStatus::InternalError SolveStatus::InternalError

View File

@ -794,12 +794,17 @@ pub fn gen_glyph_metric(rng: &mut Rng) -> GlyphMetric {
BRAVURA_METRICS[rng.below(BRAVURA_METRICS.len() as u64) as usize].clone() BRAVURA_METRICS[rng.below(BRAVURA_METRICS.len() as u64) as usize].clone()
} }
/// A SMuFL version. /// A SMuFL version. `minor_centi` is fraction-normalized (P13-S12): a small
/// integer sampled directly (as the previous generator did) would denote
/// 1.00-1.05 as hundredths, versions SMuFL never released. Instead sample the
/// minor digit string from real SMuFL minor releases and normalize it through
/// [`SmuflVersion::from_decimal`], the same constructor production code uses,
/// so the generator can never emit a value the type's own invariant forbids.
pub fn gen_smufl_version(rng: &mut Rng) -> SmuflVersion { pub fn gen_smufl_version(rng: &mut Rng) -> SmuflVersion {
SmuflVersion { const MINOR_RELEASES: [&str; 5] = ["12", "18", "20", "3", "4"];
major: rng.range(1, 2) as u16, let major = rng.range(1, 2) as u16;
minor: rng.range(0, 6) as u16, let minor_digits = MINOR_RELEASES[rng.below(MINOR_RELEASES.len() as u64) as usize];
} SmuflVersion::from_decimal(major, minor_digits).expect("a real SMuFL minor release")
} }
/// A font identifier (the bundled font). /// A font identifier (the bundled font).

View File

@ -0,0 +1,180 @@
# CONTRACT — Push 4b tranche 3b-ii: the SmuflVersion unification and the GlyphCatalogIdentity move
**Status:** dispatch-ready. Closes P13-S12. Follows tranche 3b-i (68b08ad), which
opened schema major 3.
**Ratified by the user 2026-07-23:**
1. The unified type **stays in `epiphany_core::accidental`**; `epiphany-layout-ir`
imports `epiphany_core::SmuflVersion` (already re-exported at core's root,
`lib.rs:128`). Do **not** move the type between core modules.
2. The core-spec definition is a **type listing with normative prose, no new
`\label`**. Requirement counts MUST stay **212 / 282 / 282**.
---
## What this tranche does
`epiphany-layout-ir` has its own `SmuflVersion { major: u16, minor: u16 }`
(`glyph.rs:29`) storing the **literal** minor digit — `{1, 4}` for SMuFL 1.4.
Derived `Ord` on a literal minor orders 1.3 and 1.4 **before** 1.12, which is
backwards versus SMuFL's real release history (1.12 → 1.18 → 1.20 → 1.3 → 1.4).
**That bug is live in layout-ir today**, and the type is a direct field of
`GlyphCatalogIdentity` — layout-conformance identity.
Tranche 3a defined the correct type in core (`minor_centi`, fraction-normalized
to hundredths) and deliberately left layout-ir's alone as a bounded homonym.
This tranche **unifies them**: layout-ir deletes its own and uses core's.
The crate graph forces the direction — `layout-ir` depends on `core`
(`Cargo.toml:12`); core depends only on `determinism`. Core's type is
necessarily the survivor.
## Blast radius: NO golden and NO vector moves. Do not go looking for any.
The reviewer verified this before dispatch, and it **corrects the P13-S12 ledger
entry**, which says the move lands "with golden/vector regen":
- Every assertion on `ResolvedLayoutIR::canonical_bytes()` is **relative**:
stability (`resolved.rs:420`), determinism (`reference_suite.rs:228-230`), and
sensitivity — which mutates `metrics_hash[0] ^= 1` (`resolved.rs:477`) and
never touches `smufl_version`. **No absolute hash or byte literal is pinned
anywhere in the workspace.**
- The committed SVG goldens (`epiphany-render-svg/tests/golden/`) and PNG
baselines (`epiphany-editor-gui/goldens/`) do **not** embed the catalog
identity. Their only "SMuFL" is a fixed comment string in the SVG preamble.
**Conformance gate [9] and the editor track are untouched.**
- `ChunkKind::LayoutCache` is a regenerable major-0 role — discard-and-regenerate.
No migration, no schema major, **no wire change of any kind in this tranche.**
The encoded catalog bytes do change in *value* (`encode_catalog` writes the minor
as `04 00` today and `28 00` after), and that is expected and unpinned. If a
golden or a byte-literal assertion *does* fail, **stop and report it** — it would
mean the reviewer's analysis missed a pin, and it must not be "fixed" by
re-copying bytes.
## The surface
**`crates/epiphany-layout-ir/src/glyph.rs`**
- `:29` — delete `pub struct SmuflVersion { major, minor }`.
- Re-export or `use epiphany_core::SmuflVersion` so the name still resolves
in-crate. Keep the doc note at `:27` (Chapter 7/9 role) attached to the
re-export, and drop the now-stale "This is not epiphany_layout_ir::SmuflVersion"
paragraph in `epiphany-core/src/accidental.rs:251-258` — the homonym is gone.
- `:90``GlyphCatalogIdentity.smufl_version` now takes core's type.
- `:120` (`Default`), `:332` (`BravuraCatalog::smufl_version`), `:519` (the test
catalog impl) — `SmuflVersion { major: 1, minor: 4 }` becomes
`SmuflVersion::from_decimal(1, "4").expect("1.4 is a valid SMuFL version")`,
i.e. `minor_centi: 40`. Use `from_decimal`, not a hand-written
`minor_centi: 40` — the field's doc forbids literal construction precisely
because `4` and `40` look interchangeable and are not.
- `:310` — the `GlyphCatalog::smufl_version()` trait method return type.
**`crates/epiphany-layout-ir/src/resolved.rs:364`** — `c.smufl_version.minor`
`.minor_centi`. This is the one line that changes emitted bytes.
**`crates/epiphany-layout-ir/src/solver.rs:769`** — `smufl_version.minor += 1`
`.minor_centi += 1`, inside `forged_catalog_metadata_is_rejected`. Intent is
"a catalog identity that does not match the bundled Bravura is rejected"; `+= 1`
still denotes a different (nonsense) version, which is the point. Preserve it.
**`crates/epiphany-layout-ir/src/lib.rs:113`** — **keep** `SmuflVersion` in the
public re-export list, now aliasing core's type. This keeps
`epiphany_layout_ir::SmuflVersion` compiling for every downstream user, so
downstream churn is zero.
**`crates/epiphany-testkit/src/layout_stub.rs:798`** — `gen_smufl_version`
currently emits `minor: rng.range(0, 6)`, which as *hundredths* would mean
versions 1.001.05 — not real SMuFL versions. Generate meaningful normalized
values instead (e.g. sample from the real release set {12, 18, 20, 30, 40}, or
scale a small digit by 10). Say in a comment why.
**A `const` contingency:** `from_decimal` returns `Option` and is not `const`.
Every current site is a function body, so this does not arise. If you find a site
that genuinely needs a `const`, add a `const fn` constructor to
`epiphany_core::accidental::SmuflVersion` rather than hand-writing
`minor_centi: 40` at the call site — and say so in your report.
## Spec — `spec/core_spec.tex`
`SmuflVersion` is currently **defined nowhere in core_spec.tex**: only
`SmuflVersionRequirement` (`:3269`, whose two fields are of that type) and two
Chapter 9 usages — `GlyphCatalog::smufl_version()` (`:10420`) and
`GlyphCatalogIdentity.smufl_version` (`:10460`). P13-S12's ratified shape reached
Rust and the Binary Format companion but never the core specification. This
tranche closes that — it is the last open half of S12.
Add **one** definition (near `SmuflVersionRequirement` at `:3269`, the Chapter 4
site that first needs it) as a type listing:
```
pub struct SmuflVersion {
pub major: u16,
/// The fractional part, normalized to hundredths.
pub minor_centi: u16,
}
```
with normative prose stating: the minor is stored **fraction-normalized to
hundredths** — 1.12 → 12, 1.18 → 18, 1.20 → 20, 1.3 → 30, 1.4 → 40 — so that the
derived ordering on `(major, minor_centi)` matches SMuFL's real release order;
that a literal-digit encoding would order 1.3 and 1.4 before 1.12 and is
non-conforming; and that the normalization collapses the 1.2/1.20 ambiguity (both
→ 20). Include the release table as an explanatory note, not as normative text.
Make the two Chapter 9 sites reference this single definition (a cross-reference
sentence is enough) so it is unambiguous that the glyph catalog's version and the
tuning context's version are **the same type**.
**No new `\label{req:...}`.** The counts MUST remain 212/282/282 — the gate
asserts it. The wire-level rule is already normative in
`binary_format.tex:2963-2967`; this is the core-side type definition, not a new
requirement.
## Ledger — `spec/PASS13_CANDIDATES.md`
P13-S12's resolution text says the unification lands "with golden/vector regen."
Verified false before dispatch (see *Blast radius*). Correct that clause to state
that no golden or vector is pinned to the catalog identity, so the move changes
emitted bytes with nothing to regenerate. This is the S9 discipline the ledger
already applies to itself — a claim that reads cleanly but is not supported.
## Do NOT touch
- **Any wire format.** 3b-ii changes no schema major, no `Codec`, no
`decode_vectors.txt`. `epiphany-core`'s freshly frozen major-3 layout is
off-limits except for deleting the stale homonym paragraph in `accidental.rs`.
- **The editor track**: `crates/epiphany-editor-gui/goldens/*.png`,
`spec/PLAN_EDITOR_APP.md`, `spec/CONTRACT_EDITOR_T1A_GOLDENS.md`,
`spec/CONTRACT_EDITOR_T2_SELECTION.md`. Parallel work; not yours.
- The committed SVG goldens under `crates/epiphany-render-svg/tests/golden/`.
- `.claude/worktrees/` — agent worktrees, not the repo.
## The gate (report exact numbers)
- `cargo fmt --all --check`
- `cargo clippy --workspace --all-targets` → 0 warnings
- `cargo test --workspace` → 0 failed (report the pass count; 3b-i landed at 1282)
- `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` → 0
- `cargo run -q -p epiphany-testkit --example conformance_suite` → 8/8
- `cargo test -p epiphany-testkit --test requirement_labels` → 6/6, counts
**212 / 282 / 282** unchanged
## Deliver a test that would have caught the live bug
The whole point of this tranche is that layout-ir's ordering was wrong. Add a
test **in layout-ir** proving the catalog's version type now orders the real
release sequence correctly (1.12 < 1.18 < 1.20 < 1.3 < 1.4) the assertion that
fails against the deleted literal-minor type. A unification that silently fixed a
bug with no test naming it is not finished.
## What the reviewer will verify independently — build to survive it
- The emitted catalog bytes changed at exactly the expected offset
(`04 00` → `28 00`) and nowhere else, by encoding a default
`GlyphCatalogIdentity` before and after.
- The ordering bug is genuinely dead: the new test fails if the comparison is
performed on literal minors.
- No committed golden, baseline, or vector file changed (`git status` over
`goldens/`, `tests/golden/`, `spec/vectors/`).
- `epiphany_layout_ir::SmuflVersion` still resolves for downstream crates.
- Requirement counts unmoved, and no `\label{req:` added to the spec diff.

View File

@ -85,4 +85,4 @@ open.
| 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-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-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-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) | | 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. **Correction (2026-07-23, tranche 3b-ii): "with golden/vector regen" above was verified false before that tranche's dispatch.** No golden, baseline, or vector is pinned to the catalog identity every assertion on `ResolvedLayoutIR::canonical_bytes()` is relative (stability, determinism, and a `metrics_hash[0] ^= 1` sensitivity check that never touches `smufl_version`), and the committed SVG/PNG goldens do not embed it. The move changes the catalog's emitted bytes (`encode_catalog`'s minor field: `04 00` `28 00`) in *value*, with nothing to regenerate. The hundredths scale is blocking for 3b, free to adjust in 3a) |

Binary file not shown.

View File

@ -3265,6 +3265,41 @@ pub enum GlyphReference {
indicator); it \MUSTNOT{} silently fail. indicator); it \MUSTNOT{} silently fail.
\end{requirement} \end{requirement}
\begin{lstlisting}[language=Rust]
pub struct SmuflVersion {
pub major: u16,
/// The fractional part, normalized to hundredths.
pub minor_centi: u16,
}
\end{lstlisting}
A SMuFL version's fractional part is stored \emph{normalized to
hundredths} in \texttt{minor\_centi}, not as the literal digits that
follow the decimal point: 1.12 $\to$ \tablenums{12}, 1.18 $\to$
\tablenums{18}, 1.20 $\to$ \tablenums{20}, 1.3 $\to$ \tablenums{30},
1.4 $\to$ \tablenums{40}. SMuFL versions are decimal fractions, and
this repository's published minor releases did not arrive in digit
order: 1.12 and 1.18 shipped before 1.3 and 1.4. A literal-digit
encoding (\texttt{minor: 3} for 1.3, \texttt{minor: 12} for 1.12) is
\textbf{non-conforming} because the derived ordering on
\texttt{(major, minor)} then sorts 1.3 and 1.4 \emph{before} 1.12,
backwards versus the real release history. The hundredths
normalization also collapses the 1.2/1.20 spelling ambiguity: both
denote the same release and both normalize to \tablenums{20}. This is
the single \texttt{SmuflVersion} type used throughout the
specification --- by \texttt{SmuflVersionRequirement} below and, in
Chapter~\ref{ch:layout-ir}, by \texttt{GlyphCatalog::smufl\_version()}
(Section~\ref{sec:layoutir:catalog}) and \texttt{GlyphCatalogIdentity}
(Section~\ref{sec:layoutir:catalog-identity}).
\begin{rationale}
For reference, SMuFL's minor releases in their real order, each with
its normalized \texttt{minor\_centi} value: 1.12
(\tablenums{12}), 1.18 (\tablenums{18}), 1.20 (\tablenums{20}), 1.3
(\tablenums{30}), 1.4 (\tablenums{40}).
\end{rationale}
\begin{lstlisting}[language=Rust] \begin{lstlisting}[language=Rust]
pub struct SmuflVersionRequirement { pub struct SmuflVersionRequirement {
/// Minimum SMuFL version required by this score. /// Minimum SMuFL version required by this score.
@ -10406,7 +10441,10 @@ expressed in:
The IR references glyphs by identifier; glyph metrics are queried The IR references glyphs by identifier; glyph metrics are queried
from a font catalog at IR construction. This section defines the from a font catalog at IR construction. This section defines the
interface; the catalog implementation is outside the core interface; the catalog implementation is outside the core
specification. specification. \texttt{SmuflVersion}, below, is the same type declared
by a score's \texttt{SmuflVersionRequirement}
(Section~\ref{sec:tuning:smufl}): the glyph catalog's version and the
tuning context's declared version are one type, not two.
\begin{lstlisting}[language=Rust] \begin{lstlisting}[language=Rust]
pub trait GlyphCatalog: Send + Sync { pub trait GlyphCatalog: Send + Sync {
@ -10449,7 +10487,10 @@ pub struct GlyphRenderData {
\label{sec:layoutir:catalog-identity} \label{sec:layoutir:catalog-identity}
Layout output depends on glyph metrics. For reproducible layout, Layout output depends on glyph metrics. For reproducible layout,
the exact glyph catalog used \MUST{} be identifiable. the exact glyph catalog used \MUST{} be identifiable. \texttt{smufl\_version}
below is a \texttt{SmuflVersion} (Section~\ref{sec:tuning:smufl}), ordered
by its fraction-normalized \texttt{minor\_centi} so a catalog identity's
version compares correctly against SMuFL's real release history.
\begin{lstlisting}[language=Rust] \begin{lstlisting}[language=Rust]
/// A reproducibility-quality identifier for the glyph catalog /// A reproducibility-quality identifier for the glyph catalog