1use std::collections::{HashMap, HashSet};
2
3#[cfg(feature = "parallel")]
4use rayon::prelude::*;
5
6use crate::analysis::lexicon::Lexicons;
7
8#[derive(Debug, Clone)]
10#[non_exhaustive]
11pub struct ClassificationConfig {
12 pub corrective_proximity: usize,
14 pub reinforcing_min_sessions: usize,
16 pub reinforcing_overlap_threshold: f64,
19}
20
21impl Default for ClassificationConfig {
22 fn default() -> Self {
23 Self {
24 corrective_proximity: 5,
25 reinforcing_min_sessions: 3,
26 reinforcing_overlap_threshold: 0.6,
27 }
28 }
29}
30
31#[derive(Debug, Clone)]
34pub struct PassageContext {
35 pub passage_text: String,
37 pub triggering_terms: Vec<String>,
39 pub session_id: String,
41 pub timestamp: i64,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum ImportanceCategory {
49 Corrective,
51 Stateful,
53 Decisive,
55 Reinforcing,
57}
58
59#[derive(Debug, Clone)]
63#[non_exhaustive]
64pub struct ClassifiedPassage {
65 pub text: String,
67 pub categories: Vec<ImportanceCategory>,
69 pub triggering_terms: Vec<String>,
71 pub session_id: String,
73 pub timestamp: i64,
75 pub entity: Option<String>,
77 pub value: Option<String>,
79 pub entity_pair: Option<(String, String)>,
81 pub superseded: bool,
89}
90
91#[must_use]
93#[allow(
94 clippy::cast_precision_loss,
95 reason = "Overlap ratio uses f64 by design for threshold comparisons"
96)]
97pub fn classify_passages(
98 passages: &[PassageContext],
99 lexicons: &Lexicons,
100 config: &ClassificationConfig,
101) -> Vec<ClassifiedPassage> {
102 if passages.is_empty() {
103 return Vec::new();
104 }
105
106 let classify_one = |passage: &PassageContext| {
107 let mut categories: Vec<ImportanceCategory> = Vec::new();
108
109 if is_corrective(passage, lexicons, config) {
110 categories.push(ImportanceCategory::Corrective);
111 }
112
113 let state_match = detect_stateful(passage, lexicons);
114 let (entity, value) = if let Some((state_entity, state_value)) = state_match {
115 categories.push(ImportanceCategory::Stateful);
116 (Some(state_entity), Some(state_value))
117 } else {
118 (None, None)
119 };
120
121 let entity_pair = if is_decisive(passage, lexicons) {
122 categories.push(ImportanceCategory::Decisive);
123 extract_entity_pair(passage)
124 } else {
125 None
126 };
127
128 ClassifiedPassage {
129 text: passage.passage_text.clone(),
130 categories,
131 triggering_terms: passage.triggering_terms.clone(),
132 session_id: passage.session_id.clone(),
133 timestamp: passage.timestamp,
134 entity,
135 value,
136 entity_pair,
137 superseded: false,
138 }
139 };
140
141 #[cfg(feature = "parallel")]
142 let mut classified: Vec<ClassifiedPassage> = passages.par_iter().map(classify_one).collect();
143 #[cfg(not(feature = "parallel"))]
144 let mut classified: Vec<ClassifiedPassage> = passages.iter().map(classify_one).collect();
145
146 apply_reinforcing(&mut classified, lexicons, config);
147 apply_supersession(&mut classified);
148 classified
149}
150
151fn is_corrective(
152 passage: &PassageContext,
153 lexicons: &Lexicons,
154 config: &ClassificationConfig,
155) -> bool {
156 if passage
157 .passage_text
158 .trim_end()
159 .trim_end_matches(['"', '\'', ')', ']', '}'])
160 .ends_with('?')
161 {
162 return false;
163 }
164
165 let words = tokenize_words(&passage.passage_text.to_lowercase());
166 if words.is_empty() {
167 return false;
168 }
169
170 let negation_positions: Vec<usize> = lexicons
171 .negation_markers
172 .iter()
173 .flat_map(|marker| find_marker_positions(&words, &marker.to_lowercase()))
174 .collect();
175
176 if negation_positions.is_empty() {
177 return false;
178 }
179
180 let triggering_positions: Vec<usize> = passage
181 .triggering_terms
182 .iter()
183 .flat_map(|term| find_term_positions(&words, &term.to_lowercase()))
184 .collect();
185
186 if triggering_positions.is_empty() {
187 return false;
188 }
189
190 for negation_pos in &negation_positions {
191 for term_pos in &triggering_positions {
192 if usize::abs_diff(*negation_pos, *term_pos) <= config.corrective_proximity {
193 return true;
194 }
195 }
196 }
197
198 false
199}
200
201fn detect_stateful(passage: &PassageContext, lexicons: &Lexicons) -> Option<(String, String)> {
202 let passage_lower = passage.passage_text.to_lowercase();
203 let mut matches: Vec<(usize, usize, String)> = Vec::new();
204 for operator in &lexicons.state_operators {
205 let operator_lower = operator.to_lowercase();
206 for (start_index, _) in passage_lower.match_indices(&operator_lower) {
207 let end_index = start_index + operator_lower.len();
208 let operator_starts_with_alnum = operator_lower.as_bytes()[0].is_ascii_alphanumeric();
209 let operator_ends_with_alnum =
210 operator_lower.as_bytes()[operator_lower.len() - 1].is_ascii_alphanumeric();
211
212 let has_start_boundary = !operator_starts_with_alnum
213 || start_index == 0
214 || !passage_lower.as_bytes()[start_index - 1].is_ascii_alphanumeric();
215 let has_end_boundary = !operator_ends_with_alnum
216 || end_index >= passage_lower.len()
217 || !passage_lower.as_bytes()[end_index].is_ascii_alphanumeric();
218
219 if has_start_boundary && has_end_boundary {
220 matches.push((start_index, operator_lower.len(), operator_lower.clone()));
221 }
222 }
223 }
224
225 matches.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| right.1.cmp(&left.1)));
226
227 for (start_index, operator_len, _) in matches {
228 let before = &passage_lower[..start_index];
229 let after = &passage_lower[start_index + operator_len..];
230
231 let entity_words = tokenize_words(before);
232 let value_words = tokenize_words(after);
233
234 if entity_words.is_empty() || value_words.is_empty() {
235 continue;
236 }
237
238 let entity_start = entity_words.len().saturating_sub(4);
239 let entity = entity_words[entity_start..].join(" ");
240 if !contains_ascii_alpha(&entity) {
241 continue;
242 }
243
244 let value = value_words
245 .into_iter()
246 .take(6)
247 .collect::<Vec<String>>()
248 .join(" ");
249 if value.is_empty() {
250 continue;
251 }
252
253 return Some((entity, value));
254 }
255
256 None
257}
258
259fn is_decisive(passage: &PassageContext, lexicons: &Lexicons) -> bool {
260 let passage_lower = passage.passage_text.to_lowercase();
261 let has_comparison = has_marker_in_passage(&passage_lower, &lexicons.comparison_markers);
262 let has_causal = has_marker_in_passage(&passage_lower, &lexicons.causal_connectors);
263
264 has_comparison && has_causal
265}
266
267fn has_marker_in_passage(passage_lower: &str, markers: &[String]) -> bool {
268 let words: Vec<String> = passage_lower
269 .split_whitespace()
270 .map(clean_for_comparison)
271 .filter(|word| !word.is_empty())
272 .collect();
273
274 for marker in markers {
275 let marker_lower = marker.to_lowercase();
276 if marker_lower.contains(' ') {
277 if passage_lower.contains(&marker_lower) {
278 return true;
279 }
280 } else if words.iter().any(|word| word == &marker_lower) {
281 return true;
282 }
283 }
284
285 false
286}
287
288fn extract_entity_pair(passage: &PassageContext) -> Option<(String, String)> {
289 let mut capitalized: Vec<String> = Vec::new();
290 let mut at_sentence_start = true;
291
292 for raw_word in passage.passage_text.split_whitespace() {
293 let cleaned = clean_token(raw_word);
294 if cleaned.is_empty() {
295 at_sentence_start = raw_word_ends_sentence(raw_word);
296 continue;
297 }
298
299 if !at_sentence_start
300 && cleaned
301 .chars()
302 .next()
303 .is_some_and(|character| character.is_ascii_uppercase())
304 && !capitalized.contains(&cleaned)
305 {
306 capitalized.push(cleaned.clone());
307 }
308
309 at_sentence_start = raw_word_ends_sentence(raw_word);
310 }
311
312 if capitalized.len() >= 2 {
313 return Some((capitalized[0].clone(), capitalized[1].clone()));
314 }
315
316 let mut proxies: Vec<String> = Vec::new();
317 for term in &passage.triggering_terms {
318 let trimmed = term.trim();
319 if trimmed.is_empty() {
320 continue;
321 }
322 if !proxies.contains(&trimmed.to_string()) {
323 proxies.push(trimmed.to_string());
324 }
325 }
326
327 if proxies.len() >= 2 {
328 return Some((proxies[0].clone(), proxies[1].clone()));
329 }
330
331 None
332}
333
334#[allow(
335 clippy::cast_precision_loss,
336 clippy::implicit_hasher,
337 reason = "Threshold math uses f64 and HashMap defaults are acceptable for local grouping"
338)]
339fn apply_reinforcing(
340 passages: &mut [ClassifiedPassage],
341 lexicons: &Lexicons,
342 config: &ClassificationConfig,
343) {
344 let confirmation_sets: Vec<HashSet<String>> = passages
345 .iter()
346 .map(|passage| confirmation_tokens_in_passage(&passage.text, lexicons))
347 .collect();
348
349 let candidate_indices: Vec<usize> = confirmation_sets
350 .iter()
351 .enumerate()
352 .filter_map(|(index, tokens)| if tokens.is_empty() { None } else { Some(index) })
353 .collect();
354
355 if candidate_indices.len() < config.reinforcing_min_sessions {
356 return;
357 }
358
359 let mut graph: HashMap<usize, Vec<usize>> = HashMap::new();
360 for index in &candidate_indices {
361 graph.insert(*index, Vec::new());
362 }
363
364 for left_index in 0..candidate_indices.len() {
365 for right_index in (left_index + 1)..candidate_indices.len() {
366 let left = candidate_indices[left_index];
367 let right = candidate_indices[right_index];
368
369 if !shares_confirmation_token(&confirmation_sets[left], &confirmation_sets[right]) {
370 continue;
371 }
372
373 let overlap = bigram_overlap_ratio(&passages[left].text, &passages[right].text);
374 if overlap > config.reinforcing_overlap_threshold {
375 if let Some(neighbors) = graph.get_mut(&left) {
376 neighbors.push(right);
377 }
378 if let Some(neighbors) = graph.get_mut(&right) {
379 neighbors.push(left);
380 }
381 }
382 }
383 }
384
385 let mut visited: HashSet<usize> = HashSet::new();
386 for index in candidate_indices {
387 if visited.contains(&index) {
388 continue;
389 }
390
391 let mut stack: Vec<usize> = vec![index];
392 let mut component: Vec<usize> = Vec::new();
393
394 while let Some(current) = stack.pop() {
395 if !visited.insert(current) {
396 continue;
397 }
398
399 component.push(current);
400 if let Some(neighbors) = graph.get(¤t) {
401 for neighbor in neighbors {
402 if !visited.contains(neighbor) {
403 stack.push(*neighbor);
404 }
405 }
406 }
407 }
408
409 let distinct_sessions: HashSet<&str> = component
410 .iter()
411 .map(|component_index| passages[*component_index].session_id.as_str())
412 .collect();
413
414 if distinct_sessions.len() >= config.reinforcing_min_sessions {
415 for component_index in component {
416 if !passages[component_index]
417 .categories
418 .contains(&ImportanceCategory::Reinforcing)
419 {
420 passages[component_index]
421 .categories
422 .push(ImportanceCategory::Reinforcing);
423 }
424 }
425 }
426 }
427}
428
429fn apply_supersession(passages: &mut [ClassifiedPassage]) {
430 let mut stateful_groups: HashMap<String, Vec<usize>> = HashMap::new();
431 for (index, passage) in passages.iter().enumerate() {
432 if passage.categories.contains(&ImportanceCategory::Stateful) {
433 if let Some(entity) = &passage.entity {
434 let key = entity.trim().to_lowercase();
435 if !key.is_empty() {
436 stateful_groups.entry(key).or_default().push(index);
437 }
438 }
439 }
440 }
441
442 mark_group_superseded(passages, stateful_groups.values());
443
444 let mut decisive_groups: HashMap<(String, String), Vec<usize>> = HashMap::new();
445 for (index, passage) in passages.iter().enumerate() {
446 if passage.categories.contains(&ImportanceCategory::Decisive) {
447 if let Some((left, right)) = &passage.entity_pair {
448 let mut pair = [left.trim().to_lowercase(), right.trim().to_lowercase()];
449 pair.sort();
450 decisive_groups
451 .entry((pair[0].clone(), pair[1].clone()))
452 .or_default()
453 .push(index);
454 }
455 }
456 }
457
458 mark_group_superseded(passages, decisive_groups.values());
459}
460
461fn mark_group_superseded<'a>(
462 passages: &mut [ClassifiedPassage],
463 groups: impl Iterator<Item = &'a Vec<usize>>,
464) {
465 for indices in groups {
466 if indices.len() <= 1 {
467 continue;
468 }
469
470 let latest = indices.iter().copied().max_by(|left, right| {
471 passages[*left]
472 .timestamp
473 .cmp(&passages[*right].timestamp)
474 .then_with(|| left.cmp(right))
476 });
477
478 if let Some(latest_index) = latest {
479 for index in indices {
480 if *index != latest_index {
481 passages[*index].superseded = true;
482 }
483 }
484 }
485 }
486}
487
488fn confirmation_tokens_in_passage(text: &str, lexicons: &Lexicons) -> HashSet<String> {
489 let words = tokenize_words(&text.to_lowercase());
490 let mut matched: HashSet<String> = HashSet::new();
491
492 for token in &lexicons.confirmation_tokens {
493 let token_lower = token.to_lowercase();
494 if words.iter().any(|word| word == &token_lower) {
495 matched.insert(token_lower);
496 }
497 }
498
499 matched
500}
501
502fn shares_confirmation_token(left: &HashSet<String>, right: &HashSet<String>) -> bool {
503 left.iter().any(|token| right.contains(token))
504}
505
506#[allow(
507 clippy::cast_precision_loss,
508 reason = "Overlap ratio uses f64 thresholding by configuration contract"
509)]
510fn bigram_overlap_ratio(text_a: &str, text_b: &str) -> f64 {
511 let bigrams_a = text_bigrams(text_a);
512 let bigrams_b = text_bigrams(text_b);
513
514 let min_count = bigrams_a.len().min(bigrams_b.len());
515 if min_count == 0 {
516 return 0.0;
517 }
518
519 let intersection_count = bigrams_a.intersection(&bigrams_b).count();
520 intersection_count as f64 / min_count as f64
521}
522
523fn text_bigrams(text: &str) -> HashSet<(String, String)> {
524 let words: Vec<String> = text
525 .split_whitespace()
526 .map(|word| {
527 word.to_lowercase()
528 .chars()
529 .filter(char::is_ascii_alphanumeric)
530 .collect::<String>()
531 })
532 .filter(|word| !word.is_empty())
533 .collect();
534
535 let mut bigrams: HashSet<(String, String)> = HashSet::new();
536 for pair in words.windows(2) {
537 bigrams.insert((pair[0].clone(), pair[1].clone()));
538 }
539
540 bigrams
541}
542
543fn find_marker_positions(words: &[String], marker: &str) -> Vec<usize> {
544 let marker_words: Vec<&str> = marker.split_whitespace().collect();
545 if marker_words.is_empty() {
546 return Vec::new();
547 }
548
549 if marker_words.len() == 1 {
550 return words
551 .iter()
552 .enumerate()
553 .filter_map(|(index, word)| {
554 if *word == marker_words[0] {
555 Some(index)
556 } else {
557 None
558 }
559 })
560 .collect();
561 }
562
563 find_phrase_positions(words, &marker_words)
564}
565
566fn find_term_positions(words: &[String], term: &str) -> Vec<usize> {
567 let term_words: Vec<&str> = term.split_whitespace().collect();
568 if term_words.is_empty() {
569 return Vec::new();
570 }
571
572 if term_words.len() == 1 {
573 return words
574 .iter()
575 .enumerate()
576 .filter_map(|(index, word)| {
577 if *word == term_words[0] {
578 Some(index)
579 } else {
580 None
581 }
582 })
583 .collect();
584 }
585
586 find_phrase_positions(words, &term_words)
587}
588
589fn find_phrase_positions(words: &[String], phrase_words: &[&str]) -> Vec<usize> {
590 if words.len() < phrase_words.len() {
591 return Vec::new();
592 }
593
594 let mut positions: Vec<usize> = Vec::new();
595 for index in 0..=(words.len() - phrase_words.len()) {
596 let mut matches = true;
597 for (offset, phrase_word) in phrase_words.iter().enumerate() {
598 if words[index + offset] != *phrase_word {
599 matches = false;
600 break;
601 }
602 }
603 if matches {
604 positions.push(index);
605 }
606 }
607
608 positions
609}
610
611fn tokenize_words(text: &str) -> Vec<String> {
612 text.split_whitespace()
613 .map(clean_token)
614 .filter(|token| !token.is_empty())
615 .collect()
616}
617
618fn clean_token(token: &str) -> String {
619 token
620 .trim_matches(|character: char| {
621 !character.is_ascii_alphanumeric() && !matches!(character, '\'' | '-' | '_' | ':' | '=')
622 })
623 .trim_matches([':', '='])
624 .to_string()
625}
626
627fn clean_for_comparison(token: &str) -> String {
628 clean_token(token).to_lowercase()
629}
630
631fn contains_ascii_alpha(text: &str) -> bool {
632 text.chars()
633 .any(|character| character.is_ascii_alphabetic())
634}
635
636fn raw_word_ends_sentence(raw_word: &str) -> bool {
637 raw_word
638 .trim_end_matches(['"', '\'', ')', ']'])
639 .ends_with(['.', '!', '?'])
640}
641
642#[cfg(test)]
643mod tests {
644 use super::{classify_passages, ClassificationConfig, ImportanceCategory, PassageContext};
645 use crate::analysis::lexicon::Lexicons;
646
647 fn passage(text: &str, terms: &[&str], session_id: &str, timestamp: i64) -> PassageContext {
648 PassageContext {
649 passage_text: text.to_string(),
650 triggering_terms: terms.iter().map(|term| (*term).to_string()).collect(),
651 session_id: session_id.to_string(),
652 timestamp,
653 }
654 }
655
656 fn default_config() -> ClassificationConfig {
657 ClassificationConfig::default()
658 }
659
660 fn has_category(passage: &super::ClassifiedPassage, category: ImportanceCategory) -> bool {
661 passage.categories.contains(&category)
662 }
663
664 #[test]
665 fn empty_passages_returns_empty_vec() {
666 let lexicons = Lexicons::default();
667 let config = default_config();
668
669 let result = classify_passages(&[], &lexicons, &config);
670 assert!(result.is_empty());
671 }
672
673 #[test]
674 fn no_category_match_has_empty_categories() {
675 let lexicons = Lexicons::default();
676 let config = default_config();
677 let inputs = vec![passage(
678 "General context discussion about implementation details.",
679 &["context"],
680 "session-a",
681 10,
682 )];
683
684 let result = classify_passages(&inputs, &lexicons, &config);
685 assert_eq!(result.len(), 1);
686 assert!(result[0].categories.is_empty());
687 assert!(!result[0].superseded);
688 }
689
690 #[test]
691 fn corrective_detection_matches_negation_near_trigger_term() {
692 let lexicons = Lexicons::default();
693 let config = default_config();
694 let inputs = vec![passage(
695 "We should not enable cache in production.",
696 &["cache"],
697 "session-a",
698 10,
699 )];
700
701 let result = classify_passages(&inputs, &lexicons, &config);
702 assert!(has_category(&result[0], ImportanceCategory::Corrective));
703 }
704
705 #[test]
706 fn corrective_detection_skips_questions() {
707 let lexicons = Lexicons::default();
708 let config = default_config();
709 let inputs = vec![passage(
710 "Should we not use cache?",
711 &["cache"],
712 "session-a",
713 10,
714 )];
715
716 let result = classify_passages(&inputs, &lexicons, &config);
717 assert!(!has_category(&result[0], ImportanceCategory::Corrective));
718 }
719
720 #[test]
721 fn corrective_detection_proximity_boundary() {
722 let lexicons = Lexicons::default();
723 let config = ClassificationConfig {
724 corrective_proximity: 3,
725 ..ClassificationConfig::default()
726 };
727
728 let within = passage("not alpha beta cache", &["cache"], "session-a", 10);
729 let beyond = passage("not alpha beta gamma cache", &["cache"], "session-b", 20);
730
731 let result = classify_passages(&[within, beyond], &lexicons, &config);
732 assert!(has_category(&result[0], ImportanceCategory::Corrective));
733 assert!(!has_category(&result[1], ImportanceCategory::Corrective));
734 }
735
736 #[test]
737 fn stateful_detection_matches_explicit_operators() {
738 let lexicons = Lexicons::default();
739 let config = default_config();
740 let inputs = vec![
741 passage("Cache mode set to writeback", &["cache"], "session-a", 10),
742 passage("Timeout changed to 30", &["timeout"], "session-a", 11),
743 passage("PORT = 8080", &["port"], "session-a", 12),
744 ];
745
746 let result = classify_passages(&inputs, &lexicons, &config);
747 assert!(has_category(&result[0], ImportanceCategory::Stateful));
748 assert!(has_category(&result[1], ImportanceCategory::Stateful));
749 assert!(has_category(&result[2], ImportanceCategory::Stateful));
750 }
751
752 #[test]
753 fn stateful_rejects_bare_is() {
754 let lexicons = Lexicons::default();
755 let config = default_config();
756 let inputs = vec![passage(
757 "Context is important for robust agents.",
758 &["context"],
759 "session-a",
760 10,
761 )];
762
763 let result = classify_passages(&inputs, &lexicons, &config);
764 assert!(!has_category(&result[0], ImportanceCategory::Stateful));
765 }
766
767 #[test]
768 fn stateful_entity_requires_alphabetic_characters() {
769 let lexicons = Lexicons::default();
770 let config = default_config();
771 let inputs = vec![passage("1234 = 5678", &["1234"], "session-a", 10)];
772
773 let result = classify_passages(&inputs, &lexicons, &config);
774 assert!(!has_category(&result[0], ImportanceCategory::Stateful));
775 }
776
777 #[test]
778 fn stateful_supersession_marks_older_passage() {
779 let lexicons = Lexicons::default();
780 let config = default_config();
781 let inputs = vec![
782 passage("Cache mode set to writeback", &["cache"], "session-a", 100),
783 passage(
784 "Cache mode set to writethrough",
785 &["cache"],
786 "session-b",
787 200,
788 ),
789 ];
790
791 let result = classify_passages(&inputs, &lexicons, &config);
792 assert!(has_category(&result[0], ImportanceCategory::Stateful));
793 assert!(has_category(&result[1], ImportanceCategory::Stateful));
794 assert!(result[0].superseded);
795 assert!(!result[1].superseded);
796 }
797
798 #[test]
799 fn stateful_supersession_equal_timestamps() {
800 let lexicons = Lexicons::default();
801 let config = ClassificationConfig::default();
802 let passages = vec![
803 PassageContext {
804 passage_text: "Server IP: changed to 10.0.0.1".to_string(),
805 triggering_terms: vec!["server".to_string()],
806 session_id: "s1".to_string(),
807 timestamp: 100,
808 },
809 PassageContext {
810 passage_text: "Server IP: changed to 10.0.0.2".to_string(),
811 triggering_terms: vec!["server".to_string()],
812 session_id: "s2".to_string(),
813 timestamp: 100,
814 },
815 ];
816 let result = classify_passages(&passages, &lexicons, &config);
817 let stateful: Vec<_> = result
818 .iter()
819 .filter(|passage| passage.categories.contains(&ImportanceCategory::Stateful))
820 .collect();
821 assert_eq!(stateful.len(), 2);
822 let superseded_count = result.iter().filter(|passage| passage.superseded).count();
823 assert_eq!(superseded_count, 1);
824 }
825
826 #[test]
827 fn decisive_detection_requires_comparison_and_causal() {
828 let lexicons = Lexicons::default();
829 let config = default_config();
830 let inputs = vec![passage(
831 "We switched from Redis to Memcached because latency dropped.",
832 &["redis", "memcached"],
833 "session-a",
834 10,
835 )];
836
837 let result = classify_passages(&inputs, &lexicons, &config);
838 assert!(has_category(&result[0], ImportanceCategory::Decisive));
839 }
840
841 #[test]
842 fn decisive_requires_both_signals() {
843 let lexicons = Lexicons::default();
844 let config = default_config();
845 let comparison_only = passage(
846 "We switched from Redis to Memcached yesterday.",
847 &["redis", "memcached"],
848 "session-a",
849 10,
850 );
851 let causal_only = passage(
852 "Latency dropped because pipeline tuning improved.",
853 &["latency", "pipeline"],
854 "session-b",
855 11,
856 );
857
858 let result = classify_passages(&[comparison_only, causal_only], &lexicons, &config);
859 assert!(!has_category(&result[0], ImportanceCategory::Decisive));
860 assert!(!has_category(&result[1], ImportanceCategory::Decisive));
861 }
862
863 #[test]
864 fn decisive_supersession_marks_older_entity_pair() {
865 let lexicons = Lexicons::default();
866 let config = default_config();
867 let inputs = vec![
868 passage(
869 "We switched from Redis to Memcached because latency improved.",
870 &["redis", "memcached"],
871 "session-a",
872 10,
873 ),
874 passage(
875 "We switched from Memcached to Redis because cache misses rose.",
876 &["memcached", "redis"],
877 "session-b",
878 20,
879 ),
880 ];
881
882 let result = classify_passages(&inputs, &lexicons, &config);
883 assert!(has_category(&result[0], ImportanceCategory::Decisive));
884 assert!(has_category(&result[1], ImportanceCategory::Decisive));
885 assert!(result[0].superseded);
886 assert!(!result[1].superseded);
887 }
888
889 #[test]
890 fn reinforcing_detection_with_three_sessions() {
891 let lexicons = Lexicons::default();
892 let config = ClassificationConfig {
893 reinforcing_min_sessions: 3,
894 reinforcing_overlap_threshold: 0.6,
895 ..ClassificationConfig::default()
896 };
897 let inputs = vec![
898 passage(
899 "Yes always run cargo test before committing",
900 &["cargo", "test"],
901 "s1",
902 1,
903 ),
904 passage(
905 "Yes always run cargo test before committing code",
906 &["cargo", "test"],
907 "s2",
908 2,
909 ),
910 passage(
911 "Yes always run cargo test before committing",
912 &["cargo", "test"],
913 "s3",
914 3,
915 ),
916 ];
917
918 let result = classify_passages(&inputs, &lexicons, &config);
919 assert!(result
920 .iter()
921 .all(|passage| has_category(passage, ImportanceCategory::Reinforcing)));
922 }
923
924 #[test]
925 fn reinforcing_below_threshold_with_two_sessions() {
926 let lexicons = Lexicons::default();
927 let config = ClassificationConfig {
928 reinforcing_min_sessions: 3,
929 reinforcing_overlap_threshold: 0.6,
930 ..ClassificationConfig::default()
931 };
932 let inputs = vec![
933 passage(
934 "Yes always run cargo test before committing",
935 &["cargo", "test"],
936 "s1",
937 1,
938 ),
939 passage(
940 "Yes always run cargo test before committing",
941 &["cargo", "test"],
942 "s2",
943 2,
944 ),
945 ];
946
947 let result = classify_passages(&inputs, &lexicons, &config);
948 assert!(result
949 .iter()
950 .all(|passage| !has_category(passage, ImportanceCategory::Reinforcing)));
951 }
952
953 #[test]
954 fn reinforcing_different_text_same_terms_rejected() {
955 let lexicons = Lexicons::default();
956 let config = ClassificationConfig {
957 reinforcing_min_sessions: 3,
958 reinforcing_overlap_threshold: 0.6,
959 ..ClassificationConfig::default()
960 };
961 let inputs = vec![
962 passage(
963 "Yes start the docker container on boot",
964 &["docker"],
965 "s1",
966 1,
967 ),
968 passage(
969 "Confirmed docker causes memory leak issues",
970 &["docker"],
971 "s2",
972 2,
973 ),
974 passage(
975 "Good the docker container starts on boot",
976 &["docker"],
977 "s3",
978 3,
979 ),
980 ];
981
982 let result = classify_passages(&inputs, &lexicons, &config);
983 assert!(result
984 .iter()
985 .all(|passage| !has_category(passage, ImportanceCategory::Reinforcing)));
986 }
987
988 #[test]
989 fn reinforcing_high_bigram_overlap_triggers() {
990 let lexicons = Lexicons::default();
991 let config = ClassificationConfig {
992 reinforcing_min_sessions: 3,
993 reinforcing_overlap_threshold: 0.6,
994 ..ClassificationConfig::default()
995 };
996 let inputs = vec![
997 passage(
998 "Yes always run cargo test before committing",
999 &["cargo", "test"],
1000 "s1",
1001 1,
1002 ),
1003 passage(
1004 "Yes run cargo test before committing code",
1005 &["cargo", "test"],
1006 "s2",
1007 2,
1008 ),
1009 passage(
1010 "Yes always run cargo test before committing",
1011 &["cargo", "test"],
1012 "s3",
1013 3,
1014 ),
1015 ];
1016
1017 let result = classify_passages(&inputs, &lexicons, &config);
1018 assert!(result
1019 .iter()
1020 .all(|passage| has_category(passage, ImportanceCategory::Reinforcing)));
1021 }
1022
1023 #[test]
1024 fn multi_category_passage_can_be_corrective_and_decisive() {
1025 let lexicons = Lexicons::default();
1026 let config = default_config();
1027 let inputs = vec![passage(
1028 "We should not use Redis and switched to Memcached because costs dropped.",
1029 &["redis", "memcached"],
1030 "session-a",
1031 10,
1032 )];
1033
1034 let result = classify_passages(&inputs, &lexicons, &config);
1035 assert!(has_category(&result[0], ImportanceCategory::Corrective));
1036 assert!(has_category(&result[0], ImportanceCategory::Decisive));
1037 }
1038
1039 #[test]
1040 fn stateful_is_now_operator_matches() {
1041 let lexicons = Lexicons::default();
1042 let config = default_config();
1043 let inputs = vec![passage(
1044 "Primary database is now PostgreSQL 16",
1045 &["database"],
1046 "session-a",
1047 10,
1048 )];
1049
1050 let result = classify_passages(&inputs, &lexicons, &config);
1051 assert!(has_category(&result[0], ImportanceCategory::Stateful));
1052 }
1053
1054 #[test]
1055 fn state_operators_are_phrases_set_alone_does_not_match() {
1056 let lexicons = Lexicons::default();
1057 let config = default_config();
1058 let inputs = vec![
1059 passage("We set cache flags manually", &["cache"], "session-a", 10),
1060 passage("Cache is set to strict mode", &["cache"], "session-b", 11),
1061 ];
1062
1063 let result = classify_passages(&inputs, &lexicons, &config);
1064 assert!(!has_category(&result[0], ImportanceCategory::Stateful));
1065 assert!(has_category(&result[1], ImportanceCategory::Stateful));
1066 }
1067
1068 #[test]
1069 fn stateful_reset_does_not_match_set_to_operator() {
1070 let lexicons = Lexicons::default();
1071 let config = ClassificationConfig::default();
1072 let passages = vec![PassageContext {
1073 passage_text: "Reset to factory defaults immediately".to_string(),
1074 triggering_terms: vec!["factory".to_string()],
1075 session_id: "s1".to_string(),
1076 timestamp: 10,
1077 }];
1078 let result = classify_passages(&passages, &lexicons, &config);
1079 assert!(!result[0].categories.contains(&ImportanceCategory::Stateful));
1080 }
1081}