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
9const DEFAULT_IMPORTANCE_HALF_LIFE_SECS: f64 = 604_800.0;
11
12#[derive(Debug, Clone)]
14#[non_exhaustive]
15pub struct ScoringConfig {
16 pub corrective_weight: f64,
18 pub stateful_weight: f64,
20 pub decisive_weight: f64,
22 pub reinforcing_weight: f64,
24 pub uncategorized_weight: f64,
26 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#[derive(Debug, Clone)]
47#[non_exhaustive]
48pub struct ImportanceSegment {
49 pub text: String,
51 pub categories: Vec<ImportanceCategory>,
53 pub importance_score: f64,
55 pub recurrence_score: f64,
57 pub category_weight: f64,
59 pub recency_factor: f64,
61 pub triggering_terms: Vec<String>,
63 pub session_frequency: usize,
65 pub session_id: String,
67 pub timestamp: i64,
69 pub token_estimate: usize,
71}
72
73#[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 #[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#[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
210fn estimate_tokens(text: &str) -> usize {
215 text.len().div_ceil(4)
216}
217
218fn 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 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 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}