1use std::time::{Duration, Instant};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub struct SearchBudget {
18 pub max_expansions: Option<usize>,
23 pub max_duration: Option<Duration>,
25}
26
27impl SearchBudget {
28 pub const UNLIMITED: Self = Self {
30 max_expansions: None,
31 max_duration: None,
32 };
33
34 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum BudgetExhausted {
77 Expansions {
79 limit: usize,
81 expansions: usize,
83 },
84 Duration {
86 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#[derive(Debug, Clone, Copy)]
113pub struct BudgetWatch {
114 budget: SearchBudget,
115 deadline: Option<Instant>,
116}
117
118impl BudgetWatch {
119 #[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 #[must_use]
128 pub const fn budget(&self) -> SearchBudget {
129 self.budget
130 }
131
132 #[must_use]
134 pub const fn is_unlimited(&self) -> bool {
135 self.budget.is_unlimited()
136 }
137
138 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 #[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 let result = watch.check(0);
197 assert!(matches!(result, Err(BudgetExhausted::Duration { .. })));
198 }
199}