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:
parent
6c9d84f6f6
commit
b25b8debed
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
¢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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue