use std::cmp::Reverse;
use std::collections::{BTreeMap, HashMap};
use url::Url;
pub struct BestFirst<S = ()> {
order: BTreeMap<Key, Entry<S>>,
index: HashMap<u64, Key>,
seq: u64,
taken: Option<S>,
}
type Key = (u32, Reverse<u64>);
struct Entry<S> {
hash: u64,
url: Url,
depth: usize,
signal: S,
}
impl<S> Default for BestFirst<S> {
fn default() -> Self {
Self {
order: BTreeMap::new(),
index: HashMap::new(),
seq: 0,
taken: None,
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum Offer {
Queued,
AlreadyQueued,
Evicted(u64),
Rejected,
}
pub const DIVERSITY_RATIO: f32 = 0.5;
fn score_bits(score: f32) -> u32 {
if score.is_nan() {
0
} else {
score.max(0.0).to_bits()
}
}
pub fn cluster_key(u: &Url) -> String {
let segs: Vec<&str> = u.path().split('/').filter(|s| !s.is_empty()).collect();
let home_segment = |seg: &str| {
let stem = seg
.split_once('.')
.map_or(seg, |(s, _)| s)
.to_ascii_lowercase();
let lang = |p: &str| p.len() == 2 && p.bytes().all(|b| b.is_ascii_alphabetic());
stem == "index"
|| lang(&stem)
|| stem
.split_once('-')
.is_some_and(|(a, b)| lang(a) && (2..=4).contains(&b.len()))
};
let root = match segs.as_slice() {
[] => true,
[one] => home_segment(one),
_ => false,
};
if root {
return "*/".to_string();
}
format!("{}/{}", u.host_str().unwrap_or_default(), segs[0])
}
impl<S> BestFirst<S> {
pub fn len(&self) -> usize {
self.order.len()
}
pub fn is_empty(&self) -> bool {
self.order.is_empty()
}
pub fn contains(&self, hash: u64) -> bool {
self.index.contains_key(&hash)
}
pub fn offer(
&mut self,
hash: u64,
url: Url,
depth: usize,
score: f32,
signal: S,
room: usize,
) -> Offer {
let bits = score_bits(score);
if let Some(&old) = self.index.get(&hash) {
if bits > old.0
&& let Some(mut entry) = self.order.remove(&old)
{
entry.signal = signal;
self.order.insert((bits, old.1), entry);
self.index.insert(hash, (bits, old.1));
}
return Offer::AlreadyQueued;
}
let mut evicted = None;
if self.order.len() >= room {
match self.order.first_key_value() {
Some((&worst, _)) if worst.0 < bits => {
let e = self.order.remove(&worst).expect("present");
self.index.remove(&e.hash);
evicted = Some(e.hash);
}
_ => return Offer::Rejected,
}
}
let key = (bits, Reverse(self.seq));
self.seq += 1;
self.order.insert(
key,
Entry {
hash,
url,
depth,
signal,
},
);
self.index.insert(hash, key);
evicted.map_or(Offer::Queued, Offer::Evicted)
}
pub fn rescore(&mut self, score: impl Fn(&S) -> f32) {
let old = std::mem::take(&mut self.order);
for ((_, seq), entry) in old {
let key = (score_bits(score(&entry.signal)), seq);
self.index.insert(entry.hash, key);
self.order.insert(key, entry);
}
}
#[cfg(test)]
pub fn pop(&mut self) -> Option<(Url, usize, f32)> {
let key = *self.order.last_key_value()?.0;
self.take(key)
}
pub fn pop_diverse(
&mut self,
scheduled: &HashMap<String, usize>,
cap: usize,
proven: impl Fn(&str) -> bool,
) -> Option<(Url, usize, f32)> {
let (&top_key, top) = self.order.last_key_value()?;
let top_url = &top.url;
let top_score = f32::from_bits(top_key.0);
let full = |u: &Url| {
let key = cluster_key(u);
scheduled.get(&key).copied().unwrap_or(0) >= cap && !proven(&key)
};
let floor = top_score * DIVERSITY_RATIO;
if !proven(&cluster_key(top_url))
&& let Some(k) = self
.order
.iter()
.rev()
.take_while(|(k, _)| f32::from_bits(k.0) >= floor)
.find(|(_, e)| proven(&cluster_key(&e.url)))
.map(|(k, _)| *k)
{
return self.take(k);
}
if !full(top_url) {
return self.take(top_key);
}
let pick = self
.order
.iter()
.rev()
.take_while(|(k, _)| f32::from_bits(k.0) >= floor)
.find(|(_, e)| !full(&e.url))
.map(|(k, _)| *k)
.unwrap_or(top_key);
self.take(pick)
}
pub fn peek_score(&self) -> Option<f32> {
self.order
.last_key_value()
.map(|(k, _)| f32::from_bits(k.0))
}
fn take(&mut self, key: Key) -> Option<(Url, usize, f32)> {
let e = self.order.remove(&key)?;
self.index.remove(&e.hash);
self.taken = Some(e.signal);
Some((e.url, e.depth, f32::from_bits(key.0)))
}
pub fn take_signal(&mut self) -> Option<S> {
self.taken.take()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn u(s: &str) -> Url {
Url::parse(s).unwrap()
}
#[test]
fn best_first_pops_highest_score_then_discovery_order() {
let mut q = BestFirst::default();
q.offer(1, u("https://e.com/a"), 1, 0.5, (), 10);
q.offer(2, u("https://e.com/b"), 1, 2.0, (), 10);
q.offer(3, u("https://e.com/c"), 1, 0.5, (), 10);
assert_eq!(q.pop().unwrap().0.path(), "/b");
assert_eq!(q.pop().unwrap().0.path(), "/a");
assert_eq!(q.pop().unwrap().0.path(), "/c");
assert!(q.pop().is_none());
}
#[test]
fn best_first_is_bounded_and_evicts_the_worst() {
let mut q = BestFirst::default();
assert_eq!(
q.offer(1, u("https://e.com/a"), 1, 0.1, (), 2),
Offer::Queued
);
assert_eq!(
q.offer(2, u("https://e.com/b"), 1, 0.2, (), 2),
Offer::Queued
);
assert_eq!(
q.offer(3, u("https://e.com/c"), 1, 0.05, (), 2),
Offer::Rejected
);
assert_eq!(
q.offer(4, u("https://e.com/d"), 1, 0.3, (), 2),
Offer::Evicted(1)
);
assert_eq!(q.len(), 2);
assert!(!q.contains(1) && q.contains(4));
assert_eq!(
q.offer(4, u("https://e.com/d"), 1, 0.9, (), 2),
Offer::AlreadyQueued
);
assert_eq!(q.pop().unwrap().2, 0.9);
}
#[test]
fn diverse_pop_leaves_a_saturated_cluster_for_a_close_rival() {
let mut q = BestFirst::default();
q.offer(1, u("https://e.com/news/1"), 1, 1.0, (), 10);
q.offer(2, u("https://e.com/news/2"), 1, 0.9, (), 10);
q.offer(3, u("https://e.com/study/intl"), 1, 0.6, (), 10);
q.offer(4, u("https://e.com/about"), 1, 0.2, (), 10);
let mut counts = HashMap::new();
counts.insert("e.com/news".to_string(), 3);
assert_eq!(
q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
"/study/intl"
);
assert_eq!(
q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
"/news/1"
);
assert_eq!(
q.pop_diverse(&counts, 3, |_| false).unwrap().0.path(),
"/news/2"
);
let mut q = BestFirst::default();
q.offer(1, u("https://e.com/news/1"), 1, 1.0, (), 10);
q.offer(3, u("https://e.com/study/intl"), 1, 0.6, (), 10);
assert_eq!(
q.pop_diverse(&counts, 3, |k| k == "e.com/news")
.unwrap()
.0
.path(),
"/news/1"
);
assert_eq!(cluster_key(&u("https://e.com/")), "*/");
assert_eq!(cluster_key(&u("https://cs.e.com/en/")), "*/");
assert_eq!(cluster_key(&u("https://e.com/en.html")), "*/");
assert_eq!(cluster_key(&u("https://e.com/en-gb/")), "*/");
assert_eq!(cluster_key(&u("https://e.com/news/")), "e.com/news");
assert_eq!(cluster_key(&u("https://e.com/study")), "e.com/study");
}
#[test]
fn rescoring_reorders_by_the_new_scores_and_keeps_ties_in_discovery_order() {
let mut q: BestFirst<f32> = BestFirst::default();
q.offer(1, u("https://e.com/a"), 1, 0.9, 0.1, 10);
q.offer(2, u("https://e.com/b"), 1, 0.5, 0.7, 10);
q.offer(3, u("https://e.com/c"), 1, 0.4, 0.7, 10);
q.rescore(|s| *s);
assert_eq!(q.peek_score(), Some(0.7));
assert_eq!(q.pop().unwrap().0.path(), "/b");
assert_eq!(q.pop().unwrap().0.path(), "/c");
assert_eq!(q.pop().unwrap().0.path(), "/a");
assert!(q.is_empty() && !q.contains(1));
}
}