Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
2a5b55e
test: red — PartProfile harmonic context: key estimate enriches pitch…
claude Jun 11, 2026
0da3bd6
feat(complement): harmonic context — Krumhansl–Schmuckler key estimat…
claude Jun 11, 2026
9a9c398
docs: S13 harmonic-context checkbox and done status; glossary term; r…
claude Jun 11, 2026
0793d46
test: red — sub-bar (beat-level) period detection; corpus schema v5 (…
claude Jun 11, 2026
4d54257
feat(structure): sub-bar period detection; corpus schema v5
claude Jun 11, 2026
5aa8e41
docs: S14 sub-bar period checkbox and status; record the decision
claude Jun 11, 2026
04c86f3
test: red — Codex P2 (PR #38): silence must not establish a sub-bar p…
claude Jun 11, 2026
dfa274f
fix(structure): empty beat pairs sit out of the sub-bar lag mean (Cod…
claude Jun 11, 2026
01e19c8
docs: record the empty-beat-pair exclusion (Codex P2, PR #38)
claude Jun 11, 2026
33ef917
test: red — ComplexityProfile: the per-axis complexity vector (S14 re…
claude Jun 11, 2026
7d4511c
feat(structure): ComplexityProfile — the per-axis complexity vector
claude Jun 11, 2026
ade3ee9
docs: S14 ComplexityProfile checkbox and status; record the axis deci…
claude Jun 11, 2026
d91f7d4
style: rustfmt line wraps in the ComplexityProfile tests
claude Jun 11, 2026
29311e0
test: red — preview analysis surfaces the ComplexityProfile (S8 × S14)
claude Jun 11, 2026
d65ec85
feat(preview): the inspector shows the S14 complexity vector
claude Jun 11, 2026
229caa3
docs: S8 progress note for the complexity inspector block; record the…
claude Jun 11, 2026
4083ccc
test: red — Codex P2 (PR #38): a similarity mean is not a tiling test
claude Jun 11, 2026
4cdedf4
fix(structure): the sub-bar pass is a tiling test, not a similarity m…
claude Jun 11, 2026
e52d00e
docs: record the sub-bar tiling-test decision (Codex P2, PR #38, roun…
claude Jun 11, 2026
040ec19
test: red — Codex P2 (PR #38): a sub-bar tile seen once is no period
claude Jun 11, 2026
951a074
fix(structure): a sub-bar tile must be observed twice (Codex P2, PR #38)
claude Jun 11, 2026
d80169e
docs: record the observed-twice sub-bar rule (Codex P2, PR #38, round…
claude Jun 11, 2026
7d71319
test: red — Codex P2 (PR #38): transport must survive inspector clipping
claude Jun 11, 2026
0ebaf48
fix(preview): transport renders above the metrics blocks (Codex P2, P…
claude Jun 11, 2026
ed1cb7a
docs: record the inspector liveness-ordering decision (Codex P2, PR #38)
claude Jun 11, 2026
1a153ee
test: red — corpus schema v6: ChunkMeta persists the complexity profi…
claude Jun 11, 2026
17f595c
feat(corpus): schema v6 — ChunkMeta persists the complexity profile
claude Jun 11, 2026
77f10ce
docs: S14 complexity persistence note; record the schema-v6 decision
claude Jun 11, 2026
1d55b17
style: rustfmt line wraps in the v6 schema tests
claude Jun 11, 2026
f8f7757
test: red — Codex P2 (PR #38): technique spans cover only their own v…
claude Jun 11, 2026
a698a85
fix(structure): technique spans cover only their own voice (Codex P2,…
claude Jun 11, 2026
66db4f6
docs: record the per-voice span-scoping decision (Codex P2, PR #38)
claude Jun 11, 2026
a5d0a45
test: red — similarity v3: the complexity axes join the edge (S7 × sc…
claude Jun 11, 2026
0babec0
feat(similarity): v3 — the complexity axes join the edge
claude Jun 11, 2026
ef0e525
docs: S7 similarity v3 progress note; record the complexity-join deci…
claude Jun 11, 2026
8d05ddd
test: red — S8 curation slice: approve/reject intents feed the corpus…
claude Jun 11, 2026
a6726ea
feat(preview): curation slice — approve/reject intents persist into t…
claude Jun 11, 2026
66155d3
docs: S8 curation slice progress; record the decision-placement choice
claude Jun 11, 2026
2587c52
test: red — Codex P2 (PR #38): the inferred key must hold under a non…
claude Jun 11, 2026
fb842a7
fix(complement): anchor the substitution material to the band floor's…
claude Jun 11, 2026
aee734e
docs: record the pitch-class anchoring decision (Codex P2, PR #38)
claude Jun 11, 2026
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
174 changes: 168 additions & 6 deletions core/src/complement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,45 @@ pub struct ComplementSpec {
pub register_offset: i8,
}

/// 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.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HarmonicContext {
/// Tonic pitch class: `0` = C … `11` = B.
pub tonic_pitch_class: u8,
/// Major or natural minor.
pub mode: KeyMode,
/// Duration-weighted fraction of notes on the key's scale, in `[0, 1]`.
pub scale_fit: f64,
}

/// The per-part feature summary derived from part A (glossary §8).
///
/// Richer than `VoiceFeatures`: it is bucketed against the master-bar timeline
Expand All @@ -101,6 +140,8 @@ pub struct PartProfile {
/// Distinct technique labels across the part — per-note marks *and* spanning
/// techniques (ADR-0018), so the overlap axis sees both.
pub techniques: BTreeSet<&'static str>,
/// The estimated key and scale fit, or `None` when the part has no notes.
pub harmony: Option<HarmonicContext>,
}

/// Relation-as-provenance: per-axis scores describing how B relates to A.
Expand Down Expand Up @@ -394,6 +435,8 @@ pub fn analyze_part(score: &Score, track_index: usize) -> Result<PartProfile, Co
pitches.dedup();

let techniques = voice_technique_labels(track);
let weighted: Vec<(u8, u32)> = notes.iter().map(|n| (n.pitch, n.duration.0)).collect();
let harmony = estimate_harmony(&weighted);

let note_count = notes.len();
let bar_count = score.master_bars.len();
Expand All @@ -406,9 +449,107 @@ pub fn analyze_part(score: &Score, track_index: usize) -> Result<PartProfile, Co
density,
pitches,
techniques,
harmony,
})
}

/// 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,
];

/// Estimates the part's key from its notes (Krumhansl–Schmuckler), or `None`
/// when there are none.
///
/// Histogram weights are note durations in ticks; a part whose notes all have
/// zero duration falls back to counting notes so the estimate stays defined.
/// Takes `(pitch, duration-in-ticks)` pairs so other analyses (the S14
/// complexity profile) reuse the one estimator.
// Float-only arithmetic over fixed 12-bin histograms; pitch-class indices are
// mod-12 by construction.
#[allow(clippy::arithmetic_side_effects, clippy::indexing_slicing)]
pub(crate) fn estimate_harmony(notes: &[(u8, u32)]) -> Option<HarmonicContext> {
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];
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));
}
}
}
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 };

Some(HarmonicContext {
tonic_pitch_class,
mode,
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::<f64>() / n;
let mean_y: f64 = rotated.iter().sum::<f64>() / 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 * dy;
var_x += dx * dx;
var_y += dy * dy;
Comment on lines +541 to +543

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Fix clippy-blocking float accumulations

In this repo's required cargo clippy --all-targets -- -D warnings check, these new accumulation expressions trigger clippy::suboptimal_flops under the enabled nursery lints, so the lint job fails before CI can pass. Rewriting the updates with mul_add (or explicitly allowing the lint here) keeps the harmonic-context implementation from blocking the documented lint gate.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Checked against the documented lint gate on the pinned toolchain (rust-toolchain.toml): cargo clippy --all-targets -- -D warnings exits 0 with zero warnings on this branch. clippy::suboptimal_flops does not fire on compound-assignment accumulations (numerator += dx * dy desugars to +=, which the lint skips); it only flags expressions of the shape c + a * b, which is why structure.rs uses mul_add there but plain accumulation loops elsewhere in the crate pass clean. Leaving the loop as written — no allow needed.


Generated by Claude Code

}
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
Expand Down Expand Up @@ -518,7 +659,7 @@ pub fn arrange_complement(
/// generator's `ConstrainedRandomWalk` — the one mode that synthesises a fresh
/// sequence instead of deriving B from A's grid.
///
/// The request is compiled from A: A's pitch classes as the scale, A's band
/// The request is compiled from A: A's harmonic context as the scale, A's band
/// shifted by `spec.register_offset` as the pitch bounds, A's bar count /
/// meter / tempo / PPQN as constraints, A's bar rhythms as templates. The
/// generated line is lifted onto A's master bars, which therefore must share
Expand Down Expand Up @@ -801,7 +942,7 @@ fn arrange_support_layer(
/// first sound and the end of the last master bar (leading silence has no call
/// to answer). Every gap at least one quarter long gets exactly one answer: a
/// B note at the gap start sustaining through the gap, at the velocity of the
/// preceding A note, pitched seed-deterministically from A's pitch classes in
/// preceding A note, pitched seed-deterministically from A's harmonic context in
/// the band shifted by `spec.register_offset`. B's onsets are disjoint from
/// A's by construction. No qualifying gap is
/// [`ComplementError::NoGapsToAnswer`].
Expand Down Expand Up @@ -902,15 +1043,15 @@ fn shifted_band(register: PitchRange, offset: i8) -> (u8, u8) {

/// B's event groups on A's exact onset grid: every B note keeps A's onset,
/// duration, and velocity, with the pitch substituted seed-deterministically
/// from A's pitch classes mapped into the `[band_lo, band_hi]` register band.
/// from A's harmonic context mapped into the `[band_lo, band_hi]` register band.
fn grid_locked_groups(
a_track: &Track,
profile: &PartProfile,
band_lo: u8,
band_hi: u8,
seed: GenerationSeed,
) -> Vec<EventGroup> {
// Scale: A's distinct pitch classes, as intervals above the band's low note.
// Scale: A's harmonic context, as intervals above the band's low note.
let intervals = scale_intervals_from(profile);

voice_notes(a_track)
Expand Down Expand Up @@ -1004,15 +1145,36 @@ fn voice_atom_notes(track: &Track) -> Vec<AtomNote> {
notes
}

/// A's distinct pitch classes (semitone offsets from A's lowest pitch), sorted
/// and non-empty — the scale B substitutes pitches from.
/// The scale B substitutes pitches from, as semitone offsets above A's lowest
/// pitch (mod 12), sorted and non-empty: A's distinct pitch classes *plus*
/// the inferred key's scale (the harmonic context — enrich, don't replace),
/// so B can always echo A's actual notes and a sparse part still opens a
/// whole scale of material.
fn scale_intervals_from(profile: &PartProfile) -> Vec<u8> {
let min_a = profile.pitches.first().copied().unwrap_or(0);
let mut intervals: Vec<u8> = profile
.pitches
.iter()
.map(|&p| p.saturating_sub(min_a).checked_rem(12).unwrap_or(0))
.collect();
if let Some(harmony) = profile.harmony {
let min_pc = min_a.checked_rem(12).unwrap_or(0);
for offset in harmony.mode.scale_offsets() {
let pc = harmony
.tonic_pitch_class
.saturating_add(offset)
.checked_rem(12)
.unwrap_or(0);
// The scale pitch class as an offset above A's lowest pitch class,
// wrapped into [0, 12).
let interval = pc
.saturating_add(12)
.saturating_sub(min_pc)
.checked_rem(12)
.unwrap_or(0);
intervals.push(interval);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep harmonic scale anchored to the inferred key

When register_offset is not octave-equivalent, these new harmony intervals are later added to band_lo by degree_to_pitch/PitchMaterial::root, but here they are measured from A's lowest pitch class. That transposes the inferred key by the register offset: for example, a C-major part shifted up a fifth gets G-major material (including F#) instead of the promised C-major scale, so the harmony enrichment can make RhythmLock/SupportLayer/CallResponse/CounterMelody emit off-key notes.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Confirmed and fixed in fb842a7 (red → green): a C-major part with register_offset: 7 reproduced the F# (pitch 78, pc 6) exactly as described. scale_intervals_from now takes the band floor and measures every offset from the band floor's pitch class, so the materialised pitch classes equal A's at any offset — the key is the anchor, the band only picks the octave. Went one step further than the report: A's literal pitch classes had the same flaw (masked by the fixtures' octave-only offsets), so the anchor rule covers the whole material, not just the harmony enrichment. Octave offsets are unchanged (the two anchors coincide mod 12), so all existing fixtures keep their pitches. Decision recorded in docs/decisions.log.md.


Generated by Claude Code

}
}
intervals.sort_unstable();
intervals.dedup();
if intervals.is_empty() {
Expand Down
5 changes: 4 additions & 1 deletion core/src/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ use crate::structure::StructureMetrics;
/// gains optional [`StyleCohort`] and [`EnsembleRef`] under the same
/// pattern, and [`CorpusManifest`] gains `groups` (skipped while empty), so
/// pre-v4 records and manifests keep loading and re-serialize losslessly.
pub const SCHEMA_VERSION: u32 = 4;
/// - v5 — sub-bar period detection (S14 refinement): [`StructureMetrics`]
/// gains optional `detected_subbar_period_ticks` under the same pattern;
/// pre-v5 structure blocks load it as `None` and re-serialize losslessly.
pub const SCHEMA_VERSION: u32 = 5;

// ── identifiers ───────────────────────────────────────────────────────────────

Expand Down
Loading