diff --git a/cli/src/main.rs b/cli/src/main.rs index 118101b5..f48cca8b 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -10,7 +10,7 @@ use clap::{Parser, Subcommand}; use griff_cli::generation_input::{load_corpus_material, CorpusMaterial, GenerationInputError}; use griff_cli::primary_voice_note_count; use griff_cli::rhythm_pattern; -use griff_core::generation_input::{ranked_candidates, GenerationAsk, RankedSet}; +use griff_core::generation_input::{ranked_candidates, select_ranked, GenerationAsk, RankedSet}; use griff_core::{ boundary, classify::{self, BarClass}, @@ -270,6 +270,16 @@ enum SwangCommand { #[arg(value_name = "INPUT")] input: PathBuf, }, + /// Run the program end to end — expansion, generation, strategy + /// selection (spec §3.5 law 5: `auto` matches `griff generate`; a named + /// strategy selects from the unchanged ranked set), and the program's + /// own `export`. No output flag exists: the program is the output's + /// single owner. + Build { + /// Path to the `.swg` script. + #[arg(value_name = "INPUT")] + input: PathBuf, + }, } fn run() -> Result<(), CliError> { @@ -343,6 +353,7 @@ fn run() -> Result<(), CliError> { SwangCommand::Check { input } => cmd_swang_check(&input), SwangCommand::Fmt { input } => cmd_swang_fmt(&input), SwangCommand::Expand { input } => cmd_swang_expand(&input), + SwangCommand::Build { input } => cmd_swang_build(&input), }, } } @@ -391,7 +402,111 @@ fn cmd_swang_expand(path: &Path) -> Result<(), CliError> { let score_bytes = fs::read(pattern.generate.source.as_str())?; let score = import::import_score_auto(&score_bytes)?; - let args = rhythm_pattern::RhythmPatternArgs { + let args = rhythm_args_from_program(pattern); + let bars = usize::try_from(pattern.generate.bars) + .map_err(|_| CliError::Argument("bars does not fit this platform".to_owned()))?; + + let plan = rhythm_pattern::compile_pattern_flaws(&args, &score, bars) + .map_err(|flaw| render_pattern_flaw(&flaw, path, &source_text, &spans))?; + print!("{}", plan.artifact_json); + Ok(()) +} + +/// `griff swang build`: the program end to end. The generation pass is the +/// same shared compiler every frontend enters — `ranked_candidates` — so +/// under `strategy auto` the export's bytes match `griff generate` for the +/// equivalent command (law 5's first half); a named strategy is +/// [`select_ranked`]'s reading of the same, unchanged set (the second half). +/// The output path is the program's own `export`, and only it. +fn cmd_swang_build(path: &Path) -> Result<(), CliError> { + let source_text = fs::read_to_string(path)?; + let (program, spans) = syntax::parse_with_spans(&source_text) + .map_err(|diagnostics| swang_error(path, &source_text, &diagnostics))?; + + let pattern = &program.pattern; + let score_bytes = fs::read(pattern.generate.source.as_str())?; + let score = import::import_score_auto(&score_bytes)?; + let material = pattern + .generate + .corpus + .as_ref() + .map(|corpus| load_corpus_material(Path::new(corpus.as_str()))) + .transpose()?; + if let Some(m) = &material { + print_corpus_summary(m, false); + } + + let bars = usize::try_from(pattern.generate.bars) + .map_err(|_| CliError::Argument("bars does not fit this platform".to_owned()))?; + let candidates = usize::try_from(pattern.generate.candidates) + .map_err(|_| CliError::Argument("candidates does not fit this platform".to_owned()))?; + let args = rhythm_args_from_program(pattern); + let plan = rhythm_pattern::compile_pattern_flaws(&args, &score, bars) + .map_err(|flaw| render_pattern_flaw(&flaw, path, &source_text, &spans))?; + + let set = ranked_candidates( + &score, + material.as_ref(), + &GenerationAsk { + seed: pattern.generate.seed, + bars, + variants_per_strategy: candidates, + gesture: true, + }, + Some(plan.templates.as_slice()), + )?; + print_explicit_rhythm_diagnostics(&set.source_rhythms, &set.base.constraints); + + let target = match pattern.generate.strategy { + syntax::StrategyPolicy::Auto => None, + syntax::StrategyPolicy::Named(name) => Some(strategy_kind(name)), + }; + let winner = select_ranked(&set, target).ok_or_else(|| { + target.map_or_else( + || CliError::Corpus("no candidate survived scoring".to_owned()), + |strategy| { + CliError::Corpus(format!( + "no ranked candidate of strategy {strategy:?} survived scoring" + )) + }, + ) + })?; + print_ranking(&set.ranked, &set.policy); + + let out_bytes = midi::export_score(&winner.value.score)?; + fs::write(pattern.export.path.as_str(), &out_bytes)?; + println!( + "built {bars} bars ({strategy:?}, seed {seed}) from a {tones}-tone scale \ + ({n} bytes) -> {out}", + strategy = winner.value.strategy, + seed = pattern.generate.seed, + tones = set.base.pitch_material.intervals.len(), + n = out_bytes.len(), + out = pattern.export.path.as_str(), + ); + Ok(()) +} + +/// The five program strategy names, mapped onto the S6 strategies they +/// selected in the killer demo (spec §3.3). +const fn strategy_kind(name: syntax::StrategyName) -> generate::GenerationStrategy { + match name { + syntax::StrategyName::RhythmCopy => generate::GenerationStrategy::RhythmCopyPitchSubstitute, + syntax::StrategyName::MotifTranspose => { + generate::GenerationStrategy::MotifTransposeVariation + } + syntax::StrategyName::ConstrainedWalk => { + generate::GenerationStrategy::ConstrainedRandomWalk + } + syntax::StrategyName::ShuffleMotifs => generate::GenerationStrategy::ShuffleMotifs, + syntax::StrategyName::RepeatVariation => generate::GenerationStrategy::RepeatVariation, + } +} + +/// The one mapping from a program's pattern pipeline onto the shared +/// compiler's transport args — `expand` and `build` must not drift. +fn rhythm_args_from_program(pattern: &syntax::PatternDef) -> rhythm_pattern::RhythmPatternArgs { + rhythm_pattern::RhythmPatternArgs { kernel: pattern.kernel.as_str().to_owned(), fractal_depth: pattern.fractalize.depth, density_bps: pattern @@ -413,14 +528,7 @@ fn cmd_swang_expand(path: &Path) -> Result<(), CliError> { TailPolicy::Reject => rhythm_pattern::TailChoice::Reject, TailPolicy::RestPad => rhythm_pattern::TailChoice::RestPad, }, - }; - let bars = usize::try_from(pattern.generate.bars) - .map_err(|_| CliError::Argument("bars does not fit this platform".to_owned()))?; - - let plan = rhythm_pattern::compile_pattern_flaws(&args, &score, bars) - .map_err(|flaw| render_pattern_flaw(&flaw, path, &source_text, &spans))?; - print!("{}", plan.artifact_json); - Ok(()) + } } /// Renders a [`rhythm_pattern::PatternFlaw`] in program vocabulary at diff --git a/cli/tests/swang_cmd.rs b/cli/tests/swang_cmd.rs index b77a8658..3d65c207 100644 --- a/cli/tests/swang_cmd.rs +++ b/cli/tests/swang_cmd.rs @@ -444,6 +444,131 @@ fn swang_expand_speaks_program_vocabulary_for_the_rejected_tail() { ); } +// ── build: law 5 (spec §3.5) ──────────────────────────────────────────────── + +/// A program around the strategy policy, exporting to `export`. +fn build_program(strategy: &str, export: &Path) -> String { + let source = fixture_path("simple_4_4"); + format!( + r#"swang 1 + +pattern p {{ + ascii "X.X/XX./.XX" + |> fractalize depth 1 max_cells 4096 density 9500bps seed 4 + |> linearize snake + |> map_rhythm unit 1/16 tail rest_pad + |> generate {{ + source "{}" + bars 4 + seed 42 + candidates 2 + strategy {strategy} + }} + |> export midi "{}" +}} +"#, + source.display(), + export.display() + ) +} + +#[test] +fn swang_build_under_auto_matches_griff_generate_byte_for_byte() { + // Law 5, the auto half: under `strategy auto` and the same seeds, build + // produces the same result as the existing `griff generate`. + let transport_out = env::temp_dir().join("griff_s16_swang_build_transport.mid"); + let src = fixture_path("simple_4_4"); + let transport = griff_raw(&[ + "generate", + src.to_str().unwrap(), + transport_out.to_str().unwrap(), + "--bars", + "4", + "--seed", + "42", + "--candidates", + "2", + "--rhythm-kernel", + "X.X/XX./.XX", + "--rhythm-fractal-depth", + "1", + "--rhythm-density-bps", + "9500", + "--rhythm-seed", + "4", + "--rhythm-traversal", + "snake", + "--rhythm-unit", + "1/16", + "--rhythm-max-cells", + "4096", + "--rhythm-tail", + "rest-pad", + ]); + assert!( + transport.status.success(), + "the transport command must succeed: {}", + String::from_utf8_lossy(&transport.stderr) + ); + let expected = fs::read(&transport_out).expect("the transport wrote its MIDI"); + fs::remove_file(&transport_out).ok(); + + let export = env::temp_dir().join("griff_s16_swang_build_auto.mid"); + let path = script("build_auto", &build_program("auto", &export)); + let built = griff_raw(&["swang", "build", path.to_str().unwrap()]); + fs::remove_file(&path).ok(); + assert!( + built.status.success(), + "{}", + String::from_utf8_lossy(&built.stderr) + ); + let bytes = fs::read(&export).expect("build wrote the program's export"); + fs::remove_file(&export).ok(); + assert_eq!(bytes, expected, "auto parity, byte for byte"); +} + +#[test] +fn swang_build_selects_the_named_strategy_and_is_deterministic() { + // Law 5, the named half at the CLI: the run says which strategy was + // selected, and the same program builds the same bytes twice. The + // selection-only law itself is pinned at the core seam + // (core/tests/strategy_selection.rs). + let export = env::temp_dir().join("griff_s16_swang_build_named.mid"); + let path = script("build_named", &build_program("repeat_variation", &export)); + + let first = griff_raw(&["swang", "build", path.to_str().unwrap()]); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + assert!( + String::from_utf8_lossy(&first.stdout).contains("RepeatVariation"), + "the run names the selected strategy: {}", + String::from_utf8_lossy(&first.stdout) + ); + let first_bytes = fs::read(&export).expect("build wrote the program's export"); + + let second = griff_raw(&["swang", "build", path.to_str().unwrap()]); + assert!(second.status.success()); + let second_bytes = fs::read(&export).expect("second build wrote too"); + fs::remove_file(&path).ok(); + fs::remove_file(&export).ok(); + assert_eq!(first_bytes, second_bytes, "deterministic by law"); +} + +#[test] +fn swang_build_takes_no_output_flag() { + // The program is the output's single owner (spec §3.2): build has no + // output flag to offer, so clap refuses one. + let out = griff_raw(&["swang", "build", "riff.swg", "--output", "elsewhere.mid"]); + assert_eq!( + out.status.code(), + Some(2), + "an output flag must be a usage error" + ); +} + #[test] fn swang_fmt_refuses_what_check_refuses() { let path = script("fmt_seedless", SEEDLESS_DENSITY); diff --git a/core/src/generation_input.rs b/core/src/generation_input.rs index 98b5fdb6..258335ad 100644 --- a/core/src/generation_input.rs +++ b/core/src/generation_input.rs @@ -253,6 +253,26 @@ pub fn ranked_candidates( }) } +/// Selects from an already-ranked set — **selection only**, never a +/// re-generation (S16 spec §3.5 law 5). +/// +/// `None` is the `auto` policy: the reranked winner across all strategies, +/// exactly what every frontend picks today. `Some(strategy)` returns the +/// **first ranked candidate of that strategy** from the same, unchanged set — +/// the set, the seeds, and the reranker are never touched, so the named +/// choice is a *reading* of the ranking, not a different ranking. `None` +/// comes back when the set holds no candidate of that strategy. +#[must_use] +pub fn select_ranked( + set: &RankedSet, + strategy: Option, +) -> Option<&Scored> { + strategy.map_or_else( + || set.ranked.first(), + |target| set.ranked.iter().find(|c| c.value.strategy == target), + ) +} + /// 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. diff --git a/core/tests/strategy_selection.rs b/core/tests/strategy_selection.rs new file mode 100644 index 00000000..7a580ca8 --- /dev/null +++ b/core/tests/strategy_selection.rs @@ -0,0 +1,170 @@ +// S16 Phase 3 law 5 (spec §3.5): a named strategy is **selection only** — +// the first ranked candidate of that strategy from the unchanged, +// already-ranked set. `auto` is the reranked winner every frontend already +// picks. The set, the seeds, and the reranker are never touched. +#![allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + clippy::indexing_slicing, + clippy::missing_assert_message, + clippy::absolute_paths, + clippy::arithmetic_side_effects, + clippy::str_to_string +)] + +use std::ptr; + +use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}; +use griff_core::generate::GenerationStrategy; +use griff_core::generation_input::{ranked_candidates, select_ranked, GenerationAsk, RankedSet}; +use griff_core::score::{ + AtomEvent, AtomNote, EventGroup, EventGroupKind, LossReport, MasterBar, RepeatMarker, Score, + Track, Voice, +}; +use griff_core::slice::TickRange; + +const PPQN: u16 = 480; +const BAR: u32 = 1920; + +/// A minimal one-track 4/4 score sounding quarters — enough to seed +/// `ranked_candidates` (same fixture shape as the explicit-rhythm suite). +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(), + } +} + +/// A real ranked set: every strategy contributes two variants. +fn ranked_set() -> RankedSet { + ranked_candidates( + &seed_score(2), + None, + &GenerationAsk { + seed: 42, + bars: 2, + variants_per_strategy: 2, + gesture: false, + }, + None, + ) + .expect("the seed score ranks") +} + +/// The ranking's identity: (strategy, variant seed) in rank order. +fn ranking_fingerprint(set: &RankedSet) -> Vec<(GenerationStrategy, u64)> { + set.ranked + .iter() + .map(|c| (c.value.strategy, c.value.seed.0)) + .collect() +} + +const ALL_STRATEGIES: [GenerationStrategy; 5] = [ + GenerationStrategy::RhythmCopyPitchSubstitute, + GenerationStrategy::MotifTransposeVariation, + GenerationStrategy::ConstrainedRandomWalk, + GenerationStrategy::ShuffleMotifs, + GenerationStrategy::RepeatVariation, +]; + +#[test] +fn auto_is_the_reranked_winner() { + let set = ranked_set(); + let selected = select_ranked(&set, None).expect("a winner exists"); + assert!( + ptr::eq(selected, &raw const set.ranked[0]), + "None is today's behavior: the reranked winner across all strategies" + ); +} + +#[test] +fn a_named_strategy_is_its_first_ranked_candidate_from_the_unchanged_set() { + let set = ranked_set(); + let before = ranking_fingerprint(&set); + + let target = GenerationStrategy::RepeatVariation; + let selected = select_ranked(&set, Some(target)).expect("the strategy contributed"); + let first_index = set + .ranked + .iter() + .position(|c| c.value.strategy == target) + .expect("present in a full set"); + assert!( + ptr::eq(selected, &raw const set.ranked[first_index]), + "the FIRST ranked candidate of that strategy — not a re-ranking" + ); + + assert_eq!( + ranking_fingerprint(&set), + before, + "selection only: the set, the seeds, and the order are untouched" + ); +} + +#[test] +fn every_strategy_in_a_full_set_is_selectable() { + let set = ranked_set(); + for strategy in ALL_STRATEGIES { + let selected = select_ranked(&set, Some(strategy)) + .unwrap_or_else(|| panic!("{strategy:?} contributed to the set")); + assert_eq!(selected.value.strategy, strategy); + } +} + +#[test] +fn an_absent_strategy_selects_nothing() { + let mut set = ranked_set(); + set.ranked + .retain(|c| c.value.strategy != GenerationStrategy::ShuffleMotifs); + assert!( + select_ranked(&set, Some(GenerationStrategy::ShuffleMotifs)).is_none(), + "no candidate of the strategy means no selection — never a fallback" + ); +}