1use std::cmp::Reverse;
4use std::collections::{BTreeMap, HashMap};
5
6use url::Url;
7
8pub struct BestFirst<S = ()> {
12 order: BTreeMap<Key, Entry<S>>,
15 index: HashMap<u64, Key>,
16 seq: u64,
17 taken: Option<S>,
19}
20
21type Key = (u32, Reverse<u64>);
22
23struct Entry<S> {
24 hash: u64,
25 url: Url,
26 depth: usize,
27 signal: S,
28}
29
30impl<S> Default for BestFirst<S> {
31 fn default() -> Self {
32 Self {
33 order: BTreeMap::new(),
34 index: HashMap::new(),
35 seq: 0,
36 taken: None,
37 }
38 }
39}
40
41#[derive(Debug, PartialEq, Eq)]
42pub enum Offer {
43 Queued,
44 AlreadyQueued,
46 Evicted(u64),
48 Rejected,
49}
50
51pub const DIVERSITY_RATIO: f32 = 0.5;
54
55fn score_bits(score: f32) -> u32 {
56 if score.is_nan() {
57 0
58 } else {
59 score.max(0.0).to_bits()
60 }
61}
62
63pub fn cluster_key(u: &Url) -> String {
67 let segs: Vec<&str> = u.path().split('/').filter(|s| !s.is_empty()).collect();
68 let home_segment = |seg: &str| {
70 let stem = seg
71 .split_once('.')
72 .map_or(seg, |(s, _)| s)
73 .to_ascii_lowercase();
74 let lang = |p: &str| p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic());
75 stem == "index"
76 || lang(&stem)
77 || stem
78 .split_once('-')
79 .is_some_and(|(a, b)| lang(a) && (2..=4).contains(&b.len()))
80 };
81 let root = match segs.as_slice() {
82 [] => true,
83 [one] => home_segment(one),
84 _ => false,
85 };
86 if root {
87 return "*/".to_string();
88 }
89 format!("{}/{}", u.host_str().unwrap_or_default(), segs[0])
90}
91
92impl<S> BestFirst<S> {
93 pub fn len(&self) -> usize {
94 self.order.len()
95 }
96
97 pub fn is_empty(&self) -> bool {
98 self.order.is_empty()
99 }
100
101 pub fn contains(&self, hash: u64) -> bool {
102 self.index.contains_key(&hash)
103 }
104
105 pub fn offer(
108 &mut self,
109 hash: u64,
110 url: Url,
111 depth: usize,
112 score: f32,
113 signal: S,
114 room: usize,
115 ) -> Offer {
116 let bits = score_bits(score);
117 if let Some(&old) = self.index.get(&hash) {
118 if bits > old.0
119 && let Some(mut entry) = self.order.remove(&old)
120 {
121 entry.signal = signal;
122 self.order.insert((bits, old.1), entry);
123 self.index.insert(hash, (bits, old.1));
124 }
125 return Offer::AlreadyQueued;
126 }
127 let mut evicted = None;
128 if self.order.len() >= room {
129 match self.order.first_key_value() {
130 Some((&worst, _)) if worst.0 < bits => {
131 let e = self.order.remove(&worst).expect("present");
132 self.index.remove(&e.hash);
133 evicted = Some(e.hash);
134 }
135 _ => return Offer::Rejected,
136 }
137 }
138 let key = (bits, Reverse(self.seq));
139 self.seq += 1;
140 self.order.insert(
141 key,
142 Entry {
143 hash,
144 url,
145 depth,
146 signal,
147 },
148 );
149 self.index.insert(hash, key);
150 evicted.map_or(Offer::Queued, Offer::Evicted)
151 }
152
153 pub fn rescore(&mut self, score: impl Fn(&S) -> f32) {
155 let old = std::mem::take(&mut self.order);
156 for ((_, seq), entry) in old {
157 let key = (score_bits(score(&entry.signal)), seq);
158 self.index.insert(entry.hash, key);
159 self.order.insert(key, entry);
160 }
161 }
162
163 #[cfg(test)]
165 pub fn pop(&mut self) -> Option<(Url, usize, f32)> {
166 let key = *self.order.last_key_value()?.0;
167 self.take(key)
168 }
169
170 pub fn pop_diverse(
175 &mut self,
176 scheduled: &HashMap<String, usize>,
177 cap: usize,
178 proven: impl Fn(&str) -> bool,
179 ) -> Option<(Url, usize, f32)> {
180 let (&top_key, top) = self.order.last_key_value()?;
181 let top_url = &top.url;
182 let top_score = f32::from_bits(top_key.0);
183 let full = |u: &Url| {
184 let key = cluster_key(u);
185 scheduled.get(&key).copied().unwrap_or(0) >= cap && !proven(&key)
186 };
187 let floor = top_score * DIVERSITY_RATIO;
188 if !proven(&cluster_key(top_url))
191 && let Some(k) = self
192 .order
193 .iter()
194 .rev()
195 .take_while(|(k, _)| f32::from_bits(k.0) >= floor)
196 .find(|(_, e)| proven(&cluster_key(&e.url)))
197 .map(|(k, _)| *k)
198 {
199 return self.take(k);
200 }
201 if !full(top_url) {
202 return self.take(top_key);
203 }
204 let pick = self
205 .order
206 .iter()
207 .rev()
208 .take_while(|(k, _)| f32::from_bits(k.0) >= floor)
209 .find(|(_, e)| !full(&e.url))
210 .map(|(k, _)| *k)
211 .unwrap_or(top_key);
212 self.take(pick)
213 }
214
215 pub fn peek_score(&self) -> Option<f32> {
217 self.order
218 .last_key_value()
219 .map(|(k, _)| f32::from_bits(k.0))
220 }
221
222 fn take(&mut self, key: Key) -> Option<(Url, usize, f32)> {
223 let e = self.order.remove(&key)?;
224 self.index.remove(&e.hash);
225 self.taken = Some(e.signal);
226 Some((e.url, e.depth, f32::from_bits(key.0)))
227 }
228
229 pub fn take_signal(&mut self) -> Option<S> {
231 self.taken.take()
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 fn u(s: &str) -> Url {
240 Url::parse(s).unwrap()
241 }
242
243 #[test]
244 fn best_first_pops_highest_score_then_discovery_order() {
245 let mut q = BestFirst::default();
246 q.offer(1, u("https://e.com/a"), 1, 0.5, (), 10);
247 q.offer(2, u("https://e.com/b"), 1, 2.0, (), 10);
248 q.offer(3, u("https://e.com/c"), 1, 0.5, (), 10);
249 assert_eq!(q.pop().unwrap().0.path(), "/b");
250 assert_eq!(q.pop().unwrap().0.path(), "/a");
251 assert_eq!(q.pop().unwrap().0.path(), "/c");
252 assert!(q.pop().is_none());
253 }
254
255 #[test]
256 fn best_first_is_bounded_and_evicts_the_worst() {
257 let mut q = BestFirst::default();
258 assert_eq!(
259 q.offer(1, u("https://e.com/a"), 1, 0.1, (), 2),
260 Offer::Queued
261 );
262 assert_eq!(
263 q.offer(2, u("https://e.com/b"), 1, 0.2, (), 2),
264 Offer::Queued
265 );
266 assert_eq!(
267 q.offer(3, u("https://e.com/c"), 1, 0.05, (), 2),
268 Offer::Rejected
269 );
270 assert_eq!(
271 q.offer(4, u("https://e.com/d"), 1, 0.3, (), 2),
272 Offer::Evicted(1)
273 );
274 assert_eq!(q.len(), 2);
275 assert!(!q.contains(1) && q.contains(4));
276 assert_eq!(
277 q.offer(4, u("https://e.com/d"), 1, 0.9, (), 2),
278 Offer::AlreadyQueued
279 );
280 assert_eq!(q.pop().unwrap().2, 0.9);
281 }
282
283 #[test]
284 fn diverse_pop_leaves_a_saturated_cluster_for_a_close_rival() {
285 let mut q = BestFirst::default();
286 q.offer(1, u("https://e.com/news/1"), 1, 1.0, (), 10);
287 q.offer(2, u("https://e.com/news/2"), 1, 0.9, (), 10);
288 q.offer(3, u("https://e.com/study/intl"), 1, 0.6, (), 10);
289 q.offer(4, u("https://e.com/about"), 1, 0.2, (), 10);
290 let mut counts = HashMap::new();
291 counts.insert("e.com/news".to_string(), 3);
292 assert_eq!(
294 q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
295 "/study/intl"
296 );
297 assert_eq!(
298 q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
299 "/news/1"
300 );
301 assert_eq!(
303 q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
304 "/news/2"
305 );
306 let mut q = BestFirst::default();
308 q.offer(1, u("https://e.com/news/1"), 1, 1.0, (), 10);
309 q.offer(3, u("https://e.com/study/intl"), 1, 0.6, (), 10);
310 assert_eq!(
311 q.pop_diverse(&counts, 3, |k| k == "e.com/news")
312 .unwrap()
313 .0
314 .path(),
315 "/news/1"
316 );
317 assert_eq!(cluster_key(&u("https://e.com/")), "*/");
318 assert_eq!(cluster_key(&u("https://cs.e.com/en/")), "*/");
319 assert_eq!(cluster_key(&u("https://e.com/en.html")), "*/");
320 assert_eq!(cluster_key(&u("https://e.com/en-gb/")), "*/");
321 assert_eq!(cluster_key(&u("https://e.com/news/")), "e.com/news");
322 assert_eq!(cluster_key(&u("https://e.com/study")), "e.com/study");
323 }
324
325 #[test]
326 fn rescoring_reorders_by_the_new_scores_and_keeps_ties_in_discovery_order() {
327 let mut q: BestFirst<f32> = BestFirst::default();
328 q.offer(1, u("https://e.com/a"), 1, 0.9, 0.1, 10);
329 q.offer(2, u("https://e.com/b"), 1, 0.5, 0.7, 10);
330 q.offer(3, u("https://e.com/c"), 1, 0.4, 0.7, 10);
331 q.rescore(|s| *s);
332 assert_eq!(q.peek_score(), Some(0.7));
333 assert_eq!(q.pop().unwrap().0.path(), "/b");
334 assert_eq!(q.pop().unwrap().0.path(), "/c");
335 assert_eq!(q.pop().unwrap().0.path(), "/a");
336 assert!(q.is_empty() && !q.contains(1));
337 }
338}