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
2 changes: 2 additions & 0 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -611,13 +611,15 @@ fn cmd_generate(input: &Path, output: &Path, opts: &GenerateOpts<'_>) -> Result<
variants_per_strategy: candidates,
gesture: !no_gesture,
},
None,
)?;
let RankedSet {
ranked,
base,
source_rhythms,
gesture,
policy,
..
} = &set;

print_rhythm_diagnostics(source_rhythms, &base.constraints, gesture.is_some());
Expand Down
1 change: 1 addition & 0 deletions core/src/complement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
47 changes: 45 additions & 2 deletions core/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,37 @@ fn effective_grids(templates: &[RhythmTemplate], bar_duration: Ticks) -> Vec<Vec
.collect()
}

/// The explicit scheduler (ADR-0029 §7): one grid per template, **in the
/// palette's own order and count** — an empty template stays an empty grid
/// (a silent bar in the rotation), notes still clamp to the bar, and there
/// is no quarter fallback. This is deliberately not `effective_grids` with
/// an `if`: the two schedulers answer to different laws.
fn explicit_grids(palette: &[RhythmTemplate], bar_duration: Ticks) -> Vec<Vec<TemplateNote>> {
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 {
Comment on lines +127 to +130

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Split the scheduler implementation from its tests

AGENTS.md's TDD workflow requires a failing-test commit before implementation and says never to commit a new pub fn implementation in the same commit as the tests that cover it. This commit adds core/tests/explicit_rhythm.rs alongside this new public scheduler API, so the required red/green split is missing; please split the history into a red test commit and a later implementation commit.

Useful? React with 👍 / 👎.

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.
Expand Down Expand Up @@ -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<RhythmTemplate>,
/// 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<Vec<RhythmTemplate>>,
/// Strategy to apply.
pub strategy: GenerationStrategy,
}
Expand Down Expand Up @@ -321,8 +357,12 @@ pub fn generate(request: &RuleGenerationRequest) -> Result<GenerationCandidate,
if request.constraints.bar_count == 0 {
return Err(GenerationError::BarCountZero);
}
let active_palette: &[RhythmTemplate] = request
.explicit_rhythms
.as_deref()
.unwrap_or(&request.source_rhythms);
if request.strategy == GenerationStrategy::RhythmCopyPitchSubstitute
&& !request.source_rhythms.iter().any(|t| !t.notes.is_empty())
&& !active_palette.iter().any(|t| !t.notes.is_empty())
{
return Err(GenerationError::RhythmTemplateMissing);
}
Expand All @@ -345,7 +385,10 @@ pub fn generate(request: &RuleGenerationRequest) -> Result<GenerationCandidate,

let mut prng = Xorshift64::new(request.seed.0);
let c = &request.constraints;
let grids = bar_grids(&request.source_rhythms, bar_duration, c.ticks_per_quarter);
let grids = request.explicit_rhythms.as_ref().map_or_else(
|| bar_grids(&request.source_rhythms, bar_duration, c.ticks_per_quarter),
|palette| explicit_grids(palette, bar_duration),
);
// The single degree→pitch mapper: the full in-range, in-class ladder, so
// strategies span `[pitch_lo, pitch_hi]` rather than one octave above the
// palette anchor (register increment).
Expand Down
45 changes: 34 additions & 11 deletions core/src/generation_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,15 @@ pub struct RankedSet {
pub ranked: Vec<Scored<rerank::SetCandidate>>,
/// 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<generate::RhythmTemplate>,
/// 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<GestureControl>,
/// The rerank policy the aggregates were scored under.
Expand All @@ -196,17 +202,31 @@ pub fn ranked_candidates(
score: &Score,
material: Option<&CorpusMaterial>,
ask: &GenerationAsk,
rhythm_override: Option<&[generate::RhythmTemplate]>,
) -> Result<RankedSet, GenerationInputError> {
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<Vec<generate::RhythmTemplate>> = 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);
Expand All @@ -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,
})?;
Expand All @@ -226,6 +247,7 @@ pub fn ranked_candidates(
ranked,
base,
source_rhythms,
rhythm_explicit: explicit.is_some(),
gesture,
policy,
})
Expand Down Expand Up @@ -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,
))],
Expand Down
14 changes: 12 additions & 2 deletions core/src/rerank.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ pub struct SetRequest {
/// from corpus chunks. Empty templates are ignored; with none usable,
/// `RhythmCopyPitchSubstitute` is skipped.
pub source_rhythms: Vec<RhythmTemplate>,
/// A caller-supplied palette honored verbatim by the explicit scheduler
/// (ADR-0029 §7); wins over `source_rhythms` when set.
pub explicit_rhythms: Option<Vec<RhythmTemplate>>,
/// Seed variants generated per strategy (must be ≥ 1).
pub variants_per_strategy: usize,
/// When set, every candidate is carved through the gesture compiler.
Expand Down Expand Up @@ -136,8 +139,14 @@ pub fn generate_candidate_set(request: &SetRequest) -> Result<Vec<SetCandidate>,
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();
Expand All @@ -161,6 +170,7 @@ pub fn generate_candidate_set(request: &SetRequest) -> Result<Vec<SetCandidate>,
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 {
Expand Down
1 change: 1 addition & 0 deletions core/src/structure.rs
Original file line number Diff line number Diff line change
Expand Up @@ -742,6 +742,7 @@ fn run_s6(request: &StructuredRequest, bar_count: usize) -> Result<Score, Struct
..request.constraints
},
source_rhythms: request.source_rhythms.clone(),
explicit_rhythms: None,
strategy: request.strategy,
})
.map_err(StructureGenError::Generation)?;
Expand Down
1 change: 1 addition & 0 deletions core/tests/characterization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ fn generate_is_deterministic_golden() {
pitch_lo: Pitch(36),
pitch_hi: Pitch(72),
},
explicit_rhythms: None,
source_rhythms: vec![RhythmTemplate::from_durations(&[Ticks(240); 8])],
strategy: GenerationStrategy::ConstrainedRandomWalk,
};
Expand Down
Loading
Loading