diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml
index 7d2320b5..24958367 100644
--- a/.github/workflows/web.yml
+++ b/.github/workflows/web.yml
@@ -34,6 +34,20 @@ jobs:
~/.cargo/git
web/target
key: web-wasm-${{ hashFiles('web/Cargo.toml', 'core/Cargo.toml') }}
+ # GP support needs wasm-bindgen (ADR-0025); the CLI must match the crate
+ # version pinned in web/Cargo.toml. Cache the built binary across runs.
+ - name: Cache wasm-bindgen-cli
+ uses: actions/cache@v4
+ with:
+ path: ~/.cargo/bin/wasm-bindgen
+ key: wasm-bindgen-cli-${{ hashFiles('web/Cargo.toml') }}
+ - name: Install wasm-bindgen-cli
+ run: |
+ if command -v wasm-bindgen >/dev/null; then
+ echo "cached: $(wasm-bindgen --version)"; exit 0
+ fi
+ ver=$(grep -m1 'wasm-bindgen = ' web/Cargo.toml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')
+ cargo install wasm-bindgen-cli --version "$ver" --locked
- name: Build playground
run: ./web/build.sh
- uses: actions/upload-pages-artifact@v3
diff --git a/cli/src/main.rs b/cli/src/main.rs
index f3fcd85a..d2870afb 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -11,8 +11,9 @@ use griff_core::{
classify::{self, BarClass},
complement,
corpus::{
- ChunkId, ChunkMeta, EnsembleGroup, EnsembleRef, PairRelation, QualityFlag,
- ReviewerDecision, SourceFormat, SourceRef, StyleCohort, SwancoreTag,
+ Acquisition, BoundaryEntry, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef,
+ PairRelation, QualityFlag, ReviewerDecision, RightsInfo, RightsStatus, SourceFormat,
+ SourceRef, StyleCohort, SwancoreTag, SCHEMA_VERSION,
},
event::{NoteMarks, NotePosition, Pitch, TechniqueSource, Ticks},
generate, gesture,
@@ -139,6 +140,18 @@ enum Command {
#[arg(long)]
ensemble: bool,
},
+
+ /// Build a corpus manifest from a directory of curated `*.chunk.json` /
+ /// `*.group.json` records and print a coverage summary (count toward the
+ /// S7 ~100-phrase gate, cohort mix, rights, and review status).
+ Manifest {
+ /// Directory holding the curated chunk and group JSON records.
+ #[arg(value_name = "DIR")]
+ dir: PathBuf,
+ /// Output path for the manifest JSON (default: `
/manifest.json`).
+ #[arg(short, long, value_name = "OUTPUT")]
+ output: Option,
+ },
}
fn run() -> Result<(), CliError> {
@@ -168,6 +181,7 @@ fn run() -> Result<(), CliError> {
output,
ensemble,
} => cmd_curate(&path, output.as_deref(), ensemble),
+ Command::Manifest { dir, output } => cmd_manifest(&dir, output.as_deref()),
}
}
@@ -854,6 +868,26 @@ fn source_format(score: &Score) -> SourceFormat {
}
}
+/// Detects phrase boundaries (S4) for `track_index`, scaling the detector's
+/// tick gaps to the score's PPQN exactly as `griff phrases` does, and maps them
+/// to corpus [`BoundaryEntry`] records.
+fn detect_boundaries(score: &Score, track_index: usize) -> Vec {
+ let ppqn = u32::from(score.ticks_per_quarter);
+ let config = boundary::BoundaryConfig {
+ min_gap: Ticks(ppqn.saturating_mul(2)),
+ quantize_ticks: Ticks(ppqn.checked_div(4).unwrap_or(1).max(1)),
+ ..boundary::BoundaryConfig::default()
+ };
+ boundary::detect_phrase_boundaries(score, track_index, &config)
+ .into_iter()
+ .map(|b| BoundaryEntry {
+ start_tick: b.start_tick.0,
+ end_tick: b.end_tick.0,
+ score: b.score,
+ })
+ .collect()
+}
+
/// Measures `track_index` (when present) and assembles one chunk record.
#[allow(clippy::too_many_arguments)] // a private assembly seam shared by both curate modes
fn build_chunk_meta(
@@ -883,6 +917,7 @@ fn build_chunk_meta(
let structure = track_index.and_then(|idx| structure::measure_structure(score, idx).ok());
let gesture = track_index.and_then(|idx| gesture::measure_gesture(score, idx).ok());
let complexity = track_index.and_then(|idx| structure::measure_complexity(score, idx).ok());
+ let boundaries = track_index.map_or_else(Vec::new, |idx| detect_boundaries(score, idx));
let now = "2026-05-20T00:00:00Z".to_owned();
ChunkMeta {
@@ -898,7 +933,7 @@ fn build_chunk_meta(
time_signature,
tuning: inputs.tuning.clone(),
tags: inputs.tags.clone(),
- boundaries: Vec::new(),
+ boundaries,
techniques: Vec::new(),
quality_flags: inputs.quality_flags.clone(),
reviewer: inputs.reviewer,
@@ -907,6 +942,7 @@ fn build_chunk_meta(
complexity,
style_cohort: Some(inputs.style_cohort),
ensemble,
+ rights: Some(inputs.rights.clone()),
created_at: now.clone(),
updated_at: now,
}
@@ -942,6 +978,86 @@ fn write_output(path: &Path, json: &str) -> Result<(), CliError> {
Ok(())
}
+/// Builds a [`CorpusManifest`] from a directory of curated records, prints a
+/// coverage summary, and writes the manifest. Globs `*.chunk.json` into chunks
+/// and `*.group.json` into groups (both sorted, so the manifest is
+/// deterministic regardless of directory order).
+fn cmd_manifest(dir: &Path, output: Option<&Path>) -> Result<(), CliError> {
+ let mut chunk_paths: Vec = Vec::new();
+ let mut group_paths: Vec = Vec::new();
+ for entry in fs::read_dir(dir)? {
+ let path = entry?.path();
+ let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
+ continue;
+ };
+ if name.ends_with(".group.json") {
+ group_paths.push(path);
+ } else if name.ends_with(".chunk.json") {
+ chunk_paths.push(path);
+ }
+ }
+ chunk_paths.sort();
+ group_paths.sort();
+
+ let mut chunks = Vec::with_capacity(chunk_paths.len());
+ for path in &chunk_paths {
+ let json = fs::read_to_string(path)?;
+ chunks.push(serde_json::from_str::(&json).map_err(CliError::Json)?);
+ }
+ let mut groups = Vec::with_capacity(group_paths.len());
+ for path in &group_paths {
+ let json = fs::read_to_string(path)?;
+ groups.push(serde_json::from_str::(&json).map_err(CliError::Json)?);
+ }
+
+ let manifest = CorpusManifest {
+ schema_version: SCHEMA_VERSION,
+ chunks,
+ groups,
+ };
+ print_manifest_summary(&manifest);
+
+ let out_path = output.map_or_else(|| dir.join("manifest.json"), PathBuf::from);
+ let json = serde_json::to_string_pretty(&manifest).map_err(CliError::Json)?;
+ write_output(&out_path, &json)
+}
+
+/// Prints corpus coverage: progress toward the S7 ~100-phrase gate, cohort mix
+/// (decisions 2026-06-11 targets ~70-80% core), rights coverage, and reviews.
+fn print_manifest_summary(manifest: &CorpusManifest) {
+ let chunks = &manifest.chunks;
+ let n = chunks.len();
+ let core = chunks
+ .iter()
+ .filter(|c| c.style_cohort == Some(StyleCohort::Core))
+ .count();
+ let adjacent = chunks
+ .iter()
+ .filter(|c| c.style_cohort == Some(StyleCohort::Adjacent))
+ .count();
+ let accepted = chunks
+ .iter()
+ .filter(|c| c.reviewer == Some(ReviewerDecision::Accepted))
+ .count();
+ let with_rights = chunks.iter().filter(|c| c.rights.is_some()).count();
+ let redistributable = chunks
+ .iter()
+ .filter(|c| c.rights.as_ref().is_some_and(|r| r.redistributable))
+ .count();
+
+ println!("Corpus manifest (schema v{})", manifest.schema_version);
+ println!("Chunks : {n} (S7 graph layer recommended at ~100)");
+ println!(
+ "Cohort : {core} core / {adjacent} adjacent / {} unlabeled",
+ n.saturating_sub(core).saturating_sub(adjacent)
+ );
+ println!("Review : {accepted}/{n} accepted");
+ println!("Rights : {with_rights}/{n} recorded · {redistributable} redistributable");
+ if !manifest.groups.is_empty() {
+ println!("Groups : {}", manifest.groups.len());
+ }
+}
+
struct CurateInputs {
id: String,
title: String,
@@ -950,6 +1066,7 @@ struct CurateInputs {
tags: Vec,
quality_flags: Vec,
reviewer: Option,
+ rights: RightsInfo,
}
fn gather_curate_inputs(ensemble: bool) -> Result {
@@ -1011,6 +1128,8 @@ fn gather_curate_inputs(ensemble: bool) -> Result {
_ => None,
};
+ let rights = gather_rights(&mut input_buf)?;
+
Ok(CurateInputs {
id,
title,
@@ -1019,6 +1138,52 @@ fn gather_curate_inputs(ensemble: bool) -> Result {
tags,
quality_flags,
reviewer,
+ rights,
+ })
+}
+
+/// Prompts for the schema-v7 rights record (decisions 2026-06-12). Defaults
+/// match the common case — a scraped community tab of a copyrighted modern-metal
+/// composition, not redistributable — so a blank answer is the safe one.
+fn gather_rights(input_buf: &mut String) -> Result {
+ println!(
+ "Rights status: 0=public_domain 1=cc_by 2=cc_by_sa \
+ 3=copyrighted_composition 4=unknown"
+ );
+ let status_input = prompt_line(input_buf, "Rights [3=copyrighted_composition]")?;
+ let rights_status = match status_input.trim() {
+ "0" => RightsStatus::PublicDomain,
+ "1" => RightsStatus::CcBy,
+ "2" => RightsStatus::CcBySa,
+ "4" => RightsStatus::Unknown,
+ _ => RightsStatus::CopyrightedComposition,
+ };
+
+ println!(
+ "Acquisition: 0=community_tab_site 1=purchased_official 2=self_transcribed \
+ 3=omr_from_scan 4=artist_provided"
+ );
+ let acq_input = prompt_line(input_buf, "Acquisition [0=community_tab_site]")?;
+ let acquisition = match acq_input.trim() {
+ "1" => Acquisition::PurchasedOfficial,
+ "2" => Acquisition::SelfTranscribed,
+ "3" => Acquisition::OmrFromScan,
+ "4" => Acquisition::ArtistProvided,
+ _ => Acquisition::CommunityTabSite,
+ };
+
+ let redist_input = prompt_line(input_buf, "Redistributable? 0=no 1=yes [0=no]")?;
+ let redistributable = redist_input.trim() == "1";
+
+ let notes = prompt_line(input_buf, "Rights notes (source URL, date, publisher)")?
+ .trim()
+ .to_owned();
+
+ Ok(RightsInfo {
+ rights_status,
+ acquisition,
+ redistributable,
+ notes,
})
}
diff --git a/cli/tests/curate_cmd.rs b/cli/tests/curate_cmd.rs
index 863e1aa4..fc945779 100644
--- a/cli/tests/curate_cmd.rs
+++ b/cli/tests/curate_cmd.rs
@@ -18,8 +18,13 @@ use std::{
};
use griff_core::{
+ boundary,
complement::measure_pair_axes,
- corpus::{ChunkId, ChunkMeta, EnsembleGroup, EnsembleRef, StyleCohort},
+ corpus::{
+ Acquisition, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef, RightsInfo,
+ RightsStatus, StyleCohort, SCHEMA_VERSION,
+ },
+ event::Ticks,
gesture, midi,
score::AtomEvent,
structure,
@@ -382,3 +387,187 @@ fn curate_records_complexity_of_the_first_note_bearing_track() {
"curate persists the measured complexity profile"
);
}
+
+/// Schema v7: the rights prompt (after the reviewer decision) is recorded —
+/// status, acquisition, redistributable flag, and free-form notes.
+#[test]
+fn curate_records_rights() {
+ let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/simple_4_4.mid");
+ let out_path =
+ std::env::temp_dir().join(format!("griff_curate_v7_{}.chunk.json", std::process::id()));
+
+ let mut child = Command::new(griff_bin())
+ .arg("curate")
+ .arg(&fixture)
+ .arg("--output")
+ .arg(&out_path)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn griff curate");
+ child
+ .stdin
+ .as_mut()
+ .expect("piped stdin")
+ // id, title, tuning, cohort, tags, flags, decision (all blank/default),
+ // then rights: status 0=public_domain, acquisition 2=self_transcribed,
+ // redistributable 1=yes, notes.
+ .write_all(b"rt_001\nRights Chunk\n\n\n\n\n\n0\n2\n1\npdmx.example/score, 2026-06-16\n")
+ .expect("write curate answers");
+ let out = child.wait_with_output().expect("wait for curate");
+ assert!(
+ out.status.success(),
+ "curate must exit 0: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+
+ let json = std::fs::read_to_string(&out_path).expect("curate wrote the record");
+ let _cleanup = std::fs::remove_file(&out_path);
+ let meta: ChunkMeta = serde_json::from_str(&json).expect("record parses as ChunkMeta");
+
+ assert_eq!(
+ meta.rights,
+ Some(RightsInfo {
+ rights_status: RightsStatus::PublicDomain,
+ acquisition: Acquisition::SelfTranscribed,
+ redistributable: true,
+ notes: "pdmx.example/score, 2026-06-16".to_owned(),
+ })
+ );
+}
+
+/// S4 wiring: curate persists the measured track's detected phrase boundaries
+/// (no longer hardcoded empty), matching what the detector reports.
+#[test]
+fn curate_records_phrase_boundaries() {
+ // two_phrases has detectable boundaries (unlike the single-phrase fixtures).
+ let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/two_phrases.mid");
+ let out_path = std::env::temp_dir().join(format!(
+ "griff_curate_bnd_{}.chunk.json",
+ std::process::id()
+ ));
+
+ let mut child = Command::new(griff_bin())
+ .arg("curate")
+ .arg(&fixture)
+ .arg("--output")
+ .arg(&out_path)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn griff curate");
+ child
+ .stdin
+ .as_mut()
+ .expect("piped stdin")
+ .write_all(b"bnd_001\nBoundaries\n\n\n\n\n\n")
+ .expect("write curate answers");
+ let out = child.wait_with_output().expect("wait for curate");
+ assert!(
+ out.status.success(),
+ "curate must exit 0: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+
+ let json = std::fs::read_to_string(&out_path).expect("curate wrote the record");
+ let _cleanup = std::fs::remove_file(&out_path);
+ let meta: ChunkMeta = serde_json::from_str(&json).expect("record parses as ChunkMeta");
+
+ // Recompute with the same PPQN-scaled config the curate path applies.
+ let bytes = std::fs::read(&fixture).expect("fixture bytes");
+ let score = midi::import_score(&bytes).expect("fixture imports");
+ let track = score
+ .tracks
+ .iter()
+ .position(|t| {
+ t.voices
+ .iter()
+ .flat_map(|v| &v.event_groups)
+ .flat_map(|g| &g.atoms)
+ .any(|a| matches!(a, AtomEvent::Note(_)))
+ })
+ .expect("fixture has a note-bearing track");
+ let ppqn = u32::from(score.ticks_per_quarter);
+ let config = boundary::BoundaryConfig {
+ min_gap: Ticks(ppqn.saturating_mul(2)),
+ quantize_ticks: Ticks(ppqn.checked_div(4).unwrap_or(1).max(1)),
+ ..Default::default()
+ };
+ let expected: Vec<(u32, u32)> = boundary::detect_phrase_boundaries(&score, track, &config)
+ .into_iter()
+ .map(|b| (b.start_tick.0, b.end_tick.0))
+ .collect();
+ let got: Vec<(u32, u32)> = meta
+ .boundaries
+ .iter()
+ .map(|b| (b.start_tick, b.end_tick))
+ .collect();
+
+ assert!(
+ !got.is_empty(),
+ "two_phrases fixture has detectable boundaries"
+ );
+ assert_eq!(got, expected, "curate persists the detector's boundaries");
+}
+
+/// `griff manifest` assembles a `CorpusManifest` from a directory of curated
+/// chunk records at the current schema version.
+#[test]
+fn manifest_builds_from_curated_chunks() {
+ let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/two_phrases.mid");
+ let dir = std::env::temp_dir().join(format!("griff_manifest_{}", std::process::id()));
+ std::fs::create_dir_all(&dir).expect("create corpus dir");
+
+ for id in ["m_001", "m_002"] {
+ let mut child = Command::new(griff_bin())
+ .arg("curate")
+ .arg(&fixture)
+ .arg("--output")
+ .arg(dir.join(format!("{id}.chunk.json")))
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .spawn()
+ .expect("spawn curate");
+ child
+ .stdin
+ .as_mut()
+ .expect("piped stdin")
+ .write_all(format!("{id}\nChunk {id}\n\n\n\n\n\n").as_bytes())
+ .expect("write curate answers");
+ assert!(
+ child
+ .wait_with_output()
+ .expect("wait for curate")
+ .status
+ .success(),
+ "curate must exit 0"
+ );
+ }
+
+ let out = Command::new(griff_bin())
+ .arg("manifest")
+ .arg(&dir)
+ .output()
+ .expect("run griff manifest");
+ assert!(
+ out.status.success(),
+ "manifest must exit 0: {}",
+ String::from_utf8_lossy(&out.stderr)
+ );
+
+ let manifest_json =
+ std::fs::read_to_string(dir.join("manifest.json")).expect("manifest was written");
+ let _cleanup = std::fs::remove_dir_all(&dir);
+ let manifest: CorpusManifest =
+ serde_json::from_str(&manifest_json).expect("manifest parses as CorpusManifest");
+
+ assert_eq!(manifest.schema_version, SCHEMA_VERSION);
+ assert_eq!(
+ manifest.chunks.len(),
+ 2,
+ "both curated chunks land in the manifest"
+ );
+}
diff --git a/core/src/corpus.rs b/core/src/corpus.rs
index 2535cbd1..2a463e48 100644
--- a/core/src/corpus.rs
+++ b/core/src/corpus.rs
@@ -31,7 +31,12 @@ use crate::structure::{ComplexityProfile, StructureMetrics};
/// - v6 — the per-axis complexity profile (S14): `ChunkMeta` gains optional
/// measured [`ComplexityProfile`] under the same pattern; pre-v6 records
/// load it as `None` and re-serialize losslessly.
-pub const SCHEMA_VERSION: u32 = 6;
+/// - v7 — per-chunk rights + provenance (decisions 2026-06-12): `ChunkMeta`
+/// gains optional [`RightsInfo`] under the same pattern; pre-v7 records (no
+/// `rights` key) keep loading and re-serialize losslessly. Rights status is
+/// not derivable from content, so it is captured at curation time and cannot
+/// be backfilled.
+pub const SCHEMA_VERSION: u32 = 7;
// ── identifiers ───────────────────────────────────────────────────────────────
@@ -243,6 +248,62 @@ pub struct EnsembleGroup {
pub relations: Vec,
}
+// ── rights and provenance (schema v7) ─────────────────────────────────────────
+
+/// Rights status of a chunk's underlying material (decisions 2026-06-12).
+///
+/// Not an OSS licence — this records the *composition* rights status, paired
+/// with [`Acquisition`] provenance. Most scraped, purchased, or self-transcribed
+/// modern-metal tabs are `CopyrightedComposition`; only public-domain sources
+/// (e.g. PDMX `MusicXML`) are freely redistributable.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum RightsStatus {
+ /// Public-domain composition (e.g. PDMX `MusicXML`).
+ PublicDomain,
+ /// Creative Commons Attribution.
+ CcBy,
+ /// Creative Commons Attribution-ShareAlike.
+ CcBySa,
+ /// Composition under copyright (the common case for modern-metal tabs).
+ CopyrightedComposition,
+ /// Rights status not yet determined.
+ Unknown,
+}
+
+/// How a chunk's source file was acquired (decisions 2026-06-12).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Acquisition {
+ /// Scraped from a community tab site (Ultimate Guitar, Songsterr, …).
+ CommunityTabSite,
+ /// Purchased from an official publisher (e.g. Sheet Happens GP).
+ PurchasedOfficial,
+ /// Transcribed by the curator (own transcription does not transfer the
+ /// underlying composition rights).
+ SelfTranscribed,
+ /// Optical music recognition from a scan.
+ OmrFromScan,
+ /// Provided directly by the artist.
+ ArtistProvided,
+}
+
+/// Per-chunk rights and provenance (schema v7, decisions 2026-06-12).
+///
+/// `redistributable` is a typed fact — not a freeform note — because
+/// `novelty.rs` and any future export gate must filter on it without scanning
+/// prose. Captured at curation time: rights status cannot be derived from the
+/// notes, so backfilling would mean re-researching provenance per source.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct RightsInfo {
+ pub rights_status: RightsStatus,
+ pub acquisition: Acquisition,
+ /// Whether the chunk's source material may be redistributed.
+ pub redistributable: bool,
+ /// Free-form provenance note (source URL, acquisition date, publisher).
+ pub notes: String,
+}
+
// ── chunk metadata ────────────────────────────────────────────────────────────
/// Full annotation for one corpus chunk.
@@ -292,6 +353,11 @@ pub struct ChunkMeta {
/// the key is skipped when unset.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ensemble: Option,
+ /// Rights and provenance (schema v7, decisions 2026-06-12). Absent in pre-v7
+ /// records — the key is skipped when unset, so older files round-trip
+ /// byte-identically. Captured at curation time; cannot be backfilled.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub rights: Option,
/// ISO 8601 creation timestamp.
pub created_at: String,
/// ISO 8601 last-modified timestamp.
diff --git a/core/tests/corpus_schema.rs b/core/tests/corpus_schema.rs
index 20692720..5cf6def3 100644
--- a/core/tests/corpus_schema.rs
+++ b/core/tests/corpus_schema.rs
@@ -9,9 +9,9 @@
use griff_core::complement::AxisScores;
use griff_core::corpus::{
- BoundaryEntry, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef, PairRelation,
- QualityFlag, ReviewerDecision, SourceFormat, SourceRef, StyleCohort, SwancoreTag,
- SCHEMA_VERSION,
+ Acquisition, BoundaryEntry, ChunkId, ChunkMeta, CorpusManifest, EnsembleGroup, EnsembleRef,
+ PairRelation, QualityFlag, ReviewerDecision, RightsInfo, RightsStatus, SourceFormat, SourceRef,
+ StyleCohort, SwancoreTag, SCHEMA_VERSION,
};
use griff_core::gesture::GestureStats;
use griff_core::structure::{ComplexityProfile, StructureMetrics};
@@ -87,21 +87,100 @@ fn minimal_chunk() -> ChunkMeta {
complexity: None,
style_cohort: None,
ensemble: None,
+ rights: None,
created_at: "2026-05-20T00:00:00Z".to_owned(),
updated_at: "2026-05-20T00:00:00Z".to_owned(),
}
}
-// ── schema v6: the per-axis complexity profile (S14) ──────────────────────────
+// ── schema v7: rights + provenance (decisions 2026-06-12) ─────────────────────
#[test]
-fn schema_version_is_6() {
+fn schema_version_is_7() {
assert_eq!(
- SCHEMA_VERSION, 6,
- "the complexity profile bumps the corpus schema"
+ SCHEMA_VERSION, 7,
+ "the rights record bumps the corpus schema"
+ );
+}
+
+/// A representative rights record (the common scraped-community-tab case).
+fn sample_rights() -> RightsInfo {
+ RightsInfo {
+ rights_status: RightsStatus::CopyrightedComposition,
+ acquisition: Acquisition::CommunityTabSite,
+ redistributable: false,
+ notes: "ultimate-guitar.com, 2026-06-12".to_owned(),
+ }
+}
+
+#[test]
+fn chunk_meta_with_rights_roundtrips() {
+ let mut meta = minimal_chunk();
+ meta.rights = Some(sample_rights());
+
+ let json = serde_json::to_string(&meta).expect("serialize");
+ assert!(
+ json.contains("\"rights\""),
+ "v7 records carry the rights key"
+ );
+ assert!(
+ json.contains("\"copyrighted_composition\"") && json.contains("\"community_tab_site\""),
+ "rights enums serialize snake_case: {json}"
+ );
+ let back: ChunkMeta = serde_json::from_str(&json).expect("deserialize");
+ assert_eq!(back.rights, Some(sample_rights()));
+ let json2 = serde_json::to_string(&back).expect("re-serialize");
+ assert_eq!(json, json2, "JSON round-trip must be byte-identical");
+}
+
+#[test]
+fn pre_v7_record_without_rights_loads_as_none() {
+ // A pre-v7 record has no rights key; it parses as None and re-serializes
+ // without inventing the key.
+ let old_json = serde_json::to_string(&minimal_chunk()).expect("serialize");
+ assert!(
+ !old_json.contains("\"rights\""),
+ "an absent rights record must not introduce a key: {old_json}"
);
+
+ let back: ChunkMeta = serde_json::from_str(&old_json).expect("pre-v7 record must parse");
+ assert!(back.rights.is_none());
+ let json2 = serde_json::to_string(&back).expect("re-serialize");
+ assert_eq!(old_json, json2, "JSON round-trip must be byte-identical");
+}
+
+#[test]
+fn all_rights_statuses_roundtrip() {
+ for status in [
+ RightsStatus::PublicDomain,
+ RightsStatus::CcBy,
+ RightsStatus::CcBySa,
+ RightsStatus::CopyrightedComposition,
+ RightsStatus::Unknown,
+ ] {
+ let json = serde_json::to_string(&status).expect("serialize");
+ let back: RightsStatus = serde_json::from_str(&json).expect("deserialize");
+ assert_eq!(status, back);
+ }
}
+#[test]
+fn all_acquisitions_roundtrip() {
+ for acq in [
+ Acquisition::CommunityTabSite,
+ Acquisition::PurchasedOfficial,
+ Acquisition::SelfTranscribed,
+ Acquisition::OmrFromScan,
+ Acquisition::ArtistProvided,
+ ] {
+ let json = serde_json::to_string(&acq).expect("serialize");
+ let back: Acquisition = serde_json::from_str(&json).expect("deserialize");
+ assert_eq!(acq, back);
+ }
+}
+
+// ── schema v6: the per-axis complexity profile (S14) ──────────────────────────
+
#[test]
fn chunk_meta_with_complexity_roundtrips() {
let mut meta = minimal_chunk();
@@ -620,6 +699,39 @@ fn arb_ensemble() -> impl Strategy> {
prop_opt(link)
}
+fn arb_rights() -> impl Strategy> {
+ let status = prop_oneof![
+ Just(RightsStatus::PublicDomain),
+ Just(RightsStatus::CcBy),
+ Just(RightsStatus::CcBySa),
+ Just(RightsStatus::CopyrightedComposition),
+ Just(RightsStatus::Unknown),
+ ];
+ let acquisition = prop_oneof![
+ Just(Acquisition::CommunityTabSite),
+ Just(Acquisition::PurchasedOfficial),
+ Just(Acquisition::SelfTranscribed),
+ Just(Acquisition::OmrFromScan),
+ Just(Acquisition::ArtistProvided),
+ ];
+ // ASCII notes (no quotes/backslashes) keep the JSON round-trip byte-identical.
+ let info = (
+ status,
+ acquisition,
+ any::(),
+ "[A-Za-z0-9 :/._-]{0,40}",
+ )
+ .prop_map(
+ |(rights_status, acquisition, redistributable, notes)| RightsInfo {
+ rights_status,
+ acquisition,
+ redistributable,
+ notes,
+ },
+ );
+ prop_opt(info)
+}
+
proptest! {
#[test]
fn prop_chunk_meta_json_roundtrip(
@@ -640,6 +752,7 @@ proptest! {
complexity in arb_complexity(),
style_cohort in arb_cohort(),
ensemble in arb_ensemble(),
+ rights in arb_rights(),
) {
let meta = ChunkMeta {
id: ChunkId(id),
@@ -663,6 +776,7 @@ proptest! {
complexity,
style_cohort,
ensemble,
+ rights,
created_at: "2026-05-20T00:00:00Z".to_owned(),
updated_at: "2026-05-20T00:00:00Z".to_owned(),
};
diff --git a/core/tests/similarity.rs b/core/tests/similarity.rs
index b1699a27..8c600d45 100644
--- a/core/tests/similarity.rs
+++ b/core/tests/similarity.rs
@@ -146,6 +146,7 @@ fn chunk(
complexity,
style_cohort: None,
ensemble: None,
+ rights: None,
created_at: "2026-06-10T00:00:00Z".to_owned(),
updated_at: "2026-06-10T00:00:00Z".to_owned(),
}
diff --git a/docs/adr/0025-guitar-pro-in-browser-needs-wasm-bindgen.md b/docs/adr/0025-guitar-pro-in-browser-needs-wasm-bindgen.md
new file mode 100644
index 00000000..58a2cc2e
--- /dev/null
+++ b/docs/adr/0025-guitar-pro-in-browser-needs-wasm-bindgen.md
@@ -0,0 +1,73 @@
+# ADR 0025: Guitar Pro in the browser needs wasm-bindgen (supersede ADR-0024's import-free web build)
+
+Date: 2026-06-17
+Status: Accepted
+
+Supersedes ADR-0024 §2–§3 and §6 (the import-free `cdylib`, the `gp`-off wasm
+build, and the no-`wasm-bindgen` toolchain). ADR-0024's other decisions — egui as
+the M2 canonical web frontend, WebAudio, determinism — stand.
+
+## Context
+
+ADR-0024 shipped the M1 web playground as an *import-free* `cdylib`: `griff-core`
+built with `default-features = false` (GP off), exporting C-ABI functions, loaded
+with `WebAssembly.instantiate(bytes, {})` — no `wasm-bindgen`, ~90 KiB. That kept
+the build trivial but made the browser **MIDI-only**.
+
+The corpus is swancore-first (ADR-0005), and swancore tabs are overwhelmingly
+**Guitar Pro**, not MIDI. Phone-side curation — the reason the web front exists —
+is dead without GP loading: the maintainer works from a phone and cannot feed the
+corpus real material there. Loading GP in the browser is the unblocker.
+
+The Rust GP reader is not import-free-compatible. `guitarpro` → `zip` (a
+non-optional dependency; `.gpx` is a zip container) → `time` → `js-sys` →
+`wasm-bindgen`, and `zip` → `getrandom`, whose wasm support also routes through
+`wasm-bindgen`. A `getrandom` *custom* backend (to dodge that) fails to compile on
+`wasm32-unknown-unknown` in getrandom 0.4.2 (a `WEB_CRYPTO` bug), and `time` pulls
+`wasm-bindgen` independently regardless. There is no lean shortcut: GP through the
+shared Rust parser requires the `wasm-bindgen` toolchain.
+
+The alternative — parse GP in JavaScript (e.g. alphaTab) and feed notes to the
+wasm — was rejected: it forks parsing out of `griff-core`, so the browser and the
+CLI would disagree on coverage and bugs, and it adds a heavy JS dependency.
+
+## Decision
+
+1. **The web build uses `wasm-bindgen`** (`--target web`), not the import-free
+ `cdylib`. `griff-web` exports two `#[wasm_bindgen]` functions returning JSON
+ strings (`arrange`, `load_score(bytes)`); the manual linear-memory marshalling
+ is gone. The page loads the generated ES module (`
+