Text-projection parity: smufl and overrides project, and a persistence gap surfaces

Schema major 3 put `smufl` and `overrides` on the binary wire and left the text
surface alone. This closes that: `TextValue for ScoreTuningContext` projects
five fields in `fn enc` order, with four new leaf impls (SmuflVersion,
SmuflVersionRequirement, TuningScope, TuningOverride) mirroring the four Codec
impls 3b-i froze.

The rule that moved them is the one this projection always followed — the text
projection is the same canonical surface the binary codec is. What changed was
not the rule but a premise: the old doc comment excluded all three fields
because "no schema major 3 has been opened", and 3b-i opened it.
`accidental_extensions` was staged out of that major and stays in-memory only,
so it is still correctly absent — the staging line now falls in the same place
on both surfaces, which is what the inverted tests assert.

No header-version bump and no document vector moved. This is a value-projection
change, not a document-format one: epiphany-textproj has no reference to a
tuning context anywhere, and the canonical base projects as a single opaque
byte atom (req:textproj:base-snapshot-inline), not structurally. So
req:textproj:header-version's sole accepted version (0 7 0) is untouched, all
13 document vectors are unchanged, and req:textproj:roundtrip was never
violated — the tuning context is not part of the projected document.

Both tests were renamed and inverted, following 3b-i's treatment of the binary
pair rather than rewriting the tranche-2/3a decision entries. Mutation-verified:
making `overrides` project as an always-empty vector — the exact regression this
fixes — fails both.

Two stale-pointer classes fixed, both of the kind this pass was hunting: the
comment in codec.rs naming the old text-test names and claiming the text
surface was unchanged; and ScoreTuningContext's per-field docs in graph.rs,
which still read "In memory only this tranche" for `smufl` and `overrides` —
stale since 3b-i, and contradicting the struct-level wire note they point at.
The struct-level doc was already correct.

Filed P13-S13, found while scoping and deliberately not fixed here: the tuning
context has NO canonical persistence path. Its only *persisted* carrier is the
acceleration snapshot, which Chapter 8 makes non-canonical and regenerable; no
canonical carrier embeds it at all. No operation authors it (the ops
vocabulary's only `tuning` references are the per-pitch TuningReference::Inherit)
and MaterializedState does not carry it. So a per-voice tuning override survives
a snapshot round-trip but cannot be authored, replicated, or merged, which makes
req:tuning:tuning-resolution-order's scopes 2-4 unreachable from any exchangeable
document. Not a regression — nothing ever worked; major 3 made the gap visible
by giving the value a wire form and no way to reach it.

The candidate prices both routes. Ratification is about the carrier, not the
encoding — the layouts are frozen and correct either way — but the operation
route is not quite the free schema-minor it first looks: blocks stamp minimally,
and bundle.rs caps OperationEnvelopeBlock at major 2 precisely because no
operation payload embeds the tuning context. An authoring operation carrying a
TuningOverride would stamp v3, dragging a role accept-set raise along with the
kind append.

Gate: fmt clean, clippy 0, 1336 passed / 0 failed, doc 0, conformance 8/8,
requirement labels 6/6 at 212/282/282.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjsEnYhm1gPpf6ii2iFxFV
This commit is contained in:
Levi Neuwirth 2026-07-24 13:35:51 -04:00
parent 85d8af612f
commit 6d37f39826
5 changed files with 242 additions and 47 deletions

View File

@ -1317,3 +1317,51 @@ live `Codec::dec` killed six tests at once (`v0_score_migrates_*`,
`v1_round_trips_non_default_values_for_every_new_field`) — confirming the
reroute is load-bearing across the whole frozen-decoder family, not just the
two sites the contract named.
## Text-projection parity: `smufl` and `overrides` project (2026-07-24)
Schema major 3 (tranche 3b-i) put `smufl` and `overrides` on the binary wire
but deliberately left the text surface alone, deferring it. This closes that
gap: `TextValue for ScoreTuningContext` now projects five fields, in `fn enc`
order, and four new `TextValue` impls carry the leaves
(`SmuflVersion`, `SmuflVersionRequirement`, `TuningScope`, `TuningOverride`
mirroring the four `Codec` impls 3b-i froze).
**The rule that moved them is the one this projection always followed** — the
text projection is the same canonical surface the binary codec is. What
changed is not the rule but a premise: the old doc comment justified excluding
all three fields on the grounds that "no schema major 3 has been opened", and
3b-i opened it. `accidental_extensions` was *staged* out of that major and is
still in-memory only, so it is still correctly absent — the staging line now
falls in exactly the same place on both surfaces, which is what the new tests
assert.
**No header-version bump, and no document vector moved.** This is a
value-projection change, not a document-format change: `epiphany-textproj` has
no reference to a tuning context anywhere, and the canonical base is projected
as one opaque byte atom (`req:textproj:base-snapshot-inline`), not
structurally. So `req:textproj:header-version`'s single accepted version
`(0 7 0)` is untouched and all 13 document vectors are unchanged.
`req:textproj:roundtrip` was never violated — the tuning context is not part
of the projected document — so this is a consistency fix on a value impl, not
a conformance repair.
**Test renames** (following 3b-i's own treatment of the binary pair, rather
than rewriting the tranche-2/3a entries above):
`score_tuning_context_round_trips_and_overrides_do_not_project`
`..._and_overrides_project`, with its equality assertion inverted to
`assert_ne!` plus a full round-trip; and
`score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project`
`score_tuning_context_projects_smufl_and_overrides_but_not_accidental_extensions`,
now the text analogue of the binary staging-boundary test. Both were
mutation-verified: making `overrides` project as an always-empty vector — the
exact regression this pass fixes — fails both.
**Found while scoping, not fixed here:** the tuning context has **no canonical
persistence path at all**. No operation authors it (`epiphany-ops` has no
tuning-context payload; the only `tuning` references are per-pitch
`TuningReference::Inherit`), and `MaterializedState` does not carry it. Major 3
made the *acceleration snapshot* able to carry it, and that snapshot is
explicitly non-canonical and regenerable. So a tuning override survives a
snapshot round-trip but cannot be authored, replicated, or merged. Filed as a
Pass-13 candidate; it is a data-model question, not a codec one.

View File

@ -1952,11 +1952,14 @@ impl Codec for TuningOverride {
// drift when this live impl changes. A round-trip test just below
// (`score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not`)
// proves `smufl`/`overrides` now survive `enc`→`dec` while
// `accidental_extensions` still does not. The matching text-projection
// proofs are unchanged by this tranche — `textvalue_graph.rs`'s
// `score_tuning_context_round_trips_and_overrides_do_not_project` and
// `score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project`
// (text projection is a separate surface; see this tranche's contract).
// `accidental_extensions` still does not. The text projection now draws the
// staging line in the same place, and its proofs are the direct analogues:
// `textvalue_graph.rs`'s `score_tuning_context_round_trips_and_overrides_project`
// and `score_tuning_context_projects_smufl_and_overrides_but_not_accidental_extensions`.
// (3b-i left the text surface untouched, deferring it; the parity pass that
// followed moved `smufl`/`overrides` there for the reason stated in
// `TextValue for ScoreTuningContext`'s doc — the text projection is the same
// canonical surface this codec is.)
impl Codec for ScoreTuningContext {
fn enc(&self, out: &mut Vec<u8>) {
self.default_pitch_space.enc(out);

View File

@ -1669,17 +1669,24 @@ pub struct ScoreTuningContext {
pub default_tuning_system: TuningSystemId,
pub reference: ReferencePitch,
/// Score-local accidental-registry extensions (Chapter 4 §"Score-Local
/// Extensions", `:3228`). **In memory only** this tranche (Push 4b
/// tranche 3a) — see the wire note above.
/// Extensions", `:3228`). **In memory only**: staged out of schema major 3
/// and appended after `overrides` whenever a later major lands it — see
/// the wire note above. Neither the binary codec nor the text projection
/// carries it.
pub accidental_extensions: Vec<crate::accidental::ScoreAccidentalExtensions>,
/// The SMuFL version this score requires (Chapter 4 §"SMuFL Versioning",
/// `req:tuning:smufl-version-fallback`). **In memory only** this
/// tranche — see the wire note above.
/// `req:tuning:smufl-version-fallback`). **On the wire** as of schema
/// major 3 (tranche 3b-i), and projected to text alongside it — see the
/// wire note above.
pub smufl: crate::accidental::SmuflVersionRequirement,
/// Per-scope overrides consulted by the tuning resolver
/// (`crate::tuning::resolve_pitch_frequency`), scopes 2-4 of
/// `req:tuning:tuning-resolution-order`. **In memory only** this
/// tranche — see the struct doc above.
/// `req:tuning:tuning-resolution-order`. **On the wire** as of schema
/// major 3 (tranche 3b-i), and projected to text alongside it — see the
/// wire note above. Note that reaching the wire is not the same as being
/// canonically persistable: no operation authors this field and the
/// canonical base does not carry it, so today it survives only in the
/// (regenerable) acceleration snapshot — P13-S13.
pub overrides: Vec<crate::tuning::TuningOverride>,
}

View File

@ -291,22 +291,133 @@ impl TextValue for TimeSignature {
// not a validating constructor.
// ===========================================================================
/// `(score-tuning-context <default-pitch-space> <default-tuning-system>
/// <reference>)` — exactly the three wire fields, in `fn enc` order
/// (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`).
/// `(smufl-version <major> <minor-centi>)`.
///
/// `ScoreTuningContext` has gained three fields beyond these three —
/// `overrides` (Push 4b tranche 2), then `accidental_extensions` and `smufl`
/// (Push 4b tranche 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`) — that are
/// deliberately **not** part of this projection: all three are in-memory only
/// (no schema major 3 has been opened), so none may reach the wire, and the
/// text projection is the same canonical surface the binary codec is — a
/// value that omitted them here would otherwise silently launder a
/// non-empty context into one indistinguishable from an empty one, which is
/// exactly the intended behavior, not an oversight. `parse` always
/// constructs `accidental_extensions: Vec::new()`, `smufl:
/// SmuflVersionRequirement::default()`, and `overrides: Vec::new()`,
/// mirroring `Codec::dec`.
/// `minor_centi` projects as the stored, already-normalized value (1.4 is
/// `40`, 1.12 is `12`), not as the decimal digits a human writes — the text
/// projection carries the canonical value, exactly as `Codec` does. Parsing
/// validates no further than the binary decoder does, for the same reason.
impl TextValue for crate::accidental::SmuflVersion {
fn project(&self) -> Sexp {
Sexp::List(vec![
Sexp::Symbol(kebab("SmuflVersion")),
TextValue::project(&self.major),
TextValue::project(&self.minor_centi),
])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("SmuflVersion"), 2)?;
Ok(crate::accidental::SmuflVersion {
major: <u16 as TextValue>::parse(&fields[0])?,
minor_centi: <u16 as TextValue>::parse(&fields[1])?,
})
}
}
/// `(smufl-version-requirement <minimum> <authored-against>)` — the two fields
/// in `fn enc` order.
impl TextValue for crate::accidental::SmuflVersionRequirement {
fn project(&self) -> Sexp {
Sexp::List(vec![
Sexp::Symbol(kebab("SmuflVersionRequirement")),
self.minimum.project(),
self.authored_against.project(),
])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("SmuflVersionRequirement"), 2)?;
Ok(crate::accidental::SmuflVersionRequirement {
minimum: TextValue::parse(&fields[0])?,
authored_against: TextValue::parse(&fields[1])?,
})
}
}
/// The tuning-scope union, in the discriminant order `Codec` assigns
/// (`Voice` 0, `Staff` 1, `Region` 2, `Range` 3).
impl TextValue for crate::tuning::TuningScope {
fn project(&self) -> Sexp {
use crate::tuning::TuningScope as S;
match self {
S::Voice(id) => variant("Voice", vec![id.project()]),
S::Staff(id) => variant("Staff", vec![id.project()]),
S::Region(id) => variant("Region", vec![id.project()]),
S::Range { start, end, voices } => variant(
"Range",
vec![start.project(), end.project(), voices.project()],
),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
use crate::tuning::TuningScope as S;
let (ctor, fields) = split_variant(s)?;
if ctor == kebab("Voice") {
let f = fields_of(fields, "TuningScope", 1)?;
Ok(S::Voice(TextValue::parse(&f[0])?))
} else if ctor == kebab("Staff") {
let f = fields_of(fields, "TuningScope", 1)?;
Ok(S::Staff(TextValue::parse(&f[0])?))
} else if ctor == kebab("Region") {
let f = fields_of(fields, "TuningScope", 1)?;
Ok(S::Region(TextValue::parse(&f[0])?))
} else if ctor == kebab("Range") {
let f = fields_of(fields, "TuningScope", 3)?;
Ok(S::Range {
start: TextValue::parse(&f[0])?,
end: TextValue::parse(&f[1])?,
voices: TextValue::parse(&f[2])?,
})
} else {
Err(TextError::UnknownConstructor {
type_name: "TuningScope",
found: ctor.to_owned(),
})
}
}
}
/// `(tuning-override <scope> <pitch-space> <tuning-system> <reference>)` — the
/// four fields in `fn enc` order; the last three are optional and inherit from
/// the next-outer scope when absent.
impl TextValue for crate::tuning::TuningOverride {
fn project(&self) -> Sexp {
Sexp::List(vec![
Sexp::Symbol(kebab("TuningOverride")),
self.scope.project(),
self.pitch_space.project(),
self.tuning_system.project(),
self.reference.project(),
])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("TuningOverride"), 4)?;
Ok(crate::tuning::TuningOverride {
scope: TextValue::parse(&fields[0])?,
pitch_space: TextValue::parse(&fields[1])?,
tuning_system: TextValue::parse(&fields[2])?,
reference: TextValue::parse(&fields[3])?,
})
}
}
/// `(score-tuning-context <default-pitch-space> <default-tuning-system>
/// <reference> <smufl> <overrides>)` — exactly the five wire fields, in
/// `fn enc` order.
///
/// **`smufl` and `overrides` project; `accidental_extensions` does not**, and
/// the rule dividing them is the one this projection has always followed: the
/// text projection is the same canonical surface the binary codec is. Schema
/// major 3 (Push 4b tranche 3b-i, `spec/CONTRACT_PUSH4B_3BI_WIRE.md`) put
/// `smufl` and `overrides` on the wire, so they belong here too — omitting
/// them would silently launder a loaded context into one indistinguishable
/// from an empty one. `accidental_extensions` was deliberately **staged** out
/// of that major and remains in-memory only, so it is still correctly absent;
/// `parse` constructs it as `Vec::new()`, mirroring `Codec::dec`. When a later
/// major puts it on the wire, it joins this projection then and not before.
///
/// (An earlier revision of this comment justified excluding all three on the
/// grounds that "no schema major 3 has been opened". That premise expired when
/// 3b-i opened it; the rule it appealed to is what moved two of the three in.)
impl TextValue for ScoreTuningContext {
fn project(&self) -> Sexp {
Sexp::List(vec![
@ -314,20 +425,24 @@ impl TextValue for ScoreTuningContext {
self.default_pitch_space.project(),
self.default_tuning_system.project(),
self.reference.project(),
self.smufl.project(),
self.overrides.project(),
])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("ScoreTuningContext"), 3)?;
let fields = s.expect_struct(&kebab("ScoreTuningContext"), 5)?;
let default_pitch_space = TextValue::parse(&fields[0])?;
let default_tuning_system = TextValue::parse(&fields[1])?;
let reference = TextValue::parse(&fields[2])?;
let smufl = TextValue::parse(&fields[3])?;
let overrides = TextValue::parse(&fields[4])?;
Ok(ScoreTuningContext {
default_pitch_space,
default_tuning_system,
reference,
accidental_extensions: Vec::new(),
smufl: crate::accidental::SmuflVersionRequirement::default(),
overrides: Vec::new(),
smufl,
overrides,
})
}
}
@ -935,14 +1050,14 @@ mod tests {
}
#[test]
fn score_tuning_context_round_trips_and_overrides_do_not_project() {
fn score_tuning_context_round_trips_and_overrides_project() {
// Empty overrides: ordinary identity round-trip.
round_trip(ScoreTuningContext::default());
// Non-empty overrides: the text projection is identical to the
// empty-overrides projection (the field is in-memory only, Push 4b
// tranche 2 / Ruling C), and parsing always reconstructs `overrides`
// as empty.
// Non-empty overrides: as of schema major 3 (Push 4b tranche 3b-i)
// `overrides` is on the wire, so it projects and survives the round
// trip. This assertion is the **inversion** of the pre-major-3 one it
// replaces, which required the two projections to be identical.
let mut with_overrides = ScoreTuningContext::default();
with_overrides
.overrides
@ -952,20 +1067,24 @@ mod tests {
tuning_system: None,
reference: None,
});
assert_eq!(
assert_ne!(
with_overrides.project().render(),
ScoreTuningContext::default().project().render(),
"overrides must not appear in the text projection"
"overrides must appear in the text projection as of schema major 3"
);
round_trip(with_overrides.clone());
let parsed = ScoreTuningContext::parse(&with_overrides.project()).unwrap();
assert!(parsed.overrides.is_empty());
assert_eq!(parsed.overrides, with_overrides.overrides);
}
#[test]
fn score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project() {
// The direct analogue of `score_tuning_context_round_trips_and_overrides_do_not_project`
// above, extended to all three in-memory-only fields (Push 4b tranche
// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`).
fn score_tuning_context_projects_smufl_and_overrides_but_not_accidental_extensions() {
// The text analogue of the binary staging-boundary test
// (`codec::tests::score_tuning_context_smufl_and_overrides_reach_the_wire_accidental_extensions_do_not`):
// schema major 3 put `smufl` and `overrides` on the wire, so both
// project; `accidental_extensions` was staged out of that major and is
// still in-memory only, so it must still be dropped. This proves the
// staging line falls in exactly the same place on both surfaces.
use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement};
let mut loaded = ScoreTuningContext::default();
@ -985,15 +1104,30 @@ mod tests {
tuning_system: None,
reference: None,
});
assert_eq!(
// Sanity: the fixture differs from the default in all three fields, so
// neither half of the assertion below is vacuous.
assert!(!loaded.accidental_extensions.is_empty());
assert_ne!(loaded.smufl, SmuflVersionRequirement::default());
assert!(!loaded.overrides.is_empty());
assert_ne!(
loaded.project().render(),
ScoreTuningContext::default().project().render(),
"accidental_extensions, smufl, and overrides must not appear in the text projection"
"smufl and overrides must appear in the text projection as of schema major 3"
);
let parsed = ScoreTuningContext::parse(&loaded.project()).unwrap();
// smufl and overrides survive the round trip...
assert_eq!(parsed.smufl, loaded.smufl);
assert_eq!(parsed.overrides, loaded.overrides);
// ...but accidental_extensions is still dropped: staged, never projected.
assert!(parsed.accidental_extensions.is_empty());
assert_eq!(parsed.smufl, SmuflVersionRequirement::default());
assert!(parsed.overrides.is_empty());
assert_eq!(
parsed,
ScoreTuningContext {
accidental_extensions: Vec::new(),
..loaded.clone()
}
);
}
#[test]

View File

@ -69,8 +69,10 @@ 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.
S4, S8, S9, and S13 remain open; S10S12 are resolved on ratification. The pass
stays open. (S13 was filed 2026-07-24, after S10S12's wire freeze landed: it is
the gap that freeze made visible — a value with a wire form and no canonical
carrier to reach it.)
| Id | One-line statement | Filed in | Status |
|---|---|---|---|
@ -86,3 +88,4 @@ open.
| 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. **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) |
| P13-S13 | **The score tuning context has no canonical persistence path: it can be saved, but never authored, replicated, or merged.** Schema major 3 (Push 4b tranche 3b-i) put `smufl` and `overrides` on the wire, but the only *persisted* carrier that embeds a `ScoreTuningContext` is the **acceleration snapshot**, which Chapter 8 makes explicitly non-canonical and regenerable and which the bundle may discard and rebuild at will. No **canonical** carrier embeds it at all. The two carriers that *are* canonical do not carry it: no operation authors it (`epiphany-ops` has no tuning-context payload anywhere — the vocabulary's only `tuning` references are the per-pitch `TuningReference::Inherit`), and `MaterializedState` carries effects, conflicts, anomalies, objects, spellings, breaks, page-breaks, and pending, but no tuning context. So a user who selects a tuning system or sets a per-voice override has authored something the format cannot durably represent: it survives a snapshot round-trip, and is lost the moment the snapshot is regenerated from the operation log. This also means `req:tuning:tuning-resolution-order`'s scopes 24 (`overrides`) are, in practice, unreachable from any document a replica could exchange | this file (found 2026-07-24 while scoping text-projection parity; verified by searching the whole `epiphany-ops` operation vocabulary and `MaterializedState`'s field list) | **open** (ratification needed on *where* it belongs: an authoring operation kind — a schema-minor kind append, per the `DeleteRepeatStructure`/`TransposeInterval` precedent — versus a field on the canonical base, which is a data-model major. The operation route is the cheaper mechanism and the one every other authored score property already uses; the base route would make it reduced state rather than authored state, which is likely wrong since a user *chooses* a tuning. Note the wire layouts are already frozen and correct either way — this is about which carrier embeds them, not how they encode. **One cost the operation route must price in:** blocks are stamped *minimally*, at the lowest major whose layouts decode them, and `bundle.rs`'s `max_supported_major` currently caps `OperationEnvelopeBlock` at **2** on the explicit ground that no operation payload embeds the tuning context, so no op block is ever born at v3. An authoring operation carrying a `TuningOverride` would make its blocks stamp v3, so the kind append — a schema *minor* in itself — drags a *role accept-set raise* along with it. That is a small, well-precedented change, but it is not free, and it is the one asymmetry that makes the two routes closer in cost than they first appear. Not a regression: nothing ever worked; major 3 made the gap visible by giving the value a wire form and no way to reach it) |