Skip to main content

context_forge/analysis/
classification.rs

1use std::collections::{HashMap, HashSet};
2
3#[cfg(feature = "parallel")]
4use rayon::prelude::*;
5
6use crate::analysis::lexicon::Lexicons;
7
8/// Classification configuration.
9#[derive(Debug, Clone)]
10#[non_exhaustive]
11pub struct ClassificationConfig {
12    /// Word proximity window for corrective detection (default: 5).
13    pub corrective_proximity: usize,
14    /// Minimum sessions for reinforcing classification (default: 3).
15    pub reinforcing_min_sessions: usize,
16    /// Minimum bigram overlap ratio over passage text for reinforcing
17    /// classification (default: 0.6).
18    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/// Context about a passage needed for classification.
32/// Keeps classification decoupled from core types.
33#[derive(Debug, Clone)]
34pub struct PassageContext {
35    /// The passage text.
36    pub passage_text: String,
37    /// High-recurrence terms that triggered extraction of this passage.
38    pub triggering_terms: Vec<String>,
39    /// Session ID this passage belongs to.
40    pub session_id: String,
41    /// Source entry timestamp (Unix seconds).
42    pub timestamp: i64,
43}
44
45/// A category of importance assigned to a passage during classification.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
47#[non_exhaustive]
48pub enum ImportanceCategory {
49    /// The passage corrects or contradicts earlier information.
50    Corrective,
51    /// The passage records a state change (entity is now value).
52    Stateful,
53    /// The passage records a decision and its rationale.
54    Decisive,
55    /// The passage repeats or reinforces a recurring topic.
56    Reinforcing,
57}
58
59/// A passage that has been classified into zero or more
60/// [`ImportanceCategory`] values, with extracted entities/values where
61/// applicable.
62#[derive(Debug, Clone)]
63#[non_exhaustive]
64pub struct ClassifiedPassage {
65    /// The passage text.
66    pub text: String,
67    /// Categories assigned to this passage.
68    pub categories: Vec<ImportanceCategory>,
69    /// High-recurrence terms that triggered extraction of this passage.
70    pub triggering_terms: Vec<String>,
71    /// Session ID this passage belongs to.
72    pub session_id: String,
73    /// Source entry timestamp (Unix seconds).
74    pub timestamp: i64,
75    /// Extracted entity for stateful/decisive categories.
76    pub entity: Option<String>,
77    /// Extracted value for stateful category.
78    pub value: Option<String>,
79    /// Second entity for decisive category (entity pair).
80    pub entity_pair: Option<(String, String)>,
81    /// Whether this passage has been superseded by a newer one.
82    ///
83    /// Supersession is evaluated per-category (stateful or decisive),
84    /// but this flag represents "superseded in at least one category."
85    /// A multi-category passage marked superseded may still be the
86    /// latest representative of another category it belongs to.
87    /// Consumers should check category-specific supersession if needed.
88    pub superseded: bool,
89}
90
91/// Classify passages into importance categories and apply supersession.
92#[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(&current) {
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                // Tie-broken by input order: later index wins.
475                .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}