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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 118 additions & 10 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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> {
Expand Down Expand Up @@ -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),
},
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
125 changes: 125 additions & 0 deletions cli/tests/swang_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 20 additions & 0 deletions core/src/generation_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<generate::GenerationStrategy>,
) -> Option<&Scored<rerank::SetCandidate>> {
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.
Expand Down
Loading
Loading