1use std::cell::RefCell;
18use std::collections::HashMap;
19use std::hash::{BuildHasher, RandomState};
20use std::sync::Mutex;
21
22use pagesift_stat::Delta;
23use pagesift_stat::rng::Rng;
24
25pub const PRIOR_SUFFICIENT: f32 = 3.0;
27pub const MAX_HOSTS: usize = 100_000;
29
30#[derive(Default, Clone, Copy, Debug)]
32struct Counts {
33 sufficient: f32,
34 insufficient: f32,
35}
36
37impl Counts {
38 fn observe(&mut self, sufficient: bool, keep: f32) {
39 self.sufficient *= keep;
40 self.insufficient *= keep;
41 if sufficient {
42 self.sufficient += 1.0;
43 } else {
44 self.insufficient += 1.0;
45 }
46 }
47
48 fn sample_sufficient(&self, rng: &mut Rng) -> bool {
50 if self.insufficient <= 0.0 {
51 return true;
52 }
53 let a = f64::from(PRIOR_SUFFICIENT + self.sufficient);
54 let b = f64::from(self.insufficient);
55 rng.beta(a, b) > 0.5
57 }
58
59 fn evidence(&self) -> f32 {
60 self.sufficient + self.insufficient
61 }
62}
63
64#[derive(Default)]
65struct Shard {
66 hosts: HashMap<u64, Vec<Counts>>,
67}
68
69pub type EngineObserver = std::sync::Arc<dyn Fn(&str, usize, bool) + Send + Sync>;
71
72pub struct HostLearner {
73 observer: std::sync::OnceLock<EngineObserver>,
74 hasher: RandomState,
75 shards: Box<[Mutex<Shard>]>,
76 per_shard: usize,
77 keep: f32,
78}
79
80thread_local! {
81 static RNG: RefCell<Rng> = RefCell::new(Rng::from_entropy());
82}
83
84impl Default for HostLearner {
85 fn default() -> Self {
86 Self::with_capacity(MAX_HOSTS, Delta::DEFAULT)
87 }
88}
89
90impl HostLearner {
91 fn with_capacity(capacity: usize, delta: Delta) -> Self {
92 let delta = delta.get();
93 let min_per_shard = (1.0 / delta).ceil() as usize;
95 let cores = std::thread::available_parallelism().map_or(1, |n| n.get());
96 let shards = cores
97 .next_power_of_two()
98 .min((capacity / min_per_shard).max(1));
99 HostLearner {
100 observer: std::sync::OnceLock::new(),
101 hasher: RandomState::new(),
102 shards: (0..shards).map(|_| Mutex::default()).collect(),
103 per_shard: capacity.div_ceil(shards),
104 keep: (1.0 - delta) as f32,
105 }
106 }
107
108 fn shard(&self, key: u64) -> std::sync::MutexGuard<'_, Shard> {
109 let i = (key as usize) % self.shards.len();
110 self.shards[i].lock().unwrap_or_else(|e| e.into_inner())
111 }
112
113 pub fn start_engine(&self, host: &str) -> usize {
115 let key = self.hasher.hash_one(host);
116 let shard = self.shard(key);
117 let Some(engines) = shard.hosts.get(&key) else {
118 return 0;
119 };
120 RNG.with_borrow_mut(|rng| {
121 engines
122 .iter()
123 .position(|c| c.sample_sufficient(rng))
124 .unwrap_or(engines.len())
125 })
126 }
127
128 pub fn record_engine(&self, host: &str, from: usize, used: usize) {
130 let key = self.hasher.hash_one(host);
131 let mut shard = self.shard(key);
132 if shard.hosts.len() >= self.per_shard && !shard.hosts.contains_key(&key) {
133 evict_low_evidence(&mut shard.hosts);
134 }
135 let engines = shard.hosts.entry(key).or_default();
136 if engines.len() <= used {
137 engines.resize(used + 1, Counts::default());
138 }
139 for (i, c) in engines.iter_mut().enumerate().take(used + 1).skip(from) {
140 c.observe(i == used, self.keep);
141 }
142 drop(shard);
143 if let Some(o) = self.observer.get() {
144 for i in from..=used {
145 o(host, i, i == used);
146 }
147 }
148 }
149
150 pub fn observe_with(&self, o: EngineObserver) -> bool {
152 self.observer.set(o).is_ok()
153 }
154
155 pub fn import(&self, host: &str, engine: usize, sufficient: f64, insufficient: f64) {
158 let key = self.hasher.hash_one(host);
159 let mut shard = self.shard(key);
160 if shard.hosts.len() >= self.per_shard && !shard.hosts.contains_key(&key) {
161 evict_low_evidence(&mut shard.hosts);
162 }
163 let engines = shard.hosts.entry(key).or_default();
164 if engines.len() <= engine {
165 engines.resize(engine + 1, Counts::default());
166 }
167 let c = &mut engines[engine];
168 if c.evidence() == 0.0 {
169 c.sufficient = sufficient as f32;
170 c.insufficient = insufficient as f32;
171 }
172 }
173}
174
175fn evict_low_evidence(hosts: &mut HashMap<u64, Vec<Counts>>) {
177 let evidence = |v: &Vec<Counts>| v.iter().map(Counts::evidence).sum::<f32>();
178 let mut keys: Vec<(f32, u64)> = hosts.iter().map(|(k, v)| (evidence(v), *k)).collect();
179 let half = keys.len() / 2;
180 keys.select_nth_unstable_by(half, |a, b| a.0.total_cmp(&b.0));
181 for (_, k) in &keys[..half] {
182 hosts.remove(k);
183 }
184}
185
186#[cfg(test)]
187mod tests {
188 use super::*;
189
190 #[test]
191 fn cold_start_always_starts_at_the_first_engine() {
192 let l = HostLearner::default();
193 for _ in 0..1000 {
194 assert_eq!(l.start_engine("new.test"), 0);
195 }
196 l.record_engine("ok.test", 0, 0);
198 for _ in 0..1000 {
199 assert_eq!(l.start_engine("ok.test"), 0);
200 }
201 }
202
203 #[test]
204 fn learns_the_starting_engine_and_keeps_exploring() {
205 let l = HostLearner::default();
206 for _ in 0..20 {
207 l.record_engine("spa.test", 0, 1);
208 }
209 let n = 2000;
210 let skipped = (0..n).filter(|_| l.start_engine("spa.test") == 1).count();
211 assert!(skipped > n * 9 / 10, "{skipped}");
213 assert!(skipped < n, "{skipped}");
214 }
215
216 #[test]
217 fn three_misses_put_the_posterior_mean_at_one_half() {
218 let mut c = Counts::default();
219 for _ in 0..3 {
220 c.observe(false, 1.0);
221 }
222 let mean = (PRIOR_SUFFICIENT + c.sufficient) / (PRIOR_SUFFICIENT + c.evidence());
223 assert_eq!(mean, 0.5);
224 }
225
226 #[test]
227 fn relearns_after_the_site_changes() {
228 let l = HostLearner::default();
229 for _ in 0..30 {
230 l.record_engine("spa.test", 0, 1);
231 }
232 for _ in 0..30 {
234 l.record_engine("spa.test", 0, 0);
235 }
236 let n = 2000;
237 let first = (0..n).filter(|_| l.start_engine("spa.test") == 0).count();
238 assert!(first > n * 9 / 10, "{first}");
239 }
240
241 #[test]
242 fn full_shard_evicts_the_less_informed_half() {
243 let l = HostLearner::with_capacity(40, Delta::DEFAULT);
244 assert!(l.shards.len() <= 2);
245 l.record_engine("busy.test", 0, 1);
246 l.record_engine("busy.test", 0, 1);
247 for i in 0..200 {
248 l.record_engine(&format!("h{i}.test"), 0, 0);
249 }
250 let total: usize = l.shards.iter().map(|s| s.lock().unwrap().hosts.len()).sum();
251 assert!(total <= 40, "{total}");
252 let busy = l.hasher.hash_one("busy.test");
253 assert!(l.shard(busy).hosts.contains_key(&busy));
254 }
255}