Skip to content

Commit 226eb43

Browse files
committed
Merge branch 'claude/lab-optimization-gap' into claude/lab-fingering-tie-break
Brings in the optimality-gap branch, which now includes main at e871a44 (#201, #202, #203, #204) and its re-measurement after #202. The only conflict was docs/decisions.log.md, and every entry involved is dated 2026-09-17. main's entries come first and this branch's tie-break entry comes after them, so once #197 lands this branch's decision-log diff is a single append. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 parents b5400a2 + 0740729 commit 226eb43

45 files changed

Lines changed: 11861 additions & 129 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/cockpit-pages.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
- 'cockpit/**'
1010
- 'core/**'
1111
- 'ui-core/**'
12+
- 'experiment/**'
1213
- 'Cargo.toml'
1314
- 'Cargo.lock'
1415
- '.github/workflows/cockpit-pages.yml'
@@ -38,7 +39,7 @@ jobs:
3839
~/.cargo/registry
3940
~/.cargo/git
4041
target
41-
key: cockpit-wasm-${{ hashFiles('cockpit/Cargo.toml', 'core/Cargo.toml', 'ui-core/Cargo.toml', 'Cargo.lock') }}
42+
key: cockpit-wasm-${{ hashFiles('cockpit/Cargo.toml', 'core/Cargo.toml', 'ui-core/Cargo.toml', 'experiment/Cargo.toml', 'Cargo.lock') }}
4243
# build-web.sh needs wasm-bindgen-cli matching the crate version pinned in
4344
# cockpit/Cargo.toml; cache the built binary across runs.
4445
- name: Cache wasm-bindgen-cli

‎.github/workflows/cockpit-web-test.yml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
- 'cockpit/**'
1111
- 'core/**'
1212
- 'ui-core/**'
13+
- 'experiment/**'
1314
- 'Cargo.toml'
1415
- 'Cargo.lock'
1516
- '.github/workflows/cockpit-web-test.yml'
@@ -19,6 +20,7 @@ on:
1920
- 'cockpit/**'
2021
- 'core/**'
2122
- 'ui-core/**'
23+
- 'experiment/**'
2224
- 'Cargo.toml'
2325
- 'Cargo.lock'
2426
- '.github/workflows/cockpit-web-test.yml'
@@ -46,7 +48,7 @@ jobs:
4648
~/.cargo/registry
4749
~/.cargo/git
4850
target
49-
key: cockpit-wasm-${{ hashFiles('cockpit/Cargo.toml', 'core/Cargo.toml', 'ui-core/Cargo.toml', 'Cargo.lock') }}
51+
key: cockpit-wasm-${{ hashFiles('cockpit/Cargo.toml', 'core/Cargo.toml', 'ui-core/Cargo.toml', 'experiment/Cargo.toml', 'Cargo.lock') }}
5052
# build-web.sh needs wasm-bindgen-cli matching the crate version pinned in
5153
# cockpit/Cargo.toml; cache the built binary across runs (as web.yml does).
5254
- name: Cache wasm-bindgen-cli

‎Cargo.lock‎

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[workspace]
2-
members = ["core", "pattern", "swang", "ui-core", "cockpit", "cli", "plugin", "preview"]
2+
members = ["core", "pattern", "swang", "ui-core", "experiment", "cockpit", "cli", "plugin", "preview"]
33
# `fuzz/` is an isolated nightly cargo-fuzz crate (ADR-0010); it is
44
# deliberately not a workspace member so stable `--workspace` builds,
55
# clippy, and tests never touch it.

‎cli/src/generation_input.rs‎

Lines changed: 118 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use std::collections::HashMap;
1414
use std::fs;
1515
use std::path::Path;
1616

17-
use griff_core::corpus::{source_sha256, ChunkMeta};
17+
use griff_core::corpus::{bind_source, source_sha256, ChunkMeta};
1818
use griff_core::generation_input::{corpus_material, prepare_chunk, LoadedChunk};
1919
use griff_core::import;
2020
use griff_core::score::Score;
@@ -56,7 +56,7 @@ fn load_corpus_material_with(
5656
.collect();
5757
record_names.sort_unstable();
5858

59-
let mut cache: HashMap<String, Score> = HashMap::new();
59+
let mut cache: HashMap<String, Option<SourceFile>> = HashMap::new();
6060
let mut loaded = Vec::new();
6161
let mut skipped = Vec::new();
6262
for name in record_names {
@@ -68,48 +68,69 @@ fn load_corpus_material_with(
6868
Ok(corpus_material(loaded, skipped))
6969
}
7070

71-
/// Reads one chunk record and prepares it through core, importing its source
72-
/// tab (or reusing an already-parsed one from `cache`). `None` when the record
73-
/// does not parse, the source is missing/unimportable/hash-mismatched, or the
74-
/// prepared slice carries no sounding track — the caller reports it as skipped.
71+
/// One source file, read once however many records name it: its content hash
72+
/// and its parse, which waits until a record binds to the file.
73+
struct SourceFile {
74+
/// [`source_sha256`] of the file's bytes.
75+
sha256: String,
76+
/// The bytes before the first bound record, the parse after.
77+
parse: SourceParse,
78+
}
79+
80+
/// A source file's parse, taken lazily so a file no record binds is never
81+
/// imported.
82+
enum SourceParse {
83+
/// Read and hashed; no record has bound to it yet.
84+
Pending(Vec<u8>),
85+
/// Imported — `None` when it does not import.
86+
Done(Option<Score>),
87+
}
88+
89+
/// Reads one chunk record and prepares it through core, reading and parsing its
90+
/// source tab once per file (reused from `cache` afterwards). `None` when the
91+
/// record does not parse, the file it names is missing, holds other bytes than
92+
/// the record pins, or does not import, or the prepared slice carries no
93+
/// sounding track — the caller reports it as skipped.
7594
fn load_chunk(
7695
dir: &Path,
7796
record_name: &str,
78-
cache: &mut HashMap<String, Score>,
97+
cache: &mut HashMap<String, Option<SourceFile>>,
7998
import: &mut impl FnMut(&[u8]) -> Option<Score>,
8099
) -> Option<LoadedChunk> {
81100
let meta: ChunkMeta =
82101
serde_json::from_str(&fs::read_to_string(dir.join(record_name)).ok()?).ok()?;
83-
// Key the parsed source by its content hash (v9) — falling back to the
84-
// filename for pre-v9 records — so every chunk of one tab reuses a single
85-
// parse. Determinism is unchanged: `prepare_chunk` slices an immutable
86-
// `&Score`, so a shared parse yields exactly the per-chunk-parse result.
87-
let key = meta
88-
.source
89-
.sha256
90-
.clone()
91-
.unwrap_or_else(|| meta.source.filename.clone());
92-
if !cache.contains_key(&key) {
93-
let bytes = fs::read(dir.join(&meta.source.filename)).ok()?;
94-
// A filename is not an identity: when the record pins the source's hash
95-
// (schema v9), a same-named but different file must not silently supply
96-
// the notes. A mismatch is a load failure — and a cache miss, so the
97-
// check runs for every distinct expected hash, never bypassed by reuse.
98-
if let Some(expected) = &meta.source.sha256 {
99-
if &source_sha256(&bytes) != expected {
100-
return None;
101-
}
102-
}
103-
cache.insert(key.clone(), import(&bytes)?);
102+
// Keyed by the file the record names, not by the hash it pins: a cache keyed
103+
// by hash let a record whose own file is missing, or holds other bytes,
104+
// borrow a parse another file supplied. Every record binds its own file
105+
// (`bind_source`, the rule the cockpit loader applies too); the read, the
106+
// hash, and the parse still happen once per file. Determinism is unchanged:
107+
// `prepare_chunk` slices an immutable `&Score`.
108+
let file = cache
109+
.entry(meta.source.filename.clone())
110+
.or_insert_with(|| {
111+
fs::read(dir.join(&meta.source.filename))
112+
.ok()
113+
.map(|bytes| SourceFile {
114+
sha256: source_sha256(&bytes),
115+
parse: SourceParse::Pending(bytes),
116+
})
117+
})
118+
.as_mut()?;
119+
bind_source(&meta.source, &file.sha256).ok()?;
120+
if let SourceParse::Pending(bytes) = &file.parse {
121+
file.parse = SourceParse::Done(import(bytes));
104122
}
105-
prepare_chunk(meta, cache.get(&key)?)
123+
let SourceParse::Done(Some(score)) = &file.parse else {
124+
return None;
125+
};
126+
prepare_chunk(meta, score)
106127
}
107128

108129
#[cfg(test)]
109130
mod tests {
110131
#![allow(clippy::expect_used, clippy::unwrap_used)]
111132

112-
use super::{import, load_corpus_material_with, source_sha256, Score};
133+
use super::{import, load_corpus_material, load_corpus_material_with, source_sha256, Score};
113134
use griff_core::corpus::{ChunkId, ChunkMeta, SourceFormat, SourceRef};
114135
use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity};
115136
use griff_core::midi;
@@ -247,4 +268,71 @@ mod tests {
247268

248269
fs::remove_dir_all(&dir).ok();
249270
}
271+
272+
/// `two_bar_source` with its first note moved: a valid tab with other bytes.
273+
fn other_source() -> Score {
274+
let mut score = two_bar_source();
275+
let first = score
276+
.tracks
277+
.iter_mut()
278+
.flat_map(|t| &mut t.voices)
279+
.flat_map(|v| &mut v.event_groups)
280+
.flat_map(|g| &mut g.atoms)
281+
.next()
282+
.unwrap();
283+
if let AtomEvent::Note(n) = first {
284+
n.pitch = Pitch::new(41).unwrap();
285+
}
286+
score
287+
}
288+
289+
/// The source-binding scenario. The native cockpit loader's test builds the
290+
/// same directory and expects the same accepted and skipped records: the
291+
/// two loaders are two shells over one binding rule.
292+
#[test]
293+
fn a_record_binds_only_the_file_it_names_holding_the_bytes_it_pins() {
294+
let dir = env::temp_dir().join(format!("griff_binding_cli_{}", process::id()));
295+
fs::create_dir_all(&dir).unwrap();
296+
let x = midi::export_score(&two_bar_source()).unwrap().bytes;
297+
let y = midi::export_score(&other_source()).unwrap().bytes;
298+
let (sha_x, sha_y) = (source_sha256(&x), source_sha256(&y));
299+
assert_ne!(sha_x, sha_y, "the fixture holds two different files");
300+
fs::write(dir.join("a.mid"), &x).unwrap();
301+
fs::write(dir.join("b.mid"), &y).unwrap();
302+
let records = [
303+
("a_pinned", "a.mid", Some(&sha_x)),
304+
("b_legacy", "a.mid", None),
305+
// Loads after `a_pinned`, whose hash it pins: a parse cached under
306+
// that hash must not stand in for a file that does not exist…
307+
("c_ghost", "missing.mid", Some(&sha_x)),
308+
// …or for a file that holds other bytes.
309+
("d_renamed", "b.mid", Some(&sha_x)),
310+
("e_stale", "a.mid", Some(&sha_y)),
311+
];
312+
for (id, filename, sha) in records {
313+
let mut meta = chunk_meta(id, "", (0, 0));
314+
meta.source.filename = filename.to_owned();
315+
meta.source.sha256 = sha.cloned();
316+
fs::write(
317+
dir.join(format!("{id}.chunk.json")),
318+
serde_json::to_string(&meta).unwrap(),
319+
)
320+
.unwrap();
321+
}
322+
323+
let material = load_corpus_material(&dir).expect("corpus loads");
324+
325+
assert_eq!(
326+
material.skipped,
327+
[
328+
"c_ghost.chunk.json",
329+
"d_renamed.chunk.json",
330+
"e_stale.chunk.json"
331+
],
332+
"a record binds only the file it names, holding the bytes it pins"
333+
);
334+
assert_eq!(material.references.len(), 2, "a_pinned and b_legacy load");
335+
336+
fs::remove_dir_all(&dir).ok();
337+
}
250338
}

‎cockpit/Cargo.toml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ crate-type = ["cdylib", "rlib"]
2323
[dependencies]
2424
griff-core = { path = "../core" }
2525
griff-ui-core = { path = "../ui-core" }
26+
# The Generator Observatory (ADR-0034): the cockpit shows experiment bundles
27+
# and, natively, runs the milestone experiment through the shared runner.
28+
griff-experiment = { path = "../experiment" }
2629
# The Swang authoring loop (S8): the cockpit drives the same evaluator the CLI
2730
# does, never a `griff.exe` subprocess (ADR-0029). Pure + wasm-safe.
2831
griff-swang = { path = "../swang" }

0 commit comments

Comments
 (0)