Skip to main content

contree/common/
budget_schedule.rs

1//! How the anytime search widens its budget from one pass to the next.
2//!
3//! Each pass of `ConTreeLds` runs under a [`Budget`]: how far down the Gini
4//! ranking of features it may stray (the discrepancy), and how many of the
5//! best-ranked splits it may try at each node. When a pass is cut short by its
6//! budget, the search asks its [`BudgetSchedule`] for the next one.
7//!
8//! The schedule is told what the last pass did ([`PassReport`]) — whether the
9//! discrepancy or the split budget actually cut anything — so schedules that
10//! react to the search can be written without touching the solver. The fixed
11//! schedules ignore it.
12
13use std::fmt;
14use std::str::FromStr;
15
16use serde::{Deserialize, Serialize};
17
18/// The limits one pass of the anytime search runs under.
19///
20/// Both are counted as discrepancies, from 0: a discrepancy of 0 means "only
21/// the heuristic's first choice", and each unit allows one step further down
22/// its ranking.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub struct Budget {
25    /// How far down the Gini ranking of features the search may go,
26    /// accumulated along the path from the root.
27    pub discrepancy: usize,
28    /// How far down the Gini ranking of split points each node may go: the
29    /// node tries its best `split_discrepancy + 1` splits.
30    pub split_discrepancy: usize,
31}
32
33impl Budget {
34    /// The number of best-ranked splits a node may try under this budget.
35    pub fn split_budget(&self) -> usize {
36        self.split_discrepancy + 1
37    }
38}
39
40/// What a finished pass tells the schedule.
41#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
42pub struct PassReport {
43    /// The pass found a better tree than any earlier pass.
44    pub improved: bool,
45    /// The discrepancy budget kept the pass from trying some feature.
46    pub cut_by_discrepancy: bool,
47    /// The split budget kept the pass from trying some split point.
48    pub cut_by_split: bool,
49}
50
51/// The largest budget that can matter on a given problem. A pass at this
52/// budget is the unrestricted search.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub struct ScheduleBounds {
55    /// Largest useful feature discrepancy for this depth and feature count.
56    pub max_discrepancy: usize,
57    /// Most candidate splits any feature has at the root.
58    pub max_splits: usize,
59    /// Whether the split budget constrains the search at all. With the `mid`
60    /// split selector it applies only to the first pass, so a schedule that
61    /// varies it would produce passes that are otherwise identical.
62    pub split_applies: bool,
63}
64
65/// Produces the budget of each pass.
66pub trait BudgetSchedule: Send {
67    /// The budget of the first pass.
68    fn first(&mut self) -> Budget;
69
70    /// The budget of the next pass, given what the last one did; `None` when
71    /// there is nothing larger to try, which ends the search.
72    fn next(&mut self, last: &PassReport) -> Option<Budget>;
73}
74
75/// The schedules available, as a parameter.
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum ScheduleKind {
79    /// `(discrepancy, split)` pairs in order of increasing sum:
80    /// `(0,0)`, `(0,1) (1,0)`, `(0,2) (1,1) (2,0)`, …
81    #[default]
82    Diagonal,
83    /// Grows a square: every budget with `max(d, s) = k` before any with
84    /// `k + 1`, each shell ending on `(k, k)`, which contains every budget
85    /// before it.
86    Square,
87}
88
89impl ScheduleKind {
90    /// Every schedule, in declaration order.
91    pub const ALL: [Self; 2] = [Self::Diagonal, Self::Square];
92
93    /// The spelling used on the command line and in the Python API.
94    pub const fn as_str(self) -> &'static str {
95        match self {
96            Self::Diagonal => "diagonal",
97            Self::Square => "square",
98        }
99    }
100
101    /// Creates the schedule for a problem with the given bounds.
102    pub fn build(self, bounds: ScheduleBounds) -> Box<dyn BudgetSchedule> {
103        match self {
104            Self::Diagonal => Box::new(Diagonal::new(bounds)),
105            Self::Square => Box::new(Square::new(bounds)),
106        }
107    }
108}
109
110impl fmt::Display for ScheduleKind {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        f.write_str(self.as_str())
113    }
114}
115
116impl FromStr for ScheduleKind {
117    type Err = String;
118
119    fn from_str(s: &str) -> Result<Self, Self::Err> {
120        let wanted = s.trim().to_ascii_lowercase();
121        Self::ALL
122            .into_iter()
123            .find(|kind| kind.as_str() == wanted)
124            .ok_or_else(|| {
125                let names: Vec<_> = Self::ALL.iter().map(|k| k.as_str()).collect();
126                format!(
127                    "unknown budget schedule `{wanted}` (expected one of: {})",
128                    names.join(", ")
129                )
130            })
131    }
132}
133
134/// `(discrepancy, split)` pairs in order of increasing sum.
135///
136/// Walks the diagonals `(0,0)`, `(0,1) (1,0)`, `(0,2) (1,1) (2,0)`, … and,
137/// within one, from the smallest discrepancy up. Pairs outside
138/// [`ScheduleBounds`] are skipped; the last budget is the unrestricted search.
139/// Each budget is handed out once.
140struct Diagonal {
141    max_discrepancy: usize,
142    /// `None` when no split is allowed at all: the schedule is then empty.
143    max_split_discrepancy: Option<usize>,
144    /// The last budget handed out.
145    current: Budget,
146}
147
148impl Diagonal {
149    fn new(bounds: ScheduleBounds) -> Self {
150        Self {
151            max_discrepancy: bounds.max_discrepancy,
152            max_split_discrepancy: bounds.max_splits.checked_sub(1),
153            current: Budget {
154                discrepancy: 0,
155                split_discrepancy: 0,
156            },
157        }
158    }
159}
160
161impl BudgetSchedule for Diagonal {
162    fn first(&mut self) -> Budget {
163        self.current
164    }
165
166    fn next(&mut self, _last: &PassReport) -> Option<Budget> {
167        let max_s = self.max_split_discrepancy?;
168        let max_d = self.max_discrepancy;
169        let mut sum = self.current.discrepancy + self.current.split_discrepancy;
170        let mut d = self.current.discrepancy + 1;
171        while sum <= max_d + max_s {
172            // On diagonal `sum`, `d` ranges over `sum - max_s ..= min(sum, max_d)`.
173            d = d.max(sum.saturating_sub(max_s));
174            if d <= sum.min(max_d) {
175                self.current = Budget {
176                    discrepancy: d,
177                    split_discrepancy: sum - d,
178                };
179                return Some(self.current);
180            }
181            sum += 1;
182            d = 0;
183        }
184        None
185    }
186}
187
188/// Budgets in growing squares.
189///
190/// Shell `k` holds every `(d, s)` with `max(d, s) = k`, visited as
191/// `(k, 0) .. (k, k-1)`, then `(0, k) .. (k-1, k)`, then `(k, k)`:
192///
193/// ```text
194/// (0,0) | (1,0) (0,1) (1,1) | (2,0) (2,1) (0,2) (1,2) (2,2) | ...
195/// ```
196///
197/// Within a shell the budget can still shrink in one dimension (`(1,0)` to
198/// `(0,1)`), but every shell ends on the budget that contains all the ones
199/// before it, which [`Diagonal`] never does. Pairs outside [`ScheduleBounds`] are
200/// skipped; the last budget is the unrestricted search. When the split budget
201/// does not apply, only the discrepancy axis is walked, since passes differing
202/// only in `s` would be identical.
203struct Square {
204    max_discrepancy: usize,
205    max_split_discrepancy: usize,
206    shell: usize,
207    pending: std::collections::VecDeque<Budget>,
208}
209
210impl Square {
211    fn new(bounds: ScheduleBounds) -> Self {
212        Self {
213            max_discrepancy: bounds.max_discrepancy,
214            max_split_discrepancy: if bounds.split_applies {
215                bounds.max_splits.saturating_sub(1)
216            } else {
217                0
218            },
219            shell: 0,
220            pending: Default::default(),
221        }
222    }
223
224    /// The budgets of shell `k` that lie within bounds, in visiting order.
225    fn fill(&mut self, k: usize) {
226        let (max_d, max_s) = (self.max_discrepancy, self.max_split_discrepancy);
227        let pairs = (0..k)
228            .map(|s| (k, s))
229            .chain((0..k).map(|d| (d, k)))
230            .chain(std::iter::once((k, k)));
231        self.pending
232            .extend(pairs.filter(|&(d, s)| d <= max_d && s <= max_s).map(
233                |(discrepancy, split_discrepancy)| Budget {
234                    discrepancy,
235                    split_discrepancy,
236                },
237            ));
238    }
239}
240
241impl BudgetSchedule for Square {
242    fn first(&mut self) -> Budget {
243        Budget {
244            discrepancy: 0,
245            split_discrepancy: 0,
246        }
247    }
248
249    fn next(&mut self, _last: &PassReport) -> Option<Budget> {
250        // Shell 0 is the first budget; start from shell 1.
251        while self.pending.is_empty() {
252            self.shell += 1;
253            if self.shell > self.max_discrepancy.max(self.max_split_discrepancy) {
254                return None;
255            }
256            self.fill(self.shell);
257        }
258        self.pending.pop_front()
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    fn walk(kind: ScheduleKind, bounds: ScheduleBounds) -> Vec<(usize, usize)> {
267        let mut schedule = kind.build(bounds);
268        let mut out = vec![schedule.first()];
269        while let Some(b) = schedule.next(&PassReport::default()) {
270            out.push(b);
271        }
272        out.iter()
273            .map(|b| (b.discrepancy, b.split_discrepancy))
274            .collect()
275    }
276
277    #[test]
278    fn the_diagonal_walks_by_sum_and_never_repeats_a_budget() {
279        let bounds = ScheduleBounds {
280            max_discrepancy: 2,
281            max_splits: 3,
282            split_applies: true,
283        };
284        let seen = walk(ScheduleKind::Diagonal, bounds);
285        let expected = [
286            (0, 0),
287            (0, 1),
288            (1, 0),
289            (0, 2),
290            (1, 1),
291            (2, 0),
292            (1, 2),
293            (2, 1),
294            (2, 2),
295        ];
296        assert_eq!(seen, expected);
297        let mut distinct = seen.clone();
298        distinct.sort_unstable();
299        distinct.dedup();
300        assert_eq!(distinct.len(), seen.len());
301    }
302
303    #[test]
304    fn the_square_grows_shell_by_shell() {
305        let bounds = ScheduleBounds {
306            max_discrepancy: 2,
307            max_splits: 3,
308            split_applies: true,
309        };
310        assert_eq!(
311            walk(ScheduleKind::Square, bounds),
312            vec![
313                (0, 0),
314                (1, 0),
315                (0, 1),
316                (1, 1),
317                (2, 0),
318                (2, 1),
319                (0, 2),
320                (1, 2),
321                (2, 2),
322            ]
323        );
324    }
325
326    #[test]
327    fn the_square_covers_the_rectangle_once_and_ends_unrestricted() {
328        let bounds = ScheduleBounds {
329            max_discrepancy: 3,
330            max_splits: 6,
331            split_applies: true,
332        };
333        let seen = walk(ScheduleKind::Square, bounds);
334        let mut sorted = seen.clone();
335        sorted.sort_unstable();
336        sorted.dedup();
337        assert_eq!(sorted.len(), seen.len(), "a budget was repeated");
338        assert_eq!(seen.len(), 4 * 6, "every budget in the rectangle, once");
339        assert_eq!(*seen.last().unwrap(), (3, 5));
340    }
341
342    #[test]
343    fn each_square_shell_ends_on_a_budget_containing_all_before_it() {
344        let bounds = ScheduleBounds {
345            max_discrepancy: 4,
346            max_splits: 5,
347            split_applies: true,
348        };
349        let seen = walk(ScheduleKind::Square, bounds);
350        for (i, &(d, s)) in seen.iter().enumerate() {
351            if d == s {
352                assert!(seen[..i].iter().all(|&(pd, ps)| pd <= d && ps <= s));
353            }
354        }
355    }
356
357    #[test]
358    fn without_a_split_budget_the_square_walks_only_the_discrepancy() {
359        let bounds = ScheduleBounds {
360            max_discrepancy: 3,
361            max_splits: 10,
362            split_applies: false,
363        };
364        assert_eq!(
365            walk(ScheduleKind::Square, bounds),
366            vec![(0, 0), (1, 0), (2, 0), (3, 0)]
367        );
368    }
369
370    #[test]
371    fn schedules_parse_from_their_own_names() {
372        for kind in ScheduleKind::ALL {
373            assert_eq!(kind.as_str().parse::<ScheduleKind>(), Ok(kind));
374        }
375        assert!("sideways".parse::<ScheduleKind>().is_err());
376    }
377}