Skip to main content

sim_lib_pitch_ratio/
chord.rs

1//! Exact ratio chord matrices, costs, and coverage.
2
3use std::collections::BTreeSet;
4
5use crate::{PitchRatio, PitchRatioError, RatioPolicy};
6
7/// Generalized-mean cost dialect.
8#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
9pub enum MeanDialect {
10    /// Normalize the generalized mean by the number of measured intervals.
11    #[default]
12    Standard,
13    /// Preserve the legacy tuned-recipe sum of powers without dividing by count.
14    LegacyTunedNoDivision,
15}
16
17/// Coverage summary for a ratio chord matrix.
18#[derive(Clone, Debug, PartialEq)]
19pub struct RatioCoverage {
20    /// Policy used while admitting root-normalized tones and intervals.
21    pub policy: RatioPolicy,
22    /// Number of input tones admitted under the policy.
23    pub admitted_tones: usize,
24    /// Number of input tones rejected under the policy.
25    pub rejected_tones: usize,
26    /// Number of directed matrix entries.
27    pub matrix_entries: usize,
28    /// Number of distinct exact directed intervals in the matrix.
29    pub distinct_intervals: usize,
30    /// Number of distinct octave-reduced interval classes in the matrix.
31    pub octave_classes: usize,
32    /// Number of matrix intervals rejected by the policy.
33    pub rejected_intervals: usize,
34}
35
36/// Exact chord-ratio analysis report.
37#[derive(Clone, Debug, PartialEq)]
38pub struct RatioChordReport {
39    /// Directed interval matrix, where `matrix[i][j] = tone[j] / tone[i]`.
40    pub matrix: Vec<Vec<PitchRatio>>,
41    /// Generalized-mean interval complexity cost.
42    pub cost: f64,
43    /// Exact coverage of admitted tones and intervals.
44    pub covered: RatioCoverage,
45}
46
47/// Analyze a chord using root index 0, standard generalized mean, and exponent 2.
48pub fn analyze_ratio_chord(
49    tones: &[PitchRatio],
50    policy: RatioPolicy,
51) -> Result<RatioChordReport, PitchRatioError> {
52    analyze_ratio_chord_with_root(tones, 0, policy, 2.0, MeanDialect::Standard)
53}
54
55/// Analyze a chord with an explicit root/reference tone and cost dialect.
56pub fn analyze_ratio_chord_with_root(
57    tones: &[PitchRatio],
58    root_index: usize,
59    policy: RatioPolicy,
60    mean_exponent: f64,
61    dialect: MeanDialect,
62) -> Result<RatioChordReport, PitchRatioError> {
63    let normalized = root_normalized_tones(tones, root_index, policy)?;
64    let matrix = ratio_interval_matrix(&normalized, policy)?;
65    let cost = generalized_mean_chord_cost(&matrix, policy, mean_exponent, dialect)?;
66    let covered = ratio_coverage(tones, &matrix, policy);
67    Ok(RatioChordReport {
68        matrix,
69        cost,
70        covered,
71    })
72}
73
74/// Normalize every tone against the declared root/reference tone.
75pub fn root_normalized_tones(
76    tones: &[PitchRatio],
77    root_index: usize,
78    policy: RatioPolicy,
79) -> Result<Vec<PitchRatio>, PitchRatioError> {
80    if tones.is_empty() {
81        return Err(PitchRatioError::EmptyChord);
82    }
83    let root = tones
84        .get(root_index)
85        .copied()
86        .ok_or(PitchRatioError::InvalidRootIndex {
87            root_index,
88            len: tones.len(),
89        })?;
90    tones
91        .iter()
92        .map(|tone| tone.divide(root)?.canonical(policy))
93        .collect()
94}
95
96/// Build a directed interval matrix, where `matrix[i][j] = tone[j] / tone[i]`.
97pub fn ratio_interval_matrix(
98    tones: &[PitchRatio],
99    policy: RatioPolicy,
100) -> Result<Vec<Vec<PitchRatio>>, PitchRatioError> {
101    if tones.is_empty() {
102        return Err(PitchRatioError::EmptyChord);
103    }
104    tones
105        .iter()
106        .map(|from| {
107            tones
108                .iter()
109                .map(|to| to.divide(*from)?.canonical(policy))
110                .collect()
111        })
112        .collect()
113}
114
115/// Compute generalized-mean chord cost from matrix interval complexity.
116pub fn generalized_mean_chord_cost(
117    matrix: &[Vec<PitchRatio>],
118    policy: RatioPolicy,
119    mean_exponent: f64,
120    dialect: MeanDialect,
121) -> Result<f64, PitchRatioError> {
122    if !mean_exponent.is_finite() || mean_exponent == 0.0 {
123        return Err(PitchRatioError::InvalidMeanExponent);
124    }
125    let mut sum = 0.0;
126    let mut count = 0usize;
127    for (row_index, row) in matrix.iter().enumerate() {
128        for (column_index, ratio) in row.iter().enumerate() {
129            if row_index == column_index {
130                continue;
131            }
132            let complexity = ratio_complexity(*ratio, policy)? as f64;
133            sum += complexity.powf(mean_exponent);
134            count += 1;
135        }
136    }
137    if count == 0 {
138        return Ok(0.0);
139    }
140    let mean_power = match dialect {
141        MeanDialect::Standard => sum / count as f64,
142        MeanDialect::LegacyTunedNoDivision => sum,
143    };
144    Ok(mean_power.powf(1.0 / mean_exponent))
145}
146
147/// Measure exact and octave-class coverage for a matrix.
148pub fn ratio_coverage(
149    input_tones: &[PitchRatio],
150    matrix: &[Vec<PitchRatio>],
151    policy: RatioPolicy,
152) -> RatioCoverage {
153    let mut distinct_intervals = BTreeSet::new();
154    let mut octave_classes = BTreeSet::new();
155    let mut rejected_intervals = 0usize;
156
157    for ratio in matrix.iter().flatten().copied() {
158        distinct_intervals.insert(ratio);
159        if let Ok(canonical) = ratio.canonical(policy) {
160            octave_classes.insert(canonical);
161        } else {
162            rejected_intervals += 1;
163        }
164    }
165
166    let admitted_tones = input_tones
167        .iter()
168        .filter(|tone| tone.canonical(policy).is_ok())
169        .count();
170
171    RatioCoverage {
172        policy,
173        admitted_tones,
174        rejected_tones: input_tones.len().saturating_sub(admitted_tones),
175        matrix_entries: matrix.iter().map(Vec::len).sum(),
176        distinct_intervals: distinct_intervals.len(),
177        octave_classes: octave_classes.len(),
178        rejected_intervals,
179    }
180}
181
182fn ratio_complexity(ratio: PitchRatio, policy: RatioPolicy) -> Result<u32, PitchRatioError> {
183    Ok(ratio
184        .factor_vector(policy)?
185        .exponents
186        .iter()
187        .map(|exponent| u32::from(exponent.unsigned_abs()))
188        .sum())
189}