diff --git a/crates/epiphany-core/DECISIONS.md b/crates/epiphany-core/DECISIONS.md index c8ecf0d..7013e12 100644 --- a/crates/epiphany-core/DECISIONS.md +++ b/crates/epiphany-core/DECISIONS.md @@ -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. diff --git a/crates/epiphany-core/src/codec.rs b/crates/epiphany-core/src/codec.rs index ee88631..1678f17 100644 --- a/crates/epiphany-core/src/codec.rs +++ b/crates/epiphany-core/src/codec.rs @@ -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 { + 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 { + 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 { + 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 { + crate::textvalue_impls::parse_catalog_id(s, $ty::new, |v| v.as_str()) + } + } )* }; } diff --git a/crates/epiphany-core/src/lib.rs b/crates/epiphany-core/src/lib.rs index 9d5fdf1..59d6b80 100644 --- a/crates/epiphany-core/src/lib.rs +++ b/crates/epiphany-core/src/lib.rs @@ -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; diff --git a/crates/epiphany-core/src/textvalue.rs b/crates/epiphany-core/src/textvalue.rs index 26fa9e2..ed23ffa 100644 --- a/crates/epiphany-core/src/textvalue.rs +++ b/crates/epiphany-core/src/textvalue.rs @@ -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 TextValue for BTreeSet { } } +/// 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 TextValue for (A, B) { + fn project(&self) -> Sexp { + Sexp::List(vec![self.0.project(), self.1.project()]) + } + fn parse(s: &Sexp) -> Result { + 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 `( )`")); + }; + Ok((A::parse(first)?, B::parse(second)?)) + } +} + /// A map projects as a list of `( )` entries, strictly increasing by /// key, and parses the same. Same reasoning as [`BTreeSet`]. impl TextValue for BTreeMap { diff --git a/crates/epiphany-core/src/textvalue_event.rs b/crates/epiphany-core/src/textvalue_event.rs new file mode 100644 index 0000000..9069380 --- /dev/null +++ b/crates/epiphany-core/src/textvalue_event.rs @@ -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 `( )`, 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 `( …)`, 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 { + ::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 { + ::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 )` +/// (`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 { + 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 (…))`. 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 { + 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::::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 { + 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 (…))`. +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 { + 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::::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 +/// `( )`, the kebab of the *variant* name wrapping the payload +/// record's own struct projection — e.g. `(rest (rest ))` and +/// `(pitched (pitched-event ))`. A single-field variant projects as +/// `( )`, 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 { + 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 { + let items = s.as_list().ok_or(TextError::Expected { + expected: "EventArena", + found: class_of(s), + })?; + let mut arena = EventArena::new(); + let mut previous: Option = 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(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 `( )`, 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" + )) + ); + } +} diff --git a/crates/epiphany-core/src/textvalue_graph.rs b/crates/epiphany-core/src/textvalue_graph.rs new file mode 100644 index 0000000..6e939ad --- /dev/null +++ b/crates/epiphany-core/src/textvalue_graph.rs @@ -0,0 +1,1088 @@ +//! [`TextValue`] for the Chapter-5 graph types whose binary [`Codec`] is +//! hand-written rather than macro-generated. +//! +//! The macro families in `codec.rs` (`struct_codec!`, `cstyle_enum_codec!`, +//! `unit_codec!`, `catalog_id_codec!`) emit a `TextValue` alongside every binary +//! codec, so those types cannot drift. This module is for the graph types whose +//! `impl Codec` is spelled out by hand — tagged unions, structs with private +//! fields and validating constructors, and two newtypes that need a byte string +//! rather than the transparent field. Each impl below **mirrors the field and +//! variant order of the matching `fn enc` in `codec.rs`** (that order is the +//! ratified declaration order), so the projection cannot diverge from the wire. +//! +//! Every `parse` obeys `req:textproj:strict-parse`: it rejects text that is not +//! the canonical projection of the value it denotes rather than normalizing it. +//! A fieldless variant is *only* its bare symbol, so its list spelling is +//! rejected; a validating constructor's rejection is surfaced, not swallowed; and +//! where a constructor could launder non-canonical input into a canonical value +//! ([`EventOrderingDAG::try_new`]), the result is re-projected and compared with +//! [`ensure_canonical`]. +//! +//! [`Codec`]: crate::codec::Codec + +use std::collections::BTreeMap; + +use epiphany_determinism::CanonicalF64; + +use crate::graph::{ + AnnotationAnchor, DecompositionSource, EventOrderingDAG, GestureAnchoring, KeySignature, + MetadataValue, RegionContent, RegionTimeModel, RepeatKind, SoundConfiguration, SpaceUnit, + SpannerKind, StaffGroupKind, TieClass, TimeSignature, TimeSignatureDisplay, Timestamp, + TupletRatio, VoiceOrigin, +}; +use crate::textvalue::{kebab, Sexp, TextError, TextValue}; +use crate::textvalue_impls::class_of; + +// =========================================================================== +// Tagged-union helpers. +// =========================================================================== +// +// `req:textproj:value-projection` clause 3: a tagged-union variant is +// `( …)`, and a variant with no fields is the bare symbol +// ``. These helpers give every hand-written union one strict reading of +// that rule. + +/// Projects a tagged-union variant: the bare symbol `` when it has no +/// fields, `( …)` when it has. +fn variant(name: &str, fields: Vec) -> 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) + } +} + +/// Splits a tagged-union projection into its constructor name and, when it is a +/// list, the fields after the head. +/// +/// A bare symbol yields `None` fields; a list yields `Some(fields)`. Keeping the +/// two forms apart is what makes the parse strict (`req:textproj:strict-parse`): +/// a fieldless variant projects to a bare symbol, so a caller can reject its list +/// spelling `(volta)` instead of silently reading it as `volta`, and a +/// field-bearing variant can reject a bare-symbol spelling. +fn split_variant(s: &Sexp) -> Result<(&str, Option<&[Sexp]>), TextError> { + match s { + Sexp::Symbol(name) => Ok((name.as_str(), None)), + Sexp::List(items) => { + let head = items + .first() + .and_then(Sexp::as_symbol) + .ok_or(TextError::Syntax( + "a variant is a bare symbol or a list headed by its constructor", + ))?; + Ok((head, Some(&items[1..]))) + } + _ => Err(TextError::Expected { + expected: "variant", + found: class_of(s), + }), + } +} + +/// Confirms a fieldless variant was spelled as its bare symbol, not `(name)`. +/// Accepting the list spelling would fold two texts onto one value. +fn no_fields(fields: Option<&[Sexp]>) -> Result<(), TextError> { + match fields { + None => Ok(()), + Some(_) => Err(TextError::NotCanonical( + "a fieldless variant is a bare symbol, not a list", + )), + } +} + +/// Confirms a variant with `arity` fields was spelled as a list of exactly that +/// many fields, and returns them. A bare-symbol spelling of a field-bearing +/// variant is rejected here. +fn fields_of<'a>( + fields: Option<&'a [Sexp]>, + type_name: &'static str, + arity: usize, +) -> Result<&'a [Sexp], TextError> { + let fields = fields.ok_or(TextError::Expected { + expected: "variant with fields", + found: "symbol", + })?; + if fields.len() != arity { + return Err(TextError::Arity { + type_name, + expected: arity, + found: fields.len(), + }); + } + Ok(fields) +} + +// =========================================================================== +// Newtypes. +// =========================================================================== + +/// A [`Timestamp`] is its wrapped `i64` alone (`req:textproj:value-projection` +/// clause 2): the newtype adds no bytes to the wire and no wrapper to the text. +impl TextValue for Timestamp { + fn project(&self) -> Sexp { + TextValue::project(&self.0) + } + fn parse(s: &Sexp) -> Result { + ::parse(s).map(Timestamp) + } +} + +/// A [`SpaceUnit`] is its wrapped [`CanonicalF64`] alone (clause 2). The float +/// leaf is a byte string of its eight canonical IEEE-754 bytes, never a decimal — +/// see the `CanonicalF64` impl in `textvalue_impls.rs`. +impl TextValue for SpaceUnit { + fn project(&self) -> Sexp { + TextValue::project(&self.0) + } + fn parse(s: &Sexp) -> Result { + ::parse(s).map(SpaceUnit) + } +} + +/// A [`SoundConfiguration`] projects as a **byte string**, not as a list of +/// integers. +/// +/// It wraps a `Vec`, and the generic `Vec` impl would render each byte as +/// a decimal integer inside a list. But the binary form writes this as one +/// length-prefixed opaque run, and `spec/text_projection.tex` +/// (`req:textproj:value-projection`) names `SoundConfiguration` explicitly: "the +/// projection writes a byte string, not a list of integers." The core never +/// interprets the bytes, so any `Vec` is a canonical value and the strict +/// byte-string reader already rejects a non-canonical spelling; there is nothing +/// to normalize, so `parse` is a direct read. +impl TextValue for SoundConfiguration { + fn project(&self) -> Sexp { + Sexp::Bytes(self.0.clone()) + } + fn parse(s: &Sexp) -> Result { + match s { + Sexp::Bytes(bytes) => Ok(SoundConfiguration(bytes.clone())), + _ => Err(TextError::Expected { + expected: "SoundConfiguration byte string", + found: class_of(s), + }), + } + } +} + +// =========================================================================== +// Structs with private fields / validating constructors. +// =========================================================================== + +/// `(key-signature )` — a struct with one named `i8` field +/// (`req:textproj:value-projection` clause 1). +/// +/// The field is private and the only constructor, [`KeySignature::new`], +/// *validates* the `-7..=7` circle-of-fifths range. It validates by **rejecting**, +/// never by normalizing, so a `None` from `new` is surfaced as a rejection and an +/// accepted value re-projects to exactly its input, so a whole-value guard here +/// could never fire. +impl TextValue for KeySignature { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::Symbol(kebab("KeySignature")), + TextValue::project(&self.fifths()), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct(&kebab("KeySignature"), 1)?; + let fifths = ::parse(&fields[0])?; + KeySignature::new(fifths).ok_or(TextError::NotCanonical( + "key-signature fifths outside the -7..=7 range", + )) + } +} + +/// `(tuplet-ratio )` — a struct with two private `u32` fields, +/// in `fn enc` order (`actual` then `notated`). +/// +/// [`TupletRatio::new`] rejects a *degenerate* ratio (a zero term, or +/// `actual == notated`); it does not reduce (`6:4` stays `6:4`, a value distinct +/// from `3:2` on the wire), so it never normalizes. The rejection is surfaced; an +/// accepted ratio re-projects to its input, so no guard is needed. +impl TextValue for TupletRatio { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::Symbol(kebab("TupletRatio")), + TextValue::project(&self.actual()), + TextValue::project(&self.notated()), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct(&kebab("TupletRatio"), 2)?; + let actual = ::parse(&fields[0])?; + let notated = ::parse(&fields[1])?; + TupletRatio::new(actual, notated).ok_or(TextError::NotCanonical( + "a tuplet ratio needs nonzero terms and actual != notated", + )) + } +} + +/// `(event-ordering-dag )` — a struct with one private field, the +/// `BTreeMap>` the codec writes via `edges_ref()`. +/// +/// The only constructor that can build a non-empty DAG, +/// [`EventOrderingDAG::try_new`], **validates acyclicity** and stores the map as +/// given. It rejects rather than adjusts, so an accepted value re-projects to +/// exactly its input. +/// +/// Note what that means this does *not* reject: a repeated successor in an +/// adjacency list. `try_new` admits it, and so does the binary form — the +/// successor list is an order-preserving `Vec`, so `[a, a]` and `[a]` are distinct +/// values there too. The projection is faithful to that, not lenient. +impl TextValue for EventOrderingDAG { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::Symbol(kebab("EventOrderingDAG")), + TextValue::project(self.edges_ref()), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct(&kebab("EventOrderingDAG"), 1)?; + let edges: BTreeMap> = + TextValue::parse(&fields[0])?; + // `try_new` checks acyclicity and stores the map as given — validation, + // not normalization — so an accepted value re-projects to its input. + // Note what this therefore does *not* reject: a repeated successor in an + // adjacency list. That is a distinct value in the binary form too (the + // successor list is an order-preserving `Vec`), so the projection is + // faithful, not lenient. + EventOrderingDAG::try_new(edges) + .ok_or(TextError::NotCanonical("event ordering contains a cycle")) + } +} + +/// `(time-signature )` — a struct +/// in `fn enc` order: `id`, `display`, then the two private fields +/// `measure_duration` and the `beat_groups` vector. +/// +/// [`TimeSignature::new`] enforces the Chapter-3 MUST that the beat-group +/// durations sum to `measure_duration`, rejecting on mismatch. That is validation +/// by rejection, not normalization — an accepted signature stores its fields +/// verbatim and re-projects to its input — so the `None` is surfaced and no guard +/// is needed. +impl TextValue for TimeSignature { + fn project(&self) -> Sexp { + Sexp::List(vec![ + Sexp::Symbol(kebab("TimeSignature")), + TextValue::project(&self.id), + TextValue::project(&self.display), + TextValue::project(self.measure_duration()), + Sexp::List(self.beat_groups().iter().map(TextValue::project).collect()), + ]) + } + fn parse(s: &Sexp) -> Result { + let fields = s.expect_struct(&kebab("TimeSignature"), 4)?; + let id = TextValue::parse(&fields[0])?; + let display = TextValue::parse(&fields[1])?; + let measure_duration = TextValue::parse(&fields[2])?; + let beat_groups = TextValue::parse(&fields[3])?; + TimeSignature::new(id, display, measure_duration, beat_groups).ok_or( + TextError::NotCanonical("beat groups do not sum to the measure duration"), + ) + } +} + +// =========================================================================== +// Tagged unions. +// =========================================================================== + +/// The `SpannerKind` variants, in `fn enc` tag order 0..=8. Each carries its +/// payload positionally. +impl TextValue for SpannerKind { + fn project(&self) -> Sexp { + match self { + SpannerKind::Generic => variant("Generic", vec![]), + SpannerKind::Hairpin(d) => variant("Hairpin", vec![d.project()]), + SpannerKind::OctaveLine(o) => variant("OctaveLine", vec![o.project()]), + SpannerKind::PedalLine(p) => variant("PedalLine", vec![p.project()]), + SpannerKind::TrillExtension => variant("TrillExtension", vec![]), + SpannerKind::Glissando => variant("Glissando", vec![]), + SpannerKind::Portamento => variant("Portamento", vec![]), + SpannerKind::TextLine(t) => variant("TextLine", vec![t.project()]), + SpannerKind::Bracket(b) => variant("Bracket", vec![b.project()]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("Generic") { + no_fields(fields)?; + Ok(SpannerKind::Generic) + } else if ctor == kebab("Hairpin") { + let f = fields_of(fields, "SpannerKind", 1)?; + Ok(SpannerKind::Hairpin(TextValue::parse(&f[0])?)) + } else if ctor == kebab("OctaveLine") { + let f = fields_of(fields, "SpannerKind", 1)?; + Ok(SpannerKind::OctaveLine(TextValue::parse(&f[0])?)) + } else if ctor == kebab("PedalLine") { + let f = fields_of(fields, "SpannerKind", 1)?; + Ok(SpannerKind::PedalLine(TextValue::parse(&f[0])?)) + } else if ctor == kebab("TrillExtension") { + no_fields(fields)?; + Ok(SpannerKind::TrillExtension) + } else if ctor == kebab("Glissando") { + no_fields(fields)?; + Ok(SpannerKind::Glissando) + } else if ctor == kebab("Portamento") { + no_fields(fields)?; + Ok(SpannerKind::Portamento) + } else if ctor == kebab("TextLine") { + let f = fields_of(fields, "SpannerKind", 1)?; + Ok(SpannerKind::TextLine(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Bracket") { + let f = fields_of(fields, "SpannerKind", 1)?; + Ok(SpannerKind::Bracket(TextValue::parse(&f[0])?)) + } else { + Err(TextError::UnknownConstructor { + type_name: "SpannerKind", + found: ctor.to_owned(), + }) + } + } +} + +/// The `RepeatKind` variants, in `fn enc` tag order 0..=3. `DalSegno` carries +/// `segno` then `end_target`, matching the declaration order the codec writes. +impl TextValue for RepeatKind { + fn project(&self) -> Sexp { + match self { + RepeatKind::SimpleRepeat { count } => variant("SimpleRepeat", vec![count.project()]), + RepeatKind::DaCapo { end_target } => variant("DaCapo", vec![end_target.project()]), + RepeatKind::DalSegno { segno, end_target } => { + variant("DalSegno", vec![segno.project(), end_target.project()]) + } + RepeatKind::Volta => variant("Volta", vec![]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("SimpleRepeat") { + let f = fields_of(fields, "RepeatKind", 1)?; + Ok(RepeatKind::SimpleRepeat { + count: TextValue::parse(&f[0])?, + }) + } else if ctor == kebab("DaCapo") { + let f = fields_of(fields, "RepeatKind", 1)?; + Ok(RepeatKind::DaCapo { + end_target: TextValue::parse(&f[0])?, + }) + } else if ctor == kebab("DalSegno") { + let f = fields_of(fields, "RepeatKind", 2)?; + Ok(RepeatKind::DalSegno { + segno: TextValue::parse(&f[0])?, + end_target: TextValue::parse(&f[1])?, + }) + } else if ctor == kebab("Volta") { + no_fields(fields)?; + Ok(RepeatKind::Volta) + } else { + Err(TextError::UnknownConstructor { + type_name: "RepeatKind", + found: ctor.to_owned(), + }) + } + } +} + +/// The `MetadataValue` variants, in `fn enc` tag order: `Text` (0), `Integer` +/// (1), `Flag` (2). +impl TextValue for MetadataValue { + fn project(&self) -> Sexp { + match self { + MetadataValue::Text(s) => variant("Text", vec![s.project()]), + MetadataValue::Integer(i) => variant("Integer", vec![i.project()]), + MetadataValue::Flag(b) => variant("Flag", vec![b.project()]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("Text") { + let f = fields_of(fields, "MetadataValue", 1)?; + Ok(MetadataValue::Text(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Integer") { + let f = fields_of(fields, "MetadataValue", 1)?; + Ok(MetadataValue::Integer(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Flag") { + let f = fields_of(fields, "MetadataValue", 1)?; + Ok(MetadataValue::Flag(TextValue::parse(&f[0])?)) + } else { + Err(TextError::UnknownConstructor { + type_name: "MetadataValue", + found: ctor.to_owned(), + }) + } + } +} + +/// The `RegionTimeModel` variants, in `fn enc` tag order: `Metric` (0), +/// `Proportional` (1), `Aleatoric` (2). +impl TextValue for RegionTimeModel { + fn project(&self) -> Sexp { + match self { + RegionTimeModel::Metric(m) => variant("Metric", vec![m.project()]), + RegionTimeModel::Proportional(p) => variant("Proportional", vec![p.project()]), + RegionTimeModel::Aleatoric(a) => variant("Aleatoric", vec![a.project()]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("Metric") { + let f = fields_of(fields, "RegionTimeModel", 1)?; + Ok(RegionTimeModel::Metric(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Proportional") { + let f = fields_of(fields, "RegionTimeModel", 1)?; + Ok(RegionTimeModel::Proportional(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Aleatoric") { + let f = fields_of(fields, "RegionTimeModel", 1)?; + Ok(RegionTimeModel::Aleatoric(TextValue::parse(&f[0])?)) + } else { + Err(TextError::UnknownConstructor { + type_name: "RegionTimeModel", + found: ctor.to_owned(), + }) + } + } +} + +/// The `RegionContent` variants, in `fn enc` tag order: `StaffBased` (0), +/// `FreeGraphic` (1), `Hybrid` (2). `Hybrid` carries `staves`, `overlay`, +/// `overlay_below_staves` in that order. +impl TextValue for RegionContent { + fn project(&self) -> Sexp { + match self { + RegionContent::StaffBased(c) => variant("StaffBased", vec![c.project()]), + RegionContent::FreeGraphic(g) => variant("FreeGraphic", vec![g.project()]), + RegionContent::Hybrid { + staves, + overlay, + overlay_below_staves, + } => variant( + "Hybrid", + vec![ + staves.project(), + overlay.project(), + overlay_below_staves.project(), + ], + ), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("StaffBased") { + let f = fields_of(fields, "RegionContent", 1)?; + Ok(RegionContent::StaffBased(TextValue::parse(&f[0])?)) + } else if ctor == kebab("FreeGraphic") { + let f = fields_of(fields, "RegionContent", 1)?; + Ok(RegionContent::FreeGraphic(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Hybrid") { + let f = fields_of(fields, "RegionContent", 3)?; + Ok(RegionContent::Hybrid { + staves: TextValue::parse(&f[0])?, + overlay: TextValue::parse(&f[1])?, + overlay_below_staves: TextValue::parse(&f[2])?, + }) + } else { + Err(TextError::UnknownConstructor { + type_name: "RegionContent", + found: ctor.to_owned(), + }) + } + } +} + +/// The `VoiceOrigin` variants, in `fn enc` tag order: `UserDeclared` (0), +/// `Imported` (1), `SystemPromoted` (2). `SystemPromoted` carries +/// `winning_operation`, `losing_operation`, `original_voice` in that order. +impl TextValue for VoiceOrigin { + fn project(&self) -> Sexp { + match self { + VoiceOrigin::UserDeclared => variant("UserDeclared", vec![]), + VoiceOrigin::Imported { format } => variant("Imported", vec![format.project()]), + VoiceOrigin::SystemPromoted { + winning_operation, + losing_operation, + original_voice, + } => variant( + "SystemPromoted", + vec![ + winning_operation.project(), + losing_operation.project(), + original_voice.project(), + ], + ), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("UserDeclared") { + no_fields(fields)?; + Ok(VoiceOrigin::UserDeclared) + } else if ctor == kebab("Imported") { + let f = fields_of(fields, "VoiceOrigin", 1)?; + Ok(VoiceOrigin::Imported { + format: TextValue::parse(&f[0])?, + }) + } else if ctor == kebab("SystemPromoted") { + let f = fields_of(fields, "VoiceOrigin", 3)?; + Ok(VoiceOrigin::SystemPromoted { + winning_operation: TextValue::parse(&f[0])?, + losing_operation: TextValue::parse(&f[1])?, + original_voice: TextValue::parse(&f[2])?, + }) + } else { + Err(TextError::UnknownConstructor { + type_name: "VoiceOrigin", + found: ctor.to_owned(), + }) + } + } +} + +/// The `StaffGroupKind` variants, in `fn enc` tag order 0..=4. The first four are +/// fieldless; `Registered` (4) carries a registry id. +impl TextValue for StaffGroupKind { + fn project(&self) -> Sexp { + match self { + StaffGroupKind::GrandStaff => variant("GrandStaff", vec![]), + StaffGroupKind::Bracket => variant("Bracket", vec![]), + StaffGroupKind::SubBracket => variant("SubBracket", vec![]), + StaffGroupKind::Choral => variant("Choral", vec![]), + StaffGroupKind::Registered(id) => variant("Registered", vec![id.project()]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("GrandStaff") { + no_fields(fields)?; + Ok(StaffGroupKind::GrandStaff) + } else if ctor == kebab("Bracket") { + no_fields(fields)?; + Ok(StaffGroupKind::Bracket) + } else if ctor == kebab("SubBracket") { + no_fields(fields)?; + Ok(StaffGroupKind::SubBracket) + } else if ctor == kebab("Choral") { + no_fields(fields)?; + Ok(StaffGroupKind::Choral) + } else if ctor == kebab("Registered") { + let f = fields_of(fields, "StaffGroupKind", 1)?; + Ok(StaffGroupKind::Registered(TextValue::parse(&f[0])?)) + } else { + Err(TextError::UnknownConstructor { + type_name: "StaffGroupKind", + found: ctor.to_owned(), + }) + } + } +} + +/// The `TieClass` variants, in `fn enc` tag order 0..=4. The first four are +/// fieldless; `Registered` (4) carries a registry id. +impl TextValue for TieClass { + fn project(&self) -> Sexp { + match self { + TieClass::Standard => variant("Standard", vec![]), + TieClass::Editorial => variant("Editorial", vec![]), + TieClass::CrossVoice => variant("CrossVoice", vec![]), + TieClass::LaissezVibrer => variant("LaissezVibrer", vec![]), + TieClass::Registered(id) => variant("Registered", vec![id.project()]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("Standard") { + no_fields(fields)?; + Ok(TieClass::Standard) + } else if ctor == kebab("Editorial") { + no_fields(fields)?; + Ok(TieClass::Editorial) + } else if ctor == kebab("CrossVoice") { + no_fields(fields)?; + Ok(TieClass::CrossVoice) + } else if ctor == kebab("LaissezVibrer") { + no_fields(fields)?; + Ok(TieClass::LaissezVibrer) + } else if ctor == kebab("Registered") { + let f = fields_of(fields, "TieClass", 1)?; + Ok(TieClass::Registered(TextValue::parse(&f[0])?)) + } else { + Err(TextError::UnknownConstructor { + type_name: "TieClass", + found: ctor.to_owned(), + }) + } + } +} + +/// The `AnnotationAnchor` variants, in `fn enc` tag order: `Event` (0), `Range` +/// (1), `Region` (2). `Range` carries `start` then `end`. +impl TextValue for AnnotationAnchor { + fn project(&self) -> Sexp { + match self { + AnnotationAnchor::Event(id) => variant("Event", vec![id.project()]), + AnnotationAnchor::Range { start, end } => { + variant("Range", vec![start.project(), end.project()]) + } + AnnotationAnchor::Region(id) => variant("Region", vec![id.project()]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("Event") { + let f = fields_of(fields, "AnnotationAnchor", 1)?; + Ok(AnnotationAnchor::Event(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Range") { + let f = fields_of(fields, "AnnotationAnchor", 2)?; + Ok(AnnotationAnchor::Range { + start: TextValue::parse(&f[0])?, + end: TextValue::parse(&f[1])?, + }) + } else if ctor == kebab("Region") { + let f = fields_of(fields, "AnnotationAnchor", 1)?; + Ok(AnnotationAnchor::Region(TextValue::parse(&f[0])?)) + } else { + Err(TextError::UnknownConstructor { + type_name: "AnnotationAnchor", + found: ctor.to_owned(), + }) + } + } +} + +/// The `GestureAnchoring` variants, in `fn enc` tag order: `Events` (0), `Range` +/// (1), `Free` (2). `Range` carries `start`, `end`, `staves` in that order. +impl TextValue for GestureAnchoring { + fn project(&self) -> Sexp { + match self { + GestureAnchoring::Events(v) => variant("Events", vec![v.project()]), + GestureAnchoring::Range { start, end, staves } => variant( + "Range", + vec![start.project(), end.project(), staves.project()], + ), + GestureAnchoring::Free => variant("Free", vec![]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("Events") { + let f = fields_of(fields, "GestureAnchoring", 1)?; + Ok(GestureAnchoring::Events(TextValue::parse(&f[0])?)) + } else if ctor == kebab("Range") { + let f = fields_of(fields, "GestureAnchoring", 3)?; + Ok(GestureAnchoring::Range { + start: TextValue::parse(&f[0])?, + end: TextValue::parse(&f[1])?, + staves: TextValue::parse(&f[2])?, + }) + } else if ctor == kebab("Free") { + no_fields(fields)?; + Ok(GestureAnchoring::Free) + } else { + Err(TextError::UnknownConstructor { + type_name: "GestureAnchoring", + found: ctor.to_owned(), + }) + } + } +} + +/// The `DecompositionSource` variants, in `fn enc` tag order: `UserChosen` (0), +/// `Inferred` (1), `Imported` (2), `Propagated` (3). +impl TextValue for DecompositionSource { + fn project(&self) -> Sexp { + match self { + DecompositionSource::UserChosen => variant("UserChosen", vec![]), + DecompositionSource::Inferred => variant("Inferred", vec![]), + DecompositionSource::Imported { format } => variant("Imported", vec![format.project()]), + DecompositionSource::Propagated { from } => variant("Propagated", vec![from.project()]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("UserChosen") { + no_fields(fields)?; + Ok(DecompositionSource::UserChosen) + } else if ctor == kebab("Inferred") { + no_fields(fields)?; + Ok(DecompositionSource::Inferred) + } else if ctor == kebab("Imported") { + let f = fields_of(fields, "DecompositionSource", 1)?; + Ok(DecompositionSource::Imported { + format: TextValue::parse(&f[0])?, + }) + } else if ctor == kebab("Propagated") { + let f = fields_of(fields, "DecompositionSource", 1)?; + Ok(DecompositionSource::Propagated { + from: TextValue::parse(&f[0])?, + }) + } else { + Err(TextError::UnknownConstructor { + type_name: "DecompositionSource", + found: ctor.to_owned(), + }) + } + } +} + +/// The `TimeSignatureDisplay` variants, in `fn enc` tag order 0..=5. `Standard`, +/// `Compound`, and `Irrational` each carry `numerator(s)` then `denominator`; +/// `MixedDenominators` carries its component list; `None` is fieldless; +/// `Symbolic` carries a `u32` id. +impl TextValue for TimeSignatureDisplay { + fn project(&self) -> Sexp { + match self { + TimeSignatureDisplay::Standard { + numerator, + denominator, + } => variant("Standard", vec![numerator.project(), denominator.project()]), + TimeSignatureDisplay::Compound { + numerators, + denominator, + } => variant( + "Compound", + vec![numerators.project(), denominator.project()], + ), + TimeSignatureDisplay::Irrational { + numerator, + denominator, + } => variant( + "Irrational", + vec![numerator.project(), denominator.project()], + ), + TimeSignatureDisplay::MixedDenominators { components } => { + variant("MixedDenominators", vec![components.project()]) + } + TimeSignatureDisplay::None => variant("None", vec![]), + TimeSignatureDisplay::Symbolic(v) => variant("Symbolic", vec![v.project()]), + } + } + fn parse(s: &Sexp) -> Result { + let (ctor, fields) = split_variant(s)?; + if ctor == kebab("Standard") { + let f = fields_of(fields, "TimeSignatureDisplay", 2)?; + Ok(TimeSignatureDisplay::Standard { + numerator: TextValue::parse(&f[0])?, + denominator: TextValue::parse(&f[1])?, + }) + } else if ctor == kebab("Compound") { + let f = fields_of(fields, "TimeSignatureDisplay", 2)?; + Ok(TimeSignatureDisplay::Compound { + numerators: TextValue::parse(&f[0])?, + denominator: TextValue::parse(&f[1])?, + }) + } else if ctor == kebab("Irrational") { + let f = fields_of(fields, "TimeSignatureDisplay", 2)?; + Ok(TimeSignatureDisplay::Irrational { + numerator: TextValue::parse(&f[0])?, + denominator: TextValue::parse(&f[1])?, + }) + } else if ctor == kebab("MixedDenominators") { + let f = fields_of(fields, "TimeSignatureDisplay", 1)?; + Ok(TimeSignatureDisplay::MixedDenominators { + components: TextValue::parse(&f[0])?, + }) + } else if ctor == kebab("None") { + no_fields(fields)?; + Ok(TimeSignatureDisplay::None) + } else if ctor == kebab("Symbolic") { + let f = fields_of(fields, "TimeSignatureDisplay", 1)?; + Ok(TimeSignatureDisplay::Symbolic(TextValue::parse(&f[0])?)) + } else { + Err(TextError::UnknownConstructor { + type_name: "TimeSignatureDisplay", + found: ctor.to_owned(), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use core::num::NonZeroU16; + + use crate::graph::{ + BeatGroup, GraphicContent, HairpinDirection, MetricTimeModel, OctaveOffset, PowerOfTwo, + ProportionalTimeModel, StaffBasedContent, TextLineDefinition, + }; + use crate::ids::{ + EventId, OperationId, RegionId, ReplicaId, StaffId, TimeSignatureId, VoiceId, + }; + use crate::pitch::{ForeignFormatId, StaffGroupKindRegistryId, TieClassRegistryId}; + use crate::textvalue::read_sexp; + use crate::time::{AnchorOffset, MusicalDuration, RationalTime, TimeAnchor, WallClockDuration}; + + // --- builders ----------------------------------------------------------- + + fn event(n: u64) -> EventId { + EventId::new(ReplicaId(1), n) + } + + fn anchor(n: u64) -> TimeAnchor { + TimeAnchor::Event { + id: event(n), + offset: AnchorOffset::Zero, + } + } + + fn dur(n: i64, d: i64) -> MusicalDuration { + MusicalDuration(RationalTime::new(n, d).expect("valid rational")) + } + + /// Everything a canonical value writes, a read must return unchanged: + /// `project` -> `render` -> `read_sexp` -> `parse` is the identity. + #[track_caller] + fn round_trip(value: T) { + let text = value.project().render(); + let sexp = read_sexp(&text).unwrap_or_else(|e| panic!("{text:?} is not valid syntax: {e}")); + let parsed = T::parse(&sexp).unwrap_or_else(|e| panic!("{text:?} did not parse: {e}")); + assert_eq!(value, parsed, "{text:?} did not round-trip"); + } + + /// A valid 4/4 signature whose four quarter-note beat groups sum to a whole. + fn four_four() -> TimeSignature { + let bg = || BeatGroup { + duration: dur(1, 4), + subdivision: None, + accent: 1, + }; + TimeSignature::new( + TimeSignatureId::new(ReplicaId(1), 1), + TimeSignatureDisplay::Standard { + numerator: 4, + denominator: PowerOfTwo::new(4).expect("4 is a power of two"), + }, + dur(1, 1), + vec![bg(), bg(), bg(), bg()], + ) + .expect("beat groups sum to the measure duration") + } + + // --- round trips -------------------------------------------------------- + + #[test] + fn newtypes_round_trip() { + round_trip(Timestamp(1_700_000_000_000)); + round_trip(Timestamp(0)); + round_trip(SpaceUnit(CanonicalF64::new(1.5).unwrap())); + round_trip(SoundConfiguration(vec![0xde, 0xad, 0xbe, 0xef])); + round_trip(SoundConfiguration(vec![])); + } + + #[test] + fn private_field_structs_round_trip() { + round_trip(KeySignature::new(-3).unwrap()); + round_trip(KeySignature::new(0).unwrap()); + round_trip(KeySignature::new(7).unwrap()); + round_trip(TupletRatio::new(3, 2).unwrap()); + round_trip(TupletRatio::new(6, 4).unwrap()); + round_trip(four_four()); + + let mut edges = BTreeMap::new(); + edges.insert(event(1), vec![event(2), event(3)]); + edges.insert(event(2), vec![event(3)]); + round_trip(EventOrderingDAG::try_new(edges).expect("acyclic")); + round_trip(EventOrderingDAG::default()); + } + + #[test] + fn tagged_unions_round_trip() { + round_trip(SpannerKind::Generic); + round_trip(SpannerKind::Hairpin(HairpinDirection::Crescendo)); + round_trip(SpannerKind::OctaveLine(OctaveOffset(2))); + round_trip(SpannerKind::TextLine(TextLineDefinition { + text: "cresc.".to_owned(), + })); + + round_trip(RepeatKind::SimpleRepeat { count: 2 }); + round_trip(RepeatKind::DalSegno { + segno: anchor(1), + end_target: anchor(2), + }); + round_trip(RepeatKind::Volta); + + round_trip(MetadataValue::Text("a\"b".to_owned())); + round_trip(MetadataValue::Integer(-5)); + round_trip(MetadataValue::Flag(true)); + + round_trip(RegionTimeModel::Metric(MetricTimeModel::default())); + round_trip(RegionTimeModel::Proportional(ProportionalTimeModel { + duration: WallClockDuration(1000), + })); + + round_trip(RegionContent::Hybrid { + staves: StaffBasedContent::default(), + overlay: GraphicContent::default(), + overlay_below_staves: true, + }); + round_trip(RegionContent::FreeGraphic(GraphicContent::default())); + + round_trip(VoiceOrigin::UserDeclared); + round_trip(VoiceOrigin::Imported { + format: ForeignFormatId::new("musicxml"), + }); + round_trip(VoiceOrigin::SystemPromoted { + winning_operation: OperationId::new(ReplicaId(1), 1), + losing_operation: OperationId::new(ReplicaId(1), 2), + original_voice: VoiceId::new(ReplicaId(1), 3), + }); + + round_trip(StaffGroupKind::GrandStaff); + round_trip(StaffGroupKind::Registered(StaffGroupKindRegistryId::new( + "custom", + ))); + + round_trip(TieClass::Standard); + round_trip(TieClass::LaissezVibrer); + round_trip(TieClass::Registered(TieClassRegistryId::new("x"))); + + round_trip(AnnotationAnchor::Event(event(4))); + round_trip(AnnotationAnchor::Range { + start: anchor(1), + end: anchor(2), + }); + round_trip(AnnotationAnchor::Region(RegionId::new(ReplicaId(1), 5))); + + round_trip(GestureAnchoring::Events(vec![event(1), event(2)])); + round_trip(GestureAnchoring::Range { + start: anchor(1), + end: anchor(2), + staves: vec![StaffId::new(ReplicaId(1), 1)], + }); + round_trip(GestureAnchoring::Free); + + round_trip(DecompositionSource::UserChosen); + round_trip(DecompositionSource::Propagated { from: event(9) }); + + round_trip(TimeSignatureDisplay::Standard { + numerator: 3, + denominator: PowerOfTwo::new(4).unwrap(), + }); + round_trip(TimeSignatureDisplay::MixedDenominators { + components: vec![ + (3, NonZeroU16::new(8).unwrap()), + (2, NonZeroU16::new(4).unwrap()), + ], + }); + round_trip(TimeSignatureDisplay::None); + round_trip(TimeSignatureDisplay::Symbolic(7)); + } + + // --- strict rejection (validation must not be laundered) ----------------- + + #[test] + fn a_cyclic_event_ordering_is_rejected_not_accepted() { + // A self-loop `a -> a` is a cycle: `try_new` returns `None`, and the + // parse surfaces that rather than accepting an ill-formed ordering. + let a = event(1); + let cyclic = Sexp::List(vec![ + Sexp::Symbol(kebab("EventOrderingDAG")), + Sexp::List(vec![Sexp::List(vec![ + a.project(), + Sexp::List(vec![a.project()]), + ])]), + ]); + assert!( + EventOrderingDAG::parse(&cyclic).is_err(), + "a cyclic ordering must be rejected" + ); + } + + #[test] + fn an_out_of_range_key_signature_is_rejected() { + for bad in [ + "(key-signature 8)", + "(key-signature -8)", + "(key-signature 100)", + ] { + let s = read_sexp(bad).unwrap(); + assert!( + KeySignature::parse(&s).is_err(), + "{bad} is outside -7..=7 and must be rejected" + ); + } + assert!(KeySignature::parse(&read_sexp("(key-signature -3)").unwrap()).is_ok()); + } + + #[test] + fn a_degenerate_tuplet_ratio_is_rejected() { + for bad in [ + "(tuplet-ratio 3 3)", + "(tuplet-ratio 0 2)", + "(tuplet-ratio 2 0)", + ] { + let s = read_sexp(bad).unwrap(); + assert!( + TupletRatio::parse(&s).is_err(), + "{bad} is degenerate and must be rejected" + ); + } + } + + #[test] + fn a_time_signature_whose_beat_groups_do_not_sum_is_rejected() { + // Take a valid projection and corrupt the measure-duration field + // (index 3: `[time-signature id display measure-duration beat-groups]`), + // so the beat groups no longer sum to it. `new` must reject. + let mut sexp = four_four().project(); + if let Sexp::List(items) = &mut sexp { + items[3] = dur(1, 2).project(); + } + assert!( + TimeSignature::parse(&sexp).is_err(), + "a mismatched beat-group sum must be rejected, not normalized" + ); + } + + // --- strict rejection (variant shape) ----------------------------------- + + #[test] + fn a_fieldless_variant_rejects_its_list_spelling() { + // `Volta`, `Free`, and `TimeSignatureDisplay::None` project to bare + // symbols; their list spellings denote the same value a second way, + // which strict parsing forbids. + assert!(RepeatKind::parse(&read_sexp("(volta)").unwrap()).is_err()); + assert!(GestureAnchoring::parse(&read_sexp("(free)").unwrap()).is_err()); + assert!(TimeSignatureDisplay::parse(&read_sexp("(none)").unwrap()).is_err()); + // And the bare symbols are accepted. + assert!(RepeatKind::parse(&read_sexp("volta").unwrap()).is_ok()); + assert!(GestureAnchoring::parse(&read_sexp("free").unwrap()).is_ok()); + assert!(TimeSignatureDisplay::parse(&read_sexp("none").unwrap()).is_ok()); + } + + #[test] + fn an_unknown_constructor_is_rejected() { + assert!(matches!( + RepeatKind::parse(&read_sexp("nope").unwrap()), + Err(TextError::UnknownConstructor { .. }) + )); + assert!(matches!( + RegionContent::parse(&read_sexp("(mystery x)").unwrap()), + Err(TextError::UnknownConstructor { .. }) + )); + } + + // --- SoundConfiguration: a byte string, never a list of integers -------- + + #[test] + fn sound_configuration_projects_as_a_byte_string_not_a_list() { + let sc = SoundConfiguration(vec![0x00, 0x0a, 0xff]); + let projected = sc.project(); + + // Rendered `#x…`, exactly as any other opaque byte run. + assert_eq!(projected.render(), "#x000aff"); + assert!(matches!(projected, Sexp::Bytes(_))); + + // NOT the list of integers the generic `Vec` impl would produce. + let as_integer_list: Sexp = vec![0x00u8, 0x0a, 0xff].project(); + assert!(matches!(as_integer_list, Sexp::List(_))); + assert_eq!(as_integer_list.render(), "(0 10 255)"); + assert_ne!(sc.project(), as_integer_list); + + // A list of integers is rejected where a SoundConfiguration is expected. + assert!(SoundConfiguration::parse(&read_sexp("(0 10 255)").unwrap()).is_err()); + } +} diff --git a/crates/epiphany-core/src/textvalue_impls.rs b/crates/epiphany-core/src/textvalue_impls.rs new file mode 100644 index 0000000..83cc0b8 --- /dev/null +++ b/crates/epiphany-core/src/textvalue_impls.rs @@ -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 { + 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 { + 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 )`, 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 { + 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 )`", + )); + }; + 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 { + <$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 { + 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 )`, 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 { + 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 ``, 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( + s: &Sexp, + make: F, + as_str: impl Fn(&T) -> &str, +) -> Result +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()); + } +} diff --git a/crates/epiphany-core/src/textvalue_pitch.rs b/crates/epiphany-core/src/textvalue_pitch.rs new file mode 100644 index 0000000..bd192ba --- /dev/null +++ b/crates/epiphany-core/src/textvalue_pitch.rs @@ -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 { + 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 { + 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 )`. +/// 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 { + 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 +/// )`, or `(absolute-hz )`. 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 { + 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 )`, `(integer )`, or +/// `(registered )`. 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 { + 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 { + 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 …)`. 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 { + 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 )`, or `(range +/// )`. 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 { + 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 )`, or `(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 { + 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 )`, 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 { + 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` 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 { + let fields = s.expect_struct(&kebab("SpellingPrecedence"), 1)?; + let [order] = fields else { + return Err(arity("SpellingPrecedence", 1, fields.len())); + }; + let order: Vec = 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(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()); + } +} diff --git a/crates/epiphany-core/src/textvalue_time.rs b/crates/epiphany-core/src/textvalue_time.rs new file mode 100644 index 0000000..9ebdf45 --- /dev/null +++ b/crates/epiphany-core/src/textvalue_time.rs @@ -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 { + 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 `( …)`: 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, 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(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(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::("(wall-clock)"); + rejects::("(measure #x00000000000000000000000000000001 start)"); + // No such variant. + rejects::("(sometime 1)"); + // A field-bearing union is never a bare symbol. + rejects::("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::("(musical (ratio 2 4))"); + rejects::("(musical)"); + rejects::("(musical (ratio 1 2) (ratio 1 2))"); + rejects::("(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::("(musical (ratio 2 4))"); + rejects::("(wall-clock 1 2)"); + rejects::("(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::("(indeterminate)"); + rejects::("(musical (ratio 2 4))"); + rejects::("(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::("(unbounded)"); + rejects::("musical-range"); + rejects::("(musical-range (ratio 1 2))"); + rejects::("(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::("(zero)"); + rejects::("musical"); + rejects::("(musical (ratio 2 4))"); + } +} diff --git a/crates/epiphany-core/src/time.rs b/crates/epiphany-core/src/time.rs index 6486ad2..0388537 100644 --- a/crates/epiphany-core/src/time.rs +++ b/crates/epiphany-core/src/time.rs @@ -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())) diff --git a/crates/epiphany-core/tests/textvalue_names.rs b/crates/epiphany-core/tests/textvalue_names.rs new file mode 100644 index 0000000..7f0d478 --- /dev/null +++ b/crates/epiphany-core/tests/textvalue_names.rs @@ -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(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)); + } +} diff --git a/crates/epiphany-core/tests/textvalue_roundtrip.rs b/crates/epiphany-core/tests/textvalue_roundtrip.rs new file mode 100644 index 0000000..c21f91e --- /dev/null +++ b/crates/epiphany-core/tests/textvalue_roundtrip.rs @@ -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(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 = 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 = 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" + ); + } + } +} diff --git a/crates/epiphany-ops/src/payload.rs b/crates/epiphany-ops/src/payload.rs index dedf68c..b7fead4 100644 --- a/crates/epiphany-ops/src/payload.rs +++ b/crates/epiphany-ops/src/payload.rs @@ -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 { diff --git a/crates/epiphany-testkit/tests/text_projection_grammar.rs b/crates/epiphany-testkit/tests/text_projection_grammar.rs index aa941e8..95bb4d6 100644 --- a/crates/epiphany-testkit/tests/text_projection_grammar.rs +++ b/crates/epiphany-testkit/tests/text_projection_grammar.rs @@ -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(),