use super::*;
use pagesift_stat::Delta;
pub(super) fn rule_support(_dups: u32, _pages: u32) -> usize {
let delta = Delta::DEFAULT;
let p0 = delta.get().sqrt();
if p0 <= 0.0 {
return 1;
}
let k = (delta.get().ln() / p0.ln() - 1e-9).ceil();
k.max(1.0) as usize
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Seen {
New,
Duplicate(String),
Templated,
}
#[derive(Default)]
struct ShapeStats {
fetched: u32,
repeated: u32,
skeletons: HashSet<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum Rule {
DropPair {
host: String,
name: String,
value: String,
},
DropName { host: String, name: String },
StripSuffix { host: String, suffix: String },
}
#[derive(Default)]
struct Rules {
evidence: HashMap<Rule, HashSet<u64>>,
active: HashSet<Rule>,
values: HashMap<(String, String), HashSet<String>>,
suffixes: HashMap<String, Vec<String>>,
}
impl Rules {
fn support(&mut self, rule: Rule, witness: u64, need: usize) {
if self.active.contains(&rule) {
return;
}
let ev = self.evidence.entry(rule.clone()).or_default();
ev.insert(witness);
if ev.len() >= need {
self.evidence.remove(&rule);
if let Rule::StripSuffix { host, suffix } = &rule {
self.suffixes
.entry(host.clone())
.or_default()
.push(suffix.clone());
}
if let Rule::DropPair { host, name, value } = &rule {
let vals = self.values.entry((host.clone(), name.clone())).or_default();
vals.insert(value.clone());
if vals.len() >= need.max(2) {
self.active.insert(Rule::DropName {
host: host.clone(),
name: name.clone(),
});
}
}
self.active.insert(rule);
}
}
fn learn(&mut self, long: &Url, short: &Url, need: usize) {
let (Some(host), Some(h2)) = (long.host_str(), short.host_str()) else {
return;
};
if host != h2 || long.scheme() != short.scheme() {
return;
}
let witness = fnv(short.as_str().bytes());
let lq: Vec<(String, String)> = long
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
let sq: Vec<(String, String)> = short
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
if long.path() == short.path() {
if lq.len() > sq.len() && sq.iter().all(|p| lq.contains(p)) {
for (name, value) in lq.iter().filter(|p| !sq.contains(p)) {
self.support(
Rule::DropPair {
host: host.to_string(),
name: name.clone(),
value: value.clone(),
},
witness,
need,
);
}
}
} else if lq == sq
&& let Some(suffix) = long.path().strip_prefix(short.path())
&& short.path().ends_with('/')
&& !suffix.is_empty()
{
self.support(
Rule::StripSuffix {
host: host.to_string(),
suffix: suffix.to_string(),
},
witness,
need,
);
}
}
fn apply(&self, u: &mut Url) -> bool {
if self.active.is_empty() {
return false;
}
let Some(host) = u.host_str().map(str::to_string) else {
return false;
};
let mut changed = false;
if let Some(suffixes) = self.suffixes.get(&host) {
let mut path = u.path().to_string();
loop {
let before = path.len();
for sfx in suffixes {
if path.len() > sfx.len()
&& path.ends_with(sfx.as_str())
&& path[..path.len() - sfx.len()].ends_with('/')
{
path.truncate(path.len() - sfx.len());
}
}
if path.len() == before {
break;
}
}
if path != u.path() {
u.set_path(&path);
changed = true;
}
}
if u.query().is_some() {
let pairs: Vec<(String, String)> = u
.query_pairs()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.collect();
let kept: Vec<&(String, String)> = pairs
.iter()
.filter(|(name, value)| {
!self.active.contains(&Rule::DropName {
host: host.clone(),
name: name.clone(),
}) && !self.active.contains(&Rule::DropPair {
host: host.clone(),
name: name.clone(),
value: value.clone(),
})
})
.collect();
if kept.len() != pairs.len() {
if kept.is_empty() {
u.set_query(None);
} else {
u.query_pairs_mut()
.clear()
.extend_pairs(kept.iter().map(|(k, v)| (k, v)));
}
changed = true;
}
}
changed
}
}
#[derive(Default)]
pub struct Learner {
exact: HashMap<u64, String>,
rules: Rules,
shapes: HashMap<u64, ShapeStats>,
pages: u32,
repeated: u32,
dups: u32,
pub(super) generation: u64,
}
const DEMOTE_MIN_FETCHED: u32 = 4;
const DEMOTE_SHARE: f32 = 0.75;
impl ShapeStats {
fn demoted(&self, _rest: (u32, u32)) -> bool {
self.fetched >= DEMOTE_MIN_FETCHED
&& self.repeated as f32 >= DEMOTE_SHARE * self.fetched as f32
}
}
pub(super) fn fnv(bytes: impl Iterator<Item = u8>) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in bytes {
h ^= u64::from(b);
h = h.wrapping_mul(0x0100_0000_01b3);
}
h
}
fn text_bytes(markdown: &str) -> impl Iterator<Item = u8> + '_ {
markdown
.bytes()
.filter(|b| !b.is_ascii() || b.is_ascii_alphanumeric())
}
impl Learner {
pub fn observe(&mut self, url: &Url, markdown: &str) -> Seen {
let exact = fnv(text_bytes(markdown));
let skeleton = fnv(text_bytes(markdown).map(|b| if b.is_ascii_digit() { b'0' } else { b }));
let need = rule_support(self.dups, self.pages);
let key = shape_key(url);
let was = self.demoted_key(key);
let stats = self.shapes.entry(key).or_default();
stats.fetched += 1;
self.pages += 1;
let seen = if let Some(first) = self.exact.get(&exact) {
if let Ok(f) = Url::parse(first) {
if f.as_str().len() <= url.as_str().len() {
self.rules.learn(url, &f, need);
} else {
self.rules.learn(&f, url, need);
}
}
self.dups += 1;
Seen::Duplicate(first.clone())
} else if !stats.skeletons.insert(skeleton) {
Seen::Templated
} else {
Seen::New
};
if seen == Seen::New || matches!(seen, Seen::Templated) {
self.exact.entry(exact).or_insert_with(|| url.to_string());
}
if seen != Seen::New {
stats.repeated += 1;
self.repeated += 1;
}
if !was && self.demoted_key(key) {
self.generation += 1;
}
seen
}
pub fn rewrite(&self, u: &mut Url) -> bool {
self.rules.apply(u)
}
pub fn demoted(&self, url: &Url) -> bool {
self.demoted_key(shape_key(url))
}
pub(super) fn demoted_key(&self, key: u64) -> bool {
self.shapes.get(&key).is_some_and(|s| {
s.demoted((
self.pages.saturating_sub(s.fetched),
self.repeated.saturating_sub(s.repeated),
))
})
}
}