From f447fcc92525305774edb05b0930d5c3f9532848 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 04:12:47 +0000 Subject: [PATCH 1/6] =?UTF-8?q?test(swang):=20SWG-4A-02=20red=20=E2=80=94?= =?UTF-8?q?=20the=20exact=20document's=20shape=20and=20its=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests only. The single non-test line is the `#[cfg(test)] mod v2_tests;` registration in `syntax/ast.rs`, which is test wiring — there is no way to add an in-crate test without one. Two suites, because the claims need two vantage points: `swang/src/syntax/ast/v2_tests.rs` is inside the crate, where a `pub(crate)` type is visible, and states what `ExactScoreDocument` must be able to **hold**: - the whole §6.1 reference document as data, every level of the tree; - `note` and `rest` in one ordered slot, so `note, rest, note` survives; - all four warning variants in one ordered slot, with duplicates intact; - marks as written, neither deduplicated nor reordered into `NoteMark::ALL`; - the three `source` states, `None` vs `Some("")` for a track name, and an omitted `repeat` distinct from one spelled as the default; - the six group kinds, eight span techniques, seven marks, two evidence sources, four warnings; - the ugly-but-inhabited states §3 lists: channel 200, duplicate voice ids, `tuplet 0/0`, zero durations, a string beyond the tuning, explicit evidence at confidence 0; - and the states a **later** task will refuse: ppqn 0, pitch 200, velocity 255, meter 0/3, an inverted tick range. Not a claim that such a text will be accepted — a 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; - `u64` indices past `u32::MAX`, because CORE-01 closed H3 there and a narrower syntax form would lose the value on the way in; - arbitrary UTF-8 in every string, since level 1's `StringLiteral` refuses quotes and line breaks by construction and §6.5 gives level 2 escapes precisely so the value side does not have to. `swang/tests/exact_document_boundary.rs` is outside the crate, where the type's *absence* from the public world is what can be observed. The risk here is not that the document will be wrong; it is that it will be useful and quietly become a second durable musical model. Four boundaries: - `griff-core` does not depend on `griff-swang`, asked of `cargo tree` rather than of a manifest, because a grep cannot see a dependency arriving through a feature, a rename, or a path two crates long; - the document is not re-exported from `syntax` or the crate root, and its module is `pub(crate) mod`, never `pub mod`; - nothing outside the AST mentions it — not the evaluator, not the pattern compiler, not `griff-core`, not the CLI; - it has no serialization format of its own, no `Hash`, and no conversion: no `From`, `to_score`, `build`, `validate`, or any mention of `griff_core`, so the checked constructors stay 4A-09's business. The text-reading witnesses each also assert their search can find something — the `pub use ast::v1::{` line, the `pub(crate) mod v1;` line, the derive list — because a search that cannot fail is not a witness. RED evidence: error[E0432]: unresolved import `super::v2` --> swang/src/syntax/ast/v2_tests.rs:20:12 error: could not compile `griff-swang` (lib test) and in the boundary suite, 3 of 6 fail on the missing module and file. The other 3 pass already: they guard state that exists today and must survive the next commit, which is the only thing a boundary test can usefully do. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018vdRzztKXy8bA16tEzwgLE --- swang/src/syntax/ast.rs | 9 + swang/src/syntax/ast/v2_tests.rs | 590 +++++++++++++++++++++++++ swang/tests/exact_document_boundary.rs | 250 +++++++++++ 3 files changed, 849 insertions(+) create mode 100644 swang/src/syntax/ast/v2_tests.rs create mode 100644 swang/tests/exact_document_boundary.rs diff --git a/swang/src/syntax/ast.rs b/swang/src/syntax/ast.rs index 1b9d53f..b0de03f 100644 --- a/swang/src/syntax/ast.rs +++ b/swang/src/syntax/ast.rs @@ -4,3 +4,12 @@ //! than editing it. pub(crate) mod v1; + +#[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_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..b38fcbd --- /dev/null +++ b/swang/tests/exact_document_boundary.rs @@ -0,0 +1,250 @@ +//! 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 ─────────────────────── + +#[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!( + !mentions(&syntax, "v2"), + "`syntax` re-exports level 1 only; the exact document stays crate-private" + ); + assert!( + !mentions(&syntax, "ExactScoreDocument"), + "and it is not named on the public surface by any other route" + ); + + 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 ───────────────────────────── + +#[test] +fn no_production_code_outside_the_ast_mentions_the_document() { + // 4A-02 adds a type; it does not wire one in. The evaluator and the + // pattern compiler keep taking exactly what they took before, and the + // generation path never learns this type exists. + for file in [ + "swang/src/eval.rs", + "swang/src/pattern_compile.rs", + "swang/src/lib.rs", + "swang/src/exact.rs", + "swang/src/syntax.rs", + "core/src/score.rs", + "core/src/event.rs", + "core/src/lib.rs", + "cli/src/main.rs", + "cli/src/lib.rs", + ] { + let text = code_of(&read(file)); + assert!( + !mentions(&text, "ExactScoreDocument"), + "{file} must not mention the transient syntax form" + ); + } +} + +// ── 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" + ); +} From 8cd3b1355d23b4331dea44266d20ca42955cf9dc Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 04:19:30 +0000 Subject: [PATCH 2/6] =?UTF-8?q?feat(swang):=20SWG-4A-02=20green=20?= =?UTF-8?q?=E2=80=94=20ExactScoreDocument?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swang/src/syntax/ast/v2.rs`, plus one `pub(crate) mod v2;` line beside `v1`. Nothing else in the tree changes: no parser, no builder, no formatter, no conversion, no caller. The whole module is data. The two decisions worth naming: **Raw shapes, not canonical types.** A note's pitch is a `u8`, not a `Pitch`; a meter is two `u8`s, not a `TimeSignature`. Reaching for the canonical newtypes 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`, `velocity 255`. That is not a claim any such text will be accepted; it is the claim that this task did not decide. The converse matters more: everything §3 lists as *inhabited* canonical state — `channel 200`, duplicate `Voice.id`, `tuplet 0/0`, a zero-duration rest, a string past the tuning — must be representable, or the exact writer's own output would have no syntax form to parse back into. **Named slots, not a concrete syntax tree.** §6.2 already settled what is semantic, so the fields are typed and named: order *between* slots belongs to the formatter and is not recorded, order *within* a repeated slot belongs to the music and is a `Vec` that nothing sorts or dedups. `note` and `rest` are one sum type in one sequence; the four warning variants are likewise one sequence, not four vectors. The one deliberate exception is `ExactNote::marks`, a `Vec` although the canonical model's `NoteMarks` is a set. A set here would silently accept `marks [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 that refusal to whoever should make it. Flagged rather than buried, since it is the one place the "order within a slot is semantic" rule does not apply and the shape still preserves order. `#![allow(dead_code)]` is the honest annotation, not an oversight: nothing outside the module's own tests constructs one of these yet, because 4A-06..08 fill the form from text and 4A-09 lowers it. Wiring it into a caller now is exactly what the boundary suite forbids. No `Serialize`, no `Hash`, no `From`, no `to_score`, no mention of `griff_core`. The exact score text is this document's serialization; a second one would be a compatibility promise nobody agreed to freeze. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018vdRzztKXy8bA16tEzwgLE --- swang/src/syntax/ast.rs | 1 + swang/src/syntax/ast/v2.rs | 362 +++++++++++++++++++++++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 swang/src/syntax/ast/v2.rs diff --git a/swang/src/syntax/ast.rs b/swang/src/syntax/ast.rs index b0de03f..f739663 100644 --- a/swang/src/syntax/ast.rs +++ b/swang/src/syntax/ast.rs @@ -4,6 +4,7 @@ //! than editing it. pub(crate) mod v1; +pub(crate) mod v2; #[cfg(test)] #[allow( 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), +} From 2e306636859938f78ea589d9df33d86dc2ebacb0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 04:24:28 +0000 Subject: [PATCH 3/6] =?UTF-8?q?docs(swang):=20SWG-4A-02=20closure=20?= =?UTF-8?q?=E2=80=94=20the=20document=20exists,=20INF-04=20is=20next?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marks 4A-02 done in the index and its own section, records the acceptance actually met, and moves the "next" marker to INF-04, which can now design `SourceMap`, `AstId` and `FieldRef` against a real v2 shape instead of an imagined one. Two corrections to the original entry, both in the direction of claiming less: **"Holds author order only until canonical formatting"** overstates what was built. §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. Recording the rest would have made this a concrete syntax tree preserving information the spec calls meaningless. **"After lowering, the evaluator sees only `Score`"** cannot be discharged here and is deliberately not claimed. Lowering does not exist at 4A-02, so no test can observe what the evaluator receives after it. The bullet splits: the structural half is proven now — the document cannot reach the evaluator at all, because `mod ast` is private to `syntax` and importing the type from outside is `E0603` — and the dynamic half stays open until 4A-09. Proving the second half with code that does not exist is how a backlog starts believing its own plans. The decision log gains the one real design decision (marks stored as a sequence although the canonical counterpart is a set, so that normalization is never performed by a struct definition with no diagnostic and no author) and the record of that two-part split. Level 2 is not frozen and Phase 4A is not closed. Verified: 16 census witnesses still green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018vdRzztKXy8bA16tEzwgLE --- docs/decisions.log.md | 24 ++++++++++++ docs/swang/foundation-backlog.md | 63 ++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 15 deletions(-) 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..d9df51f 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,52 @@ 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. ### SWG-4A-03 — Writer: transport and master timeline @@ -1256,8 +1288,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 From 57feec5db67ab6f715b7dc1f0a377b7ff216727f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 04:54:33 +0000 Subject: [PATCH 4/6] =?UTF-8?q?docs(swang):=20SWG-4A-02=20review=20?= =?UTF-8?q?=E2=80=94=20give=20the=20deferred=20obligation=20a=20landing=20?= =?UTF-8?q?point?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs only. No production change, no test change, no RED/GREEN rewrite. Independent review found a real hole in the closure, not in the code. SWG-4A-02 correctly refused to claim "after lowering, the evaluator sees only `Score`" — lowering does not exist yet — and split the bullet into a structural half proven now and a dynamic half deferred to 4A-09. But the deferral existed only as prose inside a task that was closing. The backlog's own rule is that every task carries an Acceptance, and that acceptance must be able to fail; a sentence buried under a `*(done)*` heading satisfies neither. An agent opening 4A-09 would work its local list, satisfy it honestly, and never learn an obligation had been assigned to it. So the obligation moves to where it must be discharged: SWG-4A-09 gains an explicit acceptance bullet — lowering consumes an `ExactScoreDocument` and returns a canonical `Score`, and no evaluator, generator, or other downstream semantic consumer accepts or observes an `ExactScoreDocument` — with a short note on which half 4A-02 already proved and why the rest could not be. 4A-02's closure now points at that bullet instead of describing the split and stopping. 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 — which is the same failure the split was meant to avoid, one level of bureaucracy up. Verified: 16 census witnesses still green. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018vdRzztKXy8bA16tEzwgLE --- docs/swang/foundation-backlog.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/docs/swang/foundation-backlog.md b/docs/swang/foundation-backlog.md index d9df51f..95aad3e 100644 --- a/docs/swang/foundation-backlog.md +++ b/docs/swang/foundation-backlog.md @@ -539,7 +539,10 @@ not exist yet. It splits in two: ``` Proving the second half with code that does not exist is how a backlog -starts believing its own plans. +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 @@ -715,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 From 0160eeedd2278fa8fe9d6bf70407ce32bd7e6ff8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 05:10:14 +0000 Subject: [PATCH 5/6] =?UTF-8?q?test(swang):=20SWG-4A-02=20review=20?= =?UTF-8?q?=E2=80=94=20check=20the=20boundary,=20not=20the=20version=20num?= =?UTF-8?q?ber?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests only, from Codex's P2 on #193. No production change. `the_exact_document_is_not_re_exported` asserted that the token `v2` appears nowhere in `syntax.rs`. That is stricter than the property it claims. When SWG-4A-06 adds level/root dispatch, a `pub use parser::v2::…` or `pub use format::v2::…` is exactly the wiring that task exists to add, and it exposes no part of the document — but the assertion would have failed on it while its message went on saying "the exact document stays crate-private", which was still true. A red test that lies about why costs more than a missing one, and this one would have fired two tasks from now, on legitimate work, with a misleading explanation attached. The predicate is now `pub use ast::v2` — a re-export of the level-2 **AST** — factored into `re_exports_the_level_two_ast` so that the narrowing itself is checkable rather than asserted. `the_re_export_check_reads_the_boundary_and_ not_the_version_number` runs it over four synthetic lines and pins both directions: the document re-exported by name and the module re-exported under an alias must both be seen; a level-2 parser and a level-2 formatter must both be allowed. Narrowing a check is only safe if it still fires on what it was narrowed away from, and that is now a test rather than a claim in a commit message. The whole-token `ExactScoreDocument` check stays, so a rename in the re-export path does not get around it. Verified: the two probes that exercise this boundary still land. `make the module public` → CAUGHT by `the_module_itself_is_crate_private`; `public module AND AST re-export together` → CAUGHT by four witnesses, `the_exact_document_is_not_re_exported` among them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018vdRzztKXy8bA16tEzwgLE --- swang/tests/exact_document_boundary.rs | 44 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/swang/tests/exact_document_boundary.rs b/swang/tests/exact_document_boundary.rs index b38fcbd..ec5f321 100644 --- a/swang/tests/exact_document_boundary.rs +++ b/swang/tests/exact_document_boundary.rs @@ -122,6 +122,42 @@ fn dependency_tree(package: &str) -> String { // ── 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")); @@ -131,12 +167,14 @@ fn the_exact_document_is_not_re_exported() { moved, the check below stopped meaning anything" ); assert!( - !mentions(&syntax, "v2"), - "`syntax` re-exports level 1 only; the exact document stays crate-private" + !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" + "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")); From 1ef0642e77f0310eb846043e02f3aa847236785e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 05:14:16 +0000 Subject: [PATCH 6/6] =?UTF-8?q?test(swang):=20SWG-4A-02=20review=20?= =?UTF-8?q?=E2=80=94=20walk=20the=20trees=20where=20the=20ban=20is=20absol?= =?UTF-8?q?ute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests only, from CodeRabbit's nitpick on #193. No production change. `no_production_code_outside_the_ast_mentions_the_document` checked a hardcoded list of ten files. The gap is real: a production module added tomorrow is not on that list, so it could name `ExactScoreDocument` and the witness would not notice. That is the same defect this series keeps finding — a check whose reach is narrower than the property it claims — one level up, in the choice of files rather than the choice of pattern. The suggestion as written was to walk `swang/src`, `core/src` and `cli/src` recursively, excluding `swang/src/syntax/ast`. Taken literally that would reintroduce the defect Codex had just caught two files over: SWG-4A-08's parser will legitimately **produce** an `ExactScoreDocument` and 4A-09's builder will legitimately **consume** one, and neither lives under `syntax/ast`. A crate-wide ban would fail on precisely the work 4A-02 is a prerequisite for. So the two prohibitions are separated, because they are different: - **`core/src` and `cli/src` are walked recursively.** The document must never cross into the model crate or the CLI, at any depth, now or later. A module added tomorrow is covered without anyone remembering to list it. - **Inside `griff-swang` the named surfaces stay named** — the evaluator, the pattern compiler, the exact writer, and the two entry points. The pipeline that 4A-08 and 4A-09 will build is deliberately left free. The walk asserts it found something (at least three files per tree, at least fifteen overall), because a recursive scan that silently walks nothing is a witness that passes for the wrong reason. Verified both directions. A mention added to `core/src/slice.rs` — a file the old hardcoded list never named — is now CAUGHT. A `parse_exact` stub in `swang/src/syntax/parser.rs` returning an `ExactScoreDocument`, which is what 4A-08 will actually add, is allowed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018vdRzztKXy8bA16tEzwgLE --- swang/tests/exact_document_boundary.rs | 72 +++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 8 deletions(-) diff --git a/swang/tests/exact_document_boundary.rs b/swang/tests/exact_document_boundary.rs index ec5f321..82cf518 100644 --- a/swang/tests/exact_document_boundary.rs +++ b/swang/tests/exact_document_boundary.rs @@ -203,29 +203,85 @@ fn the_module_itself_is_crate_private() { // ── 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. The evaluator and the - // pattern compiler keep taking exactly what they took before, and the - // generation path never learns this type exists. + // 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", - "core/src/score.rs", - "core/src/event.rs", - "core/src/lib.rs", - "cli/src/main.rs", - "cli/src/lib.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 ───────────────────────────────────