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