1mod adaptive;
11mod context;
12mod facet;
13pub mod furniture;
14mod harvest;
15mod model;
16mod queue;
17mod url_shape;
18mod weights;
19
20pub use adaptive::{Adaptive, MIN_RELEVANCE};
21pub use context::link_contexts;
22use context::prose_words;
23pub use harvest::{Harvest, subtree};
24pub use queue::{BestFirst, Offer, cluster_key};
25
26use facet::{FacetStats, Facets};
27use model::LinkModel;
28use pagesift_extract::Query;
29use url::Url;
30use url_shape::{Section, foreign_language_segment, is_english, language_portal, locator, section};
31use weights::TermWeights;
32
33const W_ANCHOR: f32 = 2.0;
35const W_LOCATOR: f32 = 1.5;
36const W_CONTEXT: f32 = 1.0;
37const W_PARENT: f32 = 0.5;
38const PHRASE_BONUS: f32 = 0.5;
40const DEPTH_DECAY: f32 = 0.9;
42const LANGUAGE_PORTAL_SCORE: f32 = 0.6;
44const SECTION_PRIOR: f32 = furniture::FURNITURE_FACTOR * (W_ANCHOR * 0.5 + PHRASE_BONUS);
48pub fn is_blind(score: f32) -> bool {
51 score <= SECTION_PRIOR
52}
53
54const SUBSITE_PRIOR: f32 = SECTION_PRIOR * 0.5;
56const FOREIGN_LANGUAGE_FACTOR: f32 = furniture::FURNITURE_FACTOR;
58pub const LIST_FREE: usize = 8;
61
62const RESCORE_DELTA: f32 = 0.05;
65
66#[derive(Clone, Copy, Debug, Default)]
69pub struct PageSignal {
70 title: u64,
71 body: u64,
72 facets: Facets,
73 factor: f32,
74 fixed: f32,
75 foreign: bool,
77}
78
79impl PageSignal {
80 pub fn fixed(relevance: f32) -> Self {
81 Self {
82 fixed: relevance,
83 ..Self::default()
84 }
85 }
86}
87
88#[derive(Clone, Copy, Debug)]
91enum Shape {
92 Plain,
93 Factor(f32),
94 Floor(f32),
95 Fixed(f32),
96}
97
98#[derive(Clone, Copy, Debug)]
100pub struct LinkSignal {
101 anchor: u64,
102 locator: u64,
103 context: u64,
104 parent: PageSignal,
105 shape: Shape,
106 depth: u8,
107 factor: f32,
109}
110
111impl LinkSignal {
112 pub fn fixed(score: f32) -> Self {
114 Self {
115 anchor: 0,
116 locator: 0,
117 context: 0,
118 parent: PageSignal::default(),
119 shape: Shape::Fixed(score),
120 depth: 0,
121 factor: 1.0,
122 }
123 }
124
125 pub fn scale(&mut self, factor: f32) {
127 self.factor *= factor;
128 }
129}
130
131pub struct Focus {
133 pub query: Query,
134 weights: TermWeights,
135 facets: FacetStats,
137 host: Option<String>,
139 english_home: bool,
141 scored_with: Vec<f32>,
143 english_seed: bool,
145 model: LinkModel,
147}
148
149impl Focus {
150 pub fn new(query: &str) -> Self {
151 let query = Query::new(query);
152 let weights = TermWeights::new(query.len());
153 let facets = FacetStats::new(query.len());
154 Self {
155 scored_with: weights
156 .current()
157 .iter()
158 .copied()
159 .chain(facets.shares())
160 .collect(),
161 query,
162 weights,
163 facets,
164 host: None,
165 english_home: false,
166 english_seed: false,
167 model: LinkModel::new([W_ANCHOR, W_LOCATOR, W_CONTEXT, W_PARENT, PHRASE_BONUS]),
168 }
169 }
170
171 pub fn note_seed_language(&mut self, lang: Option<&str>) {
173 self.english_seed = lang.is_some_and(is_english);
174 }
175
176 fn seeking_english(&self) -> bool {
183 self.english() && !self.english_home && !self.english_seed
184 }
185
186 fn english_target(&self) -> bool {
187 self.english() && (self.english_home || self.english_seed)
188 }
189
190 pub fn found_english_home(&mut self) {
192 self.english_home = true;
193 }
194
195 pub fn with_host(mut self, host: &str) -> Self {
197 self.host = Some(host.trim_start_matches("www.").to_ascii_lowercase());
198 self
199 }
200
201 fn same_host(&self, url: &Url) -> bool {
203 match (&self.host, url.host_str()) {
204 (Some(h), Some(u)) => u.trim_start_matches("www.").eq_ignore_ascii_case(h),
205 _ => true,
206 }
207 }
208
209 fn english_entry(&self, url: &Url, anchor: Option<&str>) -> bool {
213 if self.same_host(url) {
214 return language_portal(url, anchor);
215 }
216 let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
217 let same_site = self
218 .host
219 .as_deref()
220 .is_none_or(|h| pagesift_content::site_key(h) == pagesift_content::site_key(&host));
221 let english_label = matches!(
222 host.split('.').next().unwrap_or_default(),
223 "en" | "eng" | "english"
224 );
225 let root = url.path().split('/').filter(|s| !s.is_empty()).count() == 0;
226 same_site && english_label && root
227 }
228
229 pub fn observe_page(&mut self, page: &PageSignal) {
232 self.weights.observe(page.body);
233 self.facets.observe(&page.facets);
234 }
235
236 #[cfg(test)]
239 pub fn page_relevance(
240 &self,
241 url: &Url,
242 title: Option<&str>,
243 markdown: &str,
244 links: usize,
245 ) -> f32 {
246 self.relevance(&self.page_signal(url, title, markdown, links, None))
247 }
248
249 pub fn page_signal(
251 &self,
252 url: &Url,
253 title: Option<&str>,
254 markdown: &str,
255 links: usize,
256 lang: Option<&str>,
257 ) -> PageSignal {
258 let mut factor = 1.0;
259 if furniture::is_hub(links, prose_words(markdown)) {
260 factor *= furniture::HUB_FACTOR;
261 }
262 if furniture::is_furniture_url(url, &self.wanted()) {
263 factor *= furniture::FURNITURE_FACTOR;
264 }
265 let title = title.unwrap_or_default();
266 PageSignal {
267 title: self.query.hit_mask(title),
268 body: self.query.hit_mask(markdown),
269 facets: Facets::of(&self.query, title, &locator(url), markdown),
270 factor,
271 fixed: 0.0,
272 foreign: lang.is_some_and(|l| !is_english(l)),
273 }
274 }
275
276 pub fn relevance(&self, p: &PageSignal) -> f32 {
277 if p.title == 0 && p.body == 0 && p.facets.is_empty() {
279 return p.fixed;
280 }
281 p.fixed + p.factor * self.facets.relevance(&p.facets)
282 }
283
284 pub fn weights_moved(&mut self) -> bool {
287 let now: Vec<f32> = self
288 .weights
289 .current()
290 .iter()
291 .copied()
292 .chain(self.facets.shares())
293 .collect();
294 let moved = now
295 .iter()
296 .zip(&self.scored_with)
297 .any(|(a, b)| (a - b).abs() > RESCORE_DELTA);
298 if moved {
299 self.scored_with = now;
300 }
301 moved
302 }
303
304 fn wanted(&self) -> impl Fn(&str) -> bool {
306 let query = &self.query;
307 move |w: &str| query.hit_mask(w) != 0
308 }
309
310 pub fn link_score(
312 &self,
313 url: &Url,
314 anchor: Option<&str>,
315 context: Option<&str>,
316 parent: f32,
317 depth: usize,
318 ) -> f32 {
319 self.score(&self.link_signal(url, anchor, context, PageSignal::fixed(parent), depth))
320 }
321
322 pub fn link_signal(
325 &self,
326 url: &Url,
327 anchor: Option<&str>,
328 context: Option<&str>,
329 parent: PageSignal,
330 depth: usize,
331 ) -> LinkSignal {
332 let wanted = self.wanted();
333 let furniture = furniture::is_furniture_url(url, &wanted)
334 || anchor.is_some_and(|a| furniture::is_furniture_anchor(a, &wanted));
335 let shape = if furniture::is_dated_url(url) {
336 Shape::Factor(furniture::DATED_FACTOR)
338 } else if furniture {
339 Shape::Factor(furniture::FURNITURE_FACTOR)
340 } else if self.english() && foreign_language_segment(url) {
341 Shape::Factor(FOREIGN_LANGUAGE_FACTOR)
342 } else if self.seeking_english() && self.english_entry(url, anchor) {
343 Shape::Floor(LANGUAGE_PORTAL_SCORE)
344 } else {
345 match section(url, anchor) {
347 Some(Section::SameHost) if self.same_host(url) => Shape::Floor(SECTION_PRIOR),
348 Some(_) => Shape::Floor(SUBSITE_PRIOR),
349 None => Shape::Plain,
350 }
351 };
352 LinkSignal {
353 anchor: self.query.hit_mask(anchor.unwrap_or_default()),
354 locator: self.query.hit_mask(&locator(url)),
355 context: self.query.hit_mask(context.unwrap_or_default()),
356 parent,
357 shape,
358 depth: depth.min(64) as u8,
359 factor: 1.0,
360 }
361 }
362
363 pub fn score(&self, s: &LinkSignal) -> f32 {
366 let raw: f32 = self
367 .features(s)
368 .iter()
369 .zip(self.model.weights())
370 .map(|(x, w)| x * w)
371 .sum();
372 let raw = match s.shape {
373 Shape::Plain => raw,
374 Shape::Factor(f) => raw * f,
375 Shape::Floor(_) if s.parent.foreign && self.english_target() => raw,
376 Shape::Floor(f) => raw.max(f),
377 Shape::Fixed(v) => return v * s.factor,
378 };
379 raw * DEPTH_DECAY.powi(i32::from(s.depth)) * s.factor
380 }
381
382 fn features(&self, s: &LinkSignal) -> [f32; model::FEATURES] {
385 [
386 self.weights.coverage(s.anchor),
387 self.weights.coverage(s.locator),
388 self.weights.coverage(s.context),
389 self.relevance(&s.parent),
390 if s.anchor.count_ones() >= 2 { 1.0 } else { 0.0 },
391 ]
392 }
393
394 pub fn learn(&mut self, s: &LinkSignal, hit: bool) -> bool {
397 if matches!(s.shape, Shape::Fixed(_)) {
398 return false;
399 }
400 let x = self.features(s);
401 self.model.observe(&x, hit)
402 }
403
404 pub fn is_language_portal(&self, url: &Url) -> bool {
407 self.seeking_english() && self.english_entry(url, None)
408 }
409
410 pub fn alternate_score(&self, lang: &str, depth: usize) -> Option<f32> {
414 let english = lang == "en" || lang.starts_with("en-");
415 (self.seeking_english() && english && depth == 0).then_some(LANGUAGE_PORTAL_SCORE)
416 }
417
418 fn english(&self) -> bool {
420 self.query.terms().iter().all(|t| t.is_ascii())
421 }
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 fn u(s: &str) -> Url {
429 Url::parse(s).unwrap()
430 }
431
432 #[test]
433 fn relevant_links_score_higher_and_depth_decays() {
434 let f = Focus::new("refund policy");
435 let good = f.link_score(
436 &u("https://e.com/help/refund-policy"),
437 Some("Refund policy"),
438 None,
439 0.0,
440 1,
441 );
442 let ctx = f.link_score(
443 &u("https://e.com/help/x"),
444 Some("here"),
445 Some("Read our refund policy here"),
446 0.0,
447 1,
448 );
449 let bad = f.link_score(&u("https://e.com/team/x"), Some("Our team"), None, 0.0, 1);
450 assert!(good > ctx && ctx > bad, "{good} {ctx} {bad}");
451 assert_eq!(bad, 0.0);
452 let section = f.link_score(&u("https://e.com/docs/"), Some("Docs"), None, 0.0, 1);
454 let dated = f.link_score(
455 &u("https://e.com/blog/2026/04/refund-story/"),
456 Some("A refund story"),
457 None,
458 0.0,
459 1,
460 );
461 assert!(section > dated && dated > 0.0, "{section} {dated}");
462 assert!(section < ctx, "{section} {ctx}");
463 let subsite = f.link_score(&u("https://inf.e.com/"), Some("Informatics"), None, 0.0, 1);
465 let german = f.link_score(&u("https://e.com/de/"), Some("Deutsch"), None, 0.0, 1);
466 assert!(
467 subsite < section && subsite > german,
468 "{section} {subsite} {german}"
469 );
470 assert!(f.alternate_score("en-gb", 0).is_some());
471 assert!(f.alternate_score("de", 0).is_none());
472 assert!(
473 f.alternate_score("en", 1).is_none(),
474 "only the seed's alternate"
475 );
476 let en_section = f.link_score(
478 &u("https://e.com/en/studies/"),
479 Some("Studies"),
480 None,
481 0.0,
482 1,
483 );
484 assert!(
485 (en_section - section).abs() < 1e-6,
486 "{en_section} {section}"
487 );
488 let english_section = f.link_score(
489 &u("https://e.com/english/research/"),
490 Some("Research"),
491 None,
492 0.0,
493 1,
494 );
495 assert!(
496 (english_section - section).abs() < 1e-6,
497 "{english_section} {section}"
498 );
499 assert!(f.is_language_portal(&u("https://e.com/en/")));
500 assert!(f.is_language_portal(&u("https://e.com/en.html")));
501 assert!(!f.is_language_portal(&u("https://e.com/en/docs/")));
502 let g = Focus::new("api authentication").with_host("www.e.com");
504 let own = g.link_score(&u("https://e.com/en/"), Some("English"), None, 0.0, 1);
505 let dept = g.link_score(&u("https://cs.e.com/en/"), Some("English"), None, 0.0, 1);
506 assert!(own > dept && dept < section, "{own} {dept} {section}");
507 assert!(!g.is_language_portal(&u("https://cs.e.com/en/")));
508 assert!(g.is_language_portal(&u("https://en.e.com/")));
510 assert!(!g.is_language_portal(&u("https://en.other.com/")));
511 assert!(!g.is_language_portal(&u("https://en.e.com/news/")));
512 let mut g = g;
514 g.found_english_home();
515 let later = g.link_score(
516 &u("https://e.com/staff/en.html"),
517 Some("English"),
518 None,
519 0.0,
520 1,
521 );
522 assert!(later < own, "{later} {own}");
523 assert!(!g.is_language_portal(&u("https://e.com/en/")));
524 let api = Focus::new("api authentication");
526 let reference = api.link_score(
527 &u("https://e.com/docs/api/"),
528 Some("API reference"),
529 None,
530 0.0,
531 2,
532 );
533 let blog = api.link_score(
534 &u("https://e.com/blog/api-news/"),
535 Some("API news"),
536 None,
537 0.0,
538 2,
539 );
540 assert!(reference > 2.0 * blog, "{reference} {blog}");
541 let deep = f.link_score(
542 &u("https://e.com/help/refund-policy"),
543 Some("Refund policy"),
544 None,
545 0.0,
546 3,
547 );
548 assert!(deep < good);
549 let parent = f.link_score(&u("https://e.com/team/x"), Some("Our team"), None, 1.0, 1);
550 assert!(parent > bad);
551 let home = u("https://e.com/");
552 assert!(
553 f.page_relevance(&home, Some("Refund policy"), "refund", 3)
554 > f.page_relevance(&home, None, "refund", 3)
555 );
556 }
557
558 #[test]
559 fn furniture_and_hubs_are_demoted() {
560 let f = Focus::new("international students admission");
561 let content = f.link_score(
562 &u("https://e.edu/admissions/international/"),
563 Some("International students"),
564 None,
565 0.2,
566 2,
567 );
568 let news = f.link_score(
569 &u("https://e.edu/news/2026/05/story-1/"),
570 Some("International students celebrate the festival"),
571 None,
572 0.2,
573 1,
574 );
575 assert!(content > news, "{content} {news}");
576 let about = f.link_score(&u("https://e.edu/about/"), Some("About"), None, 0.9, 1);
577 let study = f.link_score(&u("https://e.edu/study/"), Some("Study"), None, 0.9, 1);
578 assert!(about < study);
579 let home = u("https://e.edu/");
580 let text = "international students admission";
581 let article = f.page_relevance(&home, None, text, 3);
582 let hub = f.page_relevance(&home, None, text, 40);
583 let list = "- [International students admission](https://e.edu/a)\n- [International students admission](https://e.edu/b)\n- [International students admission](https://e.edu/c)\n- [International students admission](https://e.edu/d)\n- [International students admission](https://e.edu/e)\n- [International students admission](https://e.edu/f)\n- [International students admission](https://e.edu/g)\n- [International students admission](https://e.edu/h)\n";
585 assert!(f.page_relevance(&home, None, list, 8) < f.page_relevance(&home, None, list, 0));
586 let dated = f.page_relevance(&u("https://e.edu/news/2026/"), None, text, 3);
587 assert!(hub < article && dated < article, "{article} {hub} {dated}");
588 }
589
590 #[test]
591 fn host_labels_count_and_common_terms_fade() {
592 let mut f = Focus::new("international students visa");
593 let intl = f.link_score(&u("https://international.e.edu/"), None, None, 0.0, 1);
594 let plain = f.link_score(&u("https://www.e.edu/"), None, None, 0.0, 1);
595 assert!(intl > plain && plain == 0.0, "{intl} {plain}");
596 for i in 0..6 {
598 let text = if i == 0 {
599 "students visa"
600 } else {
601 "students and more students"
602 };
603 let page = f.page_signal(&u("https://e.edu/p"), None, text, 0, None);
604 f.observe_page(&page);
605 }
606 let students = f.link_score(&u("https://e.edu/x"), Some("students"), None, 0.0, 1);
607 let visa = f.link_score(&u("https://e.edu/y"), Some("visa"), None, 0.0, 1);
608 assert!(visa > students, "{visa} {students}");
609 }
610}