Text Projection: the Chapter-5 value layer, from one field list

`project` and `parse` for every value an operation payload can embed.

The codec macros now emit a `TextValue` impl beside the `Codec` impl, from the
same invocation: 116 types whose field order cannot disagree between the binary
form and the text, at zero call-site churn. That is the companion's own rationale
applied to code -- a rule cannot drift from the listing it reads, and two listings
of one struct is the drift P13-I1 already cost us. `struct_codec!` rebuilds through
a struct literal and `cstyle_enum_codec!` matches exhaustively, so a field or
variant added later fails to compile rather than silently vanishing from the text.

The other 44 types have hand-written codecs and so need hand-written projections.
Their field order is verified by a mechanical diff of the identifier sequence in
each `fn enc` against the one in each `project`; all 44 agree. This matters because
a `project`/`parse` pair that agrees with itself on a *wrong* order round-trips
perfectly -- neither the compiler nor any round-trip test can see it. The
neighbouring blind spot, a mistyped constructor symbol, is closed by
`textvalue_names.rs`, which recovers each type's Rust name from its derived `Debug`
and compares it against the symbol actually emitted.

Strictness turned out to need only one of its two layers, and mutation testing is
what established that. Every per-site check is live: the set/map strictly-increasing
walk, `RationalTime`'s lowest-terms compare before construction, the catalog-id NFC
intern-and-compare, and `EventArena`'s ascending-`EventId` walk. Every whole-value
`ensure_canonical` guard was dead -- `Tempo::new`, `ReferencePitch::new`,
`SpellingPrecedence::new` and `EventOrderingDAG::try_new` reject rather than adjust,
so an accepted value re-projects to exactly its input and the guard could never
fire. A probe confirmed `try_new` returns its input map unchanged. Helper and all
four call sites removed: a check that cannot fail invites weakening the real one.

Also moves `catalog_name` out of the grammar *test* and into
`operation_kind_tag_vocabulary!`, where the discriminant and decoder already live.
It was a hand-maintained list parallel to an enum -- the exact shape that has cost
this project four bugs.

Method note recorded in DECISIONS: the work list came from compiler errors, but the
compiler reports only the frontier. `AnchorOffset`, `VoiceSelector`, `PowerOfTwo`,
`OctaveOffset` and `NonZeroU16` were each hidden behind a type that had not compiled
yet, so the list must be iterated to a fixpoint.

Gate green -- clippy 0, 1109 tests, doc 0, conformance 8/8, no golden churn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-21 12:17:49 -04:00
parent 914143c6db
commit cf81074ca0
14 changed files with 3943 additions and 103 deletions

View File

@ -589,3 +589,71 @@ Two further claims from the Push-4a audit are **unverified** and should be
checked, not inherited: that the JI dimension convention conflicts with its own
prime-2 requirement, and that the named historical tunings lack exact
deterministic ratio data. Neither was needed for 4a, and neither was confirmed.
## The Text Projection value layer (`textvalue*.rs`)
The Chapter-5 half of the Text Projection companion: `project` and `parse` for
every value an operation payload can embed.
**One field list drives both forms.** `struct_codec!`, `unit_codec!`,
`cstyle_enum_codec!` and `catalog_id_codec!` now emit a `TextValue` impl beside
the `Codec` impl, from the *same* invocation — 116 types whose field order cannot
disagree between the binary form and the text, at zero call-site churn. This is
the companion's own rationale applied to code: *a rule cannot drift from the
listing it reads*, and two listings of one struct is the drift this project has
already been bitten by (P13-I1). The `struct_codec!` expansion rebuilds through a
struct literal and `cstyle_enum_codec!` matches exhaustively, so a field or
variant added later **fails to compile** rather than silently vanishing from the
text.
The remaining 44 types have hand-written `Codec` impls and so need hand-written
projections. Their field order was verified by a **mechanical diff** of the
identifier sequence in each `fn enc` against the one in each `project`; all 44
agree. Six apparent mismatches were regex artifacts — single-field variants whose
binding is named differently on each side, and `.iter().map(…)` forms the pattern
missed — each checked by hand.
**Strictness is per-site, and the whole-value layer turned out to be dead.**
The binary decoders enforce `req:binfmt`-style canonicality in two layers: a
re-encode-and-compare guard, plus per-site checks for the order-preserving fields
that guard is blind to. The text layer was built the same way, and then
mutation-tested. The result:
| check | verdict |
|---|---|
| set / map strictly-increasing walk | **live** |
| `RationalTime` lowest-terms compare before construction | **live** |
| catalog-id NFC intern-and-compare | **live** |
| `EventArena` ascending-`EventId` walk | **live** |
| `ensure_canonical` on `Tempo` | dead — removed |
| `ensure_canonical` on `ReferencePitch` | dead — removed |
| `ensure_canonical` on `SpellingPrecedence` | dead — removed |
| `ensure_canonical` on `EventOrderingDAG` | dead — removed |
A whole-value guard can only fire when a parse **normalizes**. Every Chapter-5
constructor that normalizes (`RationalTime::new` reduces, `X::new` folds to NFC,
`EventArena::insert` re-sorts, `BTreeSet`/`BTreeMap` re-sort) needed a check that
*names the fault* anyway. The four constructors left — `Tempo::new`,
`ReferencePitch::new`, `SpellingPrecedence::new`, `EventOrderingDAG::try_new`
**reject rather than adjust**, so an accepted value re-projects to exactly its
input and the guard could never fire. A probe confirmed `try_new` returns its
input map unchanged. The helper and all four call sites were removed: *a check
that cannot fail is worse than no check, because it invites weakening the real
one* — the same finding as the reader's two diagnostic-only branches.
**What no round-trip test can see.** Two blind spots, both closed elsewhere:
1. *Field order.* A `project`/`parse` pair that agrees with itself on a wrong
order round-trips perfectly, and two adjacent same-typed fields swapped in both
directions are invisible to the compiler too. Closed by construction for the
116 macro types and by the mechanical diff for the 44 hand-written ones.
2. *Constructor names.* `Sexp::sym("measured-fracton")` round-trips, because
`parse` reads back the same wrong symbol `project` wrote. Closed by
`tests/textvalue_names.rs`, which recovers each type's Rust name from its
derived `Debug` and compares it to the symbol actually emitted.
**One method error worth recording.** The work list came from `cargo check`
errors, but the compiler reports only the *frontier*`AnchorOffset`,
`VoiceSelector`, `PowerOfTwo`, `OctaveOffset` and `NonZeroU16` were each hidden
behind a type that had not compiled yet. The list has to be iterated to a
fixpoint, never taken once.

View File

@ -516,10 +516,34 @@ macro_rules! struct_codec {
Ok($ty { $($field),* })
}
}
impl crate::textvalue::TextValue for $ty {
fn project(&self) -> crate::textvalue::Sexp {
let $ty { $($field),* } = self;
crate::textvalue::Sexp::List(vec![
crate::textvalue::Sexp::Symbol(crate::textvalue::kebab(stringify!($ty))),
$( crate::textvalue::TextValue::project($field), )*
])
}
fn parse(
s: &crate::textvalue::Sexp,
) -> core::result::Result<Self, crate::textvalue::TextError> {
const ARITY: usize = [$(stringify!($field)),*].len();
let fields = s.expect_struct(&crate::textvalue::kebab(stringify!($ty)), ARITY)?;
let mut next = fields.iter();
$( let $field = crate::textvalue::TextValue::parse(
next.next().expect("arity checked by expect_struct"))?; )*
Ok($ty { $($field),* })
}
}
};
}
/// [`Codec`] for a zero-field unit struct: no bytes.
/// [`Codec`] for a zero-field unit struct: no bytes. And its [`TextValue`]: the
/// bare symbol, as a fieldless variant is. It encodes to no bytes and carries no
/// value in the text either (`req:textproj:value-projection` clause 1).
///
/// [`TextValue`]: crate::textvalue::TextValue
macro_rules! unit_codec {
($($ty:ident),* $(,)?) => {
$(
@ -529,11 +553,30 @@ macro_rules! unit_codec {
Ok($ty)
}
}
impl crate::textvalue::TextValue for $ty {
fn project(&self) -> crate::textvalue::Sexp {
crate::textvalue_impls::project_unit(stringify!($ty))
}
fn parse(
s: &crate::textvalue::Sexp,
) -> core::result::Result<Self, crate::textvalue::TextError> {
crate::textvalue_impls::parse_unit(s, stringify!($ty)).map(|()| $ty)
}
}
)*
};
}
/// [`Codec`] for a fieldless ("C-like") enum: a single discriminant byte.
/// [`Codec`] for a fieldless ("C-like") enum: a single discriminant byte. And its
/// [`TextValue`]: the variant name as a bare symbol.
///
/// Both `match`es are exhaustive over the enum, so a variant added to the type and
/// not to this invocation **fails to compile** — the guarantee
/// `operation_kind_tag_vocabulary!` gives the operation decoder, here for every
/// C-like enum in Chapter 5.
///
/// [`TextValue`]: crate::textvalue::TextValue
macro_rules! cstyle_enum_codec {
($ty:ident { $($tag:literal => $variant:ident),* $(,)? }) => {
impl Codec for $ty {
@ -548,6 +591,30 @@ macro_rules! cstyle_enum_codec {
}
}
}
impl crate::textvalue::TextValue for $ty {
fn project(&self) -> crate::textvalue::Sexp {
let variant = match self { $( $ty::$variant => stringify!($variant), )* };
crate::textvalue::Sexp::Symbol(crate::textvalue::kebab(variant))
}
fn parse(
s: &crate::textvalue::Sexp,
) -> core::result::Result<Self, crate::textvalue::TextError> {
let name = s.as_symbol().ok_or(crate::textvalue::TextError::Expected {
expected: "symbol",
found: crate::textvalue_impls::class_of(s),
})?;
$(
if name == crate::textvalue::kebab(stringify!($variant)) {
return Ok($ty::$variant);
}
)*
Err(crate::textvalue::TextError::UnknownConstructor {
type_name: stringify!($ty),
found: name.to_owned(),
})
}
}
};
}
@ -564,6 +631,21 @@ macro_rules! catalog_id_codec {
Ok($ty::new(String::dec(r)?))
}
}
// A catalog id is canonical text, so it projects as a quoted string.
// `new` folds to NFC, so `parse` interns and then *compares*: returning
// the folded value would accept a non-NFC spelling and silently
// normalize it (`req:textproj:strict-parse`).
impl crate::textvalue::TextValue for $ty {
fn project(&self) -> crate::textvalue::Sexp {
crate::textvalue::Sexp::Str(self.as_str().to_owned())
}
fn parse(
s: &crate::textvalue::Sexp,
) -> core::result::Result<Self, crate::textvalue::TextError> {
crate::textvalue_impls::parse_catalog_id(s, $ty::new, |v| v.as_str())
}
}
)*
};
}

View File

@ -53,6 +53,11 @@ mod indexes;
mod invariants;
mod pitch;
mod tempo;
mod textvalue_event;
mod textvalue_graph;
mod textvalue_impls;
mod textvalue_pitch;
mod textvalue_time;
mod time;
pub mod fuzz;

View File

@ -16,26 +16,36 @@
//! [`Sexp::Symbol`]. It has no `Ratio` or `Option` variant for the same reason:
//! both are lists.
//!
//! # Strictness has two layers, as it does in the binary form
//! # Strictness is per-site, and that is a finding, not a shortcut
//!
//! `req:textproj:strict-parse` forbids normalizing. The Binary Format companion
//! learned this the expensive way (see `epiphany-bundle/DECISIONS.md`), and the
//! same two layers apply here:
//! `req:textproj:strict-parse` forbids normalizing. The binary decoders enforce
//! the same rule in two layers: a whole-value re-encode-and-compare guard, plus
//! per-site checks for the order-preserving fields that guard is blind to (see
//! `epiphany-bundle/DECISIONS.md`).
//!
//! 1. A **whole-value re-project-and-compare guard** at the public parse boundary.
//! It is complete for everything a typed parse *normalizes* — a re-sorted
//! `BTreeSet`, a reduced rational, an NFC-folded catalog id — because the
//! normalized value projects back to different text than it was given.
//! The text projection was built the same way and **the whole-value layer turned
//! out to be dead here.** A re-project-and-compare guard can only fire when a
//! parse *normalizes*, and in Chapter 5 every constructor that could normalize
//! needed a check that names the fault anyway:
//!
//! 2. It is **blind to anything a typed parse accepts verbatim**, which is every
//! order-preserving sequence. Where the binary form constrains a `Vec`'s order
//! (the frozen `Transpose`'s non-decreasing `targets`), the text must carry the
//! same **per-site check**. A guard cannot see an order it faithfully preserves.
//! * a set or map re-sorts and de-duplicates, so `parse` walks the elements and
//! rejects the first that does not strictly increase;
//! * `RationalTime::new` reduces, so `parse` compares against `BigRational::new`'s
//! canonical form *before* constructing;
//! * a catalog id folds to NFC, so `parse` interns and then compares;
//! * `EventArena::insert` re-sorts, so `parse` checks ascending `EventId` itself.
//!
//! The parse functions in this module take layer 1 seriously: they never
//! normalize silently. [`TextValue::parse`] for a set rejects an out-of-order or
//! duplicate element rather than absorbing it, and a catalog id is built and then
//! compared against its input rather than folded to NFC.
//! Every one of those is mutation-verified live. The remaining validating
//! constructors — `Tempo::new`, `ReferencePitch::new`, `SpellingPrecedence::new`,
//! `EventOrderingDAG::try_new` — *reject* rather than adjust, so an accepted value
//! re-projects to exactly its input and a whole-value guard could never fire. Four
//! such guards were written, mutation-tested, found dead, and removed. A check
//! that cannot fail is worse than no check: it invites weakening the real one.
//!
//! The blind spot the binary form documents still applies and still needs naming:
//! where a `Vec`'s order is constrained (the frozen `Transpose`'s non-decreasing
//! `targets`), only a per-site check can see it. Nothing in Chapter 5 constrains a
//! `Vec` that way; the operation payloads do.
use core::fmt;
use std::collections::{BTreeMap, BTreeSet};
@ -458,17 +468,30 @@ impl std::error::Error for TextError {}
impl Sexp {
/// Asserts this node is a list of exactly `arity` elements headed by the
/// symbol `name`, and returns the fields after the head.
///
/// A struct with zero fields never reaches here: it is the bare symbol
/// `name`, as a fieldless variant is.
pub fn expect_struct(&self, name: &str, arity: usize) -> Result<&[Sexp], TextError> {
let items = self.as_list().ok_or(TextError::Expected {
expected: "struct",
found: self.class(),
})?;
let head = items.first().and_then(Sexp::as_symbol);
if head != Some(name) {
return Err(TextError::Syntax("struct head is not the type name"));
match items.first().and_then(Sexp::as_symbol) {
Some(head) if head == name => {}
Some(head) => {
return Err(TextError::UnknownConstructor {
type_name: "struct",
found: head.to_owned(),
})
}
None => return Err(TextError::Syntax("a struct is headed by its type name")),
}
if items.len() != arity + 1 {
return Err(TextError::Syntax("struct has the wrong field count"));
return Err(TextError::Arity {
type_name: "struct",
expected: arity,
found: items.len().saturating_sub(1),
});
}
Ok(&items[1..])
}
@ -654,6 +677,25 @@ impl<T: TextValue + Ord> TextValue for BTreeSet<T> {
}
}
/// A pair projects as a two-element list, exactly as a map entry does — the
/// binary form writes its two components in order and adds nothing, so neither
/// does the text.
impl<A: TextValue, B: TextValue> TextValue for (A, B) {
fn project(&self) -> Sexp {
Sexp::List(vec![self.0.project(), self.1.project()])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let items = s.as_list().ok_or(TextError::Expected {
expected: "pair",
found: s.class(),
})?;
let [first, second] = items else {
return Err(TextError::Syntax("a pair is `(<first> <second>)`"));
};
Ok((A::parse(first)?, B::parse(second)?))
}
}
/// A map projects as a list of `(<key> <value>)` entries, strictly increasing by
/// key, and parses the same. Same reasoning as [`BTreeSet`].
impl<K: TextValue + Ord, V: TextValue> TextValue for BTreeMap<K, V> {

View File

@ -0,0 +1,587 @@
//! [`TextValue`] for the event taxonomy and the event arena (Chapter 5).
//!
//! The seven payload records — [`PitchedEvent`], [`UnpitchedEvent`], [`Rest`],
//! [`IndeterminateEvent`], [`TrajectoryEvent`], [`GraphicEvent`], [`CueEvent`] —
//! are `struct_codec!` types and get their projection from the same macro that
//! gives them their binary codec (see `codec.rs`), so their field order cannot
//! drift from the bytes. This module supplies what those macros cannot: the two
//! placeholder newtypes, the four hand-written tagged unions, the [`Event`] union
//! that dispatches over the seven records, and the [`EventArena`].
//!
//! Every field order below mirrors the matching `impl Codec for …` `fn enc` in
//! `codec.rs` exactly (`req:textproj:value-projection` clause 1).
//!
//! The arena is the one place a `parse` must add a **per-site order check**:
//! the binary form writes it in ascending `EventId` order but rebuilds it through
//! [`EventArena::insert`], which accepts any order and re-sorts on the way out.
//! Returning such an arena would launder a mis-ordered text into a canonical value
//! — the normalization `req:textproj:strict-parse` forbids.
use crate::event::{
ArenaError, CueEvent, Event, EventArena, GraceKind, GraphicEvent, IndeterminacyKind,
IndeterminateEvent, PitchedEvent, Rest, StaffPosition, TrajectoryEndpoint, TrajectoryEvent,
TrajectoryShape, UnpitchedEvent, UnpitchedMemberId,
};
use crate::ids::{EventId, PitchId};
use crate::pitch::IdentifiedPitch;
use crate::textvalue::{Sexp, TextError, TextValue};
use crate::textvalue_impls::class_of;
use crate::time::MusicalDuration;
// ===========================================================================
// Helpers shared by the tagged unions.
// ===========================================================================
/// The single field of a one-field variant `(<name> <field>)`, after checking
/// the head symbol and the arity. `expect_struct` guarantees exactly one field
/// remains, so indexing it cannot be out of range.
fn one_field<'a>(s: &'a Sexp, name: &str) -> Result<&'a Sexp, TextError> {
let fields = s.expect_struct(name, 1)?;
Ok(&fields[0])
}
/// The head symbol of a list `(<head> …)`, for a union whose every variant
/// carries fields. Rejects a non-list and a list not headed by a symbol.
fn head_symbol<'a>(s: &'a Sexp, type_name: &'static str) -> Result<&'a str, TextError> {
let items = s.as_list().ok_or(TextError::Expected {
expected: type_name,
found: class_of(s),
})?;
items
.first()
.and_then(Sexp::as_symbol)
.ok_or(TextError::Syntax(
"a tagged union is a list headed by its variant name",
))
}
// ===========================================================================
// Placeholder newtypes.
// ===========================================================================
/// A staff position is its inner `i16` alone, with no wrapper
/// (`req:textproj:value-projection` clause 2): the binary form writes the field
/// and adds no bytes for the newtype, and the text adds no wrapper either.
impl TextValue for StaffPosition {
fn project(&self) -> Sexp {
TextValue::project(&self.0)
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
<i16 as TextValue>::parse(s).map(StaffPosition)
}
}
/// An unpitched member id is its inner `u32` alone; a transparent newtype, as
/// above.
impl TextValue for UnpitchedMemberId {
fn project(&self) -> Sexp {
TextValue::project(&self.0)
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
<u32 as TextValue>::parse(s).map(UnpitchedMemberId)
}
}
// ===========================================================================
// Tagged unions.
// ===========================================================================
/// `impl Codec for GraceKind` writes a discriminant byte, then the fraction's
/// bytes only for `MeasuredFraction`. So the three fieldless kinds are bare
/// symbols and the fourth is `(measured-fraction <duration>)`
/// (`req:textproj:value-projection` clause 3).
///
/// A fieldless kind spelled as a list, or `measured-fraction` spelled bare, is a
/// non-canonical spelling of the same value: the `Symbol`/`List` split rejects
/// each rather than accepting it, so no two texts denote one kind.
impl TextValue for GraceKind {
fn project(&self) -> Sexp {
match self {
GraceKind::Acciaccatura => Sexp::sym("acciaccatura"),
GraceKind::Appoggiatura => Sexp::sym("appoggiatura"),
GraceKind::Unmeasured => Sexp::sym("unmeasured"),
GraceKind::MeasuredFraction(d) => {
Sexp::List(vec![Sexp::sym("measured-fraction"), d.project()])
}
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match s {
Sexp::Symbol(name) => match name.as_str() {
"acciaccatura" => Ok(GraceKind::Acciaccatura),
"appoggiatura" => Ok(GraceKind::Appoggiatura),
"unmeasured" => Ok(GraceKind::Unmeasured),
found => Err(TextError::UnknownConstructor {
type_name: "GraceKind",
found: found.to_owned(),
}),
},
Sexp::List(_) => Ok(GraceKind::MeasuredFraction(MusicalDuration::parse(
one_field(s, "measured-fraction")?,
)?)),
_ => Err(TextError::Expected {
expected: "GraceKind",
found: class_of(s),
}),
}
}
}
/// `impl Codec for IndeterminacyKind` mirrors [`GraceKind`]: three fieldless
/// kinds as bare symbols and `Compound` carrying a nested sequence, so the text
/// is `pitch` / `duration` / `choice` / `(compound (<kind>…))`. The `Compound`
/// sequence is itself an [`IndeterminacyKind`] list, and the generic `Vec` impl
/// carries it recursively.
impl TextValue for IndeterminacyKind {
fn project(&self) -> Sexp {
match self {
IndeterminacyKind::Pitch => Sexp::sym("pitch"),
IndeterminacyKind::Duration => Sexp::sym("duration"),
IndeterminacyKind::Choice => Sexp::sym("choice"),
IndeterminacyKind::Compound(v) => Sexp::List(vec![Sexp::sym("compound"), v.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match s {
Sexp::Symbol(name) => match name.as_str() {
"pitch" => Ok(IndeterminacyKind::Pitch),
"duration" => Ok(IndeterminacyKind::Duration),
"choice" => Ok(IndeterminacyKind::Choice),
found => Err(TextError::UnknownConstructor {
type_name: "IndeterminacyKind",
found: found.to_owned(),
}),
},
Sexp::List(_) => Ok(IndeterminacyKind::Compound(
Vec::<IndeterminacyKind>::parse(one_field(s, "compound")?)?,
)),
_ => Err(TextError::Expected {
expected: "IndeterminacyKind",
found: class_of(s),
}),
}
}
}
/// `impl Codec for TrajectoryEndpoint` writes a discriminant then a field for
/// both variants — a [`PitchId`] for `EventPitch`, an [`IdentifiedPitch`] for
/// `ExplicitPitch` — so every spelling is a list and the head symbol selects the
/// variant.
impl TextValue for TrajectoryEndpoint {
fn project(&self) -> Sexp {
match self {
TrajectoryEndpoint::EventPitch(id) => {
Sexp::List(vec![Sexp::sym("event-pitch"), id.project()])
}
TrajectoryEndpoint::ExplicitPitch(p) => {
Sexp::List(vec![Sexp::sym("explicit-pitch"), p.project()])
}
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match head_symbol(s, "TrajectoryEndpoint")? {
"event-pitch" => Ok(TrajectoryEndpoint::EventPitch(PitchId::parse(one_field(
s,
"event-pitch",
)?)?)),
"explicit-pitch" => Ok(TrajectoryEndpoint::ExplicitPitch(IdentifiedPitch::parse(
one_field(s, "explicit-pitch")?,
)?)),
found => Err(TextError::UnknownConstructor {
type_name: "TrajectoryEndpoint",
found: found.to_owned(),
}),
}
}
}
/// `impl Codec for TrajectoryShape` has three fieldless shapes and `Stepwise`
/// carrying a pitch sequence, so the text is `linear` / `exponential` / `curve`
/// / `(stepwise (<pitch>…))`.
impl TextValue for TrajectoryShape {
fn project(&self) -> Sexp {
match self {
TrajectoryShape::Linear => Sexp::sym("linear"),
TrajectoryShape::Exponential => Sexp::sym("exponential"),
TrajectoryShape::Curve => Sexp::sym("curve"),
TrajectoryShape::Stepwise(v) => Sexp::List(vec![Sexp::sym("stepwise"), v.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match s {
Sexp::Symbol(name) => match name.as_str() {
"linear" => Ok(TrajectoryShape::Linear),
"exponential" => Ok(TrajectoryShape::Exponential),
"curve" => Ok(TrajectoryShape::Curve),
found => Err(TextError::UnknownConstructor {
type_name: "TrajectoryShape",
found: found.to_owned(),
}),
},
Sexp::List(_) => Ok(TrajectoryShape::Stepwise(Vec::<IdentifiedPitch>::parse(
one_field(s, "stepwise")?,
)?)),
_ => Err(TextError::Expected {
expected: "TrajectoryShape",
found: class_of(s),
}),
}
}
}
// ===========================================================================
// The event union.
// ===========================================================================
/// `impl Codec for Event` writes a discriminant byte and then delegates to the
/// payload's own `enc`, so its projection is **not** transparent: it is
/// `(<variant> <payload>)`, the kebab of the *variant* name wrapping the payload
/// record's own struct projection — e.g. `(rest (rest <id> … <visible>))` and
/// `(pitched (pitched-event <id> … <grace>))`. A single-field variant projects as
/// `(<variant> <field>)`, never as the field alone; only an unnamed-field newtype
/// is transparent (`req:textproj:value-projection` clauses 2 and 3), and `Event`
/// is a union, not a newtype.
impl TextValue for Event {
fn project(&self) -> Sexp {
match self {
Event::Pitched(e) => Sexp::List(vec![Sexp::sym("pitched"), e.project()]),
Event::Unpitched(e) => Sexp::List(vec![Sexp::sym("unpitched"), e.project()]),
Event::Rest(e) => Sexp::List(vec![Sexp::sym("rest"), e.project()]),
Event::Indeterminate(e) => Sexp::List(vec![Sexp::sym("indeterminate"), e.project()]),
Event::Trajectory(e) => Sexp::List(vec![Sexp::sym("trajectory"), e.project()]),
Event::Graphic(e) => Sexp::List(vec![Sexp::sym("graphic"), e.project()]),
Event::Cue(e) => Sexp::List(vec![Sexp::sym("cue"), e.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match head_symbol(s, "Event")? {
"pitched" => Ok(Event::Pitched(PitchedEvent::parse(one_field(
s, "pitched",
)?)?)),
"unpitched" => Ok(Event::Unpitched(UnpitchedEvent::parse(one_field(
s,
"unpitched",
)?)?)),
"rest" => Ok(Event::Rest(Rest::parse(one_field(s, "rest")?)?)),
"indeterminate" => Ok(Event::Indeterminate(IndeterminateEvent::parse(one_field(
s,
"indeterminate",
)?)?)),
"trajectory" => Ok(Event::Trajectory(TrajectoryEvent::parse(one_field(
s,
"trajectory",
)?)?)),
"graphic" => Ok(Event::Graphic(GraphicEvent::parse(one_field(
s, "graphic",
)?)?)),
"cue" => Ok(Event::Cue(CueEvent::parse(one_field(s, "cue")?)?)),
found => Err(TextError::UnknownConstructor {
type_name: "Event",
found: found.to_owned(),
}),
}
}
}
// ===========================================================================
// The event arena.
// ===========================================================================
/// The arena projects as the sequence of its events in ascending `EventId`
/// order, matching `impl Codec for EventArena`, whose `enc` iterates
/// [`EventArena::iter_canonical`]. Identity travels inside each event, so no
/// key is written beside it.
///
/// `parse` enforces that ascending order **per site**. The binary decoder rebuilds
/// the arena through [`EventArena::insert`], which accepts events in any order and
/// lets [`EventArena::iter_canonical`] silently re-sort them; returning such an
/// arena would launder a mis-ordered or duplicate-id text into a canonical value,
/// the normalization `req:textproj:strict-parse` forbids. Equal ids fail the same
/// strict-`<` test, so a duplicate is rejected here and never reaches `insert`.
///
/// A whole-value re-project-and-compare guard *would* also catch this, since
/// `project` re-sorts and so a mis-ordered input re-projects differently. The
/// explicit check is what the crate keeps: it names the fault
/// (`NotStrictlyIncreasing`), points at the first offending event, and avoids a
/// redundant second projection of the whole arena. It is mutation-verified live.
impl TextValue for EventArena {
fn project(&self) -> Sexp {
Sexp::List(self.iter_canonical().map(Event::project).collect())
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let items = s.as_list().ok_or(TextError::Expected {
expected: "EventArena",
found: class_of(s),
})?;
let mut arena = EventArena::new();
let mut previous: Option<EventId> = None;
for item in items {
let event = Event::parse(item)?;
let id = event.id();
if previous.is_some_and(|prev| prev >= id) {
return Err(TextError::NotStrictlyIncreasing(
"EventArena events must be in ascending EventId order",
));
}
previous = Some(id);
// `insert` still enforces the Chapter 5 invariant that a pitched event
// has at least one pitch. A duplicate id cannot reach it — the strict
// order check above rejects equal ids — but the arm is kept exhaustive
// (no `_`) so a new `ArenaError` variant forces a decision here.
arena.insert(event).map_err(|err| match err {
ArenaError::EmptyPitchedEvent(_) => {
TextError::NotCanonical("a pitched event must have at least one pitch")
}
ArenaError::DuplicateId(_) => TextError::NotStrictlyIncreasing(
"EventArena events must be in ascending EventId order",
),
})?;
}
Ok(arena)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::StemConfiguration;
use crate::ids::{EventId, PitchId, ReplicaId, VoiceId};
use crate::pitch::{
AcousticPitch, AcousticRealization, CmnNominal, IdentifiedPitch, Pitch, PitchSpaceId,
PitchSpacePosition, ScalePosition, TuningReference,
};
use crate::textvalue::read_sexp;
use crate::time::{
EventDuration, EventPosition, MusicalDuration, MusicalPosition, RationalTime,
};
fn replica() -> ReplicaId {
ReplicaId(1)
}
fn voice() -> VoiceId {
VoiceId::new(replica(), 100)
}
/// project → render → read_sexp → parse must return the original value.
#[track_caller]
fn round_trip<T>(value: T)
where
T: TextValue + PartialEq + std::fmt::Debug,
{
let text = value.project().render();
let read = read_sexp(&text).unwrap_or_else(|e| panic!("{text:?} did not lex: {e}"));
let back = T::parse(&read).unwrap_or_else(|e| panic!("{text:?} did not parse: {e}"));
assert_eq!(back, value, "round trip changed {text:?}");
}
fn identified_pitch(counter: u64) -> IdentifiedPitch {
IdentifiedPitch {
id: PitchId::new(replica(), counter),
pitch: Pitch {
scale_position: ScalePosition {
space: PitchSpaceId::new("cmn-12"),
position: PitchSpacePosition::Cmn {
nominal: CmnNominal::C,
alteration: 0,
octave: 4,
},
},
acoustic: AcousticPitch {
tuning: TuningReference::Inherit,
realization: AcousticRealization::Implicit,
},
},
}
}
fn rest_event(counter: u64) -> Event {
Event::Rest(Rest {
id: EventId::new(replica(), counter),
voice: voice(),
position: EventPosition::Musical(MusicalPosition(
RationalTime::new(counter as i64, 4).unwrap(),
)),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
vertical_position: Some(StaffPosition(-2)),
visible: true,
})
}
fn pitched_event(counter: u64) -> Event {
Event::Pitched(PitchedEvent {
id: EventId::new(replica(), counter),
voice: voice(),
position: EventPosition::Musical(MusicalPosition(RationalTime::new(1, 2).unwrap())),
duration: EventDuration::Musical(MusicalDuration(RationalTime::new(1, 4).unwrap())),
pitches: vec![identified_pitch(counter * 10)],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: Some(GraceKind::MeasuredFraction(MusicalDuration(
RationalTime::new(1, 8).unwrap(),
))),
})
}
#[test]
fn staff_position_and_member_id_are_transparent_newtypes() {
assert_eq!(StaffPosition(-3).project().render(), "-3");
assert_eq!(UnpitchedMemberId(42).project().render(), "42");
round_trip(StaffPosition(-3));
round_trip(StaffPosition(0));
round_trip(UnpitchedMemberId(42));
}
#[test]
fn grace_kind_round_trips_every_variant() {
assert_eq!(GraceKind::Acciaccatura.project().render(), "acciaccatura");
assert_eq!(
GraceKind::MeasuredFraction(MusicalDuration(RationalTime::new(1, 8).unwrap()))
.project()
.render(),
"(measured-fraction (ratio 1 8))"
);
for g in [
GraceKind::Acciaccatura,
GraceKind::Appoggiatura,
GraceKind::Unmeasured,
GraceKind::MeasuredFraction(MusicalDuration(RationalTime::new(3, 8).unwrap())),
] {
round_trip(g);
}
}
/// A fieldless kind spelled as a list, or a field kind spelled bare, is a
/// non-canonical spelling and must be rejected, not accepted.
#[test]
fn grace_kind_rejects_the_wrong_shape() {
assert!(GraceKind::parse(&read_sexp("(acciaccatura)").unwrap()).is_err());
assert!(GraceKind::parse(&read_sexp("measured-fraction").unwrap()).is_err());
assert!(GraceKind::parse(&read_sexp("nope").unwrap()).is_err());
}
#[test]
fn indeterminacy_kind_round_trips_including_nested_compound() {
assert_eq!(IndeterminacyKind::Choice.project().render(), "choice");
assert_eq!(
IndeterminacyKind::Compound(vec![
IndeterminacyKind::Pitch,
IndeterminacyKind::Compound(vec![IndeterminacyKind::Duration]),
])
.project()
.render(),
"(compound (pitch (compound (duration))))"
);
for k in [
IndeterminacyKind::Pitch,
IndeterminacyKind::Duration,
IndeterminacyKind::Choice,
IndeterminacyKind::Compound(vec![IndeterminacyKind::Pitch, IndeterminacyKind::Choice]),
] {
round_trip(k);
}
}
#[test]
fn trajectory_endpoint_round_trips_both_variants() {
round_trip(TrajectoryEndpoint::EventPitch(PitchId::new(replica(), 7)));
round_trip(TrajectoryEndpoint::ExplicitPitch(identified_pitch(3)));
}
#[test]
fn trajectory_shape_round_trips_every_variant() {
assert_eq!(TrajectoryShape::Curve.project().render(), "curve");
for shape in [
TrajectoryShape::Linear,
TrajectoryShape::Exponential,
TrajectoryShape::Curve,
TrajectoryShape::Stepwise(vec![identified_pitch(1), identified_pitch(2)]),
] {
round_trip(shape);
}
}
/// `Event` is `(<variant> <payload>)`, not the payload alone.
#[test]
fn event_projection_wraps_the_payload_in_the_variant_name() {
let text = rest_event(1).project().render();
assert!(
text.starts_with("(rest (rest "),
"expected `(rest (rest …`, got {text}"
);
let pitched = pitched_event(1).project().render();
assert!(
pitched.starts_with("(pitched (pitched-event "),
"expected `(pitched (pitched-event …`, got {pitched}"
);
}
#[test]
fn event_round_trips_a_rest_and_a_pitched_event() {
round_trip(rest_event(4));
round_trip(pitched_event(9));
}
#[test]
fn event_arena_round_trips_in_ascending_id_order() {
let mut arena = EventArena::new();
// Inserted out of id order; projection must still be ascending.
for c in [5u64, 1, 9, 3] {
arena.insert(rest_event(c)).unwrap();
}
round_trip(arena);
}
/// A text whose events are not in ascending `EventId` order must be rejected,
/// not silently re-sorted the way [`EventArena::insert`] would.
#[test]
fn event_arena_rejects_events_out_of_id_order() {
let descending = Sexp::List(vec![rest_event(3).project(), rest_event(1).project()]);
assert_eq!(
EventArena::parse(&descending),
Err(TextError::NotStrictlyIncreasing(
"EventArena events must be in ascending EventId order"
))
);
}
/// A duplicate `EventId` breaks strict ascension and is rejected there, before
/// it can reach `insert`.
#[test]
fn event_arena_rejects_a_duplicate_id() {
let duplicated = Sexp::List(vec![rest_event(2).project(), rest_event(2).project()]);
assert_eq!(
EventArena::parse(&duplicated),
Err(TextError::NotStrictlyIncreasing(
"EventArena events must be in ascending EventId order"
))
);
}
/// A pitched event with no pitches is a well-formed projection of an invalid
/// value; the arena rejects it rather than admitting it.
#[test]
fn event_arena_rejects_an_empty_pitched_event() {
let empty = Event::Pitched(PitchedEvent {
id: EventId::new(replica(), 1),
voice: voice(),
position: EventPosition::Musical(MusicalPosition::origin()),
duration: EventDuration::Musical(MusicalDuration::whole()),
pitches: vec![],
articulations: vec![],
dynamic: None,
ornaments: vec![],
stem: StemConfiguration,
grace: None,
});
let s = Sexp::List(vec![empty.project()]);
assert_eq!(
EventArena::parse(&s),
Err(TextError::NotCanonical(
"a pitched event must have at least one pitch"
))
);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,424 @@
//! [`TextValue`] for the leaves and the hand-written composites of Chapter 5.
//!
//! The 82 `struct_codec!` structs, the 8 `unit_codec!` unit structs, the 17
//! `cstyle_enum_codec!` enums and the 9 `catalog_id_codec!` ids get their impls
//! from the *same macro invocation* that gives them their binary codec, so their
//! field order cannot drift from the order the binary form writes. See
//! `codec.rs`. This module supplies what those macros cannot: the leaves, and the
//! composites whose `Codec` impl is hand-written.
//!
//! Every `parse` here obeys `req:textproj:strict-parse`. Where the only way to
//! construct a value is through a normalizing constructor — `RationalTime::new`
//! reduces, `PitchSpaceId::new` folds to NFC — the impl constructs and then
//! **compares against its input**, rejecting on difference. It never returns the
//! normalized value and calls that acceptance.
use epiphany_determinism::{CanonicalDecode, CanonicalEncode, CanonicalF64, ContentHash};
use num_rational::BigRational;
use num_traits::Zero;
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
use crate::time::{
MusicalDuration, MusicalPosition, RationalTime, WallClockDuration, WallClockTime,
};
// ===========================================================================
// Byte-string leaves.
// ===========================================================================
/// An identifier or hash is a byte string (`req:textproj:value-projection`
/// clause 6).
///
/// `decode_canonical` is the leaf's own validating decoder, but validating is not
/// the same as *rejecting non-canonical bytes*: a decoder that accepts a
/// denormalized encoding would let two byte strings — and so two texts — denote
/// one value. The re-encode comparison closes that, exactly as
/// `Score::decode_canonical` does for the binary form.
macro_rules! bytes_text_value {
($($ty:ty => $what:literal),* $(,)?) => {
$(
impl TextValue for $ty {
fn project(&self) -> Sexp {
Sexp::Bytes(self.to_canonical_bytes())
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let Sexp::Bytes(bytes) = s else {
return Err(TextError::Expected {
expected: $what,
found: class_of(s),
});
};
let value = <$ty>::decode_canonical(bytes)
.map_err(|_| TextError::NotCanonical($what))?;
if value.to_canonical_bytes() != *bytes {
return Err(TextError::NotCanonical(
concat!($what, " is not canonically encoded")
));
}
Ok(value)
}
}
)*
};
}
/// The lexical class of `s`, for error messages. Mirrors `Sexp::class`, which is
/// private to its module.
pub(crate) fn class_of(s: &Sexp) -> &'static str {
match s {
Sexp::List(_) => "list",
Sexp::Symbol(_) => "symbol",
Sexp::Int(_) => "integer",
Sexp::Bytes(_) => "byte string",
Sexp::Str(_) => "string",
}
}
bytes_text_value! {
crate::ids::ReplicaId => "ReplicaId",
crate::ids::OperationId => "OperationId",
crate::ids::EventId => "EventId",
crate::ids::PitchId => "PitchId",
crate::ids::VoiceId => "VoiceId",
crate::ids::StaffId => "StaffId",
crate::ids::StaffInstanceId => "StaffInstanceId",
crate::ids::StaffGroupId => "StaffGroupId",
crate::ids::RegionId => "RegionId",
crate::ids::InstrumentId => "InstrumentId",
crate::ids::PartDefinitionId => "PartDefinitionId",
crate::ids::MeasureId => "MeasureId",
crate::ids::BarlineAlignmentGroupId => "BarlineAlignmentGroupId",
crate::ids::SlurId => "SlurId",
crate::ids::TieId => "TieId",
crate::ids::BeamId => "BeamId",
crate::ids::SpannerId => "SpannerId",
crate::ids::TupletId => "TupletId",
crate::ids::MarkerId => "MarkerId",
crate::ids::AnalyticalAnnotationId => "AnalyticalAnnotationId",
crate::ids::CommentId => "CommentId",
crate::ids::RepeatStructureId => "RepeatStructureId",
crate::ids::LyricLineId => "LyricLineId",
crate::ids::ChordSymbolId => "ChordSymbolId",
crate::ids::GraphicObjectId => "GraphicObjectId",
crate::ids::GraphicGestureId => "GraphicGestureId",
crate::ids::TimeSignatureId => "TimeSignatureId",
crate::ids::AnalysisLayerId => "AnalysisLayerId",
crate::ids::ViewId => "ViewId",
ContentHash => "ContentHash",
}
/// A `CanonicalF64` is its eight canonical little-endian IEEE 754 bytes, never a
/// decimal (`req:textproj:value-projection` clause 6). Decimal float text is not
/// canonically unique — shortest-round-trip and 17-significant-digit spellings both
/// round-trip, and `-0.0` has two spellings — so a decimal tempo would break
/// `req:textproj:canonical-text` at the first tempo mark.
impl TextValue for CanonicalF64 {
fn project(&self) -> Sexp {
Sexp::Bytes(self.to_le_bytes().to_vec())
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let Sexp::Bytes(bytes) = s else {
return Err(TextError::Expected {
expected: "CanonicalF64",
found: class_of(s),
});
};
let bytes: [u8; 8] = bytes
.as_slice()
.try_into()
.map_err(|_| TextError::NotCanonical("a CanonicalF64 is exactly eight bytes"))?;
CanonicalF64::from_le_bytes(bytes).ok_or(TextError::NotCanonical(
"not a canonical f64 (NaN, or -0.0)",
))
}
}
// ===========================================================================
// Rationals.
// ===========================================================================
/// `(ratio <numerator> <denominator>)`, in lowest terms with a positive
/// denominator and the sign on the numerator; zero is `(ratio 0 1)`.
///
/// `RationalTime::new` **reduces**, so parsing through it would silently accept
/// `(ratio 2 4)` as `1/2` — normalizing, which `req:textproj:strict-parse`
/// forbids. The canonical form is therefore checked *before* construction.
impl TextValue for RationalTime {
fn project(&self) -> Sexp {
let big = self.to_big();
Sexp::ratio(big.numer().clone(), big.denom().clone())
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let items = s.as_list().ok_or(TextError::Expected {
expected: "rational",
found: class_of(s),
})?;
let [head, numerator, denominator] = items else {
return Err(TextError::Syntax(
"a rational is `(ratio <numerator> <denominator>)`",
));
};
if head.as_symbol() != Some("ratio") {
return Err(TextError::Syntax("a rational is headed by `ratio`"));
}
let (Sexp::Int(numerator), Sexp::Int(denominator)) = (numerator, denominator) else {
return Err(TextError::Expected {
expected: "integer",
found: "non-integer",
});
};
if denominator.is_zero() {
// Checked before `BigRational::new`, which panics on a zero
// denominator rather than returning an error.
return Err(TextError::NotCanonical("rational denominator is zero"));
}
// `BigRational::new` *is* the canonical form: it reduces, keeps the
// denominator positive, and puts the sign on the numerator. So the
// canonical spelling of this value is the one whose parts survive it
// unchanged. Constructing first and returning the result would accept
// `(ratio 2 4)` as `1/2` — normalizing, which `req:textproj:strict-parse`
// forbids. Comparing instead rejects it.
let reduced = BigRational::new(numerator.clone(), denominator.clone());
if reduced.numer() != numerator || reduced.denom() != denominator {
return Err(TextError::NotCanonical(
"rational is not in lowest terms with a positive denominator",
));
}
Ok(RationalTime::from_big(reduced))
}
}
// ===========================================================================
// Transparent newtypes.
// ===========================================================================
/// A newtype is projected as its field alone, with no wrapper
/// (`req:textproj:value-projection` clause 2), mirroring the binary form in which
/// a newtype delegates to its field and adds no bytes.
macro_rules! newtype_text_value {
($($ty:ty => $inner:ty),* $(,)?) => {
$(
impl TextValue for $ty {
fn project(&self) -> Sexp {
TextValue::project(&self.0)
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
<$inner as TextValue>::parse(s).map(Self)
}
}
)*
};
}
newtype_text_value! {
MusicalPosition => RationalTime,
MusicalDuration => RationalTime,
WallClockTime => i64,
WallClockDuration => i64,
crate::graph::OctaveOffset => i8,
}
/// A transparent newtype over an integer whose constructor **validates** — it
/// rejects a value outside the type's domain rather than adjusting one into it.
///
/// So no `ensure_canonical` is wanted here, and none would fire: an accepted value
/// re-projects to exactly its input. What rejects `(power-of-two 3)` is `new`
/// returning `None`, and that is the whole of the strictness.
macro_rules! validated_int_newtype_text_value {
($($ty:ty => $inner:ty, $new:path, $get:ident, $what:literal);* $(;)?) => {
$(
impl TextValue for $ty {
fn project(&self) -> Sexp {
TextValue::project(&self.$get())
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let inner = <$inner as TextValue>::parse(s)?;
$new(inner).ok_or(TextError::NotCanonical($what))
}
}
)*
};
}
validated_int_newtype_text_value! {
crate::graph::PowerOfTwo => u16, crate::graph::PowerOfTwo::new, get,
"a PowerOfTwo denominator must be a power of two";
core::num::NonZeroU16 => u16, core::num::NonZeroU16::new, get,
"a NonZeroU16 must not be zero";
}
// ===========================================================================
// Tempo.
// ===========================================================================
/// `(tempo <bpm> <beat-unit>)`, in the order `impl Codec for Tempo` writes them.
///
/// Both fields are private and `Tempo::new` rejects a non-finite or non-positive
/// BPM, so the value can only be rebuilt through a validating constructor.
/// `ensure_canonical` closes what that leaves open.
impl TextValue for crate::tempo::Tempo {
fn project(&self) -> Sexp {
let bpm = CanonicalF64::new(self.bpm()).expect("a valid tempo has a canonical BPM");
Sexp::List(vec![
Sexp::Symbol(kebab("Tempo")),
bpm.project(),
self.beat_unit().project(),
])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("Tempo"), 2)?;
let bpm = CanonicalF64::parse(&fields[0])?;
let beat_unit = MusicalDuration::parse(&fields[1])?;
// `new` rejects a non-positive or non-finite BPM rather than adjusting
// one, and `CanonicalF64::parse` has already refused a non-canonical float,
// so there is nothing left for a whole-value guard to catch.
crate::tempo::Tempo::new(bpm.get(), beat_unit)
.ok_or(TextError::NotCanonical("tempo BPM or beat unit is invalid"))
}
}
// ===========================================================================
// Helpers the codec macros expand into.
// ===========================================================================
/// A zero-field struct is the bare symbol `<type-name>`, as a fieldless variant is
/// (`req:textproj:value-projection` clause 1).
pub(crate) fn project_unit(type_name: &str) -> Sexp {
Sexp::Symbol(kebab(type_name))
}
/// Reads a zero-field struct or fieldless variant.
pub(crate) fn parse_unit(s: &Sexp, type_name: &'static str) -> Result<(), TextError> {
match s.as_symbol() {
Some(name) if name == kebab(type_name) => Ok(()),
Some(found) => Err(TextError::UnknownConstructor {
type_name,
found: found.to_owned(),
}),
None => Err(TextError::Expected {
expected: "symbol",
found: class_of(s),
}),
}
}
/// Reads a catalog id: a string, checked to be exactly what interning it produces.
///
/// `PitchSpaceId::new` and its siblings **fold to Unicode NFC**. Constructing
/// through them and returning the result would accept a non-NFC spelling and
/// silently normalize it — two texts denoting one value, which
/// `req:textproj:strict-parse` forbids and which the binary form's whole-value
/// re-encode guard catches only because it re-encodes. Here the comparison is
/// explicit.
pub(crate) fn parse_catalog_id<T, F>(
s: &Sexp,
make: F,
as_str: impl Fn(&T) -> &str,
) -> Result<T, TextError>
where
F: Fn(String) -> T,
{
let Sexp::Str(text) = s else {
return Err(TextError::Expected {
expected: "catalog id",
found: class_of(s),
});
};
let value = make(text.clone());
if as_str(&value) != text {
return Err(TextError::NotCanonical("catalog id is not in Unicode NFC"));
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::textvalue::read_sexp;
#[test]
fn a_rational_projects_in_lowest_terms_with_the_sign_on_the_numerator() {
let r = RationalTime::new(-2, 4).unwrap();
assert_eq!(r.project().render(), "(ratio -1 2)");
assert_eq!(RationalTime::zero().project().render(), "(ratio 0 1)");
assert_eq!(RationalTime::one().project().render(), "(ratio 1 1)");
}
/// Parsing through `RationalTime::new` would reduce these and call it success.
#[test]
fn a_non_canonical_rational_is_rejected_not_reduced() {
for bad in [
"(ratio 2 4)", // not in lowest terms
"(ratio 1 -2)", // sign on the denominator
"(ratio -1 -2)", // both negative
"(ratio 0 2)", // zero is (ratio 0 1)
"(ratio 1 0)", // zero denominator
] {
let s = read_sexp(bad).unwrap();
assert!(
RationalTime::parse(&s).is_err(),
"{bad} must be rejected, not reduced"
);
}
}
#[test]
fn a_rational_round_trips_through_its_projection() {
for (n, d) in [(0, 1), (1, 2), (-3, 4), (7, 1), (-1, 1)] {
let r = RationalTime::new(n, d).unwrap();
let text = r.project().render();
let back = RationalTime::parse(&read_sexp(&text).unwrap()).unwrap();
assert_eq!(r, back, "{text} did not round-trip");
}
}
/// A `MusicalPosition` *is* a rational; the wrapper adds no bytes and no text.
#[test]
fn a_newtype_is_transparent() {
let p = MusicalPosition(RationalTime::new(3, 4).unwrap());
assert_eq!(p.project().render(), "(ratio 3 4)");
assert_eq!(WallClockTime(-5).project().render(), "-5");
}
#[test]
fn the_codec_macros_project_units_c_style_enums_and_catalog_ids() {
use crate::event::ArticulationMark;
use crate::pitch::{AccidentalId, SpellingSourceKind};
use crate::textvalue::read_sexp;
// cstyle enum -> bare symbol
assert_eq!(
SpellingSourceKind::UserChosen.project().render(),
"user-chosen"
);
assert_eq!(
SpellingSourceKind::parse(&read_sexp("propagated").unwrap()).unwrap(),
SpellingSourceKind::Propagated
);
assert!(SpellingSourceKind::parse(&read_sexp("nope").unwrap()).is_err());
// unit struct -> bare symbol
assert_eq!(ArticulationMark.project().render(), "articulation-mark");
// catalog id -> string, NFC-checked
let id = AccidentalId::new("sharp");
assert_eq!(id.project().render(), "\"sharp\"");
assert_eq!(
AccidentalId::parse(&read_sexp("\"sharp\"").unwrap()).unwrap(),
id
);
// A non-NFC spelling must be rejected, not folded: "e" + combining acute.
let decomposed = read_sexp("\"e\u{0301}\"").unwrap();
assert!(
AccidentalId::parse(&decomposed).is_err(),
"non-NFC catalog id must be rejected, not normalized"
);
}
#[test]
fn a_canonical_f64_is_eight_bytes_never_a_decimal() {
let f = CanonicalF64::new(1.5).unwrap();
assert_eq!(f.project().render(), "#x000000000000f83f");
let back = CanonicalF64::parse(&read_sexp("#x000000000000f83f").unwrap()).unwrap();
assert_eq!(f, back);
assert!(CanonicalF64::parse(&read_sexp("#x00").unwrap()).is_err());
}
}

View File

@ -0,0 +1,724 @@
//! [`TextValue`] for the hand-written `Codec` composites of Chapter 2's pitch and
//! spelling subsystem (`pitch.rs`).
//!
//! The macro-generated types of `pitch.rs` — the `struct_codec!` structs
//! ([`ScalePosition`](crate::pitch::ScalePosition), [`Pitch`](crate::pitch::Pitch),
//! [`PitchSpelling`](crate::pitch::PitchSpelling), …), the `cstyle_enum_codec!`
//! [`CmnNominal`](crate::pitch::CmnNominal) /
//! [`SpellingSourceKind`](crate::pitch::SpellingSourceKind), and the
//! `catalog_id_codec!` ids — get their [`TextValue`] from the same macro that
//! writes their bytes, so their projection cannot drift from the binary form.
//! This module supplies the rest: the tagged unions and the two structs whose
//! `Codec` is written out by hand. Each projection mirrors the field / variant
//! order of the corresponding `impl Codec` in `codec.rs` exactly.
//!
//! Two of these parse through a **validating** constructor, so they obey
//! `req:textproj:strict-parse` by re-projecting and comparing rather than
//! returning a laundered value:
//!
//! * [`ReferencePitch`](crate::pitch::ReferencePitch) — the frequency field is
//! private and reachable only through `ReferencePitch::new`, which rejects a
//! non-positive frequency.
//! * [`SpellingPrecedence`](crate::pitch::SpellingPrecedence) — the order is
//! private and reachable only through `SpellingPrecedence::new`, which rejects
//! any order that is not a total ranking (a kind missing or duplicated). A
//! `Vec` parse preserves order and so cannot see a duplicate itself; `new` is
//! what refuses it.
//!
//! Tagged-union parsing is strict about *shape* too: a fieldless variant projects
//! as a bare symbol, so its one-element list spelling (`(inherit)`) is rejected,
//! not accepted.
use epiphany_determinism::CanonicalF64;
use crate::pitch::{
AcousticRealization, PitchSpacePosition, ReferencePitch, SpellingDirective, SpellingNominal,
SpellingPrecedence, SpellingScope, SpellingSource, SpellingSourceKind, TuningReference,
VoiceSelector,
};
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
use crate::textvalue_impls::class_of;
// ===========================================================================
// Tagged-union helpers.
// ===========================================================================
/// Projects a tagged-union variant (`req:textproj:value-projection` clause 3): a
/// fieldless variant is its bare kebab name; a variant with fields is a list of
/// that name followed by the fields' projections.
fn variant(name: &str, fields: Vec<Sexp>) -> Sexp {
if fields.is_empty() {
Sexp::Symbol(kebab(name))
} else {
let mut items = Vec::with_capacity(fields.len() + 1);
items.push(Sexp::Symbol(kebab(name)));
items.extend(fields);
Sexp::List(items)
}
}
/// The constructor name of a tagged-union projection, and its field list when the
/// projection is *applied* (a list). A bare symbol yields `None` for the fields —
/// it is a fieldless variant — which is what lets a caller reject a fieldless
/// variant miswritten as a one-element list, a spelling `project` never emits
/// (`req:textproj:strict-parse`).
fn constructor(s: &Sexp) -> Result<(&str, Option<&[Sexp]>), TextError> {
match s {
Sexp::Symbol(name) => Ok((name.as_str(), None)),
Sexp::List(items) => {
let (head, rest) = items.split_first().ok_or(TextError::Syntax(
"a tagged-union variant is a symbol or a non-empty list",
))?;
let name = head
.as_symbol()
.ok_or(TextError::Syntax("a variant constructor is a symbol"))?;
Ok((name, Some(rest)))
}
_ => Err(TextError::Expected {
expected: "tagged-union variant",
found: class_of(s),
}),
}
}
/// The field count of a (possibly bare) variant, for arity diagnostics.
fn field_count(fields: Option<&[Sexp]>) -> usize {
fields.map_or(0, <[Sexp]>::len)
}
fn arity(type_name: &'static str, expected: usize, found: usize) -> TextError {
TextError::Arity {
type_name,
expected,
found,
}
}
/// Rejects a fieldless variant spelled as a list. A fieldless variant projects as
/// a bare symbol, so any field list — even the empty `(inherit)` — is not its
/// canonical text and must be refused rather than absorbed
/// (`req:textproj:strict-parse`).
fn expect_fieldless(fields: Option<&[Sexp]>) -> Result<(), TextError> {
match fields {
None => Ok(()),
Some(_) => Err(TextError::Syntax(
"a fieldless variant projects as a bare symbol, not a list",
)),
}
}
// ===========================================================================
// Tagged unions.
// ===========================================================================
/// A position within a pitch space (`req:textproj:value-projection` clause 3),
/// mirroring `PitchSpacePosition`'s `Codec::enc` variant order: `Cmn`, `Integer`,
/// `JiVector`, `Registered`. The `Cmn` fields project positionally in their
/// declared order — nominal, alteration, octave — never by name.
impl TextValue for PitchSpacePosition {
fn project(&self) -> Sexp {
match self {
PitchSpacePosition::Cmn {
nominal,
alteration,
octave,
} => variant(
"Cmn",
vec![nominal.project(), alteration.project(), octave.project()],
),
PitchSpacePosition::Integer { space_size, index } => {
variant("Integer", vec![space_size.project(), index.project()])
}
PitchSpacePosition::JiVector { components } => {
variant("JiVector", vec![components.project()])
}
PitchSpacePosition::Registered(id) => variant("Registered", vec![id.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Cmn") {
let Some([nominal, alteration, octave]) = fields else {
return Err(arity("PitchSpacePosition", 3, field_count(fields)));
};
return Ok(PitchSpacePosition::Cmn {
nominal: TextValue::parse(nominal)?,
alteration: TextValue::parse(alteration)?,
octave: TextValue::parse(octave)?,
});
}
if head == kebab("Integer") {
let Some([space_size, index]) = fields else {
return Err(arity("PitchSpacePosition", 2, field_count(fields)));
};
return Ok(PitchSpacePosition::Integer {
space_size: TextValue::parse(space_size)?,
index: TextValue::parse(index)?,
});
}
if head == kebab("JiVector") {
let Some([components]) = fields else {
return Err(arity("PitchSpacePosition", 1, field_count(fields)));
};
return Ok(PitchSpacePosition::JiVector {
components: TextValue::parse(components)?,
});
}
if head == kebab("Registered") {
let Some([id]) = fields else {
return Err(arity("PitchSpacePosition", 1, field_count(fields)));
};
return Ok(PitchSpacePosition::Registered(TextValue::parse(id)?));
}
Err(TextError::UnknownConstructor {
type_name: "PitchSpacePosition",
found: head.to_owned(),
})
}
}
/// The tuning reference governing a pitch: `inherit`, or `(explicit <id>)`.
/// Mirrors `TuningReference`'s `Codec::enc`.
impl TextValue for TuningReference {
fn project(&self) -> Sexp {
match self {
TuningReference::Inherit => variant("Inherit", vec![]),
TuningReference::Explicit(id) => variant("Explicit", vec![id.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Inherit") {
expect_fieldless(fields)?;
return Ok(TuningReference::Inherit);
}
if head == kebab("Explicit") {
let Some([id]) = fields else {
return Err(arity("TuningReference", 1, field_count(fields)));
};
return Ok(TuningReference::Explicit(TextValue::parse(id)?));
}
Err(TextError::UnknownConstructor {
type_name: "TuningReference",
found: head.to_owned(),
})
}
}
/// How the tuning system resolves to a frequency: `implicit`, `(cents-offset
/// <f64>)`, or `(absolute-hz <f64>)`. Mirrors `AcousticRealization`'s
/// `Codec::enc`. Each payload is a [`CanonicalF64`], so it projects as its eight
/// canonical little-endian bytes — never a decimal, which would not be uniquely
/// spellable (Appendix D §"Floating-Point Values").
impl TextValue for AcousticRealization {
fn project(&self) -> Sexp {
match self {
AcousticRealization::Implicit => variant("Implicit", vec![]),
AcousticRealization::CentsOffset(c) => variant("CentsOffset", vec![c.project()]),
AcousticRealization::AbsoluteHz(c) => variant("AbsoluteHz", vec![c.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Implicit") {
expect_fieldless(fields)?;
return Ok(AcousticRealization::Implicit);
}
if head == kebab("CentsOffset") {
let Some([c]) = fields else {
return Err(arity("AcousticRealization", 1, field_count(fields)));
};
return Ok(AcousticRealization::CentsOffset(TextValue::parse(c)?));
}
if head == kebab("AbsoluteHz") {
let Some([c]) = fields else {
return Err(arity("AcousticRealization", 1, field_count(fields)));
};
return Ok(AcousticRealization::AbsoluteHz(TextValue::parse(c)?));
}
Err(TextError::UnknownConstructor {
type_name: "AcousticRealization",
found: head.to_owned(),
})
}
}
/// The staff position a spelling draws on: `(cmn <nominal>)`, `(integer <i>)`, or
/// `(registered <id>)`. Mirrors `SpellingNominal`'s `Codec::enc`.
impl TextValue for SpellingNominal {
fn project(&self) -> Sexp {
match self {
SpellingNominal::Cmn(n) => variant("Cmn", vec![n.project()]),
SpellingNominal::Integer(i) => variant("Integer", vec![i.project()]),
SpellingNominal::Registered(id) => variant("Registered", vec![id.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Cmn") {
let Some([n]) = fields else {
return Err(arity("SpellingNominal", 1, field_count(fields)));
};
return Ok(SpellingNominal::Cmn(TextValue::parse(n)?));
}
if head == kebab("Integer") {
let Some([i]) = fields else {
return Err(arity("SpellingNominal", 1, field_count(fields)));
};
return Ok(SpellingNominal::Integer(TextValue::parse(i)?));
}
if head == kebab("Registered") {
let Some([id]) = fields else {
return Err(arity("SpellingNominal", 1, field_count(fields)));
};
return Ok(SpellingNominal::Registered(TextValue::parse(id)?));
}
Err(TextError::UnknownConstructor {
type_name: "SpellingNominal",
found: head.to_owned(),
})
}
}
/// The provenance of a spelling attachment. Mirrors `SpellingSource`'s
/// `Codec::enc` variant order: `UserChosen`, `Inferred`, `Imported`,
/// `Propagated`, `Analytical`. (That order — with `Inferred` before `Imported` —
/// is the type's declaration order and differs from
/// [`SpellingSourceKind`](crate::pitch::SpellingSourceKind)'s discriminant order;
/// the text carries variant *names*, not tags, so only the names matter here.)
/// The single-field variants carry their named field positionally.
impl TextValue for SpellingSource {
fn project(&self) -> Sexp {
match self {
SpellingSource::UserChosen => variant("UserChosen", vec![]),
SpellingSource::Inferred => variant("Inferred", vec![]),
SpellingSource::Imported { format } => variant("Imported", vec![format.project()]),
SpellingSource::Propagated { from } => variant("Propagated", vec![from.project()]),
SpellingSource::Analytical => variant("Analytical", vec![]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("UserChosen") {
expect_fieldless(fields)?;
return Ok(SpellingSource::UserChosen);
}
if head == kebab("Inferred") {
expect_fieldless(fields)?;
return Ok(SpellingSource::Inferred);
}
if head == kebab("Imported") {
let Some([format]) = fields else {
return Err(arity("SpellingSource", 1, field_count(fields)));
};
return Ok(SpellingSource::Imported {
format: TextValue::parse(format)?,
});
}
if head == kebab("Propagated") {
let Some([from]) = fields else {
return Err(arity("SpellingSource", 1, field_count(fields)));
};
return Ok(SpellingSource::Propagated {
from: TextValue::parse(from)?,
});
}
if head == kebab("Analytical") {
expect_fieldless(fields)?;
return Ok(SpellingSource::Analytical);
}
Err(TextError::UnknownConstructor {
type_name: "SpellingSource",
found: head.to_owned(),
})
}
}
/// A voice selector: `all`, or `(voices <voice-id>…)`. Mirrors `VoiceSelector`'s
/// `Codec::enc`. Implemented here — though it is not one of the "spelling" types —
/// because it is a `pitch.rs` composite with a hand-written `Codec` (the macros
/// generate no [`TextValue`] for it) and [`SpellingScope::Range`] embeds it.
impl TextValue for VoiceSelector {
fn project(&self) -> Sexp {
match self {
VoiceSelector::All => variant("All", vec![]),
VoiceSelector::Voices(voices) => variant("Voices", vec![voices.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("All") {
expect_fieldless(fields)?;
return Ok(VoiceSelector::All);
}
if head == kebab("Voices") {
let Some([voices]) = fields else {
return Err(arity("VoiceSelector", 1, field_count(fields)));
};
return Ok(VoiceSelector::Voices(TextValue::parse(voices)?));
}
Err(TextError::UnknownConstructor {
type_name: "VoiceSelector",
found: head.to_owned(),
})
}
}
/// What a spelling attachment applies to: `(pitch <id>)`, or `(range <start>
/// <end> <voices>)`. Mirrors `SpellingScope`'s `Codec::enc`, whose `Range` fields
/// are start, end, voices in that order.
impl TextValue for SpellingScope {
fn project(&self) -> Sexp {
match self {
SpellingScope::Pitch(id) => variant("Pitch", vec![id.project()]),
SpellingScope::Range { start, end, voices } => variant(
"Range",
vec![start.project(), end.project(), voices.project()],
),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Pitch") {
let Some([id]) = fields else {
return Err(arity("SpellingScope", 1, field_count(fields)));
};
return Ok(SpellingScope::Pitch(TextValue::parse(id)?));
}
if head == kebab("Range") {
let Some([start, end, voices]) = fields else {
return Err(arity("SpellingScope", 3, field_count(fields)));
};
return Ok(SpellingScope::Range {
start: TextValue::parse(start)?,
end: TextValue::parse(end)?,
voices: TextValue::parse(voices)?,
});
}
Err(TextError::UnknownConstructor {
type_name: "SpellingScope",
found: head.to_owned(),
})
}
}
/// A spelling directive: `(explicit <pitch-spelling>)`, or `(rule <spelling-rule>)`.
/// Mirrors `SpellingDirective`'s `Codec::enc`. Both payloads are `struct_codec!`
/// composites whose own [`TextValue`] is macro-generated.
impl TextValue for SpellingDirective {
fn project(&self) -> Sexp {
match self {
SpellingDirective::Explicit(spelling) => variant("Explicit", vec![spelling.project()]),
SpellingDirective::Rule(rule) => variant("Rule", vec![rule.project()]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let (head, fields) = constructor(s)?;
if head == kebab("Explicit") {
let Some([spelling]) = fields else {
return Err(arity("SpellingDirective", 1, field_count(fields)));
};
return Ok(SpellingDirective::Explicit(TextValue::parse(spelling)?));
}
if head == kebab("Rule") {
let Some([rule]) = fields else {
return Err(arity("SpellingDirective", 1, field_count(fields)));
};
return Ok(SpellingDirective::Rule(TextValue::parse(rule)?));
}
Err(TextError::UnknownConstructor {
type_name: "SpellingDirective",
found: head.to_owned(),
})
}
}
// ===========================================================================
// Structs with a private, validated field.
// ===========================================================================
/// `(reference-pitch <position> <frequency>)`, the frequency a [`CanonicalF64`]'s
/// eight bytes, mirroring `ReferencePitch`'s `Codec::enc` (position then
/// frequency).
///
/// The frequency field is private and reachable only through
/// [`ReferencePitch::new`], which *validates*: it rejects a non-positive or
/// non-finite frequency (Chapter 4: "positive and finite"). Parsing routes
/// through it — there is no other constructor — so a byte-legal frequency that is
/// negative or zero is refused rather than stored. `new` does not normalize, so a
/// value it accepts already projects back verbatim and a whole-value guard could
/// never fire — the `None` is the whole of the strictness.
impl TextValue for ReferencePitch {
fn project(&self) -> Sexp {
// `new` guaranteed a finite frequency, so re-wrapping cannot fail — the
// same invariant `ReferencePitch`'s `Codec::enc` asserts.
let hz = CanonicalF64::new(self.frequency_hz())
.expect("a constructed ReferencePitch has a finite frequency");
Sexp::List(vec![
Sexp::Symbol(kebab("ReferencePitch")),
self.position.project(),
hz.project(),
])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("ReferencePitch"), 2)?;
let [position, frequency] = fields else {
return Err(arity("ReferencePitch", 2, fields.len()));
};
let position = PitchSpacePosition::parse(position)?;
let frequency: CanonicalF64 = TextValue::parse(frequency)?;
// `new` *validates* — it refuses a non-positive or non-finite frequency —
// and never adjusts one, so an accepted value re-projects to exactly its
// input. A whole-value guard here could not fire; the `None` is the whole
// of the strictness.
ReferencePitch::new(position, frequency.get()).ok_or(TextError::NotCanonical(
"a reference pitch frequency must be positive and finite",
))
}
}
/// `(spelling-precedence <order>)`, `order` the total ranking of source kinds,
/// highest precedence first. Mirrors `SpellingPrecedence`'s `Codec::enc`, which
/// writes the single `order` vector.
///
/// The order is private and reachable only through [`SpellingPrecedence::new`],
/// which *validates*: it rejects any order that is not a total ranking — the
/// wrong length, or a source kind missing or duplicated. A `Vec` parse preserves
/// order and cannot see a duplicate itself, so `new` is what refuses it, and
/// parsing routes through `new` rather than laundering a malformed order into a
/// value (`req:textproj:strict-parse`). Any *permutation* of the five kinds is a
/// distinct, legitimate value, so `new` never reorders — it only accepts or
/// rejects, and there is nothing left for a whole-value guard to catch.
impl TextValue for SpellingPrecedence {
fn project(&self) -> Sexp {
let order = Sexp::List(self.order_ref().iter().map(TextValue::project).collect());
Sexp::List(vec![Sexp::Symbol(kebab("SpellingPrecedence")), order])
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
let fields = s.expect_struct(&kebab("SpellingPrecedence"), 1)?;
let [order] = fields else {
return Err(arity("SpellingPrecedence", 1, fields.len()));
};
let order: Vec<SpellingSourceKind> = TextValue::parse(order)?;
// `new` accepts only a permutation of the five source kinds and stores it
// unchanged: every permutation is a distinct legitimate value, so it never
// reorders. Validation, not normalization — the `None` rejects a duplicate
// or a short list, and nothing downstream could catch what it misses.
SpellingPrecedence::new(order).ok_or(TextError::NotCanonical(
"spelling precedence must rank every source kind exactly once",
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{PitchId, ReplicaId, VoiceId};
use crate::pitch::{
CmnNominal, ForeignFormatId, NominalRegistryId, PitchSpelling, PositionRegistryId,
SpellingRule, SpellingRuleSetId, TuningSystemId,
};
use crate::textvalue::read_sexp;
use crate::time::{TimeAnchor, WallClockTime};
/// Build a value, project it, render, read the text back, parse, and require
/// equality — the full `project → render → read_sexp → parse` loop.
#[track_caller]
fn round_trip<T: TextValue + PartialEq + std::fmt::Debug>(value: T) {
let text = value.project().render();
let sexp = read_sexp(&text).unwrap_or_else(|e| panic!("read_sexp rejected {text:?}: {e}"));
let back = T::parse(&sexp).unwrap_or_else(|e| panic!("parse rejected {text:?}: {e}"));
assert_eq!(value, back, "{text:?} did not round-trip");
}
#[test]
fn pitch_space_position_round_trips_every_variant() {
round_trip(PitchSpacePosition::Cmn {
nominal: CmnNominal::A,
alteration: -1,
octave: 4,
});
round_trip(PitchSpacePosition::Integer {
space_size: 31,
index: -5,
});
round_trip(PitchSpacePosition::JiVector {
components: vec![1, -2, 3],
});
round_trip(PitchSpacePosition::Registered(PositionRegistryId::new(
"my-pos",
)));
// The Cmn fields project positionally in declaration order.
assert_eq!(
PitchSpacePosition::Cmn {
nominal: CmnNominal::C,
alteration: 0,
octave: 4,
}
.project()
.render(),
"(cmn c 0 4)"
);
}
#[test]
fn tuning_reference_round_trips() {
round_trip(TuningReference::Inherit);
round_trip(TuningReference::Explicit(TuningSystemId::new("tet-12")));
assert_eq!(TuningReference::Inherit.project().render(), "inherit");
}
#[test]
fn acoustic_realization_round_trips() {
round_trip(AcousticRealization::Implicit);
round_trip(AcousticRealization::cents_offset(3.5).unwrap());
round_trip(AcousticRealization::absolute_hz(440.0).unwrap());
}
#[test]
fn reference_pitch_round_trips() {
round_trip(ReferencePitch::a440());
round_trip(
ReferencePitch::new(
PitchSpacePosition::Cmn {
nominal: CmnNominal::A,
alteration: 0,
octave: 4,
},
442.0,
)
.unwrap(),
);
}
/// A negative or zero frequency is a valid `CanonicalF64` byte string, so the
/// lexer and leaf parse accept it; only `ReferencePitch::new`'s validation
/// rejects it — which is the point, the text is the projection of no reference
/// pitch, and must not be laundered into one.
#[test]
fn a_non_positive_reference_frequency_is_rejected_not_accepted() {
let position = PitchSpacePosition::Cmn {
nominal: CmnNominal::A,
alteration: 0,
octave: 4,
};
for bad_hz in [-440.0, 0.0] {
let sexp = Sexp::List(vec![
Sexp::Symbol(kebab("ReferencePitch")),
position.project(),
CanonicalF64::new(bad_hz).unwrap().project(),
]);
let text = sexp.render();
let read = read_sexp(&text).unwrap();
assert!(
ReferencePitch::parse(&read).is_err(),
"{text} must be rejected"
);
}
}
#[test]
fn spelling_nominal_round_trips() {
round_trip(SpellingNominal::Cmn(CmnNominal::G));
round_trip(SpellingNominal::Integer(7));
round_trip(SpellingNominal::Registered(NominalRegistryId::new("nom")));
}
#[test]
fn spelling_source_round_trips() {
let r = ReplicaId::SYSTEM_DERIVED;
round_trip(SpellingSource::UserChosen);
round_trip(SpellingSource::Inferred);
round_trip(SpellingSource::Imported {
format: ForeignFormatId::new("musicxml"),
});
round_trip(SpellingSource::Propagated {
from: PitchId::new(r, 7),
});
round_trip(SpellingSource::Analytical);
}
/// A fieldless variant projects as a bare symbol, and a variant with fields as
/// a list; the wrong shape is refused rather than accepted.
#[test]
fn a_source_of_the_wrong_shape_is_rejected() {
// `user-chosen` is fieldless; `(user-chosen)` is a different text.
let listed = read_sexp("(user-chosen)").unwrap();
assert!(SpellingSource::parse(&listed).is_err());
// `imported` carries a field; the bare symbol is missing it.
let bare = read_sexp("imported").unwrap();
assert!(SpellingSource::parse(&bare).is_err());
}
#[test]
fn voice_selector_round_trips() {
let r = ReplicaId::SYSTEM_DERIVED;
round_trip(VoiceSelector::All);
round_trip(VoiceSelector::Voices(vec![
VoiceId::new(r, 1),
VoiceId::new(r, 2),
]));
}
#[test]
fn spelling_scope_round_trips() {
let r = ReplicaId::SYSTEM_DERIVED;
round_trip(SpellingScope::Pitch(PitchId::new(r, 3)));
round_trip(SpellingScope::Range {
start: TimeAnchor::WallClock {
time: WallClockTime(0),
},
end: TimeAnchor::WallClock {
time: WallClockTime(480),
},
voices: VoiceSelector::All,
});
}
#[test]
fn spelling_directive_round_trips() {
round_trip(SpellingDirective::Explicit(PitchSpelling::cmn(
CmnNominal::C,
4,
)));
round_trip(SpellingDirective::Rule(SpellingRule {
rule_set: SpellingRuleSetId::new("rs"),
}));
}
#[test]
fn spelling_precedence_round_trips() {
round_trip(SpellingPrecedence::default());
// Any permutation is a distinct, legitimate value.
round_trip(
SpellingPrecedence::new(vec![
SpellingSourceKind::Analytical,
SpellingSourceKind::Inferred,
SpellingSourceKind::Propagated,
SpellingSourceKind::Imported,
SpellingSourceKind::UserChosen,
])
.unwrap(),
);
assert_eq!(
SpellingPrecedence::default().project().render(),
"(spelling-precedence (user-chosen imported propagated inferred analytical))"
);
}
/// A `Vec` parse preserves order, so a duplicated or missing source kind is
/// invisible to it; only `SpellingPrecedence::new` catches it. Parsing must
/// reject, never silently repair, such an order.
#[test]
fn a_precedence_missing_or_duplicating_a_source_kind_is_rejected() {
let duplicated = read_sexp(
"(spelling-precedence (user-chosen user-chosen propagated inferred analytical))",
)
.unwrap();
assert!(SpellingPrecedence::parse(&duplicated).is_err());
let missing =
read_sexp("(spelling-precedence (user-chosen imported propagated inferred))").unwrap();
assert!(SpellingPrecedence::parse(&missing).is_err());
}
}

View File

@ -0,0 +1,603 @@
//! [`TextValue`] for the hand-written tagged unions of `time.rs`.
//!
//! These are the Chapter-3/Chapter-5 time enums whose [`Codec`] is written by
//! hand rather than by a `struct_codec!`/`cstyle_enum_codec!` macro, so their
//! projection has to be written by hand too: [`TimeAnchor`], [`EventPosition`],
//! [`ConcreteDuration`], [`EventDuration`], [`TimeBounds`], and [`AnchorOffset`].
//!
//! [`Codec`]: crate::codec::Codec
//!
//! `AnchorOffset` is included because [`TimeAnchor`] embeds it and nothing else
//! projects it: it is a hand-written-codec union with no macro to generate a
//! [`TextValue`], and the C-style/struct siblings a `TimeAnchor` also touches
//! ([`MeasurePosition`], [`RegionEdge`], [`DurationBounds`]) get theirs from their
//! codec macros. Its omission would leave `TimeAnchor::project` with no way to
//! project its `offset`.
//!
//! [`MeasurePosition`]: crate::time::MeasurePosition
//! [`RegionEdge`]: crate::time::RegionEdge
//! [`DurationBounds`]: crate::time::DurationBounds
//!
//! # Why none of these needs [`ensure_canonical`]
//!
//! `req:textproj:strict-parse` forbids a parse that normalizes. A whole-value
//! [`ensure_canonical`] guard is needed only where a value can *only* be built
//! through a constructor that validates or normalizes. None of these unions has
//! one: each `parse` selects a variant by its head symbol and builds it with a
//! plain enum expression, and every field it then reads parses strictly on its
//! own — a byte-string id re-encode-checks, a `RationalTime` rejects a non-reduced
//! ratio, an `i64` range-checks, a [`DurationBounds`] parses its fields
//! positionally. So the guard would have nothing left to catch, and there is no
//! order-constrained `Vec` here for it to be blind to either. The strictness these
//! impls own is narrower and explicit: a fieldless variant is the bare symbol, so
//! its one-element list spelling (`(zero)`, `(unbounded)`) is rejected, not
//! silently accepted (`req:textproj:value-projection` clause 3).
//!
//! [`ensure_canonical`]: crate::textvalue::ensure_canonical
use crate::textvalue::{kebab, Sexp, TextError, TextValue};
use crate::textvalue_impls::{class_of, project_unit};
use crate::time::{
AnchorOffset, ConcreteDuration, EventDuration, EventPosition, TimeAnchor, TimeBounds,
};
// ===========================================================================
// Shared shape helpers.
// ===========================================================================
/// The projection of a variant that carries `fields`: a list headed by the
/// variant's kebab-case name (`req:textproj:value-projection` clause 3). A
/// fieldless variant is not built here; it is the bare symbol, via
/// [`project_unit`].
fn applied(variant: &str, fields: Vec<Sexp>) -> Sexp {
let mut items = Vec::with_capacity(fields.len() + 1);
items.push(Sexp::Symbol(kebab(variant)));
items.extend(fields);
Sexp::List(items)
}
/// A tagged-union projection split into its constructor and any fields, keeping
/// the distinction the binary discriminant keeps: a bare symbol names a fieldless
/// variant and can match *only* one; a list headed by a symbol names an applied
/// variant and can match *only* one. Collapsing the two would accept `(zero)` as
/// `zero`, which is exactly the normalization `req:textproj:strict-parse` forbids.
enum Variant<'a> {
/// A bare symbol: a fieldless variant's canonical spelling.
Nullary(&'a str),
/// A list `(<name> <field>…)`: an applied variant's canonical spelling.
Applied(&'a str, &'a [Sexp]),
}
/// Classifies `s` as a tagged-union projection, or reports why it is not one.
fn classify<'a>(s: &'a Sexp, type_name: &'static str) -> Result<Variant<'a>, TextError> {
match s {
Sexp::Symbol(name) => Ok(Variant::Nullary(name)),
Sexp::List(items) => {
let (head, fields) = items.split_first().ok_or(TextError::Syntax(
"a tagged-union list is headed by its variant name",
))?;
let head = head.as_symbol().ok_or(TextError::Syntax(
"a tagged-union list is headed by its variant name",
))?;
Ok(Variant::Applied(head, fields))
}
_ => Err(TextError::Expected {
expected: type_name,
found: class_of(s),
}),
}
}
/// The error for an applied variant read with the wrong number of fields.
fn arity(type_name: &'static str, expected: usize, found: usize) -> TextError {
TextError::Arity {
type_name,
expected,
found,
}
}
/// The error for a constructor symbol naming no variant of `type_name`.
fn unknown(type_name: &'static str, found: &str) -> TextError {
TextError::UnknownConstructor {
type_name,
found: found.to_owned(),
}
}
// ===========================================================================
// TimeAnchor.
// ===========================================================================
/// A stored time reference. The variant order and every struct-variant's field
/// order mirror `impl Codec for TimeAnchor` exactly: `Event { id, offset }`,
/// `Measure { id, position, offset }`, `Region { id, edge, offset }`,
/// `WallClock { time }`.
///
/// The match in `project` is exhaustive with no `_` arm, so a new variant will
/// not compile until it is projected here. `parse` builds each variant directly
/// and reads its fields with their own strict parses, so it needs no
/// whole-value guard.
impl TextValue for TimeAnchor {
fn project(&self) -> Sexp {
match self {
TimeAnchor::Event { id, offset } => applied(
"Event",
vec![TextValue::project(id), TextValue::project(offset)],
),
TimeAnchor::Measure {
id,
position,
offset,
} => applied(
"Measure",
vec![
TextValue::project(id),
TextValue::project(position),
TextValue::project(offset),
],
),
TimeAnchor::Region { id, edge, offset } => applied(
"Region",
vec![
TextValue::project(id),
TextValue::project(edge),
TextValue::project(offset),
],
),
TimeAnchor::WallClock { time } => applied("WallClock", vec![TextValue::project(time)]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "TimeAnchor")? {
Variant::Applied(head, fields) if head == kebab("Event") => {
let [id, offset] = fields else {
return Err(arity("TimeAnchor", 2, fields.len()));
};
Ok(TimeAnchor::Event {
id: TextValue::parse(id)?,
offset: TextValue::parse(offset)?,
})
}
Variant::Applied(head, fields) if head == kebab("Measure") => {
let [id, position, offset] = fields else {
return Err(arity("TimeAnchor", 3, fields.len()));
};
Ok(TimeAnchor::Measure {
id: TextValue::parse(id)?,
position: TextValue::parse(position)?,
offset: TextValue::parse(offset)?,
})
}
Variant::Applied(head, fields) if head == kebab("Region") => {
let [id, edge, offset] = fields else {
return Err(arity("TimeAnchor", 3, fields.len()));
};
Ok(TimeAnchor::Region {
id: TextValue::parse(id)?,
edge: TextValue::parse(edge)?,
offset: TextValue::parse(offset)?,
})
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [time] = fields else {
return Err(arity("TimeAnchor", 1, fields.len()));
};
Ok(TimeAnchor::WallClock {
time: TextValue::parse(time)?,
})
}
Variant::Applied(head, _) | Variant::Nullary(head) => Err(unknown("TimeAnchor", head)),
}
}
}
// ===========================================================================
// EventPosition.
// ===========================================================================
/// An event's position, unioned over the two clocks. Variant order mirrors
/// `impl Codec for EventPosition`: `Musical(_)` then `WallClock(_)`.
impl TextValue for EventPosition {
fn project(&self) -> Sexp {
match self {
EventPosition::Musical(p) => applied("Musical", vec![TextValue::project(p)]),
EventPosition::WallClock(t) => applied("WallClock", vec![TextValue::project(t)]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "EventPosition")? {
Variant::Applied(head, fields) if head == kebab("Musical") => {
let [p] = fields else {
return Err(arity("EventPosition", 1, fields.len()));
};
Ok(EventPosition::Musical(TextValue::parse(p)?))
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [t] = fields else {
return Err(arity("EventPosition", 1, fields.len()));
};
Ok(EventPosition::WallClock(TextValue::parse(t)?))
}
Variant::Applied(head, _) | Variant::Nullary(head) => {
Err(unknown("EventPosition", head))
}
}
}
}
// ===========================================================================
// ConcreteDuration.
// ===========================================================================
/// A determinate duration in one clock. Variant order mirrors
/// `impl Codec for ConcreteDuration`: `Musical(_)` then `WallClock(_)`.
impl TextValue for ConcreteDuration {
fn project(&self) -> Sexp {
match self {
ConcreteDuration::Musical(d) => applied("Musical", vec![TextValue::project(d)]),
ConcreteDuration::WallClock(d) => applied("WallClock", vec![TextValue::project(d)]),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "ConcreteDuration")? {
Variant::Applied(head, fields) if head == kebab("Musical") => {
let [d] = fields else {
return Err(arity("ConcreteDuration", 1, fields.len()));
};
Ok(ConcreteDuration::Musical(TextValue::parse(d)?))
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [d] = fields else {
return Err(arity("ConcreteDuration", 1, fields.len()));
};
Ok(ConcreteDuration::WallClock(TextValue::parse(d)?))
}
Variant::Applied(head, _) | Variant::Nullary(head) => {
Err(unknown("ConcreteDuration", head))
}
}
}
}
// ===========================================================================
// EventDuration.
// ===========================================================================
/// An event's duration, unioned over musical, wall-clock, and indeterminate
/// forms. Variant order mirrors `impl Codec for EventDuration`: `Musical(_)`,
/// `WallClock(_)`, then `Indeterminate(_)` whose payload is a
/// [`DurationBounds`](crate::time::DurationBounds) — itself a `struct_codec!`
/// struct whose projection and strict parse the macro supplies.
impl TextValue for EventDuration {
fn project(&self) -> Sexp {
match self {
EventDuration::Musical(d) => applied("Musical", vec![TextValue::project(d)]),
EventDuration::WallClock(d) => applied("WallClock", vec![TextValue::project(d)]),
EventDuration::Indeterminate(b) => {
applied("Indeterminate", vec![TextValue::project(b)])
}
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "EventDuration")? {
Variant::Applied(head, fields) if head == kebab("Musical") => {
let [d] = fields else {
return Err(arity("EventDuration", 1, fields.len()));
};
Ok(EventDuration::Musical(TextValue::parse(d)?))
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [d] = fields else {
return Err(arity("EventDuration", 1, fields.len()));
};
Ok(EventDuration::WallClock(TextValue::parse(d)?))
}
Variant::Applied(head, fields) if head == kebab("Indeterminate") => {
let [b] = fields else {
return Err(arity("EventDuration", 1, fields.len()));
};
Ok(EventDuration::Indeterminate(TextValue::parse(b)?))
}
Variant::Applied(head, _) | Variant::Nullary(head) => {
Err(unknown("EventDuration", head))
}
}
}
}
// ===========================================================================
// TimeBounds.
// ===========================================================================
/// An aleatoric interval bound. Variant order and struct-variant field order
/// mirror `impl Codec for TimeBounds`: `MusicalRange { min, max }`,
/// `WallClockRange { min, max }`, then the fieldless `Unbounded`.
///
/// `Unbounded` is the bare symbol `unbounded`; the list `(unbounded)` is a
/// different spelling and is rejected below as an unknown *applied* constructor,
/// never accepted as `Unbounded`. The binary form imposes no `min <= max`
/// ordering and there is no validating constructor, so any `min`/`max` pair is
/// canonical and no per-site order check is owed.
impl TextValue for TimeBounds {
fn project(&self) -> Sexp {
match self {
TimeBounds::MusicalRange { min, max } => applied(
"MusicalRange",
vec![TextValue::project(min), TextValue::project(max)],
),
TimeBounds::WallClockRange { min, max } => applied(
"WallClockRange",
vec![TextValue::project(min), TextValue::project(max)],
),
TimeBounds::Unbounded => project_unit("Unbounded"),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "TimeBounds")? {
Variant::Nullary(head) if head == kebab("Unbounded") => Ok(TimeBounds::Unbounded),
Variant::Applied(head, fields) if head == kebab("MusicalRange") => {
let [min, max] = fields else {
return Err(arity("TimeBounds", 2, fields.len()));
};
Ok(TimeBounds::MusicalRange {
min: TextValue::parse(min)?,
max: TextValue::parse(max)?,
})
}
Variant::Applied(head, fields) if head == kebab("WallClockRange") => {
let [min, max] = fields else {
return Err(arity("TimeBounds", 2, fields.len()));
};
Ok(TimeBounds::WallClockRange {
min: TextValue::parse(min)?,
max: TextValue::parse(max)?,
})
}
Variant::Applied(head, _) | Variant::Nullary(head) => Err(unknown("TimeBounds", head)),
}
}
}
// ===========================================================================
// AnchorOffset.
// ===========================================================================
/// An offset applied to an anchor target, embedded in [`TimeAnchor`]. Variant
/// order mirrors `impl Codec for AnchorOffset`: `Musical(_)`, `WallClock(_)`,
/// then the fieldless `Zero`.
///
/// As with [`TimeBounds::Unbounded`], `Zero` is the bare symbol `zero` and its
/// list spelling `(zero)` is rejected rather than normalized.
impl TextValue for AnchorOffset {
fn project(&self) -> Sexp {
match self {
AnchorOffset::Musical(d) => applied("Musical", vec![TextValue::project(d)]),
AnchorOffset::WallClock(d) => applied("WallClock", vec![TextValue::project(d)]),
AnchorOffset::Zero => project_unit("Zero"),
}
}
fn parse(s: &Sexp) -> Result<Self, TextError> {
match classify(s, "AnchorOffset")? {
Variant::Nullary(head) if head == kebab("Zero") => Ok(AnchorOffset::Zero),
Variant::Applied(head, fields) if head == kebab("Musical") => {
let [d] = fields else {
return Err(arity("AnchorOffset", 1, fields.len()));
};
Ok(AnchorOffset::Musical(TextValue::parse(d)?))
}
Variant::Applied(head, fields) if head == kebab("WallClock") => {
let [d] = fields else {
return Err(arity("AnchorOffset", 1, fields.len()));
};
Ok(AnchorOffset::WallClock(TextValue::parse(d)?))
}
Variant::Applied(head, _) | Variant::Nullary(head) => {
Err(unknown("AnchorOffset", head))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::{EventId, MeasureId, RegionId, ReplicaId};
use crate::textvalue::read_sexp;
use crate::time::{
DurationBounds, MeasurePosition, MusicalDuration, MusicalPosition, RationalTime,
RegionEdge, WallClockDuration, WallClockTime,
};
fn rt(n: i64, d: i64) -> RationalTime {
RationalTime::new(n, d).unwrap()
}
/// Build a value, project it, render it, read it back with the strict reader,
/// parse it, and require the result to equal the original.
#[track_caller]
fn round_trip<T: TextValue + PartialEq + std::fmt::Debug>(value: T) {
let text = value.project().render();
let read = read_sexp(&text)
.unwrap_or_else(|e| panic!("projection {text:?} was rejected by read_sexp: {e}"));
let back =
T::parse(&read).unwrap_or_else(|e| panic!("projection {text:?} did not parse: {e}"));
assert_eq!(value, back, "{text:?} did not round-trip");
}
/// The strict reader must accept `text`, and the typed parse must then reject
/// it — the projection is well-formed lexically but is not the canonical
/// projection of any value of `T`.
#[track_caller]
fn rejects<T: TextValue + std::fmt::Debug>(text: &str) {
let s = read_sexp(text).unwrap_or_else(|e| panic!("{text:?} was not lexable: {e}"));
assert!(
T::parse(&s).is_err(),
"{text:?} was accepted; strict parsing forbids it"
);
}
// -------------------------------------------------------------------
// TimeAnchor.
// -------------------------------------------------------------------
#[test]
fn time_anchor_round_trips_every_variant() {
round_trip(TimeAnchor::WallClock {
time: WallClockTime(42),
});
round_trip(TimeAnchor::Event {
id: EventId::new(ReplicaId(1), 2),
offset: AnchorOffset::Zero,
});
round_trip(TimeAnchor::Measure {
id: MeasureId::new(ReplicaId(3), 4),
position: MeasurePosition::Start,
offset: AnchorOffset::Musical(MusicalDuration(rt(1, 4))),
});
round_trip(TimeAnchor::Region {
id: RegionId::new(ReplicaId(5), 6),
edge: RegionEdge::End,
offset: AnchorOffset::WallClock(WallClockDuration(10)),
});
}
#[test]
fn time_anchor_projects_fields_in_codec_order() {
let anchor = TimeAnchor::Measure {
id: MeasureId::new(ReplicaId(0), 1),
position: MeasurePosition::End,
offset: AnchorOffset::Zero,
};
// id, then position, then offset — the order `impl Codec` writes.
assert_eq!(
anchor.project().render(),
"(measure #x00000000000000000000000000000001 end zero)"
);
}
#[test]
fn time_anchor_rejects_wrong_arity_and_unknown_constructor() {
// A one-field `WallClock` written with none, and a `Measure` with too few.
rejects::<TimeAnchor>("(wall-clock)");
rejects::<TimeAnchor>("(measure #x00000000000000000000000000000001 start)");
// No such variant.
rejects::<TimeAnchor>("(sometime 1)");
// A field-bearing union is never a bare symbol.
rejects::<TimeAnchor>("wall-clock");
}
// -------------------------------------------------------------------
// EventPosition.
// -------------------------------------------------------------------
#[test]
fn event_position_round_trips() {
round_trip(EventPosition::Musical(MusicalPosition(rt(3, 4))));
round_trip(EventPosition::WallClock(WallClockTime(-7)));
}
/// The inner rational is parsed strictly, so a non-reduced ratio is rejected
/// through the projection rather than reduced inside it.
#[test]
fn event_position_rejects_non_canonical_field_and_shape() {
rejects::<EventPosition>("(musical (ratio 2 4))");
rejects::<EventPosition>("(musical)");
rejects::<EventPosition>("(musical (ratio 1 2) (ratio 1 2))");
rejects::<EventPosition>("(bogus (ratio 1 2))");
}
// -------------------------------------------------------------------
// ConcreteDuration.
// -------------------------------------------------------------------
#[test]
fn concrete_duration_round_trips() {
round_trip(ConcreteDuration::Musical(MusicalDuration(rt(1, 4))));
round_trip(ConcreteDuration::WallClock(WallClockDuration(500)));
}
#[test]
fn concrete_duration_rejects_non_canonical_field_and_shape() {
rejects::<ConcreteDuration>("(musical (ratio 2 4))");
rejects::<ConcreteDuration>("(wall-clock 1 2)");
rejects::<ConcreteDuration>("(indeterminate 1)");
}
// -------------------------------------------------------------------
// EventDuration.
// -------------------------------------------------------------------
#[test]
fn event_duration_round_trips_including_indeterminate() {
round_trip(EventDuration::Musical(MusicalDuration(rt(1, 8))));
round_trip(EventDuration::WallClock(WallClockDuration(250)));
round_trip(EventDuration::Indeterminate(DurationBounds {
lower: Some(ConcreteDuration::Musical(MusicalDuration(rt(1, 4)))),
upper: Some(ConcreteDuration::WallClock(WallClockDuration(1000))),
}));
round_trip(EventDuration::Indeterminate(DurationBounds {
lower: None,
upper: None,
}));
}
#[test]
fn event_duration_rejects_wrong_arity_and_unknown_constructor() {
rejects::<EventDuration>("(indeterminate)");
rejects::<EventDuration>("(musical (ratio 2 4))");
rejects::<EventDuration>("(nope 1)");
}
// -------------------------------------------------------------------
// TimeBounds.
// -------------------------------------------------------------------
#[test]
fn time_bounds_round_trips_every_variant() {
round_trip(TimeBounds::MusicalRange {
min: MusicalPosition(rt(0, 1)),
max: MusicalPosition(rt(4, 1)),
});
round_trip(TimeBounds::WallClockRange {
min: WallClockTime(0),
max: WallClockTime(1000),
});
round_trip(TimeBounds::Unbounded);
}
/// `Unbounded` projects to the bare symbol, so the list spelling `(unbounded)`
/// is not its canonical projection and must be rejected — not normalized into
/// `Unbounded`. Symmetrically, a range variant is never a bare symbol.
#[test]
fn time_bounds_rejects_the_list_form_of_the_fieldless_variant() {
rejects::<TimeBounds>("(unbounded)");
rejects::<TimeBounds>("musical-range");
rejects::<TimeBounds>("(musical-range (ratio 1 2))");
rejects::<TimeBounds>("(musical-range (ratio 2 4) (ratio 1 2))");
}
// -------------------------------------------------------------------
// AnchorOffset (the dependency TimeAnchor embeds).
// -------------------------------------------------------------------
#[test]
fn anchor_offset_round_trips_every_variant() {
round_trip(AnchorOffset::Musical(MusicalDuration(rt(1, 16))));
round_trip(AnchorOffset::WallClock(WallClockDuration(-3)));
round_trip(AnchorOffset::Zero);
}
#[test]
fn anchor_offset_rejects_the_list_form_of_zero() {
rejects::<AnchorOffset>("(zero)");
rejects::<AnchorOffset>("musical");
rejects::<AnchorOffset>("(musical (ratio 2 4))");
}
}

View File

@ -108,7 +108,7 @@ impl RationalTime {
/// [`RationalTime::Small`] when the normalized value fits the inline range.
/// This is the single chokepoint that maintains the canonical-form
/// invariant.
fn from_big(value: BigRational) -> Self {
pub(crate) fn from_big(value: BigRational) -> Self {
// `BigRational` keeps the denominator positive and the fraction
// reduced, so the sign lives on the numerator.
let numer = value.numer();
@ -126,7 +126,7 @@ impl RationalTime {
/// The value as a [`BigRational`] (allocates for the inline case; used on
/// the slow arithmetic path and for canonical encoding).
fn to_big(&self) -> BigRational {
pub(crate) fn to_big(&self) -> BigRational {
match self {
RationalTime::Small(s) => {
BigRational::new(BigInt::from(s.numerator), BigInt::from(s.denominator.get()))

View File

@ -0,0 +1,120 @@
//! The constructor symbol of every hand-written projection must be the kebab of
//! the Rust name it stands for.
//!
//! # Why this exists
//!
//! The 116 macro-generated impls take their constructor symbol from
//! `kebab(stringify!($ty))`, so it cannot be wrong. The hand-written impls spell
//! it as a string literal — `Sexp::sym("measured-fraction")` — and a typo there is
//! **invisible to every other test in the suite**. `project` and `parse` would
//! agree with each other on `"measured-fracton"`, the value would round-trip, the
//! text would be self-consistent, and it would still not be the projection
//! `req:textproj:value-projection` clause 1 and 3 specify.
//!
//! `Debug` is derived on all of these, and its output begins with the variant or
//! struct name. So the name is recoverable at runtime and can be compared against
//! the symbol the projection actually emits. That closes the gap.
//!
//! What it does **not** close: field *order* inside a variant. See
//! `textvalue_roundtrip.rs`.
use epiphany_core::textvalue::{kebab, Sexp, TextValue};
/// Asserts that `value`'s projection is headed by the kebab of its Rust name.
///
/// Applies to a fieldless variant or zero-field struct (a bare symbol) and to a
/// struct or a variant with fields (a list headed by a symbol). It does **not**
/// apply to a transparent newtype, which by clause 2 emits no name at all.
#[track_caller]
fn projects_under_its_own_name<T: TextValue + std::fmt::Debug>(value: &T) {
let debug = format!("{value:?}");
let rust_name = debug
.split(['(', ' ', '{'])
.next()
.expect("Debug output is non-empty");
let expected = kebab(rust_name);
let projected = value.project();
let head = match &projected {
Sexp::Symbol(name) => name.clone(),
Sexp::List(items) => items
.first()
.and_then(Sexp::as_symbol)
.unwrap_or_else(|| {
panic!("{rust_name} projects to a list not headed by a symbol: {projected:?}")
})
.to_owned(),
other => panic!("{rust_name} projects to {other:?}, which carries no constructor name"),
};
assert_eq!(
head, expected,
"{rust_name} projects under the name `{head}`, but its Rust name kebabs to \
`{expected}`. A round-trip test cannot see this: `parse` reads the same \
wrong symbol `project` wrote."
);
}
mod event {
use super::projects_under_its_own_name;
use epiphany_core::{
Event, EventDuration, EventPosition, GraceKind, IndeterminacyKind, MusicalDuration,
MusicalPosition, PitchId, RationalTime, ReplicaId, Rest, StaffPosition, TrajectoryEndpoint,
TrajectoryShape,
};
fn replica() -> ReplicaId {
ReplicaId::from_entropy([1; 8]).expect("a non-system-derived replica")
}
fn a_rest() -> Rest {
Rest {
id: epiphany_core::EventId::new(replica(), 1),
voice: epiphany_core::VoiceId::new(replica(), 1),
position: EventPosition::Musical(MusicalPosition(RationalTime::zero())),
duration: EventDuration::Musical(MusicalDuration(
RationalTime::new(1, 4).expect("a valid quarter"),
)),
vertical_position: Some(StaffPosition(-2)),
visible: true,
}
}
#[test]
fn grace_kind_variants_project_under_their_own_names() {
for value in [
GraceKind::Acciaccatura,
GraceKind::Appoggiatura,
GraceKind::Unmeasured,
] {
projects_under_its_own_name(&value);
}
}
#[test]
fn indeterminacy_kind_variants_project_under_their_own_names() {
for value in [
IndeterminacyKind::Pitch,
IndeterminacyKind::Duration,
IndeterminacyKind::Choice,
] {
projects_under_its_own_name(&value);
}
projects_under_its_own_name(&IndeterminacyKind::Compound(vec![IndeterminacyKind::Pitch]));
}
#[test]
fn trajectory_variants_project_under_their_own_names() {
projects_under_its_own_name(&TrajectoryShape::Linear);
projects_under_its_own_name(&TrajectoryEndpoint::EventPitch(PitchId::new(replica(), 1)));
}
/// Also covers a `struct_codec!` struct (`Rest`), whose head comes from the
/// macro, and the `Event` variant that wraps it, whose head is hand-spelled.
#[test]
fn event_variants_project_under_their_own_names() {
let rest = a_rest();
projects_under_its_own_name(&rest);
projects_under_its_own_name(&Event::Rest(rest));
}
}

View File

@ -0,0 +1,121 @@
//! The text projection's own injectivity, exercised over generated scores.
//!
//! `req:textproj:roundtrip` states two equations. The one a test can check with
//! byte equality is the *text's* injectivity:
//!
//! ```text
//! project(serialize(parse(T))) = T
//! ```
//!
//! For a single value that reads: rendering a value, reading it back, and
//! rendering again must give the same bytes; and the value that comes back must
//! equal the value that went in. Both directions are asserted here for every
//! `Event` of every score `epiphany_core::generators` can build, which reaches the
//! whole of the Chapter-5 value graph that operations embed.
//!
//! # What this test cannot see
//!
//! A `project`/`parse` pair that agrees with *itself* on a wrong field order round
//! trips perfectly. Two adjacent fields of the same type, swapped in both
//! directions, are invisible here and to the compiler both. Order is pinned
//! elsewhere:
//!
//! * for the 82 `struct_codec!` structs, by construction — the same field list
//! generates the binary codec and the projection, so they cannot disagree;
//! * for the 44 hand-written impls, by a mechanical diff of the identifier
//! sequence in `impl Codec for T`'s `fn enc` against the one in `project`.
//! `enc`'s order *is* the ratified declaration order, and all 44 agreed.
//!
//! Saying so is the point. A green round-trip is not evidence of correct order,
//! and this file must not be read as if it were.
//!
//! `textvalue_names.rs` covers the neighbouring blind spot: a mistyped
//! constructor symbol, which is equally invisible to a round trip because `parse`
//! reads back whatever `project` wrote.
use std::collections::BTreeSet;
use epiphany_core::generators::{arbitrary_graph_corpus, valid_score_rich};
use epiphany_core::textvalue::{read_sexp, TextValue};
use epiphany_core::Score;
/// Renders `value`, reads it back, and asserts both the value and its rendering
/// survive unchanged.
#[track_caller]
fn round_trips<T: TextValue + PartialEq + std::fmt::Debug>(value: &T) {
let text = value.project().render();
let sexp = read_sexp(&text).unwrap_or_else(|e| panic!("rejected its own output: {text}: {e}"));
let back = T::parse(&sexp).unwrap_or_else(|e| panic!("rejected its own output: {text}: {e}"));
assert_eq!(*value, back, "value changed across the projection");
assert_eq!(
back.project().render(),
text,
"re-projection is not byte-identical"
);
}
/// Every event of every generated score, through text and back.
#[test]
fn every_generated_event_round_trips_through_its_projection() {
let mut events = 0usize;
for score in arbitrary_graph_corpus(24, 0xF0F0_1234) {
for event in score.events.iter_canonical() {
round_trips(event);
events += 1;
}
}
// A round-trip test that round-trips nothing is a test that passes for the
// wrong reason. The corpus must actually carry events.
assert!(
events > 100,
"the corpus reached only {events} events; it proves almost nothing"
);
}
/// The arena projects as a sequence, so it exercises the ordering rule as well as
/// every event.
#[test]
fn a_whole_event_arena_round_trips() {
let score = valid_score_rich(7);
assert!(
score.events.iter_canonical().count() > 1,
"need at least two events to exercise the arena's ordering"
);
round_trips(&score.events);
}
/// Distinct values must render to distinct text: the projection determines the
/// document, so two documents may not share a projection
/// (`req:textproj:canonical-text`).
#[test]
fn distinct_scores_project_to_distinct_text() {
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut arenas = 0usize;
for score in arbitrary_graph_corpus(16, 0x5EED) {
let text = score.events.project().render();
if !seen.insert(text) {
// Two generated scores may legitimately share an event arena; only a
// *different* arena rendering to the same text would be a defect. Check
// that directly rather than assuming distinctness.
continue;
}
arenas += 1;
}
assert!(
arenas > 1,
"the corpus produced only {arenas} distinct arenas"
);
// The real statement: equal text implies equal value.
let corpus: Vec<Score> = arbitrary_graph_corpus(16, 0x5EED).collect();
for a in &corpus {
for b in &corpus {
let same_text = a.events.project().render() == b.events.project().render();
assert_eq!(
same_text,
a.events.iter_canonical().eq(b.events.iter_canonical()),
"text equality and value equality disagree"
);
}
}
}

View File

@ -438,7 +438,7 @@ pub const REGISTERED_TAG_DISCRIMINANT: u8 = 16;
/// separate hand-maintained lists — two of them asserting the tag was *unknown*
/// — stayed green (Push 5 / P4).
macro_rules! operation_kind_tag_vocabulary {
($($variant:ident = $disc:literal),+ $(,)?) => {
($($variant:ident = $disc:literal => $catalog:literal),+ $(,)?) => {
impl OperationKindTag {
/// Every payload-free tag, in discriminant order. [`Registered`]
/// is excluded: it carries an id and has no bare encoding.
@ -465,41 +465,54 @@ macro_rules! operation_kind_tag_vocabulary {
_ => return None,
})
}
/// The name this kind carries in the Text Projection, which is the
/// **Operation Catalog's** section name, not this enum's variant
/// name. The tag space renamed three pairs (`InsertRegion`,
/// `InsertStaff`, `InsertStaffInstance`); the projection keeps the
/// catalog's `create-*`, because that is what the specification a
/// reader holds calls them.
pub fn catalog_name(&self) -> &'static str {
match self {
$(OperationKindTag::$variant => $catalog,)+
OperationKindTag::Registered(_) => "registered",
}
}
}
};
}
operation_kind_tag_vocabulary! {
InsertEvent = 0,
DeleteEvent = 1,
ModifyEvent = 2,
RespellPitch = 3,
Transpose = 4,
CreateCrossCutting = 5,
DeleteCrossCutting = 6,
ModifyCrossCutting = 7,
ChangeRegionTimeModel = 8,
InsertRegion = 9,
DeleteRegion = 10,
InsertStaffInstance = 11,
DeleteStaffInstance = 12,
SetUserSystemBreak = 13,
SetUserPageBreak = 14,
DeclareTransaction = 15,
InsertIdentifiedPitch = 17,
DeleteIdentifiedPitch = 18,
ModifyIdentifiedPitch = 19,
CreateVoice = 20,
DeleteVoice = 21,
SetMetadata = 22,
SetMetricGrid = 23,
InsertStaff = 24,
SetTimeSignature = 25,
SetTempoSegment = 26,
SetStaffLayout = 27,
CreateRepeatStructure = 28,
DeleteRepeatStructure = 29,
TransposeInterval = 30,
InsertEvent = 0 => "insert-event",
DeleteEvent = 1 => "delete-event",
ModifyEvent = 2 => "modify-event",
RespellPitch = 3 => "respell-pitch",
Transpose = 4 => "transpose",
CreateCrossCutting = 5 => "create-cross-cutting",
DeleteCrossCutting = 6 => "delete-cross-cutting",
ModifyCrossCutting = 7 => "modify-cross-cutting",
ChangeRegionTimeModel = 8 => "change-region-time-model",
InsertRegion = 9 => "create-region",
DeleteRegion = 10 => "delete-region",
InsertStaffInstance = 11 => "create-staff-instance",
DeleteStaffInstance = 12 => "delete-staff-instance",
SetUserSystemBreak = 13 => "set-user-system-break",
SetUserPageBreak = 14 => "set-user-page-break",
DeclareTransaction = 15 => "declare-transaction",
InsertIdentifiedPitch = 17 => "insert-identified-pitch",
DeleteIdentifiedPitch = 18 => "delete-identified-pitch",
ModifyIdentifiedPitch = 19 => "modify-identified-pitch",
CreateVoice = 20 => "create-voice",
DeleteVoice = 21 => "delete-voice",
SetMetadata = 22 => "set-metadata",
SetMetricGrid = 23 => "set-metric-grid",
InsertStaff = 24 => "create-staff",
SetTimeSignature = 25 => "set-time-signature",
SetTempoSegment = 26 => "set-tempo-segment",
SetStaffLayout = 27 => "set-staff-layout",
CreateRepeatStructure = 28 => "create-repeat-structure",
DeleteRepeatStructure = 29 => "delete-repeat-structure",
TransposeInterval = 30 => "transpose-interval",
}
impl CanonicalEncode for OperationKindTag {

View File

@ -11,10 +11,11 @@
//! 1. Every nonterminal the grammar references is defined, and every nonterminal
//! it defines is reachable from `projection`.
//! 2. The `kind` alternatives are **exactly** the operation vocabulary, *derived*
//! from `OperationKindTag` rather than transcribed. Adding a tag without
//! adding its production fails here, and the `match` below fails to compile
//! without a name for it — the same two-layer guarantee
//! `operation_kind_tag_vocabulary!` gives the decoder.
//! from `OperationKindTag::catalog_name` rather than transcribed. That name is
//! generated by the same `operation_kind_tag_vocabulary!` list as the
//! discriminant and the decoder, so a tag added to the enum and not to the
//! vocabulary fails to compile, and one added to the vocabulary but not the
//! grammar fails here.
//! 3. The `chunk-kind` alternatives are exactly the `ChunkKind` vocabulary.
//! 4. The escape rule excludes the four codepoints
//! `req:textproj:string-escapes` requires a writer to escape — the
@ -202,49 +203,6 @@ fn every_nonterminal_is_defined_and_reachable() {
);
}
/// The Operation Catalog's section name, hyphenated, for each tag.
///
/// Exhaustive over `OperationKindTag`, so a new operation kind cannot be added
/// without naming it here — and `the_kind_productions_are_the_operation_vocabulary`
/// then fails until the grammar carries it. The tag names are *not* the catalog
/// names: the tag space renamed three pairs (`InsertRegion`, `InsertStaff`,
/// `InsertStaffInstance`), and the projection follows the semantics.
fn catalog_name(tag: OperationKindTag) -> &'static str {
match tag {
OperationKindTag::InsertEvent => "insert-event",
OperationKindTag::DeleteEvent => "delete-event",
OperationKindTag::ModifyEvent => "modify-event",
OperationKindTag::RespellPitch => "respell-pitch",
OperationKindTag::Transpose => "transpose",
OperationKindTag::TransposeInterval => "transpose-interval",
OperationKindTag::CreateCrossCutting => "create-cross-cutting",
OperationKindTag::DeleteCrossCutting => "delete-cross-cutting",
OperationKindTag::ModifyCrossCutting => "modify-cross-cutting",
OperationKindTag::ChangeRegionTimeModel => "change-region-time-model",
OperationKindTag::InsertRegion => "create-region",
OperationKindTag::DeleteRegion => "delete-region",
OperationKindTag::InsertStaffInstance => "create-staff-instance",
OperationKindTag::DeleteStaffInstance => "delete-staff-instance",
OperationKindTag::SetUserSystemBreak => "set-user-system-break",
OperationKindTag::SetUserPageBreak => "set-user-page-break",
OperationKindTag::DeclareTransaction => "declare-transaction",
OperationKindTag::Registered(_) => "registered",
OperationKindTag::InsertIdentifiedPitch => "insert-identified-pitch",
OperationKindTag::DeleteIdentifiedPitch => "delete-identified-pitch",
OperationKindTag::ModifyIdentifiedPitch => "modify-identified-pitch",
OperationKindTag::CreateVoice => "create-voice",
OperationKindTag::DeleteVoice => "delete-voice",
OperationKindTag::SetMetadata => "set-metadata",
OperationKindTag::SetMetricGrid => "set-metric-grid",
OperationKindTag::InsertStaff => "create-staff",
OperationKindTag::SetTimeSignature => "set-time-signature",
OperationKindTag::SetTempoSegment => "set-tempo-segment",
OperationKindTag::SetStaffLayout => "set-staff-layout",
OperationKindTag::CreateRepeatStructure => "create-repeat-structure",
OperationKindTag::DeleteRepeatStructure => "delete-repeat-structure",
}
}
/// The right-hand side of `production`, spanning its continuation lines.
///
/// Located by name, not by column: a grammar reflow must not silently turn a
@ -314,7 +272,12 @@ fn the_kind_productions_are_the_operation_vocabulary() {
.chain(std::iter::once(OperationKindTag::Registered(
epiphany_ops::OperationKindRegistryId(0),
)))
.map(|t| catalog_name(t).to_string())
// `catalog_name` is production code, generated by the same
// `operation_kind_tag_vocabulary!` list that generates the discriminant and
// the decoder. It was a test-local `match` until 0.5.0 — a hand-maintained
// list parallel to an enum, which is the exact shape that cost this project
// four bugs.
.map(|t| t.catalog_name().to_string())
.collect();
assert_eq!(
expected.len(),