1use sim_lib_discrete_graph::{Directedness, Graph, GraphError};
4use sim_lib_discrete_search::SearchControl;
5use sim_lib_pitch_core::PitchClass;
6
7use crate::PitchClassMask;
8
9#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
11pub enum ZeroGapPolicy {
12 CollapseDuplicates,
14 PreserveMultiplicity,
16}
17
18#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20pub struct GapForm {
21 pub gaps: Vec<u8>,
25 pub zero_gap_policy: ZeroGapPolicy,
28}
29
30#[derive(Clone, Debug, PartialEq, Eq, Hash)]
32pub struct IntervalForm {
33 pub intervals: Vec<(PitchClass, PitchClass, u8)>,
36}
37
38#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
40pub struct PitchSetSpace {
41 pub cardinality: u8,
43}
44
45impl PitchSetSpace {
46 pub fn chromatic(cardinality: u8) -> Self {
48 Self { cardinality }
49 }
50}
51
52#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
54pub enum PitchSetMovePolicy {
55 Jumping,
57 NonJumping,
59}
60
61#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
63pub struct PitchSetNeighborhood {
64 pub space: PitchSetSpace,
66 pub move_policy: PitchSetMovePolicy,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
72pub enum PitchSetGraphError {
73 #[error("pitch-set cardinality {0} is outside 0..=12")]
75 InvalidCardinality(u8),
76 #[error("pitch-set graph enumeration exceeded {limit}: used {used}")]
78 SearchLimit {
79 limit: &'static str,
81 used: u64,
83 },
84 #[error(transparent)]
86 Graph(#[from] GraphError),
87}
88
89impl PitchClassMask {
90 pub fn gap_form(self) -> GapForm {
96 gap_form_from_pitch_classes(&self.pitch_classes(), ZeroGapPolicy::CollapseDuplicates)
97 }
98
99 pub fn interval_form(self) -> IntervalForm {
101 interval_form_from_pitch_classes(&self.pitch_classes())
102 }
103}
104
105pub fn gap_form_from_pitch_classes(
107 pitch_classes: &[PitchClass],
108 zero_gap_policy: ZeroGapPolicy,
109) -> GapForm {
110 let mut values: Vec<u8> = pitch_classes
111 .iter()
112 .map(|pitch_class| pitch_class.value())
113 .collect();
114 values.sort_unstable();
115 if matches!(zero_gap_policy, ZeroGapPolicy::CollapseDuplicates) {
116 values.dedup();
117 }
118 let gaps = match values.len() {
119 0 => Vec::new(),
120 1 => vec![0],
121 len => values
122 .iter()
123 .enumerate()
124 .map(|(index, value)| {
125 let next = values[(index + 1) % len];
126 (i16::from(next) - i16::from(*value)).rem_euclid(12) as u8
127 })
128 .collect(),
129 };
130 GapForm {
131 gaps,
132 zero_gap_policy,
133 }
134}
135
136pub fn interval_form_from_pitch_classes(pitch_classes: &[PitchClass]) -> IntervalForm {
138 let mut values: Vec<PitchClass> = pitch_classes.to_vec();
139 values.sort_by_key(|pitch_class| pitch_class.value());
140 values.dedup();
141 let mut intervals = Vec::new();
142 for (index, from) in values.iter().enumerate() {
143 for to in values.iter().skip(index + 1) {
144 intervals.push((*from, *to, to.value().wrapping_sub(from.value()) % 12));
145 }
146 }
147 IntervalForm { intervals }
148}
149
150impl PitchSetNeighborhood {
151 pub fn new(space: PitchSetSpace, move_policy: PitchSetMovePolicy) -> Self {
153 Self { space, move_policy }
154 }
155
156 pub fn materialize(
162 self,
163 control: SearchControl,
164 ) -> Result<Graph<PitchClassMask, i64>, PitchSetGraphError> {
165 if self.space.cardinality > 12 {
166 return Err(PitchSetGraphError::InvalidCardinality(
167 self.space.cardinality,
168 ));
169 }
170 let nodes = enumerate_masks(self.space.cardinality, &control)?;
171 let mut graph = Graph::with_nodes(nodes, Directedness::Undirected);
172 let mut work = graph.node_count() as u64;
173 for source in 0..graph.nodes.len() {
174 for target in (source + 1)..graph.nodes.len() {
175 if are_neighbors(graph.nodes[source], graph.nodes[target], self.move_policy) {
176 work = work.checked_add(1).ok_or(PitchSetGraphError::SearchLimit {
177 limit: "max_work",
178 used: u64::MAX,
179 })?;
180 if let Some(max_work) = control.max_work
181 && work > max_work
182 {
183 return Err(PitchSetGraphError::SearchLimit {
184 limit: "max_work",
185 used: work,
186 });
187 }
188 graph.add_edge(source, target, 1)?;
189 }
190 }
191 }
192 Ok(graph)
193 }
194}
195
196fn enumerate_masks(
197 cardinality: u8,
198 control: &SearchControl,
199) -> Result<Vec<PitchClassMask>, PitchSetGraphError> {
200 let mut masks = Vec::new();
201 let mut work = 0u64;
202 for bits in 0u16..=0x0fff {
203 if bits.count_ones() == u32::from(cardinality) {
204 work = work.checked_add(1).ok_or(PitchSetGraphError::SearchLimit {
205 limit: "max_work",
206 used: u64::MAX,
207 })?;
208 if let Some(max_work) = control.max_work
209 && work > max_work
210 {
211 return Err(PitchSetGraphError::SearchLimit {
212 limit: "max_work",
213 used: work,
214 });
215 }
216 if let Some(max_results) = control.max_results
217 && masks.len() >= max_results
218 {
219 return Err(PitchSetGraphError::SearchLimit {
220 limit: "max_results",
221 used: masks.len() as u64,
222 });
223 }
224 masks.push(PitchClassMask::new(bits).expect("enumeration yields valid mask bits"));
225 }
226 }
227 Ok(masks)
228}
229
230fn are_neighbors(a: PitchClassMask, b: PitchClassMask, policy: PitchSetMovePolicy) -> bool {
231 let removed = a.bits() & !b.bits();
232 let added = b.bits() & !a.bits();
233 if removed.count_ones() != 1 || added.count_ones() != 1 {
234 return false;
235 }
236 match policy {
237 PitchSetMovePolicy::Jumping => true,
238 PitchSetMovePolicy::NonJumping => {
239 let from = removed.trailing_zeros() as i32;
240 let to = added.trailing_zeros() as i32;
241 matches!((to - from).rem_euclid(12), 1 | 11)
242 }
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use sim_lib_discrete_graph::{shortest_path, verify_shortest_paths};
250 use sim_lib_discrete_search::SearchControl;
251
252 fn mask(values: &[PitchClass]) -> PitchClassMask {
253 PitchClassMask::from_pitch_classes(values)
254 }
255
256 #[test]
257 fn gap_forms_make_zero_gap_multiplicity_explicit() {
258 let source = [PitchClass::C, PitchClass::C, PitchClass::E, PitchClass::G];
259
260 assert_eq!(
261 gap_form_from_pitch_classes(&source, ZeroGapPolicy::CollapseDuplicates).gaps,
262 vec![4, 3, 5]
263 );
264 assert_eq!(
265 gap_form_from_pitch_classes(&source, ZeroGapPolicy::PreserveMultiplicity).gaps,
266 vec![0, 4, 3, 5]
267 );
268 assert_eq!(mask(&source).interval_vector().0, [0, 0, 1, 1, 1, 0]);
269 }
270
271 #[test]
272 fn interval_and_gap_forms_are_transposition_invariant() {
273 let source = mask(&[PitchClass::C, PitchClass::DS, PitchClass::FS, PitchClass::A]);
274 let transposed = source.rotate(5);
275
276 assert_eq!(source.gap_form(), transposed.gap_form());
277 assert_eq!(source.interval_vector(), transposed.interval_vector());
278 }
279
280 #[test]
281 fn interval_and_gap_forms_are_inversion_invariant_after_reordering() {
282 let source = mask(&[PitchClass::C, PitchClass::D, PitchClass::F, PitchClass::A]);
283 let inverted = source.invert(PitchClass::C);
284
285 let mut source_gaps = source.gap_form().gaps;
286 let mut inverted_gaps = inverted.gap_form().gaps;
287 source_gaps.sort_unstable();
288 inverted_gaps.sort_unstable();
289 assert_eq!(source_gaps, inverted_gaps);
290 assert_eq!(source.interval_vector(), inverted.interval_vector());
291 }
292
293 #[test]
294 fn jumping_neighborhood_materializes_reversible_graph() {
295 let graph =
296 PitchSetNeighborhood::new(PitchSetSpace::chromatic(2), PitchSetMovePolicy::Jumping)
297 .materialize(SearchControl::default())
298 .unwrap();
299 let start = graph
300 .nodes
301 .iter()
302 .position(|node| *node == mask(&[PitchClass::C, PitchClass::E]))
303 .unwrap();
304 let goal = graph
305 .nodes
306 .iter()
307 .position(|node| *node == mask(&[PitchClass::D, PitchClass::F]))
308 .unwrap();
309
310 let round = shortest_path(&graph, start, goal).unwrap();
311
312 assert_eq!(round.distance, Some(2));
313 verify_shortest_paths(&graph, &round.certificate).unwrap();
314 for edge in &graph.edges {
315 assert!(
316 graph
317 .neighbors(edge.target)
318 .unwrap()
319 .iter()
320 .any(|neighbor| {
321 neighbor.node == edge.source && *neighbor.weight == edge.weight
322 })
323 );
324 }
325 }
326
327 #[test]
328 fn non_jumping_neighborhood_uses_single_step_edges() {
329 let graph =
330 PitchSetNeighborhood::new(PitchSetSpace::chromatic(1), PitchSetMovePolicy::NonJumping)
331 .materialize(SearchControl::default())
332 .unwrap();
333 let start = graph
334 .nodes
335 .iter()
336 .position(|node| node.bits() == 0b1)
337 .unwrap();
338 let goal = graph
339 .nodes
340 .iter()
341 .position(|node| node.bits() == 0b100)
342 .unwrap();
343
344 let trail = shortest_path(&graph, start, goal).unwrap();
345
346 assert_eq!(trail.distance, Some(2));
347 verify_shortest_paths(&graph, &trail.certificate).unwrap();
348 }
349
350 #[test]
351 fn search_control_charges_open_enumeration() {
352 let err =
353 PitchSetNeighborhood::new(PitchSetSpace::chromatic(3), PitchSetMovePolicy::Jumping)
354 .materialize(SearchControl::default().with_max_results(4))
355 .unwrap_err();
356
357 assert_eq!(
358 err,
359 PitchSetGraphError::SearchLimit {
360 limit: "max_results",
361 used: 4,
362 }
363 );
364 }
365}