1use std::collections::HashSet;
7
8use pagesift_api::types::AdaptiveOptions;
9use pagesift_extract::Query;
10
11pub const MIN_RELEVANCE: f32 = 0.05;
17pub const EVIDENCE_RELEVANCE: f32 = 0.15;
21
22const COLD_PATIENCE: usize = 2;
26
27const MIN_SOURCES: usize = 2;
30
31pub struct Adaptive {
33 opts: AdaptiveOptions,
34 evidence: Vec<usize>,
35 seen: HashSet<u64>,
37 collected: usize,
39 stale: usize,
40 cold: usize,
42 sources: usize,
44 last_fresh: bool,
46}
47
48impl Adaptive {
49 pub fn new(opts: AdaptiveOptions, terms: usize) -> Self {
50 Self {
51 opts,
52 evidence: vec![0; terms],
53 seen: HashSet::new(),
54 collected: 0,
55 stale: 0,
56 cold: 0,
57 sources: 0,
58 last_fresh: false,
59 }
60 }
61
62 pub fn coverage(&self) -> f64 {
64 if self.evidence.is_empty() {
65 return 0.0;
66 }
67 let target = self.opts.evidence_per_term.max(1) as f64;
68 self.evidence
69 .iter()
70 .map(|&e| (e as f64 / target).min(1.0))
71 .sum::<f64>()
72 / self.evidence.len() as f64
73 }
74
75 pub fn evidence(&self) -> &[usize] {
76 &self.evidence
77 }
78
79 pub fn has_found(&self) -> bool {
81 self.collected > 0
82 }
83
84 pub fn covered(&self) -> bool {
86 self.coverage() >= self.opts.coverage_threshold && self.sources >= MIN_SOURCES
87 }
88
89 pub fn last_fresh(&self) -> bool {
91 self.last_fresh
92 }
93
94 pub fn stale(&self) -> usize {
96 self.stale
97 }
98
99 pub fn forgive(&mut self) {
101 self.stale = 0;
102 }
103
104 pub fn observe(
112 &mut self,
113 query: &Query,
114 markdown: &str,
115 relevance: f32,
116 blind: bool,
117 ) -> Option<&'static str> {
118 self.last_fresh = false;
119 use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
120 if self.collected == 0 {
121 if blind && relevance < MIN_RELEVANCE {
122 self.cold += 1;
123 if self.cold >= COLD_PATIENCE * self.opts.patience.max(1) {
124 return Some("no_relevant_pages");
125 }
126 } else {
127 self.cold = 0;
128 }
129 }
130 if relevance < MIN_RELEVANCE {
131 if self.collected > 0 {
133 self.stale += 1;
134 return self.verdict();
135 }
136 return None;
137 }
138 if relevance < EVIDENCE_RELEVANCE {
139 return None;
140 }
141 let hasher = BuildHasherDefault::<DefaultHasher>::default();
142 let mut fresh = 0usize;
143 for block in markdown.split("\n\n") {
144 let block = block.trim();
145 if block.is_empty() {
146 continue;
147 }
148 let mask = query.hit_mask(block);
149 if mask == 0 || !self.seen.insert(hasher.hash_one(block)) {
150 continue;
151 }
152 for (i, e) in self.evidence.iter_mut().enumerate() {
153 if mask & (1 << i) != 0 {
154 *e += 1;
155 }
156 }
157 fresh += pagesift_content::estimate_tokens(block).max(1);
158 }
159 self.collected += fresh;
160 self.sources += usize::from(fresh > 0);
161 let gain = fresh as f64 / self.collected.max(1) as f64;
162 if gain < self.opts.min_gain || fresh == 0 {
163 self.stale += 1;
164 } else {
165 self.stale = 0;
166 self.last_fresh = true;
167 }
168 self.verdict()
169 }
170
171 fn verdict(&self) -> Option<&'static str> {
176 let covered = self.covered();
177 if covered && self.stale >= self.opts.patience.div_ceil(2).max(1) {
178 Some("coverage_reached")
179 } else if self.stale >= self.opts.patience.max(1) {
180 Some("saturated")
181 } else {
182 None
183 }
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 fn opts(threshold: f64, per_term: usize, patience: usize) -> AdaptiveOptions {
192 AdaptiveOptions {
193 coverage_threshold: threshold,
194 evidence_per_term: per_term,
195 patience,
196 min_gain: 0.05,
197 }
198 }
199
200 #[test]
201 fn adaptive_stops_once_covered_and_nothing_new_comes() {
202 let q = Query::new("refund policy");
203 let mut a = Adaptive::new(opts(1.0, 3, 5), q.len());
204 assert_eq!(
205 a.observe(
206 &q,
207 "Our refund policy.\n\nAnother refund policy line.",
208 1.0,
209 false
210 ),
211 None
212 );
213 assert!(a.coverage() < 1.0);
214 assert_eq!(
216 a.observe(&q, "The policy covers every refund.", 1.0, false),
217 None
218 );
219 assert!(a.coverage() >= 1.0);
220 assert_eq!(
221 a.observe(&q, "Refunds for gift cards: policy.", 1.0, false),
222 None
223 );
224 assert_eq!(a.observe(&q, "Our refund policy.", 1.0, false), None);
226 assert_eq!(a.observe(&q, "Our refund policy.", 1.0, false), None);
227 assert_eq!(
228 a.observe(&q, "Our refund policy.", 1.0, false),
229 Some("coverage_reached")
230 );
231 }
232
233 #[test]
234 fn adaptive_stops_when_pages_add_nothing_new() {
235 let q = Query::new("refund policy");
236 let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
237 assert_eq!(a.observe(&q, "Refund policy text.", 1.0, false), None);
238 assert_eq!(a.observe(&q, "Refund policy text.", 1.0, false), None);
239 assert_eq!(
240 a.observe(&q, "Refund policy text.", 1.0, false),
241 Some("saturated")
242 );
243 }
244
245 #[test]
246 fn irrelevant_pages_neither_count_as_evidence_nor_as_stale() {
247 let q = Query::new("refund policy");
248 let mut a = Adaptive::new(opts(1.0, 1, 1), q.len());
249 assert_eq!(
251 a.observe(&q, "A refund was mentioned in passing.", 0.03, false),
252 None
253 );
254 assert_eq!(a.evidence(), &[0, 0]);
255 assert_eq!(a.observe(&q, "Nothing here.", 0.0, false), None);
256 assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
258 assert_eq!(a.observe(&q, "The refund policy, again.", 0.9, false), None);
259 assert_eq!(
260 a.observe(&q, "Refund policy in full.", 0.9, false),
261 Some("coverage_reached")
262 );
263 let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
265 assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
266 assert_eq!(a.observe(&q, "Team page.", 0.0, false), None);
267 assert_eq!(a.observe(&q, "Refund mentioned once.", 0.1, false), None);
268 assert_eq!(a.evidence(), &[1, 1]);
269 assert_eq!(
270 a.observe(&q, "Careers page.", 0.0, false),
271 Some("saturated")
272 );
273 }
274
275 #[test]
276 fn gives_up_when_only_blind_links_are_left_and_nothing_was_found() {
277 let q = Query::new("refund policy");
278 let mut a = Adaptive::new(opts(1.0, 100, 2), q.len());
279 for _ in 0..10 {
281 assert_eq!(a.observe(&q, "Team page.", 0.0, false), None);
282 }
283 for _ in 0..3 {
285 assert_eq!(a.observe(&q, "About us.", 0.0, true), None);
286 }
287 assert_eq!(a.observe(&q, "Refund mentioned once.", 0.1, true), None);
288 for _ in 0..3 {
289 assert_eq!(a.observe(&q, "Careers.", 0.0, true), None);
290 }
291 assert_eq!(
292 a.observe(&q, "Contact.", 0.0, true),
293 Some("no_relevant_pages")
294 );
295 let mut a = Adaptive::new(opts(1.0, 100, 5), q.len());
297 assert_eq!(a.observe(&q, "Refund policy in full.", 0.9, false), None);
298 for _ in 0..4 {
299 assert_eq!(a.observe(&q, "Careers.", 0.0, true), None);
300 }
301 }
302}