Skip to main content

contree/algorithms/
contree_lds.rs

1use crate::algorithms::depth2::ConTreeDepth2;
2use crate::algorithms::interval_pruner::{Bound, IntervalsPruner};
3use crate::algorithms::shared;
4use crate::algorithms::support_feasible_splits;
5use crate::caching::{Cache, Entry, SearchedUnder};
6use crate::common::{
7    classification_error, Budget, BudgetSchedule, FitOutcome, PassReport, PointSelector,
8    ScheduleBounds, ScheduleKind, SearchConfig, SearchError, SearchStatus, Statistics,
9};
10use crate::data::view::DataView;
11use crate::data::{Dataset, Feature};
12use crate::tree::Tree;
13use rand::rngs::StdRng;
14use rand::SeedableRng;
15use std::collections::VecDeque;
16use std::time::Instant;
17
18/// Anytime ConTree search based on limited discrepancy search.
19///
20/// The search runs in passes. Each pass explores the tree space under a
21/// discrepancy budget (how far down the Gini ranking of features it may go)
22/// and, for some point selectors, a split budget (how many thresholds per
23/// feature it may try). A [`BudgetSchedule`] widens both budgets between
24/// passes, so a good tree is available early and the last complete pass
25/// proves optimality.
26pub struct ConTreeLds {
27    config: SearchConfig,
28    statistics: Statistics,
29    cache: Cache,
30    specialized: ConTreeDepth2,
31    runtime: Instant,
32    rng: StdRng,
33    max_discrepancy: usize,
34    split_budget: usize,
35    schedule_kind: ScheduleKind,
36    schedule: Option<Box<dyn BudgetSchedule>>,
37    /// Which budgets truncated the pass in progress, read by the schedule.
38    pass_report: PassReport,
39    status: SearchStatus,
40    /// Every improvement of the root incumbent, as `(seconds, error)`.
41    trajectory: Vec<(f64, usize)>,
42}
43
44impl ConTreeLds {
45    /// Builds a solver from a [`SearchConfig`].
46    ///
47    /// This is the preferred constructor; [`Self::new`] takes the same
48    /// settings as positional arguments.
49    pub fn with_config(config: SearchConfig) -> Self {
50        let mut solver = Self::new(
51            config.min_sup,
52            config.max_depth,
53            config.max_time,
54            config.max_error,
55            config.point_selector,
56            config.max_gap,
57            config.use_heuristic,
58            config.fast_d2,
59        );
60        solver.config = config;
61        solver
62    }
63
64    /// Builds a solver from positional settings. See [`Self::with_config`].
65    #[allow(clippy::too_many_arguments)]
66    pub fn new(
67        min_sup: usize,
68        max_depth: usize,
69        max_time: f64,
70        max_error: usize,
71        split_selection_strategy: PointSelector,
72        max_gap: usize,
73        use_heuristic: bool,
74        fast_d2: bool,
75    ) -> Self {
76        let config = SearchConfig::new(
77            min_sup,
78            max_depth,
79            max_time,
80            max_gap,
81            max_error,
82            use_heuristic,
83            fast_d2,
84            split_selection_strategy,
85        );
86
87        Self {
88            cache: Cache::default(),
89            config,
90            statistics: Statistics::default(),
91            specialized: ConTreeDepth2,
92            runtime: Instant::now(),
93            rng: StdRng::from_os_rng(),
94            max_discrepancy: usize::MAX,
95            split_budget: 1,
96            schedule_kind: ScheduleKind::default(),
97            schedule: None,
98            pass_report: PassReport::default(),
99            status: SearchStatus::Optimal,
100            trajectory: Vec::new(),
101        }
102    }
103
104    /// Seeds the random generator used by `PointSelector::Random`, making
105    /// runs reproducible. Unseeded solvers draw their seed from the OS.
106    pub fn with_random_state(mut self, seed: u64) -> Self {
107        self.rng = StdRng::seed_from_u64(seed);
108        self
109    }
110
111    /// Runs the anytime search until it stops.
112    ///
113    /// Each pass widens the discrepancy and split budgets. The search ends
114    /// when a pass completes without being truncated (the tree is optimal),
115    /// when the schedule has no larger budget to offer, or when the time
116    /// limit is reached.
117    pub fn fit(&mut self, dataset: &Dataset) -> Result<FitOutcome, SearchError> {
118        crate::algorithms::validate(&self.config, dataset)?;
119
120        let root_view = DataView::root(dataset, self.config.use_heuristic);
121        while !self.partial_fit(&root_view) {}
122
123        let tree = self.get_solution_tree();
124        tree.validate()?;
125
126        Ok(FitOutcome {
127            tree,
128            statistics: self.statistics,
129            status: self.status,
130        })
131    }
132
133    /// Chooses how the budget widens from pass to pass. See [`ScheduleKind`].
134    pub fn with_schedule(mut self, schedule: ScheduleKind) -> Self {
135        self.schedule_kind = schedule;
136        self
137    }
138
139    /// The budget schedule this solver uses.
140    pub fn schedule(&self) -> ScheduleKind {
141        self.schedule_kind
142    }
143
144    fn apply(&mut self, budget: Budget) {
145        self.config.budget = budget.discrepancy;
146        self.split_budget = budget.split_budget();
147    }
148
149    /// The configuration this solver was built with.
150    pub fn config(&self) -> &SearchConfig {
151        &self.config
152    }
153
154    /// The anytime profile of the last fit: `(seconds, error)` at every
155    /// improvement of the root incumbent.
156    pub fn trajectory(&self) -> &[(f64, usize)] {
157        &self.trajectory
158    }
159
160    /// Why the last `fit` stopped.
161    pub fn status(&self) -> SearchStatus {
162        self.status
163    }
164
165    /// Runs one pass of the search and returns `true` once the search is
166    /// over. The first call initialises the cache and the budget schedule.
167    pub fn partial_fit(&mut self, root_view: &DataView<'_>) -> bool {
168        self.config.nb_runs += 1;
169        let mut root_index = 0;
170        let mut entry = Entry::default();
171        if self.config.nb_runs <= 1 {
172            self.cache = Cache::new(self.config.max_depth, root_view.total_instances);
173
174            self.max_discrepancy = self.max_discrepancy.min(Self::discrepancy_limit(
175                root_view.get_feature_number(),
176                self.config.max_depth,
177            ));
178            let mut schedule = self.schedule_kind.build(ScheduleBounds {
179                max_discrepancy: self.max_discrepancy,
180                max_splits: root_view.get_max_splits(),
181                split_applies: self.config.point_selector == PointSelector::First,
182            });
183            let budget = schedule.first();
184            self.apply(budget);
185            self.schedule = Some(schedule);
186            root_index = self.cache.init();
187
188            self.statistics.num_features = root_view.get_feature_number();
189            self.statistics.num_samples = root_view.total_instances;
190
191            let (error, label) = classification_error(root_view.get_labels_freqs());
192            entry.error = error;
193            entry.label = label;
194
195            if let Some(entry) = self.cache.get_mut(root_index) {
196                self.config.max_error = self.config.max_error.min(error);
197                entry.error = error;
198                entry.label = label;
199                entry.lower_bound = 0;
200                entry.is_valid = true;
201            }
202
203            self.runtime = Instant::now();
204            self.trajectory.clear();
205        }
206
207        let mut is_optimal = false;
208
209        if let Some(e) = self.cache.get(root_index) {
210            entry = *e
211        }
212        if entry.is_optimal {
213            return true;
214        }
215
216        let mut config = self.config;
217
218        config.discrepancy = 0;
219
220        self.pass_report = PassReport::default();
221        let error = entry.error;
222        let stopped = self.expand_node_with_view(root_view, &config, &mut entry, 0, true, error);
223        self.pass_report.improved = entry.error < error;
224
225        // Once the schedule has no larger budget, another pass would repeat
226        // the same truncated search, so the search stops here.
227        let next_budget = self
228            .schedule
229            .as_mut()
230            .and_then(|schedule| schedule.next(&self.pass_report));
231        let search_exhausted = next_budget.is_none();
232
233        if !stopped || !self.time_remains() || search_exhausted {
234            entry.is_optimal = true;
235            is_optimal = true;
236            self.status = if !stopped {
237                SearchStatus::Optimal
238            } else if !self.time_remains() {
239                SearchStatus::TimeLimit
240            } else {
241                SearchStatus::BudgetExhausted
242            };
243        }
244
245        if let Some(budget) = next_budget {
246            self.apply(budget);
247        }
248
249        self.statistics.error = entry.error;
250        self.statistics.cache_size = self.cache.len();
251        self.statistics.duration = self.elapsed_time();
252        is_optimal
253    }
254
255    /// Looks a child subproblem up in the cache, seeding a new entry with the
256    /// error of its majority-class leaf.
257    ///
258    /// `child_depth` is the child's remaining depth. Returns whether the entry
259    /// is new, its cache index, and a copy of it.
260    fn cache_child(&mut self, view: &DataView<'_>, child_depth: usize) -> (bool, usize, Entry) {
261        let (is_new, index) = self.cache.insert(&view.bitset, child_depth);
262        let depth = self.config.max_depth - child_depth;
263        let mut entry = Entry::default();
264
265        if let Some(slot) = self.cache.get_mut(index) {
266            if is_new {
267                let (error, label) = classification_error(view.get_labels_freqs());
268                slot.error = error;
269                slot.label = label;
270                slot.depth = depth;
271                slot.lower_bound = 0;
272                slot.is_valid = true;
273            }
274            entry = *slot;
275        }
276        (is_new, index, entry)
277    }
278
279    fn expand_node_with_view(
280        &mut self,
281        view: &DataView<'_>,
282        config: &SearchConfig,
283        current_best: &mut Entry,
284        parent_index: usize,
285        is_new: bool,
286        upper_bound: usize,
287    ) -> bool {
288        if current_best.error == 0 || view.is_empty() {
289            if let Some(entry) = self.cache.get_mut(parent_index) {
290                entry.mark_exact();
291                *current_best = *entry;
292            }
293            return false;
294        }
295
296        // The budgets this visit runs under. The split budget only applies to
297        // the `first` selector and to the first pass of every selector.
298        let under = SearchedUnder {
299            discrepancy: config.budget.saturating_sub(config.discrepancy),
300            split_budget: if config.point_selector == PointSelector::First
301                || self.config.nb_runs <= 1
302            {
303                self.split_budget
304            } else {
305                usize::MAX
306            },
307            upper_bound,
308            truncated: false,
309        };
310
311        if !is_new {
312            if current_best.is_optimal {
313                self.statistics.cache_hits += 1;
314                return false;
315            }
316            if let Some(previous) = current_best.searched_under {
317                // A complete earlier search proved its lower bound. If that
318                // bound already reaches the parent's upper bound, no subtree
319                // here can help the parent.
320                if !previous.truncated && current_best.lower_bound >= upper_bound {
321                    self.statistics.cache_hits += 1;
322                    return false;
323                }
324                // An earlier search with at least this much budget and upper
325                // bound found everything this visit could, and was truncated
326                // exactly when this visit would be.
327                if previous.covers(&under, current_best.is_valid) {
328                    self.statistics.cache_hits += 1;
329                    return previous.truncated;
330                }
331            }
332        }
333
334        if config.max_depth == 0 {
335            current_best.is_leaf = true;
336            current_best.mark_exact();
337
338            if let Some(entry) = self.cache.get_mut(parent_index) {
339                entry.is_leaf = true;
340                entry.mark_exact();
341                *current_best = *entry;
342            }
343
344            return false;
345        }
346
347        if current_best.error <= config.max_gap || view.len() <= 1 {
348            return false;
349        }
350
351        // Only at depth exactly 2: `ConTreeDepth2` always builds two levels of
352        // tests, which would exceed a depth-1 request.
353        if config.fast_d2 && config.max_depth == 2 {
354            // Every pass revisits the same depth-2 subproblems, so each one is
355            // solved exactly once, without the parent's upper bound, and then
356            // reused. The parent still compares the result to its own bound.
357            let tree =
358                self.specialized
359                    .fit(view, config, current_best, usize::MAX, &mut self.statistics);
360            current_best.mark_exact();
361            let tree_index = self.cache.insert_tree(tree);
362            if let Some(entry) = self.cache.get_mut(parent_index) {
363                *entry = *current_best;
364                entry.tree_idx = Some(tree_index);
365            }
366            self.statistics.specialized_solver_call += 1;
367
368            return false;
369        }
370
371        let num_features = view.get_feature_number();
372        let heuristics_data = view.features_best_score();
373        debug_assert!(
374            num_features == heuristics_data.len(),
375            "Missmatch with heuristics and features number"
376        );
377        let mut stopped = false;
378        for (it, &(_, feat)) in heuristics_data.iter().take(num_features).enumerate() {
379            let feat_discrepancy = config.discrepancy + it;
380            if feat_discrepancy > config.budget {
381                // Features come in Gini order, so every later one is over the
382                // budget too. Fall through to record what this search covered.
383                self.pass_report.cut_by_discrepancy = true;
384                stopped = true;
385                break;
386            }
387
388            let mut node_config = *config;
389            node_config.discrepancy = feat_discrepancy;
390
391            stopped |= self.expand_on_feature(
392                view,
393                feat,
394                parent_index,
395                &node_config,
396                current_best,
397                upper_bound.min(current_best.error),
398            );
399
400            if current_best.error == 0 {
401                current_best.mark_exact();
402                if let Some(entry) = self.cache.get_mut(parent_index) {
403                    entry.mark_exact();
404                }
405
406                return false;
407            }
408
409            if !self.time_remains() {
410                return true;
411            }
412        }
413        // A result is exact only if the search was neither truncated by a
414        // budget nor cut short by the upper bound: a search completed under a
415        // tight bound says nothing about a looser one.
416        current_best.finalize_lower_bound(upper_bound);
417        current_best.searched_under = Some(SearchedUnder {
418            truncated: stopped,
419            ..under
420        });
421        if let Some(entry) = self.cache.get_mut(parent_index) {
422            entry.lower_bound = current_best.lower_bound;
423            entry.is_valid = current_best.is_valid;
424            entry.is_optimal = !stopped && current_best.is_valid;
425            entry.searched_under = current_best.searched_under;
426        }
427        current_best.is_optimal = !stopped && current_best.is_valid;
428
429        stopped
430    }
431
432    fn expand_on_feature(
433        &mut self,
434        view: &DataView<'_>,
435        feature_index: usize,
436        cache_index: usize,
437        config: &SearchConfig,
438        current_best: &mut Entry,
439        upper_bound: usize,
440    ) -> bool {
441        let feature_column = view.get_sorted_feature(feature_index);
442        let feature_column_ids = view.get_feature_indices(feature_index);
443
444        if config.point_selector == PointSelector::First || self.config.nb_runs <= 1 {
445            let stopped = self.expand_on_feature_gini_priority(
446                view,
447                feature_index,
448                cache_index,
449                config,
450                current_best,
451                upper_bound,
452            );
453            return stopped;
454        }
455
456        let possible_index_split = view.get_possible_split_indices(feature_index);
457        if possible_index_split.is_empty() {
458            return false;
459        }
460
461        // Restrict the search to splits that meet the minimum support. The
462        // per-candidate check below skips a whole interval, so it cannot be
463        // relied on to find the feasible splits inside it.
464        let feasible =
465            support_feasible_splits(possible_index_split, view.len(), self.config.min_sup);
466        if feasible.is_empty() {
467            return false;
468        }
469
470        let mut pruner = IntervalsPruner::new(possible_index_split, config.max_gap, config.min_sup);
471        let mut queue = VecDeque::new();
472        let init_bound = Bound::new(feasible.start, feasible.end - 1, None, None);
473        queue.push_back(init_bound);
474
475        let mut stopped = false;
476
477        while let Some(mut current_bound) = queue.pop_front() {
478            if !self.time_remains() {
479                return true;
480            }
481
482            // Prune against the tighter of the incumbent and the parent's upper
483            // bound: a split that cannot beat the parent's bound is of no use
484            // to the parent.
485            if pruner.subinterval_pruning(&current_bound, current_best.error.min(upper_bound)) {
486                continue;
487            }
488
489            pruner.interval_shrinking(&mut current_bound, current_best.error.min(upper_bound));
490            if !current_bound.is_valid() {
491                continue;
492            }
493
494            let selected_point = self.select_point(config, &current_bound);
495            let split_point = possible_index_split[selected_point];
496            let int_half_distance = split_point
497                .saturating_sub(possible_index_split[current_bound.left_bound])
498                .max(possible_index_split[current_bound.right_bound].saturating_sub(split_point));
499
500            let threshold_value = if selected_point > 0 {
501                let previous = feature_column_ids[possible_index_split[selected_point - 1]];
502                let point = feature_column_ids[split_point];
503                shared::threshold_between(
504                    feature_column[previous].value(),
505                    feature_column[point].value(),
506                )
507            } else {
508                let point = feature_column_ids[split_point];
509                shared::threshold_between(
510                    feature_column[feature_column_ids[0]].value(),
511                    feature_column[point].value(),
512                )
513            };
514
515            let (left_view, right_view) = view.split(feature_index, split_point);
516
517            if left_view.len() < self.config.min_sup || right_view.len() < self.config.min_sup {
518                continue;
519            }
520
521            // Search the larger child first: its error tightens the bound
522            // passed to the smaller one.
523            let process_left_first = left_view.len() >= right_view.len();
524
525            let left_config = config.derive_left();
526            let mut left_entry = Entry::default();
527            let mut right_entry = Entry::default();
528
529            // A cache index of 0 means "no child", kept by a side the search
530            // does not descend into.
531            let (mut left_index, mut right_index) = (0, 0);
532            let (mut left_is_new, mut right_is_new);
533
534            let larger_upper_bound = current_best.error.min(upper_bound.saturating_add(1));
535            self.statistics.general_solver_call += 1;
536
537            if process_left_first {
538                (left_is_new, left_index, left_entry) =
539                    self.cache_child(&left_view, left_config.max_depth);
540
541                stopped |= self.expand_node_with_view(
542                    &left_view,
543                    &left_config,
544                    &mut left_entry,
545                    left_index,
546                    left_is_new,
547                    larger_upper_bound,
548                );
549                left_entry.finalize_lower_bound(larger_upper_bound);
550            } else {
551                (right_is_new, right_index, right_entry) =
552                    self.cache_child(&right_view, left_config.max_depth);
553
554                stopped |= self.expand_node_with_view(
555                    &right_view,
556                    &left_config,
557                    &mut right_entry,
558                    right_index,
559                    right_is_new,
560                    larger_upper_bound,
561                );
562                right_entry.finalize_lower_bound(larger_upper_bound);
563            }
564
565            let larger_error = if process_left_first {
566                left_entry.lower_bound
567            } else {
568                right_entry.lower_bound
569            };
570            // The smaller child's bound is what the larger child left of the
571            // incumbent, widened by the interval half-distance so that one
572            // search can prune the whole interval around this split.
573            let budget =
574                current_best.error.min(upper_bound.saturating_add(1)) as i64 - larger_error as i64;
575            let smaller_ub = budget + int_half_distance as i64;
576            let smaller_upper_bound = smaller_ub.max(0) as usize;
577            // Stays `None` when the smaller child is not searched because the
578            // larger one alone already exceeds the upper bound.
579            let mut right_error: Option<usize> = None;
580
581            // Search the smaller child unless the budget is negative. A budget
582            // of exactly zero is still explored, since a zero-error subtree
583            // may exist.
584            if smaller_ub > 0 || budget == 0 {
585                self.statistics.general_solver_call += 1;
586                let right_config = config.derive_right(left_config.max_gap);
587
588                if process_left_first {
589                    (right_is_new, right_index, right_entry) =
590                        self.cache_child(&right_view, right_config.max_depth);
591
592                    stopped |= self.expand_node_with_view(
593                        &right_view,
594                        &right_config,
595                        &mut right_entry,
596                        right_index,
597                        right_is_new,
598                        smaller_upper_bound,
599                    );
600                    right_entry.finalize_lower_bound(smaller_upper_bound);
601                } else {
602                    (left_is_new, left_index, left_entry) =
603                        self.cache_child(&left_view, right_config.max_depth);
604
605                    stopped |= self.expand_node_with_view(
606                        &left_view,
607                        &right_config,
608                        &mut left_entry,
609                        left_index,
610                        left_is_new,
611                        smaller_upper_bound,
612                    );
613                    left_entry.finalize_lower_bound(smaller_upper_bound);
614                }
615
616                // Use lower bounds rather than errors: when a bound cut a
617                // child search short, its `error` is only an upper bound.
618                right_error = Some(right_entry.lower_bound);
619
620                let feature_best = left_entry.lower_bound + right_entry.lower_bound;
621                if left_entry.is_valid && right_entry.is_valid && feature_best < current_best.error
622                {
623                    current_best.error = feature_best;
624                    if config.is_root {
625                        self.trajectory.push((self.elapsed_time(), feature_best));
626                    }
627                    current_best.feature = feature_index;
628                    current_best.split = threshold_value;
629                    current_best.left = left_index;
630                    current_best.right = right_index;
631
632                    if let Some(entry) = self.cache.get_mut(cache_index) {
633                        *entry = *current_best;
634                    }
635
636                    if feature_best == 0 {
637                        return false;
638                    }
639                }
640            }
641
642            // `None` for a child the bound invalidated: its lower bound may be
643            // zero without the subtree being error-free, and the pruner would
644            // read a zero as "nothing beyond this split can do better".
645            let left_score = left_entry.is_valid.then_some(left_entry.lower_bound);
646            pruner.add_result(selected_point, left_score, right_error);
647            if current_bound.left_bound == current_bound.right_bound {
648                continue;
649            }
650
651            let score_difference = left_entry
652                .lower_bound
653                .saturating_add(right_error.unwrap_or(0))
654                .saturating_sub(current_best.error.min(upper_bound));
655
656            let (left_bound, right_bound) = pruner.neighbourhood_pruning(
657                score_difference,
658                current_bound.left_bound,
659                current_bound.right_bound,
660                selected_point,
661            );
662
663            if left_bound <= current_bound.right_bound {
664                queue.push_back(Bound {
665                    left_bound,
666                    right_bound: current_bound.right_bound,
667                    last_split_left_index: Some(selected_point),
668                    last_split_right_index: current_bound.last_split_right_index,
669                });
670            }
671
672            if current_bound.left_bound <= right_bound {
673                queue.push_back(Bound {
674                    left_bound: current_bound.left_bound,
675                    right_bound,
676                    last_split_left_index: current_bound.last_split_left_index,
677                    last_split_right_index: Some(selected_point),
678                });
679            }
680        }
681        stopped
682    }
683
684    /// Tries the thresholds of one feature one at a time, in Gini order when
685    /// the heuristic is on and in position order otherwise, up to the split
686    /// budget. Returns `true` if the budget truncated the search.
687    fn expand_on_feature_gini_priority(
688        &mut self,
689        view: &DataView<'_>,
690        feature_index: usize,
691        cache_index: usize,
692        config: &SearchConfig,
693        current_best: &mut Entry,
694        upper_bound: usize,
695    ) -> bool {
696        let feature_column = view.get_sorted_feature(feature_index);
697        let feature_column_ids = view.get_feature_indices(feature_index);
698
699        let possible_splits = view.get_possible_split_indices(feature_index);
700
701        if possible_splits.is_empty() {
702            return false;
703        }
704
705        let mut pruner = IntervalsPruner::new(possible_splits, config.max_gap, config.min_sup);
706
707        // Thresholds already evaluated or ruled out by the pruner.
708        let mut pruned = vec![false; possible_splits.len()];
709        let mut stopped = false;
710
711        let local_split_budget = possible_splits.len().min(self.split_budget);
712
713        if config.use_heuristic {
714            let sorted_indices = view.ordered_possible_splits(feature_index);
715            for (idx, &split_idx) in sorted_indices.iter().enumerate() {
716                if self.config.nb_runs <= 1 && idx > 0 {
717                    self.pass_report.cut_by_split = true;
718                    return true;
719                }
720
721                if idx > local_split_budget - 1 {
722                    self.pass_report.cut_by_split = true;
723                    return true;
724                }
725
726                if !self.time_remains() {
727                    return true;
728                }
729
730                if pruned[split_idx] {
731                    continue;
732                }
733
734                stopped |= self.evaluate_split_gini_priority(
735                    view,
736                    feature_index,
737                    split_idx,
738                    possible_splits,
739                    feature_column,
740                    feature_column_ids,
741                    config,
742                    current_best,
743                    cache_index,
744                    upper_bound,
745                    &mut pruner,
746                    &mut pruned,
747                );
748
749                if current_best.error == 0 {
750                    return false;
751                }
752            }
753        } else {
754            for split_idx in 0..possible_splits.len() {
755                if self.config.nb_runs <= 1 && split_idx > 0 {
756                    self.pass_report.cut_by_split = true;
757                    return true;
758                }
759
760                if split_idx > local_split_budget - 1 {
761                    self.pass_report.cut_by_split = true;
762                    return true;
763                }
764
765                if !self.time_remains() {
766                    return true;
767                }
768
769                if pruned[split_idx] {
770                    continue;
771                }
772
773                stopped |= self.evaluate_split_gini_priority(
774                    view,
775                    feature_index,
776                    split_idx,
777                    possible_splits,
778                    feature_column,
779                    feature_column_ids,
780                    config,
781                    current_best,
782                    cache_index,
783                    upper_bound,
784                    &mut pruner,
785                    &mut pruned,
786                );
787
788                if current_best.error == 0 {
789                    return false;
790                }
791            }
792        }
793
794        if local_split_budget < possible_splits.len() {
795            self.pass_report.cut_by_split = true;
796            stopped = true;
797        }
798
799        stopped
800    }
801
802    #[inline]
803    #[allow(clippy::too_many_arguments)]
804    fn evaluate_split_gini_priority(
805        &mut self,
806        view: &DataView<'_>,
807        feature_index: usize,
808        split_idx: usize,
809        possible_splits: &[usize],
810        feature_column: &Feature,
811        feature_column_ids: &[usize],
812        config: &SearchConfig,
813        current_best: &mut Entry,
814        cache_index: usize,
815        upper_bound: usize,
816        pruner: &mut IntervalsPruner<'_>,
817        pruned: &mut [bool],
818    ) -> bool {
819        // The interval around `split_idx` that no pruned threshold separates
820        // from it.
821        let mut current_left = split_idx;
822        while current_left > 0 && pruned[current_left - 1] {
823            current_left -= 1;
824        }
825        current_left = current_left.saturating_sub(1);
826
827        let mut current_right = split_idx;
828        while current_right < possible_splits.len() - 1 && pruned[current_right + 1] {
829            current_right += 1;
830        }
831        if current_right < possible_splits.len() - 1 {
832            current_right += 1;
833        }
834
835        let split_point = possible_splits[split_idx];
836
837        let threshold_value = if split_idx > 0 {
838            let previous = feature_column_ids[possible_splits[split_idx - 1]];
839            let point = feature_column_ids[split_point];
840            shared::threshold_between(
841                feature_column[previous].value(),
842                feature_column[point].value(),
843            )
844        } else {
845            let point = feature_column_ids[split_point];
846            shared::threshold_between(
847                feature_column[feature_column_ids[0]].value(),
848                feature_column[point].value(),
849            )
850        };
851
852        let (left_view, right_view) = view.split(feature_index, split_point);
853
854        if left_view.len() < self.config.min_sup || right_view.len() < self.config.min_sup {
855            pruned[split_idx] = true;
856            return false;
857        }
858
859        // Search the larger child first: its error tightens the bound passed
860        // to the smaller one.
861        let process_left_first = left_view.len() >= right_view.len();
862
863        let left_config = config.derive_left();
864        let mut left_entry = Entry::default();
865        let mut right_entry = Entry::default();
866
867        // A cache index of 0 means "no child", kept by a side the search does
868        // not descend into.
869        let (mut left_index, mut right_index) = (0, 0);
870        let (mut left_is_new, mut right_is_new);
871        let mut stopped = false;
872
873        let int_half_distance = split_point
874            .saturating_sub(possible_splits[0])
875            .max(possible_splits[possible_splits.len() - 1].saturating_sub(split_point));
876
877        let larger_upper_bound = current_best.error.min(upper_bound.saturating_add(1));
878        self.statistics.general_solver_call += 1;
879
880        if process_left_first {
881            (left_is_new, left_index, left_entry) =
882                self.cache_child(&left_view, left_config.max_depth);
883
884            stopped |= self.expand_node_with_view(
885                &left_view,
886                &left_config,
887                &mut left_entry,
888                left_index,
889                left_is_new,
890                larger_upper_bound,
891            );
892            left_entry.finalize_lower_bound(larger_upper_bound);
893        } else {
894            (right_is_new, right_index, right_entry) =
895                self.cache_child(&right_view, left_config.max_depth);
896
897            stopped |= self.expand_node_with_view(
898                &right_view,
899                &left_config,
900                &mut right_entry,
901                right_index,
902                right_is_new,
903                larger_upper_bound,
904            );
905            right_entry.finalize_lower_bound(larger_upper_bound);
906        }
907
908        let larger_error = if process_left_first {
909            left_entry.lower_bound
910        } else {
911            right_entry.lower_bound
912        };
913        // The smaller child's bound is what the larger child left of the
914        // incumbent, widened by the interval half-distance so that one search
915        // can prune the whole interval around this split.
916        let budget =
917            current_best.error.min(upper_bound.saturating_add(1)) as i64 - larger_error as i64;
918        let smaller_ub = budget + int_half_distance as i64;
919        let smaller_upper_bound = smaller_ub.max(0) as usize;
920        let mut right_error: Option<usize> = None;
921
922        // Search the smaller child unless the budget is negative. A budget of
923        // exactly zero is still explored, since a zero-error subtree may exist.
924        if smaller_ub > 0 || budget == 0 {
925            self.statistics.general_solver_call += 1;
926            let right_config = config.derive_right(left_config.max_gap);
927
928            if process_left_first {
929                (right_is_new, right_index, right_entry) =
930                    self.cache_child(&right_view, right_config.max_depth);
931
932                stopped |= self.expand_node_with_view(
933                    &right_view,
934                    &right_config,
935                    &mut right_entry,
936                    right_index,
937                    right_is_new,
938                    smaller_upper_bound,
939                );
940                right_entry.finalize_lower_bound(smaller_upper_bound);
941            } else {
942                (left_is_new, left_index, left_entry) =
943                    self.cache_child(&left_view, right_config.max_depth);
944
945                stopped |= self.expand_node_with_view(
946                    &left_view,
947                    &right_config,
948                    &mut left_entry,
949                    left_index,
950                    left_is_new,
951                    smaller_upper_bound,
952                );
953                left_entry.finalize_lower_bound(smaller_upper_bound);
954            }
955
956            // Use lower bounds rather than errors: when a bound cut a child
957            // search short, its `error` is only an upper bound.
958            right_error = Some(right_entry.lower_bound);
959
960            let feature_best = left_entry.lower_bound + right_entry.lower_bound;
961            if left_entry.is_valid && right_entry.is_valid && feature_best < current_best.error {
962                current_best.error = feature_best;
963                if config.is_root {
964                    self.trajectory.push((self.elapsed_time(), feature_best));
965                }
966                current_best.feature = feature_index;
967                current_best.split = threshold_value;
968                current_best.left = left_index;
969                current_best.right = right_index;
970
971                if let Some(entry) = self.cache.get_mut(cache_index) {
972                    *entry = *current_best;
973                }
974
975                if feature_best == 0 {
976                    return false;
977                }
978            }
979        }
980
981        // `None` for a child the bound invalidated: its lower bound may be
982        // zero without the subtree being error-free, and the pruner would read
983        // a zero as "nothing beyond this split can do better".
984        let left_score = left_entry.is_valid.then_some(left_entry.lower_bound);
985        pruner.add_result(split_idx, left_score, right_error);
986        pruned[split_idx] = true;
987
988        let score_difference = left_entry
989            .lower_bound
990            .saturating_add(right_error.unwrap_or(0))
991            .saturating_sub(current_best.error.min(upper_bound));
992        let (new_left_bound, new_right_bound) =
993            pruner.neighbourhood_pruning(score_difference, current_left, current_right, split_idx);
994
995        // `neighbourhood_pruning` leaves two intervals to search,
996        // `[current_left, new_right_bound]` and `[new_left_bound, current_right]`.
997        // Only the thresholds strictly between them can be skipped.
998        let skip_from = new_right_bound.saturating_add(1).max(current_left);
999        if skip_from < split_idx {
1000            pruned[skip_from..split_idx].fill(true);
1001        }
1002        let skip_to = new_left_bound.min(current_right + 1);
1003        if split_idx + 1 < skip_to {
1004            pruned[split_idx + 1..skip_to].fill(true);
1005        }
1006        stopped
1007    }
1008
1009    fn discrepancy_limit(nb_candidates: usize, remaining_depth: usize) -> usize {
1010        let mut max_discrepancy = nb_candidates;
1011        for i in 1..remaining_depth {
1012            max_discrepancy += nb_candidates.saturating_sub(i);
1013        }
1014
1015        max_discrepancy
1016    }
1017
1018    pub fn statistics(&self) -> &Statistics {
1019        &self.statistics
1020    }
1021
1022    fn time_remains(&self) -> bool {
1023        shared::time_remains(&self.runtime, self.config.max_time)
1024    }
1025
1026    fn elapsed_time(&self) -> f64 {
1027        shared::elapsed_time(&self.runtime)
1028    }
1029
1030    pub fn select_point(&mut self, config: &SearchConfig, bound: &Bound) -> usize {
1031        shared::select_point(config.point_selector, &mut self.rng, bound)
1032    }
1033
1034    /// The tree found by the passes run so far.
1035    pub fn get_solution_tree(&mut self) -> Tree {
1036        shared::build_solution_tree(&self.cache)
1037    }
1038}
1039
1040#[cfg(test)]
1041mod contree_lds_test {
1042    use crate::algorithms::{ConTree, ConTreeLds};
1043    use crate::common::PointSelector;
1044    use crate::reader::data_reader::DataReader;
1045    use crate::reader::DataReaderError;
1046    use crate::tests::fixture;
1047
1048    #[test]
1049    fn lds_is_never_better_than_the_exhaustive_optimum() -> Result<(), DataReaderError> {
1050        let reader = DataReader::default();
1051        let mut dataset = reader.read_file(&fixture("hepatitis.txt"))?;
1052        dataset.sort_features();
1053
1054        let mut exhaustive: ConTree =
1055            ConTree::new(1, 2, 100.0, usize::MAX, PointSelector::Mid, 0, false, true);
1056        exhaustive.fit(&dataset).expect("fit");
1057
1058        let mut lds: ConTreeLds =
1059            ConTreeLds::new(1, 2, 100.0, usize::MAX, PointSelector::Mid, 0, false, true);
1060        lds.fit(&dataset).expect("fit");
1061
1062        // LDS restricts the search, so it can only tie or lose against the
1063        // exhaustive optimum. Beating it would mean the exhaustive search is
1064        // pruning something it should not.
1065        assert!(
1066            lds.statistics.error >= exhaustive.statistics().error,
1067            "LDS reported {} against an exhaustive optimum of {}",
1068            lds.statistics.error,
1069            exhaustive.statistics().error
1070        );
1071        assert!(lds.cache.len() > 0, "the cache was never written to");
1072
1073        Ok(())
1074    }
1075
1076    #[test]
1077    fn the_anytime_loop_terminates_without_leaning_on_the_time_limit() {
1078        // With no time limit, the search must still stop once the budget
1079        // schedule runs out.
1080        let reader = DataReader::default();
1081        let mut dataset = reader.read_file(&fixture("small.txt")).unwrap();
1082        dataset.sort_features();
1083
1084        let mut lds: ConTreeLds = ConTreeLds::new(
1085            1,
1086            3,
1087            f64::INFINITY,
1088            usize::MAX,
1089            PointSelector::Mid,
1090            0,
1091            false,
1092            false,
1093        );
1094        lds.fit(&dataset).expect("fit");
1095
1096        assert!(lds.statistics.error <= dataset.count());
1097    }
1098}