From b25b8debedf3cee58b82b44b8a4addee75b0ebb3 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 14:48:29 -0400 Subject: [PATCH] Push 4b tranche 3a: the accidental vocabulary, in memory The accidental / glyph / engraving type surface Chapter 4 puts on ScoreTuningContext now exists in epiphany-core (new accidental.rs): ScoreAccidentalExtensions, AccidentalDefinition, GlyphReference (Chapter 4's, recursive -- not layout-ir's homonym), PitchSpaceModification, AccidentalEngraving with EngravingBoundingBox and AnchorPoint, AccidentalCombination, SmuflVersion(Requirement), plus three catalog_id! newtypes. All in memory, no Codec, canonical bytes unmoved -- the reversible half; 3b freezes them on the wire. The three ratified corrections land as filed: - S10 Cents(CanonicalF64), not raw f64 -- reverting it to f64 is 9 compile errors, the type system is the test. - S11 AnchorPoint { x, y: SpaceUnit }, core-native, frame pinned in its doc. - S12 SmuflVersion { major, minor_centi }, built only through from_decimal which normalizes 1-digit x10 / 2-digit as-is, so derived Ord orders SMuFL's real history right. layout-ir's SmuflVersion untouched; 3b unifies and moves GlyphCatalogIdentity. accidental_extensions and smufl join overrides as in-memory-only fields; the hand codec's enc is byte-for-byte unchanged (three wire fields), only dec defaults the new ones. The consumer that keeps this off the NOTEHEAD_ANCHORS path is real: resolve_accidental (override > addition > base) and the accidental-modification-compatibility invariant wired into check_invariants. Glyph/engraving metadata is carried but its deep consumer is the engraver, out of core -- said honestly, not faked. Verified independently of the agent. Through Score::canonical_bytes: a non-empty accidental_extensions + non-default smufl encode byte-identically to all-default (268 both) and decode back to empty -- all three fields off the wire. SmuflVersion orders 1.12 < 1.18 < 1.20 < 1.3 < 1.4 (the trap: 1.12 before 1.3). CanonicalF64 rejects NaN/inf. And the compatibility invariant is non-vacuous: weakening the predicate myself made the edo-31 reject test fail. No Codec, no golden moved. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-core/DECISIONS.md | 195 ++++++ crates/epiphany-core/src/accidental.rs | 715 ++++++++++++++++++++ crates/epiphany-core/src/codec.rs | 88 ++- crates/epiphany-core/src/graph.rs | 39 +- crates/epiphany-core/src/invariants.rs | 127 +++- crates/epiphany-core/src/lib.rs | 35 +- crates/epiphany-core/src/pitch.rs | 16 + crates/epiphany-core/src/textvalue_graph.rs | 56 +- 8 files changed, 1227 insertions(+), 44 deletions(-) create mode 100644 crates/epiphany-core/src/accidental.rs diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index 07fc43f..e69ad15 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -980,3 +980,198 @@ intra-doc links to the private `temperament_ratios`/`coordinate_ratio` were de-linked to plain code spans), `conformance_suite` (8/8), and `requirement_labels` (6 passed, counts unchanged at 212/282/282) all pass. No `.tex` file was touched and no requirement was added. + +## Push 4b tranche 3a: the accidental/glyph/engraving vocabulary lands, in memory, with two real consumers + +`spec/CONTRACT_PUSH4B_ACCIDENTALS.md`. Same reversible-first discipline as +tranches 1/2/2b: the accidental-registry, glyph-reference, and engraving type +surface from Chapter 4 §"Accidental Registries" / "Glyph References and +SMuFL" lands in `epiphany-core`, in memory, with two real consumers, and +**no `Codec`, no wire movement** — canonical bytes stay byte-identical. This +splits tranche 3's full `ScoreTuningContext` completion in two: 3a builds and +exercises the shapes while they are still free to change; 3b (a later +tranche) puts `accidental_extensions`, `smufl`, and `overrides` on the wire +together, opening schema major 3 — an irreversible freeze, so it is not done +until the shapes have had a consumer. + +**New module `src/accidental.rs`.** Transcribed field-for-field from +`core_spec.tex:3054`-`3277`, in spec order, with three ratified corrections +(P13-S10/S11/S12, filed and ratified before dispatch): + +* **S10** — `PitchSpaceModification::Cents(CanonicalF64)`, not `Cents(f64)`: + a raw `f64` is unencodable in canonical state (`serialize.rs` decodes + floats only through `CanonicalF64::from_le_bytes`; there is no `Codec for + f64`). Locked by `accidental::tests::cents_round_trips_a_finite_value_and_guards_non_finite`, + which round-trips a finite cents value and shows `CanonicalF64::new` rejects + NaN/±infinity outright, so a `Cents` payload can never be non-finite. +* **S11** — `AnchorPoint { x: SpaceUnit, y: SpaceUnit }`, defined core-native. + The specification references it (`:3166`, `AccidentalEngraving::anchor`) + but never defines it, and `epiphany-core` cannot depend on + `epiphany-layout-ir`. Doc comment pins the frame ratified alongside S11: + canonical space units, y-up, relative to the glyph's coordinate origin + (needed because `EngravingBoundingBox` is itself "relative to the glyph's + anchor point", `:3160`, so the anchor needs an unambiguous origin of its + own). +* **S12** — `SmuflVersion { major: u16, minor_centi: u16 }`, the minor stored + fraction-normalized to hundredths (1.4 -> 40, 1.3 -> 30, 1.12 -> 12), built + only through the checked `SmuflVersion::from_decimal(major, minor_digits)` + constructor so a caller cannot pass a literal minor digit by mistake. + Locked by `accidental::tests::smufl_version_orders_the_real_release_sequence`, + which asserts SMuFL's actual release order (1.12 < 1.18 < 1.20 < 1.3 < 1.4) + — a test that would pass under literal-minor storage (where 1.3 and 1.4 + 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** + `epiphany_layout_ir::SmuflVersion` (`glyph.rs:29`, literal-minor, + load-bearing for `GlyphCatalogIdentity`) — that type is untouched; the two + are a deliberate, bounded homonym (`epiphany-core` cannot depend on + `epiphany-layout-ir` in any case) until a later tranche unifies them and + moves `GlyphCatalogIdentity`, with golden regen. + +Also new: `CustomGlyphId`, `ModificationRegistryId`, `AccidentalGroupId` +(`catalog_id!` entries in `pitch.rs`, beside `AccidentalRegistryId`/ +`AccidentalId`, which already existed). **No `Codec` impl exists for +anything in `accidental.rs`.** + +**Two real consumers, so this does not become the `NOTEHEAD_ANCHORS` trap.** + +*(a) `resolve_accidental(base_registry, extensions, id)`* — resolution +precedence per `core_spec.tex:3224` ("Extensions are stored on the score and +override or augment the base registry during resolution"): an `overrides` +entry wins over an `additions` entry wins over the base registry, checked by +`accidental::tests::resolution_precedence_overrides_beats_additions_beats_base` +across all three tiers plus the not-found case. `base_registry` is supplied +by the caller rather than looked up from an in-core catalog: `epiphany-core` +has no built-in catalog of accidental-registry *bodies* this tranche (the +same deferred-data-catalog discipline tranche 1 applied to the six +underdetermined pitch spaces) — inventing one would itself be the +`NOTEHEAD_ANCHORS` failure this consumer exists to avoid. + +*(b) `accidental_modification_compatible_with_space(modification, space)`*, +wired into `check_invariants` as +`GraphIndex::check_accidental_modification_compatibility` — the +`req:tuning:accidental-modification-compatibility` invariant +(`core_spec.tex:3120`). `space` resolves structurally against +`built_in_position_structure` (Push 4b tranche 1), the same catalog +`Pitch::transposed` uses. The requirement's two named rules (`CmnChromatic` +only in `DiatonicOverChromatic`-shaped spaces; `EdoSteps` only in +`Chromatic` or `Registered`) are matched directly against +`PositionStructure`; the contract's instruction to "extend the same shape" +gives `JiRatio` the identical `JiLattice`-or-`Registered` rule. `Cents` and +`Registered` modifications have no requirement-stated constraint, so they +are accepted whenever the space itself resolves — inventing a constraint the +requirement does not state would be the same failure as inventing a JI +generator ratio. An unresolvable space (outside the built-in catalog, or one +of the six catalog-named-but-underdetermined ones) fails closed for *every* +modification kind, `Cents`/`Registered` included, mirroring tranche 1's +`Pitch::transposed` discipline. `check_invariants` folds the result into the +existing `GraphInvariant::CrossCuttingRefsResolve` tag rather than inventing +a 20th spec-enumerated invariant — the same choice already made for the +tempo-map and aleatoric-model checks, since this is a Chapter 4 requirement, +not one of the 19 spec-enumerated Chapter 5 graph invariants. + +The check determines "every pitch space that references \[a\] registry" +(the requirement's phrase) as every pitch space the score's tuning context +concretely names: `default_pitch_space`, plus any per-scope override's +`pitch_space` (`crate::tuning::TuningOverride`). `epiphany-core` has no +built-in catalog linking an `AccidentalRegistryId` to the pitch space(s) +that declare it their `accidental_registry` — tranche 1 built only the +id -> `PositionStructure` map, not a populated `PitchSpace` catalog — so +this is the referencing relation the score can actually attest to, stated +honestly rather than invented. Because every existing generator leaves +`accidental_extensions` empty (this tranche adds no test data to any +generator), the new check is silent across the entire pre-existing +test/property-test corpus — proven directly by +`invariants::accidental_compatibility_tests::a_score_with_no_accidental_extensions_never_fires_this_check`. + +**Glyph and engraving metadata are carried, not consumed, in core.** +`GlyphReference`, `AccidentalEngraving` (and its `EngravingBoundingBox`/ +`AnchorPoint`), and `AccidentalCombination` are read by both consumers only +incidentally — resolution returns the whole `AccidentalDefinition`, and the +compatibility check reads past `engraving`/`glyph`/`combination` straight to +`modification`. Their deep consumer is the engraver, out of +`epiphany-core`, a later tranche; no in-core consumer was fabricated for +them to manufacture coverage. + +**`GlyphReference` is Chapter 4's own, deliberately not unified with +`epiphany_layout_ir::GlyphReference`** (`glyph.rs:50`, a glyph *name*, +`Cow<'static, str>`, a rendering concern): same name, unrelated types +(Ruling D's "correction"). `epiphany-core` cannot depend on +`epiphany-layout-ir` in any case, so within this crate there is no +ambiguity. + +**`ScoreTuningContext` gains its second and third in-memory-only fields.** +`accidental_extensions: Vec` and +`smufl: SmuflVersionRequirement` join `overrides` (Push 4b tranche 2) as +Rust fields with **no wire presence**: the hand-written `Codec::enc` is +byte-for-byte unchanged (still exactly `default_pitch_space`, +`default_tuning_system`, `reference`, in that order); only `dec` grows two +more defaults (`accidental_extensions: Vec::new()`, `smufl: +SmuflVersionRequirement::default()`), alongside the pre-existing `overrides: +Vec::new()`. `SmuflVersionRequirement::default()` is `{ minimum: +SmuflVersion(1.4), authored_against: SmuflVersion(1.4) }` — the SMuFL +version this repository already targets +(`epiphany_layout_ir::glyph::GlyphCatalogIdentity`'s default), so the +default aligns with what the layout-ir unification will target. The +matching `impl TextValue` (`textvalue_graph.rs`) gets the identical +treatment: `project` still emits exactly three fields, `parse` defaults all +three in-memory fields. + +Proved with a new test extending tranche 2's +`score_tuning_context_overrides_do_not_reach_the_wire` pattern to all three +fields at once: +`codec::tests::score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire` +(binary) and +`textvalue_graph::tests::score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project` +(text) — a fixture with non-empty `accidental_extensions`, a non-default +`smufl`, and a non-empty `overrides` encodes/projects byte-for-byte +identically to the all-default fixture, and decoding/parsing either +reconstructs all three as empty/default. The original tranche-2 tests are +untouched, preserving their historical narrative. + +**Every new test was mutation-verified** (substitution made, test run to +red, then reversed by undoing the exact substitution — never `git +checkout`): + +* **S12**: `SmuflVersion::from_decimal` mutated to store the minor literally + (`minor_centi = value` unconditionally, dropping the ×10 for one-digit + input). Killed both `smufl_version_orders_the_real_release_sequence` (the + release-order lock: `(1, 20)` no longer sorted before `(1, 3)`) and + `smufl_version_from_decimal_normalizes_one_and_two_digit_minors`. +* **S10**: `PitchSpaceModification::Cents` reverted to `Cents(f64)`. This is + a type-level correction, so the "test" is the type system itself: nine + call sites across `accidental.rs`'s own tests and the codec byte-identity + fixture failed to *compile* against the reverted shape (`Option::map(Cents)` + no longer type-checks; direct `Cents(CanonicalF64::new(...).unwrap())` + construction no longer type-checks) — the strongest possible test failure. +* **Resolution precedence**: `resolve_accidental` mutated to check + `base_registry` first, `additions` second, `overrides` last (precedence + reversed). Killed `resolution_precedence_overrides_beats_additions_beats_base` + (resolved to the base-registry entry, `-1`, instead of the overrides + entry, `-3`). +* **Compatibility check**: `accidental_modification_compatible_with_space` + mutated to `true` unconditionally. Killed five tests at once: + `cmn_chromatic_is_compatible_only_with_diatonic_over_chromatic`, + `edo_steps_is_compatible_with_chromatic_and_registered_not_diatonic`, + `ji_ratio_is_compatible_only_with_ji_lattice`, + `every_modification_kind_fails_closed_on_an_unresolvable_space` (all four + in `accidental.rs`), and — proving the graph-level wiring is load-bearing, + not just the pure predicate — + `invariants::accidental_compatibility_tests::cmn_chromatic_accidental_in_edo_31_fires`. +* **Wire invisibility**: `ScoreTuningContext::enc` mutated to push + `accidental_extensions.len() as u8`, and separately `impl TextValue::project` + mutated to append the same length as a projected field. The binary + mutation killed the new byte-identity test (last byte `1` vs `0`); the + text mutation killed *both* text-projection tests, including the + pre-existing tranche-2 one (`parse` still expects exactly 3 fields, so + even `ScoreTuningContext::default()`'s own round-trip failed to parse a + 4-field list) — confirming the frozen field arity is what both tests + actually enforce. + +**Zero golden or digest movement**, confirmed by the full gate after every +mutation was reverted: `cargo fmt --all --check`, `cargo clippy --workspace +--all-targets` (0 warnings), `cargo test --workspace` (0 failed across every +crate), `RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps` (0 +warnings, after one intra-doc link — `` [`:3160`] `` — was de-linked to a +plain parenthetical citation), `conformance_suite` (8/8), and +`requirement_labels` (6 passed, counts unchanged at 212/282/282) all pass. +No `.tex` file was touched and no requirement was added. diff --git a/crates/epiphany-core/src/accidental.rs b/crates/epiphany-core/src/accidental.rs new file mode 100644 index 0000000..40c69c5 --- /dev/null +++ b/crates/epiphany-core/src/accidental.rs @@ -0,0 +1,715 @@ +//! Chapter 4 accidental, glyph, and engraving vocabulary (`core_spec.tex` +//! §"Accidental Registries", `sec:tuning:accidentals`, `:3054`-`:3234`; §"Glyph +//! References and SMuFL", `sec:tuning:smufl`, `:3235`-`:3277`). +//! +//! **Scope**, per `spec/CONTRACT_PUSH4B_ACCIDENTALS.md` (Push 4b tranche 3a): +//! the type surface, in memory, with two real consumers — +//! [`resolve_accidental`] (accidental resolution, honoring the precedence +//! "an override wins over an addition wins over the base registry") and +//! [`accidental_modification_compatible_with_space`] +//! (`req:tuning:accidental-modification-compatibility`, wired into +//! [`crate::invariants::check_invariants`]). Glyph and engraving metadata +//! ([`GlyphReference`], [`AccidentalEngraving`], [`AccidentalCombination`]) are +//! *carried* by both consumers — resolution returns them as part of the +//! resolved [`AccidentalDefinition`], and the compatibility check reads past +//! them to `modification` — but neither consumer *reads* them for their own +//! sake. Their deep consumer is the engraver, out of `epiphany-core`, a later +//! tranche; this module does not fabricate one to manufacture coverage. +//! +//! **No `Codec` impl exists, or may be added, for anything in this module** +//! (same discipline as `pitch_space.rs` and `tuning.rs`, Ruling C, +//! `spec/PLAN_PUSH4B_TUNING.md`): these types stay in memory this tranche. +//! [`crate::graph::ScoreTuningContext::accidental_extensions`] and `::smufl` +//! reference them without putting them on the wire (schema major 3, Push 4b +//! tranche 3b). +//! +//! ## Three ratified corrections (P13-S10/S11/S12, ratified 2026-07-23) +//! +//! * **S10** — [`PitchSpaceModification::Cents`] carries a +//! [`CanonicalF64`], not a raw `f64`: a raw `f64` is unencodable in +//! canonical state (`serialize.rs:110` decodes floats only through +//! `CanonicalF64::from_le_bytes`; there is no `Codec for f64`). +//! * **S11** — [`AnchorPoint`] is referenced by the specification (`:3166`) +//! but defined nowhere in it, and this crate cannot depend on +//! `epiphany-layout-ir`. It is core-native here, over [`SpaceUnit`], with a +//! pinned frame — see its doc comment. +//! * **S12** — [`SmuflVersion`] stores its minor fraction-normalized to +//! hundredths (`minor_centi`), not literally, so derived `Ord` agrees with +//! SMuFL's real release order. **This is not** +//! `epiphany_layout_ir`'s existing, differently-shaped `SmuflVersion` +//! (`glyph.rs:29`, literal-minor) — see this type's doc comment for why the +//! two are a deliberate, bounded homonym until Push 4b tranche 3b unifies +//! them. + +use core::num::NonZeroU32; + +use epiphany_determinism::CanonicalF64; + +use crate::graph::SpaceUnit; +use crate::pitch::{ + AccidentalGroupId, AccidentalId, AccidentalRegistryId, CustomGlyphId, ModificationRegistryId, + PitchSpaceId, +}; +use crate::pitch_space::{built_in_position_structure, PositionStructure}; + +// =========================================================================== +// Glyph references (Chapter 4 §"Glyph References and SMuFL", `:3244`). +// =========================================================================== + +/// A glyph reference (Chapter 4 §"Glyph References and SMuFL", `:3244`). +/// Recursive, and **this chapter's own** — do not confuse with +/// `epiphany_layout_ir::GlyphReference` (`glyph.rs:50`), a glyph *name* +/// (`Cow<'static, str>`), a rendering/layout concern. Same name, unrelated +/// types (Push 4b Ruling D's correction: "they are homonyms, not shared +/// types"); `epiphany-core` cannot depend on `epiphany-layout-ir` in any +/// case, so within this crate there is no ambiguity. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum GlyphReference { + /// SMuFL codepoint, resolved against the active SMuFL font. + Smufl(u32), + /// Custom glyph defined by the score or a plugin. + Custom(CustomGlyphId), + /// Composite glyph: multiple glyph references rendered as a single + /// accidental. Used for compound HEJI symbols and similar. + Composite(Vec), +} + +// =========================================================================== +// Pitch space modifications (Chapter 4 §"Pitch Space Modifications", `:3097`). +// =========================================================================== + +/// A position modification an accidental applies (Chapter 4 §"Pitch Space +/// Modifications", `:3097`). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum PitchSpaceModification { + /// CMN-style integer offset in chromatic steps of the enclosing pitch + /// space (`req:pitch:alteration-unit`). + CmnChromatic(i8), + /// Integer step offset in an EDO position space. + EdoSteps(i16), + /// Modification expressed as an exact rational ratio. Used for JI + /// accidentals such as the HEJI syntonic-comma symbols (81/80) and + /// septimal-comma symbols (64/63). + JiRatio { + numerator: i32, + denominator: NonZeroU32, + }, + /// Modification expressed in cents. Used for Sagittal-style precise + /// microtonal accidentals. + /// + /// **S10 correction**: `CanonicalF64`, not a raw `f64`, per + /// `req:determinism:canonical-floating-point` ("finite IEEE 754 + /// binary64") — a raw `f64` cannot be canonical state at all, since there + /// is no `Codec for f64` and the byte layer decodes floats only through + /// `CanonicalF64::from_le_bytes`. + Cents(CanonicalF64), + /// Modification defined by a grammar plugin. + Registered(ModificationRegistryId), +} + +// =========================================================================== +// Accidental engraving metadata (Chapter 4 §"Accidental Engraving Metadata", +// `:3150`, `:3159`). +// =========================================================================== + +/// A bounding box over canonical space units, used for engraving metadata +/// that lives in canonical score state (Chapter 4 §"Accidental Engraving +/// Metadata", `:3150`). Distinct from Chapter 7's `BoundingBox` (built on +/// `StaffSpace`, single precision, for the non-canonical resolved-layout +/// cache, `epiphany_layout_ir::spatial::BoundingBox`): this type's edges are +/// [`SpaceUnit`] (`CanonicalF64`), per +/// `req:determinism:canonical-floating-point`. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct EngravingBoundingBox { + pub left: SpaceUnit, + pub right: SpaceUnit, + pub top: SpaceUnit, + pub bottom: SpaceUnit, +} + +/// Where a glyph attaches to the note: typically the geometric center or a +/// custom anchor for compound glyphs (Chapter 4 §"Accidental Engraving +/// Metadata", `:3166`). +/// +/// **S11 correction**: the specification *references* this type (`:3166`, +/// `AccidentalEngraving::anchor`) but never defines it, and `epiphany-core` +/// cannot depend on `epiphany-layout-ir`. Defined core-native here, over +/// [`SpaceUnit`]. +/// +/// **Frame** (ratified alongside S11 — `EngravingBoundingBox` is "relative to +/// the glyph's anchor point" (`:3160`), so the anchor itself needs an +/// unambiguous origin): `x`/`y` are in **canonical space units**, **y-up**, +/// relative to **the glyph's coordinate origin**. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct AnchorPoint { + pub x: SpaceUnit, + pub y: SpaceUnit, +} + +/// Engraving metadata for an accidental (Chapter 4 §"Accidental Engraving +/// Metadata", `:3159`). Reachable from canonical score state — via +/// [`AccidentalDefinition`] inside [`ScoreAccidentalExtensions`], which hangs +/// off [`crate::graph::ScoreTuningContext`] — so every field is +/// canonical-safe: [`EngravingBoundingBox`], [`AnchorPoint`], and +/// `advance_width` are all [`SpaceUnit`] (`CanonicalF64`)-based, never +/// Chapter 7's single-precision `StaffSpace`. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub struct AccidentalEngraving { + /// Bounding box in canonical space units, relative to the glyph's anchor + /// point. + pub bounding_box: EngravingBoundingBox, + /// Where the glyph attaches to the note. + pub anchor: AnchorPoint, + /// Advance width for horizontal spacing computations. + pub advance_width: SpaceUnit, + /// Stacking order when multiple accidentals attach to one note. Lower + /// values are placed closer to the notehead. + pub stacking_order: i32, + /// Whether this glyph should be drawn with parentheses by default (e.g., + /// editorial or cautionary accidentals). + pub default_parenthesized: bool, +} + +// =========================================================================== +// Combination behavior (Chapter 4 §"Combination Behavior", `:3210`). +// =========================================================================== + +/// Combination behavior with other accidentals on the same note (Chapter 4 +/// §"Combination Behavior", `:3210`). Most accidentals do not combine; some +/// systems (HEJI, certain microtonal notations) permit stacking multiple +/// glyphs to express compound modifications. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub enum AccidentalCombination { + /// Stands alone; replaces any prior accidental on the same note. + Solitary, + /// May stack with members of the listed compatibility groups. Stacking + /// order is determined by the engraving metadata. + Stacking { + compatible_groups: Vec, + }, +} + +// =========================================================================== +// Accidental definitions and score-local extensions (Chapter 4 +// §"Accidental Registries" / "Score-Local Extensions", `:3073`, `:3228`). +// =========================================================================== + +/// An accidental: a glyph, a position modification, and engraving metadata +/// (Chapter 4 §"Accidental Registries", `:3073`). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct AccidentalDefinition { + pub id: AccidentalId, + /// Canonical name. Used for serialization, accessibility, and + /// foreign-format export. + pub name: String, + /// Glyph reference, typically SMuFL. + pub glyph: GlyphReference, + /// The position modification this accidental applies. + pub modification: PitchSpaceModification, + /// Engraving metadata. + pub engraving: AccidentalEngraving, + /// Combination behavior with other accidentals. + pub combination: AccidentalCombination, +} + +/// A score-local extension of a referenced accidental registry (Chapter 4 +/// §"Score-Local Extensions", `:3228`). A score MAY extend a referenced +/// accidental registry with additional accidental definitions, provided the +/// registry's `extensible` flag is true (out of this tranche's scope: the +/// registry *body*, `extensible` flag included, is not built here — see +/// [`resolve_accidental`]'s doc comment). "Extensions are stored on the score +/// and override or augment the base registry during resolution" (`:3224`). +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct ScoreAccidentalExtensions { + pub base: AccidentalRegistryId, + pub additions: Vec, + pub overrides: Vec, +} + +// =========================================================================== +// SMuFL versioning (Chapter 4 §"SMuFL Versioning", `:3260`-`:3277`). +// =========================================================================== + +/// A SMuFL version, stored so derived `Ord` agrees with SMuFL's real release +/// history (Chapter 4 §"SMuFL Versioning"). +/// +/// **S12 correction.** SMuFL versions are decimal fractions — 1.12, 1.18, +/// 1.20, 1.3, 1.4, released in that order — so storing the minor *literally* +/// and deriving `Ord` over `(major, minor)` sorts wrong: 1.3 as `(1, 3)` would +/// sort *before* 1.12 as `(1, 12)`, even though 1.12 shipped first. +/// `minor_centi` instead stores the fractional part normalized to hundredths +/// — 1.12 -> `12`, 1.18 -> `18`, 1.20 -> `20`, 1.3 -> `30`, 1.4 -> `40` — so +/// derived `Ord` on `(major, minor_centi)` is correct. +/// +/// Construct through [`SmuflVersion::from_decimal`] rather than building the +/// literal fields directly, so a caller cannot accidentally pass a literal +/// minor digit where a normalized one is required (`from_decimal(1, "3")` and +/// a mistaken direct `SmuflVersion { major: 1, minor_centi: 3 }` would +/// otherwise look interchangeable and are not: the former is 1.3, the latter +/// is nonsensical). +/// +/// **This is not** `epiphany_layout_ir::SmuflVersion` (`glyph.rs:29`, +/// `{ major: u16, minor: u16 }`, literal-minor, load-bearing for +/// `GlyphCatalogIdentity`). That type is Chapter 7's own and stays untouched: +/// unifying the two, and moving `GlyphCatalogIdentity` onto the normalized +/// 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)] +pub struct SmuflVersion { + pub major: u16, + /// The fractional part, normalized to hundredths. **Do not construct + /// this field directly with a literal minor digit** — use + /// [`SmuflVersion::from_decimal`]. + pub minor_centi: u16, +} + +impl SmuflVersion { + /// Builds a `SmuflVersion` from its conventional decimal notation, + /// normalizing the minor digits to hundredths: one digit is scaled by 10 + /// (`from_decimal(1, "4")` -> 1.4 -> `minor_centi: 40`), two digits are + /// taken as-is (`from_decimal(1, "12")` -> 1.12 -> `minor_centi: 12`). + /// + /// `minor_digits` is the literal digit string that would follow the + /// decimal point (so `"4"` for 1.4, `"12"` for 1.12, `"3"` for 1.3) — + /// *not* a numeric value to be stored as-is; `from_decimal(1, "3")` and + /// `from_decimal(1, "30")` both denote 1.3 and produce the same + /// `minor_centi: 30`. Returns `None` if `minor_digits` is empty, longer + /// than two characters, or contains anything but ASCII digits — SMuFL + /// versions in the wild (1.12, 1.18, 1.20, 1.3, 1.4, ...) never need a + /// third fractional digit. + pub fn from_decimal(major: u16, minor_digits: &str) -> Option { + if !(1..=2).contains(&minor_digits.len()) + || !minor_digits.bytes().all(|b| b.is_ascii_digit()) + { + return None; + } + let value: u16 = minor_digits.parse().ok()?; + let minor_centi = if minor_digits.len() == 1 { + value * 10 + } else { + value + }; + Some(SmuflVersion { major, minor_centi }) + } +} + +/// A score's declared SMuFL version requirements (Chapter 4 §"SMuFL +/// Versioning", `:3269`). +/// +/// `req:tuning:smufl-version-fallback`: "Every score MUST declare the SMuFL +/// version it targets" — this struct is that declaration. The requirement's +/// other clause (resolving an absent codepoint MUST produce a deterministic +/// fallback, never a silent failure) is a rendering-time behavior with no +/// consumer in `epiphany-core`; implementing it is the engraver's job, out of +/// this crate, a later tranche — this struct only carries the declaration +/// the engraver will need to consult. +#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)] +pub struct SmuflVersionRequirement { + /// Minimum SMuFL version required by this score. + pub minimum: SmuflVersion, + /// SMuFL version this score was authored against. Used for detecting + /// whether newer glyphs are in use. + pub authored_against: SmuflVersion, +} + +impl Default for SmuflVersionRequirement { + /// `1.4` for both fields — the SMuFL version this repository already + /// targets (`epiphany_layout_ir::glyph::GlyphCatalogIdentity`'s default, + /// `glyph.rs:120`), so this default aligns with what Push 4b tranche 3b + /// will unify against. Used as + /// [`crate::graph::ScoreTuningContext`]'s `smufl` default when decoding a + /// wire stream that predates this field (schema major <3). + fn default() -> Self { + let v1_4 = SmuflVersion::from_decimal(1, "4").expect("1.4 is a valid SMuFL version"); + SmuflVersionRequirement { + minimum: v1_4, + authored_against: v1_4, + } + } +} + +// =========================================================================== +// Consumer (a): accidental resolution (Chapter 4 §"Score-Local Extensions", +// `:3224`). +// =========================================================================== + +/// Resolves an [`AccidentalId`] against a score's accidental extensions, +/// honoring the precedence Chapter 4 states outright (`:3224`): "Extensions +/// are stored on the score and override or augment the base registry during +/// resolution" — an `overrides` entry wins over an `additions` entry wins +/// over the base registry. +/// +/// `base_registry` is the resolved contents of `extensions.base`'s catalog +/// body. `epiphany-core` has no built-in catalog of accidental-registry +/// bodies this tranche (the same deferred-data-catalog discipline as +/// `crate::pitch_space`'s six underdetermined pitch spaces and +/// `crate::tuning`'s partial tuning catalog: inventing registry contents the +/// specification does not pin would be exactly the `NOTEHEAD_ANCHORS` +/// failure this project exists to avoid), so callers supply the base +/// registry's contents directly rather than this function reaching into a +/// catalog that does not exist. +/// +/// Returns `None` when `id` is not found in `overrides`, `additions`, or +/// `base_registry`. +pub fn resolve_accidental<'a>( + base_registry: &'a [AccidentalDefinition], + extensions: &'a ScoreAccidentalExtensions, + id: &AccidentalId, +) -> Option<&'a AccidentalDefinition> { + extensions + .overrides + .iter() + .find(|d| &d.id == id) + .or_else(|| extensions.additions.iter().find(|d| &d.id == id)) + .or_else(|| base_registry.iter().find(|d| &d.id == id)) +} + +// =========================================================================== +// Consumer (b): the modification-compatibility invariant +// (`req:tuning:accidental-modification-compatibility`, `core_spec.tex:3120`). +// =========================================================================== + +/// Whether `modification` is expressible in the interval algebra of `space` +/// (`req:tuning:accidental-modification-compatibility`, `core_spec.tex:3120`: +/// "An accidental's modification MUST be expressible in the interval algebra +/// of every pitch space that references its registry ... Implementations +/// MUST reject scores referencing an accidental in a space whose algebra does +/// not admit the modification"). +/// +/// `space` is resolved structurally against the built-in catalog +/// ([`built_in_position_structure`], Push 4b tranche 1) — the same lookup +/// `Pitch::transposed` uses, so this consumer and that one agree on what +/// "the algebra of a pitch space" means. +/// +/// The requirement states two rules by name — `CmnChromatic` only in spaces +/// "with `DiatonicChromatic` or compatible algebra" (matched here against +/// [`PositionStructure::DiatonicOverChromatic`], the position-structure +/// family that algebra describes); `EdoSteps` only in `Chromatic` or +/// `Registered` spaces — and this tranche's contract directs extending "the +/// same shape" to `JiRatio` <-> [`PositionStructure::JiLattice`] (also +/// admitting `Registered`, matching `EdoSteps`'s explicit inclusion of it). +/// `Cents` and `Registered` modifications have no requirement-stated +/// constraint of their own — inventing one *would be* the `NOTEHEAD_ANCHORS` +/// failure this project has already paid for twice — so they are accepted +/// whenever `space` itself resolves. +/// +/// An unresolvable `space` (an identifier outside the built-in catalog, or +/// one of the six catalog-named-but-underdetermined spaces +/// `built_in_position_structure` deliberately returns `None` for) fails +/// closed for *every* modification kind, `Cents` and `Registered` included: +/// nothing can be shown expressible in an algebra that cannot itself be +/// established (`req:pitch:space-capability-refusal`'s discipline, applied +/// here as tranche 1 applied it to `Pitch::transposed`). +pub fn accidental_modification_compatible_with_space( + modification: &PitchSpaceModification, + space: &PitchSpaceId, +) -> bool { + let Some(structure) = built_in_position_structure(space) else { + return false; + }; + match modification { + PitchSpaceModification::CmnChromatic(_) => { + matches!(structure, PositionStructure::DiatonicOverChromatic { .. }) + } + PitchSpaceModification::EdoSteps(_) => matches!( + structure, + PositionStructure::Chromatic { .. } | PositionStructure::Registered(_) + ), + PitchSpaceModification::JiRatio { .. } => matches!( + structure, + PositionStructure::JiLattice { .. } | PositionStructure::Registered(_) + ), + PitchSpaceModification::Cents(_) | PitchSpaceModification::Registered(_) => true, + } +} + +// =========================================================================== +// Test-only fixtures, shared with `codec.rs`, `textvalue_graph.rs`, and +// `invariants.rs`'s test modules so each does not hand-roll its own minimal +// `AccidentalDefinition`. +// =========================================================================== + +#[cfg(test)] +pub(crate) fn fixture_engraving() -> AccidentalEngraving { + let su = |x: f64| SpaceUnit(CanonicalF64::new(x).expect("test fixture value is finite")); + AccidentalEngraving { + bounding_box: EngravingBoundingBox { + left: su(-0.5), + right: su(0.5), + top: su(1.0), + bottom: su(-1.0), + }, + anchor: AnchorPoint { + x: su(0.0), + y: su(0.0), + }, + advance_width: su(1.0), + stacking_order: 0, + default_parenthesized: false, + } +} + +#[cfg(test)] +pub(crate) fn fixture_definition( + id: &str, + modification: PitchSpaceModification, +) -> AccidentalDefinition { + AccidentalDefinition { + id: AccidentalId::new(id), + name: id.to_string(), + glyph: GlyphReference::Smufl(0xE262), + modification, + engraving: fixture_engraving(), + combination: AccidentalCombination::Solitary, + } +} + +#[cfg(test)] +pub(crate) fn fixture_extensions( + base: &str, + modification: PitchSpaceModification, +) -> ScoreAccidentalExtensions { + ScoreAccidentalExtensions { + base: AccidentalRegistryId::new(base), + additions: vec![fixture_definition("test-accidental", modification)], + overrides: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- S12: SmuflVersion ordering. ----------------------------------- + + #[test] + fn smufl_version_from_decimal_normalizes_one_and_two_digit_minors() { + assert_eq!( + SmuflVersion::from_decimal(1, "4"), + Some(SmuflVersion { + major: 1, + minor_centi: 40 + }) + ); + assert_eq!( + SmuflVersion::from_decimal(1, "3"), + Some(SmuflVersion { + major: 1, + minor_centi: 30 + }) + ); + assert_eq!( + SmuflVersion::from_decimal(1, "12"), + Some(SmuflVersion { + major: 1, + minor_centi: 12 + }) + ); + // "3" and "30" both denote 1.3. + assert_eq!( + SmuflVersion::from_decimal(1, "3"), + SmuflVersion::from_decimal(1, "30") + ); + } + + #[test] + fn smufl_version_from_decimal_rejects_malformed_input() { + assert_eq!(SmuflVersion::from_decimal(1, ""), None); + assert_eq!(SmuflVersion::from_decimal(1, "123"), None); + assert_eq!(SmuflVersion::from_decimal(1, "x"), None); + } + + #[test] + fn smufl_version_orders_the_real_release_sequence() { + // The anchor: this is SMuFL's actual release order. A test that + // would PASS under literal-minor storage (where 1.3 and 1.4 sort + // before 1.12) would not lock S12 at all — see the mutation in + // DECISIONS.md, which reproduces exactly that failure mode and + // confirms this test dies under it. + let v = |minor: &str| SmuflVersion::from_decimal(1, minor).unwrap(); + let sequence = [v("12"), v("18"), v("20"), v("3"), v("4")]; + for pair in sequence.windows(2) { + assert!( + pair[0] < pair[1], + "{:?} did not sort before {:?} in SMuFL's real release order", + pair[0], + pair[1] + ); + } + } + + // --- S10: PitchSpaceModification::Cents. --------------------------- + + #[test] + fn cents_round_trips_a_finite_value_and_guards_non_finite() { + let cents = CanonicalF64::new(23.46) + .map(PitchSpaceModification::Cents) + .expect("23.46 is finite"); + match cents { + PitchSpaceModification::Cents(c) => assert_eq!(c.get(), 23.46), + other => panic!("expected Cents, got {other:?}"), + } + // The S10 guard: a non-finite value can never become a `Cents` + // payload, because `CanonicalF64::new` is the only way to produce + // one and it rejects NaN/infinity outright. + assert!(CanonicalF64::new(f64::NAN) + .map(PitchSpaceModification::Cents) + .is_none()); + assert!(CanonicalF64::new(f64::INFINITY) + .map(PitchSpaceModification::Cents) + .is_none()); + assert!(CanonicalF64::new(f64::NEG_INFINITY) + .map(PitchSpaceModification::Cents) + .is_none()); + } + + // --- Consumer (a): resolution precedence. --------------------------- + + #[test] + fn resolution_precedence_overrides_beats_additions_beats_base() { + let base = vec![fixture_definition( + "flat", + PitchSpaceModification::CmnChromatic(-1), + )]; + let mut extensions = + fixture_extensions("cmn-accidentals", PitchSpaceModification::CmnChromatic(1)); + extensions.additions = vec![fixture_definition( + "flat", + PitchSpaceModification::CmnChromatic(-2), + )]; + extensions.overrides = vec![fixture_definition( + "flat", + PitchSpaceModification::CmnChromatic(-3), + )]; + + let id = AccidentalId::new("flat"); + // Overrides wins over additions wins over base. + let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves"); + assert_eq!( + resolved.modification, + PitchSpaceModification::CmnChromatic(-3), + "an overrides entry must shadow an additions entry for the same id" + ); + + // Drop the overrides entry: additions should now win. + extensions.overrides.clear(); + let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves"); + assert_eq!( + resolved.modification, + PitchSpaceModification::CmnChromatic(-2), + "an additions entry must shadow the base registry for the same id" + ); + + // Drop the additions entry too: an id with no extension resolves to + // the base registry. + extensions.additions.clear(); + let resolved = resolve_accidental(&base, &extensions, &id).expect("resolves"); + assert_eq!( + resolved.modification, + PitchSpaceModification::CmnChromatic(-1), + "a base-registry id with no extension must resolve to the base" + ); + + // An id present nowhere resolves to nothing. + assert!( + resolve_accidental(&base, &extensions, &AccidentalId::new("nonexistent")).is_none() + ); + } + + // --- Consumer (b): the modification-compatibility predicate. ------- + + #[test] + fn cmn_chromatic_is_compatible_only_with_diatonic_over_chromatic() { + let cmn_chromatic = PitchSpaceModification::CmnChromatic(1); + assert!(accidental_modification_compatible_with_space( + &cmn_chromatic, + &PitchSpaceId::new("cmn-12") + )); + // A test that only checked the accept case would miss the reject. + assert!(!accidental_modification_compatible_with_space( + &cmn_chromatic, + &PitchSpaceId::new("edo-31") + )); + } + + #[test] + fn edo_steps_is_compatible_with_chromatic_and_registered_not_diatonic() { + let edo_steps = PitchSpaceModification::EdoSteps(1); + assert!(accidental_modification_compatible_with_space( + &edo_steps, + &PitchSpaceId::new("edo-31") + )); + assert!(!accidental_modification_compatible_with_space( + &edo_steps, + &PitchSpaceId::new("cmn-12") + )); + } + + #[test] + fn ji_ratio_is_compatible_only_with_ji_lattice() { + // None of the built-in `ji-*limit` spaces structurally resolve this + // tranche (`built_in_position_structure` returns `None` for all + // three, tranche 1's honest gap), so both sides of this rule are + // exercised against an unresolvable space and a resolvable + // non-`JiLattice` one. + let ji_ratio = PitchSpaceModification::JiRatio { + numerator: 81, + denominator: NonZeroU32::new(80).unwrap(), + }; + assert!(!accidental_modification_compatible_with_space( + &ji_ratio, + &PitchSpaceId::new("cmn-12") + )); + assert!(!accidental_modification_compatible_with_space( + &ji_ratio, + &PitchSpaceId::new("ji-5limit") + )); + } + + #[test] + fn cents_and_registered_are_unconstrained_on_a_resolved_space() { + let cents = PitchSpaceModification::Cents(CanonicalF64::new(23.46).unwrap()); + let registered = + PitchSpaceModification::Registered(ModificationRegistryId::new("sagittal")); + for space in ["cmn-12", "edo-31"] { + assert!(accidental_modification_compatible_with_space( + ¢s, + &PitchSpaceId::new(space) + )); + assert!(accidental_modification_compatible_with_space( + ®istered, + &PitchSpaceId::new(space) + )); + } + } + + #[test] + fn every_modification_kind_fails_closed_on_an_unresolvable_space() { + let unknown = PitchSpaceId::new("not-a-built-in-space"); + assert!(!accidental_modification_compatible_with_space( + &PitchSpaceModification::CmnChromatic(1), + &unknown + )); + assert!(!accidental_modification_compatible_with_space( + &PitchSpaceModification::EdoSteps(1), + &unknown + )); + assert!(!accidental_modification_compatible_with_space( + &PitchSpaceModification::JiRatio { + numerator: 3, + denominator: NonZeroU32::new(2).unwrap() + }, + &unknown + )); + assert!(!accidental_modification_compatible_with_space( + &PitchSpaceModification::Cents(CanonicalF64::new(1.0).unwrap()), + &unknown + )); + assert!(!accidental_modification_compatible_with_space( + &PitchSpaceModification::Registered(ModificationRegistryId::new("x")), + &unknown + )); + } +} diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index 90263a4..9d3ca06 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -1832,19 +1832,25 @@ struct_codec!(BeatGroup { subdivision, accent }); -// `ScoreTuningContext` gained a fourth, in-memory-only field (`overrides`, -// Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`) that must **not** -// reach the wire: schema major 3 has not been opened. `struct_codec!` cannot -// express that — its generated `dec` ends in a struct literal naming every -// field it was given, so a fourth field either goes on the wire (freezing an -// in-memory-only type before it has a consumer) or the macro cannot build the -// value at all. Hand-written instead: encode/decode exactly the three wire -// fields, in their original order, and construct `overrides: Vec::new()` on -// decode. A round-trip test just below -// (`score_tuning_context_overrides_do_not_reach_the_wire`) proves a non-empty -// `overrides` encodes to the same bytes as an empty one; the matching text- -// projection proof is `textvalue_graph.rs`'s -// `score_tuning_context_round_trips_and_overrides_do_not_project`. +// `ScoreTuningContext` has gained three in-memory-only fields beyond the +// three wire fields — `overrides` (Push 4b tranche 2, +// `spec/CONTRACT_PUSH4B_RESOLVER.md`), then `accidental_extensions` and +// `smufl` (Push 4b tranche 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`) — none +// of which may reach the wire: schema major 3 has not been opened. +// `struct_codec!` cannot express that — its generated `dec` ends in a struct +// literal naming every field it was given, so an in-memory-only field either +// goes on the wire (freezing a type before it has a consumer) or the macro +// cannot build the value at all. Hand-written instead: encode/decode exactly +// the three wire fields, in their original order, and default the other +// three on decode (`overrides: Vec::new()`, `accidental_extensions: +// Vec::new()`, `smufl: SmuflVersionRequirement::default()`). A round-trip +// test just below (`score_tuning_context_overrides_do_not_reach_the_wire`) +// proves a non-empty `overrides` encodes to the same bytes as an empty one; a +// second (`score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire`) +// extends the same proof to all three fields at once. The matching +// text-projection proofs are `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`. impl Codec for ScoreTuningContext { fn enc(&self, out: &mut Vec) { self.default_pitch_space.enc(out); @@ -1859,6 +1865,8 @@ impl Codec for ScoreTuningContext { default_pitch_space, default_tuning_system, reference, + accidental_extensions: Vec::new(), + smufl: crate::accidental::SmuflVersionRequirement::default(), overrides: Vec::new(), }) } @@ -3445,6 +3453,60 @@ mod tests { assert!(decoded.overrides.is_empty()); } + #[test] + fn score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire() { + // The direct analogue of `score_tuning_context_overrides_do_not_reach_the_wire` + // above, extended to all three in-memory-only fields (Push 4b tranche + // 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`): `accidental_extensions` + // and `smufl` join `overrides` off the wire. + use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement}; + use crate::graph::ScoreTuningContext; + use crate::ids::{ReplicaId, VoiceId}; + use crate::pitch::TuningSystemId; + use crate::tuning::{TuningOverride, TuningScope}; + + let bare = ScoreTuningContext::default(); + let mut loaded = ScoreTuningContext::default(); + loaded + .accidental_extensions + .push(crate::accidental::fixture_extensions( + "heji", + PitchSpaceModification::CmnChromatic(1), + )); + loaded.smufl = SmuflVersionRequirement { + minimum: SmuflVersion::from_decimal(1, "12").unwrap(), + authored_against: SmuflVersion::from_decimal(1, "18").unwrap(), + }; + loaded.overrides.push(TuningOverride { + scope: TuningScope::Voice(VoiceId::new(ReplicaId(1), 1)), + pitch_space: None, + tuning_system: Some(TuningSystemId::new("tet-19")), + reference: None, + }); + // Sanity: the fixture actually differs in memory in all three + // fields, so the byte-identity assertion below is not vacuous. + assert_ne!(loaded, bare); + assert!(!loaded.accidental_extensions.is_empty()); + assert_ne!(loaded.smufl, SmuflVersionRequirement::default()); + assert!(!loaded.overrides.is_empty()); + + let mut bytes_loaded = Vec::new(); + loaded.enc(&mut bytes_loaded); + let mut bytes_bare = Vec::new(); + bare.enc(&mut bytes_bare); + assert_eq!( + bytes_loaded, bytes_bare, + "accidental_extensions, smufl, and overrides must not reach canonical bytes \ + (Push 4b tranche 3a)" + ); + + let decoded = ScoreTuningContext::dec(&mut Reader::new(&bytes_loaded)).expect("decodes"); + assert_eq!(decoded, bare); + assert!(decoded.accidental_extensions.is_empty()); + assert_eq!(decoded.smufl, SmuflVersionRequirement::default()); + assert!(decoded.overrides.is_empty()); + } + #[test] fn generator_scores_round_trip() { for seed in 0..200u64 { diff --git a/crates/epiphany-core/src/graph.rs b/crates/epiphany-core/src/graph.rs index b466cb6..7a96575 100644 --- a/crates/epiphany-core/src/graph.rs +++ b/crates/epiphany-core/src/graph.rs @@ -1641,26 +1641,37 @@ pub struct ViewDefinition { /// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline: /// the default pitch space, tuning system, and reference pitch every score must -/// declare; per-scope overrides land here (Push 4b tranche 2), accidental -/// extensions are still deferred. +/// declare; per-scope overrides land here (Push 4b tranche 2); accidental +/// registry extensions and the SMuFL version requirement land here too (Push +/// 4b tranche 3a) — all three in memory only. /// -/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`).** The -/// canonical encoding stays **exactly** `default_pitch_space`, -/// `default_tuning_system`, `reference`, in that order — schema major 3 has -/// not been opened, so `overrides` is *not* on the wire this tranche. See the -/// hand-written `impl Codec` in `codec.rs` and `impl TextValue` in +/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`; tranche +/// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`).** The canonical encoding stays +/// **exactly** `default_pitch_space`, `default_tuning_system`, `reference`, in +/// that order — schema major 3 has not been opened, so `overrides`, +/// `accidental_extensions`, and `smufl` are *not* on the wire this tranche. +/// See the hand-written `impl Codec` in `codec.rs` and `impl TextValue` in /// `textvalue_graph.rs` (replacing the `struct_codec!` this type used to use, -/// which named exactly three fields in its generated decoder and so cannot -/// compile against a fourth). Where `overrides` sits in *this* Rust struct is -/// free — the manual codec fixes the wire order independently of field -/// declaration order — but the specification's eventual major-3 field order -/// places `overrides` last, after `accidental_extensions` and `smufl`; adding -/// those two remains the wire tranche's job, not this one's. +/// which named every field it was given in its generated decoder and so +/// cannot compile against an in-memory-only one). Where the three in-memory +/// fields sit in *this* Rust struct is free — the manual codec fixes the wire +/// order independently of field declaration order — but they are declared +/// here in the specification's eventual major-3 field order +/// (`accidental_extensions`, `smufl`, `overrides`) for readability. Putting +/// all three on the wire in that order is tranche 3b's job, not this one's. #[derive(Clone, PartialEq, Eq, Debug)] pub struct ScoreTuningContext { pub default_pitch_space: PitchSpaceId, 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. + pub accidental_extensions: Vec, + /// 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. + 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 @@ -1676,6 +1687,8 @@ impl Default for ScoreTuningContext { default_pitch_space: PitchSpaceId::new("cmn-12"), default_tuning_system: TuningSystemId::new("tet-12"), reference: ReferencePitch::a440(), + accidental_extensions: Vec::new(), + smufl: crate::accidental::SmuflVersionRequirement::default(), overrides: Vec::new(), } } diff --git a/crates/epiphany-core/src/invariants.rs b/crates/epiphany-core/src/invariants.rs index b00950f..4bf383f 100644 --- a/crates/epiphany-core/src/invariants.rs +++ b/crates/epiphany-core/src/invariants.rs @@ -29,7 +29,7 @@ use crate::graph::{ use crate::ids::{ EventId, MeasureId, PitchId, RegionId, ReplicaId, StaffId, StaffInstanceId, VoiceId, }; -use crate::pitch::{SpellingDirective, SpellingScope}; +use crate::pitch::{PitchSpaceId, SpellingDirective, SpellingScope}; use crate::time::{ AnchorOffset, ConcreteDuration, EventDuration, EventPosition, MusicalPosition, OffsetKind, TimeAnchor, @@ -233,6 +233,7 @@ pub fn check_invariants(score: &Score) -> Vec { idx.check_cross_cutting_refs(&mut v); idx.check_tempo_maps(&mut v); idx.check_aleatoric_models(&mut v); + idx.check_accidental_modification_compatibility(&mut v); idx.check_unique_identifiers(&mut v); idx.check_pitch_id_unique(&mut v); idx.check_spelling_scope_resolves(&mut v); @@ -1414,6 +1415,58 @@ impl<'a> GraphIndex<'a> { } } + // --- Accidental modification / pitch-space compatibility (Chapter 4 + // §"Accidental Registries", `req:tuning:accidental-modification-compatibility`, + // `core_spec.tex:3120`; Push 4b tranche 3a, + // `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`). Not one of the 19 + // spec-enumerated Chapter 5 graph invariants (this is a Chapter 4 + // requirement), so — like the tempo-map and aleatoric-model checks above + // — it is surfaced under an existing `GraphInvariant` tag rather than + // inventing a 20th. `CrossCuttingRefsResolve` is the closest fit: like + // those checks, this is "does this cross-cutting structure's content + // hold against the rest of the score", not a per-event/per-voice + // structural rule. + fn check_accidental_modification_compatibility(&self, out: &mut Vec) { + // Every pitch space the score's tuning context concretely + // references: the default, plus any per-scope override's pitch + // space (`crate::tuning::TuningOverride::pitch_space`). This + // tranche has no built-in catalog linking an `AccidentalRegistryId` + // to the pitch space(s) that declare it as their + // `accidental_registry` (Push 4b tranche 1 built only + // `built_in_position_structure`'s id -> structure map, not a full + // `PitchSpace` catalog with that field populated) — so this is the + // referencing relation the score can actually attest to, honestly, + // rather than inventing catalog data (the `NOTEHEAD_ANCHORS` + // failure this project has already paid for twice). + let mut spaces: BTreeSet<&PitchSpaceId> = BTreeSet::new(); + spaces.insert(&self.score.tuning_context.default_pitch_space); + for ov in &self.score.tuning_context.overrides { + if let Some(space) = &ov.pitch_space { + spaces.insert(space); + } + } + for ext in &self.score.tuning_context.accidental_extensions { + for def in ext.additions.iter().chain(ext.overrides.iter()) { + for space in &spaces { + if !crate::accidental::accidental_modification_compatible_with_space( + &def.modification, + space, + ) { + out.push(InvariantViolation::new( + GraphInvariant::CrossCuttingRefsResolve, + format!( + "accidental {:?} (registry {:?}) modification {:?} is not \ + expressible in pitch space {:?}'s interval algebra \ + (req:tuning:accidental-modification-compatibility)", + def.id, ext.base, def.modification, space + ), + )); + } + } + } + } + } + // --- 11. Identifiers unique within their kind. -------------------------- fn check_unique_identifiers(&self, out: &mut Vec) { let mut regions = BTreeSet::new(); @@ -3949,3 +4002,75 @@ mod review_fix_tests_4 { assert!(fires(&s, GraphInvariant::TupletSum)); } } + +#[cfg(test)] +mod accidental_compatibility_tests { + //! `check_accidental_modification_compatibility` (Push 4b tranche 3a, + //! `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`, proof-of-life item 3): "a + //! `CmnChromatic` accidental in a `cmn-12` (`DiatonicOverChromatic`) + //! space passes; the same accidental in an `edo-31` (`Chromatic`) space + //! is rejected with the violation." + use super::*; + use crate::accidental::{fixture_extensions, PitchSpaceModification}; + use crate::generators::valid_score; + use crate::pitch::PitchSpaceId; + + fn fires(s: &Score, inv: GraphInvariant) -> bool { + !check_invariant(s, inv).is_empty() + } + + #[test] + fn cmn_chromatic_accidental_in_cmn_12_does_not_fire() { + let mut s = valid_score(300); + s.tuning_context.default_pitch_space = PitchSpaceId::new("cmn-12"); + s.tuning_context + .accidental_extensions + .push(fixture_extensions( + "cmn-accidentals", + PitchSpaceModification::CmnChromatic(1), + )); + let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve); + assert!( + violations + .iter() + .all(|v| !v.witness.contains("accidental-modification-compatibility")), + "a CmnChromatic accidental in cmn-12 must not violate the compatibility \ + invariant, got: {violations:?}" + ); + } + + #[test] + fn cmn_chromatic_accidental_in_edo_31_fires() { + // A test that only checked the accept case above would miss this + // reject — the contract's own warning. + let mut s = valid_score(300); + s.tuning_context.default_pitch_space = PitchSpaceId::new("edo-31"); + s.tuning_context + .accidental_extensions + .push(fixture_extensions( + "cmn-accidentals", + PitchSpaceModification::CmnChromatic(1), + )); + assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve)); + let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve); + assert!( + violations + .iter() + .any(|v| v.witness.contains("accidental-modification-compatibility")), + "expected an accidental-modification-compatibility violation, got: {violations:?}" + ); + } + + #[test] + fn a_score_with_no_accidental_extensions_never_fires_this_check() { + // Every generator-produced score has an empty `accidental_extensions` + // (this tranche adds no data to any generator), so the new check must + // be silent across the existing test/property-test corpus. + let s = valid_score(301); + assert!(s.tuning_context.accidental_extensions.is_empty()); + let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve); + assert!(violations + .iter() + .all(|v| !v.witness.contains("accidental-modification-compatibility"))); + } +} diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index 3d9c08d..5a31cff 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -39,6 +39,15 @@ //! [`resolve_pitch_frequency`], the five-scope resolver from a pitch to a //! frequency in Hz. In-memory only, same discipline as `pitch_space` //! (Push 4b Ruling C). +//! * `accidental` — the Chapter 4 accidental/glyph/engraving vocabulary +//! ([`AccidentalDefinition`], [`ScoreAccidentalExtensions`], +//! [`GlyphReference`], [`PitchSpaceModification`], [`AccidentalEngraving`], +//! [`SmuflVersion`], [`SmuflVersionRequirement`]), [`resolve_accidental`] +//! (override > addition > base-registry precedence), and +//! [`accidental_modification_compatible_with_space`] +//! (`req:tuning:accidental-modification-compatibility`, wired into +//! [`check_invariants`]). In-memory only, same discipline as `pitch_space` +//! and `tuning` (Push 4b tranche 3a; the wire tranche is 3b). //! * `event` — the [`Event`] taxonomy and the [`EventArena`] (Chapter 5 //! §"The Event Arena"). //! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct @@ -57,6 +66,7 @@ //! event-arena storage via `slotmap` (decision 2), fully sync (decision 4), //! current stable Rust (decision 5). `unsafe` is forbidden crate-wide. +mod accidental; mod codec; mod event; mod graph; @@ -95,15 +105,16 @@ pub use time::{ }; pub use pitch::{ - canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId, - AcousticPitch, AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId, - IdentifiedPitch, IntervalAlgebraRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, - PitchSpacePosition, PitchSpelling, PositionRegistryId, PositionStructureRegistryId, - ReferencePitch, ScalePosition, SpellingAlgorithmId, SpellingAttachment, SpellingContext, - SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingRenderHints, SpellingRule, - SpellingRuleSetId, SpellingScope, SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, - TieClassRegistryId, TransposeRefusal, TranspositionInterval, TranspositionRegistryId, - TuningFunctionId, TuningReference, TuningSystemId, VoiceSelector, + canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalGroupId, AccidentalId, + AccidentalRegistryId, AcousticPitch, AcousticRealization, CmnNominal, CustomGlyphId, + DecompositionAlgorithmId, ForeignFormatId, IdentifiedPitch, IntervalAlgebraRegistryId, + ModificationRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition, + PitchSpelling, PositionRegistryId, PositionStructureRegistryId, ReferencePitch, ScalePosition, + SpellingAlgorithmId, SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal, + SpellingPrecedence, SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope, + SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId, + TransposeRefusal, TranspositionInterval, TranspositionRegistryId, TuningFunctionId, + TuningReference, TuningSystemId, VoiceSelector, }; pub use pitch_space::{ @@ -111,6 +122,12 @@ pub use pitch_space::{ SpellingParameters, SpellingRuleSet, TranspositionBehavior, }; +pub use accidental::{ + accidental_modification_compatible_with_space, resolve_accidental, AccidentalCombination, + AccidentalDefinition, AccidentalEngraving, AnchorPoint, EngravingBoundingBox, GlyphReference, + PitchSpaceModification, ScoreAccidentalExtensions, SmuflVersion, SmuflVersionRequirement, +}; + pub use prepass::{ derive_annotations, resolve_decomposition, resolve_spelling, simplest_spelling, DerivedAnnotations, PrePassError, PrePassProfile, ResolvedSpelling, SpellingProvenance, diff --git a/crates/epiphany-core/src/pitch.rs b/crates/epiphany-core/src/pitch.rs index 2c7c758..3349df1 100644 --- a/crates/epiphany-core/src/pitch.rs +++ b/crates/epiphany-core/src/pitch.rs @@ -157,6 +157,22 @@ catalog_id!( /// (Chapter 4 §"Transposition Behavior", `TranspositionBehavior::Registered`). TranspositionRegistryId ); +catalog_id!( + /// Identifies a custom (non-SMuFL) glyph (Chapter 4 §"Glyph References + /// and SMuFL", `crate::accidental::GlyphReference::Custom`). + CustomGlyphId +); +catalog_id!( + /// Identifies a registered (grammar-defined) pitch-space modification + /// (Chapter 4 §"Pitch Space Modifications", + /// `crate::accidental::PitchSpaceModification::Registered`). + ModificationRegistryId +); +catalog_id!( + /// Identifies a compatibility group for stacking accidentals (Chapter 4 + /// §"Combination Behavior", `crate::accidental::AccidentalCombination::Stacking`). + AccidentalGroupId +); impl SpellingAlgorithmId { /// The Phase-2 default spelling algorithm, registered under the id diff --git a/crates/epiphany-core/src/textvalue_graph.rs b/crates/epiphany-core/src/textvalue_graph.rs index bcf3a45..1658f67 100644 --- a/crates/epiphany-core/src/textvalue_graph.rs +++ b/crates/epiphany-core/src/textvalue_graph.rs @@ -295,15 +295,18 @@ impl TextValue for TimeSignature { /// )` — exactly the three wire fields, in `fn enc` order /// (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`). /// -/// `ScoreTuningContext` gained a fourth field, `overrides`, that is -/// deliberately **not** part of this projection: it is in-memory only (no -/// schema major 3 has been opened), so it must never reach the wire, and the +/// `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 omits it here would otherwise silently launder a -/// non-empty-`overrides` context into one indistinguishable from an -/// empty-`overrides` context, which is exactly the intended behavior, not an -/// oversight. `parse` always constructs `overrides: Vec::new()`, mirroring -/// `Codec::dec`. +/// 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`. impl TextValue for ScoreTuningContext { fn project(&self) -> Sexp { Sexp::List(vec![ @@ -322,6 +325,8 @@ impl TextValue for ScoreTuningContext { default_pitch_space, default_tuning_system, reference, + accidental_extensions: Vec::new(), + smufl: crate::accidental::SmuflVersionRequirement::default(), overrides: Vec::new(), }) } @@ -956,6 +961,41 @@ mod tests { assert!(parsed.overrides.is_empty()); } + #[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`). + use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement}; + + let mut loaded = ScoreTuningContext::default(); + loaded + .accidental_extensions + .push(crate::accidental::fixture_extensions( + "heji", + PitchSpaceModification::CmnChromatic(1), + )); + loaded.smufl = SmuflVersionRequirement { + minimum: SmuflVersion::from_decimal(1, "12").unwrap(), + authored_against: SmuflVersion::from_decimal(1, "18").unwrap(), + }; + loaded.overrides.push(crate::tuning::TuningOverride { + scope: crate::tuning::TuningScope::Staff(StaffId::new(ReplicaId(1), 1)), + pitch_space: None, + tuning_system: None, + reference: None, + }); + assert_eq!( + loaded.project().render(), + ScoreTuningContext::default().project().render(), + "accidental_extensions, smufl, and overrides must not appear in the text projection" + ); + let parsed = ScoreTuningContext::parse(&loaded.project()).unwrap(); + assert!(parsed.accidental_extensions.is_empty()); + assert_eq!(parsed.smufl, SmuflVersionRequirement::default()); + assert!(parsed.overrides.is_empty()); + } + #[test] fn tagged_unions_round_trip() { round_trip(SpannerKind::Generic);