use sim_lib_discrete_graph::{Directedness, Graph, GraphError};
use sim_lib_discrete_search::SearchControl;
use sim_lib_pitch_core::PitchClass;
use crate::PitchClassMask;
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum ZeroGapPolicy {
CollapseDuplicates,
PreserveMultiplicity,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct GapForm {
pub gaps: Vec<u8>,
pub zero_gap_policy: ZeroGapPolicy,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct IntervalForm {
pub intervals: Vec<(PitchClass, PitchClass, u8)>,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct PitchSetSpace {
pub cardinality: u8,
}
impl PitchSetSpace {
pub fn chromatic(cardinality: u8) -> Self {
Self { cardinality }
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum PitchSetMovePolicy {
Jumping,
NonJumping,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub struct PitchSetNeighborhood {
pub space: PitchSetSpace,
pub move_policy: PitchSetMovePolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum PitchSetGraphError {
#[error("pitch-set cardinality {0} is outside 0..=12")]
InvalidCardinality(u8),
#[error("pitch-set graph enumeration exceeded {limit}: used {used}")]
SearchLimit {
limit: &'static str,
used: u64,
},
#[error(transparent)]
Graph(#[from] GraphError),
}
impl PitchClassMask {
pub fn gap_form(self) -> GapForm {
gap_form_from_pitch_classes(&self.pitch_classes(), ZeroGapPolicy::CollapseDuplicates)
}
pub fn interval_form(self) -> IntervalForm {
interval_form_from_pitch_classes(&self.pitch_classes())
}
}
pub fn gap_form_from_pitch_classes(
pitch_classes: &[PitchClass],
zero_gap_policy: ZeroGapPolicy,
) -> GapForm {
let mut values: Vec<u8> = pitch_classes
.iter()
.map(|pitch_class| pitch_class.value())
.collect();
values.sort_unstable();
if matches!(zero_gap_policy, ZeroGapPolicy::CollapseDuplicates) {
values.dedup();
}
let gaps = match values.len() {
0 => Vec::new(),
1 => vec![0],
len => values
.iter()
.enumerate()
.map(|(index, value)| {
let next = values[(index + 1) % len];
(i16::from(next) - i16::from(*value)).rem_euclid(12) as u8
})
.collect(),
};
GapForm {
gaps,
zero_gap_policy,
}
}
pub fn interval_form_from_pitch_classes(pitch_classes: &[PitchClass]) -> IntervalForm {
let mut values: Vec<PitchClass> = pitch_classes.to_vec();
values.sort_by_key(|pitch_class| pitch_class.value());
values.dedup();
let mut intervals = Vec::new();
for (index, from) in values.iter().enumerate() {
for to in values.iter().skip(index + 1) {
intervals.push((*from, *to, to.value().wrapping_sub(from.value()) % 12));
}
}
IntervalForm { intervals }
}
impl PitchSetNeighborhood {
pub fn new(space: PitchSetSpace, move_policy: PitchSetMovePolicy) -> Self {
Self { space, move_policy }
}
pub fn materialize(
self,
control: SearchControl,
) -> Result<Graph<PitchClassMask, i64>, PitchSetGraphError> {
if self.space.cardinality > 12 {
return Err(PitchSetGraphError::InvalidCardinality(
self.space.cardinality,
));
}
let nodes = enumerate_masks(self.space.cardinality, &control)?;
let mut graph = Graph::with_nodes(nodes, Directedness::Undirected);
let mut work = graph.node_count() as u64;
for source in 0..graph.nodes.len() {
for target in (source + 1)..graph.nodes.len() {
if are_neighbors(graph.nodes[source], graph.nodes[target], self.move_policy) {
work = work.checked_add(1).ok_or(PitchSetGraphError::SearchLimit {
limit: "max_work",
used: u64::MAX,
})?;
if let Some(max_work) = control.max_work
&& work > max_work
{
return Err(PitchSetGraphError::SearchLimit {
limit: "max_work",
used: work,
});
}
graph.add_edge(source, target, 1)?;
}
}
}
Ok(graph)
}
}
fn enumerate_masks(
cardinality: u8,
control: &SearchControl,
) -> Result<Vec<PitchClassMask>, PitchSetGraphError> {
let mut masks = Vec::new();
let mut work = 0u64;
for bits in 0u16..=0x0fff {
if bits.count_ones() == u32::from(cardinality) {
work = work.checked_add(1).ok_or(PitchSetGraphError::SearchLimit {
limit: "max_work",
used: u64::MAX,
})?;
if let Some(max_work) = control.max_work
&& work > max_work
{
return Err(PitchSetGraphError::SearchLimit {
limit: "max_work",
used: work,
});
}
if let Some(max_results) = control.max_results
&& masks.len() >= max_results
{
return Err(PitchSetGraphError::SearchLimit {
limit: "max_results",
used: masks.len() as u64,
});
}
masks.push(PitchClassMask::new(bits).expect("enumeration yields valid mask bits"));
}
}
Ok(masks)
}
fn are_neighbors(a: PitchClassMask, b: PitchClassMask, policy: PitchSetMovePolicy) -> bool {
let removed = a.bits() & !b.bits();
let added = b.bits() & !a.bits();
if removed.count_ones() != 1 || added.count_ones() != 1 {
return false;
}
match policy {
PitchSetMovePolicy::Jumping => true,
PitchSetMovePolicy::NonJumping => {
let from = removed.trailing_zeros() as i32;
let to = added.trailing_zeros() as i32;
matches!((to - from).rem_euclid(12), 1 | 11)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use sim_lib_discrete_graph::{shortest_path, verify_shortest_paths};
use sim_lib_discrete_search::SearchControl;
fn mask(values: &[PitchClass]) -> PitchClassMask {
PitchClassMask::from_pitch_classes(values)
}
#[test]
fn gap_forms_make_zero_gap_multiplicity_explicit() {
let source = [PitchClass::C, PitchClass::C, PitchClass::E, PitchClass::G];
assert_eq!(
gap_form_from_pitch_classes(&source, ZeroGapPolicy::CollapseDuplicates).gaps,
vec![4, 3, 5]
);
assert_eq!(
gap_form_from_pitch_classes(&source, ZeroGapPolicy::PreserveMultiplicity).gaps,
vec![0, 4, 3, 5]
);
assert_eq!(mask(&source).interval_vector().0, [0, 0, 1, 1, 1, 0]);
}
#[test]
fn interval_and_gap_forms_are_transposition_invariant() {
let source = mask(&[PitchClass::C, PitchClass::DS, PitchClass::FS, PitchClass::A]);
let transposed = source.rotate(5);
assert_eq!(source.gap_form(), transposed.gap_form());
assert_eq!(source.interval_vector(), transposed.interval_vector());
}
#[test]
fn interval_and_gap_forms_are_inversion_invariant_after_reordering() {
let source = mask(&[PitchClass::C, PitchClass::D, PitchClass::F, PitchClass::A]);
let inverted = source.invert(PitchClass::C);
let mut source_gaps = source.gap_form().gaps;
let mut inverted_gaps = inverted.gap_form().gaps;
source_gaps.sort_unstable();
inverted_gaps.sort_unstable();
assert_eq!(source_gaps, inverted_gaps);
assert_eq!(source.interval_vector(), inverted.interval_vector());
}
#[test]
fn jumping_neighborhood_materializes_reversible_graph() {
let graph =
PitchSetNeighborhood::new(PitchSetSpace::chromatic(2), PitchSetMovePolicy::Jumping)
.materialize(SearchControl::default())
.unwrap();
let start = graph
.nodes
.iter()
.position(|node| *node == mask(&[PitchClass::C, PitchClass::E]))
.unwrap();
let goal = graph
.nodes
.iter()
.position(|node| *node == mask(&[PitchClass::D, PitchClass::F]))
.unwrap();
let round = shortest_path(&graph, start, goal).unwrap();
assert_eq!(round.distance, Some(2));
verify_shortest_paths(&graph, &round.certificate).unwrap();
for edge in &graph.edges {
assert!(
graph
.neighbors(edge.target)
.unwrap()
.iter()
.any(|neighbor| {
neighbor.node == edge.source && *neighbor.weight == edge.weight
})
);
}
}
#[test]
fn non_jumping_neighborhood_uses_single_step_edges() {
let graph =
PitchSetNeighborhood::new(PitchSetSpace::chromatic(1), PitchSetMovePolicy::NonJumping)
.materialize(SearchControl::default())
.unwrap();
let start = graph
.nodes
.iter()
.position(|node| node.bits() == 0b1)
.unwrap();
let goal = graph
.nodes
.iter()
.position(|node| node.bits() == 0b100)
.unwrap();
let trail = shortest_path(&graph, start, goal).unwrap();
assert_eq!(trail.distance, Some(2));
verify_shortest_paths(&graph, &trail.certificate).unwrap();
}
#[test]
fn search_control_charges_open_enumeration() {
let err =
PitchSetNeighborhood::new(PitchSetSpace::chromatic(3), PitchSetMovePolicy::Jumping)
.materialize(SearchControl::default().with_max_results(4))
.unwrap_err();
assert_eq!(
err,
PitchSetGraphError::SearchLimit {
limit: "max_results",
used: 4,
}
);
}
}