Skip to main content

sim_lib_pitch_scale/
generate.rs

1//! Bounded scale generation from tetrachords and tertian fixtures.
2
3use sim_lib_discrete_search::{
4    NeverInterrupt, SearchControl, SearchProblem, SearchRun, SearchStep, solve,
5};
6use sim_lib_pitch_core::PitchClass;
7use sim_lib_pitch_set::{
8    PitchClassMask, PitchSetGraphError, PitchSetMovePolicy, PitchSetNeighborhood, PitchSetSpace,
9    ThirdStackSignature, ThirdStep,
10};
11
12use crate::PitchScaleError;
13
14/// Join interval between the lower tetrachord end and the upper tetrachord root.
15#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
16pub enum TetrachordJoin {
17    /// Join by a whole tone.
18    Whole,
19    /// Join by a semitone.
20    Half,
21}
22
23impl TetrachordJoin {
24    /// Returns the join width in semitones.
25    pub const fn semitones(self) -> u8 {
26        match self {
27            Self::Whole => 2,
28            Self::Half => 1,
29        }
30    }
31}
32
33/// Four scale degrees contained in one tetrachord, rooted at zero.
34#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub struct Tetrachord {
36    /// Ascending semitone offsets from the tetrachord root.
37    pub offsets: [u8; 4],
38}
39
40impl Tetrachord {
41    /// Constructs a validated rooted tetrachord.
42    pub fn new(offsets: [u8; 4]) -> Result<Self, PitchScaleError> {
43        if offsets[0] != 0 {
44            return Err(PitchScaleError::InvalidScaleInterval(offsets[0]));
45        }
46        for offset in offsets {
47            if offset >= 12 {
48                return Err(PitchScaleError::InvalidScaleInterval(offset));
49            }
50        }
51        if offsets.windows(2).any(|window| window[0] >= window[1]) {
52            return Err(PitchScaleError::InvalidScaleDegree(4));
53        }
54        Ok(Self { offsets })
55    }
56}
57
58/// One generated scale candidate with its source choices and derived evidence.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct GeneratedScale {
61    /// Stable zero-based ordinal in emission order.
62    pub ordinal: usize,
63    /// Lower tetrachord.
64    pub lower: Tetrachord,
65    /// Upper tetrachord.
66    pub upper: Tetrachord,
67    /// Join between the tetrachords.
68    pub join: TetrachordJoin,
69    /// Ascending semitone offsets from the scale root.
70    pub intervals: Vec<u8>,
71    /// Pitch-class set identity for the scale.
72    pub mask: PitchClassMask,
73    /// Tertian decoding from the scale, when every adjacent third is major or minor.
74    pub third_stack: Option<ThirdStackSignature>,
75    /// Matching catalog fixture name, if this is one of the preserved w/x/y/z scales.
76    pub fixture: Option<char>,
77}
78
79/// A preserved catalog fixture for the four named seven-note scale derivations.
80#[derive(Copy, Clone, Debug, PartialEq, Eq)]
81pub struct ScaleFixture {
82    /// Fixture name: w, x, y, or z.
83    pub name: char,
84    /// Third-stack gap string recorded by the catalog.
85    pub third_stack_gaps: &'static [ThirdStep],
86    /// Ascending scale intervals rooted at zero.
87    pub intervals: &'static [u8],
88}
89
90/// Catalog fixture w: the major scale.
91pub const SCALE_FIXTURE_W: ScaleFixture = ScaleFixture {
92    name: 'w',
93    third_stack_gaps: &[
94        ThirdStep::Major,
95        ThirdStep::Minor,
96        ThirdStep::Major,
97        ThirdStep::Minor,
98        ThirdStep::Minor,
99        ThirdStep::Major,
100        ThirdStep::Minor,
101    ],
102    intervals: &[0, 2, 4, 5, 7, 9, 11],
103};
104
105/// Catalog fixture x.
106pub const SCALE_FIXTURE_X: ScaleFixture = ScaleFixture {
107    name: 'x',
108    third_stack_gaps: &[
109        ThirdStep::Major,
110        ThirdStep::Minor,
111        ThirdStep::Major,
112        ThirdStep::Minor,
113        ThirdStep::Minor,
114        ThirdStep::Minor,
115        ThirdStep::Major,
116    ],
117    intervals: &[0, 2, 4, 5, 7, 8, 11],
118};
119
120/// Catalog fixture y.
121pub const SCALE_FIXTURE_Y: ScaleFixture = ScaleFixture {
122    name: 'y',
123    third_stack_gaps: &[
124        ThirdStep::Major,
125        ThirdStep::Major,
126        ThirdStep::Minor,
127        ThirdStep::Minor,
128        ThirdStep::Minor,
129        ThirdStep::Major,
130        ThirdStep::Minor,
131    ],
132    intervals: &[0, 2, 4, 5, 8, 9, 11],
133};
134
135/// Catalog fixture z.
136pub const SCALE_FIXTURE_Z: ScaleFixture = ScaleFixture {
137    name: 'z',
138    third_stack_gaps: &[
139        ThirdStep::Minor,
140        ThirdStep::Major,
141        ThirdStep::Major,
142        ThirdStep::Minor,
143        ThirdStep::Minor,
144        ThirdStep::Major,
145        ThirdStep::Minor,
146    ],
147    intervals: &[0, 2, 3, 5, 7, 9, 11],
148};
149
150/// All preserved catalog scale fixtures in their stable name order.
151pub const SCALE_FIXTURES: &[ScaleFixture] = &[
152    SCALE_FIXTURE_W,
153    SCALE_FIXTURE_X,
154    SCALE_FIXTURE_Y,
155    SCALE_FIXTURE_Z,
156];
157
158/// Generate scales from lower and upper tetrachords under explicit search controls.
159///
160/// Choices are emitted in input order, with join choices as the final axis. The
161/// generic discrete-search engine owns work charging, result limits, seed
162/// recording, and the receipt.
163pub fn generate_scales(
164    tetrachords: &[Tetrachord],
165    joins: &[TetrachordJoin],
166    control: SearchControl,
167) -> SearchRun<GeneratedScale> {
168    solve(
169        &ScaleGenerationProblem { tetrachords, joins },
170        control,
171        &NeverInterrupt,
172    )
173}
174
175/// Decode a rooted seven-note scale into its cyclic stack of thirds.
176pub fn decode_scale_third_stack(intervals: &[u8]) -> Option<ThirdStackSignature> {
177    if intervals.len() != 7 || intervals.first().copied() != Some(0) {
178        return None;
179    }
180    let mut sorted = intervals.to_vec();
181    sorted.sort_unstable();
182    sorted.dedup();
183    if sorted != intervals {
184        return None;
185    }
186    let mut steps = Vec::new();
187    let mut index = 0usize;
188    for _ in 0..intervals.len() {
189        let next = (index + 2) % intervals.len();
190        let delta = (i16::from(intervals[next]) - i16::from(intervals[index])).rem_euclid(12);
191        steps.push(match delta {
192            3 => ThirdStep::Minor,
193            4 => ThirdStep::Major,
194            _ => return None,
195        });
196        index = next;
197    }
198    Some(ThirdStackSignature {
199        root: PitchClass::C,
200        steps,
201        guard: true,
202    })
203}
204
205/// Return neighboring scale masks under the prior pitch-set neighborhood API.
206pub fn nudge_scale_neighborhood(
207    scale: PitchClassMask,
208    move_policy: PitchSetMovePolicy,
209    control: SearchControl,
210) -> Result<Vec<PitchClassMask>, PitchSetGraphError> {
211    let graph = PitchSetNeighborhood::new(
212        PitchSetSpace::chromatic(scale.count_bits() as u8),
213        move_policy,
214    )
215    .materialize(control)?;
216    let Some(source) = graph.nodes.iter().position(|candidate| *candidate == scale) else {
217        return Ok(Vec::new());
218    };
219    let mut neighbors = graph
220        .edges
221        .iter()
222        .filter_map(|edge| {
223            if edge.source == source {
224                Some(graph.nodes[edge.target])
225            } else if edge.target == source {
226                Some(graph.nodes[edge.source])
227            } else {
228                None
229            }
230        })
231        .collect::<Vec<_>>();
232    neighbors.sort_by_key(|mask| mask.bits());
233    neighbors.dedup();
234    Ok(neighbors)
235}
236
237struct ScaleGenerationProblem<'a> {
238    tetrachords: &'a [Tetrachord],
239    joins: &'a [TetrachordJoin],
240}
241
242#[derive(Clone, Debug, Default, PartialEq, Eq)]
243struct ScaleGenerationState {
244    lower: Option<usize>,
245    upper: Option<usize>,
246    join: Option<usize>,
247}
248
249#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
250enum ScaleGenerationChoice {
251    Lower(usize),
252    Upper(usize),
253    Join(usize),
254}
255
256impl SearchProblem for ScaleGenerationProblem<'_> {
257    type State = ScaleGenerationState;
258    type Choice = ScaleGenerationChoice;
259    type Output = GeneratedScale;
260
261    fn initial_state(&self) -> Self::State {
262        ScaleGenerationState::default()
263    }
264
265    fn expand(&self, state: &Self::State, out: &mut Vec<Self::Choice>) {
266        if state.lower.is_none() {
267            out.extend((0..self.tetrachords.len()).map(ScaleGenerationChoice::Lower));
268        } else if state.upper.is_none() {
269            out.extend((0..self.tetrachords.len()).map(ScaleGenerationChoice::Upper));
270        } else if state.join.is_none() {
271            out.extend((0..self.joins.len()).map(ScaleGenerationChoice::Join));
272        }
273    }
274
275    fn apply(&self, state: &Self::State, choice: &Self::Choice) -> SearchStep<Self::State> {
276        let mut next = state.clone();
277        match *choice {
278            ScaleGenerationChoice::Lower(index) if index < self.tetrachords.len() => {
279                next.lower = Some(index);
280            }
281            ScaleGenerationChoice::Upper(index) if index < self.tetrachords.len() => {
282                next.upper = Some(index);
283            }
284            ScaleGenerationChoice::Join(index) if index < self.joins.len() => {
285                next.join = Some(index);
286            }
287            _ => return SearchStep::infeasible("scale generation choice index outside axis"),
288        }
289        SearchStep::Continue(next)
290    }
291
292    fn finish(&self, state: &Self::State) -> Option<Self::Output> {
293        let lower_index = state.lower?;
294        let upper_index = state.upper?;
295        let join_index = state.join?;
296        let lower = self.tetrachords[lower_index];
297        let upper = self.tetrachords[upper_index];
298        let join = self.joins[join_index];
299        let upper_root = lower.offsets[3].checked_add(join.semitones())?;
300        let mut intervals = lower.offsets.to_vec();
301        for offset in upper.offsets {
302            let interval = upper_root.checked_add(offset)?;
303            if interval == 12 {
304                continue;
305            }
306            if interval > 12 {
307                return None;
308            }
309            intervals.push(interval);
310        }
311        intervals.sort_unstable();
312        intervals.dedup();
313        if intervals.len() < 2 {
314            return None;
315        }
316        let pitch_classes = intervals
317            .iter()
318            .map(|offset| PitchClass::C.transpose(i32::from(*offset)))
319            .collect::<Vec<_>>();
320        let mask = PitchClassMask::from_pitch_classes(&pitch_classes);
321        let third_stack = decode_scale_third_stack(&intervals);
322        let fixture = fixture_for(&intervals, third_stack.as_ref());
323        Some(GeneratedScale {
324            ordinal: lower_index * self.tetrachords.len() * self.joins.len()
325                + upper_index * self.joins.len()
326                + join_index,
327            lower,
328            upper,
329            join,
330            intervals,
331            mask,
332            third_stack,
333            fixture,
334        })
335    }
336}
337
338fn fixture_for(intervals: &[u8], stack: Option<&ThirdStackSignature>) -> Option<char> {
339    let stack = stack?;
340    SCALE_FIXTURES
341        .iter()
342        .find(|fixture| fixture.intervals == intervals && fixture.third_stack_gaps == stack.steps)
343        .map(|fixture| fixture.name)
344}