pagesift_frontier/focus/
harvest.rs1use std::collections::HashMap;
12
13use url::Url;
14
15fn wilson(hits: u32, n: u32) -> (f64, f64) {
20 pagesift_stat::beta::wilson_z(f64::from(hits), f64::from(n), *Z)
21}
22
23static Z: std::sync::LazyLock<f64> = std::sync::LazyLock::new(|| {
25 pagesift_stat::special::normal_quantile(1.0 - pagesift_stat::Delta::DEFAULT.get() / 2.0)
26});
27
28#[derive(Default)]
30pub struct Harvest {
31 parts: HashMap<String, (u32, u32)>,
33 total: (u32, u32),
34}
35
36impl Harvest {
37 pub fn observe(&mut self, part: String, hit: bool) {
39 let p = self.parts.entry(part).or_default();
40 p.0 += 1;
41 p.1 += u32::from(hit);
42 self.total.0 += 1;
43 self.total.1 += u32::from(hit);
44 }
45
46 fn split(&self, part: &str) -> ((u32, u32), (u32, u32)) {
48 let mine = self.parts.get(part).copied().unwrap_or_default();
49 (mine, (self.total.0 - mine.0, self.total.1 - mine.1))
50 }
51
52 pub fn is_dry(&self, part: &str) -> bool {
54 let ((n, h), (rn, rh)) = self.split(part);
55 n > 0 && wilson(h, n).1 < wilson(rh, rn).0
56 }
57
58 pub fn is_proven(&self, part: &str) -> bool {
61 let ((n, h), (rn, rh)) = self.split(part);
62 n > 0 && wilson(h, n).0 > wilson(rh, rn).0
63 }
64}
65
66pub fn subtree(seed_host: &str, url: &Url) -> String {
71 let host = url
72 .host_str()
73 .unwrap_or_default()
74 .trim_start_matches("www.")
75 .to_ascii_lowercase();
76 if host != seed_host {
77 return host;
78 }
79 let mut segs: Vec<&str> = url.path().split('/').filter(|s| !s.is_empty()).collect();
80 segs.pop();
81 if segs.first().is_some_and(|s| {
82 super::url_shape::is_language_code(s) || super::url_shape::is_language_name(s)
83 }) {
84 segs.remove(0);
85 }
86 segs.truncate(2);
87 format!("{host}/{}", segs.join("/"))
88}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93
94 fn u(s: &str) -> Url {
95 Url::parse(s).unwrap()
96 }
97
98 #[test]
99 fn a_part_that_never_pays_off_is_dropped_once_the_evidence_is_clear() {
100 let mut h = Harvest::default();
101 for i in 0..10 {
103 h.observe("lib".into(), i % 2 == 0);
104 }
105 for _ in 0..3 {
107 h.observe("journals".into(), false);
108 }
109 assert!(!h.is_dry("journals"));
110 for _ in 0..12 {
111 h.observe("journals".into(), false);
112 }
113 assert!(h.is_dry("journals"));
114 assert!(!h.is_dry("lib"));
115 assert!(h.is_proven("lib"));
116 assert!(!h.is_proven("journals"));
117 let mut h = Harvest::default();
119 for i in 0..40 {
120 h.observe("study".into(), i % 5 == 0);
121 h.observe("other".into(), i % 4 == 0);
122 }
123 assert!(!h.is_dry("study") && !h.is_dry("other"));
124 }
125
126 #[test]
128 fn wilson_uses_two_sided_delta() {
129 let old = |h: f64, n: f64| {
130 let z: f64 = 1.96;
131 let p = h / n;
132 let z2 = z * z;
133 let c = (p + z2 / (2.0 * n)) / (1.0 + z2 / n);
134 let half = z * (p * (1.0 - p) / n + z2 / (4.0 * n * n)).sqrt() / (1.0 + z2 / n);
135 ((c - half).max(0.0), (c + half).min(1.0))
136 };
137 for (h, n) in [(0, 1), (3, 4), (5, 40), (40, 40)] {
138 let (a, b) = wilson(h, n);
139 let (c, d) = old(f64::from(h), f64::from(n));
140 assert!((a - c).abs() < 1e-4 && (b - d).abs() < 1e-4, "{h}/{n}");
141 }
142 assert_eq!(wilson(0, 0), (0.0, 1.0));
143 }
144
145 #[test]
146 fn subtrees_follow_parent_directories() {
147 let seed = "e.edu";
148 assert_eq!(
149 subtree(seed, &u("https://journals.e.edu/j1/issue/current")),
150 "journals.e.edu"
151 );
152 assert_eq!(
153 subtree(seed, &u("https://www.e.edu/en/research-highlight/story-1")),
154 "e.edu/research-highlight"
155 );
156 assert_eq!(
157 subtree(seed, &u("https://e.edu/en/education/programmes/x/y")),
158 "e.edu/education/programmes"
159 );
160 assert_eq!(subtree(seed, &u("https://e.edu/about")), "e.edu/");
161 }
162}