Skip to main content

condor_core/
budget.rs

1//! Optional wall-clock and expansion budgets for online search requests.
2//!
3//! Budgets are **caller-supplied** and **unlimited by default**. When a limit is
4//! hit mid-search, owners return domain `Err(…BudgetExhausted…)` rather than
5//! [`crate::SearchOutcome::NoPath`]: the search did not prove unreachability.
6//!
7//! Prepared preprocess budgets (for example prepared any-angle node/edge caps)
8//! remain separate fail-closed build limits in owner crates.
9
10use std::time::{Duration, Instant};
11
12/// Caller-chosen expansion and/or wall-clock limits for one search.
13///
14/// Both fields are independent optional caps. Default / [`Self::UNLIMITED`] means
15/// the solver runs until found, no-path, or an owner-defined hard stop.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub struct SearchBudget {
18    /// Maximum algorithm-defined expansions (see lane `visited_nodes` docs).
19    ///
20    /// When set, the solver may expand at most this many nodes. Finding the goal
21    /// on the last allowed expansion still returns found.
22    pub max_expansions: Option<usize>,
23    /// Maximum wall-clock duration measured from search start ([`BudgetWatch::start`]).
24    pub max_duration: Option<Duration>,
25}
26
27impl SearchBudget {
28    /// No expansion or wall-clock limit.
29    pub const UNLIMITED: Self = Self {
30        max_expansions: None,
31        max_duration: None,
32    };
33
34    /// Expansion-only budget.
35    #[must_use]
36    pub const fn max_expansions(limit: usize) -> Self {
37        Self {
38            max_expansions: Some(limit),
39            max_duration: None,
40        }
41    }
42
43    /// Wall-clock-only budget.
44    #[must_use]
45    pub const fn max_duration(limit: Duration) -> Self {
46        Self {
47            max_expansions: None,
48            max_duration: Some(limit),
49        }
50    }
51
52    /// Sets [`Self::max_expansions`], preserving any duration limit.
53    #[must_use]
54    pub const fn with_max_expansions(mut self, limit: usize) -> Self {
55        self.max_expansions = Some(limit);
56        self
57    }
58
59    /// Sets [`Self::max_duration`], preserving any expansion limit.
60    #[must_use]
61    pub const fn with_max_duration(mut self, limit: Duration) -> Self {
62        self.max_duration = Some(limit);
63        self
64    }
65
66    /// Whether both caps are unset.
67    #[must_use]
68    pub const fn is_unlimited(&self) -> bool {
69        self.max_expansions.is_none() && self.max_duration.is_none()
70    }
71}
72
73/// Why a search stopped for budget rather than completing found/no-path.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum BudgetExhausted {
77    /// Expansion count reached the configured [`SearchBudget::max_expansions`].
78    Expansions {
79        /// Configured expansion limit.
80        limit: usize,
81        /// Expansions performed when the limit was hit.
82        expansions: usize,
83    },
84    /// Wall-clock time reached the configured [`SearchBudget::max_duration`].
85    Duration {
86        /// Configured duration limit.
87        limit: Duration,
88    },
89}
90
91impl std::fmt::Display for BudgetExhausted {
92    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::Expansions { limit, expansions } => write!(
95                formatter,
96                "search budget exhausted after {expansions} expansions (limit {limit})"
97            ),
98            Self::Duration { limit } => {
99                write!(formatter, "search budget exhausted after {limit:?}")
100            }
101        }
102    }
103}
104
105impl std::error::Error for BudgetExhausted {}
106
107/// One-shot wall-clock deadline for a single search invocation.
108///
109/// Create with [`BudgetWatch::start`] at the beginning of `search` and call
110/// [`BudgetWatch::check`] after each counted expansion when the node is not the
111/// goal (or before continuing after an expansion that did not finish the search).
112#[derive(Debug, Clone, Copy)]
113pub struct BudgetWatch {
114    budget: SearchBudget,
115    deadline: Option<Instant>,
116}
117
118impl BudgetWatch {
119    /// Captures the wall-clock deadline for `budget` (if any).
120    #[must_use]
121    pub fn start(budget: SearchBudget) -> Self {
122        let deadline = budget.max_duration.map(|limit| Instant::now() + limit);
123        Self { budget, deadline }
124    }
125
126    /// Returns the budget this watch was started with.
127    #[must_use]
128    pub const fn budget(&self) -> SearchBudget {
129        self.budget
130    }
131
132    /// Fast path when both caps are unset.
133    #[must_use]
134    pub const fn is_unlimited(&self) -> bool {
135        self.budget.is_unlimited()
136    }
137
138    /// Checks expansion and wall-clock caps against the work done so far.
139    ///
140    /// Call after incrementing the expansion counter when the search has not yet
141    /// terminated as found. Unlimited budgets always return `Ok(())`.
142    ///
143    /// Expansion semantics: after `expansions` counted nodes, if
144    /// `expansions >= max_expansions` the budget is exhausted (the caller should
145    /// only invoke this when the current node is not an accepted goal).
146    pub fn check(&self, expansions: usize) -> Result<(), BudgetExhausted> {
147        if let Some(limit) = self.budget.max_expansions
148            && expansions >= limit
149        {
150            return Err(BudgetExhausted::Expansions { limit, expansions });
151        }
152        if let (Some(deadline), Some(limit)) = (self.deadline, self.budget.max_duration)
153            && Instant::now() >= deadline
154        {
155            return Err(BudgetExhausted::Duration { limit });
156        }
157        Ok(())
158    }
159
160    /// Whether any budget limit is configured (used to skip watch setup cost).
161    #[must_use]
162    pub const fn has_limits(&self) -> bool {
163        !self.budget.is_unlimited()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::{BudgetExhausted, BudgetWatch, SearchBudget};
170    use std::time::Duration;
171
172    #[test]
173    fn unlimited_watch_never_exhausts() {
174        let watch = BudgetWatch::start(SearchBudget::UNLIMITED);
175        assert!(watch.check(0).is_ok());
176        assert!(watch.check(usize::MAX).is_ok());
177    }
178
179    #[test]
180    fn expansion_limit_trips_at_limit() {
181        let watch = BudgetWatch::start(SearchBudget::max_expansions(3));
182        assert!(watch.check(2).is_ok());
183        assert_eq!(
184            watch.check(3),
185            Err(BudgetExhausted::Expansions {
186                limit: 3,
187                expansions: 3
188            })
189        );
190    }
191
192    #[test]
193    fn duration_limit_trips_after_deadline() {
194        let watch = BudgetWatch::start(SearchBudget::max_duration(Duration::from_millis(0)));
195        // Zero-duration deadline is already due once Instant advances past start.
196        let result = watch.check(0);
197        assert!(matches!(result, Err(BudgetExhausted::Duration { .. })));
198    }
199}