use std::collections::HashSet;
use pagesift_api::types::AdaptiveOptions;
use pagesift_extract::Query;
pub const MIN_RELEVANCE: f32 = 0.05;
pub const EVIDENCE_RELEVANCE: f32 = 0.15;
const COLD_PATIENCE: usize = 2;
const MIN_SOURCES: usize = 2;
pub struct Adaptive {
opts: AdaptiveOptions,
evidence: Vec<usize>,
seen: HashSet<u64>,
collected: usize,
stale: usize,
cold: usize,
sources: usize,
last_fresh: bool,
}
impl Adaptive {
pub fn new(opts: AdaptiveOptions, terms: usize) -> Self {
Self {
opts,
evidence: vec![0; terms],
seen: HashSet::new(),
collected: 0,
stale: 0,
cold: 0,
sources: 0,
last_fresh: false,
}
}
pub fn coverage(&self) -> f64 {
if self.evidence.is_empty() {
return 0.0;
}
let target = self.opts.evidence_per_term.max(1) as f64;
self.evidence
.iter()
.map(|&e| (e as f64 / target).min(1.0))
.sum::<f64>()
/ self.evidence.len() as f64
}
pub fn evidence(&self) -> &[usize] {
&self.evidence
}
pub fn has_found(&self) -> bool {
self.collected > 0
}
pub fn covered(&self) -> bool {
self.coverage() >= self.opts.coverage_threshold && self.sources >= MIN_SOURCES
}
pub fn last_fresh(&self) -> bool {
self.last_fresh
}
pub fn stale(&self) -> usize {
self.stale
}
pub fn forgive(&mut self) {
self.stale = 0;
}
pub fn observe(
&mut self,
query: &Query,
markdown: &str,
relevance: f32,
blind: bool,
) -> Option<&'static str> {
self.last_fresh = false;
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
if self.collected == 0 {
if blind && relevance < MIN_RELEVANCE {
self.cold += 1;
if self.cold >= COLD_PATIENCE * self.opts.patience.max(1) {
return Some("no_relevant_pages");
}
} else {
self.cold = 0;
}
}
if relevance < MIN_RELEVANCE {
if self.collected > 0 {
self.stale += 1;
return self.verdict();
}
return None;
}
if relevance < EVIDENCE_RELEVANCE {
return None;
}
let hasher = BuildHasherDefault::<DefaultHasher>::default();
let mut fresh = 0usize;
for block in markdown.split("\n\n") {
let block = block.trim();
if block.is_empty() {
continue;
}
let mask = query.hit_mask(block);
if mask == 0 || !self.seen.insert(hasher.hash_one(block)) {
continue;
}
for (i, e) in self.evidence.iter_mut().enumerate() {
if mask & (1 << i) != 0 {
*e += 1;
}
}
fresh += pagesift_content::estimate_tokens(block).max(1);
}
self.collected += fresh;
self.sources += usize::from(fresh > 0);
let gain = fresh as f64 / self.collected.max(1) as f64;
if gain < self.opts.min_gain || fresh == 0 {
self.stale += 1;
} else {
self.stale = 0;
self.last_fresh = true;
}
self.verdict()
}
fn verdict(&self) -> Option<&'static str> {
let covered = self.covered();
if covered && self.stale >= self.opts.patience.div_ceil(2).max(1) {
Some("coverage_reached")
} else if self.stale >= self.opts.patience.max(1) {
Some("saturated")
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opts(threshold: f64, per_term: usize, patience: usize) -> AdaptiveOptions {
AdaptiveOptions {
coverage_threshold: threshold,
evidence_per_term: per_term,
patience,
min_gain: 0.05,
}
}
#[test]
fn adaptive_stops_once_covered_and_nothing_new_comes() {
let q = Query::new("refund policy");
let mut a = Adaptive::new(opts(1.0, 3, 5), q.len());
assert_eq!(
a.observe(
&q,
"Our refund policy.\n\nAnother refund policy line.",
1.0,
false
),
None
);
assert!(a.coverage() < 1.0);
assert_eq!(
a.observe(&q, "The policy covers every refund.", 1.0, false),
None
);
assert!(a.coverage() >= 1.0);
assert_eq!(
a.observe(&q, "Refunds for gift cards: policy.", 1.0, false),
None
);
assert_eq!(a.observe(&q, "Our refund policy.", 1.0, false), None);
assert_eq!(a.observe(&q, "Our refund policy.", 1.0, false), None);
assert_eq!(
a.observe(&q, "Our refund policy.", 1.0, false),
Some("coverage_reached")
);
}
#[test]
fn adaptive_stops_when_pages_add_nothing_new() {
let q = Query::new("refund policy");
let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
assert_eq!(a.observe(&q, "Refund policy text.", 1.0, false), None);
assert_eq!(a.observe(&q, "Refund policy text.", 1.0, false), None);
assert_eq!(
a.observe(&q, "Refund policy text.", 1.0, false),
Some("saturated")
);
}
#[test]
fn irrelevant_pages_neither_count_as_evidence_nor_as_stale() {
let q = Query::new("refund policy");
let mut a = Adaptive::new(opts(1.0, 1, 1), q.len());
assert_eq!(
a.observe(&q, "A refund was mentioned in passing.", 0.03, false),
None
);
assert_eq!(a.evidence(), &[0, 0]);
assert_eq!(a.observe(&q, "Nothing here.", 0.0, false), None);
assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
assert_eq!(a.observe(&q, "The refund policy, again.", 0.9, false), None);
assert_eq!(
a.observe(&q, "Refund policy in full.", 0.9, false),
Some("coverage_reached")
);
let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
assert_eq!(a.observe(&q, "Team page.", 0.0, false), None);
assert_eq!(a.observe(&q, "Refund mentioned once.", 0.1, false), None);
assert_eq!(a.evidence(), &[1, 1]);
assert_eq!(
a.observe(&q, "Careers page.", 0.0, false),
Some("saturated")
);
}
#[test]
fn gives_up_when_only_blind_links_are_left_and_nothing_was_found() {
let q = Query::new("refund policy");
let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
for _ in 0..10 {
assert_eq!(a.observe(&q, "Team page.", 0.0, false), None);
}
for _ in 0..3 {
assert_eq!(a.observe(&q, "About us.", 0.0, true), None);
}
assert_eq!(a.observe(&q, "Refund mentioned once.", 0.1, true), None);
for _ in 0..3 {
assert_eq!(a.observe(&q, "Careers.", 0.0, true), None);
}
assert_eq!(
a.observe(&q, "Contact.", 0.0, true),
Some("no_relevant_pages")
);
let mut a = Adaptive::new(opts(1.0, 100, 5), q.len());
assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
for _ in 0..4 {
assert_eq!(a.observe(&q, "Careers.", 0.0, true), None);
}
}
}