Skip to main content

converge_knowledge/ingest/
routing.rs

1//! Knowledge routing and classification system.
2//!
3//! This module implements the Case vs Background knowledge classification system
4//! as described in the design document. It provides:
5//!
6//! - Classification of incoming knowledge as Case (foreground) or Background (indirect)
7//! - Rule-based routing using source paths, categories, tags, and content patterns
8//! - Relevance scoring using the unified ranking formula
9//!
10//! # Example
11//!
12//! ```rust
13//! use converge_knowledge::ingest::{
14//!     KnowledgeRouter, RoutingRule, RoutingCondition, KnowledgeTypeHint,
15//!     AccessPattern, Permanence,
16//! };
17//! use std::collections::HashMap;
18//! use std::path::Path;
19//!
20//! let mut router = KnowledgeRouter::new();
21//!
22//! // Route project files as case knowledge
23//! router.add_rule(RoutingRule {
24//!     condition: RoutingCondition::SourcePath("projects/**/*".to_string()),
25//!     knowledge_type: KnowledgeTypeHint::Case {
26//!         context: "active-project".to_string(),
27//!         access_pattern: AccessPattern::ActiveUse,
28//!     },
29//! });
30//!
31//! // Route reference docs as background knowledge
32//! router.add_rule(RoutingRule {
33//!     condition: RoutingCondition::Category("reference".to_string()),
34//!     knowledge_type: KnowledgeTypeHint::Background {
35//!         domain: "documentation".to_string(),
36//!         permanence: Permanence::Versioned,
37//!     },
38//! });
39//!
40//! let metadata = HashMap::new();
41//! let knowledge = router.classify(
42//!     Path::new("projects/my-app/README.md"),
43//!     "Project documentation...",
44//!     &metadata,
45//! );
46//! ```
47
48use chrono::{DateTime, Utc};
49use regex::Regex;
50use serde::{Deserialize, Serialize};
51use std::collections::HashMap;
52use std::path::Path;
53use uuid::Uuid;
54
55/// Knowledge directly relevant to the current task or case.
56///
57/// Case knowledge has high relevance, is recently accessed, and is explicitly
58/// linked to the current working context.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct CaseKnowledge {
61    /// Unique identifier for this knowledge entry.
62    pub entry_id: Uuid,
63
64    /// Project or case identifier this knowledge belongs to.
65    pub case_context: String,
66
67    /// Time-based decay factor (0.0 to 1.0).
68    /// Higher values indicate more recent/relevant knowledge.
69    pub relevance_decay: f32,
70
71    /// Manually linked entry IDs.
72    pub explicit_links: Vec<Uuid>,
73
74    /// Current access pattern for this knowledge.
75    pub access_pattern: AccessPattern,
76
77    /// When this knowledge was last accessed.
78    pub last_accessed: DateTime<Utc>,
79}
80
81impl CaseKnowledge {
82    /// Create new case knowledge with default values.
83    pub fn new(entry_id: Uuid, case_context: impl Into<String>) -> Self {
84        Self {
85            entry_id,
86            case_context: case_context.into(),
87            relevance_decay: 1.0,
88            explicit_links: Vec::new(),
89            access_pattern: AccessPattern::ActiveUse,
90            last_accessed: Utc::now(),
91        }
92    }
93
94    /// Set the access pattern.
95    pub fn with_access_pattern(mut self, pattern: AccessPattern) -> Self {
96        self.access_pattern = pattern;
97        self
98    }
99
100    /// Add an explicit link to another entry.
101    pub fn with_link(mut self, linked_id: Uuid) -> Self {
102        self.explicit_links.push(linked_id);
103        self
104    }
105
106    /// Update the relevance decay based on time since last access.
107    ///
108    /// Uses exponential decay: `decay = exp(-days / half_life)`
109    pub fn update_decay(&mut self, half_life_days: f32) {
110        let days_since = (Utc::now() - self.last_accessed).num_hours() as f32 / 24.0;
111        self.relevance_decay = (-days_since / half_life_days).exp();
112    }
113
114    /// Record an access, resetting the decay factor.
115    pub fn record_access(&mut self) {
116        self.last_accessed = Utc::now();
117        self.relevance_decay = 1.0;
118    }
119}
120
121/// Access pattern indicating how the knowledge is currently being used.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
123pub enum AccessPattern {
124    /// Currently being actively referenced.
125    ActiveUse,
126
127    /// Used in the last session but not currently active.
128    #[default]
129    RecentHistory,
130
131    /// From a completed case, kept for reference.
132    Archived,
133}
134
135impl AccessPattern {
136    /// Get the boost factor for this access pattern.
137    pub fn boost_factor(&self) -> f32 {
138        match self {
139            AccessPattern::ActiveUse => 2.0,
140            AccessPattern::RecentHistory => 1.5,
141            AccessPattern::Archived => 0.5,
142        }
143    }
144}
145
146/// Contextual knowledge that supports understanding but isn't directly actionable.
147///
148/// Background knowledge provides context, general reference material,
149/// and supports case knowledge.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct BackgroundKnowledge {
152    /// Unique identifier for this knowledge entry.
153    pub entry_id: Uuid,
154
155    /// Knowledge domain (e.g., "rust", "architecture", "devops").
156    pub domain: String,
157
158    /// How stable/permanent this knowledge is.
159    pub permanence: Permanence,
160
161    /// IDs of case knowledge entries this supports.
162    pub supports: Vec<Uuid>,
163
164    /// When this knowledge was last verified as current.
165    pub last_verified: DateTime<Utc>,
166
167    /// Version information if applicable.
168    pub version: Option<String>,
169}
170
171impl BackgroundKnowledge {
172    /// Create new background knowledge with default values.
173    pub fn new(entry_id: Uuid, domain: impl Into<String>) -> Self {
174        Self {
175            entry_id,
176            domain: domain.into(),
177            permanence: Permanence::Evergreen,
178            supports: Vec::new(),
179            last_verified: Utc::now(),
180            version: None,
181        }
182    }
183
184    /// Set the permanence level.
185    pub fn with_permanence(mut self, permanence: Permanence) -> Self {
186        self.permanence = permanence;
187        self
188    }
189
190    /// Set the version.
191    pub fn with_version(mut self, version: impl Into<String>) -> Self {
192        self.version = Some(version.into());
193        self
194    }
195
196    /// Add a supported case knowledge entry.
197    pub fn with_support(mut self, case_id: Uuid) -> Self {
198        self.supports.push(case_id);
199        self
200    }
201
202    /// Check if this knowledge is still valid based on permanence.
203    pub fn is_valid(&self) -> bool {
204        !matches!(self.permanence, Permanence::Deprecated)
205    }
206}
207
208/// How stable or permanent the knowledge is.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
210pub enum Permanence {
211    /// Always valid knowledge (math, physics, fundamental concepts).
212    #[default]
213    Evergreen,
214
215    /// Valid for a specific version of software/specification.
216    Versioned,
217
218    /// Valid for a specific time period.
219    Temporal,
220
221    /// Outdated but kept for historical reference.
222    Deprecated,
223}
224
225impl Permanence {
226    /// Get the support factor for this permanence level.
227    pub fn support_factor(&self) -> f32 {
228        match self {
229            Permanence::Evergreen => 1.0,
230            Permanence::Versioned => 0.8,
231            Permanence::Temporal => 0.5,
232            Permanence::Deprecated => 0.1,
233        }
234    }
235}
236
237/// Classified knowledge type.
238#[derive(Debug, Clone, Serialize, Deserialize)]
239pub enum KnowledgeType {
240    /// Direct, actionable knowledge for current tasks.
241    Case(CaseKnowledge),
242
243    /// Contextual knowledge that supports understanding.
244    Background(BackgroundKnowledge),
245}
246
247impl KnowledgeType {
248    /// Get the entry ID regardless of type.
249    pub fn entry_id(&self) -> Uuid {
250        match self {
251            KnowledgeType::Case(k) => k.entry_id,
252            KnowledgeType::Background(k) => k.entry_id,
253        }
254    }
255
256    /// Check if this is case knowledge.
257    pub fn is_case(&self) -> bool {
258        matches!(self, KnowledgeType::Case(_))
259    }
260
261    /// Check if this is background knowledge.
262    pub fn is_background(&self) -> bool {
263        matches!(self, KnowledgeType::Background(_))
264    }
265
266    /// Get as case knowledge if applicable.
267    pub fn as_case(&self) -> Option<&CaseKnowledge> {
268        match self {
269            KnowledgeType::Case(k) => Some(k),
270            KnowledgeType::Background(_) => None,
271        }
272    }
273
274    /// Get as background knowledge if applicable.
275    pub fn as_background(&self) -> Option<&BackgroundKnowledge> {
276        match self {
277            KnowledgeType::Case(_) => None,
278            KnowledgeType::Background(k) => Some(k),
279        }
280    }
281}
282
283/// Hint for what type of knowledge to create during routing.
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub enum KnowledgeTypeHint {
286    /// Create case knowledge with the given context.
287    Case {
288        /// The case/project context.
289        context: String,
290        /// Initial access pattern.
291        access_pattern: AccessPattern,
292    },
293
294    /// Create background knowledge with the given domain.
295    Background {
296        /// The knowledge domain.
297        domain: String,
298        /// Permanence level.
299        permanence: Permanence,
300    },
301}
302
303impl Default for KnowledgeTypeHint {
304    fn default() -> Self {
305        Self::Background {
306            domain: "general".to_string(),
307            permanence: Permanence::Evergreen,
308        }
309    }
310}
311
312/// Condition for matching knowledge to route.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub enum RoutingCondition {
315    /// Match source path against a glob pattern.
316    SourcePath(String),
317
318    /// Match exact category.
319    Category(String),
320
321    /// Match if entry has this tag.
322    Tag(String),
323
324    /// Match content against a regex pattern.
325    ContentMatch(String),
326
327    /// Match metadata key-value pair.
328    Metadata(String, String),
329
330    /// Combine multiple conditions with AND.
331    All(Vec<RoutingCondition>),
332
333    /// Combine multiple conditions with OR.
334    Any(Vec<RoutingCondition>),
335}
336
337impl RoutingCondition {
338    /// Check if this condition matches the given input.
339    pub fn matches(
340        &self,
341        source_path: &Path,
342        content: &str,
343        metadata: &HashMap<String, String>,
344    ) -> bool {
345        match self {
346            RoutingCondition::SourcePath(pattern) => {
347                glob_match(pattern, &source_path.to_string_lossy())
348            }
349            RoutingCondition::Category(category) => {
350                metadata.get("category").is_some_and(|c| c == category)
351            }
352            RoutingCondition::Tag(tag) => metadata
353                .get("tags")
354                .is_some_and(|tags| tags.split(',').any(|t| t.trim() == tag)),
355            RoutingCondition::ContentMatch(pattern) => {
356                Regex::new(pattern).is_ok_and(|re| re.is_match(content))
357            }
358            RoutingCondition::Metadata(key, value) => metadata.get(key).is_some_and(|v| v == value),
359            RoutingCondition::All(conditions) => conditions
360                .iter()
361                .all(|c| c.matches(source_path, content, metadata)),
362            RoutingCondition::Any(conditions) => conditions
363                .iter()
364                .any(|c| c.matches(source_path, content, metadata)),
365        }
366    }
367}
368
369/// Simple glob pattern matching.
370///
371/// Supports `*` (any characters except `/`) and `**` (any characters including `/`).
372fn glob_match(pattern: &str, path: &str) -> bool {
373    let regex_pattern = pattern
374        .replace('.', r"\.")
375        // Handle **/ to match zero or more path components
376        .replace("**/", "\x00")
377        // Handle ** at the end to match remaining path
378        .replace("**", "\x01")
379        .replace('*', "[^/]*")
380        // **/ can match empty or any path ending with /
381        .replace('\x00', "([^/]+/)*")
382        // ** matches any characters
383        .replace('\x01', ".*");
384
385    Regex::new(&format!("^{regex_pattern}$")).is_ok_and(|re| re.is_match(path))
386}
387
388/// A routing rule that maps conditions to knowledge types.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct RoutingRule {
391    /// Condition that must match for this rule to apply.
392    pub condition: RoutingCondition,
393
394    /// The knowledge type to assign when this rule matches.
395    pub knowledge_type: KnowledgeTypeHint,
396}
397
398impl RoutingRule {
399    /// Create a new routing rule.
400    pub fn new(condition: RoutingCondition, knowledge_type: KnowledgeTypeHint) -> Self {
401        Self {
402            condition,
403            knowledge_type,
404        }
405    }
406}
407
408/// Router for classifying incoming knowledge.
409///
410/// The router applies rules in order, using the first matching rule.
411/// If no rules match, the default type is used.
412#[derive(Debug, Clone)]
413pub struct KnowledgeRouter {
414    /// Ordered list of routing rules.
415    rules: Vec<RoutingRule>,
416
417    /// Default knowledge type when no rules match.
418    default_type: KnowledgeTypeHint,
419
420    /// Scoring weights for relevance calculation.
421    scoring_weights: ScoringWeights,
422}
423
424/// Weights used in the relevance scoring formula.
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct ScoringWeights {
427    /// Weight for base similarity score.
428    pub source_weight: f32,
429
430    /// Boost factor for case knowledge matching context.
431    pub context_boost: f32,
432
433    /// Factor for background knowledge support.
434    pub support_factor: f32,
435
436    /// Half-life in days for recency decay.
437    pub decay_half_life_days: f32,
438}
439
440impl Default for ScoringWeights {
441    fn default() -> Self {
442        Self {
443            source_weight: 1.0,
444            context_boost: 2.0,
445            support_factor: 0.5,
446            decay_half_life_days: 7.0,
447        }
448    }
449}
450
451impl KnowledgeRouter {
452    /// Create a new router with default settings.
453    pub fn new() -> Self {
454        Self {
455            rules: Vec::new(),
456            default_type: KnowledgeTypeHint::default(),
457            scoring_weights: ScoringWeights::default(),
458        }
459    }
460
461    /// Create a router with a custom default type.
462    pub fn with_default(default_type: KnowledgeTypeHint) -> Self {
463        Self {
464            rules: Vec::new(),
465            default_type,
466            scoring_weights: ScoringWeights::default(),
467        }
468    }
469
470    /// Set custom scoring weights.
471    pub fn with_scoring_weights(mut self, weights: ScoringWeights) -> Self {
472        self.scoring_weights = weights;
473        self
474    }
475
476    /// Add a routing rule.
477    ///
478    /// Rules are evaluated in the order they are added.
479    pub fn add_rule(&mut self, rule: RoutingRule) {
480        self.rules.push(rule);
481    }
482
483    /// Add multiple routing rules at once.
484    pub fn add_rules(&mut self, rules: impl IntoIterator<Item = RoutingRule>) {
485        self.rules.extend(rules);
486    }
487
488    /// Classify knowledge based on source path, content, and metadata.
489    ///
490    /// Evaluates rules in order and returns the first matching type.
491    /// If no rules match, returns the default type.
492    pub fn classify(
493        &self,
494        source_path: &Path,
495        content: &str,
496        metadata: &HashMap<String, String>,
497    ) -> KnowledgeType {
498        let entry_id = Uuid::new_v4();
499
500        let hint = self
501            .rules
502            .iter()
503            .find(|rule| rule.condition.matches(source_path, content, metadata))
504            .map(|rule| &rule.knowledge_type)
505            .unwrap_or(&self.default_type);
506
507        self.create_knowledge(entry_id, hint)
508    }
509
510    /// Classify with a specific entry ID.
511    pub fn classify_with_id(
512        &self,
513        entry_id: Uuid,
514        source_path: &Path,
515        content: &str,
516        metadata: &HashMap<String, String>,
517    ) -> KnowledgeType {
518        let hint = self
519            .rules
520            .iter()
521            .find(|rule| rule.condition.matches(source_path, content, metadata))
522            .map(|rule| &rule.knowledge_type)
523            .unwrap_or(&self.default_type);
524
525        self.create_knowledge(entry_id, hint)
526    }
527
528    /// Create knowledge from a hint.
529    fn create_knowledge(&self, entry_id: Uuid, hint: &KnowledgeTypeHint) -> KnowledgeType {
530        match hint {
531            KnowledgeTypeHint::Case {
532                context,
533                access_pattern,
534            } => KnowledgeType::Case(
535                CaseKnowledge::new(entry_id, context).with_access_pattern(*access_pattern),
536            ),
537            KnowledgeTypeHint::Background { domain, permanence } => KnowledgeType::Background(
538                BackgroundKnowledge::new(entry_id, domain).with_permanence(*permanence),
539            ),
540        }
541    }
542
543    /// Compute the relevance score for a knowledge entry.
544    ///
545    /// Uses the unified ranking formula:
546    /// ```text
547    /// final_score = (
548    ///     base_similarity * source_weight +
549    ///     case_relevance * context_boost +
550    ///     background_relevance * support_factor +
551    ///     recency_score * decay_factor
552    /// )
553    /// ```
554    ///
555    /// # Arguments
556    ///
557    /// * `knowledge` - The classified knowledge to score
558    /// * `base_similarity` - Base similarity score from vector search (0.0 to 1.0)
559    /// * `query_context` - Optional current case context for boosting
560    ///
561    /// # Returns
562    ///
563    /// The final relevance score.
564    pub fn compute_relevance_score(
565        &self,
566        knowledge: &KnowledgeType,
567        base_similarity: f32,
568        query_context: Option<&str>,
569    ) -> f32 {
570        let weights = &self.scoring_weights;
571
572        match knowledge {
573            KnowledgeType::Case(case) => {
574                // Case relevance is boosted if context matches
575                let context_matches = query_context.is_some_and(|ctx| ctx == case.case_context);
576
577                let context_multiplier = if context_matches {
578                    weights.context_boost
579                } else {
580                    1.0
581                };
582
583                let access_boost = case.access_pattern.boost_factor();
584
585                // Final score for case knowledge
586                base_similarity * weights.source_weight
587                    + context_multiplier * access_boost
588                    + case.relevance_decay * access_boost
589            }
590            KnowledgeType::Background(bg) => {
591                // Background knowledge gets support factor applied
592                let permanence_factor = bg.permanence.support_factor();
593
594                // Bonus if this background knowledge supports active case knowledge
595                let support_bonus = if !bg.supports.is_empty() {
596                    0.2 * bg.supports.len() as f32
597                } else {
598                    0.0
599                };
600
601                // Final score for background knowledge
602                base_similarity * weights.source_weight
603                    + permanence_factor * weights.support_factor
604                    + support_bonus
605            }
606        }
607    }
608
609    /// Get all rules.
610    pub fn rules(&self) -> &[RoutingRule] {
611        &self.rules
612    }
613
614    /// Get the default knowledge type hint.
615    pub fn default_type(&self) -> &KnowledgeTypeHint {
616        &self.default_type
617    }
618
619    /// Get the scoring weights.
620    pub fn scoring_weights(&self) -> &ScoringWeights {
621        &self.scoring_weights
622    }
623}
624
625impl Default for KnowledgeRouter {
626    fn default() -> Self {
627        Self::new()
628    }
629}
630
631#[cfg(test)]
632mod tests {
633    use super::*;
634
635    #[test]
636    fn test_case_knowledge_creation() {
637        let id = Uuid::new_v4();
638        let case = CaseKnowledge::new(id, "my-project")
639            .with_access_pattern(AccessPattern::ActiveUse)
640            .with_link(Uuid::new_v4());
641
642        assert_eq!(case.entry_id, id);
643        assert_eq!(case.case_context, "my-project");
644        assert_eq!(case.access_pattern, AccessPattern::ActiveUse);
645        assert_eq!(case.explicit_links.len(), 1);
646        assert!((case.relevance_decay - 1.0).abs() < f32::EPSILON);
647    }
648
649    #[test]
650    fn test_case_knowledge_decay() {
651        let id = Uuid::new_v4();
652        let mut case = CaseKnowledge::new(id, "test");
653
654        // Simulate time passing by setting last_accessed to the past
655        case.last_accessed = Utc::now() - chrono::Duration::days(7);
656        case.update_decay(7.0);
657
658        // After one half-life, decay should be approximately 0.37 (1/e)
659        assert!(case.relevance_decay < 0.5);
660        assert!(case.relevance_decay > 0.3);
661    }
662
663    #[test]
664    fn test_background_knowledge_creation() {
665        let id = Uuid::new_v4();
666        let bg = BackgroundKnowledge::new(id, "rust")
667            .with_permanence(Permanence::Versioned)
668            .with_version("1.75")
669            .with_support(Uuid::new_v4());
670
671        assert_eq!(bg.entry_id, id);
672        assert_eq!(bg.domain, "rust");
673        assert_eq!(bg.permanence, Permanence::Versioned);
674        assert_eq!(bg.version, Some("1.75".to_string()));
675        assert_eq!(bg.supports.len(), 1);
676        assert!(bg.is_valid());
677    }
678
679    #[test]
680    fn test_background_knowledge_deprecated() {
681        let id = Uuid::new_v4();
682        let bg = BackgroundKnowledge::new(id, "legacy").with_permanence(Permanence::Deprecated);
683
684        assert!(!bg.is_valid());
685    }
686
687    #[test]
688    fn test_knowledge_type_accessors() {
689        let case_id = Uuid::new_v4();
690        let bg_id = Uuid::new_v4();
691
692        let case = KnowledgeType::Case(CaseKnowledge::new(case_id, "test"));
693        let bg = KnowledgeType::Background(BackgroundKnowledge::new(bg_id, "test"));
694
695        assert!(case.is_case());
696        assert!(!case.is_background());
697        assert_eq!(case.entry_id(), case_id);
698        assert!(case.as_case().is_some());
699        assert!(case.as_background().is_none());
700
701        assert!(!bg.is_case());
702        assert!(bg.is_background());
703        assert_eq!(bg.entry_id(), bg_id);
704        assert!(bg.as_case().is_none());
705        assert!(bg.as_background().is_some());
706    }
707
708    #[test]
709    fn test_access_pattern_boost() {
710        assert!((AccessPattern::ActiveUse.boost_factor() - 2.0).abs() < f32::EPSILON);
711        assert!((AccessPattern::RecentHistory.boost_factor() - 1.5).abs() < f32::EPSILON);
712        assert!((AccessPattern::Archived.boost_factor() - 0.5).abs() < f32::EPSILON);
713    }
714
715    #[test]
716    fn test_permanence_support_factor() {
717        assert!((Permanence::Evergreen.support_factor() - 1.0).abs() < f32::EPSILON);
718        assert!((Permanence::Versioned.support_factor() - 0.8).abs() < f32::EPSILON);
719        assert!((Permanence::Temporal.support_factor() - 0.5).abs() < f32::EPSILON);
720        assert!((Permanence::Deprecated.support_factor() - 0.1).abs() < f32::EPSILON);
721    }
722
723    #[test]
724    fn test_glob_matching() {
725        assert!(glob_match("*.rs", "main.rs"));
726        assert!(!glob_match("*.rs", "main.txt"));
727        assert!(glob_match("src/*.rs", "src/lib.rs"));
728        assert!(!glob_match("src/*.rs", "src/foo/lib.rs"));
729        assert!(glob_match("src/**/*.rs", "src/foo/bar/lib.rs"));
730        assert!(glob_match("projects/**/*", "projects/my-app/README.md"));
731    }
732
733    #[test]
734    fn test_routing_condition_source_path() {
735        let condition = RoutingCondition::SourcePath("projects/**/*".to_string());
736        let metadata = HashMap::new();
737
738        assert!(condition.matches(Path::new("projects/my-app/src/main.rs"), "", &metadata));
739        assert!(!condition.matches(Path::new("docs/README.md"), "", &metadata));
740    }
741
742    #[test]
743    fn test_routing_condition_category() {
744        let condition = RoutingCondition::Category("reference".to_string());
745        let mut metadata = HashMap::new();
746
747        assert!(!condition.matches(Path::new("test.md"), "", &metadata));
748
749        metadata.insert("category".to_string(), "reference".to_string());
750        assert!(condition.matches(Path::new("test.md"), "", &metadata));
751    }
752
753    #[test]
754    fn test_routing_condition_tag() {
755        let condition = RoutingCondition::Tag("rust".to_string());
756        let mut metadata = HashMap::new();
757
758        assert!(!condition.matches(Path::new("test.md"), "", &metadata));
759
760        metadata.insert("tags".to_string(), "programming, rust, systems".to_string());
761        assert!(condition.matches(Path::new("test.md"), "", &metadata));
762    }
763
764    #[test]
765    fn test_routing_condition_content_match() {
766        let condition = RoutingCondition::ContentMatch(r"TODO:|FIXME:".to_string());
767        let metadata = HashMap::new();
768
769        assert!(condition.matches(Path::new("test.rs"), "// TODO: implement this", &metadata));
770        assert!(!condition.matches(Path::new("test.rs"), "// This is done", &metadata));
771    }
772
773    #[test]
774    fn test_routing_condition_metadata() {
775        let condition = RoutingCondition::Metadata("status".to_string(), "active".to_string());
776        let mut metadata = HashMap::new();
777
778        assert!(!condition.matches(Path::new("test.md"), "", &metadata));
779
780        metadata.insert("status".to_string(), "active".to_string());
781        assert!(condition.matches(Path::new("test.md"), "", &metadata));
782    }
783
784    #[test]
785    fn test_routing_condition_all() {
786        let condition = RoutingCondition::All(vec![
787            RoutingCondition::SourcePath("src/**/*.rs".to_string()),
788            RoutingCondition::Category("code".to_string()),
789        ]);
790
791        let mut metadata = HashMap::new();
792        metadata.insert("category".to_string(), "code".to_string());
793
794        assert!(condition.matches(Path::new("src/lib.rs"), "", &metadata));
795        assert!(!condition.matches(Path::new("docs/README.md"), "", &metadata));
796
797        let mut wrong_category = HashMap::new();
798        wrong_category.insert("category".to_string(), "docs".to_string());
799        assert!(!condition.matches(Path::new("src/lib.rs"), "", &wrong_category));
800    }
801
802    #[test]
803    fn test_routing_condition_any() {
804        let condition = RoutingCondition::Any(vec![
805            RoutingCondition::Tag("important".to_string()),
806            RoutingCondition::Tag("urgent".to_string()),
807        ]);
808
809        let mut metadata1 = HashMap::new();
810        metadata1.insert("tags".to_string(), "important".to_string());
811        assert!(condition.matches(Path::new("test.md"), "", &metadata1));
812
813        let mut metadata2 = HashMap::new();
814        metadata2.insert("tags".to_string(), "urgent".to_string());
815        assert!(condition.matches(Path::new("test.md"), "", &metadata2));
816
817        let metadata3 = HashMap::new();
818        assert!(!condition.matches(Path::new("test.md"), "", &metadata3));
819    }
820
821    #[test]
822    fn test_router_classify_with_rule() {
823        let mut router = KnowledgeRouter::new();
824
825        router.add_rule(RoutingRule {
826            condition: RoutingCondition::SourcePath("projects/**/*".to_string()),
827            knowledge_type: KnowledgeTypeHint::Case {
828                context: "active-project".to_string(),
829                access_pattern: AccessPattern::ActiveUse,
830            },
831        });
832
833        let metadata = HashMap::new();
834        let knowledge = router.classify(
835            Path::new("projects/my-app/README.md"),
836            "Project documentation",
837            &metadata,
838        );
839
840        assert!(knowledge.is_case());
841        let case = knowledge.as_case().unwrap();
842        assert_eq!(case.case_context, "active-project");
843        assert_eq!(case.access_pattern, AccessPattern::ActiveUse);
844    }
845
846    #[test]
847    fn test_router_classify_default() {
848        let router = KnowledgeRouter::with_default(KnowledgeTypeHint::Background {
849            domain: "general".to_string(),
850            permanence: Permanence::Evergreen,
851        });
852
853        let metadata = HashMap::new();
854        let knowledge =
855            router.classify(Path::new("some/random/file.txt"), "Some content", &metadata);
856
857        assert!(knowledge.is_background());
858        let bg = knowledge.as_background().unwrap();
859        assert_eq!(bg.domain, "general");
860        assert_eq!(bg.permanence, Permanence::Evergreen);
861    }
862
863    #[test]
864    fn test_router_rule_priority() {
865        let mut router = KnowledgeRouter::new();
866
867        // More specific rule first
868        router.add_rule(RoutingRule {
869            condition: RoutingCondition::SourcePath("projects/urgent/**/*".to_string()),
870            knowledge_type: KnowledgeTypeHint::Case {
871                context: "urgent".to_string(),
872                access_pattern: AccessPattern::ActiveUse,
873            },
874        });
875
876        // General rule second
877        router.add_rule(RoutingRule {
878            condition: RoutingCondition::SourcePath("projects/**/*".to_string()),
879            knowledge_type: KnowledgeTypeHint::Case {
880                context: "normal".to_string(),
881                access_pattern: AccessPattern::RecentHistory,
882            },
883        });
884
885        let metadata = HashMap::new();
886
887        // Should match first rule
888        let urgent = router.classify(Path::new("projects/urgent/fix.rs"), "", &metadata);
889        assert_eq!(urgent.as_case().unwrap().case_context, "urgent");
890
891        // Should match second rule
892        let normal = router.classify(Path::new("projects/other/main.rs"), "", &metadata);
893        assert_eq!(normal.as_case().unwrap().case_context, "normal");
894    }
895
896    #[test]
897    fn test_compute_relevance_score_case_matching_context() {
898        let router = KnowledgeRouter::new();
899
900        let case = KnowledgeType::Case(
901            CaseKnowledge::new(Uuid::new_v4(), "my-project")
902                .with_access_pattern(AccessPattern::ActiveUse),
903        );
904
905        // Score with matching context should be higher
906        let score_match = router.compute_relevance_score(&case, 0.8, Some("my-project"));
907        let score_no_match = router.compute_relevance_score(&case, 0.8, Some("other-project"));
908        let score_no_context = router.compute_relevance_score(&case, 0.8, None);
909
910        assert!(score_match > score_no_match);
911        assert!(score_match > score_no_context);
912    }
913
914    #[test]
915    fn test_compute_relevance_score_background() {
916        let router = KnowledgeRouter::new();
917
918        let bg = KnowledgeType::Background(
919            BackgroundKnowledge::new(Uuid::new_v4(), "rust").with_permanence(Permanence::Evergreen),
920        );
921
922        let score = router.compute_relevance_score(&bg, 0.8, Some("any-context"));
923
924        // Background knowledge gets base score + support factor
925        assert!(score > 0.8);
926    }
927
928    #[test]
929    fn test_compute_relevance_score_background_with_supports() {
930        let router = KnowledgeRouter::new();
931
932        let bg_no_supports =
933            KnowledgeType::Background(BackgroundKnowledge::new(Uuid::new_v4(), "rust"));
934
935        let bg_with_supports = KnowledgeType::Background(
936            BackgroundKnowledge::new(Uuid::new_v4(), "rust")
937                .with_support(Uuid::new_v4())
938                .with_support(Uuid::new_v4()),
939        );
940
941        let score_no = router.compute_relevance_score(&bg_no_supports, 0.8, None);
942        let score_with = router.compute_relevance_score(&bg_with_supports, 0.8, None);
943
944        // More supports = higher score
945        assert!(score_with > score_no);
946    }
947
948    #[test]
949    fn test_router_with_custom_weights() {
950        let weights = ScoringWeights {
951            source_weight: 2.0,
952            context_boost: 3.0,
953            support_factor: 0.8,
954            decay_half_life_days: 14.0,
955        };
956
957        let router = KnowledgeRouter::new().with_scoring_weights(weights);
958
959        assert!((router.scoring_weights().source_weight - 2.0).abs() < f32::EPSILON);
960        assert!((router.scoring_weights().context_boost - 3.0).abs() < f32::EPSILON);
961    }
962
963    #[test]
964    fn test_complex_routing_scenario() {
965        let mut router = KnowledgeRouter::new();
966
967        // Active project code
968        router.add_rule(RoutingRule::new(
969            RoutingCondition::All(vec![
970                RoutingCondition::SourcePath("src/**/*.rs".to_string()),
971                RoutingCondition::Metadata("status".to_string(), "active".to_string()),
972            ]),
973            KnowledgeTypeHint::Case {
974                context: "current-sprint".to_string(),
975                access_pattern: AccessPattern::ActiveUse,
976            },
977        ));
978
979        // Reference documentation
980        router.add_rule(RoutingRule::new(
981            RoutingCondition::Any(vec![
982                RoutingCondition::Category("reference".to_string()),
983                RoutingCondition::Tag("documentation".to_string()),
984            ]),
985            KnowledgeTypeHint::Background {
986                domain: "documentation".to_string(),
987                permanence: Permanence::Versioned,
988            },
989        ));
990
991        // Test active code
992        let mut metadata1 = HashMap::new();
993        metadata1.insert("status".to_string(), "active".to_string());
994
995        let code = router.classify(Path::new("src/lib.rs"), "pub fn main() {}", &metadata1);
996        assert!(code.is_case());
997        assert_eq!(code.as_case().unwrap().case_context, "current-sprint");
998
999        // Test reference doc
1000        let mut metadata2 = HashMap::new();
1001        metadata2.insert("category".to_string(), "reference".to_string());
1002
1003        let doc = router.classify(Path::new("docs/api.md"), "# API Reference", &metadata2);
1004        assert!(doc.is_background());
1005        assert_eq!(doc.as_background().unwrap().domain, "documentation");
1006
1007        // Test unmatched file - should use default
1008        let other = router.classify(Path::new("random.txt"), "some content", &HashMap::new());
1009        assert!(other.is_background()); // Default is background
1010    }
1011}