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
|
||||
`requirement_labels` (6 passed, counts unchanged at 212/282/282) all pass.
|
||||
No `.tex` file was touched and no requirement was added.
|
||||
|
||||
## Push 4b tranche 3a: the accidental/glyph/engraving vocabulary lands, in memory, with two real consumers
|
||||
|
||||
`spec/CONTRACT_PUSH4B_ACCIDENTALS.md`. Same reversible-first discipline as
|
||||
tranches 1/2/2b: the accidental-registry, glyph-reference, and engraving type
|
||||
surface from Chapter 4 §"Accidental Registries" / "Glyph References and
|
||||
SMuFL" lands in `epiphany-core`, in memory, with two real consumers, and
|
||||
**no `Codec`, no wire movement** — canonical bytes stay byte-identical. This
|
||||
splits tranche 3's full `ScoreTuningContext` completion in two: 3a builds and
|
||||
exercises the shapes while they are still free to change; 3b (a later
|
||||
tranche) puts `accidental_extensions`, `smufl`, and `overrides` on the wire
|
||||
together, opening schema major 3 — an irreversible freeze, so it is not done
|
||||
until the shapes have had a consumer.
|
||||
|
||||
**New module `src/accidental.rs`.** Transcribed field-for-field from
|
||||
`core_spec.tex:3054`-`3277`, in spec order, with three ratified corrections
|
||||
(P13-S10/S11/S12, filed and ratified before dispatch):
|
||||
|
||||
* **S10** — `PitchSpaceModification::Cents(CanonicalF64)`, not `Cents(f64)`:
|
||||
a raw `f64` is unencodable in canonical state (`serialize.rs` decodes
|
||||
floats only through `CanonicalF64::from_le_bytes`; there is no `Codec for
|
||||
f64`). Locked by `accidental::tests::cents_round_trips_a_finite_value_and_guards_non_finite`,
|
||||
which round-trips a finite cents value and shows `CanonicalF64::new` rejects
|
||||
NaN/±infinity outright, so a `Cents` payload can never be non-finite.
|
||||
* **S11** — `AnchorPoint { x: SpaceUnit, y: SpaceUnit }`, defined core-native.
|
||||
The specification references it (`:3166`, `AccidentalEngraving::anchor`)
|
||||
but never defines it, and `epiphany-core` cannot depend on
|
||||
`epiphany-layout-ir`. Doc comment pins the frame ratified alongside S11:
|
||||
canonical space units, y-up, relative to the glyph's coordinate origin
|
||||
(needed because `EngravingBoundingBox` is itself "relative to the glyph's
|
||||
anchor point", `:3160`, so the anchor needs an unambiguous origin of its
|
||||
own).
|
||||
* **S12** — `SmuflVersion { major: u16, minor_centi: u16 }`, the minor stored
|
||||
fraction-normalized to hundredths (1.4 -> 40, 1.3 -> 30, 1.12 -> 12), built
|
||||
only through the checked `SmuflVersion::from_decimal(major, minor_digits)`
|
||||
constructor so a caller cannot pass a literal minor digit by mistake.
|
||||
Locked by `accidental::tests::smufl_version_orders_the_real_release_sequence`,
|
||||
which asserts SMuFL's actual release order (1.12 < 1.18 < 1.20 < 1.3 < 1.4)
|
||||
— a test that would pass under literal-minor storage (where 1.3 and 1.4
|
||||
sort before 1.12) would not lock S12 at all; see the mutation below, which
|
||||
reproduces exactly that failure and confirms this test catches it. **Not**
|
||||
`epiphany_layout_ir::SmuflVersion` (`glyph.rs:29`, literal-minor,
|
||||
load-bearing for `GlyphCatalogIdentity`) — that type is untouched; the two
|
||||
are a deliberate, bounded homonym (`epiphany-core` cannot depend on
|
||||
`epiphany-layout-ir` in any case) until a later tranche unifies them and
|
||||
moves `GlyphCatalogIdentity`, with golden regen.
|
||||
|
||||
Also new: `CustomGlyphId`, `ModificationRegistryId`, `AccidentalGroupId`
|
||||
(`catalog_id!` entries in `pitch.rs`, beside `AccidentalRegistryId`/
|
||||
`AccidentalId`, which already existed). **No `Codec` impl exists for
|
||||
anything in `accidental.rs`.**
|
||||
|
||||
**Two real consumers, so this does not become the `NOTEHEAD_ANCHORS` trap.**
|
||||
|
||||
*(a) `resolve_accidental(base_registry, extensions, id)`* — resolution
|
||||
precedence per `core_spec.tex:3224` ("Extensions are stored on the score and
|
||||
override or augment the base registry during resolution"): an `overrides`
|
||||
entry wins over an `additions` entry wins over the base registry, checked by
|
||||
`accidental::tests::resolution_precedence_overrides_beats_additions_beats_base`
|
||||
across all three tiers plus the not-found case. `base_registry` is supplied
|
||||
by the caller rather than looked up from an in-core catalog: `epiphany-core`
|
||||
has no built-in catalog of accidental-registry *bodies* this tranche (the
|
||||
same deferred-data-catalog discipline tranche 1 applied to the six
|
||||
underdetermined pitch spaces) — inventing one would itself be the
|
||||
`NOTEHEAD_ANCHORS` failure this consumer exists to avoid.
|
||||
|
||||
*(b) `accidental_modification_compatible_with_space(modification, space)`*,
|
||||
wired into `check_invariants` as
|
||||
`GraphIndex::check_accidental_modification_compatibility` — the
|
||||
`req:tuning:accidental-modification-compatibility` invariant
|
||||
(`core_spec.tex:3120`). `space` resolves structurally against
|
||||
`built_in_position_structure` (Push 4b tranche 1), the same catalog
|
||||
`Pitch::transposed` uses. The requirement's two named rules (`CmnChromatic`
|
||||
only in `DiatonicOverChromatic`-shaped spaces; `EdoSteps` only in
|
||||
`Chromatic` or `Registered`) are matched directly against
|
||||
`PositionStructure`; the contract's instruction to "extend the same shape"
|
||||
gives `JiRatio` the identical `JiLattice`-or-`Registered` rule. `Cents` and
|
||||
`Registered` modifications have no requirement-stated constraint, so they
|
||||
are accepted whenever the space itself resolves — inventing a constraint the
|
||||
requirement does not state would be the same failure as inventing a JI
|
||||
generator ratio. An unresolvable space (outside the built-in catalog, or one
|
||||
of the six catalog-named-but-underdetermined ones) fails closed for *every*
|
||||
modification kind, `Cents`/`Registered` included, mirroring tranche 1's
|
||||
`Pitch::transposed` discipline. `check_invariants` folds the result into the
|
||||
existing `GraphInvariant::CrossCuttingRefsResolve` tag rather than inventing
|
||||
a 20th spec-enumerated invariant — the same choice already made for the
|
||||
tempo-map and aleatoric-model checks, since this is a Chapter 4 requirement,
|
||||
not one of the 19 spec-enumerated Chapter 5 graph invariants.
|
||||
|
||||
The check determines "every pitch space that references \[a\] registry"
|
||||
(the requirement's phrase) as every pitch space the score's tuning context
|
||||
concretely names: `default_pitch_space`, plus any per-scope override's
|
||||
`pitch_space` (`crate::tuning::TuningOverride`). `epiphany-core` has no
|
||||
built-in catalog linking an `AccidentalRegistryId` to the pitch space(s)
|
||||
that declare it their `accidental_registry` — tranche 1 built only the
|
||||
id -> `PositionStructure` map, not a populated `PitchSpace` catalog — so
|
||||
this is the referencing relation the score can actually attest to, stated
|
||||
honestly rather than invented. Because every existing generator leaves
|
||||
`accidental_extensions` empty (this tranche adds no test data to any
|
||||
generator), the new check is silent across the entire pre-existing
|
||||
test/property-test corpus — proven directly by
|
||||
`invariants::accidental_compatibility_tests::a_score_with_no_accidental_extensions_never_fires_this_check`.
|
||||
|
||||
**Glyph and engraving metadata are carried, not consumed, in core.**
|
||||
`GlyphReference`, `AccidentalEngraving` (and its `EngravingBoundingBox`/
|
||||
`AnchorPoint`), and `AccidentalCombination` are read by both consumers only
|
||||
incidentally — resolution returns the whole `AccidentalDefinition`, and the
|
||||
compatibility check reads past `engraving`/`glyph`/`combination` straight to
|
||||
`modification`. Their deep consumer is the engraver, out of
|
||||
`epiphany-core`, a later tranche; no in-core consumer was fabricated for
|
||||
them to manufacture coverage.
|
||||
|
||||
**`GlyphReference` is Chapter 4's own, deliberately not unified with
|
||||
`epiphany_layout_ir::GlyphReference`** (`glyph.rs:50`, a glyph *name*,
|
||||
`Cow<'static, str>`, a rendering concern): same name, unrelated types
|
||||
(Ruling D's "correction"). `epiphany-core` cannot depend on
|
||||
`epiphany-layout-ir` in any case, so within this crate there is no
|
||||
ambiguity.
|
||||
|
||||
**`ScoreTuningContext` gains its second and third in-memory-only fields.**
|
||||
`accidental_extensions: Vec<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,
|
||||
accent
|
||||
});
|
||||
// `ScoreTuningContext` gained a fourth, in-memory-only field (`overrides`,
|
||||
// Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`) that must **not**
|
||||
// reach the wire: schema major 3 has not been opened. `struct_codec!` cannot
|
||||
// express that — its generated `dec` ends in a struct literal naming every
|
||||
// field it was given, so a fourth field either goes on the wire (freezing an
|
||||
// in-memory-only type before it has a consumer) or the macro cannot build the
|
||||
// value at all. Hand-written instead: encode/decode exactly the three wire
|
||||
// fields, in their original order, and construct `overrides: Vec::new()` on
|
||||
// decode. A round-trip test just below
|
||||
// (`score_tuning_context_overrides_do_not_reach_the_wire`) proves a non-empty
|
||||
// `overrides` encodes to the same bytes as an empty one; the matching text-
|
||||
// projection proof is `textvalue_graph.rs`'s
|
||||
// `score_tuning_context_round_trips_and_overrides_do_not_project`.
|
||||
// `ScoreTuningContext` has gained three in-memory-only fields beyond the
|
||||
// three wire fields — `overrides` (Push 4b tranche 2,
|
||||
// `spec/CONTRACT_PUSH4B_RESOLVER.md`), then `accidental_extensions` and
|
||||
// `smufl` (Push 4b tranche 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`) — none
|
||||
// of which may reach the wire: schema major 3 has not been opened.
|
||||
// `struct_codec!` cannot express that — its generated `dec` ends in a struct
|
||||
// literal naming every field it was given, so an in-memory-only field either
|
||||
// goes on the wire (freezing a type before it has a consumer) or the macro
|
||||
// cannot build the value at all. Hand-written instead: encode/decode exactly
|
||||
// the three wire fields, in their original order, and default the other
|
||||
// three on decode (`overrides: Vec::new()`, `accidental_extensions:
|
||||
// Vec::new()`, `smufl: SmuflVersionRequirement::default()`). A round-trip
|
||||
// test just below (`score_tuning_context_overrides_do_not_reach_the_wire`)
|
||||
// proves a non-empty `overrides` encodes to the same bytes as an empty one; a
|
||||
// second (`score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire`)
|
||||
// extends the same proof to all three fields at once. The matching
|
||||
// text-projection proofs are `textvalue_graph.rs`'s
|
||||
// `score_tuning_context_round_trips_and_overrides_do_not_project` and
|
||||
// `score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project`.
|
||||
impl Codec for ScoreTuningContext {
|
||||
fn enc(&self, out: &mut Vec<u8>) {
|
||||
self.default_pitch_space.enc(out);
|
||||
|
|
@ -1859,6 +1865,8 @@ impl Codec for ScoreTuningContext {
|
|||
default_pitch_space,
|
||||
default_tuning_system,
|
||||
reference,
|
||||
accidental_extensions: Vec::new(),
|
||||
smufl: crate::accidental::SmuflVersionRequirement::default(),
|
||||
overrides: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
|
@ -3445,6 +3453,60 @@ mod tests {
|
|||
assert!(decoded.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_reach_the_wire() {
|
||||
// The direct analogue of `score_tuning_context_overrides_do_not_reach_the_wire`
|
||||
// above, extended to all three in-memory-only fields (Push 4b tranche
|
||||
// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`): `accidental_extensions`
|
||||
// and `smufl` join `overrides` off the wire.
|
||||
use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement};
|
||||
use crate::graph::ScoreTuningContext;
|
||||
use crate::ids::{ReplicaId, VoiceId};
|
||||
use crate::pitch::TuningSystemId;
|
||||
use crate::tuning::{TuningOverride, TuningScope};
|
||||
|
||||
let bare = ScoreTuningContext::default();
|
||||
let mut loaded = ScoreTuningContext::default();
|
||||
loaded
|
||||
.accidental_extensions
|
||||
.push(crate::accidental::fixture_extensions(
|
||||
"heji",
|
||||
PitchSpaceModification::CmnChromatic(1),
|
||||
));
|
||||
loaded.smufl = SmuflVersionRequirement {
|
||||
minimum: SmuflVersion::from_decimal(1, "12").unwrap(),
|
||||
authored_against: SmuflVersion::from_decimal(1, "18").unwrap(),
|
||||
};
|
||||
loaded.overrides.push(TuningOverride {
|
||||
scope: TuningScope::Voice(VoiceId::new(ReplicaId(1), 1)),
|
||||
pitch_space: None,
|
||||
tuning_system: Some(TuningSystemId::new("tet-19")),
|
||||
reference: None,
|
||||
});
|
||||
// Sanity: the fixture actually differs in memory in all three
|
||||
// fields, so the byte-identity assertion below is not vacuous.
|
||||
assert_ne!(loaded, bare);
|
||||
assert!(!loaded.accidental_extensions.is_empty());
|
||||
assert_ne!(loaded.smufl, SmuflVersionRequirement::default());
|
||||
assert!(!loaded.overrides.is_empty());
|
||||
|
||||
let mut bytes_loaded = Vec::new();
|
||||
loaded.enc(&mut bytes_loaded);
|
||||
let mut bytes_bare = Vec::new();
|
||||
bare.enc(&mut bytes_bare);
|
||||
assert_eq!(
|
||||
bytes_loaded, bytes_bare,
|
||||
"accidental_extensions, smufl, and overrides must not reach canonical bytes \
|
||||
(Push 4b tranche 3a)"
|
||||
);
|
||||
|
||||
let decoded = ScoreTuningContext::dec(&mut Reader::new(&bytes_loaded)).expect("decodes");
|
||||
assert_eq!(decoded, bare);
|
||||
assert!(decoded.accidental_extensions.is_empty());
|
||||
assert_eq!(decoded.smufl, SmuflVersionRequirement::default());
|
||||
assert!(decoded.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generator_scores_round_trip() {
|
||||
for seed in 0..200u64 {
|
||||
|
|
|
|||
|
|
@ -1641,26 +1641,37 @@ pub struct ViewDefinition {
|
|||
|
||||
/// The score's tuning environment (Chapter 4 §"Score Tuning Context"). Baseline:
|
||||
/// the default pitch space, tuning system, and reference pitch every score must
|
||||
/// declare; per-scope overrides land here (Push 4b tranche 2), accidental
|
||||
/// extensions are still deferred.
|
||||
/// declare; per-scope overrides land here (Push 4b tranche 2); accidental
|
||||
/// registry extensions and the SMuFL version requirement land here too (Push
|
||||
/// 4b tranche 3a) — all three in memory only.
|
||||
///
|
||||
/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`).** The
|
||||
/// canonical encoding stays **exactly** `default_pitch_space`,
|
||||
/// `default_tuning_system`, `reference`, in that order — schema major 3 has
|
||||
/// not been opened, so `overrides` is *not* on the wire this tranche. See the
|
||||
/// hand-written `impl Codec` in `codec.rs` and `impl TextValue` in
|
||||
/// **Wire note (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`; tranche
|
||||
/// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`).** The canonical encoding stays
|
||||
/// **exactly** `default_pitch_space`, `default_tuning_system`, `reference`, in
|
||||
/// that order — schema major 3 has not been opened, so `overrides`,
|
||||
/// `accidental_extensions`, and `smufl` are *not* on the wire this tranche.
|
||||
/// See the hand-written `impl Codec` in `codec.rs` and `impl TextValue` in
|
||||
/// `textvalue_graph.rs` (replacing the `struct_codec!` this type used to use,
|
||||
/// which named exactly three fields in its generated decoder and so cannot
|
||||
/// compile against a fourth). Where `overrides` sits in *this* Rust struct is
|
||||
/// free — the manual codec fixes the wire order independently of field
|
||||
/// declaration order — but the specification's eventual major-3 field order
|
||||
/// places `overrides` last, after `accidental_extensions` and `smufl`; adding
|
||||
/// those two remains the wire tranche's job, not this one's.
|
||||
/// which named every field it was given in its generated decoder and so
|
||||
/// cannot compile against an in-memory-only one). Where the three in-memory
|
||||
/// fields sit in *this* Rust struct is free — the manual codec fixes the wire
|
||||
/// order independently of field declaration order — but they are declared
|
||||
/// here in the specification's eventual major-3 field order
|
||||
/// (`accidental_extensions`, `smufl`, `overrides`) for readability. Putting
|
||||
/// all three on the wire in that order is tranche 3b's job, not this one's.
|
||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||
pub struct ScoreTuningContext {
|
||||
pub default_pitch_space: PitchSpaceId,
|
||||
pub default_tuning_system: TuningSystemId,
|
||||
pub reference: ReferencePitch,
|
||||
/// Score-local accidental-registry extensions (Chapter 4 §"Score-Local
|
||||
/// Extensions", `:3228`). **In memory only** this tranche (Push 4b
|
||||
/// tranche 3a) — see the wire note above.
|
||||
pub accidental_extensions: Vec<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
|
||||
/// (`crate::tuning::resolve_pitch_frequency`), scopes 2-4 of
|
||||
/// `req:tuning:tuning-resolution-order`. **In memory only** this
|
||||
|
|
@ -1676,6 +1687,8 @@ impl Default for ScoreTuningContext {
|
|||
default_pitch_space: PitchSpaceId::new("cmn-12"),
|
||||
default_tuning_system: TuningSystemId::new("tet-12"),
|
||||
reference: ReferencePitch::a440(),
|
||||
accidental_extensions: Vec::new(),
|
||||
smufl: crate::accidental::SmuflVersionRequirement::default(),
|
||||
overrides: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ use crate::graph::{
|
|||
use crate::ids::{
|
||||
EventId, MeasureId, PitchId, RegionId, ReplicaId, StaffId, StaffInstanceId, VoiceId,
|
||||
};
|
||||
use crate::pitch::{SpellingDirective, SpellingScope};
|
||||
use crate::pitch::{PitchSpaceId, SpellingDirective, SpellingScope};
|
||||
use crate::time::{
|
||||
AnchorOffset, ConcreteDuration, EventDuration, EventPosition, MusicalPosition, OffsetKind,
|
||||
TimeAnchor,
|
||||
|
|
@ -233,6 +233,7 @@ pub fn check_invariants(score: &Score) -> Vec<InvariantViolation> {
|
|||
idx.check_cross_cutting_refs(&mut v);
|
||||
idx.check_tempo_maps(&mut v);
|
||||
idx.check_aleatoric_models(&mut v);
|
||||
idx.check_accidental_modification_compatibility(&mut v);
|
||||
idx.check_unique_identifiers(&mut v);
|
||||
idx.check_pitch_id_unique(&mut v);
|
||||
idx.check_spelling_scope_resolves(&mut v);
|
||||
|
|
@ -1414,6 +1415,58 @@ impl<'a> GraphIndex<'a> {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Accidental modification / pitch-space compatibility (Chapter 4
|
||||
// §"Accidental Registries", `req:tuning:accidental-modification-compatibility`,
|
||||
// `core_spec.tex:3120`; Push 4b tranche 3a,
|
||||
// `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`). Not one of the 19
|
||||
// spec-enumerated Chapter 5 graph invariants (this is a Chapter 4
|
||||
// requirement), so — like the tempo-map and aleatoric-model checks above
|
||||
// — it is surfaced under an existing `GraphInvariant` tag rather than
|
||||
// inventing a 20th. `CrossCuttingRefsResolve` is the closest fit: like
|
||||
// those checks, this is "does this cross-cutting structure's content
|
||||
// hold against the rest of the score", not a per-event/per-voice
|
||||
// structural rule.
|
||||
fn check_accidental_modification_compatibility(&self, out: &mut Vec<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. --------------------------
|
||||
fn check_unique_identifiers(&self, out: &mut Vec<InvariantViolation>) {
|
||||
let mut regions = BTreeSet::new();
|
||||
|
|
@ -3949,3 +4002,75 @@ mod review_fix_tests_4 {
|
|||
assert!(fires(&s, GraphInvariant::TupletSum));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod accidental_compatibility_tests {
|
||||
//! `check_accidental_modification_compatibility` (Push 4b tranche 3a,
|
||||
//! `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`, proof-of-life item 3): "a
|
||||
//! `CmnChromatic` accidental in a `cmn-12` (`DiatonicOverChromatic`)
|
||||
//! space passes; the same accidental in an `edo-31` (`Chromatic`) space
|
||||
//! is rejected with the violation."
|
||||
use super::*;
|
||||
use crate::accidental::{fixture_extensions, PitchSpaceModification};
|
||||
use crate::generators::valid_score;
|
||||
use crate::pitch::PitchSpaceId;
|
||||
|
||||
fn fires(s: &Score, inv: GraphInvariant) -> bool {
|
||||
!check_invariant(s, inv).is_empty()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmn_chromatic_accidental_in_cmn_12_does_not_fire() {
|
||||
let mut s = valid_score(300);
|
||||
s.tuning_context.default_pitch_space = PitchSpaceId::new("cmn-12");
|
||||
s.tuning_context
|
||||
.accidental_extensions
|
||||
.push(fixture_extensions(
|
||||
"cmn-accidentals",
|
||||
PitchSpaceModification::CmnChromatic(1),
|
||||
));
|
||||
let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve);
|
||||
assert!(
|
||||
violations
|
||||
.iter()
|
||||
.all(|v| !v.witness.contains("accidental-modification-compatibility")),
|
||||
"a CmnChromatic accidental in cmn-12 must not violate the compatibility \
|
||||
invariant, got: {violations:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmn_chromatic_accidental_in_edo_31_fires() {
|
||||
// A test that only checked the accept case above would miss this
|
||||
// reject — the contract's own warning.
|
||||
let mut s = valid_score(300);
|
||||
s.tuning_context.default_pitch_space = PitchSpaceId::new("edo-31");
|
||||
s.tuning_context
|
||||
.accidental_extensions
|
||||
.push(fixture_extensions(
|
||||
"cmn-accidentals",
|
||||
PitchSpaceModification::CmnChromatic(1),
|
||||
));
|
||||
assert!(fires(&s, GraphInvariant::CrossCuttingRefsResolve));
|
||||
let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve);
|
||||
assert!(
|
||||
violations
|
||||
.iter()
|
||||
.any(|v| v.witness.contains("accidental-modification-compatibility")),
|
||||
"expected an accidental-modification-compatibility violation, got: {violations:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_score_with_no_accidental_extensions_never_fires_this_check() {
|
||||
// Every generator-produced score has an empty `accidental_extensions`
|
||||
// (this tranche adds no data to any generator), so the new check must
|
||||
// be silent across the existing test/property-test corpus.
|
||||
let s = valid_score(301);
|
||||
assert!(s.tuning_context.accidental_extensions.is_empty());
|
||||
let violations = check_invariant(&s, GraphInvariant::CrossCuttingRefsResolve);
|
||||
assert!(violations
|
||||
.iter()
|
||||
.all(|v| !v.witness.contains("accidental-modification-compatibility")));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,15 @@
|
|||
//! [`resolve_pitch_frequency`], the five-scope resolver from a pitch to a
|
||||
//! frequency in Hz. In-memory only, same discipline as `pitch_space`
|
||||
//! (Push 4b Ruling C).
|
||||
//! * `accidental` — the Chapter 4 accidental/glyph/engraving vocabulary
|
||||
//! ([`AccidentalDefinition`], [`ScoreAccidentalExtensions`],
|
||||
//! [`GlyphReference`], [`PitchSpaceModification`], [`AccidentalEngraving`],
|
||||
//! [`SmuflVersion`], [`SmuflVersionRequirement`]), [`resolve_accidental`]
|
||||
//! (override > addition > base-registry precedence), and
|
||||
//! [`accidental_modification_compatible_with_space`]
|
||||
//! (`req:tuning:accidental-modification-compatibility`, wired into
|
||||
//! [`check_invariants`]). In-memory only, same discipline as `pitch_space`
|
||||
//! and `tuning` (Push 4b tranche 3a; the wire tranche is 3b).
|
||||
//! * `event` — the [`Event`] taxonomy and the [`EventArena`] (Chapter 5
|
||||
//! §"The Event Arena").
|
||||
//! * `graph` — [`Canvas`], [`Region`], [`Staff`]/[`StaffInstance`] (distinct
|
||||
|
|
@ -57,6 +66,7 @@
|
|||
//! event-arena storage via `slotmap` (decision 2), fully sync (decision 4),
|
||||
//! current stable Rust (decision 5). `unsafe` is forbidden crate-wide.
|
||||
|
||||
mod accidental;
|
||||
mod codec;
|
||||
mod event;
|
||||
mod graph;
|
||||
|
|
@ -95,15 +105,16 @@ pub use time::{
|
|||
};
|
||||
|
||||
pub use pitch::{
|
||||
canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalId, AccidentalRegistryId,
|
||||
AcousticPitch, AcousticRealization, CmnNominal, DecompositionAlgorithmId, ForeignFormatId,
|
||||
IdentifiedPitch, IntervalAlgebraRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId,
|
||||
PitchSpacePosition, PitchSpelling, PositionRegistryId, PositionStructureRegistryId,
|
||||
ReferencePitch, ScalePosition, SpellingAlgorithmId, SpellingAttachment, SpellingContext,
|
||||
SpellingDirective, SpellingNominal, SpellingPrecedence, SpellingRenderHints, SpellingRule,
|
||||
SpellingRuleSetId, SpellingScope, SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId,
|
||||
TieClassRegistryId, TransposeRefusal, TranspositionInterval, TranspositionRegistryId,
|
||||
TuningFunctionId, TuningReference, TuningSystemId, VoiceSelector,
|
||||
canonical_pitch_bytes, derive_system_pitch_id, spell, AccidentalGroupId, AccidentalId,
|
||||
AccidentalRegistryId, AcousticPitch, AcousticRealization, CmnNominal, CustomGlyphId,
|
||||
DecompositionAlgorithmId, ForeignFormatId, IdentifiedPitch, IntervalAlgebraRegistryId,
|
||||
ModificationRegistryId, NominalRegistryId, Pitch, PitchRange, PitchSpaceId, PitchSpacePosition,
|
||||
PitchSpelling, PositionRegistryId, PositionStructureRegistryId, ReferencePitch, ScalePosition,
|
||||
SpellingAlgorithmId, SpellingAttachment, SpellingContext, SpellingDirective, SpellingNominal,
|
||||
SpellingPrecedence, SpellingRenderHints, SpellingRule, SpellingRuleSetId, SpellingScope,
|
||||
SpellingSource, SpellingSourceKind, StaffGroupKindRegistryId, TieClassRegistryId,
|
||||
TransposeRefusal, TranspositionInterval, TranspositionRegistryId, TuningFunctionId,
|
||||
TuningReference, TuningSystemId, VoiceSelector,
|
||||
};
|
||||
|
||||
pub use pitch_space::{
|
||||
|
|
@ -111,6 +122,12 @@ pub use pitch_space::{
|
|||
SpellingParameters, SpellingRuleSet, TranspositionBehavior,
|
||||
};
|
||||
|
||||
pub use accidental::{
|
||||
accidental_modification_compatible_with_space, resolve_accidental, AccidentalCombination,
|
||||
AccidentalDefinition, AccidentalEngraving, AnchorPoint, EngravingBoundingBox, GlyphReference,
|
||||
PitchSpaceModification, ScoreAccidentalExtensions, SmuflVersion, SmuflVersionRequirement,
|
||||
};
|
||||
|
||||
pub use prepass::{
|
||||
derive_annotations, resolve_decomposition, resolve_spelling, simplest_spelling,
|
||||
DerivedAnnotations, PrePassError, PrePassProfile, ResolvedSpelling, SpellingProvenance,
|
||||
|
|
|
|||
|
|
@ -157,6 +157,22 @@ catalog_id!(
|
|||
/// (Chapter 4 §"Transposition Behavior", `TranspositionBehavior::Registered`).
|
||||
TranspositionRegistryId
|
||||
);
|
||||
catalog_id!(
|
||||
/// Identifies a custom (non-SMuFL) glyph (Chapter 4 §"Glyph References
|
||||
/// and SMuFL", `crate::accidental::GlyphReference::Custom`).
|
||||
CustomGlyphId
|
||||
);
|
||||
catalog_id!(
|
||||
/// Identifies a registered (grammar-defined) pitch-space modification
|
||||
/// (Chapter 4 §"Pitch Space Modifications",
|
||||
/// `crate::accidental::PitchSpaceModification::Registered`).
|
||||
ModificationRegistryId
|
||||
);
|
||||
catalog_id!(
|
||||
/// Identifies a compatibility group for stacking accidentals (Chapter 4
|
||||
/// §"Combination Behavior", `crate::accidental::AccidentalCombination::Stacking`).
|
||||
AccidentalGroupId
|
||||
);
|
||||
|
||||
impl SpellingAlgorithmId {
|
||||
/// The Phase-2 default spelling algorithm, registered under the id
|
||||
|
|
|
|||
|
|
@ -295,15 +295,18 @@ impl TextValue for TimeSignature {
|
|||
/// <reference>)` — exactly the three wire fields, in `fn enc` order
|
||||
/// (Push 4b tranche 2, `spec/CONTRACT_PUSH4B_RESOLVER.md`).
|
||||
///
|
||||
/// `ScoreTuningContext` gained a fourth field, `overrides`, that is
|
||||
/// deliberately **not** part of this projection: it is in-memory only (no
|
||||
/// schema major 3 has been opened), so it must never reach the wire, and the
|
||||
/// `ScoreTuningContext` has gained three fields beyond these three —
|
||||
/// `overrides` (Push 4b tranche 2), then `accidental_extensions` and `smufl`
|
||||
/// (Push 4b tranche 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`) — that are
|
||||
/// deliberately **not** part of this projection: all three are in-memory only
|
||||
/// (no schema major 3 has been opened), so none may reach the wire, and the
|
||||
/// text projection is the same canonical surface the binary codec is — a
|
||||
/// value that omits it here would otherwise silently launder a
|
||||
/// non-empty-`overrides` context into one indistinguishable from an
|
||||
/// empty-`overrides` context, which is exactly the intended behavior, not an
|
||||
/// oversight. `parse` always constructs `overrides: Vec::new()`, mirroring
|
||||
/// `Codec::dec`.
|
||||
/// value that omitted them here would otherwise silently launder a
|
||||
/// non-empty context into one indistinguishable from an empty one, which is
|
||||
/// exactly the intended behavior, not an oversight. `parse` always
|
||||
/// constructs `accidental_extensions: Vec::new()`, `smufl:
|
||||
/// SmuflVersionRequirement::default()`, and `overrides: Vec::new()`,
|
||||
/// mirroring `Codec::dec`.
|
||||
impl TextValue for ScoreTuningContext {
|
||||
fn project(&self) -> Sexp {
|
||||
Sexp::List(vec![
|
||||
|
|
@ -322,6 +325,8 @@ impl TextValue for ScoreTuningContext {
|
|||
default_pitch_space,
|
||||
default_tuning_system,
|
||||
reference,
|
||||
accidental_extensions: Vec::new(),
|
||||
smufl: crate::accidental::SmuflVersionRequirement::default(),
|
||||
overrides: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
|
@ -956,6 +961,41 @@ mod tests {
|
|||
assert!(parsed.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn score_tuning_context_accidental_extensions_smufl_and_overrides_do_not_project() {
|
||||
// The direct analogue of `score_tuning_context_round_trips_and_overrides_do_not_project`
|
||||
// above, extended to all three in-memory-only fields (Push 4b tranche
|
||||
// 3a, `spec/CONTRACT_PUSH4B_ACCIDENTALS.md`).
|
||||
use crate::accidental::{PitchSpaceModification, SmuflVersion, SmuflVersionRequirement};
|
||||
|
||||
let mut loaded = ScoreTuningContext::default();
|
||||
loaded
|
||||
.accidental_extensions
|
||||
.push(crate::accidental::fixture_extensions(
|
||||
"heji",
|
||||
PitchSpaceModification::CmnChromatic(1),
|
||||
));
|
||||
loaded.smufl = SmuflVersionRequirement {
|
||||
minimum: SmuflVersion::from_decimal(1, "12").unwrap(),
|
||||
authored_against: SmuflVersion::from_decimal(1, "18").unwrap(),
|
||||
};
|
||||
loaded.overrides.push(crate::tuning::TuningOverride {
|
||||
scope: crate::tuning::TuningScope::Staff(StaffId::new(ReplicaId(1), 1)),
|
||||
pitch_space: None,
|
||||
tuning_system: None,
|
||||
reference: None,
|
||||
});
|
||||
assert_eq!(
|
||||
loaded.project().render(),
|
||||
ScoreTuningContext::default().project().render(),
|
||||
"accidental_extensions, smufl, and overrides must not appear in the text projection"
|
||||
);
|
||||
let parsed = ScoreTuningContext::parse(&loaded.project()).unwrap();
|
||||
assert!(parsed.accidental_extensions.is_empty());
|
||||
assert_eq!(parsed.smufl, SmuflVersionRequirement::default());
|
||||
assert!(parsed.overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tagged_unions_round_trip() {
|
||||
round_trip(SpannerKind::Generic);
|
||||
|
|
|
|||
Loading…
Reference in New Issue