@@ -14,7 +14,7 @@ use std::collections::HashMap;
1414use std:: fs;
1515use std:: path:: Path ;
1616
17- use griff_core:: corpus:: { source_sha256, ChunkMeta } ;
17+ use griff_core:: corpus:: { bind_source , source_sha256, ChunkMeta } ;
1818use griff_core:: generation_input:: { corpus_material, prepare_chunk, LoadedChunk } ;
1919use griff_core:: import;
2020use 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.
7594fn 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) ]
109130mod 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}
0 commit comments