Skip to main content

sim_lib_pitch_ratio/
relation.rs

1//! Lazy, cycle-safe relation trees over exact ratios.
2
3use sim_lib_discrete_search::{
4    NeverInterrupt, SearchControl, SearchProblem, SearchRun, SearchStep, solve,
5};
6
7use crate::{PitchRatio, RatioPolicy};
8
9/// One labeled ratio relation step.
10#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct RatioRelation {
12    /// Stable relation label.
13    pub label: String,
14    /// Exact ratio applied from the current node to the next node.
15    pub interval: PitchRatio,
16}
17
18impl RatioRelation {
19    /// Construct a relation step.
20    pub fn new(label: impl Into<String>, interval: PitchRatio) -> Self {
21        Self {
22            label: label.into(),
23            interval,
24        }
25    }
26}
27
28/// A path emitted from a bounded ratio relation tree.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub struct RatioRelationPath {
31    /// Ratios visited from the root through the terminal node.
32    pub nodes: Vec<PitchRatio>,
33    /// Relation labels applied between adjacent nodes.
34    pub labels: Vec<String>,
35}
36
37/// Lazily expand a cycle-safe relation tree under generic search control.
38pub fn expand_ratio_relation_tree(
39    root: PitchRatio,
40    relations: &[RatioRelation],
41    policy: RatioPolicy,
42    control: SearchControl,
43) -> SearchRun<RatioRelationPath> {
44    solve(
45        &RatioRelationProblem {
46            root: root.canonical(policy).unwrap_or(root),
47            relations,
48            policy,
49        },
50        control,
51        &NeverInterrupt,
52    )
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
56struct RatioRelationState {
57    nodes: Vec<PitchRatio>,
58    labels: Vec<String>,
59    terminal: bool,
60}
61
62#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
63enum RatioRelationChoice {
64    Emit,
65    Step { label: String, interval: PitchRatio },
66}
67
68struct RatioRelationProblem<'a> {
69    root: PitchRatio,
70    relations: &'a [RatioRelation],
71    policy: RatioPolicy,
72}
73
74impl SearchProblem for RatioRelationProblem<'_> {
75    type State = RatioRelationState;
76    type Choice = RatioRelationChoice;
77    type Output = RatioRelationPath;
78
79    fn initial_state(&self) -> Self::State {
80        RatioRelationState {
81            nodes: vec![self.root],
82            labels: Vec::new(),
83            terminal: false,
84        }
85    }
86
87    fn expand(&self, state: &Self::State, out: &mut Vec<Self::Choice>) {
88        if state.terminal {
89            return;
90        }
91        if !state.labels.is_empty() {
92            out.push(RatioRelationChoice::Emit);
93        }
94        out.extend(
95            self.relations
96                .iter()
97                .map(|relation| RatioRelationChoice::Step {
98                    label: relation.label.clone(),
99                    interval: relation.interval,
100                }),
101        );
102    }
103
104    fn apply(&self, state: &Self::State, choice: &Self::Choice) -> SearchStep<Self::State> {
105        let RatioRelationChoice::Step { label, interval } = choice else {
106            let mut terminal = state.clone();
107            terminal.terminal = true;
108            return SearchStep::Continue(terminal);
109        };
110        let Some(current) = state.nodes.last().copied() else {
111            return SearchStep::infeasible("relation path has no current node");
112        };
113        let Ok(next) = current
114            .multiply(*interval)
115            .and_then(|ratio| ratio.canonical(self.policy))
116        else {
117            return SearchStep::pruned("relation exceeds ratio policy");
118        };
119        if state.nodes.contains(&next) {
120            return SearchStep::pruned("ratio relation cycle");
121        }
122        let mut nodes = state.nodes.clone();
123        nodes.push(next);
124        let mut labels = state.labels.clone();
125        labels.push(label.clone());
126        SearchStep::Continue(RatioRelationState {
127            nodes,
128            labels,
129            terminal: false,
130        })
131    }
132
133    fn finish(&self, state: &Self::State) -> Option<Self::Output> {
134        if !state.terminal {
135            return None;
136        }
137        Some(RatioRelationPath {
138            nodes: state.nodes.clone(),
139            labels: state.labels.clone(),
140        })
141    }
142
143    fn score_state(&self, state: &Self::State) -> i64 {
144        i64::try_from(state.labels.len()).unwrap_or(i64::MAX)
145    }
146}