Skip to main content

context_forge/analysis/
scoring.rs

1use std::collections::HashMap;
2
3#[cfg(feature = "parallel")]
4use rayon::prelude::*;
5
6use crate::analysis::classification::{ClassifiedPassage, ImportanceCategory};
7use crate::analysis::recurrence::RecurrenceResult;
8
9/// Default importance recency half-life: 7 days in seconds.
10const DEFAULT_IMPORTANCE_HALF_LIFE_SECS: f64 = 604_800.0;
11
12/// Configuration for importance scoring.
13#[derive(Debug, Clone)]
14#[non_exhaustive]
15pub struct ScoringConfig {
16    /// Weight multiplier for Corrective passages (default: 1.5).
17    pub corrective_weight: f64,
18    /// Weight multiplier for Stateful passages (default: 1.2).
19    pub stateful_weight: f64,
20    /// Weight multiplier for Decisive passages (default: 1.3).
21    pub decisive_weight: f64,
22    /// Weight multiplier for Reinforcing passages (default: 1.0).
23    pub reinforcing_weight: f64,
24    /// Weight for uncategorized passages (default: 0.5).
25    pub uncategorized_weight: f64,
26    /// Half-life in seconds for importance recency decay (default: 604800 = 7 days).
27    /// NOT the same as BM25's 72-hour half-life in core.
28    /// Values ≤ 0 or non-finite fall back to `DEFAULT_IMPORTANCE_HALF_LIFE_SECS`.
29    pub importance_half_life_secs: f64,
30}
31
32impl Default for ScoringConfig {
33    fn default() -> Self {
34        Self {
35            corrective_weight: 1.5,
36            stateful_weight: 1.2,
37            decisive_weight: 1.3,
38            reinforcing_weight: 1.0,
39            uncategorized_weight: 0.5,
40            importance_half_life_secs: DEFAULT_IMPORTANCE_HALF_LIFE_SECS,
41        }
42    }
43}
44
45/// A scored passage ready for context injection.
46#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub struct ImportanceSegment {
49    /// The passage text for injection.
50    pub text: String,
51    /// Importance categories assigned to the passage.
52    pub categories: Vec<ImportanceCategory>,
53    /// Combined importance score.
54    pub importance_score: f64,
55    /// The highest recurrence score among triggering terms.
56    pub recurrence_score: f64,
57    /// Category weight used (max across categories).
58    pub category_weight: f64,
59    /// Recency factor applied.
60    pub recency_factor: f64,
61    /// High-recurrence terms that triggered extraction.
62    pub triggering_terms: Vec<String>,
63    /// Maximum number of sessions any triggering term appears in.
64    pub session_frequency: usize,
65    /// Session ID this passage belongs to.
66    pub session_id: String,
67    /// Source entry timestamp (Unix seconds).
68    pub timestamp: i64,
69    /// Estimated token count (`text.len().div_ceil(4)`).
70    pub token_estimate: usize,
71}
72
73/// Score classified passages and produce ranked `ImportanceSegment` values.
74///
75/// 1. Filters out superseded passages
76/// 2. Computes importance score for each remaining passage
77/// 3. Sorts by score descending (ties broken by timestamp descending, then text ascending)
78///
79/// `recurrence_map`: maps term -> `RecurrenceResult` for score lookup.
80/// `now_timestamp`: current time in Unix seconds (for recency calculation).
81#[must_use]
82#[allow(
83    clippy::cast_precision_loss,
84    reason = "Timestamp/age conversion to f64 is intentional for scoring arithmetic"
85)]
86#[allow(
87    clippy::implicit_hasher,
88    reason = "HashMap default hasher is acceptable for in-memory scoring"
89)]
90pub fn score_passages(
91    classified: &[ClassifiedPassage],
92    recurrence_map: &HashMap<String, RecurrenceResult>,
93    config: &ScoringConfig,
94    now_timestamp: i64,
95) -> Vec<ImportanceSegment> {
96    let half_life =
97        if config.importance_half_life_secs.is_finite() && config.importance_half_life_secs > 0.0 {
98            config.importance_half_life_secs
99        } else {
100            DEFAULT_IMPORTANCE_HALF_LIFE_SECS
101        };
102
103    let score_one = |passage: &ClassifiedPassage| {
104        let recurrence_score = passage
105            .triggering_terms
106            .iter()
107            .filter_map(|term| recurrence_map.get(term))
108            .map(|result| result.recurrence_score)
109            .max_by(f64::total_cmp)
110            .unwrap_or(0.0);
111
112        let session_frequency = passage
113            .triggering_terms
114            .iter()
115            .filter_map(|term| recurrence_map.get(term))
116            .map(|result| result.session_frequency)
117            .max()
118            .unwrap_or(0);
119
120        let category_weight = category_weight(&passage.categories, config);
121
122        let age_seconds = (now_timestamp - passage.timestamp).max(0) as f64;
123        let recency_factor = recency_decay(age_seconds, half_life);
124        let importance_score = recurrence_score * category_weight * recency_factor;
125
126        ImportanceSegment {
127            text: passage.text.clone(),
128            categories: passage.categories.clone(),
129            importance_score,
130            recurrence_score,
131            category_weight,
132            recency_factor,
133            triggering_terms: passage.triggering_terms.clone(),
134            session_frequency,
135            session_id: passage.session_id.clone(),
136            timestamp: passage.timestamp,
137            token_estimate: estimate_tokens(&passage.text),
138        }
139    };
140
141    // NOTE: `superseded` means "superseded in at least one category" - a multi-category
142    // passage may still be the latest representative of another category. Per-category
143    // supersession tracking is a known improvement tracked separately.
144    #[cfg(feature = "parallel")]
145    let mut segments: Vec<ImportanceSegment> = classified
146        .par_iter()
147        .filter(|passage| !passage.superseded)
148        .map(score_one)
149        .collect();
150    #[cfg(not(feature = "parallel"))]
151    let mut segments: Vec<ImportanceSegment> = classified
152        .iter()
153        .filter(|passage| !passage.superseded)
154        .map(score_one)
155        .collect();
156
157    segments.sort_by(|left, right| {
158        right
159            .importance_score
160            .total_cmp(&left.importance_score)
161            .then_with(|| right.timestamp.cmp(&left.timestamp))
162            .then_with(|| left.text.cmp(&right.text))
163    });
164
165    segments
166}
167
168/// Greedy token-budget bin-packing by descending importance score.
169///
170/// Iterates segments in order (assumed pre-sorted by score descending).
171/// Skips any segment whose `token_estimate` exceeds remaining budget.
172/// Does NOT stop on first skip - continues looking for smaller segments that fit.
173#[must_use]
174pub fn pack_segments(
175    segments: &[ImportanceSegment],
176    token_budget: usize,
177) -> Vec<ImportanceSegment> {
178    let mut packed: Vec<ImportanceSegment> = Vec::new();
179    let mut remaining_budget = token_budget;
180
181    for segment in segments {
182        if segment.token_estimate > remaining_budget {
183            continue;
184        }
185
186        remaining_budget -= segment.token_estimate;
187        packed.push(segment.clone());
188    }
189
190    packed
191}
192
193fn category_weight(categories: &[ImportanceCategory], config: &ScoringConfig) -> f64 {
194    if categories.is_empty() {
195        return config.uncategorized_weight;
196    }
197
198    categories
199        .iter()
200        .map(|category| match category {
201            ImportanceCategory::Corrective => config.corrective_weight,
202            ImportanceCategory::Stateful => config.stateful_weight,
203            ImportanceCategory::Decisive => config.decisive_weight,
204            ImportanceCategory::Reinforcing => config.reinforcing_weight,
205        })
206        .max_by(f64::total_cmp)
207        .unwrap_or(config.uncategorized_weight)
208}
209
210/// Estimate token count from text length.
211///
212/// Mirrors `crates/core/src/engine.rs::estimate_tokens`.
213/// Do NOT import from core — analysis must remain layer-independent.
214fn estimate_tokens(text: &str) -> usize {
215    text.len().div_ceil(4)
216}
217
218/// Exponential recency decay.
219///
220/// Intentionally mirrors `crates/core/src/engine.rs::recency_decay`.
221/// Do NOT import from core — analysis must remain layer-independent.
222fn recency_decay(age_seconds: f64, half_life: f64) -> f64 {
223    0.5_f64.powf(age_seconds / half_life)
224}
225
226#[cfg(test)]
227mod tests {
228    use std::collections::HashMap;
229
230    use super::{pack_segments, score_passages, ImportanceSegment, ScoringConfig};
231    use crate::analysis::classification::{ClassifiedPassage, ImportanceCategory};
232    use crate::analysis::recurrence::RecurrenceResult;
233
234    const NOW: i64 = 2_000_000_000;
235
236    fn make_passage(
237        text: &str,
238        categories: Vec<ImportanceCategory>,
239        triggering_terms: Vec<&str>,
240        timestamp: i64,
241        superseded: bool,
242    ) -> ClassifiedPassage {
243        ClassifiedPassage {
244            text: text.to_string(),
245            categories,
246            triggering_terms: triggering_terms
247                .into_iter()
248                .map(std::string::ToString::to_string)
249                .collect(),
250            session_id: "session-1".to_string(),
251            timestamp,
252            entity: None,
253            value: None,
254            entity_pair: None,
255            superseded,
256        }
257    }
258
259    fn make_recurrence_map(values: &[(&str, f64)]) -> HashMap<String, RecurrenceResult> {
260        values
261            .iter()
262            .map(|(term, score)| {
263                (
264                    (*term).to_string(),
265                    RecurrenceResult {
266                        term: (*term).to_string(),
267                        session_frequency: 1,
268                        recurrence_score: *score,
269                    },
270                )
271            })
272            .collect()
273    }
274
275    fn default_config() -> ScoringConfig {
276        ScoringConfig::default()
277    }
278
279    fn make_segment(text: &str, token_estimate: usize) -> ImportanceSegment {
280        ImportanceSegment {
281            text: text.to_string(),
282            categories: Vec::new(),
283            importance_score: 0.0,
284            recurrence_score: 0.0,
285            category_weight: 0.5,
286            recency_factor: 1.0,
287            triggering_terms: Vec::new(),
288            session_frequency: 0,
289            session_id: "session-1".to_string(),
290            timestamp: NOW,
291            token_estimate,
292        }
293    }
294
295    #[test]
296    fn score_passages_returns_empty_for_empty_input() {
297        let recurrence_map = make_recurrence_map(&[("term", 0.5)]);
298        let result = score_passages(&[], &recurrence_map, &default_config(), NOW);
299        assert!(result.is_empty());
300    }
301
302    #[test]
303    fn score_passages_returns_empty_when_all_passages_are_superseded() {
304        let passages = vec![
305            make_passage("a", Vec::new(), vec!["x"], NOW, true),
306            make_passage("b", Vec::new(), vec!["x"], NOW, true),
307            make_passage("c", Vec::new(), vec!["x"], NOW, true),
308        ];
309
310        let recurrence_map = make_recurrence_map(&[("x", 0.8)]);
311        let result = score_passages(&passages, &recurrence_map, &default_config(), NOW);
312        assert!(result.is_empty());
313    }
314
315    #[test]
316    fn score_passages_single_uncategorized_uses_uncategorized_weight() {
317        let passage = make_passage("abcd", Vec::new(), vec!["term"], NOW, false);
318        let recurrence_map = make_recurrence_map(&[("term", 0.5)]);
319
320        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
321        assert_eq!(result.len(), 1);
322
323        let segment = &result[0];
324        assert!((segment.recurrence_score - 0.5).abs() < 1e-12);
325        assert!((segment.category_weight - 0.5).abs() < 1e-12);
326        assert!((segment.recency_factor - 1.0).abs() < 1e-12);
327        assert!((segment.importance_score - 0.25).abs() < 1e-12);
328    }
329
330    #[test]
331    fn score_passages_multi_category_uses_max_weight() {
332        let passage = make_passage(
333            "text",
334            vec![ImportanceCategory::Corrective, ImportanceCategory::Decisive],
335            vec!["term"],
336            NOW,
337            false,
338        );
339        let recurrence_map = make_recurrence_map(&[("term", 1.0)]);
340
341        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
342        let segment = &result[0];
343        assert!((segment.category_weight - 1.5).abs() < 1e-12);
344    }
345
346    #[test]
347    fn score_passages_multi_term_uses_max_recurrence() {
348        let passage = make_passage("text", Vec::new(), vec!["low", "high"], NOW, false);
349        let recurrence_map = make_recurrence_map(&[("low", 0.5), ("high", 0.667)]);
350
351        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
352        let segment = &result[0];
353        assert!((segment.recurrence_score - 0.667).abs() < 1e-12);
354    }
355
356    #[test]
357    fn score_passages_session_frequency_uses_max_across_terms() {
358        let passage = make_passage("text", Vec::new(), vec!["term_a", "term_b"], NOW, false);
359
360        let recurrence_map = HashMap::from([
361            (
362                "term_a".to_string(),
363                RecurrenceResult {
364                    term: "term_a".to_string(),
365                    session_frequency: 2,
366                    recurrence_score: 0.4,
367                },
368            ),
369            (
370                "term_b".to_string(),
371                RecurrenceResult {
372                    term: "term_b".to_string(),
373                    session_frequency: 5,
374                    recurrence_score: 0.6,
375                },
376            ),
377        ]);
378
379        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
380        assert_eq!(result.len(), 1);
381        assert_eq!(result[0].session_frequency, 5);
382    }
383
384    #[test]
385    fn score_passages_session_frequency_zero_when_terms_absent_from_map() {
386        let passage = make_passage("text", Vec::new(), vec!["missing_term"], NOW, false);
387        let recurrence_map = make_recurrence_map(&[("other", 0.7)]);
388
389        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
390        assert_eq!(result.len(), 1);
391        assert_eq!(result[0].session_frequency, 0);
392    }
393
394    #[test]
395    fn score_passages_missing_term_uses_zero_recurrence() {
396        let passage = make_passage("text", Vec::new(), vec!["foo"], NOW, false);
397        let recurrence_map = make_recurrence_map(&[("other", 0.9)]);
398
399        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
400        let segment = &result[0];
401        assert!((segment.recurrence_score - 0.0).abs() < 1e-12);
402        assert!((segment.importance_score - 0.0).abs() < 1e-12);
403    }
404
405    #[test]
406    fn score_passages_recency_factor_is_one_at_now() {
407        let passage = make_passage("text", Vec::new(), vec!["term"], NOW, false);
408        let recurrence_map = make_recurrence_map(&[("term", 1.0)]);
409
410        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
411        assert!((result[0].recency_factor - 1.0).abs() < 1e-12);
412    }
413
414    #[test]
415    fn score_passages_recency_factor_is_half_at_half_life() {
416        let half_life_secs = 604_800_i64;
417        let passage = make_passage(
418            "text",
419            Vec::new(),
420            vec!["term"],
421            NOW - half_life_secs,
422            false,
423        );
424        let recurrence_map = make_recurrence_map(&[("term", 1.0)]);
425
426        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
427        assert!((result[0].recency_factor - 0.5).abs() < 1e-12);
428    }
429
430    #[test]
431    fn score_passages_recency_factor_is_quarter_at_two_half_lives() {
432        let half_life_secs = 604_800_i64;
433        let passage = make_passage(
434            "text",
435            Vec::new(),
436            vec!["term"],
437            NOW - (2 * half_life_secs),
438            false,
439        );
440        let recurrence_map = make_recurrence_map(&[("term", 1.0)]);
441
442        let result = score_passages(&[passage], &recurrence_map, &default_config(), NOW);
443        assert!((result[0].recency_factor - 0.25).abs() < 1e-12);
444    }
445
446    #[test]
447    fn score_passages_uses_default_half_life_for_invalid_config() {
448        let passage = make_passage("text", Vec::new(), vec!["term"], NOW - 604_800, false);
449        let recurrence_map = make_recurrence_map(&[("term", 1.0)]);
450
451        let mut config = default_config();
452        config.importance_half_life_secs = 0.0;
453        let result_zero = score_passages(
454            std::slice::from_ref(&passage),
455            &recurrence_map,
456            &config,
457            NOW,
458        );
459        assert!((result_zero[0].recency_factor - 0.5).abs() < 1e-12);
460
461        config.importance_half_life_secs = f64::NAN;
462        let result_nan = score_passages(&[passage], &recurrence_map, &config, NOW);
463        assert!((result_nan[0].recency_factor - 0.5).abs() < 1e-12);
464    }
465
466    #[test]
467    fn score_passages_text_tiebreak_alphabetical_order() {
468        // Both passages have identical scores (0.0 × 0.5 × recency = 0.0) and same timestamp
469        // -> text ascending is the final tiebreaker
470        let a = make_passage("alpha", Vec::new(), vec!["absent"], NOW - 10, false);
471        let b = make_passage("beta", Vec::new(), vec!["absent"], NOW - 10, false);
472        let result = score_passages(&[b, a], &make_recurrence_map(&[]), &default_config(), NOW);
473
474        assert_eq!(result.len(), 2);
475        assert_eq!(result[0].text, "alpha");
476        assert_eq!(result[1].text, "beta");
477    }
478
479    #[test]
480    fn score_passages_timestamp_tiebreak_newer_wins() {
481        // Both passages score 0.0 (term absent from map) -> tie on score -> timestamp decides
482        let newer = make_passage("text", Vec::new(), vec!["absent"], NOW, false);
483        let older = make_passage("text", Vec::new(), vec!["absent"], NOW - 100, false);
484        let result = score_passages(
485            &[older, newer],
486            &make_recurrence_map(&[]),
487            &default_config(),
488            NOW,
489        );
490        assert_eq!(result.len(), 2);
491        assert_eq!(result[0].timestamp, NOW);
492        assert_eq!(result[1].timestamp, NOW - 100);
493    }
494
495    #[test]
496    fn pack_segments_basic_greedy_pack() {
497        let segments = vec![
498            make_segment("one", 100),
499            make_segment("two", 200),
500            make_segment("three", 150),
501        ];
502
503        let packed = pack_segments(&segments, 350);
504        assert_eq!(packed.len(), 2);
505        assert_eq!(packed[0].text, "one");
506        assert_eq!(packed[1].text, "two");
507    }
508
509    #[test]
510    fn pack_segments_skip_then_fit() {
511        let segments = vec![
512            make_segment("first", 300),
513            make_segment("second", 250),
514            make_segment("third", 50),
515        ];
516
517        let packed = pack_segments(&segments, 350);
518        assert_eq!(packed.len(), 2);
519        assert_eq!(packed[0].text, "first");
520        assert_eq!(packed[1].text, "third");
521    }
522
523    #[test]
524    fn pack_segments_zero_budget_returns_empty() {
525        let segments = vec![make_segment("one", 10)];
526        let packed = pack_segments(&segments, 0);
527        assert!(packed.is_empty());
528    }
529
530    #[test]
531    fn score_passages_mixed_superseded_keeps_only_active() {
532        let passages = vec![
533            make_passage("s1", Vec::new(), vec!["term"], NOW, true),
534            make_passage("a1", Vec::new(), vec!["term"], NOW, false),
535            make_passage("s2", Vec::new(), vec!["term"], NOW, true),
536            make_passage("a2", Vec::new(), vec!["term"], NOW - 1, false),
537            make_passage("a3", Vec::new(), vec!["term"], NOW - 2, false),
538        ];
539
540        let recurrence_map = make_recurrence_map(&[("term", 1.0)]);
541        let result = score_passages(&passages, &recurrence_map, &default_config(), NOW);
542
543        assert_eq!(result.len(), 3);
544        assert!(result.iter().all(|segment| !segment.text.starts_with('s')));
545    }
546
547    #[test]
548    fn score_passages_excludes_superseded_even_if_it_would_score_highest() {
549        let passages = vec![
550            make_passage("highest", Vec::new(), vec!["hot"], NOW, true),
551            make_passage("active", Vec::new(), vec!["cool"], NOW - 1_000, false),
552        ];
553        let recurrence_map = make_recurrence_map(&[("hot", 1.0), ("cool", 0.1)]);
554
555        let result = score_passages(&passages, &recurrence_map, &default_config(), NOW);
556        assert_eq!(result.len(), 1);
557        assert_eq!(result[0].text, "active");
558    }
559}