Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/web.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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
Expand Down
171 changes: 168 additions & 3 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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: `<dir>/manifest.json`).
#[arg(short, long, value_name = "OUTPUT")]
output: Option<PathBuf>,
},
}

fn run() -> Result<(), CliError> {
Expand Down Expand Up @@ -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()),
}
}

Expand Down Expand Up @@ -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<BoundaryEntry> {
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(
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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,
}
Expand Down Expand Up @@ -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<PathBuf> = Vec::new();
let mut group_paths: Vec<PathBuf> = 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::<ChunkMeta>(&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::<EnsembleGroup>(&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,
Expand All @@ -950,6 +1066,7 @@ struct CurateInputs {
tags: Vec<SwancoreTag>,
quality_flags: Vec<QualityFlag>,
reviewer: Option<ReviewerDecision>,
rights: RightsInfo,
}

fn gather_curate_inputs(ensemble: bool) -> Result<CurateInputs, CliError> {
Expand Down Expand Up @@ -1011,6 +1128,8 @@ fn gather_curate_inputs(ensemble: bool) -> Result<CurateInputs, CliError> {
_ => None,
};

let rights = gather_rights(&mut input_buf)?;

Ok(CurateInputs {
id,
title,
Expand All @@ -1019,6 +1138,52 @@ fn gather_curate_inputs(ensemble: bool) -> Result<CurateInputs, CliError> {
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<RightsInfo, CliError> {
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,
})
}

Expand Down
Loading