pagesift_frontier/frontier/
learn.rs1use super::*;
4
5use pagesift_stat::Delta;
6
7pub(super) fn rule_support(_dups: u32, _pages: u32) -> usize {
17 let delta = Delta::DEFAULT;
18 let p0 = delta.get().sqrt();
20 if p0 <= 0.0 {
21 return 1;
22 }
23 let k = (delta.get().ln() / p0.ln() - 1e-9).ceil();
25 k.max(1.0) as usize
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Seen {
31 New,
32 Duplicate(String),
34 Templated,
36}
37
38#[derive(Default)]
39struct ShapeStats {
40 fetched: u32,
41 repeated: u32,
42 skeletons: HashSet<u64>,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Hash)]
49enum Rule {
50 DropPair {
52 host: String,
53 name: String,
54 value: String,
55 },
56 DropName { host: String, name: String },
58 StripSuffix { host: String, suffix: String },
60}
61
62#[derive(Default)]
63struct Rules {
64 evidence: HashMap<Rule, HashSet<u64>>,
66 active: HashSet<Rule>,
67 values: HashMap<(String, String), HashSet<String>>,
69 suffixes: HashMap<String, Vec<String>>,
71}
72
73impl Rules {
74 fn support(&mut self, rule: Rule, witness: u64, need: usize) {
76 if self.active.contains(&rule) {
77 return;
78 }
79 let ev = self.evidence.entry(rule.clone()).or_default();
80 ev.insert(witness);
81 if ev.len() >= need {
82 self.evidence.remove(&rule);
83 if let Rule::StripSuffix { host, suffix } = &rule {
84 self.suffixes
85 .entry(host.clone())
86 .or_default()
87 .push(suffix.clone());
88 }
89 if let Rule::DropPair { host, name, value } = &rule {
90 let vals = self.values.entry((host.clone(), name.clone())).or_default();
91 vals.insert(value.clone());
92 if vals.len() >= need.max(2) {
93 self.active.insert(Rule::DropName {
94 host: host.clone(),
95 name: name.clone(),
96 });
97 }
98 }
99 self.active.insert(rule);
100 }
101 }
102
103 fn learn(&mut self, long: &Url, short: &Url, need: usize) {
105 let (Some(host), Some(h2)) = (long.host_str(), short.host_str()) else {
106 return;
107 };
108 if host != h2 || long.scheme() != short.scheme() {
109 return;
110 }
111 let witness = fnv(short.as_str().bytes());
112 let lq: Vec<(String, String)> = long
113 .query_pairs()
114 .map(|(k, v)| (k.into_owned(), v.into_owned()))
115 .collect();
116 let sq: Vec<(String, String)> = short
117 .query_pairs()
118 .map(|(k, v)| (k.into_owned(), v.into_owned()))
119 .collect();
120 if long.path() == short.path() {
121 if lq.len() > sq.len() && sq.iter().all(|p| lq.contains(p)) {
123 for (name, value) in lq.iter().filter(|p| !sq.contains(p)) {
124 self.support(
125 Rule::DropPair {
126 host: host.to_string(),
127 name: name.clone(),
128 value: value.clone(),
129 },
130 witness,
131 need,
132 );
133 }
134 }
135 } else if lq == sq
136 && let Some(suffix) = long.path().strip_prefix(short.path())
137 && short.path().ends_with('/')
138 && !suffix.is_empty()
139 {
140 self.support(
141 Rule::StripSuffix {
142 host: host.to_string(),
143 suffix: suffix.to_string(),
144 },
145 witness,
146 need,
147 );
148 }
149 }
150
151 fn apply(&self, u: &mut Url) -> bool {
153 if self.active.is_empty() {
154 return false;
155 }
156 let Some(host) = u.host_str().map(str::to_string) else {
157 return false;
158 };
159 let mut changed = false;
160 if let Some(suffixes) = self.suffixes.get(&host) {
161 let mut path = u.path().to_string();
162 loop {
163 let before = path.len();
164 for sfx in suffixes {
165 if path.len() > sfx.len()
166 && path.ends_with(sfx.as_str())
167 && path[..path.len() - sfx.len()].ends_with('/')
168 {
169 path.truncate(path.len() - sfx.len());
170 }
171 }
172 if path.len() == before {
173 break;
174 }
175 }
176 if path != u.path() {
177 u.set_path(&path);
178 changed = true;
179 }
180 }
181 if u.query().is_some() {
182 let pairs: Vec<(String, String)> = u
183 .query_pairs()
184 .map(|(k, v)| (k.into_owned(), v.into_owned()))
185 .collect();
186 let kept: Vec<&(String, String)> = pairs
187 .iter()
188 .filter(|(name, value)| {
189 !self.active.contains(&Rule::DropName {
190 host: host.clone(),
191 name: name.clone(),
192 }) && !self.active.contains(&Rule::DropPair {
193 host: host.clone(),
194 name: name.clone(),
195 value: value.clone(),
196 })
197 })
198 .collect();
199 if kept.len() != pairs.len() {
200 if kept.is_empty() {
201 u.set_query(None);
202 } else {
203 u.query_pairs_mut()
204 .clear()
205 .extend_pairs(kept.iter().map(|(k, v)| (k, v)));
206 }
207 changed = true;
208 }
209 }
210 changed
211 }
212}
213
214#[derive(Default)]
215pub struct Learner {
216 exact: HashMap<u64, String>,
217 rules: Rules,
218 shapes: HashMap<u64, ShapeStats>,
219 pages: u32,
221 repeated: u32,
222 dups: u32,
223 pub(super) generation: u64,
225}
226
227const DEMOTE_MIN_FETCHED: u32 = 4;
230const DEMOTE_SHARE: f32 = 0.75;
232
233impl ShapeStats {
234 fn demoted(&self, _rest: (u32, u32)) -> bool {
244 self.fetched >= DEMOTE_MIN_FETCHED
246 && self.repeated as f32 >= DEMOTE_SHARE * self.fetched as f32
247 }
248}
249
250pub(super) fn fnv(bytes: impl Iterator<Item = u8>) -> u64 {
251 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
252 for b in bytes {
253 h ^= u64::from(b);
254 h = h.wrapping_mul(0x0100_0000_01b3);
255 }
256 h
257}
258
259fn text_bytes(markdown: &str) -> impl Iterator<Item = u8> + '_ {
261 markdown
262 .bytes()
263 .filter(|b| !b.is_ascii() || b.is_ascii_alphanumeric())
264}
265
266impl Learner {
267 pub fn observe(&mut self, url: &Url, markdown: &str) -> Seen {
269 let exact = fnv(text_bytes(markdown));
270 let skeleton = fnv(text_bytes(markdown).map(|b| if b.is_ascii_digit() { b'0' } else { b }));
271 let need = rule_support(self.dups, self.pages);
272 let key = shape_key(url);
273 let was = self.demoted_key(key);
274 let stats = self.shapes.entry(key).or_default();
275 stats.fetched += 1;
276 self.pages += 1;
277 let seen = if let Some(first) = self.exact.get(&exact) {
278 if let Ok(f) = Url::parse(first) {
279 if f.as_str().len() <= url.as_str().len() {
281 self.rules.learn(url, &f, need);
282 } else {
283 self.rules.learn(&f, url, need);
284 }
285 }
286 self.dups += 1;
287 Seen::Duplicate(first.clone())
288 } else if !stats.skeletons.insert(skeleton) {
289 Seen::Templated
290 } else {
291 Seen::New
292 };
293 if seen == Seen::New || matches!(seen, Seen::Templated) {
294 self.exact.entry(exact).or_insert_with(|| url.to_string());
295 }
296 if seen != Seen::New {
297 stats.repeated += 1;
298 self.repeated += 1;
299 }
300 if !was && self.demoted_key(key) {
301 self.generation += 1;
302 }
303 seen
304 }
305
306 pub fn rewrite(&self, u: &mut Url) -> bool {
308 self.rules.apply(u)
309 }
310
311 pub fn demoted(&self, url: &Url) -> bool {
313 self.demoted_key(shape_key(url))
314 }
315
316 pub(super) fn demoted_key(&self, key: u64) -> bool {
317 self.shapes.get(&key).is_some_and(|s| {
318 s.demoted((
319 self.pages.saturating_sub(s.fetched),
320 self.repeated.saturating_sub(s.repeated),
321 ))
322 })
323 }
324}