diff --git a/cli/src/main.rs b/cli/src/main.rs index 0dd919c3..d85dfe7a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -611,6 +611,7 @@ fn cmd_generate(input: &Path, output: &Path, opts: &GenerateOpts<'_>) -> Result< variants_per_strategy: candidates, gesture: !no_gesture, }, + None, )?; let RankedSet { ranked, @@ -618,6 +619,7 @@ fn cmd_generate(input: &Path, output: &Path, opts: &GenerateOpts<'_>) -> Result< source_rhythms, gesture, policy, + .. } = &set; print_rhythm_diagnostics(source_rhythms, &base.constraints, gesture.is_some()); diff --git a/core/src/complement.rs b/core/src/complement.rs index d0f43c55..fd507b22 100644 --- a/core/src/complement.rs +++ b/core/src/complement.rs @@ -769,6 +769,7 @@ fn arrange_counter_melody( // 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. + explicit_rhythms: None, source_rhythms: Vec::new(), strategy: GenerationStrategy::ConstrainedRandomWalk, }; diff --git a/core/src/generate.rs b/core/src/generate.rs index a67760fc..3e38d83c 100644 --- a/core/src/generate.rs +++ b/core/src/generate.rs @@ -106,6 +106,37 @@ fn effective_grids(templates: &[RhythmTemplate], bar_duration: Ticks) -> Vec Vec> { + palette + .iter() + .map(|template| clamp_template(template, bar_duration)) + .collect() +} + +/// Reports how an **explicit** palette resolves. +/// +/// Nothing is filtered, so `loaded == effective == fingerprints.len()` and a +/// silent template fingerprints as itself — the palette is never compressed +/// (#114 review, law 4). +#[must_use] +pub fn explicit_rhythm_diagnostics( + palette: &[RhythmTemplate], + bar_duration: Ticks, +) -> RhythmDiagnostics { + let grids = explicit_grids(palette, bar_duration); + let fingerprints = grids.iter().map(|g| grid_fingerprint(g)).collect(); + RhythmDiagnostics { + loaded: palette.len(), + effective: grids.len(), + fingerprints, + } +} + /// 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. @@ -284,6 +315,11 @@ pub struct RuleGenerationRequest { /// generation carries the corpus's rhythmic variety across its bars. /// Empty or all-unusable → the quarter-note fallback grid. pub source_rhythms: Vec, + /// A caller-supplied palette honored **verbatim** by a separate scheduler + /// (ADR-0029 §7): silent templates stay in the rotation as silent bars, + /// nothing is filtered, and there is no quarter fallback. When set, it + /// wins over `source_rhythms` outright. + pub explicit_rhythms: Option>, /// Strategy to apply. pub strategy: GenerationStrategy, } @@ -321,8 +357,12 @@ pub fn generate(request: &RuleGenerationRequest) -> Result Result>, /// The tab-seeded base request: pitch material, meter, tempo, range. pub base: generate::RuleGenerationRequest, - /// The rhythm templates the pass actually rotated (corpus palette, or the - /// source's first sounding bar as the fallback). + /// The rhythm templates the pass actually rotated (explicit palette, + /// corpus palette, or the source's first sounding bar as the fallback). + /// For an explicit palette this is the caller's vector **verbatim**, + /// silent templates included — provenance is never compressed. pub source_rhythms: Vec, + /// Whether `source_rhythms` is an explicit palette (ADR-0029 §7) — the + /// separate scheduler that keeps silent bars in rotation — rather than + /// the automatic corpus/source path. + pub rhythm_explicit: bool, /// The gesture ask the pass carved against, when it carved. pub gesture: Option, /// The rerank policy the aggregates were scored under. @@ -196,17 +202,31 @@ pub fn ranked_candidates( score: &Score, material: Option<&CorpusMaterial>, ask: &GenerationAsk, + rhythm_override: Option<&[generate::RhythmTemplate]>, ) -> Result { let base = generation_request_from_score(score, ask.seed, ask.bars)?; - let (source_rhythms, gesture) = material.map_or_else( - || (base.source_rhythms.clone(), None), - |m| { - let rhythms = if m.rhythms.is_empty() { - base.source_rhythms.clone() - } else { - m.rhythms.clone() - }; - (rhythms, if ask.gesture { m.gesture } else { None }) + // Rhythm precedence (ADR-0029 §7): explicit pattern > corpus > source + // first bar. Novelty references and gesture stay corpus-based either way. + let explicit: Option> = rhythm_override.map(<[_]>::to_vec); + let (source_rhythms, gesture) = explicit.as_ref().map_or_else( + || { + material.map_or_else( + || (base.source_rhythms.clone(), None), + |m| { + let rhythms = if m.rhythms.is_empty() { + base.source_rhythms.clone() + } else { + m.rhythms.clone() + }; + (rhythms, if ask.gesture { m.gesture } else { None }) + }, + ) + }, + |palette| { + ( + palette.clone(), + material.and_then(|m| if ask.gesture { m.gesture } else { None }), + ) }, ); let references: &[Score] = material.map_or(&[], |m| &m.references); @@ -216,6 +236,7 @@ pub fn ranked_candidates( pitch_material: base.pitch_material.clone(), constraints: base.constraints, source_rhythms: source_rhythms.clone(), + explicit_rhythms: explicit.clone(), variants_per_strategy: ask.variants_per_strategy, gesture, })?; @@ -226,6 +247,7 @@ pub fn ranked_candidates( ranked, base, source_rhythms, + rhythm_explicit: explicit.is_some(), gesture, policy, }) @@ -264,6 +286,7 @@ pub fn generation_request_from_score( seed: generate::GenerationSeed(seed), pitch_material: pitch_material_from(lo, &pitches), constraints, + explicit_rhythms: None, source_rhythms: vec![generate::RhythmTemplate::from_durations(&first_bar_rhythm( score, ))], diff --git a/core/src/rerank.rs b/core/src/rerank.rs index 4c09b35d..e8316556 100644 --- a/core/src/rerank.rs +++ b/core/src/rerank.rs @@ -84,6 +84,9 @@ pub struct SetRequest { /// from corpus chunks. Empty templates are ignored; with none usable, /// `RhythmCopyPitchSubstitute` is skipped. pub source_rhythms: Vec, + /// A caller-supplied palette honored verbatim by the explicit scheduler + /// (ADR-0029 §7); wins over `source_rhythms` when set. + pub explicit_rhythms: Option>, /// Seed variants generated per strategy (must be ≥ 1). pub variants_per_strategy: usize, /// When set, every candidate is carved through the gesture compiler. @@ -136,8 +139,14 @@ pub fn generate_candidate_set(request: &SetRequest) -> Result, return Err(SetError::VariantCountZero); } - let templates: Vec<&RhythmTemplate> = request - .source_rhythms + // Rhythm-copy needs at least one sounding template in whichever palette + // is active: the explicit one when set (honored verbatim by the explicit + // scheduler), the automatic one otherwise. + let active_palette: &[RhythmTemplate] = request + .explicit_rhythms + .as_deref() + .unwrap_or(&request.source_rhythms); + let templates: Vec<&RhythmTemplate> = active_palette .iter() .filter(|t| !t.notes.is_empty()) .collect(); @@ -161,6 +170,7 @@ pub fn generate_candidate_set(request: &SetRequest) -> Result, pitch_material: request.pitch_material.clone(), constraints: request.constraints, source_rhythms: source_rhythms.clone(), + explicit_rhythms: request.explicit_rhythms.clone(), strategy: *strategy, }; let (score, gesture) = match request.gesture { diff --git a/core/src/structure.rs b/core/src/structure.rs index f3f636c3..7364c020 100644 --- a/core/src/structure.rs +++ b/core/src/structure.rs @@ -742,6 +742,7 @@ fn run_s6(request: &StructuredRequest, bar_count: usize) -> Result PitchMaterial { + PitchMaterial { + root: Pitch(40), + intervals: vec![0, 3, 5, 7, 10], + } +} + +fn constraints(bar_count: usize) -> GenerationConstraints { + GenerationConstraints { + bar_count, + time_signature: TimeSignature { + numerator: 4, + denominator: 4, + }, + tempo: Tempo(120.0), + ticks_per_quarter: Ticks(u32::from(PPQN)), + pitch_lo: Pitch(36), + pitch_hi: Pitch(72), + } +} + +/// Two placed sixteenths: beat 1 and beat 3. +fn two_note_template() -> RhythmTemplate { + RhythmTemplate { + notes: vec![ + TemplateNote { + offset: Ticks(0), + duration: Ticks(120), + }, + TemplateNote { + offset: Ticks(960), + duration: Ticks(120), + }, + ], + } +} + +fn request_with_palette( + strategy: GenerationStrategy, + bar_count: usize, + palette: Vec, +) -> RuleGenerationRequest { + RuleGenerationRequest { + seed: GenerationSeed(42), + pitch_material: e_minor_pentatonic(), + constraints: constraints(bar_count), + source_rhythms: Vec::new(), + explicit_rhythms: Some(palette), + strategy, + } +} + +/// Onsets of every generated note, ascending. +fn onsets(score: &Score) -> Vec { + let mut all: 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.absolute_start.0), + AtomEvent::Rest(_) => None, + }) + .collect(); + all.sort_unstable(); + all +} + +/// Note count inside bar `index` (half-open tick range). +fn notes_in_bar(score: &Score, index: u32) -> usize { + let start = index * BAR; + onsets(score) + .iter() + .filter(|&&o| o >= start && o < start + BAR) + .count() +} + +/// A minimal one-track 4/4 score sounding quarters — enough to seed +/// `ranked_candidates`. +fn seed_score(bar_count: usize) -> Score { + let master_bars = (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("valid tempo"), + repeat: RepeatMarker::default(), + } + }) + .collect(); + + let mut groups = Vec::new(); + for bar in 0..bar_count { + let bar_start = u32::try_from(bar).unwrap() * BAR; + for beat in 0..4_u32 { + groups.push(EventGroup { + kind: EventGroupKind::Single, + atoms: vec![AtomEvent::Note(AtomNote { + absolute_start: Ticks(bar_start + beat * 480), + duration: Ticks(480), + pitch: Pitch::new(40 + u8::try_from(beat).unwrap()).expect("valid pitch"), + velocity: Velocity::new(90).expect("valid velocity"), + marks: NoteMarks::empty(), + position: None, + })], + technique_spans: Vec::new(), + }); + } + } + + Score { + ticks_per_quarter: PPQN, + master_bars, + tracks: vec![Track { + name: Some("seed".to_string()), + channel: 0, + voices: vec![Voice { + id: 0, + event_groups: groups, + }], + tuning: Tuning::standard_e(), + }], + source_meta: None, + loss: LossReport::new(), + } +} + +// ── law 1 + 3: silent bars survive, in rotation ───────────────────────────── + +#[test] +fn silent_bars_stay_in_the_rotation() { + let candidate = generate(&request_with_palette( + GenerationStrategy::MotifTransposeVariation, + 4, + vec![two_note_template(), RhythmTemplate::default()], + )) + .expect("generates"); + + // The palette is a 2-cycle: sounding, silent, sounding, silent — the + // silent template is a bar of the cycle, not a dropped entry. + assert_eq!(notes_in_bar(&candidate.score, 0), 2); + assert_eq!(notes_in_bar(&candidate.score, 1), 0, "bar 1 must be silent"); + assert_eq!(notes_in_bar(&candidate.score, 2), 2); + assert_eq!(notes_in_bar(&candidate.score, 3), 0, "bar 3 must be silent"); + + // And the sounding bars sit exactly on the template's offsets. + let all = onsets(&candidate.score); + assert_eq!(all, vec![0, 960, 2 * BAR, 2 * BAR + 960]); +} + +// ── law 1: no quarter fallback for an explicit palette ────────────────────── + +#[test] +fn an_all_silent_explicit_palette_stays_silent() { + let candidate = generate(&request_with_palette( + GenerationStrategy::MotifTransposeVariation, + 2, + vec![RhythmTemplate::default()], + )) + .expect("generates a silent part"); + assert_eq!( + onsets(&candidate.score).len(), + 0, + "explicit silence must not fall back to quarters" + ); +} + +// ── law 2: the automatic path keeps its filtering and fallback ────────────── + +#[test] +fn the_automatic_path_still_filters_and_falls_back() { + let candidate = generate(&RuleGenerationRequest { + seed: GenerationSeed(42), + pitch_material: e_minor_pentatonic(), + constraints: constraints(1), + source_rhythms: vec![RhythmTemplate::default()], + explicit_rhythms: None, + strategy: GenerationStrategy::MotifTransposeVariation, + }) + .expect("generates"); + assert_eq!( + notes_in_bar(&candidate.score, 0), + 4, + "an all-empty automatic palette still falls back to quarters" + ); +} + +// ── precedence: explicit beats corpus; novelty and gesture stay corpus ────── + +#[test] +fn an_explicit_palette_beats_the_corpus_and_keeps_its_silence() { + let palette = vec![two_note_template(), RhythmTemplate::default()]; + let material = CorpusMaterial { + rhythms: vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], + references: vec![seed_score(1)], + gesture: None, + skipped: Vec::new(), + }; + let set = ranked_candidates( + &seed_score(2), + Some(&material), + &GenerationAsk { + seed: 42, + bars: 2, + variants_per_strategy: 1, + gesture: false, + }, + Some(&palette), + ) + .expect("ranks"); + + assert!(set.rhythm_explicit, "provenance must say explicit"); + assert_eq!( + set.source_rhythms, palette, + "the palette is provenance, verbatim — silent template included" + ); +} + +#[test] +fn without_an_override_the_corpus_still_wins() { + let corpus_rhythm = RhythmTemplate::from_durations(&[Ticks(480); 4]); + let material = CorpusMaterial { + rhythms: vec![corpus_rhythm.clone()], + references: Vec::new(), + gesture: None, + skipped: Vec::new(), + }; + let set = ranked_candidates( + &seed_score(2), + Some(&material), + &GenerationAsk { + seed: 42, + bars: 2, + variants_per_strategy: 1, + gesture: false, + }, + None, + ) + .expect("ranks"); + + assert!(!set.rhythm_explicit); + assert_eq!(set.source_rhythms, vec![corpus_rhythm]); +} + +// ── law 4: diagnostics never compress an explicit palette ─────────────────── + +#[test] +fn explicit_diagnostics_report_the_palette_uncompressed() { + let palette = [two_note_template(), RhythmTemplate::default()]; + let explicit = explicit_rhythm_diagnostics(&palette, Ticks(BAR)); + assert_eq!(explicit.loaded, 2); + assert_eq!(explicit.effective, 2, "the silent template is not dropped"); + assert_eq!(explicit.fingerprints.len(), 2); + + // Contrast: the automatic diagnostics compress the same palette to one + // effective grid — which is exactly why the explicit path needs its own. + let automatic = rhythm_diagnostics(&palette, Ticks(BAR)); + assert_eq!(automatic.effective, 1); +} diff --git a/core/tests/gesture_control.rs b/core/tests/gesture_control.rs index 4d0a06a8..b1d8943f 100644 --- a/core/tests/gesture_control.rs +++ b/core/tests/gesture_control.rs @@ -76,6 +76,7 @@ fn walk_request() -> RuleGenerationRequest { pitch_lo: Pitch(36), // C2 pitch_hi: Pitch(72), // C5 }, + explicit_rhythms: None, source_rhythms: Vec::new(), strategy: GenerationStrategy::ConstrainedRandomWalk, } diff --git a/core/tests/pitch_ladder.rs b/core/tests/pitch_ladder.rs index 8ecf21e4..b01bfdec 100644 --- a/core/tests/pitch_ladder.rs +++ b/core/tests/pitch_ladder.rs @@ -177,6 +177,7 @@ fn request(strategy: GenerationStrategy, seed: u64) -> RuleGenerationRequest { constraints: wide(8), // A quarter template so RhythmCopyPitchSubstitute has one; the pitch // contract is about degrees, not rhythm. + explicit_rhythms: None, source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], strategy, } @@ -211,6 +212,7 @@ fn candidate_set_reaches_beyond_the_first_octave() { seed: GenerationSeed(7), pitch_material: pentatonic(), constraints: wide(8), + explicit_rhythms: None, source_rhythms: Vec::new(), variants_per_strategy: 3, gesture: None, diff --git a/core/tests/rerank.rs b/core/tests/rerank.rs index 8f368a5c..54ae6fa5 100644 --- a/core/tests/rerank.rs +++ b/core/tests/rerank.rs @@ -87,6 +87,7 @@ fn set_request( seed: GenerationSeed(seed), pitch_material: material(), constraints: constraints(4), + explicit_rhythms: None, source_rhythms, variants_per_strategy, gesture, diff --git a/core/tests/rhythm_grid.rs b/core/tests/rhythm_grid.rs index 8c3fe989..e0807456 100644 --- a/core/tests/rhythm_grid.rs +++ b/core/tests/rhythm_grid.rs @@ -67,6 +67,7 @@ fn request(strategy: GenerationStrategy, templates: Vec) -> Rule seed: GenerationSeed(42), pitch_material: material(), constraints: constraints(), + explicit_rhythms: None, source_rhythms: templates, strategy, } diff --git a/core/tests/rule_generator.rs b/core/tests/rule_generator.rs index d6d188ca..12502641 100644 --- a/core/tests/rule_generator.rs +++ b/core/tests/rule_generator.rs @@ -51,6 +51,7 @@ fn request(strategy: GenerationStrategy) -> RuleGenerationRequest { seed: GenerationSeed(42), pitch_material: e_minor_pentatonic(), constraints: constraints_2_bars_4_4(), + explicit_rhythms: None, source_rhythms: vec![quarter_rhythm()], strategy, } @@ -313,6 +314,7 @@ fn zero_bar_count_returns_error() { #[test] fn rhythm_copy_without_source_rhythms_returns_error() { let req = RuleGenerationRequest { + explicit_rhythms: None, source_rhythms: Vec::new(), ..request(GenerationStrategy::RhythmCopyPitchSubstitute) }; diff --git a/core/tests/shuffle_window.rs b/core/tests/shuffle_window.rs index a63bf9be..8aa94972 100644 --- a/core/tests/shuffle_window.rs +++ b/core/tests/shuffle_window.rs @@ -259,6 +259,7 @@ fn shuffle_request(seed: u64, constraints: GenerationConstraints) -> RuleGenerat seed: GenerationSeed(seed), pitch_material: chromatic(), constraints, + explicit_rhythms: None, source_rhythms: Vec::new(), strategy: GenerationStrategy::ShuffleMotifs, } diff --git a/core/tests/structure_control.rs b/core/tests/structure_control.rs index 08ff52bf..bdf8eabe 100644 --- a/core/tests/structure_control.rs +++ b/core/tests/structure_control.rs @@ -209,6 +209,7 @@ fn through_composed_control_delegates_to_s6() { seed: GenerationSeed(7), pitch_material: c_major(), constraints: constraints(4), + explicit_rhythms: None, source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], strategy: GenerationStrategy::ConstrainedRandomWalk, }) diff --git a/core/tests/wrap_free_traversal.rs b/core/tests/wrap_free_traversal.rs index fae771b4..4a2d2418 100644 --- a/core/tests/wrap_free_traversal.rs +++ b/core/tests/wrap_free_traversal.rs @@ -75,6 +75,7 @@ fn request( seed: GenerationSeed(seed), pitch_material: pm, constraints, + explicit_rhythms: None, source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(480); 4])], strategy, } diff --git a/ui-core/src/generate.rs b/ui-core/src/generate.rs index a3e1992c..2f04bc1f 100644 --- a/ui-core/src/generate.rs +++ b/ui-core/src/generate.rs @@ -82,7 +82,7 @@ pub fn generate_set( material: Option<&CorpusMaterial>, ask: &GenerationAsk, ) -> Result { - let set = ranked_candidates(source, material, ask)?; + let set = ranked_candidates(source, material, ask, None)?; let rows = set .ranked