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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-23 14:48:29 -04:00
parent 6c9d84f6f6
commit b25b8debed
8 changed files with 1227 additions and 44 deletions

View File

@ -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 were de-linked to plain code spans), `conformance_suite` (8/8), and
`requirement_labels` (6 passed, counts unchanged at 212/282/282) all pass. `requirement_labels` (6 passed, counts unchanged at 212/282/282) all pass.
No `.tex` file was touched and no requirement was added. 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<ScoreAccidentalExtensions>` 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<CanonicalF64>::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.

View File

@ -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<GlyphReference>),
}
// ===========================================================================
// 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<AccidentalGroupId>,
},
}
// ===========================================================================
// 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<AccidentalDefinition>,
pub overrides: Vec<AccidentalDefinition>,
}
// ===========================================================================
// 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<Self> {
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(
&cents,
&PitchSpaceId::new(space)
));
assert!(accidental_modification_compatible_with_space(
&registered,
&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
));
}
}

View File

@ -1832,19 +1832,25 @@ struct_codec!(BeatGroup {
subdivision, subdivision,
accent accent
}); });
// `ScoreTuningContext` gained a fourth, in-memory-only field (`overrides`, // `ScoreTuningContext` has gained three in-memory-only fields beyond the
// Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`) that must **not** // three wire fields — `overrides` (Push 4b tranche 2,
// reach the wire: schema major 3 has not been opened. `struct_codec!` cannot // `spec/CONTRACT_PUSH4B_RESOLVER.md`), then `accidental_extensions` and
// express that — its generated `dec` ends in a struct literal naming every // `smufl` (Push 4b tranche 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`) — none
// field it was given, so a fourth field either goes on the wire (freezing an // of which may reach the wire: schema major 3 has not been opened.
// in-memory-only type before it has a consumer) or the macro cannot build the // `struct_codec!` cannot express that — its generated `dec` ends in a struct
// value at all. Hand-written instead: encode/decode exactly the three wire // literal naming every field it was given, so an in-memory-only field either
// fields, in their original order, and construct `overrides: Vec::new()` on // goes on the wire (freezing a type before it has a consumer) or the macro
// decode. A round-trip test just below // cannot build the value at all. Hand-written instead: encode/decode exactly
// (`score_tuning_context_overrides_do_not_reach_the_wire`) proves a non-empty // the three wire fields, in their original order, and default the other
// `overrides` encodes to the same bytes as an empty one; the matching text- // three on decode (`overrides: Vec::new()`, `accidental_extensions:
// projection proof is `textvalue_graph.rs`'s // Vec::new()`, `smufl: SmuflVersionRequirement::default()`). A round-trip
// `score_tuning_context_round_trips_and_overrides_do_not_project`. // 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 { impl Codec for ScoreTuningContext {
fn enc(&self, out: &mut Vec<u8>) { fn enc(&self, out: &mut Vec<u8>) {
self.default_pitch_space.enc(out); self.default_pitch_space.enc(out);
@ -1859,6 +1865,8 @@ impl Codec for ScoreTuningContext {
default_pitch_space, default_pitch_space,
default_tuning_system, default_tuning_system,
reference, reference,
accidental_extensions: Vec::new(),
smufl: crate::accidental::SmuflVersionRequirement::default(),
overrides: Vec::new(), overrides: Vec::new(),
}) })
} }
@ -3445,6 +3453,60 @@ mod tests {
assert!(decoded.overrides.is_empty()); 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] #[test]
fn generator_scores_round_trip() { fn generator_scores_round_trip() {
for seed in 0..200u64 { for seed in 0..200u64 {

View File

@ -1641,26 +1641,37 @@ pub struct ViewDefinition {
/// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline: /// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline:
/// the default pitch space, tuning system, and reference pitch every score must /// the default pitch space, tuning system, and reference pitch every score must
/// declare; per-scope overrides land here (Push 4b tranche 2), accidental /// declare; per-scope overrides land here (Push 4b tranche 2); accidental
/// extensions are still deferred. /// 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 /// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`; tranche
/// canonical encoding stays **exactly** `default_pitch_space`, /// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`).** The canonical encoding stays
/// `default_tuning_system`, `reference`, in that order — schema major 3 has /// **exactly** `default_pitch_space`, `default_tuning_system`, `reference`, in
/// not been opened, so `overrides` is *not* on the wire this tranche. See the /// that order — schema major 3 has not been opened, so `overrides`,
/// hand-written `impl Codec` in `codec.rs` and `impl TextValue` in /// `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, /// `textvalue_graph.rs` (replacing the `struct_codec!` this type used to use,
/// which named exactly three fields in its generated decoder and so cannot /// which named every field it was given in its generated decoder and so
/// compile against a fourth). Where `overrides` sits in *this* Rust struct is /// cannot compile against an in-memory-only one). Where the three in-memory
/// free — the manual codec fixes the wire order independently of field /// fields sit in *this* Rust struct is free — the manual codec fixes the wire
/// declaration order — but the specification's eventual major-3 field order /// order independently of field declaration order — but they are declared
/// places `overrides` last, after `accidental_extensions` and `smufl`; adding /// here in the specification's eventual major-3 field order
/// those two remains the wire tranche's job, not this one's. /// (`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)] #[derive(Clone, PartialEq, Eq, Debug)]
pub struct ScoreTuningContext { pub struct ScoreTuningContext {
pub default_pitch_space: PitchSpaceId, pub default_pitch_space: PitchSpaceId,
pub default_tuning_system: TuningSystemId, pub default_tuning_system: TuningSystemId,
pub reference: ReferencePitch, 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<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.
pub smufl: crate::accidental::SmuflVersionRequirement,
/// Per-scope overrides consulted by the tuning resolver /// Per-scope overrides consulted by the tuning resolver
/// (`crate::tuning::resolve_pitch_frequency`), scopes 2-4 of /// (`crate::tuning::resolve_pitch_frequency`), scopes 2-4 of
/// `req:tuning:tuning-resolution-order`. **In memory only** this /// `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_pitch_space: PitchSpaceId::new("cmn-12"),
default_tuning_system: TuningSystemId::new("tet-12"), default_tuning_system: TuningSystemId::new("tet-12"),
reference: ReferencePitch::a440(), reference: ReferencePitch::a440(),
accidental_extensions: Vec::new(),
smufl: crate::accidental::SmuflVersionRequirement::default(),
overrides: Vec::new(), overrides: Vec::new(),
} }
} }

View File

@ -29,7 +29,7 @@ use crate::graph::{
use crate::ids::{ use crate::ids::{
EventId, MeasureId, PitchId, RegionId, ReplicaId, StaffId, StaffInstanceId, VoiceId, EventId, MeasureId, PitchId, RegionId, ReplicaId, StaffId, StaffInstanceId, VoiceId,
}; };
use crate::pitch::{SpellingDirective, SpellingScope}; use crate::pitch::{PitchSpaceId, SpellingDirective, SpellingScope};
use crate::time::{ use crate::time::{
AnchorOffset, ConcreteDuration, EventDuration, EventPosition, MusicalPosition, OffsetKind, AnchorOffset, ConcreteDuration, EventDuration, EventPosition, MusicalPosition, OffsetKind,
TimeAnchor, TimeAnchor,
@ -233,6 +233,7 @@ pub fn check_invariants(score: &Score) -> Vec<InvariantViolation> {
idx.check_cross_cutting_refs(&mut v); idx.check_cross_cutting_refs(&mut v);
idx.check_tempo_maps(&mut v); idx.check_tempo_maps(&mut v);
idx.check_aleatoric_models(&mut v); idx.check_aleatoric_models(&mut v);
idx.check_accidental_modification_compatibility(&mut v);
idx.check_unique_identifiers(&mut v); idx.check_unique_identifiers(&mut v);
idx.check_pitch_id_unique(&mut v); idx.check_pitch_id_unique(&mut v);
idx.check_spelling_scope_resolves(&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<InvariantViolation>) {
// 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. -------------------------- // --- 11. Identifiers unique within their kind. --------------------------
fn check_unique_identifiers(&self, out: &mut Vec<InvariantViolation>) { fn check_unique_identifiers(&self, out: &mut Vec<InvariantViolation>) {
let mut regions = BTreeSet::new(); let mut regions = BTreeSet::new();
@ -3949,3 +4002,75 @@ mod review_fix_tests_4 {
assert!(fires(&s, GraphInvariant::TupletSum)); 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")));
}
}

View File

@ -39,6 +39,15 @@
//! [`resolve_pitch_frequency`], the five-scope resolver from a pitch to a //! [`resolve_pitch_frequency`], the five-scope resolver from a pitch to a
//! frequency in Hz. In-memory only, same discipline as `pitch_space` //! frequency in Hz. In-memory only, same discipline as `pitch_space`
//! (Push 4b Ruling C). //! (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 //! * `event` — the [`Event`] taxonomy and the [`EventArena`] (Chapter 5
//! §"The Event Arena"). //! §"The Event Arena").
//! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct //! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct
@ -57,6 +66,7 @@
//! event-arena storage via `slotmap` (decision 2), fully sync (decision 4), //! event-arena storage via `slotmap` (decision 2), fully sync (decision 4),
//! current stable Rust (decision 5). `unsafe` is forbidden crate-wide. //! current stable Rust (decision 5). `unsafe` is forbidden crate-wide.
mod accidental;
mod codec; mod codec;
mod event; mod event;
mod graph; mod graph;
@ -95,15 +105,16 @@ pub use time::{
}; };
pub use pitch::{ pub use pitch::{
canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId, canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalGroupId, AccidentalId,
AcousticPitch, AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId, AccidentalRegistryId, AcousticPitch, AcousticRealization, CmnNominal, CustomGlyphId,
IdentifiedPitch, IntervalAlgebraRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, DecompositionAlgorithmId, ForeignFormatId, IdentifiedPitch, IntervalAlgebraRegistryId,
PitchSpacePosition, PitchSpelling, PositionRegistryId, PositionStructureRegistryId, ModificationRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition,
ReferencePitch, ScalePosition, SpellingAlgorithmId, SpellingAttachment, SpellingContext, PitchSpelling, PositionRegistryId, PositionStructureRegistryId, ReferencePitch, ScalePosition,
SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingRenderHints, SpellingRule, SpellingAlgorithmId, SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal,
SpellingRuleSetId, SpellingScope, SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, SpellingPrecedence, SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope,
TieClassRegistryId, TransposeRefusal, TranspositionInterval, TranspositionRegistryId, SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId,
TuningFunctionId, TuningReference, TuningSystemId, VoiceSelector, TransposeRefusal, TranspositionInterval, TranspositionRegistryId, TuningFunctionId,
TuningReference, TuningSystemId, VoiceSelector,
}; };
pub use pitch_space::{ pub use pitch_space::{
@ -111,6 +122,12 @@ pub use pitch_space::{
SpellingParameters, SpellingRuleSet, TranspositionBehavior, 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::{ pub use prepass::{
derive_annotations, resolve_decomposition, resolve_spelling, simplest_spelling, derive_annotations, resolve_decomposition, resolve_spelling, simplest_spelling,
DerivedAnnotations, PrePassError, PrePassProfile, ResolvedSpelling, SpellingProvenance, DerivedAnnotations, PrePassError, PrePassProfile, ResolvedSpelling, SpellingProvenance,

View File

@ -157,6 +157,22 @@ catalog_id!(
/// (Chapter 4 §"Transposition Behavior", `TranspositionBehavior::Registered`). /// (Chapter 4 §"Transposition Behavior", `TranspositionBehavior::Registered`).
TranspositionRegistryId 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 { impl SpellingAlgorithmId {
/// The Phase-2 default spelling algorithm, registered under the id /// The Phase-2 default spelling algorithm, registered under the id

View File

@ -295,15 +295,18 @@ impl TextValue for TimeSignature {
/// <reference>)` — exactly the three wire fields, in `fn enc` order /// <reference>)` — exactly the three wire fields, in `fn enc` order
/// (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`). /// (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`).
/// ///
/// `ScoreTuningContext` gained a fourth field, `overrides`, that is /// `ScoreTuningContext` has gained three fields beyond these three —
/// deliberately **not** part of this projection: it is in-memory only (no /// `overrides` (Push 4b tranche 2), then `accidental_extensions` and `smufl`
/// schema major 3 has been opened), so it must never reach the wire, and the /// (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 /// text projection is the same canonical surface the binary codec is — a
/// value that omits it here would otherwise silently launder a /// value that omitted them here would otherwise silently launder a
/// non-empty-`overrides` context into one indistinguishable from an /// non-empty context into one indistinguishable from an empty one, which is
/// empty-`overrides` context, which is exactly the intended behavior, not an /// exactly the intended behavior, not an oversight. `parse` always
/// oversight. `parse` always constructs `overrides: Vec::new()`, mirroring /// constructs `accidental_extensions: Vec::new()`, `smufl:
/// `Codec::dec`. /// SmuflVersionRequirement::default()`, and `overrides: Vec::new()`,
/// mirroring `Codec::dec`.
impl TextValue for ScoreTuningContext { impl TextValue for ScoreTuningContext {
fn project(&self) -> Sexp { fn project(&self) -> Sexp {
Sexp::List(vec![ Sexp::List(vec![
@ -322,6 +325,8 @@ impl TextValue for ScoreTuningContext {
default_pitch_space, default_pitch_space,
default_tuning_system, default_tuning_system,
reference, reference,
accidental_extensions: Vec::new(),
smufl: crate::accidental::SmuflVersionRequirement::default(),
overrides: Vec::new(), overrides: Vec::new(),
}) })
} }
@ -956,6 +961,41 @@ mod tests {
assert!(parsed.overrides.is_empty()); 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] #[test]
fn tagged_unions_round_trip() { fn tagged_unions_round_trip() {
round_trip(SpannerKind::Generic); round_trip(SpannerKind::Generic);