diff --git a/Cargo.lock b/Cargo.lock index dbc9f64e..14af806c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1839,13 +1839,13 @@ dependencies = [ [[package]] name = "guitarpro" -version = "0.3.0" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae0433af5ed536eca518e8d6dddcc6a8e58d58e0124988ec0e4b6f3f793bb14" +checksum = "f54b47a04cb02940455044f86a66f8cb69d3478734b69762402d921f89b0eb76" dependencies = [ "encoding_rs", "fraction", - "quick-xml", + "quick-xml 0.41.0", "serde", "thiserror 2.0.18", "zip", @@ -3279,6 +3279,16 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quote" version = "1.0.45" @@ -4492,7 +4502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.39.4", "quote", ] @@ -5298,7 +5308,7 @@ version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" dependencies = [ - "quick-xml", + "quick-xml 0.39.4", "serde", "zbus_names", "zvariant", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 72dc738d..f2a5d4da 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -10,6 +10,10 @@ readme.workspace = true keywords.workspace = true categories.workspace = true +[lib] +name = "griff_cli" +path = "src/lib.rs" + [[bin]] name = "griff" path = "src/main.rs" diff --git a/cli/src/generation_input.rs b/cli/src/generation_input.rs new file mode 100644 index 00000000..46caca04 --- /dev/null +++ b/cli/src/generation_input.rs @@ -0,0 +1,331 @@ +//! Reusable generation-input seam (arbiter 2026-07-12). +//! +//! One implementation of the corpus→generation compiler, shared by the +//! `griff generate` command and any experimental A/B harness (an +//! `examples/` binary), so a scan can never drift from production — same +//! placed-`(offset, duration)` rhythm extraction, same median gesture +//! aggregation, same resting-chunk filter. +//! +//! **Experimental, `#[doc(hidden)]`**: this is a stability-exempt seam for +//! tooling, not a public library surface. File I/O stays here (not in +//! `griff-core`); the pure musical transforms live in `griff-core`. + +use std::fs; +use std::path::Path; + +use griff_core::corpus::ChunkMeta; +use griff_core::event::{Pitch, Ticks}; +use griff_core::generate; +use griff_core::gesture::GestureControl; +use griff_core::import; +use griff_core::score::{AtomEvent, Score}; +use griff_core::slice; + +use crate::primary_voice_note_count; + +/// Why building a generation input failed. +#[derive(Debug)] +pub enum GenerationInputError { + /// The source could not seed a request (silent source, zero bars, …). + Generation(generate::GenerationError), + /// A corpus directory could not be read. + Corpus(String), +} + +impl From for GenerationInputError { + fn from(e: generate::GenerationError) -> Self { + Self::Generation(e) + } +} + +/// What a corpus directory supplies to a generation pass. +#[derive(Debug)] +pub struct CorpusMaterial { + /// Per-bar rhythm templates from the chunks' sliced sources, deduped in + /// first-seen order. + pub rhythms: Vec, + /// The sliced chunk scores — the novelty guard's reference set. + pub references: Vec, + /// Aggregated burst/rest gesture ask, when any chunk carries stats. + pub gesture: Option, + /// Record names skipped because their source was missing/unreadable or + /// carried no notes; reported to the curator, never silently dropped. + pub skipped: Vec, +} + +/// Builds a tab-seeded [`generate::RuleGenerationRequest`]: the scale is the +/// source's distinct pitch classes, the rhythm template its first sounding bar, +/// and meter / tempo / range its transport. +/// +/// # Errors +/// [`GenerationInputError::Generation`] when `bars` is zero, the source is +/// silent (no pitch material), or it carries no master bars. +pub fn generation_request_from_score( + score: &Score, + seed: u64, + bars: usize, +) -> Result { + if bars == 0 { + return Err(generate::GenerationError::BarCountZero.into()); + } + let pitches = all_pitches(score); + let (lo, hi) = pitch_range(&pitches)?; + let first_bar = score + .master_bars + .first() + .ok_or(generate::GenerationError::InvalidConstraints)?; + let constraints = generate::GenerationConstraints { + bar_count: bars, + time_signature: first_bar.time_signature, + tempo: first_bar.tempo, + ticks_per_quarter: Ticks(u32::from(score.ticks_per_quarter)), + pitch_lo: lo, + pitch_hi: hi, + }; + Ok(generate::RuleGenerationRequest { + seed: generate::GenerationSeed(seed), + pitch_material: pitch_material_from(lo, &pitches), + constraints, + source_rhythms: vec![generate::RhythmTemplate::from_durations(&first_bar_rhythm( + score, + ))], + strategy: generate::GenerationStrategy::RhythmCopyPitchSubstitute, + }) +} + +/// Every note pitch across all tracks and voices, in track/voice order. +fn all_pitches(score: &Score) -> Vec { + score + .tracks + .iter() + .flat_map(|t| &t.voices) + .flat_map(|v| &v.event_groups) + .flat_map(|g| &g.atoms) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some(n.pitch.0), + AtomEvent::Rest(_) => None, + }) + .collect() +} + +/// The lowest and highest pitch present; errors (no pitch material) when the +/// source is silent. +fn pitch_range(pitches: &[u8]) -> Result<(Pitch, Pitch), GenerationInputError> { + let lo = pitches + .iter() + .min() + .copied() + .ok_or(generate::GenerationError::EmptyPitchMaterial)?; + let hi = pitches.iter().max().copied().unwrap_or(lo); + Ok((Pitch(lo), Pitch(hi))) +} + +/// A scale rooted at `lo` whose intervals are the distinct semitone classes the +/// source uses, so the generated riff stays in the tab's pitch palette. +fn pitch_material_from(lo: Pitch, pitches: &[u8]) -> generate::PitchMaterial { + let mut intervals: Vec = pitches + .iter() + .map(|&p| p.saturating_sub(lo.0).checked_rem(12).unwrap_or(0)) + .collect(); + intervals.sort_unstable(); + intervals.dedup(); + if intervals.is_empty() { + intervals.push(0); + } + generate::PitchMaterial { + root: lo, + intervals, + } +} + +/// The note durations of the first *sounding* bar — the earliest master bar +/// holding any note across all tracks and voices — in onset order, as the +/// rhythm template the generator copies. Falls back to four quarter notes only +/// when the source is entirely silent. +fn first_bar_rhythm(score: &Score) -> Vec { + for bar in &score.master_bars { + let mut notes: Vec<(u32, Ticks)> = score + .tracks + .iter() + .flat_map(|t| &t.voices) + .flat_map(|v| &v.event_groups) + .flat_map(|g| &g.atoms) + .filter_map(|a| match a { + AtomEvent::Note(n) + if n.absolute_start.0 >= bar.tick_range.start.0 + && n.absolute_start.0 < bar.tick_range.end.0 => + { + Some((n.absolute_start.0, n.duration)) + } + _ => None, + }) + .collect(); + if !notes.is_empty() { + notes.sort_by_key(|&(onset, _)| onset); + return notes.into_iter().map(|(_, dur)| dur).collect(); + } + } + let quarter = Ticks(u32::from(score.ticks_per_quarter)); + vec![quarter; 4] +} + +/// Loads every `*.chunk.json` record in `dir` with its source tab. +/// +/// Records are sorted by name (deterministic result); each source tab is +/// expected next to it under `source.filename`, sliced to the record's +/// `bar_range` provenance when one is present. Group records +/// (`*.group.json`) are curation metadata, not chunks, and are ignored. +/// +/// # Errors +/// [`GenerationInputError::Corpus`] when `dir` cannot be read. +pub fn load_corpus_material(dir: &Path) -> Result { + let entries = fs::read_dir(dir).map_err(|e| { + GenerationInputError::Corpus(format!("cannot read corpus dir {}: {e}", dir.display())) + })?; + let mut record_names: Vec = entries + .filter_map(Result::ok) + .filter_map(|e| e.file_name().to_str().map(ToOwned::to_owned)) + .filter(|n| n.ends_with(".chunk.json")) + .collect(); + record_names.sort_unstable(); + + let mut rhythms: Vec = Vec::new(); + let mut references = Vec::new(); + let mut loaded_chunks = Vec::new(); + let mut skipped = Vec::new(); + + for name in record_names { + let Some(chunk) = load_chunk_source(dir, &name) else { + skipped.push(name); + continue; + }; + let (meta, sliced, track) = chunk; + for template in bar_rhythms(&sliced, track) { + if !rhythms.contains(&template) { + rhythms.push(template); + } + } + references.push(sliced); + loaded_chunks.push(meta); + } + + Ok(CorpusMaterial { + rhythms, + references, + gesture: gesture_control_from_chunks(&loaded_chunks), + skipped, + }) +} + +/// Reads one chunk record and its source tab, slicing the record's +/// `bar_range`. `None` when the record does not parse, the source is +/// missing/unimportable, or the slice carries no notes — the caller reports +/// the record as skipped. +fn load_chunk_source(dir: &Path, record_name: &str) -> Option<(ChunkMeta, Score, usize)> { + let meta: ChunkMeta = + serde_json::from_str(&fs::read_to_string(dir.join(record_name)).ok()?).ok()?; + let source = + import::import_score_auto(&fs::read(dir.join(&meta.source.filename)).ok()?).ok()?; + let sliced = match meta.source.bar_range { + Some((first, last)) => { + let first = usize::try_from(first).ok()?; + let last = usize::try_from(last).ok()?; + slice::extract_bars(&source, first..last.checked_add(1)?) + } + None => source, + }; + let track = sliced + .tracks + .iter() + .position(|t| primary_voice_note_count(t) > 0)?; + Some((meta, sliced, track)) +} + +/// One placement template per *sounding* bar of the track's primary voice. +/// +/// Each note keeps its in-bar offset, so rests and syncopation survive into +/// the grid. Silent bars are phrase rests, not templates; identical templates +/// are deduped so a looped riff does not drown the set's template rotation in +/// copies of one rhythm. +pub fn bar_rhythms(score: &Score, track: usize) -> Vec { + let Some(voice) = score.tracks.get(track).and_then(|t| t.voices.first()) else { + return Vec::new(); + }; + let mut notes: Vec<(u32, Ticks)> = voice + .event_groups + .iter() + .flat_map(|g| &g.atoms) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some((n.absolute_start.0, n.duration)), + AtomEvent::Rest(_) => None, + }) + .collect(); + notes.sort_unstable_by_key(|&(onset, _)| onset); + + let mut templates = Vec::new(); + for bar in &score.master_bars { + let placed: Vec = notes + .iter() + .filter(|(onset, _)| *onset >= bar.tick_range.start.0 && *onset < bar.tick_range.end.0) + .map(|&(onset, duration)| generate::TemplateNote { + offset: Ticks(onset.saturating_sub(bar.tick_range.start.0)), + duration, + }) + .collect(); + if placed.is_empty() { + continue; + } + let template = generate::RhythmTemplate { notes: placed }; + if !templates.contains(&template) { + templates.push(template); + } + } + templates +} + +/// Aggregates the chunks' gesture statistics into one ask. +/// +/// The per-axis *median* of the per-chunk [`GestureControl`]s (each already +/// clamped by [`GestureControl::from_stats`]), rounded back to whole burst +/// notes. +/// +/// Only chunks that actually rest vote: a wall-to-wall riff's stats describe +/// one giant burst (mean burst = the whole chunk) and would inflate the ask +/// past ever carving (2026-07-11 playtest: burst 69 over a 32-note request +/// carved nothing). The median keeps one long-burst outlier from dragging +/// the ask out of carving range. `None` when no resting chunk carries stats — +/// the caller then generates wall-to-wall, it does not invent a gesture. +#[must_use] +pub fn gesture_control_from_chunks(chunks: &[ChunkMeta]) -> Option { + let controls: Vec = chunks + .iter() + .filter_map(|c| c.gesture.as_ref()) + .filter(|s| s.rest_count > 0 && s.mean_rest_quarters > 0.0) + .map(GestureControl::from_stats) + .collect(); + if controls.is_empty() { + return None; + } + #[allow(clippy::cast_precision_loss)] // burst lengths are tiny + let burst = median(controls.iter().map(|c| c.burst_notes as f64).collect()); + let rest = median(controls.iter().map(|c| c.rest_quarters).collect()); + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // rounded, clamped ≥ 1 + Some(GestureControl { + burst_notes: burst.round().max(1.0) as usize, + rest_quarters: rest.max(1.0), + }) +} + +/// The median of `values` (mean of the two middles for an even count). +/// Deterministic: ties order by `total_cmp`. Caller guarantees non-empty. +fn median(mut values: Vec) -> f64 { + values.sort_by(f64::total_cmp); + let mid = values.len() / 2; + if values.len() % 2 == 1 { + values.get(mid).copied().unwrap_or(0.0) + } else { + let hi = values.get(mid).copied().unwrap_or(0.0); + let lo = values.get(mid.saturating_sub(1)).copied().unwrap_or(0.0); + (lo + hi) / 2.0 + } +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs new file mode 100644 index 00000000..2789cefd --- /dev/null +++ b/cli/src/lib.rs @@ -0,0 +1,26 @@ +//! `griff-cli` internal library — the reusable seam shared by the `griff` +//! binary and experimental A/B harnesses (see [`generation_input`]). +//! +//! This is **not** a stable public API: it exists so tooling reuses the exact +//! production corpus→generation compiler instead of reimplementing (and +//! drifting from) it. Everything here is `#[doc(hidden)]` and stability-exempt. + +#![doc(hidden)] + +use griff_core::score::{AtomEvent, Track}; + +pub mod generation_input; + +/// Notes in a track's *primary* (first) voice — the track-selection predicate +/// shared by curation, splitting, and corpus loading, so selection and +/// measurement agree on which track sounds. +#[must_use] +pub fn primary_voice_note_count(track: &Track) -> usize { + track.voices.first().map_or(0, |v| { + v.event_groups + .iter() + .flat_map(|g| &g.atoms) + .filter(|a| matches!(a, AtomEvent::Note(_))) + .count() + }) +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 93a0262f..768a5518 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -7,6 +7,10 @@ use std::{ }; use clap::{Parser, Subcommand}; +use griff_cli::generation_input::{ + generation_request_from_score, load_corpus_material, CorpusMaterial, GenerationInputError, +}; +use griff_cli::primary_voice_note_count; use griff_core::{ boundary, classify::{self, BarClass}, @@ -16,12 +20,13 @@ use griff_core::{ PairRelation, QualityFlag, ReviewerDecision, RightsInfo, RightsStatus, SourceFormat, SourceRef, StyleCohort, SwancoreTag, SCHEMA_VERSION, }, - event::{NoteMarks, NotePosition, Pitch, TechniqueSource, Ticks}, + event::{NoteMarks, NotePosition, TechniqueSource, Ticks}, generate, gesture, harmony, import::{self, ImportError}, midi::{self, MidiError}, - novelty, + novelty, rerank, score::{AtomEvent, Score, Track, Voice}, + scoring, slice::{self, TickRange}, split, structure, syncopation, technique, unfold, }; @@ -86,8 +91,12 @@ enum Command { path: PathBuf, }, - /// Generate a fresh riff (S6) seeded from a tab's scale, rhythm, meter, and - /// pitch range, and write it to a MIDI file. + /// Generate a fresh riff (S6) seeded from a tab's scale, meter, and pitch + /// range: every strategy contributes seed variants to a candidate set, + /// the set is reranked on the closure + novelty axes (ADR-0017), and the + /// winner is written to a MIDI file. With `--corpus`, rhythm templates, + /// novelty references, and the gesture ask come from curated chunks + /// instead of the input's first bar. Generate { /// Source MIDI or Guitar Pro file whose material seeds the generator. #[arg(value_name = "INPUT")] @@ -101,6 +110,20 @@ enum Command { /// Number of bars to generate. #[arg(long, default_value_t = 8)] bars: usize, + /// Directory of curated `*.chunk.json` records sitting next to their + /// source tabs: supplies rhythm templates, novelty references, and + /// the burst/rest gesture ask. + #[arg(long, value_name = "DIR")] + corpus: Option, + /// Seed variants *per strategy* in the candidate set. The reranked set + /// holds this many × 5 strategies candidates (fewer only when + /// rhythm-copy is skipped for want of a template) — e.g. `10` ranks 50. + #[arg(long, default_value_t = 2)] + candidates: usize, + /// Skip burst/rest gesture carving even when the corpus provides + /// gesture statistics (wall-to-wall writing). + #[arg(long)] + no_gesture: bool, }, /// Arrange a complementary part (S13) for a tab's primary track — a second @@ -184,7 +207,20 @@ fn run() -> Result<(), CliError> { output, seed, bars, - } => cmd_generate(&input, &output, seed, bars), + corpus, + candidates, + no_gesture, + } => cmd_generate( + &input, + &output, + &GenerateOpts { + seed, + bars, + corpus: corpus.as_deref(), + candidates, + no_gesture, + }, + ), Command::Complement { input, output, @@ -233,19 +269,6 @@ fn note_count_in_range(voice: &Voice, range: TickRange) -> usize { .count() } -/// Note atoms in a track's primary (first) voice — the voice every analysis -/// module measures (`analyze_part`, structure, gesture, …). Curate selects -/// measurable tracks with this predicate so selection and measurement agree. -fn primary_voice_note_count(track: &Track) -> usize { - track.voices.first().map_or(0, |v| { - v.event_groups - .iter() - .flat_map(|g| &g.atoms) - .filter(|a| matches!(a, AtomEvent::Note(_))) - .count() - }) -} - /// Total note atoms across all voices of a track. fn track_note_count(track: &Track) -> usize { track @@ -551,132 +574,181 @@ fn phrase_reasons(r: boundary::BoundaryReason) -> String { } } -/// Generates a fresh riff (S6) seeded from the source's musical material — its -/// pitch palette, the rhythm of its first sounding bar, and its meter, tempo, -/// and range — then writes the result to a MIDI file. -fn cmd_generate(input: &Path, output: &Path, seed: u64, bars: usize) -> Result<(), CliError> { +/// Generates a fresh riff seeded from the source's musical material — its +/// pitch palette, meter, tempo, and range — as a reranked candidate set +/// (research note §7.2/§7.3): every S6 strategy contributes `candidates` +/// seed variants, each candidate is scored on the closure + novelty axes +/// under the `generation_rerank` v1 policy, and the winner is written to a +/// MIDI file. +/// +/// Without `--corpus`, the rhythm template is the input's first sounding bar +/// and novelty has nothing to measure against (all candidates read fully +/// novel). With `--corpus`, rhythm templates, novelty references, and the +/// burst/rest gesture ask come from the curated chunks. +fn cmd_generate(input: &Path, output: &Path, opts: &GenerateOpts<'_>) -> Result<(), CliError> { + let GenerateOpts { + seed, + bars, + corpus, + candidates, + no_gesture, + } = *opts; let data = fs::read(input)?; let score = import::import_score_auto(&data)?; - let request = generation_request_from_score(&score, seed, bars)?; - let candidate = generate::generate(&request)?; - let out_bytes = midi::export_score(&candidate.score)?; + let base = generation_request_from_score(&score, seed, bars)?; + + let material = corpus.map(load_corpus_material).transpose()?; + let (source_rhythms, gesture_ask) = material.as_ref().map_or_else( + || (base.source_rhythms.clone(), None), + |m| { + // A corpus without extractable rhythms still generates: fall back + // to the input's first bar rather than dropping rhythm-copy. + let rhythms = if m.rhythms.is_empty() { + base.source_rhythms.clone() + } else { + m.rhythms.clone() + }; + (rhythms, if no_gesture { None } else { m.gesture }) + }, + ); + let references: &[Score] = material.as_ref().map_or(&[], |m| &m.references); + + if let Some(m) = &material { + print_corpus_summary(m, no_gesture); + } + print_rhythm_diagnostics(&source_rhythms, &base.constraints, gesture_ask.is_some()); + + let set = rerank::generate_candidate_set(&rerank::SetRequest { + seed: base.seed, + pitch_material: base.pitch_material.clone(), + constraints: base.constraints, + source_rhythms, + variants_per_strategy: candidates, + gesture: gesture_ask, + })?; + let policy = rerank::rerank_weights_v1(); + let ranked = rerank::rerank_candidates(set, &base.pitch_material, references, &policy); + let winner = ranked + .first() + .ok_or_else(|| CliError::Corpus("no candidate survived scoring".to_owned()))?; + + print_ranking(&ranked, &policy); + + let out_bytes = midi::export_score(&winner.value.score)?; fs::write(output, &out_bytes)?; println!( "generated {bars} bars ({strategy:?}, seed {seed}) from a {tones}-tone scale \ ({n} bytes) -> {out}", - strategy = candidate.strategy, - tones = request.pitch_material.intervals.len(), + strategy = winner.value.strategy, + tones = base.pitch_material.intervals.len(), n = out_bytes.len(), out = output.display(), ); Ok(()) } -/// Builds a tab-seeded [`generate::RuleGenerationRequest`]: the scale is the -/// source's distinct pitch classes, the rhythm template its first sounding bar, -/// and meter / tempo / range its transport. -fn generation_request_from_score( - score: &Score, - seed: u64, - bars: usize, -) -> Result { - if bars == 0 { - return Err(CliError::Generate(generate::GenerationError::BarCountZero)); - } - let pitches = all_pitches(score); - let (lo, hi) = pitch_range(&pitches)?; - let first_bar = score.master_bars.first().ok_or(CliError::Generate( - generate::GenerationError::InvalidConstraints, - ))?; - let constraints = generate::GenerationConstraints { - bar_count: bars, - time_signature: first_bar.time_signature, - tempo: first_bar.tempo, - ticks_per_quarter: Ticks(u32::from(score.ticks_per_quarter)), - pitch_lo: lo, - pitch_hi: hi, - }; - Ok(generate::RuleGenerationRequest { - seed: generate::GenerationSeed(seed), - pitch_material: pitch_material_from(lo, &pitches), - constraints, - source_rhythms: vec![first_bar_rhythm(score)], - strategy: generate::GenerationStrategy::RhythmCopyPitchSubstitute, - }) -} - -/// Every note pitch across all tracks and voices, in track/voice order. -fn all_pitches(score: &Score) -> Vec { - score - .tracks - .iter() - .flat_map(|t| &t.voices) - .flat_map(|v| &v.event_groups) - .flat_map(|g| &g.atoms) - .filter_map(|a| match a { - AtomEvent::Note(n) => Some(n.pitch.0), - AtomEvent::Rest(_) => None, - }) - .collect() +/// Prints what the corpus supplied to the pass: chunk / template counts, the +/// gesture ask (and whether `--no-gesture` overrode it), and any skipped +/// records. +fn print_corpus_summary(m: &CorpusMaterial, no_gesture: bool) { + let gesture_note = m.gesture.map_or_else( + || "no gesture stats".to_owned(), + |g| { + format!( + "gesture burst {} / rest {}q{}", + g.burst_notes, + g.rest_quarters, + if no_gesture { " (skipped)" } else { "" }, + ) + }, + ); + println!( + "corpus: {} chunks ({} rhythm templates, {gesture_note}){}", + m.references.len(), + m.rhythms.len(), + if m.skipped.is_empty() { + String::new() + } else { + format!(", skipped: {}", m.skipped.join(", ")) + }, + ); } -/// The lowest and highest pitch present; errors (no pitch material) when the -/// source is silent. -fn pitch_range(pitches: &[u8]) -> Result<(Pitch, Pitch), CliError> { - let lo = pitches.iter().min().copied().ok_or(CliError::Generate( - generate::GenerationError::EmptyPitchMaterial, - ))?; - let hi = pitches.iter().max().copied().unwrap_or(lo); - Ok((Pitch(lo), Pitch(hi))) +/// Prints a deterministic rhythm-grid diagnostic for the run: how many +/// templates were loaded vs effective (after empty-removal + clamp), the bar +/// count, whether gesture carving is on, and the fingerprints of the first +/// `min(bars, effective)` grids a run of `bars` bars actually rotates through. +/// +/// A small transparency seam for corpus A/B, not an analytics subsystem: with +/// no corpus, `source_rhythms` is the input's own first-bar rhythm (one +/// template, so `1 loaded / 1 effective`), so the two A/B legs are directly +/// comparable. `effective == 0` means the quarter fallback was used. +fn print_rhythm_diagnostics( + source_rhythms: &[generate::RhythmTemplate], + constraints: &generate::GenerationConstraints, + gesture_on: bool, +) { + let Ok(bar_duration) = + generate::bar_duration_ticks(constraints.time_signature, constraints.ticks_per_quarter) + else { + return; + }; + let diag = generate::rhythm_diagnostics(source_rhythms, bar_duration); + let shown = diag.effective.min(constraints.bar_count); + let fingerprints = if diag.effective == 0 { + " (quarter fallback)".to_owned() + } else { + let hexes: Vec = diag + .fingerprints + .iter() + .take(shown) + .map(|h| format!("{h:x}")) + .collect(); + format!("; grids[{shown}] {}", hexes.join(" ")) + }; + println!( + "rhythm: {loaded} loaded / {effective} effective templates over {bars} bars; gesture {onoff}{fingerprints}", + loaded = diag.loaded, + effective = diag.effective, + bars = constraints.bar_count, + onoff = if gesture_on { "on" } else { "off" }, + ); } -/// A scale rooted at `lo` whose intervals are the distinct semitone classes the -/// source uses, so the generated riff stays in the tab's pitch palette. -fn pitch_material_from(lo: Pitch, pitches: &[u8]) -> generate::PitchMaterial { - let mut intervals: Vec = pitches - .iter() - .map(|&p| p.saturating_sub(lo.0).checked_rem(12).unwrap_or(0)) - .collect(); - intervals.sort_unstable(); - intervals.dedup(); - if intervals.is_empty() { - intervals.push(0); - } - generate::PitchMaterial { - root: lo, - intervals, +/// Prints the ranked candidate list with its policy provenance (ADR-0017). +fn print_ranking(ranked: &[scoring::Scored], policy: &scoring::WeightPolicy) { + // `ranked.len()` is the *total* candidate count (variants × strategies), + // not the `--candidates` flag — spelled out so the summary is unambiguous. + println!( + "candidates: {} total ranked under {} v{}", + ranked.len(), + policy.id, + policy.version, + ); + for (rank, scored) in ranked.iter().enumerate() { + println!( + " {}. {:?} variant-seed {} aggregate {:.3}", + rank.saturating_add(1), + scored.value.strategy, + scored.value.seed.0, + scored.aggregate(), + ); } } -/// The note durations of the first *sounding* bar — the earliest master bar -/// holding any note across all tracks and voices — in onset order, as the -/// rhythm template the generator copies. Falls back to four quarter notes only -/// when the source is entirely silent. -fn first_bar_rhythm(score: &Score) -> Vec { - for bar in &score.master_bars { - let mut notes: Vec<(u32, Ticks)> = score - .tracks - .iter() - .flat_map(|t| &t.voices) - .flat_map(|v| &v.event_groups) - .flat_map(|g| &g.atoms) - .filter_map(|a| match a { - AtomEvent::Note(n) - if n.absolute_start.0 >= bar.tick_range.start.0 - && n.absolute_start.0 < bar.tick_range.end.0 => - { - Some((n.absolute_start.0, n.duration)) - } - _ => None, - }) - .collect(); - if !notes.is_empty() { - notes.sort_by_key(|&(onset, _)| onset); - return notes.into_iter().map(|(_, dur)| dur).collect(); - } - } - let quarter = Ticks(u32::from(score.ticks_per_quarter)); - vec![quarter; 4] +/// Options of `griff generate` beyond the input/output pair. +#[derive(Debug, Clone, Copy)] +struct GenerateOpts<'a> { + /// Deterministic base seed. + seed: u64, + /// Bars to generate. + bars: usize, + /// Corpus directory, when one is given. + corpus: Option<&'a Path>, + /// Seed variants per strategy in the candidate set. + candidates: usize, + /// Skip gesture carving even when the corpus provides stats. + no_gesture: bool, } /// Arranges a complementary part B (S13) for the primary track of `input` and @@ -1424,6 +1496,8 @@ enum CliError { Ensemble(String), Split(String), Generate(generate::GenerationError), + Set(rerank::SetError), + Corpus(String), Complement(complement::ComplementError), } @@ -1438,6 +1512,8 @@ impl fmt::Display for CliError { Self::Ensemble(msg) => write!(f, "ensemble error: {msg}"), Self::Split(msg) => write!(f, "split error: {msg}"), Self::Generate(e) => write!(f, "generation error: {e:?}"), + Self::Set(e) => write!(f, "candidate set error: {e:?}"), + Self::Corpus(msg) => write!(f, "corpus error: {msg}"), Self::Complement(e) => write!(f, "complement error: {e:?}"), } } @@ -1467,6 +1543,26 @@ impl From for CliError { } } +impl From for CliError { + fn from(e: rerank::SetError) -> Self { + // Flatten the plain-generation case so it reads the same wherever it + // surfaced from. + match e { + rerank::SetError::Generation(g) => Self::Generate(g), + other => Self::Set(other), + } + } +} + +impl From for CliError { + fn from(e: GenerationInputError) -> Self { + match e { + GenerationInputError::Generation(g) => Self::Generate(g), + GenerationInputError::Corpus(msg) => Self::Corpus(msg), + } + } +} + impl From for CliError { fn from(e: complement::ComplementError) -> Self { Self::Complement(e) @@ -1487,8 +1583,10 @@ impl From for CliError { clippy::indexing_slicing )] mod tests { - use griff_core::corpus::SourceFormat; + use griff_core::corpus::{ChunkMeta, SourceFormat}; use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}; + use griff_core::generate::RhythmTemplate; + use griff_core::gesture; use griff_core::score::{ AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score, SourceMeta, Voice, @@ -1496,7 +1594,8 @@ mod tests { use griff_core::slice::TickRange; use super::{ - measure_group_relations, primary_voice_note_count, source_format, track_note_count, Track, + build_chunk_meta, measure_group_relations, primary_voice_note_count, source_format, + track_note_count, CurateInputs, Track, }; fn score_tagged(format: Option<&str>) -> Score { @@ -1755,9 +1854,9 @@ mod tests { } /// Single-track curation inputs with id `dgd`. - fn split_inputs() -> super::CurateInputs { + fn split_inputs() -> CurateInputs { use griff_core::corpus::{Acquisition, QualityFlag, RightsInfo, RightsStatus, StyleCohort}; - super::CurateInputs { + CurateInputs { id: "dgd".to_owned(), title: "Riff".to_owned(), tuning: "standard_e".to_owned(), @@ -1998,4 +2097,336 @@ mod tests { "the curator sees which measurement failed: {err}" ); } + + // ── corpus-fed generation (research note §7.2/§7.3 wiring) ──────────────── + // + // TDD red phase: `bar_rhythms`, `gesture_control_from_chunks`, and + // `load_corpus_material` do not exist yet, so these tests fail to compile + // until the green step. They specify how `griff generate --corpus ` + // turns curated chunk records + their source tabs into generator inputs: + // per-bar rhythm templates, novelty reference scores, and an aggregated + // gesture ask. + + /// A score of `bar_count` 4/4 bars (1920 ticks each) over `tracks`. + fn bars_score(bar_count: usize, tracks: Vec) -> Score { + let master_bars = (0..bar_count) + .map(|i| { + let start = u32::try_from(i).expect("small index").saturating_mul(1920); + MasterBar { + index: i, + tick_range: TickRange::new(Ticks(start), Ticks(start.saturating_add(1920))) + .expect("ordered"), + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo::new(120.0).expect("120 BPM"), + repeat: RepeatMarker::default(), + } + }) + .collect(); + Score { + ticks_per_quarter: 480, + master_bars, + tracks, + source_meta: None, + loss: LossReport::new(), + } + } + + /// A note atom of arbitrary duration. + fn note(start: u32, dur: u32, pitch: u8) -> AtomEvent { + AtomEvent::Note(AtomNote { + absolute_start: Ticks(start), + duration: Ticks(dur), + pitch: Pitch::new(pitch).expect("valid pitch"), + velocity: Velocity::new(90).expect("valid velocity"), + marks: NoteMarks::empty(), + position: None, + }) + } + + #[test] + fn bar_rhythms_extracts_per_bar_templates_and_skips_silent_bars() { + use griff_cli::generation_input::bar_rhythms; + use griff_core::generate::TemplateNote; + + // Bar 0: four quarters; bar 1: silent; bar 2: two *syncopated* + // eighths (off the downbeat, a gap between them). The extracted + // template must keep the in-bar offsets — rests and syncopation are + // exactly what the corpus should teach the grid (2026-07-11 + // playtest: back-to-back extraction flattened them away). + let track = track_of(vec![voice_of( + 0, + vec![ + note(0, 480, 40), + note(480, 480, 43), + note(960, 480, 45), + note(1440, 480, 47), + note(4080, 240, 50), + note(4800, 240, 47), + ], + )]); + let score = bars_score(3, vec![track]); + + assert_eq!( + bar_rhythms(&score, 0), + vec![ + RhythmTemplate::from_durations(&[Ticks(480); 4]), + RhythmTemplate { + notes: vec![ + TemplateNote { + offset: Ticks(240), + duration: Ticks(240), + }, + TemplateNote { + offset: Ticks(960), + duration: Ticks(240), + }, + ], + }, + ], + "templates keep in-bar offsets; silent bars skipped" + ); + } + + #[test] + fn bar_rhythms_dedups_identical_templates() { + use griff_cli::generation_input::bar_rhythms; + + // Two identical quarter-note bars: the corpus should not drown the + // template rotation in copies of one rhythm. + let track = track_of(vec![voice_of( + 0, + vec![ + note(0, 480, 40), + note(480, 480, 43), + note(960, 480, 45), + note(1440, 480, 47), + note(1920, 480, 40), + note(2400, 480, 43), + note(2880, 480, 45), + note(3360, 480, 47), + ], + )]); + let score = bars_score(2, vec![track]); + + assert_eq!( + bar_rhythms(&score, 0), + vec![RhythmTemplate::from_durations(&[Ticks(480); 4])] + ); + } + + /// Curate inputs with the community-tab rights defaults. + fn corpus_test_inputs(id: &str) -> CurateInputs { + use griff_core::corpus::{Acquisition, QualityFlag, RightsInfo, RightsStatus, StyleCohort}; + CurateInputs { + id: id.to_owned(), + title: format!("Chunk {id}"), + tuning: "standard_e".to_owned(), + style_cohort: StyleCohort::Core, + tags: Vec::new(), + quality_flags: vec![QualityFlag::Clean], + reviewer: None, + rights: RightsInfo { + rights_status: RightsStatus::CopyrightedComposition, + acquisition: Acquisition::CommunityTabSite, + redistributable: false, + notes: String::new(), + }, + } + } + + /// Gesture stats whose only meaningful fields here are the two the + /// control derives from; the rest are plausible fillers. + fn gesture_stats(mean_burst_notes: f64, mean_rest_quarters: f64) -> gesture::GestureStats { + gesture::GestureStats { + note_count: 12, + burst_count: 3, + mean_burst_notes, + max_burst_notes: 6, + rest_count: if mean_rest_quarters == 0.0 { 0 } else { 2 }, + mean_rest_quarters, + rest_on_grid_share: 1.0, + modal_landing_share: 0.5, + mean_final_lengthening: 0.5, + } + } + + /// A chunk record built through the real builder, with its measured + /// gesture replaced by `stats` (or cleared). + fn chunk_with_gesture(id: &str, stats: Option) -> ChunkMeta { + use std::path::Path; + let track = track_of(vec![voice_of(0, vec![quarter(0, 60), quarter(480, 62)])]); + let score = one_bar_score(vec![track]); + let inputs = corpus_test_inputs(id); + let mut meta = build_chunk_meta( + &score, + Path::new(&format!("{id}.mid")), + Some(0), + inputs.id.clone(), + inputs.title.clone(), + &inputs, + None, + ); + meta.gesture = stats; + meta + } + + #[test] + fn gesture_control_from_chunks_averages_per_chunk_controls() { + use griff_cli::generation_input::gesture_control_from_chunks; + + // Per-chunk controls (4, 1.0q) and (2, 3.0q) average to (3, 2.0q); + // a stats-less chunk is skipped, not treated as zero. + let chunks = vec![ + chunk_with_gesture("a", Some(gesture_stats(4.0, 1.0))), + chunk_with_gesture("b", None), + chunk_with_gesture("c", Some(gesture_stats(2.0, 3.0))), + ]; + let control = gesture_control_from_chunks(&chunks).expect("stats present"); + assert_eq!(control.burst_notes, 3); + assert!((control.rest_quarters - 2.0).abs() < 1e-9); + } + + #[test] + fn gesture_control_from_chunks_ignores_restless_chunks() { + use griff_cli::generation_input::gesture_control_from_chunks; + + // A wall-to-wall riff's stats describe one giant burst (mean burst = + // the whole chunk); letting it vote inflates the ask past ever + // carving (2026-07-11 playtest: burst 69 over a 32-note request + // carved nothing). Only chunks that actually rest vote. + let chunks = vec![ + chunk_with_gesture("wall", Some(gesture_stats(120.0, 0.0))), + chunk_with_gesture("a", Some(gesture_stats(4.0, 1.0))), + chunk_with_gesture("b", Some(gesture_stats(2.0, 3.0))), + ]; + let control = gesture_control_from_chunks(&chunks).expect("resting chunks vote"); + assert_eq!(control.burst_notes, 3, "the restless chunk does not vote"); + assert!((control.rest_quarters - 2.0).abs() < 1e-9); + } + + #[test] + fn gesture_control_from_chunks_takes_the_median_against_outliers() { + use griff_cli::generation_input::gesture_control_from_chunks; + + // One long-burst outlier must not drag the ask out of carving range: + // the aggregate is the per-axis median, not the mean. + let chunks = vec![ + chunk_with_gesture("a", Some(gesture_stats(2.0, 1.0))), + chunk_with_gesture("b", Some(gesture_stats(3.0, 1.5))), + chunk_with_gesture("c", Some(gesture_stats(100.0, 4.0))), + ]; + let control = gesture_control_from_chunks(&chunks).expect("stats present"); + assert_eq!(control.burst_notes, 3); + assert!((control.rest_quarters - 1.5).abs() < 1e-9); + } + + #[test] + fn gesture_control_from_chunks_is_none_when_no_chunk_rests() { + use griff_cli::generation_input::gesture_control_from_chunks; + let chunks = vec![chunk_with_gesture("wall", Some(gesture_stats(120.0, 0.0)))]; + assert!( + gesture_control_from_chunks(&chunks).is_none(), + "an all-wall-to-wall corpus asks for no gesture instead of a degenerate one" + ); + } + + #[test] + fn gesture_control_from_chunks_is_none_without_stats() { + use griff_cli::generation_input::gesture_control_from_chunks; + let chunks = vec![chunk_with_gesture("a", None)]; + assert!(gesture_control_from_chunks(&chunks).is_none()); + } + + #[test] + fn load_corpus_material_reads_chunks_slices_ranges_and_skips_missing_sources() { + use std::{env, fs, process}; + + use griff_cli::generation_input::load_corpus_material; + use griff_core::midi; + + let dir = env::temp_dir().join(format!("griff_corpus_material_{}", process::id())); + fs::create_dir_all(&dir).expect("create corpus dir"); + + // Source tab: two bars, quarters then eighths. + let track = track_of(vec![voice_of( + 0, + vec![ + note(0, 480, 40), + note(480, 480, 43), + note(960, 480, 45), + note(1440, 480, 47), + note(1920, 240, 50), + note(2160, 240, 47), + note(2400, 240, 45), + note(2640, 240, 43), + note(2880, 240, 40), + note(3120, 240, 43), + note(3360, 240, 45), + note(3600, 240, 47), + ], + )]); + let source = bars_score(2, vec![track]); + fs::write( + dir.join("a.mid"), + midi::export_score(&source).expect("export source"), + ) + .expect("write source"); + + // Chunk a: covers only bar 0 of its source. Chunk b: source missing. + let inputs = corpus_test_inputs("a"); + let mut meta_a = build_chunk_meta( + &source, + &dir.join("a.mid"), + Some(0), + inputs.id.clone(), + inputs.title.clone(), + &inputs, + None, + ); + meta_a.source.bar_range = Some((0, 0)); + fs::write( + dir.join("a.chunk.json"), + serde_json::to_string(&meta_a).expect("serialize a"), + ) + .expect("write a.chunk.json"); + + let mut meta_b = chunk_with_gesture("b", None); + meta_b.source.filename = "missing.mid".to_owned(); + fs::write( + dir.join("b.chunk.json"), + serde_json::to_string(&meta_b).expect("serialize b"), + ) + .expect("write b.chunk.json"); + + // A group record must be ignored, not parsed as a chunk. + fs::write(dir.join("g.group.json"), "{}").expect("write group"); + + let material = load_corpus_material(&dir).expect("corpus loads"); + + assert_eq!( + material.references.len(), + 1, + "one chunk with a readable source" + ); + assert_eq!( + material.references[0].master_bars.len(), + 1, + "bar_range (0, 0) slices the source to one bar" + ); + assert_eq!( + material.rhythms, + vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], + "rhythm templates come from the sliced bar range only" + ); + assert_eq!( + material.skipped, + vec!["b.chunk.json".to_owned()], + "records whose source cannot be read are skipped, by record name" + ); + + fs::remove_dir_all(&dir).ok(); + } } diff --git a/cli/tests/generate_corpus.rs b/cli/tests/generate_corpus.rs new file mode 100644 index 00000000..28c97622 --- /dev/null +++ b/cli/tests/generate_corpus.rs @@ -0,0 +1,207 @@ +// TDD red phase: corpus-fed candidate-set generation on the CLI — the wiring +// of the melodic-closure note (§7.2 rerank, §7.3 novelty measure) and the S6 +// stage doc's promised candidate *set* into `griff generate`: +// +// - `--corpus ` points at a directory of curated `*.chunk.json` records +// sitting next to their source tabs. The chunks supply per-bar rhythm +// templates (instead of the input's first bar), novelty reference scores, +// and an aggregated gesture ask. +// - `--candidates ` sets the seed variants per strategy in the candidate +// set; every set is reranked under the `generation_rerank` v1 policy and +// the winner is written to the output file. +// - `--no-gesture` opts out of gesture carving even when the corpus provides +// stats. +// +// The flags do not exist yet, so the suite fails until the green step. +#![allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::missing_assert_message, + clippy::absolute_paths +)] + +use std::{ + fs, + io::Write as _, + path::PathBuf, + process::{Command, Output, Stdio}, +}; + +/// Locate the compiled `griff` binary. +fn griff_bin() -> PathBuf { + std::env::var_os("CARGO_BIN_EXE_griff").map_or_else( + || PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../target/debug/griff"), + Into::into, + ) +} + +/// The committed `two_phrases.mid` characterization fixture. +fn fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/two_phrases.mid") +} + +/// Builds a corpus directory under the OS temp dir: one curated chunk record +/// of the `two_phrases` fixture plus the source file itself, named as the +/// record's `source.filename` expects. +fn build_corpus_dir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "griff_generate_corpus_{tag}_{}", + std::process::id() + )); + fs::remove_dir_all(&dir).ok(); + fs::create_dir_all(&dir).expect("create corpus dir"); + + let mut child = Command::new(griff_bin()) + .arg("curate") + .arg(fixture()) + .arg("--output") + .arg(dir.join("a.chunk.json")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn curate"); + child + .stdin + .as_mut() + .expect("piped stdin") + .write_all(b"a_001\nChunk a\n\n\n\n\n\n") + .expect("write curate answers"); + assert!( + child + .wait_with_output() + .expect("wait for curate") + .status + .success(), + "curate must exit 0" + ); + + fs::copy(fixture(), dir.join("two_phrases.mid")).expect("copy source tab into corpus dir"); + dir +} + +/// Runs `griff generate` with the given extra args, returning the raw output. +fn run_generate(out_file: &PathBuf, extra: &[&str]) -> Output { + let mut cmd = Command::new(griff_bin()); + cmd.arg("generate") + .arg(fixture()) + .arg(out_file) + .args(["--seed", "7", "--bars", "4"]); + cmd.args(extra); + cmd.output().expect("run griff generate") +} + +#[test] +fn generate_help_mentions_corpus_and_candidates() { + let out = Command::new(griff_bin()) + .args(["generate", "--help"]) + .output() + .expect("run griff generate --help"); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + for flag in ["--corpus", "--candidates", "--no-gesture"] { + assert!(stdout.contains(flag), "help must mention {flag}: {stdout}"); + } +} + +#[test] +fn generate_with_corpus_ranks_candidates_and_writes_winner() { + let dir = build_corpus_dir("rank"); + let out_file = dir.join("out.mid"); + + let out = run_generate(&out_file, &["--corpus", dir.to_str().unwrap()]); + assert!( + out.status.success(), + "generate --corpus must exit 0: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("corpus:"), + "stdout reports what the corpus supplied: {stdout}" + ); + assert!( + stdout.contains("ranked under generation_rerank v1"), + "stdout names the rerank policy and version (ADR-0017 provenance): {stdout}" + ); + // The rhythm diagnostic seam (A/B transparency): loaded vs effective + // templates, bar count, gesture on/off, and per-grid fingerprints. + assert!( + stdout.contains("rhythm:") + && stdout.contains("effective templates") + && stdout.contains("grids["), + "stdout carries the rhythm-grid diagnostics: {stdout}" + ); + assert!( + !fs::read(&out_file).expect("winner written").is_empty(), + "the top-ranked candidate lands in the output file" + ); + + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn generate_with_corpus_is_deterministic() { + let dir = build_corpus_dir("det"); + let out_a = dir.join("out_a.mid"); + let out_b = dir.join("out_b.mid"); + + assert!(run_generate(&out_a, &["--corpus", dir.to_str().unwrap()]) + .status + .success()); + assert!(run_generate(&out_b, &["--corpus", dir.to_str().unwrap()]) + .status + .success()); + assert_eq!( + fs::read(&out_a).expect("first run output"), + fs::read(&out_b).expect("second run output"), + "same seed + same corpus => byte-identical winner (SPEC §6)" + ); + + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn generate_no_gesture_changes_the_carve_not_the_determinism() { + let dir = build_corpus_dir("plain"); + let out_file = dir.join("out.mid"); + + let out = run_generate( + &out_file, + &["--corpus", dir.to_str().unwrap(), "--no-gesture"], + ); + assert!( + out.status.success(), + "generate --corpus --no-gesture must exit 0: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !fs::read(&out_file).expect("winner written").is_empty(), + "the un-carved winner still lands in the output file" + ); + + fs::remove_dir_all(&dir).ok(); +} + +#[test] +fn generate_rejects_a_missing_corpus_dir() { + let out_file = std::env::temp_dir().join(format!( + "griff_generate_corpus_missing_{}.mid", + std::process::id() + )); + let out = run_generate( + &out_file, + &["--corpus", "nonexistent_corpus_dir_that_does_not_exist"], + ); + assert!( + !out.status.success(), + "a missing corpus dir is an error, not a silent fallback" + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("corpus"), + "the error names the corpus: {stderr}" + ); + fs::remove_file(&out_file).ok(); +} diff --git a/cli/tests/snapshots/generate__multi_track.txt b/cli/tests/snapshots/generate__multi_track.txt index c8a036c2..b467faca 100644 --- a/cli/tests/snapshots/generate__multi_track.txt +++ b/cli/tests/snapshots/generate__multi_track.txt @@ -1,5 +1,17 @@ $ griff generate exit: 0 --- stdout --- -generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 6-tone scale (558 bytes) -> +rhythm: 1 loaded / 1 effective templates over 8 bars; gesture off; grids[1] 7d150fc3c01468b8 +candidates: 10 total ranked under generation_rerank v1 + 1. MotifTransposeVariation variant-seed 7960286522194355700 aggregate 0.873 + 2. RhythmCopyPitchSubstitute variant-seed 16294208416658607535 aggregate 0.823 + 3. RhythmCopyPitchSubstitute variant-seed 16481712997681181849 aggregate 0.806 + 4. MotifTransposeVariation variant-seed 4560642061891045783 aggregate 0.790 + 5. ConstrainedRandomWalk variant-seed 487617019471545679 aggregate 0.790 + 6. ConstrainedRandomWalk variant-seed 398795221420464796 aggregate 0.790 + 7. RepeatVariation variant-seed 1961750202426094747 aggregate 0.790 + 8. RepeatVariation variant-seed 3625251794129094637 aggregate 0.756 + 9. ShuffleMotifs variant-seed 17909611376780542444 aggregate 0.656 + 10. ShuffleMotifs variant-seed 2939584983604071071 aggregate 0.656 +generated 8 bars (MotifTransposeVariation, seed 0) from a 6-tone scale (558 bytes) -> --- stderr --- diff --git a/cli/tests/snapshots/generate__seven_eight.txt b/cli/tests/snapshots/generate__seven_eight.txt index f553408a..cf79273a 100644 --- a/cli/tests/snapshots/generate__seven_eight.txt +++ b/cli/tests/snapshots/generate__seven_eight.txt @@ -1,5 +1,17 @@ $ griff generate exit: 0 --- stdout --- -generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 4-tone scale (558 bytes) -> +rhythm: 1 loaded / 1 effective templates over 8 bars; gesture off; grids[1] a00f28234ee2108a +candidates: 10 total ranked under generation_rerank v1 + 1. ConstrainedRandomWalk variant-seed 398795221420464796 aggregate 0.883 + 2. MotifTransposeVariation variant-seed 4560642061891045783 aggregate 0.833 + 3. ConstrainedRandomWalk variant-seed 487617019471545679 aggregate 0.833 + 4. ShuffleMotifs variant-seed 2939584983604071071 aggregate 0.817 + 5. RhythmCopyPitchSubstitute variant-seed 16294208416658607535 aggregate 0.800 + 6. RhythmCopyPitchSubstitute variant-seed 16481712997681181849 aggregate 0.800 + 7. MotifTransposeVariation variant-seed 7960286522194355700 aggregate 0.800 + 8. ShuffleMotifs variant-seed 17909611376780542444 aggregate 0.800 + 9. RepeatVariation variant-seed 1961750202426094747 aggregate 0.733 + 10. RepeatVariation variant-seed 3625251794129094637 aggregate 0.733 +generated 8 bars (ConstrainedRandomWalk, seed 0) from a 4-tone scale (558 bytes) -> --- stderr --- diff --git a/cli/tests/snapshots/generate__simple_4_4.txt b/cli/tests/snapshots/generate__simple_4_4.txt index a3d8d711..3b682412 100644 --- a/cli/tests/snapshots/generate__simple_4_4.txt +++ b/cli/tests/snapshots/generate__simple_4_4.txt @@ -1,5 +1,17 @@ $ griff generate exit: 0 --- stdout --- -generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 6-tone scale (342 bytes) -> +rhythm: 1 loaded / 1 effective templates over 8 bars; gesture off; grids[1] ee747d11b006e748 +candidates: 10 total ranked under generation_rerank v1 + 1. ConstrainedRandomWalk variant-seed 487617019471545679 aggregate 0.833 + 2. ShuffleMotifs variant-seed 17909611376780542444 aggregate 0.833 + 3. RhythmCopyPitchSubstitute variant-seed 16294208416658607535 aggregate 0.800 + 4. RhythmCopyPitchSubstitute variant-seed 16481712997681181849 aggregate 0.800 + 5. MotifTransposeVariation variant-seed 7960286522194355700 aggregate 0.800 + 6. RepeatVariation variant-seed 1961750202426094747 aggregate 0.800 + 7. RepeatVariation variant-seed 3625251794129094637 aggregate 0.800 + 8. ConstrainedRandomWalk variant-seed 398795221420464796 aggregate 0.767 + 9. MotifTransposeVariation variant-seed 4560642061891045783 aggregate 0.733 + 10. ShuffleMotifs variant-seed 2939584983604071071 aggregate 0.733 +generated 8 bars (ConstrainedRandomWalk, seed 0) from a 6-tone scale (342 bytes) -> --- stderr --- diff --git a/cli/tests/snapshots/generate__tempo_change.txt b/cli/tests/snapshots/generate__tempo_change.txt index cfcec5d7..466eddb3 100644 --- a/cli/tests/snapshots/generate__tempo_change.txt +++ b/cli/tests/snapshots/generate__tempo_change.txt @@ -1,5 +1,17 @@ $ griff generate exit: 0 --- stdout --- -generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 4-tone scale (342 bytes) -> +rhythm: 1 loaded / 1 effective templates over 8 bars; gesture off; grids[1] ee747d11b006e748 +candidates: 10 total ranked under generation_rerank v1 + 1. ShuffleMotifs variant-seed 17909611376780542444 aggregate 0.883 + 2. ConstrainedRandomWalk variant-seed 398795221420464796 aggregate 0.833 + 3. ShuffleMotifs variant-seed 2939584983604071071 aggregate 0.833 + 4. RhythmCopyPitchSubstitute variant-seed 16294208416658607535 aggregate 0.800 + 5. RhythmCopyPitchSubstitute variant-seed 16481712997681181849 aggregate 0.800 + 6. MotifTransposeVariation variant-seed 7960286522194355700 aggregate 0.800 + 7. MotifTransposeVariation variant-seed 4560642061891045783 aggregate 0.800 + 8. ConstrainedRandomWalk variant-seed 487617019471545679 aggregate 0.800 + 9. RepeatVariation variant-seed 1961750202426094747 aggregate 0.733 + 10. RepeatVariation variant-seed 3625251794129094637 aggregate 0.733 +generated 8 bars (ShuffleMotifs, seed 0) from a 4-tone scale (342 bytes) -> --- stderr --- diff --git a/cli/tests/snapshots/generate__two_phrases.txt b/cli/tests/snapshots/generate__two_phrases.txt index f753e8d1..47eedeb3 100644 --- a/cli/tests/snapshots/generate__two_phrases.txt +++ b/cli/tests/snapshots/generate__two_phrases.txt @@ -1,5 +1,17 @@ $ griff generate exit: 0 --- stdout --- +rhythm: 1 loaded / 1 effective templates over 8 bars; gesture off; grids[1] ee747d11b006e748 +candidates: 10 total ranked under generation_rerank v1 + 1. RhythmCopyPitchSubstitute variant-seed 16294208416658607535 aggregate 0.833 + 2. ConstrainedRandomWalk variant-seed 487617019471545679 aggregate 0.833 + 3. RepeatVariation variant-seed 1961750202426094747 aggregate 0.833 + 4. ShuffleMotifs variant-seed 2939584983604071071 aggregate 0.817 + 5. RhythmCopyPitchSubstitute variant-seed 16481712997681181849 aggregate 0.800 + 6. MotifTransposeVariation variant-seed 7960286522194355700 aggregate 0.800 + 7. MotifTransposeVariation variant-seed 4560642061891045783 aggregate 0.800 + 8. ConstrainedRandomWalk variant-seed 398795221420464796 aggregate 0.800 + 9. ShuffleMotifs variant-seed 17909611376780542444 aggregate 0.800 + 10. RepeatVariation variant-seed 3625251794129094637 aggregate 0.800 generated 8 bars (RhythmCopyPitchSubstitute, seed 0) from a 7-tone scale (342 bytes) -> --- stderr --- diff --git a/core/Cargo.toml b/core/Cargo.toml index 998fd17f..d96ca811 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -13,7 +13,7 @@ categories.workspace = true [dependencies] midly = { workspace = true } thiserror = { workspace = true } -guitarpro = { version = "0.3", default-features = false, optional = true } +guitarpro = { version = "0.4", default-features = false, optional = true } serde = { workspace = true } [features] diff --git a/core/src/complement.rs b/core/src/complement.rs index b82384d0..d0f43c55 100644 --- a/core/src/complement.rs +++ b/core/src/complement.rs @@ -39,6 +39,13 @@ use crate::generate::{ }; use crate::score::{AtomEvent, AtomNote, EventGroup, EventGroupKind, Score, Track, Voice}; use crate::scoring::{rank_indices, Axes, Axis, Scored, WeightPolicy}; +use crate::tonal::estimate_from_histograms; + +/// The two scale shapes the key estimate considers. +/// +/// Re-exported from [`crate::tonal`], which now owns the tonal vocabulary; the +/// `complement::KeyMode` path is preserved for existing callers. +pub use crate::tonal::KeyMode; /// A named complementarity preset for generating part B (glossary §8). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -133,35 +140,17 @@ pub enum VariationError { Arrange(ComplementError), } -/// Major or natural minor — the two scale shapes the key estimate considers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KeyMode { - /// The major (Ionian) scale. - Major, - /// The natural minor (Aeolian) scale. - Minor, -} - -impl KeyMode { - /// Semitone offsets of this mode's scale above its tonic. - #[must_use] - pub const fn scale_offsets(self) -> [u8; 7] { - match self { - Self::Major => [0, 2, 4, 5, 7, 9, 11], - Self::Minor => [0, 2, 3, 5, 7, 8, 10], - } - } -} - /// The part's estimated key and how well its notes fit that key's scale — /// the harmonic context of a part profile (glossary §8). /// -/// Estimated with the Krumhansl–Schmuckler key-finding algorithm: the part's -/// duration-weighted pitch-class histogram is correlated (Pearson) against -/// the 24 rotated Krumhansl–Kessler tonal-hierarchy profiles and the best -/// correlation wins, ties resolving to the earliest key in the -/// major-then-minor, C-upward scan. `scale_fit` is a fact, not a verdict — -/// what counts as "fitting well enough" is corpus/S9 calibration territory. +/// The lossy projection of the winning [`crate::tonal::TonalCandidate`]: the +/// part's duration-weighted pitch-class histogram is correlated (Pearson) +/// against the 24 rotated Krumhansl–Kessler tonal-hierarchy profiles and the +/// best correlation wins, ties resolving to the earliest key in the +/// major-then-minor, C-upward scan. Dropping the runner-up and margin discards +/// the estimate's uncertainty — callers that need it use [`crate::tonal`] +/// directly. `scale_fit` is a fact, not a verdict — what counts as "fitting +/// well enough" is corpus/S9 calibration territory. #[derive(Debug, Clone, Copy, PartialEq)] pub struct HarmonicContext { /// Tonic pitch class: `0` = C … `11` = B. @@ -506,103 +495,38 @@ pub fn analyze_part(score: &Score, track_index: usize) -> Result Option { if notes.is_empty() { return None; } - let total_ticks: u64 = notes.iter().map(|&(_, d)| u64::from(d)).sum(); - let mut histogram = [0.0_f64; 12]; + let mut onset_counts = [0_u32; 12]; + let mut duration_mass = [0_u64; 12]; for &(pitch, duration) in notes { let pc = usize::from(pitch) % 12; - let weight = if total_ticks == 0 { - 1.0 - } else { - f64::from(duration) - }; - histogram[pc] += weight; - } - - let mut best: Option<(f64, u8, KeyMode)> = None; - for mode in [KeyMode::Major, KeyMode::Minor] { - let profile = match mode { - KeyMode::Major => &KK_MAJOR, - KeyMode::Minor => &KK_MINOR, - }; - for tonic in 0..12_u8 { - let r = rotated_correlation(&histogram, profile, tonic); - if best.map_or(true, |(b, _, _)| r > b) { - best = Some((r, tonic, mode)); - } - } + onset_counts[pc] = onset_counts[pc].saturating_add(1); + duration_mass[pc] = duration_mass[pc].saturating_add(u64::from(duration)); } - let (_, tonic_pitch_class, mode) = best?; - - let total: f64 = histogram.iter().sum(); - let on_scale: f64 = mode - .scale_offsets() - .iter() - .map(|&s| histogram[usize::from(tonic_pitch_class + s) % 12]) - .sum(); - let scale_fit = if total > 0.0 { on_scale / total } else { 0.0 }; + let winner = *estimate_from_histograms(notes.len(), &onset_counts, &duration_mass)?.winner()?; Some(HarmonicContext { - tonic_pitch_class, - mode, - scale_fit, + tonic_pitch_class: winner.tonic, + mode: winner.mode, + scale_fit: winner.scale_fit, }) } -/// Pearson correlation between `histogram` and `profile` rotated so the -/// profile's tonic sits on pitch class `tonic`. -// Float-only arithmetic over fixed 12-bin arrays; indices are mod-12. -#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] -fn rotated_correlation(histogram: &[f64; 12], profile: &[f64; 12], tonic: u8) -> f64 { - let mut rotated = [0.0_f64; 12]; - for (pc, slot) in rotated.iter_mut().enumerate() { - *slot = profile[(pc + 12 - usize::from(tonic)) % 12]; - } - - let n = 12.0_f64; - let mean_x: f64 = histogram.iter().sum::() / n; - let mean_y: f64 = rotated.iter().sum::() / n; - let mut numerator = 0.0_f64; - let mut var_x = 0.0_f64; - let mut var_y = 0.0_f64; - for (x, y) in histogram.iter().zip(rotated.iter()) { - let dx = x - mean_x; - let dy = y - mean_y; - numerator = dx.mul_add(dy, numerator); - var_x = dx.mul_add(dx, var_x); - var_y = dy.mul_add(dy, var_y); - } - let denominator = (var_x * var_y).sqrt(); - if denominator > 0.0 { - numerator / denominator - } else { - 0.0 - } -} - /// Measures the complement relation between two *existing* tracks. /// /// The corpus-side counterpart of the per-mode `AxisScores` provenance, used @@ -841,7 +765,11 @@ fn arrange_counter_melody( pitch_lo: Pitch::new(band_lo).unwrap_or(register.lowest), pitch_hi: Pitch::new(band_hi).unwrap_or(register.highest), }, - source_rhythms: profile.bar_rhythms.clone(), + // Deliberately empty: the walk historically wrote quarters and the + // rhythm-grid change would otherwise silently re-rhythm part B onto + // A's bar durations. Feeding `profile.bar_rhythms` (as onset-aware + // templates) into the grid is its own increment with its own goldens. + source_rhythms: Vec::new(), strategy: GenerationStrategy::ConstrainedRandomWalk, }; let candidate = generate(&request).map_err(ComplementError::Generation)?; @@ -1369,26 +1297,21 @@ fn spread_window(ladder_len: usize, pitch_spread: f64) -> usize { /// octave — every degree (`ladder_index`) was previously placed only in the /// lowest octave. fn band_scale_ladder(lo: u8, hi: u8, intervals: &[u8]) -> Vec { - let hi16 = u16::from(hi); - let mut ladder: Vec = Vec::new(); - let mut base = u16::from(lo); - while base <= hi16 { - for &interval in intervals { - let p = base.saturating_add(u16::from(interval)); - if p <= hi16 { - if let Ok(p8) = u8::try_from(p) { - ladder.push(p8); - } - } - } - base = base.saturating_add(12); - } - ladder.sort_unstable(); - ladder.dedup(); - if ladder.is_empty() { - ladder.push(lo); - } - ladder + // Delegates to the shared ladder (register increment): one degree→pitch + // mapper across the codebase. `intervals` are offsets from `lo`, so the + // palette is their pitch classes anchored at `lo`. Fully qualified to avoid + // the `feature::PitchRange` import already in scope. + use crate::pitch::{PitchClassSet, PitchRange as LadderRange, ScaleLadder}; + + let range = LadderRange::new(Pitch(lo), Pitch(hi)); + let classes = PitchClassSet::new(intervals.iter().map(|&i| lo.wrapping_add(i))); + // The arranger tolerates a bare `lo` floor when the palette selects nothing + // in range (its pre-existing behaviour) — the strict always-in-class + // contract is the generator's, enforced there via the `Result`. + ScaleLadder::build(&range, &classes).map_or_else( + |_| vec![lo], + |ladder| ladder.pitches().iter().map(|p| p.0).collect(), + ) } /// Seed-deterministic scale-degree (`ladder_index`) picker for note `index` diff --git a/core/src/generate.rs b/core/src/generate.rs index b962df1a..8dbaed3c 100644 --- a/core/src/generate.rs +++ b/core/src/generate.rs @@ -3,6 +3,7 @@ use crate::event::{ NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, ValidationError, Velocity, }; +use crate::pitch::{PitchClassSet, PitchRange, PitchSelectionError, ScaleLadder}; use crate::score::{ AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score, Track, Voice, @@ -14,10 +15,14 @@ use crate::slice::TickRange; /// A single generated note, the internal intermediate of the rule strategies. /// /// Strategies emit `Vec>` (one inner `Vec` per bar); `bars_to_score` -/// lowers these onto the canonical model. Meter and tempo are not carried here — -/// they live on the master bars (ADR-0003), derived from the request constraints. +/// lowers these onto the canonical model at `bar start + offset`, so in-bar +/// silence (rests, syncopation) survives generation. Meter and tempo are not +/// carried here — they live on the master bars (ADR-0003), derived from the +/// request constraints. #[derive(Debug, Clone, Copy)] struct GenNote { + /// Onset offset from the bar start (from the rhythm grid). + offset: Ticks, pitch: Pitch, duration: Ticks, velocity: Velocity, @@ -27,36 +32,186 @@ struct GenNote { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct GenerationSeed(pub u64); -/// A pitch scale expressed as a root MIDI note plus semitone offsets from it. +/// A pitch palette: an anchor MIDI note plus semitone offsets from it. +/// +/// The palette contributes only its *pitch classes* to generation — the +/// `root` is a class anchor, **not** a tonal center. Degree-to-pitch mapping +/// goes through a [`ScaleLadder`](crate::pitch::ScaleLadder) over the request's +/// `[pitch_lo, pitch_hi]` range, so the full register is reachable and the +/// input's minimum pitch is never treated as a tonic (tonal-center inference +/// is a later increment). #[derive(Debug, Clone)] pub struct PitchMaterial { - /// Root MIDI pitch (0–127). + /// Anchor MIDI pitch (0–127); contributes its class to the palette. pub root: Pitch, - /// Semitone offsets from root (typically 0–11) that define the scale. + /// Semitone offsets from `root` (typically 0–11) that define the scale. pub intervals: Vec, } impl PitchMaterial { - /// Maps a linear `degree` (unbounded) to a MIDI pitch, clamped to `[lo, hi]`. - /// - /// Successive degrees walk up the scale; each full cycle adds one octave. - fn pitch_at(&self, degree: usize, lo: Pitch, hi: Pitch) -> Pitch { - let scale_len = self.intervals.len(); - // scale_len >= 1 guaranteed by EmptyPitchMaterial guard in generate(). - let octave = degree.checked_div(scale_len).unwrap_or(0); - let idx = degree.checked_rem(scale_len).unwrap_or(0); - let interval = self.intervals.get(idx).copied().unwrap_or(0); - // octave * 12 capped at 127 via u8 cast; saturating_add avoids overflow - let octave_offset = u8::try_from(octave.saturating_mul(12)).unwrap_or(127_u8); - let raw = self - .root + /// The palette's pitch classes: each `(root + interval) mod 12`. + #[must_use] + pub fn pitch_classes(&self) -> PitchClassSet { + PitchClassSet::new(self.intervals.iter().map(|&i| self.root.0.wrapping_add(i))) + } +} + +// ── rhythm grid ─────────────────────────────────────────────────────────────── + +/// One placed note of a bar-length rhythm template. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TemplateNote { + /// Onset offset from the bar start. + pub offset: Ticks, + /// Note duration. + pub duration: Ticks, +} + +/// A one-bar rhythm pattern of onset-*placed* notes. +/// +/// Gaps — rests and syncopation — survive extraction and generation (offsets +/// need not be contiguous). Every strategy lays its pitches onto this grid; +/// the pitch logic stays the strategy's own. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RhythmTemplate { + /// The placed notes, expected in ascending offset order. + pub notes: Vec, +} + +impl RhythmTemplate { + /// Rebuilds the legacy wall-to-wall shape: back-to-back durations become + /// notes whose offsets accumulate from the bar start. + #[must_use] + pub fn from_durations(durations: &[Ticks]) -> Self { + let mut notes = Vec::with_capacity(durations.len()); + let mut offset = Ticks::ZERO; + for &duration in durations { + notes.push(TemplateNote { offset, duration }); + offset = Ticks(offset.0.saturating_add(duration.0)); + } + Self { notes } + } +} + +/// The *effective* grids: one clamped grid per template that survives +/// empty-removal and clamping to the bar, in input order. May be empty (the +/// caller falls back to the quarter grid). This is the set the per-bar +/// scheduler rotates, and the set [`rhythm_diagnostics`] reports. +fn effective_grids(templates: &[RhythmTemplate], bar_duration: Ticks) -> Vec> { + templates + .iter() + .filter(|t| !t.notes.is_empty()) + .map(|t| clamp_template(t, bar_duration)) + .filter(|g| !g.is_empty()) + .collect() +} + +/// The per-bar placement grids: the effective grids, or a single quarter-note +/// grid when none is usable — the no-corpus case keeps today's wall-to-wall +/// quarter behaviour. Never empty. +fn bar_grids( + templates: &[RhythmTemplate], + bar_duration: Ticks, + ticks_per_quarter: Ticks, +) -> Vec> { + let grids = effective_grids(templates, bar_duration); + if grids.is_empty() { + vec![quarter_grid(bar_duration, ticks_per_quarter)] + } else { + grids + } +} + +/// A deterministic diagnostic of how rhythm templates resolve for a given bar +/// duration — for CLI generation-summary transparency (making a corpus A/B +/// interpretable), not a runtime generation input. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RhythmDiagnostics { + /// Templates passed in, before empty-removal. + pub loaded: usize, + /// Effective grids: templates surviving empty-removal and clamping to the + /// bar (the grids the per-bar scheduler rotates). Zero means the quarter + /// fallback was used. + pub effective: usize, + /// A stable fingerprint per effective grid, in scheduler order — the + /// FNV-1a hash of its `(offset, duration)` pairs. Equal rhythms share a + /// fingerprint; distinct rhythms differ. + pub fingerprints: Vec, +} + +/// Reports how `templates` resolve at `bar_duration` (see [`RhythmDiagnostics`]). +#[must_use] +pub fn rhythm_diagnostics(templates: &[RhythmTemplate], bar_duration: Ticks) -> RhythmDiagnostics { + let grids = effective_grids(templates, bar_duration); + let fingerprints = grids.iter().map(|g| grid_fingerprint(g)).collect(); + RhythmDiagnostics { + loaded: templates.len(), + effective: grids.len(), + fingerprints, + } +} + +/// FNV-1a (64-bit) hash of a grid's `(offset, duration)` pairs — a stable, +/// order-sensitive fingerprint of one bar rhythm. +fn grid_fingerprint(grid: &[TemplateNote]) -> u64 { + const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + const PRIME: u64 = 0x0000_0100_0000_01b3; + let mut hash = OFFSET_BASIS; + for note in grid { + for byte in note + .offset .0 - .saturating_add(interval) - .saturating_add(octave_offset); - let actual_lo = lo.0.min(hi.0); - let actual_hi = lo.0.max(hi.0).min(127); - Pitch(raw.clamp(actual_lo, actual_hi)) + .to_le_bytes() + .into_iter() + .chain(note.duration.0.to_le_bytes()) + { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(PRIME); + } + } + hash +} + +/// The grid for bar `bar_index`, cycling through `grids` (guaranteed +/// non-empty by [`bar_grids`]). +fn grid_for_bar(grids: &[Vec], bar_index: usize) -> &[TemplateNote] { + let idx = bar_index.checked_rem(grids.len()).unwrap_or(0); + grids.get(idx).map_or(&[], Vec::as_slice) +} + +/// Clamps a template to one bar: notes at or past the bar end drop, durations +/// clamp to the bar end, zero durations drop, and the result sorts by offset +/// so onsets stay non-decreasing. +fn clamp_template(template: &RhythmTemplate, bar_duration: Ticks) -> Vec { + let mut notes: Vec = template + .notes + .iter() + .filter(|n| n.offset < bar_duration) + .map(|n| TemplateNote { + offset: n.offset, + duration: fit_duration(n.duration, Ticks(bar_duration.0.saturating_sub(n.offset.0))), + }) + .filter(|n| n.duration > Ticks::ZERO) + .collect(); + notes.sort_by_key(|n| n.offset.0); + notes +} + +/// The fallback grid: quarter notes from the bar start, the last clamped to +/// the bar end (e.g. 7/8 ends on an eighth). +fn quarter_grid(bar_duration: Ticks, ticks_per_quarter: Ticks) -> Vec { + let step = ticks_per_quarter.max(Ticks(1)); + let mut notes = Vec::new(); + let mut offset = Ticks::ZERO; + while offset < bar_duration { + let remaining = Ticks(bar_duration.0.saturating_sub(offset.0)); + notes.push(TemplateNote { + offset, + duration: fit_duration(step, remaining), + }); + offset = Ticks(offset.0.saturating_add(step.0)); } + notes } /// Hard constraints that all generated phrases must satisfy. @@ -77,9 +232,16 @@ pub struct GenerationConstraints { } /// Which rule-based strategy to apply. +/// +/// Every strategy lays its pitches onto the rhythm grids built from +/// `source_rhythms` (quarter notes when none are usable); the four +/// per-bar-independent strategies rotate the grids by bar index, while +/// [`RepeatVariation`](GenerationStrategy::RepeatVariation) deliberately holds +/// the first grid (repetition is its identity). The strategies differ in +/// *pitch* behaviour only. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum GenerationStrategy { - /// Copy a rhythm template from the corpus and substitute pitches from the scale. + /// Ascending scale degrees over the corpus rhythm (requires a template). RhythmCopyPitchSubstitute, /// Build a short motif and transpose it for each bar. MotifTransposeVariation, @@ -115,8 +277,13 @@ pub struct RuleGenerationRequest { pub pitch_material: PitchMaterial, /// Structural constraints. pub constraints: GenerationConstraints, - /// Rhythm templates extracted from the corpus (inner `Vec` = note durations per bar). - pub source_rhythms: Vec>, + /// Rhythm templates extracted from the corpus — the whole palette. Each + /// non-empty template becomes one effective bar grid (empty templates and + /// templates that clamp away are dropped); the per-bar-independent + /// strategies rotate the effective grids by bar index, so a single + /// generation carries the corpus's rhythmic variety across its bars. + /// Empty or all-unusable → the quarter-note fallback grid. + pub source_rhythms: Vec, /// Strategy to apply. pub strategy: GenerationStrategy, } @@ -132,6 +299,15 @@ pub enum GenerationError { BarCountZero, /// `RhythmCopyPitchSubstitute` requires at least one non-empty source rhythm. RhythmTemplateMissing, + /// No pitch could be selected: the palette is empty, or no allowed pitch + /// class falls in `[pitch_lo, pitch_hi]` (from [`ScaleLadder::build`]). + PitchSelection(PitchSelectionError), +} + +impl From for GenerationError { + fn from(e: PitchSelectionError) -> Self { + Self::PitchSelection(e) + } } /// Generates a single candidate phrase using the requested rule-based strategy. @@ -146,7 +322,7 @@ pub fn generate(request: &RuleGenerationRequest) -> Result Result { - strategy_rhythm_copy(c, pm, &request.source_rhythms, &mut prng, bar_duration) + strategy_rhythm_copy(c, &ladder, &grids, &mut prng) } GenerationStrategy::MotifTransposeVariation => { - strategy_motif_transpose(c, pm, &mut prng, bar_duration) + strategy_motif_transpose(c, &ladder, &grids, &mut prng) } GenerationStrategy::ConstrainedRandomWalk => { - strategy_constrained_walk(c, pm, &mut prng, bar_duration) - } - GenerationStrategy::ShuffleMotifs => { - strategy_shuffle_motifs(c, pm, &mut prng, bar_duration) + strategy_constrained_walk(c, &ladder, &grids, &mut prng) } + GenerationStrategy::ShuffleMotifs => strategy_shuffle_motifs(c, &ladder, &grids, &mut prng), GenerationStrategy::RepeatVariation => { - strategy_repeat_variation(c, pm, &mut prng, bar_duration) + strategy_repeat_variation(c, &ladder, &grids, &mut prng) } }; @@ -199,9 +380,10 @@ pub fn generate(request: &RuleGenerationRequest) -> Result`) onto the canonical model. /// -/// Builds `MasterBar`s back-to-back from `bar_duration` and flattens each bar's -/// notes into one `Voice` of `Single` event groups carrying absolute onsets. -/// Tempo and meter live on the master bars (ADR-0003), not on notes. +/// Builds `MasterBar`s back-to-back from `bar_duration` and places each bar's +/// notes at `bar start + note offset` in one `Voice` of `Single` event groups — +/// grid gaps become real silence. Tempo and meter live on the master bars +/// (ADR-0003), not on notes. /// /// Returns [`GenerationError::InvalidConstraints`] when the absolute timeline /// cannot be represented in the `u32` tick space (rather than silently @@ -229,22 +411,20 @@ fn bars_to_score( repeat: RepeatMarker::default(), }); - let mut cursor = bar_start; for note in bar { - let atom = AtomEvent::Note(AtomNote { - absolute_start: cursor, - duration: note.duration, - pitch: note.pitch, - velocity: note.velocity, - marks: NoteMarks::empty(), - position: None, - }); - cursor = cursor - .checked_add(atom.duration()) + let absolute_start = bar_start + .checked_add(note.offset) .map_err(|_| GenerationError::InvalidConstraints)?; event_groups.push(EventGroup { kind: EventGroupKind::Single, - atoms: vec![atom], + atoms: vec![AtomEvent::Note(AtomNote { + absolute_start, + duration: note.duration, + pitch: note.pitch, + velocity: note.velocity, + marks: NoteMarks::empty(), + position: None, + })], technique_spans: Vec::new(), }); } @@ -310,23 +490,42 @@ impl Xorshift64 { // ── helpers ─────────────────────────────────────────────────────────────────── -/// Returns the next degree in the scale, wrapping at `scale_len`. -const fn advance_degree(degree: usize, scale_len: usize) -> usize { - let next = degree.wrapping_add(1); - if next >= scale_len { - 0 - } else { - next - } +/// A degree cursor that walks the full ladder by one rung at a time and +/// **reflects** at the ends instead of wrapping — `… 3 4 3 2 1 0 1 …` — so a +/// traversal never jumps top→bottom (register A/B, 2026-07-12). A one-rung +/// ladder stays put. +struct DegreeCursor { + degree: usize, + ascending: bool, } -/// Returns the next index into a slice of length `len`, wrapping at `len`. -const fn advance_idx(idx: usize, len: usize) -> usize { - let next = idx.wrapping_add(1); - if next >= len { - 0 - } else { - next +impl DegreeCursor { + const fn new(degree: usize) -> Self { + Self { + degree, + ascending: true, + } + } + + /// Advances one rung within `[0, len)`, reversing direction at either end. + fn step(&mut self, len: usize) { + let max = len.saturating_sub(1); + if max == 0 { + return; // one-rung ladder: stationary + } + if self.ascending { + if self.degree >= max { + self.ascending = false; + self.degree = max.saturating_sub(1); + } else { + self.degree = self.degree.saturating_add(1); + } + } else if self.degree == 0 { + self.ascending = true; + self.degree = 1.min(max); + } else { + self.degree = self.degree.saturating_sub(1); + } } } @@ -340,42 +539,36 @@ const fn fit_duration(raw: Ticks, remaining: Ticks) -> Ticks { } // ── strategies ──────────────────────────────────────────────────────────────── +// +// Every strategy writes one pitch per grid slot; the grid carries the rhythm +// (offsets + durations), the strategy carries the pitch logic. The four +// per-bar-independent strategies select their bar's grid via `grid_for_bar` +// (rotation by bar index); `strategy_repeat_variation` holds `grids[0]`. fn strategy_rhythm_copy( c: &GenerationConstraints, - pm: &PitchMaterial, - source_rhythms: &[Vec], + ladder: &ScaleLadder, + grids: &[Vec], prng: &mut Xorshift64, - bar_duration: Ticks, ) -> Vec> { - // Validated non-empty before entering; first() is guaranteed Some. - let template: &[Ticks] = source_rhythms.first().map_or(&[], Vec::as_slice); - - let scale_len = pm.intervals.len(); - let mut degree = prng.next_mod(scale_len); + let len = ladder.len(); + // Reflecting cursor over the full ladder — gradual traversal, never a + // top->bottom modulo wrap (register A/B, 2026-07-12). + let mut cursor = DegreeCursor::new(prng.next_mod(len)); let mut bars = Vec::with_capacity(c.bar_count); - for _ in 0..c.bar_count { - let mut notes = Vec::new(); - let mut remaining = bar_duration; - let mut tidx = 0_usize; - - while remaining > Ticks::ZERO { - let raw = template.get(tidx).copied().unwrap_or(Ticks(480)); - let duration = fit_duration(raw, remaining); - if duration == Ticks::ZERO { - break; - } + for bar_index in 0..c.bar_count { + let grid = grid_for_bar(grids, bar_index); + let mut notes = Vec::with_capacity(grid.len()); + for slot in grid { notes.push(GenNote { - pitch: pm.pitch_at(degree, c.pitch_lo, c.pitch_hi), - duration, + offset: slot.offset, + pitch: ladder.at(cursor.degree), + duration: slot.duration, velocity: Velocity(90), }); - remaining = Ticks(remaining.0.saturating_sub(duration.0)); - degree = advance_degree(degree, scale_len); - tidx = advance_idx(tidx, template.len().max(1)); + cursor.step(len); } - bars.push(notes); } bars @@ -383,49 +576,40 @@ fn strategy_rhythm_copy( fn strategy_motif_transpose( c: &GenerationConstraints, - pm: &PitchMaterial, + ladder: &ScaleLadder, + grids: &[Vec], prng: &mut Xorshift64, - bar_duration: Ticks, ) -> Vec> { const MOTIF_LEN: usize = 4; - // Transpositions in semitones applied cyclically per bar. - const TRANSPOSES: [i8; 7] = [0, 3, 5, 7, -3, -5, -7]; - - let scale_len = pm.intervals.len(); - let start_degree = prng.next_mod(scale_len); - let step = Ticks(c.ticks_per_quarter.0); + // Per-bar climb offsets in ladder *degrees* (scale steps, not semitones): + // transposing along the ladder keeps every note in the pitch-class palette + // and reaches higher registers, where the old semitone shift could leave + // the palette entirely. + const DEGREE_OFFSETS: [usize; 7] = [0, 2, 4, 7, 3, 5, 1]; + let start_degree = prng.next_mod(ladder.len()); let mut bars = Vec::with_capacity(c.bar_count); for bi in 0..c.bar_count { - let transpose = TRANSPOSES - .get(bi.checked_rem(TRANSPOSES.len()).unwrap_or(0)) + let grid = grid_for_bar(grids, bi); + let bar_offset = DEGREE_OFFSETS + .get(bi.checked_rem(DEGREE_OFFSETS.len()).unwrap_or(0)) .copied() - .unwrap_or(0_i8); - let mut notes = Vec::new(); - let mut cursor = Ticks::ZERO; - let mut note_idx = 0_usize; - - while cursor < bar_duration { - let motif_pos = note_idx.checked_rem(MOTIF_LEN).unwrap_or(0); - let degree = (start_degree.wrapping_add(motif_pos)) - .checked_rem(scale_len) - .unwrap_or(0); - let base = pm.pitch_at(degree, c.pitch_lo, c.pitch_hi); - let transposed = apply_transpose(base, transpose, c.pitch_lo, c.pitch_hi); - - let remaining = Ticks(bar_duration.0.saturating_sub(cursor.0)); - let duration = fit_duration(step, remaining); - if duration == Ticks::ZERO { - break; - } + .unwrap_or(0); + let mut notes = Vec::with_capacity(grid.len()); + + for (slot_idx, slot) in grid.iter().enumerate() { + let motif_pos = slot_idx.checked_rem(MOTIF_LEN).unwrap_or(0); + // `ladder.at` clamps a degree past the top to the highest rung. + let degree = start_degree + .saturating_add(bar_offset) + .saturating_add(motif_pos); notes.push(GenNote { - pitch: transposed, - duration, + offset: slot.offset, + pitch: ladder.at(degree), + duration: slot.duration, velocity: Velocity(85), }); - cursor = Ticks(cursor.0.saturating_add(duration.0)); - note_idx = note_idx.wrapping_add(1); } bars.push(notes); @@ -435,35 +619,27 @@ fn strategy_motif_transpose( fn strategy_constrained_walk( c: &GenerationConstraints, - pm: &PitchMaterial, + ladder: &ScaleLadder, + grids: &[Vec], prng: &mut Xorshift64, - bar_duration: Ticks, ) -> Vec> { - let scale_len = pm.intervals.len(); - // Two-octave degree range keeps consecutive leaps ≤ 12 semitones. - let max_degree = scale_len.saturating_mul(2).saturating_sub(1); - let step = Ticks(c.ticks_per_quarter.0); - let mut degree = prng.next_mod(scale_len); + // Walk the whole ladder — consecutive ±1-degree steps are adjacent scale + // tones, so leaps stay small while the full register is reachable. + let max_degree = ladder.len().saturating_sub(1); + let mut degree = prng.next_mod(ladder.len()); let mut bars = Vec::with_capacity(c.bar_count); - for _ in 0..c.bar_count { - let mut notes = Vec::new(); - let mut cursor = Ticks::ZERO; - - while cursor < bar_duration { - let pitch = pm.pitch_at(degree, c.pitch_lo, c.pitch_hi); - let remaining = Ticks(bar_duration.0.saturating_sub(cursor.0)); - let duration = fit_duration(step, remaining); - if duration == Ticks::ZERO { - break; - } + for bar_index in 0..c.bar_count { + let grid = grid_for_bar(grids, bar_index); + let mut notes = Vec::with_capacity(grid.len()); + for slot in grid { notes.push(GenNote { - pitch, - duration, + offset: slot.offset, + pitch: ladder.at(degree), + duration: slot.duration, velocity: Velocity(80), }); - cursor = Ticks(cursor.0.saturating_add(duration.0)); // Walk ±1 degree, bounded to [0, max_degree]. if prng.next_u64() & 1 == 0 { @@ -472,7 +648,6 @@ fn strategy_constrained_walk( degree = degree.saturating_add(1).min(max_degree); } } - bars.push(notes); } bars @@ -480,35 +655,31 @@ fn strategy_constrained_walk( fn strategy_shuffle_motifs( c: &GenerationConstraints, - pm: &PitchMaterial, + ladder: &ScaleLadder, + grids: &[Vec], prng: &mut Xorshift64, - bar_duration: Ticks, ) -> Vec> { - let scale_len = pm.intervals.len(); - let step = Ticks(c.ticks_per_quarter.0); - + // One deterministic per-candidate register window (≤ one octave): drawing + // every note from the whole ladder lost local coherence (register A/B, + // 2026-07-12). The anchor is drawn over the anchor count (not the ladder + // length), so windows are unbiased and variants still cover the full + // ladder while each candidate stays locally coherent. Shuffle-only. + let window = ladder.octave_window(prng.next_mod(ladder.octave_window_count())); + let window_len = window.len(); let mut bars = Vec::with_capacity(c.bar_count); - for _ in 0..c.bar_count { - let mut notes = Vec::new(); - let mut cursor = Ticks::ZERO; - - while cursor < bar_duration { - let degree = prng.next_mod(scale_len); - let pitch = pm.pitch_at(degree, c.pitch_lo, c.pitch_hi); - let remaining = Ticks(bar_duration.0.saturating_sub(cursor.0)); - let duration = fit_duration(step, remaining); - if duration == Ticks::ZERO { - break; - } + for bar_index in 0..c.bar_count { + let grid = grid_for_bar(grids, bar_index); + let mut notes = Vec::with_capacity(grid.len()); + for slot in grid { + let degree = prng.next_mod(window_len); notes.push(GenNote { - pitch, - duration, + offset: slot.offset, + pitch: window.at(degree), + duration: slot.duration, velocity: Velocity(88), }); - cursor = Ticks(cursor.0.saturating_add(duration.0)); } - bars.push(notes); } bars @@ -516,28 +687,26 @@ fn strategy_shuffle_motifs( fn strategy_repeat_variation( c: &GenerationConstraints, - pm: &PitchMaterial, + ladder: &ScaleLadder, + grids: &[Vec], prng: &mut Xorshift64, - bar_duration: Ticks, ) -> Vec> { - let scale_len = pm.intervals.len(); - let step = Ticks(c.ticks_per_quarter.0); - let base_degree = prng.next_mod(scale_len); + let len = ladder.len(); + let base_degree = prng.next_mod(len); - let base_bar = build_ascending_bar(c, pm, base_degree, step, bar_duration); + // Repetition is this strategy's identity (call/response), so it stays on + // the first bar's rhythm rather than rotating templates. + let grid = grid_for_bar(grids, 0); + let base_bar = build_ascending_bar(ladder, base_degree, grid); let mut bars = Vec::with_capacity(c.bar_count); bars.push(base_bar.clone()); - // Variation degree: advance 2 steps from base so the pitch always differs. - let var_degree = { - let d = base_degree.wrapping_add(2); - if d < scale_len { - d - } else { - d.saturating_sub(scale_len) - } - }; - let var_pitch = pm.pitch_at(var_degree, c.pitch_lo, c.pitch_hi); + // The variation replaces the base bar's *last* note. On a dense grid the + // ascending bar climbs (and clamps) far from `base_degree`, so a base-local + // displacement would drop a wide intra-bar interval from the high + // penultimate note. Pick the variation local to the actual bar endpoint + // instead (register A/B, 2026-07-12). + let var_pitch = ladder.at(variation_degree(base_degree, grid.len(), len)); for _ in 1..c.bar_count { let mut varied = base_bar.clone(); @@ -550,49 +719,85 @@ fn strategy_repeat_variation( bars } -/// Builds one bar of ascending scale-degree notes at `step` duration each. +/// The variation degree for a repeat bar: local to the base bar's *endpoint*. +/// +/// The base bar ascends and clamps, so its last note sits at +/// `min(base + grid_len − 1, max)` and its penultimate at +/// `min(base + grid_len − 2, max)`. The historical `base ± 2` displacement is +/// kept when it is already within two rungs of that penultimate (the common +/// short-grid case is unchanged); otherwise the variation is chosen from the +/// rungs around the penultimate — nearest to the old preferred degree, never +/// equal to the final degree when an alternative exists, deterministic. +fn variation_degree(base_degree: usize, grid_len: usize, len: usize) -> usize { + let max_degree = len.saturating_sub(1); + let last_degree = base_degree + .saturating_add(grid_len.saturating_sub(1)) + .min(max_degree); + let penult_degree = if grid_len >= 2 { + base_degree + .saturating_add(grid_len.saturating_sub(2)) + .min(max_degree) + } else { + last_degree + }; + + // The historical base-local preference (two rungs, reflected near the top). + let preferred = if base_degree.saturating_add(2) <= max_degree { + base_degree.saturating_add(2) + } else if base_degree >= 2 { + base_degree.saturating_sub(2) + } else if base_degree.saturating_add(1) <= max_degree { + base_degree.saturating_add(1) + } else { + base_degree.saturating_sub(1) + }; + + // Keep the preferred degree when it is already endpoint-local and (for a + // multi-rung ladder) differs from the final degree — the safe common case. + if preferred.abs_diff(penult_degree) <= 2 && (len <= 1 || preferred != last_degree) { + return preferred; + } + + // Otherwise choose a rung around the penultimate: in bounds, not the final + // degree (when an alternative exists), nearest the old preferred degree, + // ties broken by the lower degree. + [ + penult_degree.wrapping_sub(2), + penult_degree.wrapping_sub(1), + penult_degree.wrapping_add(1), + penult_degree.wrapping_add(2), + ] + .into_iter() + .filter(|&d| d <= max_degree) + .filter(|&d| len <= 1 || d != last_degree) + .min_by_key(|&d| (d.abs_diff(preferred), d)) + .unwrap_or(preferred) +} + +/// Builds one bar of ascending ladder-degree notes over the grid. fn build_ascending_bar( - c: &GenerationConstraints, - pm: &PitchMaterial, + ladder: &ScaleLadder, start_degree: usize, - step: Ticks, - bar_duration: Ticks, + grid: &[TemplateNote], ) -> Vec { - let scale_len = pm.intervals.len(); - // Two-octave ceiling mirrors the walk strategy. - let max_degree = scale_len.saturating_mul(2).saturating_sub(1); - let mut notes = Vec::new(); - let mut cursor = Ticks::ZERO; + // Climb the whole ladder; `ladder.at` clamps at the top rung. + let max_degree = ladder.len().saturating_sub(1); + let mut notes = Vec::with_capacity(grid.len()); let mut degree = start_degree; - while cursor < bar_duration { - let pitch = pm.pitch_at(degree, c.pitch_lo, c.pitch_hi); - let remaining = Ticks(bar_duration.0.saturating_sub(cursor.0)); - let duration = fit_duration(step, remaining); - if duration == Ticks::ZERO { - break; - } + for slot in grid { notes.push(GenNote { - pitch, - duration, + offset: slot.offset, + pitch: ladder.at(degree), + duration: slot.duration, velocity: Velocity(92), }); - cursor = Ticks(cursor.0.saturating_add(duration.0)); degree = degree.saturating_add(1).min(max_degree); } notes } -/// Transposes a pitch by `semitones`, clamping the result to `[lo, hi]`. -fn apply_transpose(pitch: Pitch, semitones: i8, lo: Pitch, hi: Pitch) -> Pitch { - let raw = i16::from(pitch.0).saturating_add(i16::from(semitones)); - let actual_lo = i16::from(lo.0.min(hi.0)); - let actual_hi = i16::from(lo.0.max(hi.0).min(127)); - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - Pitch(raw.clamp(actual_lo, actual_hi) as u8) -} - /// Computes the duration of one bar for a PPQN resolution and meter. pub fn bar_duration_ticks( time_signature: TimeSignature, @@ -623,7 +828,9 @@ pub fn bar_duration_ticks( #[cfg(test)] mod tests { - use super::bar_duration_ticks; + #![allow(clippy::expect_used)] + + use super::{bar_duration_ticks, bar_grids, RhythmTemplate, TemplateNote}; use crate::event::{Ticks, TimeSignature, ValidationError}; #[test] @@ -673,4 +880,56 @@ mod tests { Err(ValidationError::InvalidTimeSignatureDenominator { value: 3 }), ); } + + #[test] + fn quarter_fallback_grid_clamps_the_last_slot() { + // 7/8 at 480 PPQN: 1680-tick bar → three quarters and one eighth. + // No template → a single quarter-grid fallback. + let grids = bar_grids(&[], Ticks(1680), Ticks(480)); + assert_eq!(grids.len(), 1, "no template yields one fallback grid"); + let grid = grids.first().expect("one fallback grid"); + let expected: Vec = vec![ + TemplateNote { + offset: Ticks(0), + duration: Ticks(480), + }, + TemplateNote { + offset: Ticks(480), + duration: Ticks(480), + }, + TemplateNote { + offset: Ticks(960), + duration: Ticks(480), + }, + TemplateNote { + offset: Ticks(1440), + duration: Ticks(240), + }, + ]; + assert_eq!(grid, &expected); + } + + #[test] + fn unsorted_template_grid_sorts_by_offset() { + let template = RhythmTemplate { + notes: vec![ + TemplateNote { + offset: Ticks(960), + duration: Ticks(240), + }, + TemplateNote { + offset: Ticks(0), + duration: Ticks(240), + }, + ], + }; + let grids = bar_grids(&[template], Ticks(1920), Ticks(480)); + let offsets: Vec = grids + .first() + .expect("one grid") + .iter() + .map(|n| n.offset.0) + .collect(); + assert_eq!(offsets, vec![0, 960], "onsets must come back sorted"); + } } diff --git a/core/src/gp.rs b/core/src/gp.rs index 7680d3d1..d9e2a223 100644 --- a/core/src/gp.rs +++ b/core/src/gp.rs @@ -35,8 +35,8 @@ use crate::{ }, slice::TickRange, }; -use guitarpro::model::key_signature::Duration as GpDuration; -use guitarpro::model::note::NoteEffect as GpNoteEffect; +use guitarpro::model::legacy::key_signature::Duration as GpDuration; +use guitarpro::model::legacy::note::NoteEffect as GpNoteEffect; use std::collections::HashMap; /// Guitar Pro internal PPQN (pulses per quarter note). @@ -471,11 +471,18 @@ fn append_beat( } } guitarpro::NoteType::Tie => { - if extend_tie(note, dur_ticks, ctx.zero_indexed, acc) { + if extend_tie(note, start, dur_ticks, ctx.zero_indexed, acc) { continued = true; } } - guitarpro::NoteType::Rest | guitarpro::NoteType::Unknown(_) => { + // A per-string entry whose type flag is absent keeps the parser's + // default kind `Rest` and carries no fret: it encodes a *silent + // string* in an otherwise sounding beat — normal GP encoding, not + // lost content. (A whole-rest beat is `BeatStatus::Rest`, handled + // above; an all-Rest-notes beat still falls through to the + // gapless rest group below.) + guitarpro::NoteType::Rest => {} + guitarpro::NoteType::Unknown(_) => { acc.loss.add(ImportWarning::Other(format!( "GP note kind {kind:?} not fully supported; skipped", kind = note.kind, @@ -506,14 +513,17 @@ fn append_beat( /// Continues a tied note onto the most recent note on its string by extending /// that note's duration. Returns `true` when a held note was found; otherwise -/// records a loss (an orphan tie) and returns `false`. +/// records a loss naming the string and the tie's start tick (so corpus scans +/// can bucket orphan ties by cause) and returns `false`. fn extend_tie( note: &guitarpro::Note, + start: Ticks, dur_ticks: u32, zero_indexed: bool, acc: &mut VoiceAccum<'_>, ) -> bool { - let location = u8::try_from(gp_one_indexed_string(note.string, zero_indexed)) + let string = gp_one_indexed_string(note.string, zero_indexed); + let location = u8::try_from(string) .ok() .and_then(|string| acc.held.get(&string).copied()); if let Some((group_index, atom_index)) = location { @@ -526,9 +536,10 @@ fn extend_tie( return true; } } - acc.loss.add(ImportWarning::Other( - "GP tie has no preceding note on its string; skipped".to_owned(), - )); + acc.loss.add(ImportWarning::Other(format!( + "GP tie has no preceding note on its string (string {string}, tick {tick}); skipped", + tick = start.0, + ))); false } @@ -696,8 +707,8 @@ fn gp_duration_ticks(dur: &GpDuration) -> u32 { )] mod tests { use super::*; - use guitarpro::model::effects::BendEffect; - use guitarpro::model::key_signature::Duration as GpDurationTest; + use guitarpro::model::legacy::effects::BendEffect; + use guitarpro::model::legacy::key_signature::Duration as GpDurationTest; use std::collections::HashMap; #[test] @@ -1233,6 +1244,158 @@ mod tests { assert!(note.marks.contains(NoteMark::Tap)); } + #[test] + fn silent_string_rest_note_is_not_a_loss() { + // A per-string note entry whose type flag (0x20) is absent keeps the + // parser's default kind `Rest` and carries no fret: it encodes a + // *silent string* in an otherwise sounding beat — normal GP encoding, + // not lost content. It must be skipped without an ImportWarning (the + // 2026-07 corpus scan mis-read 138 of these as real losses). + let strings = vec![(1_i8, 64_i8), (2, 59), (3, 55), (4, 50), (5, 45), (6, 40)]; + let beat = guitarpro::Beat { + notes: vec![ + guitarpro::Note { + value: 5, + string: 3, + kind: guitarpro::NoteType::Normal, + ..Default::default() + }, + guitarpro::Note { + string: 4, + kind: guitarpro::NoteType::Rest, + ..Default::default() + }, + ], + status: guitarpro::BeatStatus::Normal, + ..Default::default() + }; + + let mut groups: Vec = Vec::new(); + let mut held: HashMap = HashMap::new(); + let mut loss = LossReport::new(); + let mut acc = VoiceAccum { + groups: &mut groups, + held: &mut held, + loss: &mut loss, + }; + append_beat( + &beat, + 0, + 480, + StringCtx { + strings: &strings, + zero_indexed: false, + }, + &mut acc, + ); + + assert_eq!(groups.len(), 1, "the sounding note still imports"); + assert_eq!(groups[0].atoms.len(), 1); + assert!( + matches!(groups[0].atoms[0], AtomEvent::Note(_)), + "the struck string is a note" + ); + assert!( + loss.is_clean(), + "a silent string is not a loss: {:?}", + loss.warnings + ); + } + + #[test] + fn unknown_note_kind_still_records_a_loss() { + // The Rest fix must not swallow genuinely unsupported kinds: an + // Unknown(_) note stays a reported loss. Characterization of existing + // behaviour (no new API), committed alongside the red test above as + // its guard rail. + let strings = vec![(1_i8, 64_i8), (2, 59), (3, 55), (4, 50), (5, 45), (6, 40)]; + let beat = guitarpro::Beat { + notes: vec![guitarpro::Note { + string: 3, + kind: guitarpro::NoteType::Unknown(9), + ..Default::default() + }], + status: guitarpro::BeatStatus::Normal, + ..Default::default() + }; + + let mut groups: Vec = Vec::new(); + let mut held: HashMap = HashMap::new(); + let mut loss = LossReport::new(); + let mut acc = VoiceAccum { + groups: &mut groups, + held: &mut held, + loss: &mut loss, + }; + append_beat( + &beat, + 0, + 480, + StringCtx { + strings: &strings, + zero_indexed: false, + }, + &mut acc, + ); + + assert!(!loss.is_clean(), "an unknown note kind is a real loss"); + assert_eq!(groups.len(), 1, "the beat still occupies its time..."); + assert!( + matches!(groups[0].atoms[0], AtomEvent::Rest(_)), + "...as a rest, so the timeline stays gapless" + ); + } + + #[test] + fn orphan_tie_loss_names_the_string_and_start() { + // An orphan tie (no held note on its string) is a diagnosable event: + // the loss must carry the string number and the beat's start tick so + // corpus-side scans can localise the cause (2026-07 scan: 172 + // occurrences with no context to bucket them by). + let strings = vec![(1_i8, 64_i8), (2, 59), (3, 55), (4, 50), (5, 45), (6, 40)]; + let tie_only = guitarpro::Beat { + notes: vec![guitarpro::Note { + value: 2, + string: 3, + kind: guitarpro::NoteType::Tie, + ..Default::default() + }], + status: guitarpro::BeatStatus::Normal, + ..Default::default() + }; + + let mut groups: Vec = Vec::new(); + let mut held: HashMap = HashMap::new(); + let mut loss = LossReport::new(); + let mut acc = VoiceAccum { + groups: &mut groups, + held: &mut held, + loss: &mut loss, + }; + append_beat( + &tie_only, + 1920, + 480, + StringCtx { + strings: &strings, + zero_indexed: false, + }, + &mut acc, + ); + + let warning = loss + .warnings + .first() + .expect("an orphan tie is still a reported loss"); + let ImportWarning::Other(message) = warning else { + panic!("orphan ties report as Other, got {warning:?}"); + }; + assert!( + message.contains("string 3") && message.contains("tick 1920"), + "the loss names where the tie hangs: {message}" + ); + } + #[test] fn tied_note_extends_previous_note_duration() { // A tie (NoteType::Tie) continues the previous note on the same string: diff --git a/core/src/lib.rs b/core/src/lib.rs index cfd1a255..d8bb3c4b 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -21,6 +21,8 @@ pub mod harmony; pub mod import; pub mod midi; pub mod novelty; +pub mod pitch; +pub mod rerank; pub mod score; pub mod scoring; pub mod similarity; @@ -29,4 +31,5 @@ pub mod split; pub mod structure; pub mod syncopation; pub mod technique; +pub mod tonal; pub mod unfold; diff --git a/core/src/pitch.rs b/core/src/pitch.rs new file mode 100644 index 00000000..8d09caa5 --- /dev/null +++ b/core/src/pitch.rs @@ -0,0 +1,334 @@ +//! Shared pitch-selection primitives for the generator and the complement +//! arranger (register increment, 2026-07-12). +//! +//! One degree→pitch mapper, not two: a [`ScaleLadder`] is the ascending list +//! of every pitch in a [`PitchRange`] whose pitch class is in a +//! [`PitchClassSet`], and a linear *degree* indexes into it. This replaces the +//! octave-walking `PitchMaterial::pitch_at`, whose degree window pinned some +//! strategies to a single octave above the material's anchor, and the private +//! `band_scale_ladder` in `complement` (which now delegates here). +//! +//! The anchor pitch of the source material contributes only its *pitch class* +//! to the palette — it is **not** a tonal center. Tonal-center inference is a +//! separate, later increment; until then no code treats the input's minimum +//! pitch as a tonic. + +use crate::event::Pitch; + +/// An inclusive MIDI pitch range, normalised so `lo <= hi` and both are valid +/// MIDI notes (`0..=127`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PitchRange { + /// Inclusive lower bound. + pub lo: Pitch, + /// Inclusive upper bound. + pub hi: Pitch, +} + +impl PitchRange { + /// Builds a range from two bounds in any order, clamped to valid MIDI. + #[must_use] + pub fn new(a: Pitch, b: Pitch) -> Self { + let lo = a.0.min(b.0); + let hi = a.0.max(b.0).min(127); + Self { + lo: Pitch(lo.min(hi)), + hi: Pitch(hi), + } + } +} + +/// A set of pitch classes (`0..=11`), sorted and deduplicated. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct PitchClassSet { + classes: Vec, +} + +impl PitchClassSet { + /// Builds a class set from arbitrary semitone values, folded to `0..=11`. + #[must_use] + pub fn new(classes: impl IntoIterator) -> Self { + let mut classes: Vec = classes.into_iter().map(|c| c % 12).collect(); + classes.sort_unstable(); + classes.dedup(); + Self { classes } + } + + /// `true` when the set holds no classes. + #[must_use] + pub fn is_empty(&self) -> bool { + self.classes.is_empty() + } + + /// `true` when `pitch`'s class is in the set. + #[must_use] + pub fn contains_pitch(&self, pitch: Pitch) -> bool { + self.classes.contains(&(pitch.0 % 12)) + } + + /// The classes, ascending. + #[must_use] + pub fn classes(&self) -> &[u8] { + &self.classes + } +} + +/// Why a [`ScaleLadder`] cannot be built: the palette selects no pitch, so no +/// degree can resolve in-class. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PitchSelectionError { + /// The [`PitchClassSet`] is empty — no classes to select. + EmptyPitchClassSet, + /// The palette is non-empty, but no allowed pitch falls in the range. + NoAllowedPitchInRange, +} + +/// The ascending ladder of every pitch in a [`PitchRange`] whose class is in a +/// [`PitchClassSet`]. +/// +/// Never empty by construction: [`build`](ScaleLadder::build) returns +/// [`PitchSelectionError`] rather than fall back to an out-of-palette pitch, so +/// every rung — and every degree — is guaranteed in-class. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScaleLadder { + pitches: Vec, +} + +impl ScaleLadder { + /// Builds the ladder over `range` from the `classes` palette, or reports + /// why no in-class pitch is available (empty palette, or none in range). + /// + /// # Errors + /// [`PitchSelectionError::EmptyPitchClassSet`] when `classes` is empty; + /// [`PitchSelectionError::NoAllowedPitchInRange`] when the palette is + /// non-empty but no allowed pitch falls in `range`. + pub fn build(range: &PitchRange, classes: &PitchClassSet) -> Result { + if classes.is_empty() { + return Err(PitchSelectionError::EmptyPitchClassSet); + } + let pitches: Vec = (range.lo.0..=range.hi.0) + .map(Pitch) + .filter(|&p| classes.contains_pitch(p)) + .collect(); + if pitches.is_empty() { + return Err(PitchSelectionError::NoAllowedPitchInRange); + } + Ok(Self { pitches }) + } + + /// Number of rungs (always ≥ 1). + #[must_use] + pub fn len(&self) -> usize { + self.pitches.len() + } + + /// Always `false` — the ladder is never empty; present for lint parity. + #[must_use] + pub fn is_empty(&self) -> bool { + self.pitches.is_empty() + } + + /// The pitch at `degree`, **clamped** to the ladder ends: a degree past the + /// top lands on the highest rung (it does not wrap or leave the range). + #[must_use] + pub fn at(&self, degree: usize) -> Pitch { + let idx = degree.min(self.pitches.len().saturating_sub(1)); + // `idx < len` and the ladder is non-empty, so `get` is always `Some`; + // the fallback is unreachable. + self.pitches.get(idx).copied().unwrap_or(Pitch(0)) + } + + /// The rungs, ascending. + #[must_use] + pub fn pitches(&self) -> &[Pitch] { + &self.pitches + } + + /// The number of window anchors: rungs that leave a full octave above them + /// (`≤ top − 12`), floored at 1. When the ladder is narrower than an octave + /// only the bottom rung anchors, and the window is then the whole ladder. + /// + /// Callers pick an anchor with `next_mod` over this count, so + /// [`octave_window`](ScaleLadder::octave_window) is fed a valid index + /// directly — no double modulo, no low-anchor bias. + #[must_use] + pub fn octave_window_count(&self) -> usize { + const SPAN: u8 = 12; + let anchor_ceiling = self.pitches.last().map_or(0, |p| p.0).saturating_sub(SPAN); + self.pitches + .iter() + .take_while(|p| p.0 <= anchor_ceiling) + .count() + .max(1) + } + + /// A contiguous window of the ladder spanning at most one octave, anchored + /// at rung `anchor_index`. + /// + /// The parameter is an **anchor index**, not an arbitrary selector: valid + /// indices are `0` up to [`octave_window_count`](ScaleLadder::octave_window_count) + /// exclusive, and an index at or past the count wraps once. Never empty. + #[must_use] + pub fn octave_window(&self, anchor_index: usize) -> LadderWindow<'_> { + const SPAN: u8 = 12; + let top = self.pitches.last().map_or(0, |p| p.0); + let anchor = anchor_index + .checked_rem(self.octave_window_count()) + .unwrap_or(0); + let anchor_pitch = self.pitches.get(anchor).map_or(top, |p| p.0); + let window_ceiling = anchor_pitch.saturating_add(SPAN); + let end = self + .pitches + .iter() + .rposition(|p| p.0 <= window_ceiling) + .unwrap_or(anchor); + LadderWindow { + ladder: self, + start: anchor, + len: end.saturating_sub(anchor).saturating_add(1), + } + } +} + +/// A contiguous, at-most-one-octave slice of a [`ScaleLadder`]. +/// +/// The local register one candidate stays within. The full ladder remains the +/// source of reachability; the window is the locally-coherent subset. +#[derive(Debug, Clone, Copy)] +pub struct LadderWindow<'a> { + ladder: &'a ScaleLadder, + start: usize, + len: usize, +} + +impl LadderWindow<'_> { + /// Number of rungs in the window (always ≥ 1). + #[must_use] + pub const fn len(&self) -> usize { + self.len + } + + /// Always `false` — a window is never empty; present for lint parity. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.len == 0 + } + + /// The pitch at window-relative `degree`, clamped to the window ends. + #[must_use] + pub fn at(&self, degree: usize) -> Pitch { + let within = degree.min(self.len.saturating_sub(1)); + self.ladder.at(self.start.saturating_add(within)) + } + + /// The window's rungs, ascending. + #[must_use] + pub fn pitches(&self) -> &[Pitch] { + let end = self.start.saturating_add(self.len).min(self.ladder.len()); + self.ladder.pitches().get(self.start..end).unwrap_or(&[]) + } +} + +/// Diagnostic register statistics of a pitch line. +/// +/// For tests and the A/B harness (arbiter). **Purely observational**: never +/// wired into [`rerank_weights_v1`](crate::rerank::rerank_weights_v1); +/// repairing candidate generation is separate from ranking. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RegisterStats { + /// Mean absolute interval between successive notes, in semitones. + pub mean_abs_interval: f64, + /// Largest absolute interval between successive notes, in semitones. + pub max_abs_interval: u8, + /// Share of successive intervals **strictly greater** than an octave + /// (> 12 semitones). + pub over_octave_share: f64, + /// Share of successive intervals that are **exactly** an octave + /// (== 12 semitones). + pub exact_octave_share: f64, + /// Share of successive intervals **at least** an octave (≥ 12 semitones) — + /// the sum of the exact and over-octave shares. + pub at_least_octave_share: f64, + /// Population standard deviation of the pitches. + pub pitch_stddev: f64, +} + +impl RegisterStats { + /// Measures the register shape of `pitches` (in program order). An empty or + /// single-note line reports zeros. + #[must_use] + pub fn measure(pitches: &[u8]) -> Self { + let intervals: Vec = pitches + .windows(2) + .filter_map(|w| match w { + [a, b] => Some(u16::from((*a).abs_diff(*b))), + _ => None, + }) + .collect(); + if intervals.is_empty() { + return Self { + mean_abs_interval: 0.0, + max_abs_interval: 0, + over_octave_share: 0.0, + exact_octave_share: 0.0, + at_least_octave_share: 0.0, + pitch_stddev: stddev(pitches), + }; + } + #[allow(clippy::cast_precision_loss)] // counts are tiny + let n = intervals.len() as f64; + let sum: u32 = intervals.iter().map(|&i| u32::from(i)).sum(); + let max = intervals.iter().copied().max().unwrap_or(0); + let over = intervals.iter().filter(|&&i| i > 12).count(); + let exact = intervals.iter().filter(|&&i| i == 12).count(); + #[allow(clippy::cast_precision_loss)] // counts are tiny + let share = |count: usize| count as f64 / n; + #[allow(clippy::cast_precision_loss)] + let mean = f64::from(sum) / n; + #[allow(clippy::cast_possible_truncation)] // ≤ 127 by MIDI range + Self { + mean_abs_interval: mean, + max_abs_interval: max.min(255) as u8, + over_octave_share: share(over), + exact_octave_share: share(exact), + at_least_octave_share: share(over.saturating_add(exact)), + pitch_stddev: stddev(pitches), + } + } +} + +/// Population standard deviation of a pitch line (0 for < 2 notes). +fn stddev(pitches: &[u8]) -> f64 { + if pitches.len() < 2 { + return 0.0; + } + #[allow(clippy::cast_precision_loss)] // small counts / MIDI range + let n = pitches.len() as f64; + let mean = pitches.iter().map(|&p| f64::from(p)).sum::() / n; + let var = pitches + .iter() + .map(|&p| (f64::from(p) - mean).powi(2)) + .sum::() + / n; + var.sqrt() +} + +#[cfg(test)] +mod tests { + #![allow(clippy::indexing_slicing)] + + use super::{PitchClassSet, PitchRange, ScaleLadder}; + use crate::event::Pitch; + + #[test] + fn ladder_at_clamps_past_the_top() { + let classes = PitchClassSet::new([0, 7]); // C, G + let ladder = + ScaleLadder::build(&PitchRange::new(Pitch(48), Pitch(60)), &classes).expect("in-class"); + // C3(48) G3(55) C4(60) + assert_eq!(ladder.pitches(), &[Pitch(48), Pitch(55), Pitch(60)]); + assert_eq!(ladder.at(0), Pitch(48)); + assert_eq!(ladder.at(2), Pitch(60)); + assert_eq!(ladder.at(99), Pitch(60), "clamps to the top rung"); + } +} diff --git a/core/src/rerank.rs b/core/src/rerank.rs new file mode 100644 index 00000000..4c09b35d --- /dev/null +++ b/core/src/rerank.rs @@ -0,0 +1,257 @@ +//! Candidate set + explainable rerank over the S6 strategies (ADR-0017; +//! melodic-closure note §7.2/§7.3). +//! +//! S6 [`generate`] produces exactly one candidate +//! from one strategy; the S6 stage doc promises a *set*, and the ADR-0017 +//! scoring vocabulary exists precisely to rank one. This module is that seam, +//! as two pure functions: +//! +//! - [`generate_candidate_set`] fans a base request out over **every** S6 +//! strategy × `variants_per_strategy` seed variants. Variant seeds are +//! derived from the base seed with a `SplitMix64` mix over +//! `(strategy index, variant index)` — deterministic (SPEC §6) and +//! independent of whether gesture carving is on, so gestured and plain runs +//! of the same request pair up seed-for-seed. `RhythmCopyPitchSubstitute` +//! is skipped (not an error) when no usable rhythm template exists; +//! otherwise every candidate receives the whole template palette, which +//! [`generate`] rotates per bar — so a multi-template corpus is audible +//! *within* each candidate, and variants of one strategy differ by seed +//! (the pitch line), not by template. With a [`GestureControl`] the +//! candidates are carved through the S6 gesture compiler +//! ([`generate_gestured`], research note §3.5) instead of staying +//! wall-to-wall. +//! - [`rerank_candidates`] scores each candidate on the four closure axes +//! ([`closure_axes`]) plus the two novelty axes ([`novelty_axes`] against +//! caller-supplied reference scores) under a caller-supplied +//! [`WeightPolicy`], and returns [`Scored`] envelopes in rank order +//! (aggregate descending, ties by candidate index — [`rank_indices`]). +//! Weights are data (ADR-0017 §3): [`rerank_weights_v1`] is the untuned +//! uniform baseline, and rejection thresholds (e.g. a novelty cut) remain +//! the caller's policy. + +use crate::closure::{closure_axes, ClosureError}; +use crate::generate::{ + generate, GenerationConstraints, GenerationError, GenerationSeed, GenerationStrategy, + PitchMaterial, RhythmTemplate, RuleGenerationRequest, +}; +use crate::gesture::{generate_gestured, GestureControl, GestureGenError}; +use crate::novelty::{measure_novelty, novelty_axes, NoveltyError}; +use crate::score::Score; +use crate::scoring::{rank_indices, Axes, Scored, WeightPolicy}; + +/// The rerank axes, in canonical order: the four closure axes followed by the +/// two novelty axes (ADR-0017). +pub const RERANK_AXIS_LABELS: [&str; 6] = [ + "internal_continuity", + "ending_stability", + "final_lengthening", + "gap_fill", + "quote_novelty", + "ngram_novelty", +]; + +/// The baseline rerank weight policy (`generation_rerank` v1): uniform over +/// the six axes. +/// +/// Untuned by design — weights are data the feedback layer (S9) learns +/// (ADR-0017 §3), and the swancore-specific weighting awaits corpus +/// calibration. +#[must_use] +pub fn rerank_weights_v1() -> WeightPolicy { + WeightPolicy::uniform("generation_rerank", 1, &RERANK_AXIS_LABELS) +} + +/// Every S6 strategy, in declaration order — the fan-out axis of the set. +const SET_STRATEGIES: [GenerationStrategy; 5] = [ + GenerationStrategy::RhythmCopyPitchSubstitute, + GenerationStrategy::MotifTransposeVariation, + GenerationStrategy::ConstrainedRandomWalk, + GenerationStrategy::ShuffleMotifs, + GenerationStrategy::RepeatVariation, +]; + +/// Everything a candidate-set pass needs: the S6 request fields minus the +/// single strategy, plus the fan-out width and the optional gesture ask. +#[derive(Debug, Clone)] +pub struct SetRequest { + /// Base deterministic seed; variant seeds are derived from it. + pub seed: GenerationSeed, + /// Scale to draw pitches from. + pub pitch_material: PitchMaterial, + /// Structural constraints applied to every candidate. + pub constraints: GenerationConstraints, + /// Rhythm templates (one bar of placed notes each), typically extracted + /// from corpus chunks. Empty templates are ignored; with none usable, + /// `RhythmCopyPitchSubstitute` is skipped. + pub source_rhythms: Vec, + /// Seed variants generated per strategy (must be ≥ 1). + pub variants_per_strategy: usize, + /// When set, every candidate is carved through the gesture compiler. + pub gesture: Option, +} + +/// One candidate of the set, with full provenance. +#[derive(Debug, Clone)] +pub struct SetCandidate { + /// The generated (and possibly gesture-carved) score. + pub score: Score, + /// Strategy that produced this candidate. + pub strategy: GenerationStrategy, + /// Derived variant seed this candidate ran under. + pub seed: GenerationSeed, + /// The gesture ask the candidate was carved against, when one was. + pub gesture: Option, +} + +/// Errors the candidate-set builder can emit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetError { + /// `variants_per_strategy` is zero. + VariantCountZero, + /// The underlying S6 generator rejected a derived request. + Generation(GenerationError), + /// The gesture compiler rejected the control or a derived request. + Gesture(GestureGenError), +} + +impl From for SetError { + fn from(e: GenerationError) -> Self { + Self::Generation(e) + } +} + +impl From for SetError { + fn from(e: GestureGenError) -> Self { + Self::Gesture(e) + } +} + +/// Generates the full candidate set for `request`. +/// +/// Fully deterministic: the same request always produces the same set, and +/// variant seeds do not depend on the gesture ask (so gestured and plain runs +/// pair up seed-for-seed). +pub fn generate_candidate_set(request: &SetRequest) -> Result, SetError> { + if request.variants_per_strategy == 0 { + return Err(SetError::VariantCountZero); + } + + let templates: Vec<&RhythmTemplate> = request + .source_rhythms + .iter() + .filter(|t| !t.notes.is_empty()) + .collect(); + + let mut set = Vec::new(); + for (si, strategy) in SET_STRATEGIES.iter().enumerate() { + let needs_template = *strategy == GenerationStrategy::RhythmCopyPitchSubstitute; + if needs_template && templates.is_empty() { + continue; + } + // Every candidate hears the whole template palette: `generate` rotates + // it per bar, so a single candidate carries the corpus's rhythmic + // variety across its bars (not just across variants). Variants then + // differ by seed — the pitch line — while sharing the rhythm sequence. + // Without templates the strategies fall back to the quarter grid. + let source_rhythms: Vec = templates.iter().map(|t| (*t).clone()).collect(); + for variant in 0..request.variants_per_strategy { + let seed = GenerationSeed(derive_seed(request.seed.0, si, variant)); + let sub = RuleGenerationRequest { + seed, + pitch_material: request.pitch_material.clone(), + constraints: request.constraints, + source_rhythms: source_rhythms.clone(), + strategy: *strategy, + }; + let (score, gesture) = match request.gesture { + Some(control) => (generate_gestured(&sub, control)?.score, Some(control)), + None => (generate(&sub)?.score, None), + }; + set.push(SetCandidate { + score, + strategy: *strategy, + seed, + gesture, + }); + } + } + Ok(set) +} + +/// Scores `candidates` on the six rerank axes and returns them as [`Scored`] +/// envelopes in rank order (aggregate descending, ties by candidate index). +/// +/// Novelty is measured against `references` (corpus chunk scores); an empty +/// reference set reads as fully novel. A candidate whose first track cannot +/// be measured is dropped from the ranking — unreachable for S6/gestured +/// output, which always keeps at least one note, but stated rather than +/// silently mis-scored. +#[must_use] +pub fn rerank_candidates( + candidates: Vec, + material: &PitchMaterial, + references: &[Score], + policy: &WeightPolicy, +) -> Vec> { + let scored: Vec> = candidates + .into_iter() + .filter_map(|candidate| { + let axes = candidate_axes(&candidate.score, material, references).ok()?; + let seed = candidate.seed.0; + Some(Scored::new(candidate, axes, policy, Some(seed))) + }) + .collect(); + + let order = rank_indices(&scored); + let mut slots: Vec>> = scored.into_iter().map(Some).collect(); + order + .into_iter() + .filter_map(|i| slots.get_mut(i).and_then(Option::take)) + .collect() +} + +/// The six rerank axes of a candidate score's first track: closure then +/// novelty, in [`RERANK_AXIS_LABELS`] order. +fn candidate_axes( + score: &Score, + material: &PitchMaterial, + references: &[Score], +) -> Result { + let closure = closure_axes(score, 0, material)?; + let novelty = novelty_axes(&measure_novelty(score, 0, references)?); + Ok(Axes::new( + closure.iter().chain(novelty.iter()).copied().collect(), + )) +} + +/// Internal measurement failure (candidate dropped from the ranking). +enum MeasureError { + Closure, + Novelty, +} + +impl From for MeasureError { + fn from(_: ClosureError) -> Self { + Self::Closure + } +} + +impl From for MeasureError { + fn from(_: NoveltyError) -> Self { + Self::Novelty + } +} + +/// `SplitMix64` finalizer — mixes `(base, strategy, variant)` into a variant +/// seed with good avalanche, so neighbouring variants do not correlate. +fn derive_seed(base: u64, strategy_index: usize, variant: usize) -> u64 { + let si = u64::try_from(strategy_index).unwrap_or(u64::MAX); + let vi = u64::try_from(variant).unwrap_or(u64::MAX); + let mut z = base + .wrapping_add(si.wrapping_mul(0x9E37_79B9_7F4A_7C15)) + .wrapping_add(vi.wrapping_mul(0xBF58_476D_1CE4_E5B9)); + z = z.wrapping_add(0x9E37_79B9_7F4A_7C15); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} diff --git a/core/src/structure.rs b/core/src/structure.rs index cf359c5b..3b6d0189 100644 --- a/core/src/structure.rs +++ b/core/src/structure.rs @@ -53,7 +53,7 @@ use crate::event::{Pitch, SpanTechnique, Ticks, Tuning}; use crate::fretboard::{measure_playability, FingeringWeights, STANDARD_MAX_FRET}; use crate::generate::{ bar_duration_ticks, generate, GenerationConstraints, GenerationError, GenerationSeed, - GenerationStrategy, PitchMaterial, RuleGenerationRequest, + GenerationStrategy, PitchMaterial, RhythmTemplate, RuleGenerationRequest, }; use crate::score::{ AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score, @@ -660,7 +660,7 @@ pub struct StructuredRequest { /// Structural constraints; `bar_count` is the **target span** in bars. pub constraints: GenerationConstraints, /// Rhythm templates for strategies that need them. - pub source_rhythms: Vec>, + pub source_rhythms: Vec, /// S6 strategy used to generate the base motif. pub strategy: GenerationStrategy, /// The structure control to compile. diff --git a/core/src/tonal.rs b/core/src/tonal.rs new file mode 100644 index 00000000..a25a02de --- /dev/null +++ b/core/src/tonal.rs @@ -0,0 +1,373 @@ +//! Shared pure-core tonal *evidence* and *inference* — the tonal-context layer +//! (Phase 1). +//! +//! Two layers, deliberately separated so measurement is a pure fact and +//! inference is a scored, *uncertain* verdict (mirroring the axes-vs-aggregate +//! split of ADR-0017): +//! +//! - [`PitchEvidence`] — the raw, observed pitch-class facts of an explicit +//! [`EvidenceScope`] (whole score / track / voice): per-class onset counts, +//! per-class duration mass in ticks, and the observed +//! [`PitchRange`](crate::feature::PitchRange). No thresholds, no key; a pure +//! projection of a [`Score`] region that is *additive* across scopes (a whole +//! score's evidence equals the sum of its tracks', a track's the sum of its +//! voices'). +//! - [`TonalEstimate`] — a ranked 24-key Krumhansl–Schmuckler inference with an +//! explicit [`confidence_margin`](TonalEstimate::confidence_margin); every +//! [`TonalCandidate`] carries its tonic, [`KeyMode`], Pearson correlation and +//! `scale_fit`. +//! +//! This generalises the previously private, single-winner +//! `complement::estimate_harmony`, which now projects the winning +//! [`TonalCandidate`] into a `HarmonicContext` — one estimator, not two +//! (heuristics-first, ADR-0008; the profiles are the same Krumhansl–Kessler +//! ratings, never ML). +//! +//! **KS v1 is duration-only.** The histogram is weighted by duration mass; raw +//! onset counts are the fallback *only* when the total duration mass is zero (a +//! part whose notes all have zero duration still estimates). Phase 1 blends +//! nothing and applies no metric-accent policy — those remain uncalibrated +//! design space (see `docs/audit/2026-07-tonal-context-phase0.md`). + +use std::cmp::Ordering; + +use crate::event::Pitch; +use crate::feature::PitchRange; +use crate::score::{AtomEvent, Score, Voice}; + +/// Krumhansl–Kessler major tonal-hierarchy profile (probe-tone ratings). +const KK_MAJOR: [f64; 12] = [ + 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88, +]; +/// Krumhansl–Kessler natural-minor tonal-hierarchy profile. +const KK_MINOR: [f64; 12] = [ + 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17, +]; + +/// Major or natural minor — the two scale shapes the key estimate considers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyMode { + /// The major (Ionian) scale. + Major, + /// The natural minor (Aeolian) scale. + Minor, +} + +impl KeyMode { + /// Semitone offsets of this mode's scale above its tonic. + #[must_use] + pub const fn scale_offsets(self) -> [u8; 7] { + match self { + Self::Major => [0, 2, 4, 5, 7, 9, 11], + Self::Minor => [0, 2, 3, 5, 7, 8, 10], + } + } + + /// The Krumhansl–Kessler profile for this mode. + const fn profile(self) -> &'static [f64; 12] { + match self { + Self::Major => &KK_MAJOR, + Self::Minor => &KK_MINOR, + } + } +} + +/// The region of a [`Score`] a [`PitchEvidence`] projection covers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EvidenceScope { + /// Every note of every voice on every track. + WholeScore, + /// Every voice of the track at this index. + Track(usize), + /// A single voice, addressed by its *position* in the track's voice list + /// (not its [`Voice::id`](crate::score::Voice::id)). + Voice { + /// Track index. + track: usize, + /// Voice position within the track. + voice: usize, + }, +} + +/// Raw, observed pitch-class facts for an [`EvidenceScope`] — a pure projection +/// of a [`Score`] region with no thresholds and no key (glossary §8). +/// +/// The two histograms are kept apart because onset salience and sustained +/// duration disagree (a pedal tone dominates `duration_mass` but not +/// `onset_counts`); inference weights them, evidence does not. Both are *raw*: +/// `onset_counts` are literal onset tallies and `duration_mass` is summed +/// sounded ticks — neither is a probability, wall-clock time, or verdict. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PitchEvidence { + /// The region these facts were measured over. + pub scope: EvidenceScope, + /// Total sounding notes in scope. + pub note_count: usize, + /// Per-pitch-class count of note onsets (index `0` = C … `11` = B). + pub onset_counts: [u32; 12], + /// Per-pitch-class summed note duration in ticks (duration *mass*, not + /// wall-clock sounding time). + pub duration_mass: [u64; 12], + /// The inclusive pitch span observed, or `None` when the scope is silent. + pub pitch_range: Option, +} + +impl PitchEvidence { + /// Measures the raw pitch-class evidence of `scope` on `score`. + /// + /// Pure and deterministic; a silent or out-of-range scope yields zeroed + /// histograms, `note_count` 0 and `pitch_range` `None`. + // `score` (the Score) and `scope` (the region) are distinct domain terms. + #[allow(clippy::similar_names)] + #[must_use] + pub fn measure(score: &Score, scope: EvidenceScope) -> Self { + let mut tally = Tally::default(); + + match scope { + EvidenceScope::WholeScore => { + for track in &score.tracks { + for voice in &track.voices { + tally.visit_voice(voice); + } + } + } + EvidenceScope::Track(index) => { + if let Some(track) = score.tracks.get(index) { + for voice in &track.voices { + tally.visit_voice(voice); + } + } + } + EvidenceScope::Voice { track, voice } => { + if let Some(v) = score.tracks.get(track).and_then(|t| t.voices.get(voice)) { + tally.visit_voice(v); + } + } + } + + Self { + scope, + note_count: tally.note_count, + onset_counts: tally.onset_counts, + duration_mass: tally.duration_mass, + pitch_range: tally.pitch_range, + } + } +} + +/// One key's fit against the evidence: a tonic, a mode, the Pearson correlation +/// with that key's rotated profile, and the fraction of weight on its scale. +/// +/// `scale_fit` is a *fact*, not a verdict — what counts as "fitting well enough" +/// is corpus/S9 calibration territory. It is duration-weighted whenever duration +/// mass is present and onset-count-weighted only in the zero-duration fallback, +/// exactly matching the correlation's weighting. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TonalCandidate { + /// Tonic pitch class: `0` = C … `11` = B. + pub tonic: u8, + /// Major or natural minor. + pub mode: KeyMode, + /// Pearson correlation with the mode's profile rotated onto `tonic`. + pub correlation: f64, + /// Weighted fraction of the histogram on this key's scale, in `[0, 1]`. + pub scale_fit: f64, +} + +/// A ranked key estimate carrying *explicit uncertainty*. +/// +/// `candidates` holds all 24 keys best-first; `confidence_margin` is the +/// correlation gap between the winner and the best rival, so a caller can tell a +/// confident estimate (wide margin) from an ambiguous one (near tie) without +/// re-running the maths. A margin near zero is *honest ambiguity*, not a defect: +/// an exactly flat histogram scores every key at a finite zero and leaves a zero +/// margin, and the deterministic C-major-first tie order that results is an +/// ordering convention, never a confidence claim. +#[derive(Debug, Clone, PartialEq)] +pub struct TonalEstimate { + /// All 24 keys (12 tonics × 2 modes), ranked best-first. + pub candidates: Vec, + /// `winner.correlation - runner_up.correlation` (`0.0` when tied). + pub confidence_margin: f64, +} + +impl TonalEstimate { + /// The best-ranked candidate, or `None` when there are no candidates. + #[must_use] + pub fn winner(&self) -> Option<&TonalCandidate> { + self.candidates.first() + } +} + +/// Infers a ranked [`TonalEstimate`] from measured [`PitchEvidence`]. +/// +/// Returns `None` for a silent scope (`note_count == 0`); otherwise ranks all 24 +/// keys (KS v1: duration mass weights the histogram, raw onset counts are the +/// fallback only when the total duration mass is zero). Deterministic. +#[must_use] +pub fn estimate_key(evidence: &PitchEvidence) -> Option { + estimate_from_histograms( + evidence.note_count, + &evidence.onset_counts, + &evidence.duration_mass, + ) +} + +/// The shared inference core over raw histograms, reused by [`estimate_key`] and +/// by `complement::estimate_harmony` (which has weighted notes, not a scope). +#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] +pub(crate) fn estimate_from_histograms( + note_count: usize, + onset_counts: &[u32; 12], + duration_mass: &[u64; 12], +) -> Option { + if note_count == 0 { + return None; + } + + let weights = resolve_weights(onset_counts, duration_mass); + let candidates = rank_keys(&weights); + let confidence_margin = match (candidates.first(), candidates.get(1)) { + (Some(best), Some(runner_up)) => best.correlation - runner_up.correlation, + _ => 0.0, + }; + + Some(TonalEstimate { + candidates, + confidence_margin, + }) +} + +/// Resolves the weighting histogram: duration mass when any is present, else the +/// raw onset counts (KS v1 duration-only rule, onset fallback at zero duration). +#[allow(clippy::cast_precision_loss)] +fn resolve_weights(onset_counts: &[u32; 12], duration_mass: &[u64; 12]) -> [f64; 12] { + let total_duration: u64 = duration_mass.iter().sum(); + let mut weights = [0.0_f64; 12]; + if total_duration == 0 { + for (slot, &count) in weights.iter_mut().zip(onset_counts.iter()) { + *slot = f64::from(count); + } + } else { + // Duration mass in ticks; exact in f64 for any realistic score. + for (slot, &mass) in weights.iter_mut().zip(duration_mass.iter()) { + *slot = mass as f64; + } + } + weights +} + +/// Scores all 24 keys against `weights` and returns them best-first. +/// +/// Ranking is a stable descending sort by correlation, so ties keep the +/// scan order (major before minor, tonic ascending). That reproduces +/// `estimate_harmony`'s strict-greater winner exactly and gives the full list a +/// deterministic order — over a flat histogram every key ties at zero and C +/// major sorts first. +#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] +fn rank_keys(weights: &[f64; 12]) -> Vec { + let total: f64 = weights.iter().sum(); + + let mut candidates: Vec = Vec::with_capacity(24); + for mode in [KeyMode::Major, KeyMode::Minor] { + let profile = mode.profile(); + for tonic in 0..12_u8 { + let correlation = rotated_correlation(weights, profile, tonic); + let on_scale: f64 = mode + .scale_offsets() + .iter() + .map(|&offset| weights[usize::from((tonic + offset) % 12)]) + .sum(); + let scale_fit = if total > 0.0 { on_scale / total } else { 0.0 }; + candidates.push(TonalCandidate { + tonic, + mode, + correlation, + scale_fit, + }); + } + } + + candidates.sort_by(|a, b| { + b.correlation + .partial_cmp(&a.correlation) + .unwrap_or(Ordering::Equal) + }); + candidates +} + +/// Pearson correlation between `histogram` and `profile` rotated so the +/// profile's tonic sits on pitch class `tonic`. +/// +/// Returns a finite `0.0` when either side has zero variance (a flat histogram +/// correlates with nothing), so the score is always finite. +// Float-only arithmetic over fixed 12-bin arrays; indices are mod-12. +#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] +fn rotated_correlation(histogram: &[f64; 12], profile: &[f64; 12], tonic: u8) -> f64 { + let mut rotated = [0.0_f64; 12]; + for (pc, slot) in rotated.iter_mut().enumerate() { + *slot = profile[(pc + 12 - usize::from(tonic)) % 12]; + } + + let n = 12.0_f64; + let mean_x: f64 = histogram.iter().sum::() / n; + let mean_y: f64 = rotated.iter().sum::() / n; + let mut numerator = 0.0_f64; + let mut var_x = 0.0_f64; + let mut var_y = 0.0_f64; + for (x, y) in histogram.iter().zip(rotated.iter()) { + let dx = x - mean_x; + let dy = y - mean_y; + numerator = dx.mul_add(dy, numerator); + var_x = dx.mul_add(dx, var_x); + var_y = dy.mul_add(dy, var_y); + } + let denominator = (var_x * var_y).sqrt(); + if denominator > 0.0 { + numerator / denominator + } else { + 0.0 + } +} + +/// Mutable accumulator for [`PitchEvidence::measure`] — the running raw facts +/// as notes are folded in, one scope region at a time. +#[derive(Default)] +struct Tally { + onset_counts: [u32; 12], + duration_mass: [u64; 12], + note_count: usize, + pitch_range: Option, +} + +impl Tally { + /// Folds every sounding note atom of `voice` into the tally, in stored order. + fn visit_voice(&mut self, voice: &Voice) { + for group in &voice.event_groups { + for atom in &group.atoms { + if let AtomEvent::Note(note) = atom { + self.push(note.pitch, note.duration.0); + } + } + } + } + + /// Folds one note's pitch and duration into the accumulators. + #[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)] + fn push(&mut self, pitch: Pitch, duration: u32) { + let pc = usize::from(pitch.0) % 12; + self.onset_counts[pc] = self.onset_counts[pc].saturating_add(1); + self.duration_mass[pc] = self.duration_mass[pc].saturating_add(u64::from(duration)); + self.note_count = self.note_count.saturating_add(1); + self.pitch_range = Some(self.pitch_range.map_or( + PitchRange { + lowest: pitch, + highest: pitch, + }, + |range| PitchRange { + lowest: range.lowest.min(pitch), + highest: range.highest.max(pitch), + }, + )); + } +} diff --git a/core/tests/characterization.rs b/core/tests/characterization.rs index c9030104..91d3e7a4 100644 --- a/core/tests/characterization.rs +++ b/core/tests/characterization.rs @@ -27,7 +27,7 @@ use griff_core::{ feature::voice_features, generate::{ generate, GenerationConstraints, GenerationSeed, GenerationStrategy, PitchMaterial, - RuleGenerationRequest, + RhythmTemplate, RuleGenerationRequest, }, midi::import_score, score::{AtomEvent, Score, Voice}, @@ -126,7 +126,7 @@ fn generate_is_deterministic_golden() { pitch_lo: Pitch(36), pitch_hi: Pitch(72), }, - source_rhythms: vec![vec![Ticks(240); 8]], + source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(240); 8])], strategy: GenerationStrategy::ConstrainedRandomWalk, }; diff --git a/core/tests/pitch_ladder.rs b/core/tests/pitch_ladder.rs new file mode 100644 index 00000000..8ecf21e4 --- /dev/null +++ b/core/tests/pitch_ladder.rs @@ -0,0 +1,307 @@ +// TDD red phase: the full-range scale ladder — the register/pitch increment +// (arbiter 2026-07-12). The rotation A/B exposed a narrow, low register (~one +// octave, root E1): `rhythm_copy`/`shuffle_motifs` walked scale degrees only +// in `[0, scale_len)` — one octave up from `PitchMaterial.root` (the input's +// MINIMUM pitch) — and `motif_transpose` shifted by *semitones*, which can +// leave the pitch-class palette entirely. +// +// The fix is a single shared degree->pitch mapper: a `ScaleLadder` over a +// `PitchRange` and a `PitchClassSet`, reused by the generator and (via +// `band_scale_ladder`) the complement arranger — no second mapper. The +// contract this suite pins: +// +// - pitches ALWAYS fall inside `[pitch_lo, pitch_hi]`; +// - generated pitches ALWAYS belong to the allowed pitch classes; +// - the candidate set is no longer structurally confined to the first octave +// (a full in-range ladder is REACHABLE — not that every single piece must +// visit the whole range); +// - determinism under a fixed request holds; +// - a narrow range still works; reversed/equal bounds do not regress. +// +// References `griff_core::pitch::{PitchRange, PitchClassSet, ScaleLadder}`, +// which do not exist yet, so the suite fails to compile until the green step. +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + clippy::missing_assert_message, + clippy::missing_const_for_fn, + clippy::arithmetic_side_effects +)] + +use std::collections::BTreeSet; + +use griff_core::{ + event::{Pitch, Tempo, Ticks, TimeSignature}, + generate::{ + generate, GenerationConstraints, GenerationSeed, GenerationStrategy, PitchMaterial, + RhythmTemplate, RuleGenerationRequest, + }, + pitch::{PitchClassSet, PitchRange, PitchSelectionError, ScaleLadder}, + rerank::{generate_candidate_set, SetRequest}, + score::{AtomEvent, Score}, +}; + +// ── shared primitive ────────────────────────────────────────────────────────── + +#[test] +fn pitch_class_set_normalises_and_dedups() { + let set = PitchClassSet::new([4, 16, 7, 7, 24]); // 16%12=4, 24%12=0, dup 7 + assert_eq!(set.classes(), &[0, 4, 7], "sorted, mod 12, deduped"); + assert!(set.contains_pitch(Pitch(40))); // 40%12 = 4 + assert!(!set.contains_pitch(Pitch(41))); // 41%12 = 5 +} + +#[test] +fn pitch_range_normalises_reversed_and_clamped_bounds() { + // Reversed bounds must not panic or invert; MIDI clamps at 127. + let r = PitchRange::new(Pitch(72), Pitch(36)); + assert_eq!((r.lo.0, r.hi.0), (36, 72)); + let equal = PitchRange::new(Pitch(50), Pitch(50)); + assert_eq!((equal.lo.0, equal.hi.0), (50, 50)); +} + +#[test] +fn scale_ladder_spans_the_full_range_in_class() { + // E minor pentatonic classes {E,G,A,B,D} over E1..E4 (28..=64): every rung + // is in class, ascending, and the ladder covers more than one octave. + let classes = PitchClassSet::new([2, 4, 7, 9, 11]); // D E G A B + let ladder = + ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("in-class"); + + assert!(ladder.len() > 5, "a 3-octave pentatonic ladder is long"); + let pitches: Vec = ladder.pitches().iter().map(|p| p.0).collect(); + for w in pitches.windows(2) { + assert!(w[0] < w[1], "ladder is strictly ascending"); + } + for p in &pitches { + assert!((28..=64).contains(p), "rung {p} in range"); + assert!(classes.contains_pitch(Pitch(*p)), "rung {p} in class"); + } + assert!( + pitches.last().unwrap() - pitches.first().unwrap() > 12, + "ladder spans more than one octave" + ); +} + +#[test] +fn scale_ladder_empty_class_set_errors() { + // An empty palette has no in-class pitch — the ladder must NOT fall back + // to `lo` (which would silently break the always-in-class contract). + let err = ScaleLadder::build( + &PitchRange::new(Pitch(40), Pitch(60)), + &PitchClassSet::new([]), + ); + assert_eq!(err, Err(PitchSelectionError::EmptyPitchClassSet)); +} + +#[test] +fn scale_ladder_no_allowed_pitch_in_range_errors() { + // Palette {C} (class 0); range [41,42] contains no C — an explicit error, + // not a fallback to an out-of-palette pitch. + let err = ScaleLadder::build( + &PitchRange::new(Pitch(41), Pitch(42)), + &PitchClassSet::new([0]), + ); + assert_eq!(err, Err(PitchSelectionError::NoAllowedPitchInRange)); +} + +#[test] +fn scale_ladder_single_allowed_note_is_ok() { + // A narrow range holding exactly one in-class pitch resolves to a + // one-rung ladder (that pitch), not an error. + let ladder = ScaleLadder::build( + &PitchRange::new(Pitch(47), Pitch(49)), + &PitchClassSet::new([0]), + ) // C=48 + .expect("one C in [47,49]"); + assert_eq!(ladder.pitches(), &[Pitch(48)]); +} + +// ── generation contract ─────────────────────────────────────────────────────── + +/// E minor pentatonic anchored at E2 (root 40): intervals 0,3,5,7,10 → +/// classes {2,4,7,9,11}. A deliberately *incomplete* class set (not chromatic). +fn pentatonic() -> PitchMaterial { + PitchMaterial { + root: Pitch(40), + intervals: vec![0, 3, 5, 7, 10], + } +} + +/// The pitch classes the pentatonic material allows. +fn allowed_classes() -> BTreeSet { + [2_u8, 4, 7, 9, 11].into_iter().collect() +} + +/// Wide 3-octave range E1..E4 (28..=64). +fn wide(bar_count: usize) -> GenerationConstraints { + GenerationConstraints { + bar_count, + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + ticks_per_quarter: Ticks(480), + pitch_lo: Pitch(28), + pitch_hi: Pitch(64), + } +} + +const ALL_STRATEGIES: [GenerationStrategy; 5] = [ + GenerationStrategy::RhythmCopyPitchSubstitute, + GenerationStrategy::MotifTransposeVariation, + GenerationStrategy::ConstrainedRandomWalk, + GenerationStrategy::ShuffleMotifs, + GenerationStrategy::RepeatVariation, +]; + +fn pitches(score: &Score) -> Vec { + score.tracks[0].voices[0] + .event_groups + .iter() + .flat_map(|g| g.atoms.iter()) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some(n.pitch.0), + AtomEvent::Rest(_) => None, + }) + .collect() +} + +fn request(strategy: GenerationStrategy, seed: u64) -> RuleGenerationRequest { + RuleGenerationRequest { + seed: GenerationSeed(seed), + pitch_material: pentatonic(), + constraints: wide(8), + // A quarter template so RhythmCopyPitchSubstitute has one; the pitch + // contract is about degrees, not rhythm. + source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], + strategy, + } +} + +#[test] +fn every_strategy_stays_in_bounds_and_in_class_over_seeds() { + for strategy in ALL_STRATEGIES { + for seed in [1_u64, 2, 7, 42, 1000] { + let candidate = generate(&request(strategy, seed)).expect("generate succeeds"); + for p in pitches(&candidate.score) { + assert!( + (28..=64).contains(&p), + "{strategy:?} seed {seed}: pitch {p} out of [28,64]" + ); + assert!( + allowed_classes().contains(&(p % 12)), + "{strategy:?} seed {seed}: pitch class {} not in the palette", + p % 12 + ); + } + } + } +} + +#[test] +fn candidate_set_reaches_beyond_the_first_octave() { + // The contract is REACHABILITY, not per-piece coverage: across the set, + // pitches must span more than one octave above pitch_lo — the old mapper + // pinned rhythm-copy/shuffle to [root, root+12). + let set = generate_candidate_set(&SetRequest { + seed: GenerationSeed(7), + pitch_material: pentatonic(), + constraints: wide(8), + source_rhythms: Vec::new(), + variants_per_strategy: 3, + gesture: None, + }) + .expect("set generation"); + + let highest = set + .iter() + .flat_map(|c| pitches(&c.score)) + .max() + .expect("the set has notes"); + assert!( + highest > 28 + 12, + "candidate set stays within the first octave (highest {highest})" + ); +} + +#[test] +fn generation_is_deterministic_over_the_ladder() { + for strategy in ALL_STRATEGIES { + let a = generate(&request(strategy, 123)).expect("run a"); + let b = generate(&request(strategy, 123)).expect("run b"); + assert_eq!( + pitches(&a.score), + pitches(&b.score), + "{strategy:?}: fixed request must be deterministic" + ); + } +} + +#[test] +fn narrow_range_still_generates_in_bounds() { + // A sub-octave range must still work (degenerate ladder), never panic. + let narrow = GenerationConstraints { + pitch_lo: Pitch(40), + pitch_hi: Pitch(45), + ..wide(4) + }; + for strategy in ALL_STRATEGIES { + let req = RuleGenerationRequest { + constraints: narrow, + ..request(strategy, 5) + }; + let candidate = generate(&req).expect("narrow generate succeeds"); + for p in pitches(&candidate.score) { + assert!( + (40..=45).contains(&p), + "{strategy:?}: pitch {p} out of narrow range" + ); + } + } +} + +#[test] +fn generate_errors_when_no_palette_pitch_is_in_range() { + // A single-pitch range whose class is outside the pentatonic palette: + // the generator surfaces an explicit error instead of a silent + // out-of-palette floor. + let narrow = GenerationConstraints { + pitch_lo: Pitch(48), // C, class 0 — not in {2,4,7,9,11} + pitch_hi: Pitch(48), + ..wide(4) + }; + let req = RuleGenerationRequest { + constraints: narrow, + ..request(GenerationStrategy::ConstrainedRandomWalk, 5) + }; + assert!( + generate(&req).is_err(), + "no in-palette pitch in range must be a GenerationError, not a fallback" + ); +} + +#[test] +fn reversed_bounds_do_not_regress() { + // pitch_lo > pitch_hi is normalised, not a panic or an empty part. + let reversed = GenerationConstraints { + pitch_lo: Pitch(64), + pitch_hi: Pitch(28), + ..wide(4) + }; + let candidate = generate(&RuleGenerationRequest { + constraints: reversed, + ..request(GenerationStrategy::ConstrainedRandomWalk, 9) + }) + .expect("reversed-bounds generate succeeds"); + assert!( + !pitches(&candidate.score).is_empty(), + "still produces notes" + ); + for p in pitches(&candidate.score) { + assert!((28..=64).contains(&p), "pitch {p} in normalised range"); + } +} diff --git a/core/tests/rerank.rs b/core/tests/rerank.rs new file mode 100644 index 00000000..8f368a5c --- /dev/null +++ b/core/tests/rerank.rs @@ -0,0 +1,396 @@ +// TDD red phase: candidate set + explainable rerank — the wiring the +// melodic-closure research note left open (§7.2 "wired into S6 / S14 +// candidate reranking", §7.3 caller-side novelty cut; ADR-0017). +// +// S6 `generate` produces exactly one candidate from one hardcoded strategy; +// the S6 stage doc promises `Vec` and the ADR-0017 +// vocabulary exists precisely to rank such a set. This suite specifies the +// missing seam as two pure functions in `griff_core::rerank`: +// +// - `generate_candidate_set` — fan a base request out over every S6 strategy +// × a caller-chosen number of seed variants (deterministically derived from +// the base seed; independent of whether gesture carving is on), skipping +// `RhythmCopyPitchSubstitute` when no rhythm template exists, rotating +// templates across variants so a multi-template corpus actually shows up in +// the set, and optionally carving each candidate through the S6 gesture +// compiler (`generate_gestured`, research note §3.5). +// - `rerank_candidates` — score each candidate on the four closure axes plus +// the two novelty axes (against caller-supplied reference scores) under a +// caller-supplied `WeightPolicy`, and return `Scored` envelopes in rank +// order (aggregate descending, ties by candidate index — `rank_indices`). +// +// References `griff_core::rerank::{…}`, which does not exist yet, so the +// suite fails to compile until the green step. +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + clippy::missing_assert_message, + clippy::float_cmp, + clippy::arithmetic_side_effects +)] + +use std::slice; + +use griff_core::{ + closure::CLOSURE_AXIS_LABELS, + event::{Pitch, Tempo, Ticks, TimeSignature}, + generate::{ + GenerationConstraints, GenerationSeed, GenerationStrategy, PitchMaterial, RhythmTemplate, + TemplateNote, + }, + gesture::GestureControl, + novelty::NOVELTY_AXIS_LABELS, + rerank::{ + generate_candidate_set, rerank_candidates, rerank_weights_v1, SetCandidate, SetError, + SetRequest, RERANK_AXIS_LABELS, + }, + score::{AtomEvent, Score}, + scoring::WeightPolicy, +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/// E minor pentatonic from E2 — the swancore-ish default material used across +/// the generator suites. +fn material() -> PitchMaterial { + PitchMaterial { + root: Pitch(40), // E2 + intervals: vec![0, 3, 5, 7, 10], + } +} + +/// 4/4 at 480 PPQN over `bar_count` bars, range C2–C5. +const fn constraints(bar_count: usize) -> GenerationConstraints { + GenerationConstraints { + bar_count, + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + ticks_per_quarter: Ticks(480), + pitch_lo: Pitch(36), // C2 + pitch_hi: Pitch(72), // C5 + } +} + +/// A base set request; `source_rhythms` and `gesture` vary per test. +fn set_request( + seed: u64, + variants_per_strategy: usize, + source_rhythms: Vec, + gesture: Option, +) -> SetRequest { + SetRequest { + seed: GenerationSeed(seed), + pitch_material: material(), + constraints: constraints(4), + source_rhythms, + variants_per_strategy, + gesture, + } +} + +/// One bar of quarter notes — the simplest usable rhythm template. +fn quarters() -> Vec { + vec![RhythmTemplate::from_durations(&[Ticks(480); 4])] +} + +/// The `(onset, duration, pitch)` triples of the candidate's single voice. +fn notes(score: &Score) -> Vec<(u32, u32, u8)> { + score.tracks[0].voices[0] + .event_groups + .iter() + .flat_map(|g| g.atoms.iter()) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some((n.absolute_start.0, n.duration.0, n.pitch.0)), + AtomEvent::Rest(_) => None, + }) + .collect() +} + +/// Comparable identity of a candidate: provenance plus produced notes. +type Fingerprint = (GenerationStrategy, u64, Vec<(u32, u32, u8)>); + +fn fingerprint(c: &SetCandidate) -> Fingerprint { + (c.strategy, c.seed.0, notes(&c.score)) +} + +/// Every S6 strategy, in declaration order. +const ALL_STRATEGIES: [GenerationStrategy; 5] = [ + GenerationStrategy::RhythmCopyPitchSubstitute, + GenerationStrategy::MotifTransposeVariation, + GenerationStrategy::ConstrainedRandomWalk, + GenerationStrategy::ShuffleMotifs, + GenerationStrategy::RepeatVariation, +]; + +// ── candidate set ───────────────────────────────────────────────────────────── + +#[test] +fn set_covers_every_strategy_and_derives_distinct_seeds() { + let set = generate_candidate_set(&set_request(7, 2, quarters(), None)) + .expect("set generation succeeds"); + + assert_eq!(set.len(), 10, "5 strategies × 2 variants"); + for strategy in ALL_STRATEGIES { + let count = set.iter().filter(|c| c.strategy == strategy).count(); + assert_eq!(count, 2, "two variants of {strategy:?}"); + } + + let mut seeds: Vec = set.iter().map(|c| c.seed.0).collect(); + seeds.sort_unstable(); + seeds.dedup(); + assert_eq!(seeds.len(), 10, "every variant runs under its own seed"); + + for candidate in &set { + assert!( + !notes(&candidate.score).is_empty(), + "{:?} produced an empty candidate", + candidate.strategy + ); + } +} + +#[test] +fn set_skips_rhythm_copy_without_templates() { + let set = generate_candidate_set(&set_request(7, 1, Vec::new(), None)).expect("set generation"); + + assert_eq!(set.len(), 4, "remaining strategies × 1 variant"); + assert!( + set.iter() + .all(|c| c.strategy != GenerationStrategy::RhythmCopyPitchSubstitute), + "rhythm-copy needs a template and must be skipped, not fail the set" + ); +} + +#[test] +fn set_is_deterministic_for_a_fixed_request() { + let request = set_request(1234, 3, quarters(), None); + let a: Vec<_> = generate_candidate_set(&request) + .expect("first run") + .iter() + .map(fingerprint) + .collect(); + let b: Vec<_> = generate_candidate_set(&request) + .expect("second run") + .iter() + .map(fingerprint) + .collect(); + assert_eq!(a, b, "the same request always yields the same set"); +} + +#[test] +fn candidate_hears_every_template_across_its_bars() { + // Per-bar rotation supersedes per-variant rotation: a single candidate now + // cycles the whole rhythmic palette across its bars, so a multi-template + // corpus is audible *within* each candidate, not only across the set. + let templates = vec![ + RhythmTemplate::from_durations(&[Ticks(1920)]), // bar 0: 1 note + RhythmTemplate::from_durations(&[Ticks(240); 8]), // bar 1: 8 notes + ]; + let mut request = set_request(9, 1, templates, None); + request.constraints = constraints(2); + + let set = generate_candidate_set(&request).expect("set generation"); + let rhythm_copy = set + .iter() + .find(|c| c.strategy == GenerationStrategy::RhythmCopyPitchSubstitute) + .expect("rhythm-copy candidate present"); + assert_eq!( + notes(&rhythm_copy.score).len(), + 9, + "one candidate hears both rhythms across its two bars (1 + 8)" + ); +} + +#[test] +fn set_feeds_the_template_grid_to_every_strategy() { + // A gapped template must shape ALL strategies' candidates — the corpus + // is inaudible if only rhythm-copy hears it (2026-07-11 playtest). + let template = RhythmTemplate { + notes: vec![ + TemplateNote { + offset: Ticks(0), + duration: Ticks(240), + }, + TemplateNote { + offset: Ticks(960), + duration: Ticks(240), + }, + ], + }; + let set = + generate_candidate_set(&set_request(7, 1, vec![template], None)).expect("set generation"); + + assert_eq!(set.len(), 5, "every strategy contributes"); + for candidate in &set { + let in_bar: Vec = notes(&candidate.score) + .iter() + .map(|&(onset, _, _)| onset % 1920) + .collect(); + assert!( + in_bar.iter().all(|o| *o == 0 || *o == 960), + "{:?}: notes sit on the template grid, got {in_bar:?}", + candidate.strategy + ); + assert!( + notes(&candidate.score) + .iter() + .all(|&(_, dur, _)| dur == 240), + "{:?}: durations come from the template", + candidate.strategy + ); + } +} + +#[test] +fn set_applies_gesture_control() { + let control = GestureControl { + burst_notes: 2, + rest_quarters: 1.0, + }; + let plain = generate_candidate_set(&set_request(7, 1, quarters(), None)).expect("plain set"); + let gestured = generate_candidate_set(&set_request(7, 1, quarters(), Some(control))) + .expect("gestured set"); + + assert_eq!(plain.len(), gestured.len()); + for (p, g) in plain.iter().zip(&gestured) { + assert_eq!(p.strategy, g.strategy); + assert_eq!( + p.seed, g.seed, + "variant seeds depend only on (base seed, strategy, variant)" + ); + assert_eq!(g.gesture, Some(control), "carved candidates keep the ask"); + assert!( + notes(&g.score).len() < notes(&p.score).len(), + "{:?}: carving gesture rests must drop notes from wall-to-wall output", + p.strategy + ); + } + assert!( + plain.iter().all(|c| c.gesture.is_none()), + "no control asked, none recorded" + ); +} + +#[test] +fn set_rejects_zero_variants() { + // `Score` (inside `SetCandidate`) carries no `PartialEq`, so match on the + // error rather than comparing whole `Result`s. + assert!(matches!( + generate_candidate_set(&set_request(7, 0, quarters(), None)), + Err(SetError::VariantCountZero), + )); +} + +// ── rerank ──────────────────────────────────────────────────────────────────── + +#[test] +fn rerank_labels_join_closure_and_novelty() { + assert_eq!(RERANK_AXIS_LABELS.len(), 6); + assert_eq!(&RERANK_AXIS_LABELS[..4], &CLOSURE_AXIS_LABELS); + assert_eq!(&RERANK_AXIS_LABELS[4..], &NOVELTY_AXIS_LABELS); +} + +#[test] +fn rerank_returns_ranked_explainable_envelopes() { + let request = set_request(7, 1, quarters(), None); + let set = generate_candidate_set(&request).expect("set generation"); + let expected = set.len(); + let policy = rerank_weights_v1(); + + let ranked = rerank_candidates(set, &material(), &[], &policy); + + assert_eq!(ranked.len(), expected, "every candidate is scored"); + for pair in ranked.windows(2) { + assert!( + pair[0].aggregate() >= pair[1].aggregate(), + "envelopes come back in rank order" + ); + } + for scored in &ranked { + assert_eq!(scored.provenance.policy_id, "generation_rerank"); + assert_eq!(scored.provenance.policy_version, 1); + assert_eq!( + scored.provenance.seed, + Some(scored.value.seed.0), + "provenance carries the candidate's own seed" + ); + for label in RERANK_AXIS_LABELS { + assert!( + scored.axes.get(label).is_some(), + "axis {label} present on every envelope" + ); + } + // No corpus yet: nothing to quote, both novelty axes read fully novel. + assert_eq!(scored.axes.get("quote_novelty"), Some(1.0)); + assert_eq!(scored.axes.get("ngram_novelty"), Some(1.0)); + } +} + +#[test] +fn rerank_is_deterministic() { + let request = set_request(21, 2, quarters(), None); + let policy = rerank_weights_v1(); + + let a: Vec<(GenerationStrategy, u64)> = rerank_candidates( + generate_candidate_set(&request).expect("set"), + &material(), + &[], + &policy, + ) + .iter() + .map(|s| (s.value.strategy, s.value.seed.0)) + .collect(); + let b: Vec<(GenerationStrategy, u64)> = rerank_candidates( + generate_candidate_set(&request).expect("set"), + &material(), + &[], + &policy, + ) + .iter() + .map(|s| (s.value.strategy, s.value.seed.0)) + .collect(); + + assert_eq!( + a, b, + "rank order is stable under a fixed request and policy" + ); +} + +#[test] +fn rerank_penalises_a_verbatim_quote_under_a_novelty_policy() { + let set = generate_candidate_set(&set_request(7, 1, quarters(), None)).expect("set"); + // The corpus "contains" candidate 0 verbatim: quoting it must cost rank. + let quoted_seed = set[0].seed; + let reference = set[0].score.clone(); + let policy = WeightPolicy::new( + "novelty_only", + 1, + vec![(NOVELTY_AXIS_LABELS[0], 1.0), (NOVELTY_AXIS_LABELS[1], 1.0)], + ); + + let ranked = rerank_candidates(set, &material(), slice::from_ref(&reference), &policy); + + let quoted = ranked + .iter() + .find(|s| s.value.seed == quoted_seed) + .expect("the quoted candidate is still scored"); + assert_eq!( + quoted.axes.get("quote_novelty"), + Some(0.0), + "a full self-quote has zero quote novelty" + ); + let top = &ranked[0]; + assert!( + top.value.seed != quoted_seed, + "the verbatim quote must not win the rank" + ); + assert!( + top.aggregate() > quoted.aggregate(), + "novel material outranks the quote" + ); +} diff --git a/core/tests/rhythm_grid.rs b/core/tests/rhythm_grid.rs new file mode 100644 index 00000000..8c3fe989 --- /dev/null +++ b/core/tests/rhythm_grid.rs @@ -0,0 +1,350 @@ +// TDD red phase: the rhythm grid — corpus rhythm becomes an input of *every* +// strategy, and rests become first-class in generated output. +// +// The 2026-07-11 corpus playtest (decisions.log; 220 chunks, 330 templates) +// showed the corpus is audible only when a RhythmCopyPitchSubstitute +// candidate wins the rerank: the other four strategies hardcode wall-to-wall +// quarter notes, so corpus rhythm templates and in-bar silence never reach +// their output. This suite specifies the fix: +// +// - `RhythmTemplate` replaces the bare duration list: one bar of +// onset-*placed* notes (`TemplateNote { offset, duration }`), so gaps — +// rests, syncopation — survive extraction and generation. +// `RhythmTemplate::from_durations` rebuilds the legacy back-to-back shape. +// - Every strategy lays its pitches onto the same per-bar grid: the first +// usable template, clamped to the bar (offsets past the bar end drop the +// note; durations clamp to the bar end; an unusable grid and an absent +// template both fall back to the quarter-note grid, preserving today's +// behaviour). Pitch logic stays the strategy's own. +// +// References `griff_core::generate::{RhythmTemplate, TemplateNote}`, which do +// not exist yet, so the suite fails to compile until the green step. +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + clippy::missing_assert_message, + clippy::missing_const_for_fn, + clippy::arithmetic_side_effects +)] + +use griff_core::{ + event::{Pitch, Tempo, Ticks, TimeSignature}, + generate::{ + generate, rhythm_diagnostics, GenerationCandidate, GenerationConstraints, GenerationSeed, + GenerationStrategy, PitchMaterial, RhythmTemplate, RuleGenerationRequest, TemplateNote, + }, + score::{AtomEvent, AtomNote, Voice}, +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn material() -> PitchMaterial { + PitchMaterial { + root: Pitch(40), // E2 + intervals: vec![0, 3, 5, 7, 10], + } +} + +/// 4/4 at 480 PPQN over two bars (bar = 1920 ticks), range C2–C5. +fn constraints() -> GenerationConstraints { + GenerationConstraints { + bar_count: 2, + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + ticks_per_quarter: Ticks(480), + pitch_lo: Pitch(36), + pitch_hi: Pitch(72), + } +} + +fn request(strategy: GenerationStrategy, templates: Vec) -> RuleGenerationRequest { + RuleGenerationRequest { + seed: GenerationSeed(42), + pitch_material: material(), + constraints: constraints(), + source_rhythms: templates, + strategy, + } +} + +/// A template note at `offset` for `duration` ticks. +fn tn(offset: u32, duration: u32) -> TemplateNote { + TemplateNote { + offset: Ticks(offset), + duration: Ticks(duration), + } +} + +const ALL_STRATEGIES: [GenerationStrategy; 5] = [ + GenerationStrategy::RhythmCopyPitchSubstitute, + GenerationStrategy::MotifTransposeVariation, + GenerationStrategy::ConstrainedRandomWalk, + GenerationStrategy::ShuffleMotifs, + GenerationStrategy::RepeatVariation, +]; + +/// The single generated voice (track 0, voice 0). +fn voice(candidate: &GenerationCandidate) -> &Voice { + &candidate.score.tracks[0].voices[0] +} + +/// `(onset, duration)` of every note atom, in order. +fn placements(candidate: &GenerationCandidate) -> Vec<(u32, u32)> { + voice(candidate) + .event_groups + .iter() + .flat_map(|g| g.atoms.iter()) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some((n.absolute_start.0, n.duration.0)), + AtomEvent::Rest(_) => None, + }) + .collect() +} + +/// All note atoms, in order. +fn notes(candidate: &GenerationCandidate) -> Vec { + voice(candidate) + .event_groups + .iter() + .flat_map(|g| g.atoms.iter()) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some(*n), + AtomEvent::Rest(_) => None, + }) + .collect() +} + +/// Note count per bar of `bar_ticks`, indexed by bar. +fn notes_per_bar(candidate: &GenerationCandidate, bar_ticks: u32) -> Vec { + let mut per_bar = vec![0_usize; candidate.score.master_bars.len()]; + for (onset, _) in placements(candidate) { + let bar = (onset / bar_ticks) as usize; + if let Some(slot) = per_bar.get_mut(bar) { + *slot += 1; + } + } + per_bar +} + +// ── the grid reaches every strategy ─────────────────────────────────────────── + +#[test] +fn gapped_template_places_notes_at_offsets_for_every_strategy() { + // Burst-and-rest bar: a note on the downbeat, silence, a note on beat 3 — + // the gap must survive into every strategy's output (research note §1.3: + // a metrically placed rest is phrasing, not absence). + let template = RhythmTemplate { + notes: vec![tn(0, 240), tn(960, 240)], + }; + for strategy in ALL_STRATEGIES { + let candidate = + generate(&request(strategy, vec![template.clone()])).expect("generate succeeds"); + assert_eq!( + placements(&candidate), + vec![(0, 240), (960, 240), (1920, 240), (2880, 240)], + "{strategy:?}: the gapped grid places two notes per bar, silence between" + ); + } +} + +#[test] +fn from_durations_rebuilds_the_wall_to_wall_layout() { + // The legacy shape — back-to-back durations — is a template whose offsets + // accumulate. A quarter grid must therefore reproduce today's layout. + let template = RhythmTemplate::from_durations(&[Ticks(480); 4]); + assert_eq!( + template.notes, + vec![tn(0, 480), tn(480, 480), tn(960, 480), tn(1440, 480)], + ); + + let candidate = generate(&request( + GenerationStrategy::ConstrainedRandomWalk, + vec![template], + )) + .expect("generate succeeds"); + let quarters: Vec<(u32, u32)> = (0..8).map(|i| (i * 480, 480)).collect(); + assert_eq!(placements(&candidate), quarters); +} + +#[test] +fn strategies_fall_back_to_quarters_without_a_template() { + // No corpus, no template: today's wall-to-wall quarter behaviour holds + // (characterization — the grid must not change the no-input case). + let candidate = generate(&request( + GenerationStrategy::ConstrainedRandomWalk, + Vec::new(), + )) + .expect("generate succeeds"); + let quarters: Vec<(u32, u32)> = (0..8).map(|i| (i * 480, 480)).collect(); + assert_eq!(placements(&candidate), quarters); +} + +#[test] +fn template_durations_clamp_to_the_bar_end() { + // A note starting on beat 4 asking for two beats gets one: durations + // clamp to the bar boundary rather than bleeding into the next bar. + let template = RhythmTemplate { + notes: vec![tn(1440, 960)], + }; + let candidate = generate(&request(GenerationStrategy::ShuffleMotifs, vec![template])) + .expect("generate succeeds"); + assert_eq!(placements(&candidate), vec![(1440, 480), (3360, 480)]); +} + +#[test] +fn unusable_template_falls_back_to_quarters() { + // Every note sits past the bar end: the grid is unusable, and the bar + // falls back to quarters instead of generating silence-only bars that + // the rerank would drop. + let template = RhythmTemplate { + notes: vec![tn(2000, 240)], + }; + let candidate = generate(&request( + GenerationStrategy::ConstrainedRandomWalk, + vec![template], + )) + .expect("generate succeeds"); + let quarters: Vec<(u32, u32)> = (0..8).map(|i| (i * 480, 480)).collect(); + assert_eq!(placements(&candidate), quarters); +} + +// ── per-bar template rotation ───────────────────────────────────────────────── + +#[test] +fn multiple_templates_rotate_across_bars() { + // Two rhythms — bar of eighths (8 notes) vs one whole note (1) — must + // alternate across bars so a corpus's rhythmic variety survives *inside* + // one phrase (2026-07 A/B: distinct_dur stuck at 1 because every bar + // reused the first template). + let eighths = RhythmTemplate::from_durations(&[Ticks(240); 8]); + let whole = RhythmTemplate { + notes: vec![tn(0, 1920)], + }; + // Every per-bar-independent strategy rotates; RepeatVariation is its own + // case (its identity is repetition — tested separately below). + for strategy in [ + GenerationStrategy::RhythmCopyPitchSubstitute, + GenerationStrategy::MotifTransposeVariation, + GenerationStrategy::ConstrainedRandomWalk, + GenerationStrategy::ShuffleMotifs, + ] { + let mut req = request(strategy, vec![eighths.clone(), whole.clone()]); + req.constraints.bar_count = 4; + let candidate = generate(&req).expect("generate succeeds"); + assert_eq!( + notes_per_bar(&candidate, 1920), + vec![8, 1, 8, 1], + "{strategy:?}: bars must cycle the two template rhythms" + ); + } +} + +#[test] +fn repeat_variation_keeps_one_rhythm_across_bars() { + // RepeatVariation's identity is repetition (call/response), so it stays on + // the first template's rhythm even when several exist — otherwise the + // "repeat" no longer reads as one. + let eighths = RhythmTemplate::from_durations(&[Ticks(240); 8]); + let whole = RhythmTemplate { + notes: vec![tn(0, 1920)], + }; + let mut req = request(GenerationStrategy::RepeatVariation, vec![eighths, whole]); + req.constraints.bar_count = 4; + let candidate = generate(&req).expect("generate succeeds"); + assert_eq!( + notes_per_bar(&candidate, 1920), + vec![8, 8, 8, 8], + "repeat variation repeats the first template's rhythm" + ); +} + +#[test] +fn single_template_still_repeats_every_bar() { + // One template → rotation is trivial: every bar uses it (the grid must not + // regress the single-template case). + let template = RhythmTemplate { + notes: vec![tn(0, 240), tn(960, 240)], + }; + let mut req = request(GenerationStrategy::ConstrainedRandomWalk, vec![template]); + req.constraints.bar_count = 3; + let candidate = generate(&req).expect("generate succeeds"); + assert_eq!(notes_per_bar(&candidate, 1920), vec![2, 2, 2]); +} + +#[test] +fn gapped_generation_is_deterministic_and_in_range() { + let template = RhythmTemplate { + notes: vec![tn(0, 240), tn(360, 120), tn(960, 480)], + }; + for strategy in ALL_STRATEGIES { + let a = generate(&request(strategy, vec![template.clone()])).expect("run a"); + let b = generate(&request(strategy, vec![template.clone()])).expect("run b"); + assert_eq!(voice(&a), voice(&b), "{strategy:?}: deterministic"); + for n in notes(&a) { + assert!( + (36..=72).contains(&n.pitch.0), + "{strategy:?}: pitch {} in range", + n.pitch.0 + ); + } + } +} + +// ── diagnostics seam (CLI generation-summary transparency) ───────────────────── + +#[test] +fn rhythm_diagnostics_separate_loaded_from_effective() { + // Three templates: one empty, one entirely past the bar end (clamps away), + // one usable. Only the last is an effective grid the scheduler rotates. + let empty = RhythmTemplate { notes: vec![] }; + let past_end = RhythmTemplate { + notes: vec![tn(2000, 240)], + }; + let usable = RhythmTemplate { + notes: vec![tn(0, 480), tn(960, 480)], + }; + let diag = rhythm_diagnostics(&[empty, past_end, usable], Ticks(1920)); + assert_eq!(diag.loaded, 3, "loaded counts every passed template"); + assert_eq!(diag.effective, 1, "empty and clamped-away templates drop"); + assert_eq!( + diag.fingerprints.len(), + 1, + "one fingerprint per effective grid" + ); +} + +#[test] +fn rhythm_diagnostics_fingerprints_track_rhythm_identity() { + // Equal rhythms → equal fingerprints; a different rhythm → a different + // one. This is the metric that makes a corpus A/B interpretable. + let eighths_a = RhythmTemplate::from_durations(&[Ticks(240); 8]); + let eighths_b = RhythmTemplate::from_durations(&[Ticks(240); 8]); + let whole = RhythmTemplate { + notes: vec![tn(0, 1920)], + }; + let diag = rhythm_diagnostics(&[eighths_a, eighths_b, whole], Ticks(1920)); + assert_eq!(diag.effective, 3); + assert_eq!( + diag.fingerprints[0], diag.fingerprints[1], + "identical rhythms share a fingerprint" + ); + assert_ne!( + diag.fingerprints[0], diag.fingerprints[2], + "a distinct rhythm gets a distinct fingerprint" + ); +} + +#[test] +fn rhythm_diagnostics_no_templates_reads_as_quarter_fallback() { + // No usable template → zero effective grids (the caller then knows the + // quarter fallback was used); no fingerprints. + let diag = rhythm_diagnostics(&[], Ticks(1920)); + assert_eq!(diag.loaded, 0); + assert_eq!(diag.effective, 0); + assert!(diag.fingerprints.is_empty()); +} diff --git a/core/tests/rule_generator.rs b/core/tests/rule_generator.rs index 5d67346a..d6d188ca 100644 --- a/core/tests/rule_generator.rs +++ b/core/tests/rule_generator.rs @@ -14,7 +14,7 @@ use griff_core::{ event::{Pitch, Tempo, Ticks, TimeSignature}, generate::{ generate, GenerationCandidate, GenerationConstraints, GenerationError, GenerationSeed, - GenerationStrategy, PitchMaterial, RuleGenerationRequest, + GenerationStrategy, PitchMaterial, RhythmTemplate, RuleGenerationRequest, }, score::{AtomEvent, AtomNote, MasterBar, Voice}, }; @@ -42,8 +42,8 @@ fn constraints_2_bars_4_4() -> GenerationConstraints { } } -fn quarter_rhythm() -> Vec { - vec![Ticks(480); 4] // four quarter notes per bar +fn quarter_rhythm() -> RhythmTemplate { + RhythmTemplate::from_durations(&[Ticks(480); 4]) // four quarter notes per bar } fn request(strategy: GenerationStrategy) -> RuleGenerationRequest { diff --git a/core/tests/shuffle_window.rs b/core/tests/shuffle_window.rs new file mode 100644 index 00000000..a0726f0c --- /dev/null +++ b/core/tests/shuffle_window.rs @@ -0,0 +1,373 @@ +// TDD red phase: Shuffle-only LadderWindow (arbiter 2026-07-12). The register +// A/B confirmed reachability but flagged a coherence regression isolated to +// ShuffleMotifs: drawing every note from the whole ladder blew the register up +// (wide-synthetic median span ~11→46, octave-leap share ~0→0.62). Fix: a +// deterministic per-candidate contiguous ladder window (≤ one octave span) for +// Shuffle only — the full ladder stays the source of reachability, one window +// is the locally-coherent subset one candidate uses. +// +// This increment touches ShuffleMotifs ONLY; the other four strategies are +// unchanged. No rerank axis, no octave-leap rejection, no weight change. +// +// References griff_core::pitch::{LadderWindow, RegisterStats} and +// ScaleLadder::octave_window, which do not exist yet, so the suite fails to +// compile until the green step. +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + clippy::missing_assert_message, + clippy::missing_const_for_fn, + clippy::arithmetic_side_effects +)] + +use std::collections::BTreeSet; + +use griff_core::{ + event::{Pitch, Tempo, Ticks, TimeSignature}, + generate::{ + generate, GenerationConstraints, GenerationSeed, GenerationStrategy, PitchMaterial, + RuleGenerationRequest, + }, + pitch::{PitchClassSet, PitchRange, RegisterStats, ScaleLadder}, + score::{AtomEvent, Score}, +}; + +// ── LadderWindow primitive ───────────────────────────────────────────────────── + +#[test] +fn octave_window_spans_at_most_one_octave() { + // Chromatic ladder over 3 octaves: every selector's window spans ≤ 12 + // semitones and is never empty. + let classes = PitchClassSet::new(0..12); + let ladder = ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("ok"); + for selector in 0..64 { + let w = ladder.octave_window(selector); + assert!(w.len() >= 1, "selector {selector}: window never empty"); + let ps: Vec = w.pitches().iter().map(|p| p.0).collect(); + assert!( + ps.last().unwrap() - ps.first().unwrap() <= 12, + "selector {selector}: span {} > one octave", + ps.last().unwrap() - ps.first().unwrap() + ); + } +} + +#[test] +fn octave_window_selectors_cover_the_full_ladder() { + // Across selectors the windows reach both the lowest and the highest rung + // — no systematic top/bottom bias. + let classes = PitchClassSet::new([0, 3, 5, 7, 10]); + let ladder = ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("ok"); + let mut union: BTreeSet = BTreeSet::new(); + for selector in 0..128 { + for p in ladder.octave_window(selector).pitches() { + union.insert(p.0); + } + } + let rungs: Vec = ladder.pitches().iter().map(|p| p.0).collect(); + assert_eq!( + union.iter().min(), + rungs.first(), + "windows reach the bottom rung" + ); + assert_eq!( + union.iter().max(), + rungs.last(), + "windows reach the top rung" + ); +} + +#[test] +fn octave_window_handles_narrow_and_single_rung_ladders() { + // A ladder narrower than an octave → the window is the whole ladder. + let classes = PitchClassSet::new([0, 4, 7]); + let narrow = ScaleLadder::build(&PitchRange::new(Pitch(48), Pitch(55)), &classes).expect("ok"); + assert_eq!(narrow.octave_window(3).len(), narrow.len()); + + // A single-rung ladder → a one-pitch window. + let one = ScaleLadder::build( + &PitchRange::new(Pitch(47), Pitch(49)), + &PitchClassSet::new([0]), + ) + .expect("one C"); + let w = one.octave_window(9); + assert_eq!(w.len(), 1); + assert_eq!(w.pitches(), &[Pitch(48)]); +} + +#[test] +fn register_stats_measure_interval_shape() { + // Exact-octave line [40,52,64]: intervals 12, 12 — exactly an octave each, + // so over-octave (> 12) is 0 while exact-octave (== 12) is all of them. + let stats = RegisterStats::measure(&[40, 52, 64]); + assert!( + (stats.mean_abs_interval - 12.0).abs() < 1e-9, + "mean interval 12" + ); + assert_eq!(stats.max_abs_interval, 12, "max interval 12"); + assert!( + (stats.over_octave_share - 0.0).abs() < 1e-9, + "no interval > 12" + ); + assert!( + (stats.exact_octave_share - 1.0).abs() < 1e-9, + "every interval == 12" + ); + assert!( + (stats.at_least_octave_share - 1.0).abs() < 1e-9, + ">= 12 is all" + ); + + // A flat line has zero spread and zero leaps of any kind. + let flat = RegisterStats::measure(&[50, 50, 50]); + assert_eq!(flat.max_abs_interval, 0); + assert!((flat.over_octave_share - 0.0).abs() < 1e-9); + assert!((flat.exact_octave_share - 0.0).abs() < 1e-9); + assert!((flat.at_least_octave_share - 0.0).abs() < 1e-9); + assert!((flat.pitch_stddev - 0.0).abs() < 1e-9); +} + +#[test] +fn register_stats_split_octave_families_on_mixed_intervals() { + // Intervals 5, 12, 13: a below-octave step, an exact octave, and an + // over-octave leap — the three octave shares must separate cleanly. + let stats = RegisterStats::measure(&[50, 55, 67, 80]); + assert_eq!(stats.max_abs_interval, 13); + assert!((stats.mean_abs_interval - 10.0).abs() < 1e-9, "(5+12+13)/3"); + assert!( + (stats.over_octave_share - 1.0 / 3.0).abs() < 1e-9, + "only 13 is > 12" + ); + assert!( + (stats.exact_octave_share - 1.0 / 3.0).abs() < 1e-9, + "only 12 is == 12" + ); + assert!( + (stats.at_least_octave_share - 2.0 / 3.0).abs() < 1e-9, + "12 and 13 are >= 12" + ); +} + +// ── anchor selection (unbiased) ──────────────────────────────────────────────── + +#[test] +fn octave_window_count_is_the_full_octave_anchor_count() { + // The anchor count is the number of rungs that leave a full octave above + // them (rungs ≤ top − 12), floored at 1 — for both a dense (chromatic) and + // a sparse (pentatonic) palette. + for classes in [ + PitchClassSet::new(0..12), + PitchClassSet::new([0, 3, 5, 7, 10]), + ] { + let ladder = + ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("ok"); + let rungs: Vec = ladder.pitches().iter().map(|p| p.0).collect(); + let top = *rungs.last().unwrap(); + let expected = rungs + .iter() + .filter(|&&p| p <= top.saturating_sub(12)) + .count() + .max(1); + assert_eq!( + ladder.octave_window_count(), + expected, + "anchor count must be the full-octave anchor count" + ); + } +} + +#[test] +fn every_anchor_index_is_a_nonempty_octave_window() { + let classes = PitchClassSet::new(0..12); + let ladder = ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("ok"); + for anchor in 0..ladder.octave_window_count() { + let w = ladder.octave_window(anchor); + assert!(w.len() >= 1, "anchor {anchor}: window never empty"); + let ps: Vec = w.pitches().iter().map(|p| p.0).collect(); + assert!( + ps.last().unwrap() - ps.first().unwrap() <= 12, + "anchor {anchor}: span exceeds one octave" + ); + } +} + +#[test] +fn first_and_last_anchors_reach_low_and_high_ends() { + let classes = PitchClassSet::new([0, 3, 5, 7, 10]); + let ladder = ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("ok"); + let count = ladder.octave_window_count(); + assert_eq!( + ladder.octave_window(0).pitches().first(), + ladder.pitches().first(), + "anchor 0 starts at the lowest rung" + ); + assert_eq!( + ladder.octave_window(count - 1).pitches().last(), + ladder.pitches().last(), + "the top anchor reaches the highest rung" + ); +} + +#[test] +fn cycling_anchor_indices_visits_each_window_once() { + let classes = PitchClassSet::new(0..12); + let ladder = ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("ok"); + let count = ladder.octave_window_count(); + let starts: Vec = (0..count) + .map(|a| ladder.octave_window(a).pitches()[0].0) + .collect(); + let mut unique = starts.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!( + unique.len(), + count, + "each anchor index is a distinct window" + ); + // `count` (one past the last valid index) wraps to anchor 0 — the only + // modulo, so the caller passing `next_mod(count)` never double-mods. + assert_eq!(ladder.octave_window(count).pitches()[0].0, starts[0]); +} + +// ── Shuffle generation contract ───────────────────────────────────────────────── + +fn chromatic() -> PitchMaterial { + PitchMaterial { + root: Pitch(28), + intervals: (0..12).collect(), + } +} + +fn wide(bar_count: usize) -> GenerationConstraints { + GenerationConstraints { + bar_count, + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + ticks_per_quarter: Ticks(480), + pitch_lo: Pitch(28), + pitch_hi: Pitch(64), + } +} + +fn shuffle_request(seed: u64, constraints: GenerationConstraints) -> RuleGenerationRequest { + RuleGenerationRequest { + seed: GenerationSeed(seed), + pitch_material: chromatic(), + constraints, + source_rhythms: Vec::new(), + strategy: GenerationStrategy::ShuffleMotifs, + } +} + +fn pitches(score: &Score) -> Vec { + score.tracks[0].voices[0] + .event_groups + .iter() + .flat_map(|g| g.atoms.iter()) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some(n.pitch.0), + AtomEvent::Rest(_) => None, + }) + .collect() +} + +#[test] +fn shuffle_candidate_stays_within_one_octave() { + for seed in [1_u64, 2, 7, 42, 99, 1000, 55_555] { + let c = generate(&shuffle_request(seed, wide(8))).expect("generate"); + let ps = pitches(&c.score); + assert!(!ps.is_empty(), "seed {seed}: no empty line"); + let (lo, hi) = (*ps.iter().min().unwrap(), *ps.iter().max().unwrap()); + assert!( + hi - lo <= 12, + "seed {seed}: candidate span {} exceeds one octave", + hi - lo + ); + for p in &ps { + assert!((28..=64).contains(p), "seed {seed}: pitch {p} out of range"); + assert!(chromatic().pitch_classes().contains_pitch(Pitch(*p))); + } + // A one-octave window admits no strictly-over-octave leap. + assert!( + (RegisterStats::measure(&ps).over_octave_share - 0.0).abs() < 1e-9, + "seed {seed}: over-octave share must be zero inside a window" + ); + } +} + +#[test] +fn shuffle_variants_cover_below_middle_and_above_first_octave() { + // Union across a stable seed set must reach the bottom, the second octave, + // and the top — reachability is retained, only per-candidate locality is + // bounded. (Not every variant must hit the exact endpoints.) + let mut union: BTreeSet = BTreeSet::new(); + for seed in 0..200_u64 { + for p in pitches( + &generate(&shuffle_request(seed, wide(8))) + .expect("generate") + .score, + ) { + union.insert(p); + } + } + assert!( + union.iter().any(|&p| p < 40), + "reaches the first octave (28..40)" + ); + assert!( + union.iter().any(|&p| (40..52).contains(&p)), + "reaches the second octave" + ); + assert!( + union.iter().any(|&p| p >= 52), + "reaches above the second octave" + ); +} + +#[test] +fn shuffle_is_deterministic() { + let a = generate(&shuffle_request(321, wide(8))).expect("a"); + let b = generate(&shuffle_request(321, wide(8))).expect("b"); + assert_eq!(pitches(&a.score), pitches(&b.score)); +} + +#[test] +fn shuffle_narrow_ladder_under_one_octave() { + let narrow = GenerationConstraints { + pitch_lo: Pitch(40), + pitch_hi: Pitch(45), + ..wide(4) + }; + let c = generate(&shuffle_request(5, narrow)).expect("narrow"); + let ps = pitches(&c.score); + assert!(!ps.is_empty()); + for p in ps { + assert!((40..=45).contains(&p), "narrow pitch {p} out of range"); + } +} + +#[test] +fn shuffle_single_rung_ladder_repeats_one_pitch() { + // Range [47,49] with palette {C}: exactly one in-class pitch (C=48). + let one = GenerationConstraints { + pitch_lo: Pitch(47), + pitch_hi: Pitch(49), + ..wide(4) + }; + let req = RuleGenerationRequest { + pitch_material: PitchMaterial { + root: Pitch(48), + intervals: vec![0], + }, + ..shuffle_request(5, one) + }; + let c = generate(&req).expect("single rung"); + let ps = pitches(&c.score); + assert!(!ps.is_empty()); + assert!(ps.iter().all(|&p| p == 48), "single-rung line is all C"); +} diff --git a/core/tests/snapshots/generate__deterministic_7_8.txt b/core/tests/snapshots/generate__deterministic_7_8.txt index 5379f026..bccf6774 100644 --- a/core/tests/snapshots/generate__deterministic_7_8.txt +++ b/core/tests/snapshots/generate__deterministic_7_8.txt @@ -1,13 +1,22 @@ ppqn 480 master_bars 3 - g0 note @0 dur=480 pitch=40 vel=80 - g1 note @480 dur=480 pitch=40 vel=80 - g2 note @960 dur=480 pitch=43 vel=80 - g3 note @1440 dur=240 pitch=40 vel=80 - g4 note @1680 dur=480 pitch=40 vel=80 - g5 note @2160 dur=480 pitch=40 vel=80 - g6 note @2640 dur=480 pitch=43 vel=80 - g7 note @3120 dur=240 pitch=45 vel=80 - g8 note @3360 dur=480 pitch=47 vel=80 - g9 note @3840 dur=480 pitch=50 vel=80 - g10 note @4320 dur=480 pitch=52 vel=80 - g11 note @4800 dur=240 pitch=55 vel=80 + g0 note @0 dur=240 pitch=38 vel=80 + g1 note @240 dur=240 pitch=38 vel=80 + g2 note @480 dur=240 pitch=40 vel=80 + g3 note @720 dur=240 pitch=38 vel=80 + g4 note @960 dur=240 pitch=38 vel=80 + g5 note @1200 dur=240 pitch=38 vel=80 + g6 note @1440 dur=240 pitch=40 vel=80 + g7 note @1680 dur=240 pitch=43 vel=80 + g8 note @1920 dur=240 pitch=45 vel=80 + g9 note @2160 dur=240 pitch=47 vel=80 + g10 note @2400 dur=240 pitch=50 vel=80 + g11 note @2640 dur=240 pitch=52 vel=80 + g12 note @2880 dur=240 pitch=55 vel=80 + g13 note @3120 dur=240 pitch=57 vel=80 + g14 note @3360 dur=240 pitch=59 vel=80 + g15 note @3600 dur=240 pitch=57 vel=80 + g16 note @3840 dur=240 pitch=55 vel=80 + g17 note @4080 dur=240 pitch=57 vel=80 + g18 note @4320 dur=240 pitch=55 vel=80 + g19 note @4560 dur=240 pitch=57 vel=80 + g20 note @4800 dur=240 pitch=55 vel=80 diff --git a/core/tests/structure_control.rs b/core/tests/structure_control.rs index 508252bc..08ff52bf 100644 --- a/core/tests/structure_control.rs +++ b/core/tests/structure_control.rs @@ -23,7 +23,7 @@ use griff_core::{ event::{Pitch, Tempo, Ticks, TimeSignature}, generate::{ generate, GenerationConstraints, GenerationSeed, GenerationStrategy, PitchMaterial, - RuleGenerationRequest, + RhythmTemplate, RuleGenerationRequest, }, score::{AtomEvent, Score}, structure::{generate_structured, StructureControl, StructureGenError, StructuredRequest}, @@ -60,7 +60,7 @@ fn request(target_bars: usize, control: StructureControl) -> StructuredRequest { seed: GenerationSeed(7), pitch_material: c_major(), constraints: constraints(target_bars), - source_rhythms: vec![vec![Ticks(480); 4]], + source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], strategy: GenerationStrategy::ConstrainedRandomWalk, control, } @@ -209,7 +209,7 @@ fn through_composed_control_delegates_to_s6() { seed: GenerationSeed(7), pitch_material: c_major(), constraints: constraints(4), - source_rhythms: vec![vec![Ticks(480); 4]], + source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], strategy: GenerationStrategy::ConstrainedRandomWalk, }) .expect("plain S6 ok"); diff --git a/core/tests/tonal.rs b/core/tests/tonal.rs new file mode 100644 index 00000000..986cfdce --- /dev/null +++ b/core/tests/tonal.rs @@ -0,0 +1,367 @@ +// TDD red phase: the shared pure-core tonal evidence/inference layer +// (TonalContext Phase 1, arbiter 2026-07-12). Generalises the private +// `complement::estimate_harmony` into two separated layers: +// +// - `PitchEvidence` — raw, observed pitch-class facts for an explicit +// `EvidenceScope` (whole-score / track / voice): raw onset counts, duration +// mass in ticks, and the observed `feature::PitchRange`. No thresholds, no +// key. Additive across scopes (whole score = Σ tracks = Σ voices). +// - `TonalEstimate` — a ranked 24-key Krumhansl–Schmuckler inference with an +// explicit `confidence_margin`; each `TonalCandidate` carries tonic, mode, +// correlation and scale_fit. +// +// KS v1 is duration-only: duration mass weights the histogram; the raw +// onset-count fallback applies *only* when the total duration mass is zero. No +// onset/duration blend and no metric-accent policy in Phase 1. +// +// References `griff_core::tonal`, which does not exist yet, so the suite fails +// to compile until the green step. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::missing_assert_message, + clippy::indexing_slicing, + clippy::missing_const_for_fn, + clippy::float_cmp, + clippy::arithmetic_side_effects, + clippy::str_to_string, + clippy::doc_markdown +)] + +use griff_core::{ + event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}, + feature::PitchRange, + score::{ + AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, + Score, Track, Voice, + }, + slice::TickRange, + tonal::{estimate_key, EvidenceScope, KeyMode, PitchEvidence}, +}; + +const PPQN: u16 = 480; +const QUARTER: u32 = 480; +const BAR: u32 = 1920; // 4/4 at 480 PPQN + +fn quarter_note(start: u32, pitch: u8) -> AtomEvent { + AtomEvent::Note(AtomNote { + absolute_start: Ticks(start), + duration: Ticks(QUARTER), + pitch: Pitch::new(pitch).expect("valid pitch"), + velocity: Velocity::new(90).expect("valid velocity"), + marks: NoteMarks::empty(), + position: None, + }) +} + +fn single_group(atom: AtomEvent) -> EventGroup { + EventGroup { + kind: EventGroupKind::Single, + atoms: vec![atom], + technique_spans: Vec::new(), + } +} + +fn master_bars(bar_count: usize) -> Vec { + (0..bar_count) + .map(|i| { + let start = u32::try_from(i).unwrap() * BAR; + MasterBar { + index: i, + tick_range: TickRange::new(Ticks(start), Ticks(start + BAR)).expect("ordered"), + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo::new(120.0).expect("120 BPM"), + repeat: RepeatMarker::default(), + } + }) + .collect() +} + +fn voice_with(pitches: &[u8]) -> Voice { + let groups = pitches + .iter() + .enumerate() + .map(|(i, &p)| single_group(quarter_note(u32::try_from(i).unwrap() * QUARTER, p))) + .collect(); + Voice { + id: 0, + event_groups: groups, + } +} + +fn track_with_voices(voices: Vec) -> Track { + Track { + name: Some("T".to_string()), + channel: 0, + voices, + tuning: Tuning::standard_e(), + } +} + +fn score_with_tracks(tracks: Vec) -> Score { + Score { + ticks_per_quarter: PPQN, + master_bars: master_bars(1), + tracks, + source_meta: None, + loss: LossReport::new(), + } +} + +// Pitch-class histogram of the clean C major scale with the tonic doubled at the +// octave (C D E F G A B C) — the exact shape complement's key test uses. +const C_MAJOR_COUNTS: [u32; 12] = [2, 0, 1, 0, 1, 1, 0, 1, 0, 1, 0, 1]; + +// ── evidence: additive across scopes ───────────────────────────────────────── + +#[test] +fn whole_score_evidence_is_the_sum_of_its_tracks() { + let score = score_with_tracks(vec![ + track_with_voices(vec![voice_with(&[60, 64, 67])]), // C E G + track_with_voices(vec![voice_with(&[62, 65, 69])]), // D F A + ]); + + let whole = PitchEvidence::measure(&score, EvidenceScope::WholeScore); + let t0 = PitchEvidence::measure(&score, EvidenceScope::Track(0)); + let t1 = PitchEvidence::measure(&score, EvidenceScope::Track(1)); + + assert_eq!(whole.note_count, t0.note_count + t1.note_count); + assert_eq!(whole.note_count, 6); + for pc in 0..12 { + assert_eq!( + whole.onset_counts[pc], + t0.onset_counts[pc] + t1.onset_counts[pc], + "onset counts add across tracks at pc {pc}" + ); + assert_eq!( + whole.duration_mass[pc], + t0.duration_mass[pc] + t1.duration_mass[pc], + "duration mass adds across tracks at pc {pc}" + ); + } + // Whole-score range spans both tracks: C4 (60) .. A4 (69). + assert_eq!( + whole.pitch_range, + Some(PitchRange { + lowest: Pitch::new(60).unwrap(), + highest: Pitch::new(69).unwrap(), + }) + ); +} + +#[test] +fn track_evidence_is_the_sum_of_its_voices() { + let score = score_with_tracks(vec![track_with_voices(vec![ + voice_with(&[60, 64]), // voice 0: C E + voice_with(&[67, 72]), // voice 1: G C + ])]); + + let track = PitchEvidence::measure(&score, EvidenceScope::Track(0)); + let v0 = PitchEvidence::measure(&score, EvidenceScope::Voice { track: 0, voice: 0 }); + let v1 = PitchEvidence::measure(&score, EvidenceScope::Voice { track: 0, voice: 1 }); + + assert_eq!(track.note_count, v0.note_count + v1.note_count); + assert_eq!(track.note_count, 4); + for pc in 0..12 { + assert_eq!( + track.onset_counts[pc], + v0.onset_counts[pc] + v1.onset_counts[pc] + ); + assert_eq!( + track.duration_mass[pc], + v0.duration_mass[pc] + v1.duration_mass[pc] + ); + } +} + +#[test] +fn a_silent_scope_yields_empty_evidence_and_no_estimate() { + let score = score_with_tracks(vec![track_with_voices(vec![voice_with(&[60])])]); + + // Out-of-range track: no notes, no range, no estimate. + let empty = PitchEvidence::measure(&score, EvidenceScope::Track(9)); + assert_eq!(empty.note_count, 0); + assert_eq!(empty.onset_counts, [0_u32; 12]); + assert_eq!(empty.duration_mass, [0_u64; 12]); + assert_eq!(empty.pitch_range, None); + assert!(estimate_key(&empty).is_none()); +} + +// ── inference: 24 ranked candidates, explicit margin ───────────────────────── + +#[test] +fn estimate_ranks_all_twenty_four_keys_best_first() { + let evidence = PitchEvidence { + scope: EvidenceScope::WholeScore, + note_count: 8, + onset_counts: C_MAJOR_COUNTS, + duration_mass: C_MAJOR_COUNTS.map(|c| u64::from(c) * u64::from(QUARTER)), + pitch_range: None, + }; + + let est = estimate_key(&evidence).expect("non-empty evidence estimates a key"); + assert_eq!(est.candidates.len(), 24, "all 12 tonics × 2 modes ranked"); + + // Descending by correlation. + for pair in est.candidates.windows(2) { + assert!( + pair[0].correlation >= pair[1].correlation, + "candidates are ranked best-first" + ); + } + + let winner = &est.candidates[0]; + assert_eq!(winner.tonic, 0, "C"); + assert_eq!(winner.mode, KeyMode::Major); + assert!( + (winner.scale_fit - 1.0).abs() < 1e-12, + "every note diatonic => winner scale_fit 1.0, got {}", + winner.scale_fit + ); + + // The margin is exactly the winner-vs-runner-up correlation gap, and this + // clean diatonic case is unambiguous (a real gap). + assert_eq!( + est.confidence_margin, + est.candidates[0].correlation - est.candidates[1].correlation + ); + assert!(est.confidence_margin > 0.0, "clean C major is unambiguous"); +} + +#[test] +fn every_candidate_carries_a_scale_fit_in_range() { + let evidence = PitchEvidence { + scope: EvidenceScope::WholeScore, + note_count: 8, + onset_counts: C_MAJOR_COUNTS, + duration_mass: C_MAJOR_COUNTS.map(|c| u64::from(c) * u64::from(QUARTER)), + pitch_range: None, + }; + let est = estimate_key(&evidence).expect("estimate"); + for c in &est.candidates { + assert!( + (0.0..=1.0).contains(&c.scale_fit), + "scale_fit is a fraction, got {} for tonic {} {:?}", + c.scale_fit, + c.tonic, + c.mode + ); + assert!(c.correlation.is_finite()); + } +} + +// ── flat chromatic histogram: zero confidence, C-major-first, no confidence ── + +#[test] +fn an_exactly_flat_histogram_has_zero_correlation_and_zero_margin() { + // Programmatically flat: every class carries identical onset count *and* + // identical duration mass. This is the zero-margin characterisation — the + // deterministic C-major-first order below must NOT be read as confidence. + let evidence = PitchEvidence { + scope: EvidenceScope::WholeScore, + note_count: 12, + onset_counts: [1_u32; 12], + duration_mass: [u64::from(QUARTER); 12], + pitch_range: None, + }; + + let est = estimate_key(&evidence).expect("a full chromatic scope still estimates"); + assert_eq!(est.candidates.len(), 24); + for c in &est.candidates { + assert!(c.correlation.is_finite(), "flat histogram stays finite"); + assert_eq!( + c.correlation, 0.0, + "a flat histogram correlates with nothing" + ); + } + assert_eq!( + est.confidence_margin, 0.0, + "no winner over a flat histogram" + ); + + // Deterministic tie order: C major sorts first. This is an ordering + // convention, not a claim of confidence — the zero margin says so. + let winner = &est.candidates[0]; + assert_eq!(winner.tonic, 0); + assert_eq!(winner.mode, KeyMode::Major); +} + +// ── KS v1 weighting: duration mass normally, onset-count fallback at zero ───── + +#[test] +fn duration_mass_decides_when_present() { + // Onset counts point at B (all onsets on pc 11); duration mass spells C + // major. Duration must win in KS v1. + let evidence = PitchEvidence { + scope: EvidenceScope::WholeScore, + note_count: 8, + onset_counts: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 8], + duration_mass: C_MAJOR_COUNTS.map(|c| u64::from(c) * u64::from(QUARTER)), + pitch_range: None, + }; + let winner = estimate_key(&evidence) + .expect("estimate") + .candidates + .swap_remove(0); + assert_eq!( + winner.tonic, 0, + "duration mass (C major) decides, not onsets" + ); + assert_eq!(winner.mode, KeyMode::Major); +} + +#[test] +fn onset_counts_are_the_fallback_only_when_duration_mass_is_zero() { + // No duration mass at all: the estimate falls back to raw onset counts and + // stays defined. + let evidence = PitchEvidence { + scope: EvidenceScope::WholeScore, + note_count: 8, + onset_counts: C_MAJOR_COUNTS, + duration_mass: [0_u64; 12], + pitch_range: None, + }; + let winner = estimate_key(&evidence) + .expect("onset fallback keeps the estimate defined") + .candidates + .swap_remove(0); + assert_eq!(winner.tonic, 0, "C"); + assert_eq!(winner.mode, KeyMode::Major); + assert!((winner.scale_fit - 1.0).abs() < 1e-12); +} + +// ── preserved complement winner (characterisation at the tonal layer) ──────── + +#[test] +fn estimate_reproduces_the_complement_minor_winner_and_tie_order() { + // E natural minor with the tonic doubled (E3 + E4): pitch-class set equals G + // major's, so only the tonal weighting picks the minor tonic — the exact + // case complement pins. The shared estimator must reproduce E minor. + let e_minor: [u32; 12] = pc_counts(&[52, 55, 57, 59, 60, 62, 64]); + let evidence = PitchEvidence { + scope: EvidenceScope::WholeScore, + note_count: 7, + onset_counts: e_minor, + duration_mass: e_minor.map(|c| u64::from(c) * u64::from(QUARTER)), + pitch_range: None, + }; + let winner = estimate_key(&evidence) + .expect("estimate") + .candidates + .swap_remove(0); + assert_eq!(winner.tonic, 4, "E"); + assert_eq!(winner.mode, KeyMode::Minor); +} + +fn pc_counts(pitches: &[u8]) -> [u32; 12] { + let mut counts = [0_u32; 12]; + for &p in pitches { + counts[usize::from(p) % 12] += 1; + } + counts +} diff --git a/core/tests/wrap_free_traversal.rs b/core/tests/wrap_free_traversal.rs new file mode 100644 index 00000000..0110cfc0 --- /dev/null +++ b/core/tests/wrap_free_traversal.rs @@ -0,0 +1,434 @@ +// TDD red phase: wrap-free full-ladder traversal (arbiter 2026-07-12, commit +// B). After the Shuffle window fix all 21 residual production winners with a +// max interval > 12 were RhythmCopyPitchSubstitute: its ascending degree walk +// wrapped modulo the whole ladder (…top, 0, …) producing a 44–57 semitone +// downward jump. RepeatVariation had a latent candidate-level twin: its +// variation degree wrapped `base + 2` modulo the whole ladder. +// +// Fix (this increment): a reflecting degree cursor for RhythmCopy over the +// full ladder (…3 4 3 2 1 0 1…, no modulo), and a local two-degree +// displacement for RepeatVariation. No rerank axis, no weight change; the +// three other strategies (Shuffle/Motif/CRW) are untouched. +// +// Behaviour lives in griff_core::generate; this suite asserts the observable +// contract, so it fails on the current wrapping code until the green step. +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + clippy::missing_assert_message, + clippy::missing_const_for_fn, + clippy::arithmetic_side_effects +)] + +use std::collections::BTreeSet; + +use griff_core::{ + event::{Pitch, Tempo, Ticks, TimeSignature}, + generate::{ + generate, GenerationConstraints, GenerationSeed, GenerationStrategy, PitchMaterial, + RhythmTemplate, RuleGenerationRequest, + }, + pitch::{PitchRange, ScaleLadder}, + score::{AtomEvent, Score}, +}; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +fn material(intervals: Vec) -> PitchMaterial { + PitchMaterial { + root: Pitch(28), + intervals, + } +} + +fn chromatic() -> PitchMaterial { + material((0..12).collect()) +} + +fn pentatonic() -> PitchMaterial { + material(vec![0, 3, 5, 7, 10]) +} + +fn wide(bar_count: usize) -> GenerationConstraints { + GenerationConstraints { + bar_count, + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + ticks_per_quarter: Ticks(480), + pitch_lo: Pitch(28), + pitch_hi: Pitch(64), + } +} + +fn request( + strategy: GenerationStrategy, + pm: PitchMaterial, + seed: u64, + constraints: GenerationConstraints, +) -> RuleGenerationRequest { + RuleGenerationRequest { + seed: GenerationSeed(seed), + pitch_material: pm, + constraints, + source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], + strategy, + } +} + +fn pitches(score: &Score) -> Vec { + score.tracks[0].voices[0] + .event_groups + .iter() + .flat_map(|g| g.atoms.iter()) + .filter_map(|a| match a { + AtomEvent::Note(n) => Some(n.pitch.0), + AtomEvent::Rest(_) => None, + }) + .collect() +} + +/// The ladder the generator uses for a material over the wide range. +fn ladder_of(pm: &PitchMaterial) -> Vec { + let c = wide(1); + ScaleLadder::build( + &PitchRange::new(c.pitch_lo, c.pitch_hi), + &pm.pitch_classes(), + ) + .expect("in-class") + .pitches() + .iter() + .map(|p| p.0) + .collect() +} + +fn max_abs_interval(ps: &[u8]) -> u8 { + ps.windows(2) + .map(|w| w[0].abs_diff(w[1])) + .max() + .unwrap_or(0) +} + +/// The longest run of a single repeated pitch. +fn longest_repeat(ps: &[u8]) -> usize { + let mut best = 0; + let mut run = 0; + let mut prev = None; + for &p in ps { + if Some(p) == prev { + run += 1; + } else { + run = 1; + prev = Some(p); + } + best = best.max(run); + } + best +} + +// ── RhythmCopy: reflecting traversal ──────────────────────────────────────────── + +#[test] +fn rhythm_copy_moves_by_adjacent_rungs_without_wrap() { + for pm in [chromatic(), pentatonic()] { + let ladder = ladder_of(&pm); + let index_of = |p: u8| -> usize { + ladder + .iter() + .position(|&r| r == p) + .expect("pitch on ladder") + }; + for seed in [1_u64, 2, 7, 42, 99, 1000, 55_555] { + let c = generate(&request( + GenerationStrategy::RhythmCopyPitchSubstitute, + pm.clone(), + seed, + wide(8), + )) + .expect("generate"); + let ps = pitches(&c.score); + assert!(!ps.is_empty(), "seed {seed}: no empty line"); + // Every consecutive step is one ladder rung — no high->low wrap. + for w in ps.windows(2) { + let (a, b) = (index_of(w[0]), index_of(w[1])); + assert_eq!( + a.abs_diff(b), + 1, + "seed {seed}: {} -> {} is not an adjacent rung (wrap?)", + w[0], + w[1] + ); + } + assert!( + max_abs_interval(&ps) <= 12, + "seed {seed}: interval > 12 (wrap)" + ); + // Reflection never dwells on the top rung. + assert!(longest_repeat(&ps) <= 1, "seed {seed}: prolonged clamp"); + for p in &ps { + assert!((28..=64).contains(p)); + assert!(pm.pitch_classes().contains_pitch(Pitch(*p))); + } + } + } +} + +#[test] +fn rhythm_copy_union_reaches_low_middle_high() { + let pm = pentatonic(); + let ladder = ladder_of(&pm); + let (lo, hi) = (ladder[0], *ladder.last().unwrap()); + let mut union: BTreeSet = BTreeSet::new(); + for seed in 0..40 { + let c = generate(&request( + GenerationStrategy::RhythmCopyPitchSubstitute, + pm.clone(), + seed, + wide(8), + )) + .expect("generate"); + union.extend(pitches(&c.score)); + } + let mid = (u16::from(lo) + u16::from(hi)) / 2; + assert!( + union.iter().any(|&p| p <= lo + 4), + "reaches the low register" + ); + assert!( + union.iter().any(|&p| u16::from(p).abs_diff(mid) <= 3), + "reaches the middle register" + ); + assert!( + union.iter().any(|&p| p >= hi - 4), + "reaches the high register" + ); +} + +#[test] +fn rhythm_copy_is_deterministic() { + let a = generate(&request( + GenerationStrategy::RhythmCopyPitchSubstitute, + chromatic(), + 123, + wide(8), + )) + .expect("a"); + let b = generate(&request( + GenerationStrategy::RhythmCopyPitchSubstitute, + chromatic(), + 123, + wide(8), + )) + .expect("b"); + assert_eq!(pitches(&a.score), pitches(&b.score)); +} + +// ── RepeatVariation: local displacement ───────────────────────────────────────── + +#[test] +fn repeat_variation_stays_local_without_wrap() { + // A wide seed sweep so a base degree near the top (the latent wrap case, + // ~16/500 candidates) is certainly hit. + for pm in [chromatic(), pentatonic()] { + for seed in 0..256_u64 { + let c = generate(&request( + GenerationStrategy::RepeatVariation, + pm.clone(), + seed, + wide(8), + )) + .expect("generate"); + let ps = pitches(&c.score); + assert!(!ps.is_empty(), "seed {seed}: no empty line"); + for p in &ps { + assert!((28..=64).contains(p), "seed {seed}: pitch {p} out of range"); + assert!(pm.pitch_classes().contains_pitch(Pitch(*p))); + } + // No full-ladder wrap: the varied last note is a *local* move from + // the base, never a multi-octave jump. + assert!( + max_abs_interval(&ps) <= 12, + "seed {seed}: interval > 12 (variation wrap)" + ); + } + } +} + +#[test] +fn repeat_variation_narrow_ladders_work() { + // Two-rung and one-rung ladders must not panic and stay in bounds. + for (lo, hi, classes) in [(48_u8, 51_u8, vec![0_u8, 7]), (48, 49, vec![0])] { + let pm = PitchMaterial { + root: Pitch(48), + intervals: classes, + }; + let constraints = GenerationConstraints { + pitch_lo: Pitch(lo), + pitch_hi: Pitch(hi), + ..wide(4) + }; + let c = generate(&request( + GenerationStrategy::RepeatVariation, + pm.clone(), + 5, + constraints, + )) + .expect("generate"); + for p in pitches(&c.score) { + assert!((lo..=hi).contains(&p), "pitch {p} out of [{lo},{hi}]"); + } + } +} + +// ── RepeatVariation: endpoint-local on DENSE grids ────────────────────────────── + +/// An `n`-note template filling a 4/4 bar (1920 ticks) — a dense grid whose +/// ascending base bar climbs many rungs, so a base-local variation would be a +/// large intra-bar drop from the (high) penultimate note. +fn dense_template(n: usize) -> RhythmTemplate { + let dur = u32::try_from(1920 / n.max(1)).unwrap_or(30); + RhythmTemplate::from_durations(&vec![Ticks(dur.max(1)); n]) +} + +/// `(material, lo, hi)` covering the arbiter's matrix. +fn repeat_materials() -> Vec<(PitchMaterial, u8, u8)> { + vec![ + (chromatic(), 28, 64), // wide chromatic + (pentatonic(), 28, 64), // wide pentatonic + (material_at(48, vec![0, 7]), 48, 55), // narrow two-rung (C,G) + (material_at(48, vec![0]), 48, 49), // single-rung + ] +} + +fn material_at(root: u8, intervals: Vec) -> PitchMaterial { + PitchMaterial { + root: Pitch(root), + intervals, + } +} + +/// Per-bar pitch lists (onset order within each 1920-tick bar). +fn bars_pitches(score: &Score) -> Vec> { + let mut bars = vec![Vec::new(); score.master_bars.len()]; + for group in &score.tracks[0].voices[0].event_groups { + for atom in &group.atoms { + if let AtomEvent::Note(n) = atom { + let bar = (n.absolute_start.0 / 1920) as usize; + if let Some(b) = bars.get_mut(bar) { + b.push(n.pitch.0); + } + } + } + } + bars +} + +#[test] +fn repeat_variation_endpoint_local_across_grid_sizes() { + for (pm, lo, hi) in repeat_materials() { + let constraints = GenerationConstraints { + pitch_lo: Pitch(lo), + pitch_hi: Pitch(hi), + ..wide(8) + }; + for &n in &[4_usize, 6, 8, 16, 32, 64] { + let template = dense_template(n); + for seed in 0..256_u64 { + let mut req = request( + GenerationStrategy::RepeatVariation, + pm.clone(), + seed, + constraints, + ); + req.source_rhythms = vec![template.clone()]; + let c = generate(&req).expect("dense repeat generates"); + let ps = pitches(&c.score); + assert!(!ps.is_empty(), "n={n} seed {seed}: empty line"); + for p in &ps { + assert!( + (lo..=hi).contains(p), + "n={n} seed {seed}: pitch {p} out of bounds" + ); + assert!( + pm.pitch_classes().contains_pitch(Pitch(*p)), + "n={n} seed {seed}: out of class" + ); + } + // Intra-bar intervals stay within an octave: the ascending + // climb is adjacent rungs, and the variation step from the + // (possibly high) penultimate is endpoint-local. The phrase + // boundary reset (each bar restarts the ascending figure at the + // base degree) is RepeatVariation's call/response identity and + // is deliberately not bounded here. + for (bar_index, bar) in bars_pitches(&c.score).iter().enumerate() { + assert!( + max_abs_interval(bar) <= 12, + "n={n} seed {seed} bar {bar_index}: intra-bar interval > 12" + ); + if bar_index >= 1 && bar.len() >= 2 { + let last = bar[bar.len() - 1]; + let penult = bar[bar.len() - 2]; + assert!( + last.abs_diff(penult) <= 12, + "n={n} seed {seed} bar {bar_index}: {penult} -> {last} exceeds an octave" + ); + } + } + } + } + } +} + +#[test] +fn repeat_variation_dense_grid_counterexample() { + // The proven counterexample: chromatic ladder (len 37), a 32-note grid. + // The ascending base bar reaches ~degree 31, so a base-local variation + // (~degree 2) is a ~28-semitone drop. The endpoint-local fix keeps the + // varied last note within an octave of the penultimate. + let pm = chromatic(); + for seed in 0..96_u64 { + let mut req = request( + GenerationStrategy::RepeatVariation, + pm.clone(), + seed, + wide(4), + ); + req.source_rhythms = vec![dense_template(32)]; + let c = generate(&req).expect("gen"); + for (bar_index, bar) in bars_pitches(&c.score).iter().enumerate() { + if bar_index >= 1 && bar.len() >= 2 { + let last = bar[bar.len() - 1]; + let penult = bar[bar.len() - 2]; + assert!( + last.abs_diff(penult) <= 12, + "seed {seed} bar {bar_index}: dense-grid jump {penult} -> {last}" + ); + } + } + } +} + +#[test] +fn repeat_variation_differs_from_final_and_is_deterministic() { + // On a wide ladder the variation must differ from the base bar's final + // note (an alternative always exists), and the request stays deterministic. + let pm = pentatonic(); + let mut req = request(GenerationStrategy::RepeatVariation, pm, 77, wide(8)); + req.source_rhythms = vec![dense_template(16)]; + let a = generate(&req).expect("a"); + let b = generate(&req).expect("b"); + assert_eq!(pitches(&a.score), pitches(&b.score), "deterministic"); + + let bars = bars_pitches(&a.score); + assert!(bars.len() >= 2); + let base_last = *bars[0].last().unwrap(); + let varied_last = *bars[1].last().unwrap(); + assert_ne!( + varied_last, base_last, + "variation must differ from the base bar's final note when possible" + ); +} diff --git a/docs/audit/2026-07-tonal-context-phase0.md b/docs/audit/2026-07-tonal-context-phase0.md new file mode 100644 index 00000000..4432f076 --- /dev/null +++ b/docs/audit/2026-07-tonal-context-phase0.md @@ -0,0 +1,262 @@ +# Tonal context — evidence / inference layer, Phase 0 design (2026-07) + +Status: **design note (not an ADR yet)** — no behavior change lands with it. +Input: the arbiter's follow-up to the accepted register track. The register +work made `PitchMaterial.root` an explicit pitch-class *anchor*, **not** a +tonic; cadence-aware endings are frozen precisely because no real tonal center +exists to cadence onto. This note audits the current contract, proposes a +typed evidence/inference layer with explicit uncertainty, and fixes a synthetic +test plan — **without** writing production tonal inference. Production +implementation waits on local `tonal_evidence` scan numbers. + +Heuristics-first (ADR-0008 / S12 gate): the inference is a +Krumhansl–Schmuckler correlation, never ML. This note reuses the estimator +griff already has rather than inventing a second one (the "one mapper" +principle the register track settled). + +## 0. One-line + +Split *evidence* (pure, observed pitch-class facts) from *inference* (a scored, +uncertain key estimate) as shared pure-core types; promote the existing +private, single-winner `complement::estimate_harmony` into that shape with a +candidate list and an explicit confidence margin — later, gated on corpus data. + +## 1. Current contract (audit — what is true today) + +### 1.1 How generation seeds pitch material + +`griff_cli::generation_input::generation_request_from_score`: + +- gathers **all** note pitches across **all** tracks and voices + (`all_pitches`); +- pitch range = the global `(min, max)` of those pitches + (`pitch_range` → `constraints.pitch_lo/hi`); +- `PitchMaterial.root` = the **global minimum** pitch; +- `PitchMaterial.intervals` = the distinct pitch classes, expressed as + semitone offsets from that minimum (`pitch_material_from`); +- `root` is an **anchor** that only contributes its pitch class to + `PitchMaterial::pitch_classes()` — it is **not** a tonic (register track, + accepted 2026-07-12). + +So the generator today has a pitch-class *palette* and a *range*, and no notion +of a tonal center, key, or mode. Every strategy walks the `ScaleLadder` built +from that palette; nothing knows which class is "home". + +### 1.2 Tonal inference already in-tree (do not re-derive) + +`core/src/complement.rs` already estimates a key: + +- `estimate_harmony(notes) -> Option` (`pub(crate)`), using + the **Krumhansl–Schmuckler** algorithm: a **duration-weighted** pitch-class + histogram correlated (Pearson) against the 24 rotated Krumhansl–Kessler + major/natural-minor profiles; the single best correlation wins, ties broken + by earliest key in a major-then-minor, C-upward scan; +- returns `HarmonicContext { tonic_pitch_class, mode: KeyMode, scale_fit }`, + carried on `PartProfile.harmony` and consumed by the ComplementArranger; +- glossary §8 already names this "Harmonic context" and marks `scale_fit` a + *fact*, not a verdict (fit thresholds are corpus/S9 calibration). + +**The gap this note addresses** — the existing estimator: + +1. is private to `complement`, so generation cannot reuse it; +2. returns a **single winner**, with no runner-up and no confidence margin — + it cannot express *ambiguous* or *modulating*; +3. weights by **duration only** — onset salience is not separable; +4. is **part-scoped** — there is no whole-score / per-track / per-voice + distinction; +5. mixes measurement and inference in one call (no reusable *evidence*). + +Phase 1 is to close (1)–(5) by generalising this one estimator, not adding a +second. + +## 2. Design — evidence vs inference (typed, pure-core) + +Two layers, deliberately separated so measurement is a pure fact and inference +is a scored, *uncertain* verdict (mirroring ADR-0017's axes-vs-aggregate split +and the `StructureMetrics`-vs-`StructureControl` duality). + +### 2.1 Evidence (facts, pure, deterministic) + +``` +struct PitchEvidence { + scope: EvidenceScope, + note_count: usize, + sounding_ticks: u64, // total sounded duration in scope + pitch_range: Option, // None when the scope is silent + onset_pc_weights: [f64; 12], // per-class count of note onsets + duration_pc_weights: [f64; 12], // per-class sounded duration +} + +enum EvidenceScope { + WholeScore, + Track(usize), + Voice { track: usize, voice: usize }, +} +``` + +`PitchEvidence` is a pure projection of a `Score` region — no thresholds, no +key. The two histograms are kept separate because onset salience and sustained +duration disagree (a pedal tone dominates `duration_pc_weights` but not +`onset_pc_weights`); the inference weights them, evidence does not. The +existing estimator's duration histogram is exactly `duration_pc_weights`; the +onset histogram is the new axis. + +### 2.2 Inference (scored, uncertain) + +``` +struct TonalCandidate { + tonic: PitchClass, // 0..=11 + mode: Mode, // major / natural-minor (extendable) + score: f64, // correlation against the rotated profile +} + +struct TonalEstimate { + candidates: Vec, // best-first, at least the top few + confidence_margin: f64, // best.score - runner_up.score + evidence_scope: EvidenceScope, +} +``` + +`TonalEstimate` carries **explicit uncertainty**: a `confidence_margin` (the +gap between the winner and the best rival key) plus the full ranked list, so a +caller can distinguish *high confidence* (large margin) from *ambiguous* (near +tie) without re-running the maths. `HarmonicContext` becomes a lossy projection +of a `TonalEstimate` (its winner's tonic/mode + `scale_fit`), so complement +keeps its current output while generation gets the richer shape. + +Names are not binding; the two invariants are: **evidence separated from +inference**, and **uncertainty explicit** (never a bare single key). + +### 2.3 Where scoring weights live + +The correlation weights (Krumhansl–Kessler profiles) and the onset-vs-duration +blend are **data**, not code (ADR-0017 §3): a named, versioned policy the S9 +feedback layer can tune, the same posture as every other griff scorer. Phase 0 +does not fix the blend — the synthetic scan (below) informs it. + +## 3. Scope guardrails — nothing changes yet + +This increment adds **only** this note and the test plan. Explicitly **not** +touched: + +- `RuleGenerationRequest` — no `TonalCenter` field; +- `PitchMaterial`, `ScaleLadder`, and the five generation strategies; +- the reranker and its weights (no register-coherence axis, no tonal axis); +- cadence — stays frozen until a real `TonalEstimate` is available to cadence + onto, and even then behind its own increment. + +`estimate_harmony` / `HarmonicContext` stay exactly as they are until Phase 1. + +## 4. Synthetic test plan (Phase 0 deliverable) + +The estimator must earn trust on constructed inputs before any corpus number. +Each case fixes the *expected uncertainty class*, not a hard threshold (the +thresholds are what the scan calibrates): + +| # | Synthetic input | Expected verdict | +|---|-----------------|------------------| +| 1 | Clean C major (diatonic, tonic-weighted) | **high confidence** — winner C major, wide margin | +| 2 | Clean A minor (natural, tonic-weighted) | **high confidence** — winner A minor; C-major relative is the runner-up, margin non-trivial | +| 3 | Pentatonic material (C D E G A) | **low confidence** — C-major favoured but small margin (pentatonic underdetermines major/minor) | +| 4 | Chromatic material (all 12 classes ~even) | **ambiguous** — flat histogram, near-tie candidates, margin ≈ 0 | +| 5 | Two tracks in conflicting keys (C major + F# major) | scope-dependent: `WholeScore` → **ambiguous / low**; each `Track` → **high** for its own key | +| 6 | Melodic guitar (clear key) + chromatic percussion/noise track | `WholeScore` degraded by noise → **low**; melodic `Track` → **high** — motivates scoping the evidence | +| 7 | Short tonic pedal (few onsets, one long sustained tonic) | **low confidence** and/or *unsupported* — `note_count`/`sounding_ticks` too thin; onset vs duration disagree | +| 8 | Modulating two-section score (C major → G major) | `WholeScore` → **ambiguous / modulating**; per-section (future windowed scope) → two **high**-confidence estimates | + +Cases 5–8 are the reason `EvidenceScope` and `confidence_margin` exist: +whole-score inference must be *allowed to be uncertain*, and per-scope evidence +must be reachable. An `unsupported` outcome (too few notes / ticks to estimate) +is distinct from `ambiguous` (enough data, no clear winner) — both are honest, +neither is a silent guess. + +## 5. External inspiration — borrow the decomposition only + +From **AugmentedNet** (Roman-numeral analysis network) take *only* the output +**decomposition**: `key / root / degree / quality / confidence` as the +vocabulary a tonal estimate should expose (it confirms that *confidence* and a +separable *key/root* are the right surface). Reject the rest for our specifics: + +- **no TensorFlow / neural runtime** — violates ADR-0008 / the S12 heuristics + gate; the corpus for training does not exist; +- **no MusicXML inference runtime** — griff's boundary is MIDI/GP → canonical + model, not MusicXML; +- **no Roman-numeral / functional-harmony model** — degree/quality beyond + tonic+mode is far past what generation needs now; `TonalCandidate` stays + tonic + mode until a concrete need appears. + +The actual estimator stays the Krumhansl–Schmuckler correlation already in +`complement`. + +## 6. What lands where (gated on the local scan) + +- **Phase 0 (this note):** contract audit, typed evidence/inference design, + synthetic test plan. No code. +- **Phase 1 (landed 2026-07-12, `core/src/tonal.rs`):** a pure-core + evidence/inference module — `PitchEvidence` measurement + a `TonalEstimate` + inference promoted from `estimate_harmony`, with the candidate list and + confidence margin; `HarmonicContext` re-expressed as its projection + (characterization tests, no golden change to complement). No + generation/reranker/cadence change. See §7 for what shipped vs. this sketch. +- **Later (own increments):** a scoped `TonalEstimate` on the generation input; + only then does cadence unfreeze, and only behind a confidence gate (a + low-confidence or ambiguous estimate must *not* force a cadence onto a + guessed tonic — the register track's "no silent fallback" rule applies to + tonality too). + +## 7. Phase 1 amendments (landed 2026-07-12) + +Phase 1 shipped as the pure-core module `core/src/tonal.rs` (red suite +`core/tests/tonal.rs`); `complement::estimate_harmony` now projects the winning +candidate of a `tonal::TonalEstimate` and its characterization tests are +unchanged. The shipped shape refines the §2 sketch in a few honest ways, +recorded here rather than rewritten in place so the design history stays +readable. + +**What shipped (vs. the §2.1–§2.2 sketch).** + +- `PitchEvidence` carries *raw integer* histograms, not the `f64` `*_pc_weights` + of the sketch: `onset_counts: [u32; 12]` and `duration_mass: [u64; 12]`, plus + `note_count` and the observed `feature::PitchRange`. Evidence stays raw; the + inference resolves the weighting. There is no separate `sounding_ticks` field + — per-class `duration_mass` carries it and its sum is the scope total. +- `TonalCandidate` is `{ tonic, mode: KeyMode, correlation, scale_fit }`. It + reuses the existing `KeyMode` (not a fresh `Mode`), and — beyond the sketch — + *every* candidate carries its own `scale_fit`, not only the winner. +- `TonalEstimate` is `{ candidates (24, best-first), confidence_margin }`. The + scope is **not** duplicated onto the estimate; it lives on the `PitchEvidence` + that produced it. This also lets `estimate_harmony` — which holds weighted + notes and no scope — share the one inference core without inventing a scope. +- KS v1 landed exactly as contracted: duration mass weights the histogram, raw + onset counts are the fallback only when the total duration mass is zero, and + there is no onset/duration blend and no metric-accent policy. + +**What these facts are — and are not.** + +1. A key result on real-song input is a *tonal hypothesis for a scope*, not + verified truth. The estimator earns trust on the synthetic cases (§4); a key + it reports over a corpus track is the winning correlation, not a + ground-truth label. +2. `onset_counts` are **raw facts** — literal per-class onset tallies, carrying + no weighting or normalisation. +3. `duration_mass` is **duration mass** — summed sounded ticks — **not** + wall-clock sounding time; ties, overlaps and tempo are not modelled here. +4. Every `TonalCandidate` contains `scale_fit`, the weighted on-scale fraction + for that key — a per-candidate fact, not a verdict. +5. Confidence thresholds and automatic scope selection remain **uncalibrated**. + Phase 1 exposes the margin and the per-scope evidence but sets no + High/Low/Ambiguous cutoff and makes no automatic whole-score-vs-track + choice. The local margins observed on the synthetic fixtures (diatonic + ≈ 0.076, pentatonic ≈ 0.085) are diagnostics, not thresholds. + +## 8. Sources + +- C. Krumhansl & E. Kessler, probe-tone key profiles, *Psychological Review* + 89, 1982 (the profiles already used by `estimate_harmony`). +- D. Temperley, *The Cognition of Basic Musical Structures*, MIT Press, 2001 + (Krumhansl–Schmuckler key-finding and its known pentatonic/modal failure + modes — cases 3 and 8 above). +- N. Nápoles López et al., *AugmentedNet* (2021) — borrowed for its + key/root/degree/quality/**confidence** output decomposition only. +- ADR-0008 (heuristics before ML), ADR-0017 (axes vs aggregate; weights as + data), glossary §8 (Harmonic context). diff --git a/docs/decisions.log.md b/docs/decisions.log.md index 1daf4c33..c3799bc9 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -1372,3 +1372,273 @@ Architectural decisions go to [`adr/`](adr/) instead. ergonomic gap. Accepting that the HTML toolbar (Open/Capture/Corpus/Manifest) stays for now — the Playwright suite drives those DOM buttons, and audio + visual phrase-slicing are the next ergonomic steps. + +- 2026-07-11 — In the context of `griff generate` emitting one hardcoded + rhythm-copy pass while the closure / novelty / gesture machinery sat + unwired (melodic-closure note §7.2/§7.3 named the gap), facing how to make + generation corpus-fed and self-selecting, we decided for a core + `rerank` seam — `generate_candidate_set` (every S6 strategy × + seed variants, SplitMix64-derived; template rotation; optional gesture + carving) plus `rerank_candidates` (closure + novelty axes under the + uniform `generation_rerank` v1 policy) — and a CLI `--corpus ` that + turns curated chunk records + source tabs into rhythm templates, novelty + references, and a mean burst/rest gesture ask, and against teaching each + strategy about the corpus directly or picking a winner inside core, to + achieve ADR-0017-explainable candidate selection with thresholds left to + the caller, accepting that the generate golden snapshots were re-blessed + (the default path now prints the ranking and picks the top-ranked + candidate) and that S9 still owes the policy its tuned weights. + +- 2026-07-11 — In the context of the corpus import scan (410 community tabs; + 98 parse errors, ~30% of supported formats, dominated by the `guitarpro` + 0.3 parser's hard failures on cosmetic fields — "Invalid value N for + triplet feel", "Type conversion failed" for rse/lyrics/portamento — and 9 + gpx XML errors), facing whether to fork/vendor the parser for leniency, we + decided for bumping to upstream `guitarpro` 0.4.2 first — it already makes + triplet feel lenient (unknown → `None`), halves the strict conversions + (124 → 59 sites), and rewrites the gpx importer — and against an immediate + fork, to achieve the cheapest possible ceiling lift with zero maintenance + surface, accepting a `model::legacy` import-path rename and one duplicated + `quick-xml` version in the tree (bans.multiple-versions = warn). The fork + question is deferred until the corpus re-scan shows which error buckets + survive 0.4.2; if a meaningful share remains, that becomes an ADR + (MIT-licensed upstream, so vendoring stays available). + +- 2026-07-11 — In the context of the first corpus-fed playtest (220 chunks: + the corpus was audible only when a rhythm-copy candidate won — the other + strategies hardcoded wall-to-wall quarters — and the aggregated gesture ask + of burst 69 / rest 6.6q never carved), facing how corpus rhythm should + reach generation, we decided for a shared **rhythm grid** — `RhythmTemplate` + carries onset-*placed* notes (offsets + durations, so rests and syncopation + survive extraction), every S6 strategy lays its pitches onto the first + usable template's per-bar grid (quarter fallback preserves the no-input + case), and the candidate set feeds the rotated template to every strategy — + plus a gesture-ask aggregation fix (only chunks that actually rest vote; + per-axis median), and against teaching each strategy corpus awareness + separately or padding templates with explicit rest events, to achieve + corpus rhythm audible across the whole candidate set, accepting deliberate + re-blesses of the generation goldens and that `complement` keeps its + historical quarter grid (an explicitly empty template list) until its own + increment. + +- 2026-07-12 — In the context of the corpus-fed playtest showing rhythm + monotony (`distinct_dur` stuck at 1.0 — one rhythm for the whole phrase), + facing where the collapse happened, we decided for **per-bar template + rotation** — `bar_grids` builds one grid per corpus template and strategies + cycle them by bar index, and the rerank set hands every candidate the whole + template palette (variants differ by seed, the pitch line) — and against + keeping per-variant rotation or randomising the per-bar choice, to achieve + within-phrase rhythmic variety that stays deterministic (SPEC §6), + prioritising it over cadence-aware endings (the previously-queued next step) + because it hit the larger measured hole. `RepeatVariation` keeps one rhythm + across bars (repetition is its identity). Accepting the deterministic + index-mod scheduler's limits as **parked refinements** (not this + increment): a large corpus is heard only through its first templates in + first-seen filename order, and every candidate starts at the same template + phase (bar 0 → template 0) — a later increment can add a deterministic + per-candidate phase offset or diverse-template selection. Cadence-aware + endings move to the next slot — and are further blocked until the pitch + model is split (below), because `PitchMaterial.root` is currently the + input's minimum pitch, so landing on the "tonic" would land on the lowest + pitch class, a wrong musical contract. + +- 2026-07-12 — In the context of closing the rotation bump for a corpus A/B, + facing that per-bar template resolution (empty-removal, clamp, fallback) is + invisible from the MIDI output, we decided for a small deterministic + **diagnostic seam** — `rhythm_diagnostics` (loaded vs effective template + counts plus a stable FNV-1a fingerprint per effective grid) printed in the + CLI generation summary — and against a standing analytics subsystem, to + make the A/B interpretable (which run used how many *distinct* bar rhythms), + accepting that `distinct_bar_rhythms == bar_count` is explicitly **not** a + contract (it depends on the count of unique effective templates, clamping, + and gesture carving), and that the arbiter's acceptance of the bump awaits + the local corpus A/B — checking a systematic rise in distinct bar rhythms, + comparing gesture-on against `--no-gesture` separately, and confirming no + empty / anomalously-clamped / non-deterministic bars. + +- 2026-07-12 — **Rotation bump: ACCEPTED.** The local corpus A/B (60 runs, 30 + before/after pairs) is in — but the independent evidence is **10 unique + rhythmic conditions** (5 seeds × gesture on/off), not 30 confirmations: the + three inputs produced identical rhythmic results because the rhythm schedule + is set by the shared corpus, not the input. The large effect held in all ten + conditions and every one of the 30 rows. `distinct_bar_rhythms` rose gesture-on + `2.20 → 7.80` (+5.60) and gesture-off `1.80 → 7.80` (+6.00), and after + rotation the per-seed `distinct_bar_rhythms` **matches** between gesture on and + off. Stated precisely: *gesture does not re-collapse per-bar rhythm-signature + diversity after rotation, although it still changes duration diversity and + notes-per-bar dispersion* (after-rotation `distinct_dur` gesture-on 3.0 / + off 4.2; `npb_std` gesture-on 2.26 / off 1.86) — so gesture is **not** claimed + orthogonal to rotation. No empty pieces (8/8 sounding bars), `RepeatVariation` + never won to mask the effect, and repeat runs were byte-identical + (determinism, SPEC §6). The parked refinements above stand. + +- 2026-07-12 — Clarification (no code decision), **corrected**: the rotation + A/B did **not** pass `--candidates` — it ran at the default **2** + variants-per-strategy, so `candidates=10` in that tooling's JSON is the + *ranked-set size* (2 × 5 strategies = 10 total ranked candidates), not a + variant count. (The later register baseline is a different config: 10 + variants-per-strategy → 50 candidates per condition, 2500 total.) The + `--candidates` flag semantics themselves are variants-per-strategy, and the + CLI help / ranking line already say so — only this historical A/B + description is corrected here; an earlier draft wrongly read the rotation run + as "10 × 5 = 50". + +- 2026-07-12 — **Register status: NOT accepted yet.** The structural + first-octave confinement is confirmed and the shared full-range + `ScaleLadder` is implemented, but behavioral acceptance is **pending a + corpus/synthetic post-fix A/B** (bounds, class membership, register + reachability, candidate- and winner-level span, max/mean intervals, exact + low/high boundary shares, top-clamp saturation, longest repeated-pitch run, + winner distribution). Until those numbers are in, the register decision + stays pending — `TonalCenter` and cadence remain not-started. Checkable + risks of the current strategy code, to be measured (not pre-fixed): (a) + `ShuffleMotifs` draws every note from the whole ladder → possible + many-octave leaps; (b) `MotifTransposeVariation` uses positive degree + offsets + top clamp → possible saturation on the top rung; (c) + `RepeatVariation`/`build_ascending_bar` can likewise saturate at the top; + (d) `RhythmCopyPitchSubstitute`'s full ascending wrap can jump high→low; (e) + the reranker has no register-coherence axis. The likely post-measurement + shape (a **parked** direction, not this increment): full `ScaleLadder` → a + deterministic per-candidate `LadderWindow`/`RegisterPlan` → local strategy + movement, so the full range is reachable *between variants* without every + note using it. + +- 2026-07-12 — In the context of the register the rotation A/B exposed (~one + octave, low — `rhythm_copy`/`shuffle` walked degrees only in `[0, scale_len)`, + one octave above `PitchMaterial.root` = the input's minimum pitch, and + `motif_transpose` shifted by semitones, leaving the pitch-class palette), + facing how to use the full `[pitch_lo, pitch_hi]` range without a second + pitch mapper, we decided for a shared **`griff_core::pitch`** module — + `PitchRange`, `PitchClassSet`, and a `ScaleLadder` (the ascending in-range, + in-class pitches indexed by a linear degree) that the generator and (via + `band_scale_ladder`) the complement arranger both use — and against a + second independent degree→pitch mapper or a full tonal-center inference now, + to achieve a full-range, always-in-class register that stays deterministic + (SPEC §6), accepting that the contract is **reachability** ("the full ladder + is reachable and generation is no longer structurally confined to the first + octave"), *not* that every piece must span the whole range. `PitchMaterial.root` + is now a pitch-class anchor, **not** a tonal center; `TonalCenter` inference + is a separate later increment (and cadence-aware endings stay frozen until it + lands, since only then is a real tonic available to cadence onto). Generate + goldens (core + CLI) were re-blessed for the new mapping. + +- 2026-07-12 — **Register post-fix A/B (local experimental evidence, not + product thresholds).** The full-range ladder fixed structural + first-octave confinement and reachability (synthetic after-ladder + in-range/in-class share was 1.0). But candidate *coherence* regressed, + isolated mainly to `ShuffleMotifs`: drawing every note from the whole + ladder blew up the register — Shuffle wide-synthetic median candidate span + ~`11 → 46` semitones, octave-leap share ~`0 → 0.62` — and 10 of 50 + post-ladder production winners crossed the experiment's incoherence + threshold, all `ShuffleMotifs`. Wording correction: the reranker does **not** + "prefer wide register" (its six axes are only closure + novelty) — rather, + *it does not penalize register incoherence, so some incoherent candidates + score well on the existing closure/novelty axes and reach the output.* + Consequence: register behavioral acceptance stays **blocked** pending a + Shuffle-window A/B; the fix repairs candidate *generation* (a deterministic + per-candidate `LadderWindow` for Shuffle), not the ranker — scoring must not + become a landfill that hides malformed candidates. `TonalCenter`, cadence, + a register-coherence rerank axis, and reranker weights v2 all stay + not-started. + +- 2026-07-12 — In the context of the register A/B's isolated blocker + (`ShuffleMotifs` incoherence from sampling the whole ladder), facing how to + restore local coherence without touching the other strategies or the ranker, + we decided for a **Shuffle-only `LadderWindow`** — `ScaleLadder::octave_window` + returns a contiguous ≤-one-octave slice, seed-positioned per candidate (low + anchor drawn from rungs that leave a full octave above, so selectors cover + the ladder without top/bottom bias), and Shuffle draws every note from its + window — and against redesigning the strategy, a register-coherence rerank + axis, hard octave-leap rejection, or a weights change, to fix candidate + *generation* (not hide malformed candidates behind scoring). Register + diagnostics (`RegisterStats`: mean/max abs interval, octave-leap share, + pitch stddev) are added as a pure, reusable measurement for tests and the + harness, explicitly **not** part of `rerank_weights_v1`. Reachability is + retained across variants; the window bounds per-candidate locality only. + `TonalCenter`, cadence, a global `RegisterPlan`, and reranker policy v2 stay + not-started. Register behavioral acceptance remains **pending** the external + Shuffle-window A/B. + +- 2026-07-12 — **Register track: ACCEPTED.** The wrap-free A/B is in + (2500/2500 candidate pairs, 50/50 winner pairs, published raw checksums + match): RhythmCopy > 12-semitone candidates 398 → 0, RepeatVariation + > 12 candidates on the observed grids 16 → 0, winner > 12 candidates + 19 → 0, non-target strategy pitch hashes 500/500 identical, mean aggregate + 0.8896 → 0.8951 / median 0.879 → 0.901. The register track is accepted as: + full-range reachability (`ScaleLadder`), an unbiased Shuffle window + (anchor drawn over `octave_window_count`), wrap-free RhythmCopy (a reflecting + `DegreeCursor`), and endpoint-local RepeatVariation (variation chosen local + to the bar's actual penultimate degree, closing the proven dense-grid + counterexample). A generic register-coherence rerank axis is **not** + justified — repairing candidate generation was sufficient, and scoring stays + free of malformed-candidate hiding. + + Documentation corrections to the experimental record: + - the register candidate *scan* used **10 variants/strategy** (50 candidates + per condition, 2500 total); + - the *winner* CLI command omitted `--candidates`, so it used the default + **2 variants/strategy** (10 ranked) — the two configs are distinct; + - `25.36 → 6.02` is the **mean** winner max interval; the observed **maximum** + max interval is `57 → 12`; + - the bias CSV records observed output minima unless the harness genuinely + exposes the internal anchor index. + + Still frozen: `TonalCenter` and cadence (both awaiting a real tonal center); + no reranker policy v2. + +- 2026-07-12 — **TonalContext Phase 1: shared tonal evidence/inference layer.** + In the context of generation having only a pitch-class palette and no notion + of a tonal center, and facing a key estimator that was private to + `complement`, single-winner, and part-scoped, we decided to generalise + `complement::estimate_harmony` into a pure-core module `core/src/tonal.rs` + split into *evidence* (raw, observed facts) and *inference* (a scored, + uncertain estimate), and against inventing a second estimator or wiring any of + it into generation yet: + - `PitchEvidence::measure(score, scope)` projects an explicit `EvidenceScope` + (whole-score / track / voice) into raw `onset_counts: [u32;12]`, + `duration_mass: [u64;12]` (summed ticks), `note_count`, and the observed + `feature::PitchRange` — additive across scopes (whole = Σ tracks = Σ voices); + - `estimate_key` ranks all 24 keys best-first into a `TonalEstimate` with an + explicit `confidence_margin` (winner − runner-up); each `TonalCandidate` + carries tonic, `KeyMode`, Pearson correlation and its own `scale_fit`; + - KS v1 stays duration-only: duration mass weights the histogram, raw onset + counts are the fallback only when total duration mass is zero — no + onset/duration blend and no metric-accent policy; + - an exactly flat histogram scores every key at a finite `0.0`, margin `0.0`, + C-major-first tie order — an ordering convention, explicitly not confidence. + `complement::estimate_harmony` now projects the winning candidate into + `HarmonicContext`; `KeyMode` moved to `tonal` and is re-exported from + `complement`, so its public path is unchanged. To achieve one shared estimator + with explicit uncertainty, accepting that the richer estimate is not yet + consumed anywhere. Characterization held: all existing complement/structure + tests and the tie ordering are unchanged (no golden change). Scope guardrails + held — no generation/reranker/cadence/track-selection change and no + `PitchMaterial` change. Uncalibrated by design: no confidence thresholds and no + automatic scope selection (the diatonic ≈ 0.076 / pentatonic ≈ 0.085 margins + are diagnostics, not cutoffs). Phase 0 note amended (§7) to record what shipped + and that a real-song key is a hypothesis for a scope, not verified truth. + Cadence and `TonalCenter` stay frozen. + +- 2026-07-12 — **TonalContext Phase 1: ACCEPTED AND CLOSED.** Focused local + equivalence validation is green and independently reviewed; the cloud + implementation (red `6f9114d`, green `184b586`, docs `e2c9c7f`) is accepted + against the local validation commit + `bd2c7c8575858de414861dd3bf8562f70597ce06`. Acceptance figures: + - `HarmonicContext` exact equivalence: **16/16, 0 changed**; + - structure-consumer equivalence: **7/7 byte-identical**; + - core evidence mapping vs. the frozen prototype facts: **39/39, 0 + mismatches**; + - histogram additivity (whole = Σ tracks = Σ voices): **PASS**; + - 24 finite candidates per scope: **PASS**; + - generation byte smoke: **30/30 identical**. + + Follow-up archival/housekeeping commit + `3993bb096ebb4dded8bd71501ff9801b9f2cf81d` added `phase1_evidence.jsonl`, + regenerated the generation-smoke CSV with full 64-hex SHA-256, reconfirmed + 30/30 byte-identical via `cmp`, and documented the exact comparison + methodology. + + **No generation behavior changed.** Frozen status carried forward unchanged: + confidence thresholds **not calibrated**; automatic scope selection **not + approved**; generation integration **frozen**; cadence **frozen**; Phase 2 + **not started**. diff --git a/docs/stages/S6-rule-generator-v0.md b/docs/stages/S6-rule-generator-v0.md index c003d5de..842598b1 100644 --- a/docs/stages/S6-rule-generator-v0.md +++ b/docs/stages/S6-rule-generator-v0.md @@ -4,6 +4,16 @@ Status: done Depends on: S5 ADRs: ADR-0005, ADR-0010 +> Progress (2026-07-11): the promised candidate *set* landed — +> `core/src/rerank.rs` fans a request over every strategy × seed variants and +> reranks on the closure + novelty axes under the `generation_rerank` v1 +> policy (ADR-0017; melodic-closure note §7.2/§7.3). `griff generate` uses it +> by default, and `--corpus ` feeds rhythm templates, novelty +> references, and the burst/rest gesture ask from curated chunks +> (decisions.log 2026-07-11). Still open from the list below: cadence-aware +> endings inside the strategies, anchor preservation, the string/fret +> playability filter, and the density/syncopation corpus gates. + ## Goal First musically useful, non-neural generator producing recognizably diff --git a/fuzz/fuzz_targets/generation_request.rs b/fuzz/fuzz_targets/generation_request.rs index 5e659047..997754e5 100644 --- a/fuzz/fuzz_targets/generation_request.rs +++ b/fuzz/fuzz_targets/generation_request.rs @@ -20,7 +20,7 @@ use griff_core::{ event::{Pitch, Tempo, Ticks, TimeSignature}, generate::{ generate, GenerationConstraints, GenerationSeed, GenerationStrategy, PitchMaterial, - RuleGenerationRequest, + RhythmTemplate, RuleGenerationRequest, }, score::AtomEvent, }; @@ -98,7 +98,7 @@ fuzz_target!(|input: FuzzInput| { source_rhythms: if rhythm.is_empty() { Vec::new() } else { - vec![rhythm] + vec![RhythmTemplate::from_durations(&rhythm)] }, strategy, }; diff --git a/fuzz/fuzz_targets/gesture_request.rs b/fuzz/fuzz_targets/gesture_request.rs index 4d355fc2..1c84a047 100644 --- a/fuzz/fuzz_targets/gesture_request.rs +++ b/fuzz/fuzz_targets/gesture_request.rs @@ -28,7 +28,7 @@ use griff_core::{ event::{Pitch, Tempo, Ticks, TimeSignature}, generate::{ generate, GenerationConstraints, GenerationSeed, GenerationStrategy, PitchMaterial, - RuleGenerationRequest, + RhythmTemplate, RuleGenerationRequest, }, gesture::{generate_gestured, measure_gesture, GestureControl}, score::AtomEvent, @@ -112,7 +112,7 @@ fuzz_target!(|input: FuzzInput| { source_rhythms: if rhythm.is_empty() { Vec::new() } else { - vec![rhythm] + vec![RhythmTemplate::from_durations(&rhythm)] }, strategy, };