diff --git a/docs/decisions.log.md b/docs/decisions.log.md index 9d55967..d44b195 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -2453,3 +2453,27 @@ Architectural decisions go to [`adr/`](adr/) instead. one rendered entry per `LossReport` element, preserving vector order. Production behavior is unchanged; so is the test suite, because freezing a stderr line policy the contract never asked for is the defect, not the fix. + +- 2026-08-22 — In the context of SWG-4A-02, facing a note's `marks` word + whose canonical counterpart is a set, we decided to store the marks as a + sequence in the syntax form and against a set or a bitset, to achieve a + document that can hold `marks [tap accent]` and `marks [accent accent]` + exactly as written, accepting that the syntax form is then able to spell + something the builder must refuse. A set would have accepted both and + handed back `[accent tap]`: normalization performed by a struct + definition, with no diagnostic, no author, and no place for 4A-09 to say + which rule was broken. This is the one field where §6.2's "order within a + repeated slot is semantic" does not apply — a set has no author order — + and the sequence is still the right shape, because the alternative is not + "no order" but "an order chosen silently". + +- 2026-08-22 — In the context of SWG-4A-02's closure, facing the acceptance + bullet "after lowering, the evaluator sees only `Score`", we decided to + split it into a structural half discharged now and a dynamic half left to + SWG-4A-09, and against marking it met, to achieve a record that does not + claim a property of code that has not been written. Lowering does not + exist at 4A-02, so no test here can observe what the evaluator receives + after it. What can be observed — and is — is that the document cannot + reach the evaluator at all: `mod ast` is private to `syntax`, so naming + the type from outside is `E0603`. Accepting that the bullet stays open in + the backlog until 4A-09 closes it. diff --git a/docs/swang/foundation-backlog.md b/docs/swang/foundation-backlog.md index ca607c3..95aad3e 100644 --- a/docs/swang/foundation-backlog.md +++ b/docs/swang/foundation-backlog.md @@ -113,7 +113,7 @@ recorded in `decisions.log.md` if reversed. | SWG-4A-01 | Normative exact-score-text grammar *(done)* | docs | INF-02 | | SWG-CORE-01 | Fixed-width migration for the three `usize` fields *(done)* | code | 4A-01 | | SWG-CORE-02 | Decide whether the canonical newtypes seal their fields | docs | 4A-01 | -| SWG-4A-02 | `ExactScoreDocument` as a transient syntax form | code | 4A-01 | +| SWG-4A-02 | `ExactScoreDocument` as a transient syntax form *(done)* | code | 4A-01 | | SWG-4A-03 | Writer: transport and master timeline | code | 4A-01 | | SWG-4A-04 | Writer: tracks, voices, groups, atoms | code | 4A-03 | | SWG-4A-05 | Writer: techniques, positions, evidence, losses *(done)* | code | 4A-04, CORE-01 | @@ -486,7 +486,7 @@ wrong more expensively. Acceptance: a decision, written down, either sealing the fields behind constructors or stating why they stay open and what compensates. -### SWG-4A-02 — `ExactScoreDocument` as a transient syntax form +### SWG-4A-02 — `ExactScoreDocument` as a transient syntax form *(done)* **Kind:** code. **Depends on:** 4A-01 @@ -494,20 +494,55 @@ constructors or stating why they stay open and what compensates. text -> ExactScoreDocument -> checked ScoreBuilder -> canonical Score ``` -`ExactScoreDocument` is a syntax representation with a short life. It holds -author order only until canonical formatting, is never consumed by a -generator, never becomes a persistent domain model, and is never imported -into `griff-core`. This is S16 required control #3 — no permanent score -hierarchy beside canonical `Score` — enforced by construction rather than by -good intentions. +`ExactScoreDocument` is a syntax representation with a short life. It is +never consumed by a generator, never becomes a persistent domain model, and +is never imported into `griff-core`. This is S16 required control #3 — no +permanent score hierarchy beside canonical `Score` — enforced by +construction rather than by good intentions. + +One phrase in the original entry said the document "holds author order only +until canonical formatting". As built it holds less than that, deliberately: +§6.2 assigns the order *between* slots to the formatter, so nothing records +that a `track` was written above a `master_bar`. What is kept is the order +*within* each repeated slot, which is semantic, plus the marks a note listed +— see the decision log. Recording the rest would have made this a concrete +syntax tree preserving information the spec calls meaningless. Acceptance: -- `griff-core` does not depend on `griff-swang` (assert in the dependency - test, not by reading `Cargo.toml` by eye); -- after lowering, the evaluator sees only `Score`; -- no generation entry point accepts an `ExactScoreDocument`; -- the document has no serialization format of its own beyond Swang source. +- `griff-core` does not depend on `griff-swang`, asked of `cargo tree` + rather than of `Cargo.toml`, because a manifest grep cannot see a + dependency arriving through a feature, a rename, or a longer path; +- no generation entry point accepts an `ExactScoreDocument`; the module is + `pub(crate)` inside a `mod ast` that is private to `syntax`, so nothing + outside the AST can name the type at all — importing it into the + evaluator is `E0603`, not a lint; +- the document has no serialization format of its own beyond Swang source, + and no `Hash`, no `From`, and no lowering method; +- the whole grammar tree is representable, including everything §3 calls + inhabited, and including the states 4A-07 and 4A-09 will refuse — `ppqn 0`, + `pitch 200`, `meter 0/3`, `ticks 100..10`. Holding them is not a claim + they will be accepted; it is the claim that a struct definition did not + quietly appoint itself the validator and leave those refusals + unattributable; +- sixteen mutations, none survived. + +One acceptance bullet, "after lowering, the evaluator sees only `Score`", +cannot be discharged here and is deliberately **not** claimed: lowering does +not exist yet. It splits in two: + +```text +4A-02 structural half, proven now + ExactScoreDocument cannot escape the syntax module at all +4A-09 dynamic half, still open + lowering returns a canonical Score and downstream receives only Score +``` + +Proving the second half with code that does not exist is how a backlog +starts believing its own plans. The open half is carried as an acceptance +bullet of SWG-4A-09, not as a remark here: moving an obligation out of a +task that is closing, without moving the point at which someone must +discharge it, only makes it easier to lose. ### SWG-4A-03 — Writer: transport and master timeline @@ -683,6 +718,19 @@ syntactically valid text There is no third outcome, and no partially valid `Score` is ever returned. +- **Inherited from SWG-4A-02.** Lowering consumes an `ExactScoreDocument` + and returns a canonical `Score`; no evaluator, generator, or other + downstream semantic consumer accepts or observes an `ExactScoreDocument`. + + 4A-02 built the syntax form and proved the half of this that could be + proved without lowering: the type cannot escape the syntax module, because + `mod ast` is private to `syntax` and naming it from outside is `E0603`. + The other half is dynamic and could not be tested against code that did + not exist. It lands here because this is the task that writes that code, + and it is written as an acceptance bullet rather than as a note in a + closed task's prose, because a criterion nobody has to satisfy is not a + criterion. + ### SWG-4A-10 — `griff swang dump` *(done)* **Kind:** CLI. **Depends on:** 4A-05 @@ -1256,8 +1304,9 @@ INF-01 status sync (done) │ surface over it, not another slice of it │ └─→ 4A-02 → INF-04 → INF-06 → 4A-06 parser skeleton - ↑ - next + (done) ↑ + next — INF-04 can now design SourceMap, + AstId and FieldRef against a real v2 shape -> 4A-02..4A-09 writer / parser / builder -> 4A-10..4A-14 dump / verify / laws / fuzz -> 4B corpus acceptance diff --git a/swang/src/syntax/ast.rs b/swang/src/syntax/ast.rs index 1b9d53f..f739663 100644 --- a/swang/src/syntax/ast.rs +++ b/swang/src/syntax/ast.rs @@ -4,3 +4,13 @@ //! than editing it. pub(crate) mod v1; +pub(crate) mod v2; + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::missing_assert_message +)] +mod v2_tests; diff --git a/swang/src/syntax/ast/v2.rs b/swang/src/syntax/ast/v2.rs new file mode 100644 index 0000000..c08819e --- /dev/null +++ b/swang/src/syntax/ast/v2.rs @@ -0,0 +1,362 @@ +//! The level-2 exact-score AST — a transient syntax form (SWG-4A-02). +//! +//! ```text +//! level-2 text -> ExactScoreDocument -> ScoreBuilder -> griff_core::Score +//! (here) (SWG-4A-09) +//! ``` +//! +//! This module is the middle box and nothing else. It holds no parser, no +//! builder, no formatter, and no conversion in either direction: it is the +//! shape a parsed exact score has on its way to becoming a `Score`, and it +//! is expected to be short-lived in every program that constructs one. +//! +//! # Raw shapes, on purpose +//! +//! Nothing here is a canonical type. A note's pitch is a `u8`, not a +//! [`Pitch`]; a bar's meter is two `u8`s, not a `TimeSignature`. That is not +//! laziness — it is where the boundary goes. The canonical newtypes have +//! checked constructors, and reaching for them here would move every +//! refusal into a struct definition: `pitch 200` would become unbuildable, +//! and the diagnostic that should name it (4A-07's, or 4A-09's) would have +//! nowhere left to be raised from. So the document can hold `ppqn 0`, +//! `meter 0/3`, `ticks 100..10`, and `velocity 255`. None of that says such +//! a text will be accepted. It says this task did not decide. +//! +//! The converse also holds, and matters more. Everything +//! `docs/swang/exact-score-text.md` §3 lists as *inhabited* canonical state +//! — `channel 200`, duplicate `Voice.id`, `tuplet 0/0`, a zero-duration +//! rest, a string beyond the tuning — must be representable, or the exact +//! writer's own output would have no syntax form to be parsed back into. +//! +//! # Named slots, not a concrete syntax tree +//! +//! The fields are typed and named rather than a `Vec` of "whatever word came +//! next", because §6.2 already settled what is semantic: +//! +//! - the order *between* slots belongs to the formatter — so nothing here +//! remembers that an author wrote `track` above `master_bar`; +//! - the order *within* one repeated slot belongs to the music — so +//! `master_bars`, `tracks`, `voices`, `groups`, `atoms`, `spans`, `tuning` +//! and `loss` are all `Vec`, never a set, and nothing sorts or dedups; +//! - `note` and `rest` are variant tags at one position of one sequence, so +//! [`ExactAtom`] is a sum type and there is no `rests` field to interleave +//! wrongly with; +//! - the four warning variants are likewise one sequence, not four. +//! +//! Recording the author's word order would make this a concrete syntax tree +//! and would preserve information the spec has declared meaningless. The one +//! place that reasoning is deliberately *not* applied is [`ExactNote::marks`] +//! — see its own note. +//! +//! # No second format +//! +//! There is no `Serialize`, no `Hash`, no JSON schema, no artifact. The exact +//! score text **is** this document's serialization; a second one would be a +//! compatibility promise nobody agreed to freeze. +//! +//! # Why this module allows dead code +//! +//! Nothing outside its own tests constructs one of these types yet, and that +//! is the task's shape rather than an oversight: 4A-02 builds the form, +//! 4A-06..08 fill it from text, and 4A-09 lowers it. Wiring it into a caller +//! now — an evaluator that takes one, a public constructor — is precisely +//! what the boundary tests in `swang/tests/exact_document_boundary.rs` +//! forbid, because that is how a transient form becomes a second model. So +//! the allow stays until a parser exists to remove the need for it. +//! +//! [`Pitch`]: griff_core::event::Pitch +#![allow(dead_code)] + +/// One whole level-2 exact score, as the grammar spells it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExactScoreDocument { + /// `ppqn ` — the tick resolution, unvalidated. + pub(crate) ppqn: u16, + /// `master_bar { … }` blocks, in the order the score carries them. + pub(crate) master_bars: Vec, + /// `track { … }` blocks, in order. + pub(crate) tracks: Vec, + /// The `source` block: absent, or present and possibly formatless. The + /// three states are three values (§2.1). + pub(crate) source: Option, + /// The `loss` block's warnings, in order, duplicates included. An empty + /// vector is a clean report, which is the state that omits the block. + pub(crate) loss: Vec, +} + +/// One `master_bar` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExactMasterBar { + /// The **stored** index, not a position (H4), and `u64` wide because + /// SWG-CORE-01 closed H3 there. + pub(crate) index: u64, + /// `ticks ..`. + pub(crate) ticks: ExactTickRange, + /// `meter /`. + pub(crate) meter: ExactMeter, + /// `tempo /`, the reduced rational as written. + pub(crate) tempo: ExactTempo, + /// The `repeat` block, when the text spells one. `None` is a bar with no + /// repeat block at all, which is not the same document as one carrying a + /// repeat whose fields happen to hold the model's default. + pub(crate) repeat: Option, +} + +/// `..` — no ordering claim; `100..10` is representable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactTickRange { + /// First tick. + pub(crate) start: u32, + /// End tick, exclusive. + pub(crate) end: u32, +} + +/// `/` — no power-of-two or nonzero claim. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactMeter { + /// Beats per bar. + pub(crate) numerator: u8, + /// Beat unit. + pub(crate) denominator: u8, +} + +/// `/` beats per minute, as the reduced rational. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactTempo { + /// BPM numerator. + pub(crate) numerator: u32, + /// BPM denominator; `1` for an integer BPM. + pub(crate) denominator: u32, +} + +/// `repeat { start play_count }`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactRepeat { + /// This bar opens a repeated section. + pub(crate) start: bool, + /// Times the section closing here is played. + pub(crate) play_count: u8, +} + +/// One `track` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExactTrack { + /// `name "…"`, absent when the block carries no name word. An absent + /// name and an empty one are two documents (§2.1). + pub(crate) name: Option, + /// `channel ` — unvalidated; `200` is inhabited canonical state. + pub(crate) channel: u8, + /// `tuning [p …]` — raw pitch numbers in written order. Empty is `[]`, + /// a required word with no elements, not an absent tuning. + pub(crate) tuning: Vec, + /// `voice { … }` blocks, in order. Duplicate ids are not merged. + pub(crate) voices: Vec, +} + +/// One `voice` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExactVoice { + /// `id `. + pub(crate) id: u8, + /// `group … { … }` blocks, in order. + pub(crate) groups: Vec, +} + +/// One `group` block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExactGroup { + /// The word after `group`, with `tuplet`'s payload opened. + pub(crate) kind: ExactGroupKind, + /// `note` and `rest` in one sequence — one slot, two variant tags + /// (§6.2 rule 3). + pub(crate) atoms: Vec, + /// `span … { … }` blocks, in order. A separate slot from `atoms`, and + /// the formatter's rule that spans follow atoms is the formatter's. + pub(crate) spans: Vec, +} + +/// The six `group` words (§6.4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactGroupKind { + /// `group single`. + Single, + /// `group chord`. + Chord, + /// `group arpeggio`. + Arpeggio, + /// `group strum`. + Strum, + /// `group tuplet /` — `0/0` is representable. + Tuplet { + /// Note count within the tuplet. + num: u8, + /// Grid subdivision it fits into. + den: u8, + }, + /// `group grace`. + Grace, +} + +/// One element of a group's atom slot. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ExactAtom { + /// `note { … }`. + Note(ExactNote), + /// `rest { … }` — never an absence (§2.5). + Rest(ExactRest), +} + +/// `note { at … duration … pitch … velocity … marks [ … ] position? }`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExactNote { + /// `at `. + pub(crate) at: u32, + /// `duration ` — zero is representable. + pub(crate) duration: u32, + /// `pitch ` — unvalidated; the 7-bit check is 4A-09's. + pub(crate) pitch: u8, + /// `velocity ` — likewise. + pub(crate) velocity: u8, + /// `marks [ … ]`, **as written**. + /// + /// The canonical model's `NoteMarks` is a set whose order is + /// `NoteMark::ALL`, so this is the one place the "order within a slot is + /// semantic" rule does not apply — and a sequence is still the right + /// shape. A set here would silently accept `[tap accent]` and hand back + /// `[accent tap]`, and silently swallow a repeat: normalization + /// performed by a struct definition, with no diagnostic and no author. + /// Keeping what the text spelled leaves the refusal to whoever should + /// make it. + pub(crate) marks: Vec, + /// The `position` block, when the note carries one. + pub(crate) position: Option, +} + +/// `rest { at … duration … }`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactRest { + /// `at `. + pub(crate) at: u32, + /// `duration ` — zero is representable. + pub(crate) duration: u32, +} + +/// The seven `marks` words (§6.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactNoteMark { + /// `accent`. + Accent, + /// `ghost`. + Ghost, + /// `staccato`. + Staccato, + /// `dead_note`. + DeadNote, + /// `harmonic_natural`. + HarmonicNatural, + /// `harmonic_pinch`. + HarmonicPinch, + /// `tap`. + Tap, +} + +/// `position { string … fret … evidence { … } }`. +/// +/// No claim that `string` fits the track's tuning: §3 lists that as +/// inhabited, and this is not a fretboard validator. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactPosition { + /// `string `, 1-indexed in the canonical model. + pub(crate) string: u8, + /// `fret `. + pub(crate) fret: u8, + /// The position's own evidence. + pub(crate) evidence: ExactEvidence, +} + +/// `span { ticks … evidence { … } }`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactSpan { + /// The word after `span`. + pub(crate) technique: ExactSpanTechnique, + /// `ticks ..`, with no claim about the enclosing group. + pub(crate) ticks: ExactTickRange, + /// The span's evidence. + pub(crate) evidence: ExactEvidence, +} + +/// The eight `span` words (§6.4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactSpanTechnique { + /// `slide`. + Slide, + /// `bend`. + Bend, + /// `legato`. + Legato, + /// `palm_mute`. + PalmMute, + /// `hammer_on`. + HammerOn, + /// `pull_off`. + PullOff, + /// `vibrato`. + Vibrato, + /// `let_ring`. + LetRing, +} + +/// `evidence { source confidence }`. +/// +/// The two fields are independent facts and are never tidied into a pair +/// that looks more sensible (§2.6): explicit at 0 and inferred at 10 000 are +/// both representable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ExactEvidence { + /// `source explicit` or `source inferred_from_midi`. + pub(crate) source: ExactEvidenceSource, + /// `confidence ` — a raw basis-point number. `ConfidenceBps` keeps + /// its field private and clamps; that check belongs to lowering, so the + /// syntax form holds what was written. + pub(crate) confidence: u16, +} + +/// The two `source` words inside an `evidence` block (§6.4). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ExactEvidenceSource { + /// `explicit`. + Explicit, + /// `inferred_from_midi`. + InferredFromMidi, +} + +/// The `source` block's contents. +/// +/// The block's own presence is the enclosing `Option`; this is what is +/// inside it, so `source { }` and `source { format "" }` stay distinct. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExactSource { + /// `format "…"`, absent when the block is empty. + pub(crate) format: Option, +} + +/// One element of the `loss` block (§2.8, §6.4). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ExactWarning { + /// `track_name_invalid_utf8 { track_index }`. + TrackNameInvalidUtf8 { + /// Zero-based track index; `u64` since SWG-CORE-01. + track_index: u64, + }, + /// `smpte_timing_unsupported` — a bare word, no block. + SmpteTimingUnsupported, + /// `tempo_approximated { bar_index nearest_micros }`. + TempoApproximated { + /// Zero-based master-bar index; `u64` since SWG-CORE-01. + bar_index: u64, + /// Microseconds per quarter actually written. + nearest_micros: u32, + }, + /// `other { message "…" }`, whose message is unrestricted. + Other(String), +} diff --git a/swang/src/syntax/ast/v2_tests.rs b/swang/src/syntax/ast/v2_tests.rs new file mode 100644 index 0000000..16d3ce5 --- /dev/null +++ b/swang/src/syntax/ast/v2_tests.rs @@ -0,0 +1,590 @@ +//! SWG-4A-02: what [`v2::ExactScoreDocument`] must be able to hold. +//! +//! These witnesses are about **representation**, not acceptance. The exact +//! score text sits between a parser that does not exist yet (4A-06..08) and a +//! checked builder that does not exist yet (4A-09): +//! +//! ```text +//! level-2 text -> ExactScoreDocument -> ScoreBuilder -> griff_core::Score +//! ``` +//! +//! This task builds only the middle form. Its whole job is to be able to say +//! what the grammar can spell — including things the parser will reject and +//! things the builder will refuse — so that neither of those later tasks +//! finds its work already done, badly, by a struct definition. +//! +//! So a document that holds `ppqn 0` is not a claim that `ppqn 0` will ever +//! be accepted. It is a claim that the *syntax type* did not quietly appoint +//! itself the validator. + +use super::v2::{ + ExactAtom, ExactEvidence, ExactEvidenceSource, ExactGroup, ExactGroupKind, ExactMasterBar, + ExactMeter, ExactNote, ExactNoteMark, ExactPosition, ExactRepeat, ExactRest, + ExactScoreDocument, ExactSource, ExactSpan, ExactSpanTechnique, ExactTempo, ExactTickRange, + ExactTrack, ExactVoice, ExactWarning, +}; + +// ── a whole tree, once ────────────────────────────────────────────────────── + +/// The §6.1 reference document as data: every level of the grammar tree, so +/// that a missing child type is a compile error rather than a discovery made +/// later by the parser. +fn reference() -> ExactScoreDocument { + ExactScoreDocument { + ppqn: 960, + master_bars: reference_bars(), + tracks: vec![reference_track()], + source: Some(ExactSource { + format: Some(String::from("GP5")), + }), + loss: vec![ExactWarning::TempoApproximated { + bar_index: 1, + nearest_micros: 4_200_000, + }], + } +} + +fn reference_bars() -> Vec { + vec![ + ExactMasterBar { + index: 0, + ticks: ExactTickRange { + start: 0, + end: 3840, + }, + meter: ExactMeter { + numerator: 4, + denominator: 4, + }, + tempo: ExactTempo { + numerator: 120, + denominator: 1, + }, + repeat: None, + }, + ExactMasterBar { + index: 1, + ticks: ExactTickRange { + start: 3840, + end: 7680, + }, + meter: ExactMeter { + numerator: 7, + denominator: 8, + }, + tempo: ExactTempo { + numerator: 100, + denominator: 7, + }, + repeat: Some(ExactRepeat { + start: true, + play_count: 2, + }), + }, + ] +} + +fn reference_track() -> ExactTrack { + ExactTrack { + name: Some(String::from("Guitar")), + channel: 0, + tuning: vec![64, 59, 55, 50, 45, 40], + voices: vec![ExactVoice { + id: 0, + groups: vec![reference_chord(), reference_rest(), reference_tuplet()], + }], + } +} + +fn reference_chord() -> ExactGroup { + ExactGroup { + kind: ExactGroupKind::Chord, + atoms: vec![ + ExactAtom::Note(ExactNote { + at: 0, + duration: 480, + pitch: 40, + velocity: 96, + marks: Vec::new(), + position: None, + }), + ExactAtom::Note(ExactNote { + at: 0, + duration: 480, + pitch: 47, + velocity: 96, + marks: vec![ExactNoteMark::Accent], + position: None, + }), + ], + spans: vec![ExactSpan { + technique: ExactSpanTechnique::PalmMute, + ticks: ExactTickRange { start: 0, end: 480 }, + evidence: ExactEvidence { + source: ExactEvidenceSource::Explicit, + confidence: 10_000, + }, + }], + } +} + +fn reference_rest() -> ExactGroup { + ExactGroup { + kind: ExactGroupKind::Single, + atoms: vec![ExactAtom::Rest(ExactRest { + at: 480, + duration: 240, + })], + spans: Vec::new(), + } +} + +fn reference_tuplet() -> ExactGroup { + ExactGroup { + kind: ExactGroupKind::Tuplet { num: 3, den: 2 }, + atoms: vec![ExactAtom::Note(ExactNote { + at: 720, + duration: 160, + pitch: 52, + velocity: 80, + marks: Vec::new(), + position: Some(ExactPosition { + string: 4, + fret: 2, + evidence: ExactEvidence { + source: ExactEvidenceSource::InferredFromMidi, + confidence: 5_000, + }, + }), + })], + spans: Vec::new(), + } +} + +#[test] +fn the_whole_grammar_tree_is_representable() { + let document = reference(); + assert_eq!(document, reference(), "the form is plain, comparable data"); + assert_eq!(document.master_bars.len(), 2); + assert_eq!(document.tracks.len(), 1); +} + +// ── one repeated slot, not two collections ────────────────────────────────── + +#[test] +fn notes_and_rests_share_one_ordered_slot() { + // §6.2 rule 3: `note` and `rest` are variant tags at one position of a + // single sequence. Two collections would make the interleaving + // unrepresentable, and re-ordering it would rewrite the music. + let group = ExactGroup { + kind: ExactGroupKind::Single, + atoms: vec![ + ExactAtom::Note(ExactNote { + at: 0, + duration: 240, + pitch: 40, + velocity: 90, + marks: Vec::new(), + position: None, + }), + ExactAtom::Rest(ExactRest { + at: 240, + duration: 240, + }), + ExactAtom::Note(ExactNote { + at: 480, + duration: 240, + pitch: 43, + velocity: 90, + marks: Vec::new(), + position: None, + }), + ], + spans: Vec::new(), + }; + let kinds: Vec = group + .atoms + .iter() + .map(|atom| matches!(atom, ExactAtom::Note(_))) + .collect(); + assert_eq!( + kinds, + vec![true, false, true], + "note, rest, note stays one ordered vector" + ); +} + +#[test] +fn all_four_warning_variants_share_one_ordered_slot() { + // Order is vector order and duplicates are duplicates (§2.7): A, B, A is + // three elements, in that sequence. + let loss = vec![ + ExactWarning::SmpteTimingUnsupported, + ExactWarning::Other(String::from("something else")), + ExactWarning::SmpteTimingUnsupported, + ExactWarning::TrackNameInvalidUtf8 { track_index: 2 }, + ]; + let document = ExactScoreDocument { + loss: loss.clone(), + ..reference() + }; + assert_eq!(document.loss, loss, "no sorting, no grouping, no dedup"); + assert_eq!( + document.loss.first(), + document.loss.get(2), + "a repeated warning is repeated, not collapsed" + ); +} + +#[test] +fn marks_keep_what_the_text_spelled() { + // The canonical model's `NoteMarks` is a set whose order is + // `NoteMark::ALL`. That is the *model's* rule, discharged by 4A-09. A + // sequence here is what lets the document hold an order or a repetition + // the builder will later refuse, instead of normalising it away at the + // syntax boundary and hiding whose refusal it was. + let note = ExactNote { + at: 0, + duration: 480, + pitch: 40, + velocity: 90, + marks: vec![ + ExactNoteMark::Tap, + ExactNoteMark::Accent, + ExactNoteMark::Tap, + ], + position: None, + }; + assert_eq!(note.marks.len(), 3, "no dedup"); + assert_eq!( + note.marks.first(), + Some(&ExactNoteMark::Tap), + "no reordering into NoteMark::ALL order" + ); +} + +// ── absence is a distinction ──────────────────────────────────────────────── + +#[test] +fn the_three_source_states_are_three_values() { + let absent = ExactScoreDocument { + source: None, + ..reference() + }; + let present_empty = ExactScoreDocument { + source: Some(ExactSource { format: None }), + ..reference() + }; + let empty_format = ExactScoreDocument { + source: Some(ExactSource { + format: Some(String::new()), + }), + ..reference() + }; + assert_ne!(absent, present_empty, "omitted is not present-and-empty"); + assert_ne!(present_empty, empty_format, "no format is not an empty one"); + assert_ne!(absent, empty_format); +} + +#[test] +fn an_unnamed_track_is_not_an_empty_named_one() { + let unnamed = ExactTrack { + name: None, + channel: 0, + tuning: Vec::new(), + voices: Vec::new(), + }; + let empty_name = ExactTrack { + name: Some(String::new()), + ..unnamed.clone() + }; + assert_ne!(unnamed, empty_name); +} + +/// A bar with no `repeat` block at all. +fn bar_without_repeat() -> ExactMasterBar { + ExactMasterBar { + index: 0, + ticks: ExactTickRange { start: 0, end: 0 }, + meter: ExactMeter { + numerator: 4, + denominator: 4, + }, + tempo: ExactTempo { + numerator: 120, + denominator: 1, + }, + repeat: None, + } +} + +#[test] +fn an_absent_repeat_is_not_a_default_one() { + let written = ExactMasterBar { + repeat: Some(ExactRepeat { + start: false, + play_count: 0, + }), + ..bar_without_repeat() + }; + assert_ne!( + bar_without_repeat(), + written, + "a repeat block the text omitted is not one it spelled as the default" + ); +} + +// ── every closed vocabulary is complete ───────────────────────────────────── + +#[test] +fn every_group_kind_is_representable() { + let kinds = [ + ExactGroupKind::Single, + ExactGroupKind::Chord, + ExactGroupKind::Arpeggio, + ExactGroupKind::Strum, + ExactGroupKind::Tuplet { num: 3, den: 2 }, + ExactGroupKind::Grace, + ]; + assert_eq!(kinds.len(), 6, "§6.4's six group kinds"); +} + +#[test] +fn every_span_technique_is_representable() { + let techniques = [ + ExactSpanTechnique::Slide, + ExactSpanTechnique::Bend, + ExactSpanTechnique::Legato, + ExactSpanTechnique::PalmMute, + ExactSpanTechnique::HammerOn, + ExactSpanTechnique::PullOff, + ExactSpanTechnique::Vibrato, + ExactSpanTechnique::LetRing, + ]; + assert_eq!(techniques.len(), 8, "§6.4's eight span techniques"); +} + +#[test] +fn every_note_mark_and_evidence_source_is_representable() { + let marks = [ + ExactNoteMark::Accent, + ExactNoteMark::Ghost, + ExactNoteMark::Staccato, + ExactNoteMark::DeadNote, + ExactNoteMark::HarmonicNatural, + ExactNoteMark::HarmonicPinch, + ExactNoteMark::Tap, + ]; + assert_eq!(marks.len(), 7, "§6.3's seven marks"); + let sources = [ + ExactEvidenceSource::Explicit, + ExactEvidenceSource::InferredFromMidi, + ]; + assert_eq!(sources.len(), 2); +} + +#[test] +fn every_warning_variant_carries_its_own_payload() { + let warnings = [ + ExactWarning::TrackNameInvalidUtf8 { track_index: 0 }, + ExactWarning::SmpteTimingUnsupported, + ExactWarning::TempoApproximated { + bar_index: 0, + nearest_micros: 500_000, + }, + ExactWarning::Other(String::new()), + ]; + assert_eq!(warnings.len(), 4, "§2.8's four warning variants"); +} + +// ── ugly, legal, canonical states ─────────────────────────────────────────── + +#[test] +fn the_states_the_model_calls_inhabited_are_representable() { + // §3 lists all of these as model-valid. A syntax form that could not + // hold them would make the writer's own output unrepresentable. + let document = ExactScoreDocument { + tracks: vec![ExactTrack { + name: None, + channel: 200, + tuning: Vec::new(), + voices: vec![ + ExactVoice { + id: 0, + groups: vec![ExactGroup { + kind: ExactGroupKind::Tuplet { num: 0, den: 0 }, + atoms: vec![ + ExactAtom::Note(ExactNote { + at: 0, + duration: 0, + pitch: 40, + velocity: 90, + marks: Vec::new(), + position: Some(ExactPosition { + string: 99, + fret: 250, + evidence: ExactEvidence { + source: ExactEvidenceSource::Explicit, + confidence: 0, + }, + }), + }), + ExactAtom::Rest(ExactRest { at: 0, duration: 0 }), + ], + spans: Vec::new(), + }], + }, + ExactVoice { + id: 0, + groups: Vec::new(), + }, + ], + }], + ..reference() + }; + let voices = &document.tracks.first().expect("one track").voices; + assert_eq!(voices.len(), 2, "duplicate voice ids are not deduplicated"); + assert_eq!( + voices.first().map(|v| v.id), + voices.get(1).map(|v| v.id), + "and they really are the same id" + ); +} + +#[test] +fn the_states_a_later_task_will_refuse_are_still_representable() { + // None of this is a claim that such a text will be accepted. It is the + // claim that 4A-02 did not decide — the scalar parser (4A-07) and the + // checked builder (4A-09) each still have their refusals to make, and a + // struct that could not hold the value would have made those refusals + // unreachable and unattributable. + let document = ExactScoreDocument { + ppqn: 0, + master_bars: vec![ExactMasterBar { + index: 0, + ticks: ExactTickRange { + start: 100, + end: 10, + }, + meter: ExactMeter { + numerator: 0, + denominator: 3, + }, + tempo: ExactTempo { + numerator: 0, + denominator: 0, + }, + repeat: None, + }], + tracks: vec![ExactTrack { + name: None, + channel: 0, + tuning: vec![200], + voices: vec![ExactVoice { + id: 0, + groups: vec![ExactGroup { + kind: ExactGroupKind::Single, + atoms: vec![ExactAtom::Note(ExactNote { + at: 0, + duration: 1, + pitch: 200, + velocity: 255, + marks: Vec::new(), + position: None, + })], + spans: vec![ExactSpan { + technique: ExactSpanTechnique::Bend, + ticks: ExactTickRange { + start: 500, + end: 100, + }, + evidence: ExactEvidence { + source: ExactEvidenceSource::InferredFromMidi, + confidence: 65_535, + }, + }], + }], + }], + }], + source: None, + loss: Vec::new(), + }; + assert_eq!(document.ppqn, 0, "no constructor refused it"); + assert_eq!( + document.master_bars.first().map(|b| b.ticks.start), + Some(100), + "an inverted range is stored as written" + ); +} + +// ── widths ────────────────────────────────────────────────────────────────── + +#[test] +fn both_canonical_indices_are_wide_enough_for_the_values_core_admits() { + // SWG-CORE-01 closed H3 at `u64` because values above `u32::MAX` were + // already inhabited. A syntax form narrower than the model would lose + // them on the way in, which is the same defect one layer up. + let big = u64::from(u32::MAX) + 1; + let document = ExactScoreDocument { + master_bars: vec![ExactMasterBar { + index: big, + ticks: ExactTickRange { start: 0, end: 1 }, + meter: ExactMeter { + numerator: 4, + denominator: 4, + }, + tempo: ExactTempo { + numerator: 120, + denominator: 1, + }, + repeat: None, + }], + loss: vec![ + ExactWarning::TrackNameInvalidUtf8 { track_index: big }, + ExactWarning::TempoApproximated { + bar_index: big, + nearest_micros: u32::MAX, + }, + ], + ..reference() + }; + assert_eq!(document.master_bars.first().map(|b| b.index), Some(big)); + assert_eq!( + document.loss.first(), + Some(&ExactWarning::TrackNameInvalidUtf8 { track_index: big }), + "the warning payload survives past u32::MAX too" + ); +} + +// ── strings ───────────────────────────────────────────────────────────────── + +#[test] +fn strings_are_arbitrary_utf8_not_level_one_string_literals() { + // Level 1's `StringLiteral` refuses quotes and line breaks by + // construction, because its grammar has no escapes. §6.5 gives level 2 + // an escape policy, so the *value* side must hold anything a `String` + // can — that is what the escapes exist to spell. + let awkward = "a \" b \\ c \n d \r e \u{85} f \u{1f} g é 音"; + let document = ExactScoreDocument { + tracks: vec![ExactTrack { + name: Some(String::from(awkward)), + channel: 0, + tuning: Vec::new(), + voices: Vec::new(), + }], + source: Some(ExactSource { + format: Some(String::from(awkward)), + }), + loss: vec![ExactWarning::Other(String::from(awkward))], + ..reference() + }; + assert_eq!( + document.tracks.first().and_then(|t| t.name.as_deref()), + Some(awkward), + "the value is the value; escaping belongs to the text, not the tree" + ); + assert_eq!( + document.loss.first(), + Some(&ExactWarning::Other(String::from(awkward))) + ); +} diff --git a/swang/tests/exact_document_boundary.rs b/swang/tests/exact_document_boundary.rs new file mode 100644 index 0000000..82cf518 --- /dev/null +++ b/swang/tests/exact_document_boundary.rs @@ -0,0 +1,344 @@ +//! SWG-4A-02: the four boundaries the exact document must not cross. +//! +//! `ExactScoreDocument` is a **transient** syntax form. The danger it carries +//! is not that it will be wrong — it is that it will be useful, and quietly +//! become a second durable musical model beside `griff_core::Score`. Every +//! step of that happening looks like an improvement at the time: make it +//! public so a caller can build one, derive `Serialize` so a tool can dump +//! one, let the evaluator take one instead of a `Score`. +//! +//! So the shape tests live inside the crate, where the type is visible, and +//! these four live out here, where its *absence* from the public world is +//! what can be observed: +//! +//! A. `griff-core` does not depend on `griff-swang`; +//! B. the document is not re-exported from `syntax`, or anywhere public; +//! C. nothing outside the AST mentions it — not the evaluator, not the +//! pattern compiler, not `griff-core`, not the CLI; +//! D. it has no serialization format of its own. +//! +//! B, C and D read source text, which is a blunt instrument, so each one +//! also checks that its search *can* find something — a witness that cannot +//! fail is not a witness. + +// Reason: integration-test code. `unwrap`/`expect`/`panic` abort loudly with +// a clear message, which is exactly what a test harness wants. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::missing_assert_message +)] + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{env, fs}; + +/// The workspace root, from this crate's manifest directory. +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("the swang crate sits one level below the workspace root") + .to_path_buf() +} + +fn read(relative: &str) -> String { + let path = workspace_root().join(relative); + fs::read_to_string(&path).unwrap_or_else(|e| panic!("{} must be readable: {e}", path.display())) +} + +/// The code of a Rust source file: every line that is not a comment. +/// +/// The claims below are about what the module *does*, and a doc comment +/// explaining that it does not serialize is not an implementation of +/// serialization. Searching raw text would make the prose that documents a +/// boundary indistinguishable from a breach of it — which is exactly the +/// failure this witness family exists to avoid, one level down. +fn code_of(source: &str) -> String { + source + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n") +} + +/// Whether `haystack` mentions `needle` as a whole token, so that a longer +/// identifier containing it does not count as a mention. +/// +/// Byte-wise rather than char-wise: every identifier these witnesses look for +/// is ASCII, and any non-ASCII byte is a boundary anyway. +fn mentions(haystack: &str, needle: &str) -> bool { + let bytes = haystack.as_bytes(); + let is_word = |b: Option<&u8>| b.is_some_and(|&b| b.is_ascii_alphanumeric() || b == b'_'); + haystack.match_indices(needle).any(|(at, _)| { + let before = at.checked_sub(1).and_then(|i| bytes.get(i)); + let after = bytes.get(at.saturating_add(needle.len())); + !is_word(before) && !is_word(after) + }) +} + +// ── A. the dependency arrow points one way ────────────────────────────────── + +#[test] +fn griff_core_does_not_depend_on_griff_swang() { + // The lowering direction is text -> document -> builder -> Score. If the + // model crate could see the syntax form, the arrow could quietly reverse + // and the "transient" claim would be unenforceable. + // + // Asked of the resolver rather than of `Cargo.toml`: a manifest grep + // cannot see a dependency arriving through a feature, a rename, a target + // table, or a path two crates long. `cargo tree` walks what is actually + // built, dev-dependencies included. + let core_tree = dependency_tree("griff-core"); + assert!( + !core_tree.contains("griff-swang"), + "griff-core must not depend on griff-swang, directly or transitively:\n{core_tree}" + ); + + // The same command against the other crate must find the arrow that does + // exist, or the check above proves only that the command printed nothing. + let swang_tree = dependency_tree("griff-swang"); + assert!( + swang_tree.contains("griff-core"), + "the witness must be able to see a real dependency, and \ + griff-swang -> griff-core is the one it should find:\n{swang_tree}" + ); +} + +/// The full dependency tree of one workspace crate, as the resolver sees it. +fn dependency_tree(package: &str) -> String { + let output = Command::new(env::var("CARGO").unwrap_or_else(|_| String::from("cargo"))) + .args(["tree", "--package", package, "--prefix", "none"]) + .current_dir(workspace_root()) + .output() + .expect("cargo tree must run"); + assert!( + output.status.success(), + "cargo tree -p {package} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).expect("cargo tree emits UTF-8") +} + +// ── B. the document is not part of the public surface ─────────────────────── + +/// Whether the code of a `syntax` module re-exports the level-2 **AST**. +/// +/// The property is "the transient AST is not re-exported", not "the token +/// `v2` never appears". SWG-4A-06 adds level-2 parsing and formatting, and a +/// `pub use parser::v2::…` or `pub use format::v2::…` is precisely the wiring +/// that task exists to add — it exposes no part of the document. A check that +/// banned the version number outright would fail on legitimate work while its +/// message went on claiming the document had escaped, which is worse than not +/// checking: a red test that lies costs more than a missing one. +fn re_exports_the_level_two_ast(syntax_code: &str) -> bool { + syntax_code.contains("pub use ast::v2") +} + +#[test] +fn the_re_export_check_reads_the_boundary_and_not_the_version_number() { + // Both halves, because narrowing a check is only safe if it still fires + // on the thing it was narrowed away from. + assert!( + re_exports_the_level_two_ast("pub use ast::v2::ExactScoreDocument;"), + "re-exporting the document itself is a breach and must be seen" + ); + assert!( + re_exports_the_level_two_ast("pub use ast::v2 as level_two;"), + "so is re-exporting the module under any name" + ); + assert!( + !re_exports_the_level_two_ast("pub use parser::v2::parse_exact;"), + "a level-2 parser is not the AST, and 4A-06 must not be blocked by \ + this test" + ); + assert!( + !re_exports_the_level_two_ast("pub use format::v2::format_exact;"), + "nor a level-2 formatter" + ); +} + +#[test] +fn the_exact_document_is_not_re_exported() { + let syntax = code_of(&read("swang/src/syntax.rs")); + assert!( + syntax.contains("pub use ast::v1::{"), + "the witness must be able to see a real re-export: if this line \ + moved, the check below stopped meaning anything" + ); + assert!( + !re_exports_the_level_two_ast(&syntax), + "`syntax` re-exports level 1's AST only; the exact document and its \ + module stay crate-private" + ); + assert!( + !mentions(&syntax, "ExactScoreDocument"), + "and it is not named on the public surface by any other route — a \ + rename in the re-export path does not get around this" + ); + + let lib = code_of(&read("swang/src/lib.rs")); + assert!( + !mentions(&lib, "ExactScoreDocument"), + "nor re-exported from the crate root" + ); +} + +#[test] +fn the_module_itself_is_crate_private() { + let ast = code_of(&read("swang/src/syntax/ast.rs")); + assert!( + ast.contains("pub(crate) mod v1;"), + "the witness must be able to see the visibility it is checking for" + ); + assert!( + ast.contains("pub(crate) mod v2;"), + "v2 is declared beside v1, and at the same crate-private visibility" + ); + assert!( + !ast.contains("pub mod v2;"), + "not `pub mod` — that would put the transient form in the public API" + ); +} + +// ── C. nothing outside the AST is coupled to it ───────────────────────────── + +/// Every `.rs` file under `relative`, at any depth. +fn rust_files_under(relative: &str) -> Vec { + let mut found = Vec::new(); + let mut pending = vec![workspace_root().join(relative)]; + while let Some(dir) = pending.pop() { + let entries = fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("{} must be readable: {e}", dir.display())); + for entry in entries { + let path = entry.expect("a readable directory entry").path(); + if path.is_dir() { + pending.push(path); + } else if path.extension().is_some_and(|e| e == "rs") { + found.push(path); + } + } + } + found +} + +#[test] +fn no_production_code_outside_the_ast_mentions_the_document() { + // 4A-02 adds a type; it does not wire one in. + // + // Two different prohibitions, because they really are different, and + // collapsing them into one crate-wide scan would break the next two + // tasks. `griff-core` and the CLI must never see this type at any depth, + // now or later — so those are walked recursively and a module added + // tomorrow is covered without anyone remembering to list it. Inside + // `griff-swang` the prohibition is *not* crate-wide: 4A-08's parser will + // legitimately produce an `ExactScoreDocument` and 4A-09's builder will + // legitimately consume one. They are the pipeline this task exists to + // feed. Banning the name across `swang/src` would fail on exactly the + // work 4A-02 is a prerequisite for — the same shape of defect as a check + // that banned the token `v2` outright. + let mut checked = 0_usize; + + for tree in ["core/src", "cli/src"] { + let files = rust_files_under(tree); + assert!( + files.len() >= 3, + "{tree} should hold several modules; a walk that found {} has \ + stopped being a witness", + files.len() + ); + for path in files { + let text = code_of(&fs::read_to_string(&path).expect("a readable source file")); + assert!( + !mentions(&text, "ExactScoreDocument"), + "{} must not mention the transient syntax form: the document \ + never crosses into the model crate or the CLI", + path.display() + ); + checked = checked.saturating_add(1); + } + } + + // The named `griff-swang` surfaces that stay clear of it: the evaluator + // and the pattern compiler keep taking what they took before, the exact + // writer works from a `Score`, and neither entry point re-exports it. + for file in [ + "swang/src/eval.rs", + "swang/src/pattern_compile.rs", + "swang/src/lib.rs", + "swang/src/exact.rs", + "swang/src/syntax.rs", + ] { + let text = code_of(&read(file)); + assert!( + !mentions(&text, "ExactScoreDocument"), + "{file} must not mention the transient syntax form" + ); + checked = checked.saturating_add(1); + } + + assert!( + checked >= 15, + "only {checked} files were examined; the witness is not reaching the \ + tree it claims to cover" + ); +} + +// ── D. no serialization format of its own ─────────────────────────────────── + +#[test] +fn the_document_has_no_serialization_format() { + // The exact score text *is* the document's serialization. A second one — + // a JSON schema, a bincode dump, a hash contract — would be a format + // nobody agreed to freeze, and the first bug report against it would be + // about a compatibility promise this task never made. + let v2 = code_of(&read("swang/src/syntax/ast/v2.rs")); + for forbidden in ["Serialize", "Deserialize", "serde"] { + assert!( + !mentions(&v2, forbidden), + "the exact document must not derive or implement {forbidden}" + ); + } + assert!( + v2.contains("#[derive(Debug, Clone, PartialEq, Eq)]"), + "the witness must be able to see the derive list it is constraining, \ + and to see it in code rather than in a comment about code" + ); + assert!( + !mentions(&v2, "Hash"), + "and no hashing contract either — nothing keys anything by this form" + ); +} + +#[test] +fn the_document_performs_no_conversion() { + // No `From`, no `to_score`, no `build`, no `validate`. Lowering is + // 4A-09's whole job; a conversion here would either duplicate it or + // pre-empt it, and either way the refusals would stop being attributable. + let v2 = code_of(&read("swang/src/syntax/ast/v2.rs")); + assert!( + v2.contains("pub(crate) struct ExactScoreDocument {"), + "the witness must be able to see the declarations it is constraining" + ); + for forbidden in [ + "impl From", + "fn to_score", + "fn build", + "fn validate", + "fn parse", + "fn format", + "fn normalize", + "fn canonicalize", + ] { + assert!( + !v2.contains(forbidden), + "the exact document must not carry `{forbidden}`" + ); + } + assert!( + !mentions(&v2, "griff_core"), + "the syntax form names no canonical type at all: it is raw shapes, \ + so that `Pitch`, `Velocity` and their checked constructors stay \ + 4A-09's business" + ); +}