Skip to content

Commit f61a899

Browse files
authored
Merge pull request #183 from PhysShell/claude/swang-4a03-writer-transport
SWG-4A-03: exact writer — transport and master timeline
2 parents 78ce186 + 8554f97 commit f61a899

6 files changed

Lines changed: 625 additions & 0 deletions

File tree

‎swang/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,8 @@ griff-core = { path = "../core", default-features = false }
1515
griff-pattern = { path = "../pattern" }
1616
serde_json = { workspace = true }
1717

18+
[dev-dependencies]
19+
griff-core = { path = "../core", default-features = false }
20+
1821
[lints]
1922
workspace = true

‎swang/src/exact.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//! The exact canonical score text — level 2 (`docs/swang/exact-score-text.md`).
2+
//!
3+
//! This module is the writer half: a canonical `Score` in, one canonical
4+
//! level-2 document out. The parser half arrives later and separately.
5+
//!
6+
//! Written incrementally by slice. SWG-4A-03 covers the header, `ppqn`, and
7+
//! the master timeline; tracks and everything below them are SWG-4A-04 and
8+
//! after, and a `Score` carrying them is refused with
9+
//! [`ExactWriteError::NotYetWritten`] rather than half-written.
10+
11+
mod error;
12+
mod write;
13+
14+
pub use error::ExactWriteError;
15+
pub use write::write_score;

‎swang/src/exact/error.rs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//! Why the exact writer produced no bytes.
2+
3+
use core::error::Error;
4+
use core::fmt::{Display, Formatter, Result as FmtResult};
5+
6+
use griff_core::event::ValidationError;
7+
8+
/// A refusal from the exact-text writer.
9+
///
10+
/// The two variants say different things and must not be collapsed. One is
11+
/// a judgement about the `Score`; the other is a statement about how much
12+
/// of the writer exists yet.
13+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14+
pub enum ExactWriteError {
15+
/// The `Score` violates an invariant `griff-core` itself declares, so
16+
/// **no** exact writer — this one or the finished one — may emit it
17+
/// (`docs/swang/exact-score-text.md` §3).
18+
///
19+
/// The carried `reason` is the model's own [`ValidationError`], not a
20+
/// message of the writer's invention: that is what makes "this
21+
/// predicate invents nothing" checkable rather than merely claimed.
22+
OutsideWriterDomain {
23+
/// Where in the score the violation sits, for a human reading the
24+
/// refusal. Not a source span — there is no source yet.
25+
at: &'static str,
26+
/// The model's own verdict.
27+
reason: ValidationError,
28+
},
29+
/// The `Score` is perfectly writable in principle; this increment of
30+
/// the writer does not reach that part of the tree yet.
31+
///
32+
/// A slice boundary, never a claim that an inhabited state is
33+
/// unspellable — the census forbids the latter, and reporting a
34+
/// build-stage limit as a domain judgement would smuggle it in.
35+
NotYetWritten {
36+
/// The part of the tree that is still unimplemented.
37+
what: &'static str,
38+
/// The task that will write it.
39+
task: &'static str,
40+
},
41+
}
42+
43+
impl Display for ExactWriteError {
44+
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
45+
match self {
46+
Self::OutsideWriterDomain { at, reason } => {
47+
write!(f, "{at} is outside the exact writer's domain: {reason:?}")
48+
}
49+
Self::NotYetWritten { what, task } => {
50+
write!(f, "{what} is not written yet; {task} adds it")
51+
}
52+
}
53+
}
54+
}
55+
56+
impl Error for ExactWriteError {}

‎swang/src/exact/write.rs‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
//! Canonical level-2 text from a canonical `Score` — transport and master
2+
//! timeline (SWG-4A-03).
3+
//!
4+
//! The census (`docs/swang/exact-score-text.md`) decided every spelling
5+
//! here; this module only obeys it. Four of those decisions are load-bearing
6+
//! and easy to undo by accident, so each is marked at its site:
7+
//!
8+
//! - `MasterBar.index` is the **stored** value, never the vector position;
9+
//! - `master_bars` keeps **vector order** and is never sorted;
10+
//! - `Tempo` is written from its own reduced rational, including `7/1` and
11+
//! `100/7`, never reconstructed from a constructor's provenance;
12+
//! - `repeat` is omitted for `RepeatMarker::default()` and written
13+
//! literally for every other combination.
14+
15+
use griff_core::event::{TimeSignature, ValidationError};
16+
use griff_core::score::{MasterBar, RepeatMarker, Score};
17+
use griff_core::slice::TickRange;
18+
19+
use super::ExactWriteError;
20+
21+
/// The level-2 header, byte for byte (spec §1.1).
22+
const HEADER: &str = "swang 2\n";
23+
24+
/// Writes `score` as one canonical level-2 document.
25+
///
26+
/// # Errors
27+
/// [`ExactWriteError::OutsideWriterDomain`] when the score violates an
28+
/// invariant `griff-core` declares, and [`ExactWriteError::NotYetWritten`]
29+
/// when it reaches a part of the tree this slice does not cover. Both are
30+
/// returned **before any byte is produced**: a partial document is not a
31+
/// thing this writer can emit.
32+
pub fn write_score(score: &Score) -> Result<String, ExactWriteError> {
33+
// Domain first, frontier second, and the order is deliberate. A score
34+
// that is both invalid and beyond this slice gets the permanent verdict,
35+
// not the temporary one: "the finished writer will refuse this too" is
36+
// strictly more informative than "not implemented yet", and reversing
37+
// the checks would hide the stronger statement behind the weaker.
38+
check_writer_domain(score)?;
39+
check_slice_frontier(score)?;
40+
41+
let mut out = String::from(HEADER);
42+
out.push_str("\nscore {\n");
43+
field(
44+
&mut out,
45+
" ",
46+
"ppqn",
47+
&score.ticks_per_quarter.to_string(),
48+
);
49+
// Vector order, never sorted: the order is the semantics (§6.2 rule 2).
50+
for bar in &score.master_bars {
51+
write_master_bar(&mut out, bar);
52+
}
53+
out.push_str("}\n");
54+
Ok(out)
55+
}
56+
57+
/// One `<indent><word> <value>` line — the shape of every scalar field.
58+
///
59+
/// Assembled by pushing parts rather than by `write!` or
60+
/// `push_str(&format!(…))`. The first returns a `Result` that cannot fail
61+
/// when the sink is a `String`, and pretending to handle it would be
62+
/// dishonest; the second is denied by `clippy::format_push_string`.
63+
fn field(out: &mut String, indent: &str, word: &str, value: &str) {
64+
out.push_str(indent);
65+
out.push_str(word);
66+
out.push(' ');
67+
out.push_str(value);
68+
out.push('\n');
69+
}
70+
71+
/// One `master_bar` block.
72+
fn write_master_bar(out: &mut String, bar: &MasterBar) {
73+
out.push_str("\n master_bar {\n");
74+
75+
// The *stored* index (H4). Deriving it from the enumeration position
76+
// would make a disagreeing import unrepresentable.
77+
field(out, " ", "index", &bar.index.to_string());
78+
79+
let ticks = format!("{}..{}", bar.tick_range.start.0, bar.tick_range.end.0);
80+
field(out, " ", "ticks", &ticks);
81+
82+
let meter = format!(
83+
"{}/{}",
84+
bar.time_signature.numerator, bar.time_signature.denominator
85+
);
86+
field(out, " ", "meter", &meter);
87+
88+
// The reduced rational itself, denominator always written. `7/1` is a
89+
// tempo `from_bpm_integer` builds and `60_000_000 % 7 != 0` would
90+
// wrongly reject; writing the value rather than a reconstructed
91+
// provenance keeps that trap out of the writer entirely (H1).
92+
let tempo = format!(
93+
"{}/{}",
94+
bar.tempo.bpm_numerator(),
95+
bar.tempo.bpm_denominator()
96+
);
97+
field(out, " ", "tempo", &tempo);
98+
99+
// Omitted only for the model's own documented default, which
100+
// `RepeatMarker` itself defines as "no repeat barline". Every other
101+
// combination is written literally, including the degenerate
102+
// `play_count 1` that `closes()` calls false.
103+
if bar.repeat != RepeatMarker::default() {
104+
// Inline, as §6.1 spells it. A block small enough to read on one
105+
// line is written on one line; the layout is part of the canonical
106+
// text, not a formatting preference.
107+
out.push_str(" repeat { start ");
108+
out.push_str(&bar.repeat.start.to_string());
109+
out.push_str(" play_count ");
110+
out.push_str(&bar.repeat.play_count.to_string());
111+
out.push_str(" }\n");
112+
}
113+
114+
out.push_str(" }\n");
115+
}
116+
117+
// ── the writer domain (census §3) ──────────────────────────────────────────
118+
119+
/// Re-checks the invariants `griff-core` already declares.
120+
///
121+
/// Every clause delegates to the model's own checked constructor where one
122+
/// exists, so the predicate cannot drift away from the model's rules — and
123+
/// the error it carries is the model's [`ValidationError`], not a second
124+
/// small constitution written here.
125+
///
126+
/// The constructors are advisory rather than enforced (H5: the fields are
127+
/// public), which is exactly why the writer must run them again on values
128+
/// that may never have passed through them.
129+
fn check_writer_domain(score: &Score) -> Result<(), ExactWriteError> {
130+
if score.ticks_per_quarter == 0 {
131+
return Err(ExactWriteError::OutsideWriterDomain {
132+
at: "score.ppqn",
133+
reason: ValidationError::InvalidTicksPerQuarter,
134+
});
135+
}
136+
for bar in &score.master_bars {
137+
TickRange::new(bar.tick_range.start, bar.tick_range.end).map_err(|reason| {
138+
ExactWriteError::OutsideWriterDomain {
139+
at: "master_bar.ticks",
140+
reason,
141+
}
142+
})?;
143+
TimeSignature::new(bar.time_signature.numerator, bar.time_signature.denominator).map_err(
144+
|reason| ExactWriteError::OutsideWriterDomain {
145+
at: "master_bar.meter",
146+
reason,
147+
},
148+
)?;
149+
}
150+
Ok(())
151+
}
152+
153+
// ── this slice's frontier ──────────────────────────────────────────────────
154+
155+
/// Refuses the parts of the tree SWG-4A-03 does not write yet.
156+
///
157+
/// Not a claim about representability: every state named here is spellable
158+
/// under the accepted grammar, and a later slice will spell it.
159+
const fn check_slice_frontier(score: &Score) -> Result<(), ExactWriteError> {
160+
if !score.tracks.is_empty() {
161+
return Err(ExactWriteError::NotYetWritten {
162+
what: "score.track",
163+
task: "SWG-4A-04",
164+
});
165+
}
166+
if score.source_meta.is_some() {
167+
return Err(ExactWriteError::NotYetWritten {
168+
what: "score.source",
169+
task: "SWG-4A-05",
170+
});
171+
}
172+
if !score.loss.is_clean() {
173+
return Err(ExactWriteError::NotYetWritten {
174+
what: "score.loss",
175+
task: "SWG-4A-05",
176+
});
177+
}
178+
Ok(())
179+
}

‎swang/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
//! spec §3).
1010
1111
pub mod eval;
12+
pub mod exact;
1213
pub mod pattern_compile;
1314
pub mod syntax;
1415

0 commit comments

Comments
 (0)