Skip to main content

qualia_core_db/lora/
context_detector.rs

1//! Context detection for LoRA adapter selection.
2//!
3//! Classifies a natural-language prompt (and optionally the NQuin
4//! metadata vector) into one of the six `ContextType` domains using
5//! weighted keyword scoring and bigram analysis.
6
7use std::collections::HashMap;
8
9// ─── ContextType ─────────────────────────────────────────────────────────────
10
11/// Domain classification used to select the correct LoRA adapter.
12///
13/// The 4-bit encoding in `NQuin.metadata` bits 63–60 must stay stable;
14/// do not reorder these variants.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
16#[repr(u8)]
17pub enum ContextType {
18    General = 0x0,
19    Medical = 0x1,
20    Legal = 0x2,
21    Chemical = 0x3,
22    Biological = 0x4,
23    Technical = 0x5,
24}
25
26impl ContextType {
27    /// Decode from the 4-bit metadata field (bits 63–60).
28    pub fn from_metadata_bits(bits: u8) -> Self {
29        match bits & 0xF {
30            0x1 => ContextType::Medical,
31            0x2 => ContextType::Legal,
32            0x3 => ContextType::Chemical,
33            0x4 => ContextType::Biological,
34            0x5 => ContextType::Technical,
35            _ => ContextType::General,
36        }
37    }
38
39    pub fn to_metadata_bits(self) -> u8 {
40        self as u8
41    }
42
43    pub fn adapter_filename(self) -> &'static str {
44        match self {
45            ContextType::General => "general_v1.lora",
46            ContextType::Medical => "medical_v1.lora",
47            ContextType::Legal => "legal_v1.lora",
48            ContextType::Chemical => "chemical_v1.lora",
49            ContextType::Biological => "biological_v1.lora",
50            ContextType::Technical => "technical_v1.lora",
51        }
52    }
53
54    pub fn all() -> &'static [ContextType] {
55        &[
56            ContextType::General,
57            ContextType::Medical,
58            ContextType::Legal,
59            ContextType::Chemical,
60            ContextType::Biological,
61            ContextType::Technical,
62        ]
63    }
64}
65
66impl std::fmt::Display for ContextType {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            ContextType::General => write!(f, "general"),
70            ContextType::Medical => write!(f, "medical"),
71            ContextType::Legal => write!(f, "legal"),
72            ContextType::Chemical => write!(f, "chemical"),
73            ContextType::Biological => write!(f, "biological"),
74            ContextType::Technical => write!(f, "technical"),
75        }
76    }
77}
78
79// ─── NGramAnalyzer ────────────────────────────────────────────────────────────
80
81struct NGramAnalyzer {
82    bigram_weights: HashMap<(&'static str, &'static str), (ContextType, f32)>,
83}
84
85impl NGramAnalyzer {
86    fn new() -> Self {
87        let mut bigram_weights = HashMap::new();
88        // Medical bigrams
89        for pair in [
90            ("patient", "diagnosis"),
91            ("medical", "record"),
92            ("clinical", "trial"),
93            ("emergency", "room"),
94            ("blood", "pressure"),
95            ("heart", "rate"),
96            ("drug", "dosage"),
97            ("surgical", "procedure"),
98        ] {
99            bigram_weights.insert(pair, (ContextType::Medical, 0.85));
100        }
101        // Legal bigrams
102        for pair in [
103            ("legal", "contract"),
104            ("court", "order"),
105            ("intellectual", "property"),
106            ("due", "diligence"),
107            ("breach", "contract"),
108            ("statute", "limitations"),
109            ("case", "law"),
110            ("legal", "counsel"),
111        ] {
112            bigram_weights.insert(pair, (ContextType::Legal, 0.85));
113        }
114        // Chemical bigrams
115        for pair in [
116            ("chemical", "reaction"),
117            ("organic", "compound"),
118            ("synthesis", "reaction"),
119            ("chemical", "bond"),
120            ("molecular", "weight"),
121            ("oxidation", "reduction"),
122            ("acid", "base"),
123            ("reaction", "mechanism"),
124        ] {
125            bigram_weights.insert(pair, (ContextType::Chemical, 0.85));
126        }
127        // Biological bigrams
128        for pair in [
129            ("gene", "expression"),
130            ("cell", "division"),
131            ("protein", "synthesis"),
132            ("natural", "selection"),
133            ("dna", "replication"),
134            ("immune", "system"),
135            ("metabolic", "pathway"),
136            ("stem", "cell"),
137        ] {
138            bigram_weights.insert(pair, (ContextType::Biological, 0.85));
139        }
140        // Technical bigrams
141        for pair in [
142            ("machine", "learning"),
143            ("neural", "network"),
144            ("api", "endpoint"),
145            ("data", "structure"),
146            ("time", "complexity"),
147            ("memory", "management"),
148            ("software", "architecture"),
149            ("distributed", "system"),
150        ] {
151            bigram_weights.insert(pair, (ContextType::Technical, 0.85));
152        }
153        Self { bigram_weights }
154    }
155
156    fn score(&self, tokens: &[&str]) -> HashMap<ContextType, f32> {
157        let mut scores: HashMap<ContextType, f32> = HashMap::new();
158        for window in tokens.windows(2) {
159            if let [a, b] = window {
160                if let Some(&(ctx, w)) = self.bigram_weights.get(&(*a, *b)) {
161                    *scores.entry(ctx).or_insert(0.0) += w;
162                }
163            }
164        }
165        scores
166    }
167}
168
169// ─── ContextDetector ─────────────────────────────────────────────────────────
170
171/// Classifies text into one of the `ContextType` domains.
172///
173/// Uses a two-phase approach:
174/// 1. Unigram keyword scoring (fast, O(n) where n = token count).
175/// 2. Bigram analysis for disambiguation of overlapping domains.
176///
177/// The resulting confidence is normalised to [0, 1]; a score below
178/// `confidence_threshold` falls back to `ContextType::General`.
179pub struct ContextDetector {
180    /// Per-domain keyword → weight table.
181    keyword_weights: HashMap<&'static str, Vec<(ContextType, f32)>>,
182    ngrams: NGramAnalyzer,
183    /// Minimum normalised confidence before the detector commits to a domain.
184    pub confidence_threshold: f32,
185}
186
187impl ContextDetector {
188    pub fn new() -> Self {
189        let mut kw: HashMap<&'static str, Vec<(ContextType, f32)>> = HashMap::new();
190
191        macro_rules! add {
192            ($word:expr, $( ($ctx:expr, $w:expr) ),+) => {
193                kw.entry($word).or_default().extend([$( ($ctx, $w) ),+]);
194            };
195        }
196
197        // ── Medical ──
198        for w in [
199            "diagnosis",
200            "symptom",
201            "treatment",
202            "medication",
203            "patient",
204            "clinical",
205            "prescription",
206            "therapy",
207            "disease",
208            "anatomy",
209            "physiology",
210            "pharmacology",
211            "surgery",
212            "emergency",
213            "vaccine",
214            "oncology",
215            "radiology",
216            "pathology",
217            "neurology",
218            "cardiology",
219            "prognosis",
220            "aetiology",
221            "comorbidity",
222            "triage",
223            "dosage",
224        ] {
225            add!(
226                w,
227                (ContextType::Medical, 0.9),
228                (ContextType::Biological, 0.4)
229            );
230        }
231
232        // ── Legal ──
233        for w in [
234            "contract",
235            "agreement",
236            "liability",
237            "legal",
238            "court",
239            "law",
240            "jurisdiction",
241            "statute",
242            "regulation",
243            "compliance",
244            "litigation",
245            "plaintiff",
246            "defendant",
247            "attorney",
248            "verdict",
249            "evidence",
250            "indemnity",
251            "arbitration",
252            "injunction",
253            "tort",
254            "precedent",
255            "affidavit",
256            "deposition",
257            "fiduciary",
258            "subpoena",
259        ] {
260            add!(w, (ContextType::Legal, 0.9));
261        }
262
263        // ── Chemical ──
264        for w in [
265            "molecule",
266            "compound",
267            "reaction",
268            "chemical",
269            "synthesis",
270            "bond",
271            "catalyst",
272            "reagent",
273            "solvent",
274            "stoichiometry",
275            "organic",
276            "inorganic",
277            "polymer",
278            "spectroscopy",
279            "titration",
280            "oxidation",
281            "reduction",
282            "isomer",
283            "alkyl",
284            "hydroxyl",
285            "carbonyl",
286            "ester",
287            "molar",
288            "entropy",
289            "enthalpy",
290        ] {
291            add!(
292                w,
293                (ContextType::Chemical, 0.9),
294                (ContextType::Technical, 0.2)
295            );
296        }
297
298        // ── Biological ──
299        for w in [
300            "cell",
301            "gene",
302            "protein",
303            "dna",
304            "rna",
305            "organism",
306            "species",
307            "evolution",
308            "ecosystem",
309            "metabolism",
310            "genetics",
311            "biology",
312            "immunology",
313            "mitosis",
314            "meiosis",
315            "chromosome",
316            "allele",
317            "phenotype",
318            "genotype",
319            "ribosome",
320            "enzyme",
321            "chlorophyll",
322            "photosynthesis",
323            "fermentation",
324            "microbiome",
325        ] {
326            add!(
327                w,
328                (ContextType::Biological, 0.9),
329                (ContextType::Chemical, 0.3)
330            );
331        }
332
333        // ── Technical ──
334        for w in [
335            "algorithm",
336            "software",
337            "hardware",
338            "programming",
339            "code",
340            "system",
341            "database",
342            "network",
343            "protocol",
344            "interface",
345            "api",
346            "encryption",
347            "authentication",
348            "optimization",
349            "latency",
350            "throughput",
351            "compiler",
352            "runtime",
353            "kernel",
354            "concurrency",
355            "async",
356            "cache",
357            "shader",
358            "tensor",
359            "gradient",
360        ] {
361            add!(w, (ContextType::Technical, 0.9));
362        }
363
364        Self {
365            keyword_weights: kw,
366            ngrams: NGramAnalyzer::new(),
367            confidence_threshold: 0.55,
368        }
369    }
370
371    /// Classify `text` and return `(domain, confidence)`.
372    ///
373    /// Confidence of 0.0 means no relevant keywords found.
374    pub fn analyze_text(&self, text: &str) -> (ContextType, f32) {
375        let tokens: Vec<&str> = text
376            .split(|c: char| !c.is_alphanumeric())
377            .filter(|s| !s.is_empty())
378            .map(|s| {
379                // &'static trick: we only look up keys that are &'static str.
380                // For matching, we lowercase into a stack buffer via a scratch approach.
381                // Here we accept the pointer-equality limitation and use a raw compare.
382                s
383            })
384            .collect();
385
386        // Lowercase comparison via a small scratch allocation (adapter init path, not hot).
387        let lowered: Vec<String> = tokens.iter().map(|t| t.to_lowercase()).collect();
388        let lower_refs: Vec<&str> = lowered.iter().map(|s| s.as_str()).collect();
389
390        let mut scores: HashMap<ContextType, f32> = HashMap::new();
391
392        // Unigram pass
393        for tok in &lower_refs {
394            if let Some(weights) = self.keyword_weights.get(tok) {
395                for &(ctx, w) in weights {
396                    *scores.entry(ctx).or_insert(0.0) += w;
397                }
398            }
399        }
400
401        // Bigram pass (half weight)
402        for (ctx, w) in self.ngrams.score(&lower_refs) {
403            *scores.entry(ctx).or_insert(0.0) += w * 0.5;
404        }
405
406        if scores.is_empty() {
407            return (ContextType::General, 0.0);
408        }
409
410        let best = scores
411            .iter()
412            .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
413            .map(|(&ctx, &s)| (ctx, s))
414            .unwrap();
415
416        let total: f32 = scores.values().sum();
417        let confidence = if total > 0.0 { best.1 / total } else { 0.0 };
418
419        if confidence < self.confidence_threshold {
420            (ContextType::General, confidence)
421        } else {
422            (best.0, confidence)
423        }
424    }
425
426    /// Combine text-derived and metadata-derived contexts.
427    ///
428    /// The higher-confidence signal wins; ties go to metadata.
429    pub fn combine(
430        &self,
431        text_result: (ContextType, f32),
432        meta_result: (ContextType, f32),
433    ) -> (ContextType, f32) {
434        if text_result.1 > meta_result.1 {
435            text_result
436        } else {
437            meta_result
438        }
439    }
440}
441
442impl Default for ContextDetector {
443    fn default() -> Self {
444        Self::new()
445    }
446}
447
448// ─── Tests ───────────────────────────────────────────────────────────────────
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn test_medical_detection() {
456        let det = ContextDetector::new();
457        let (ctx, conf) = det.analyze_text("The patient requires medication for the diagnosis");
458        assert_eq!(
459            ctx,
460            ContextType::Medical,
461            "expected Medical, got {:?} (conf={conf:.2})",
462            ctx
463        );
464        assert!(conf > 0.5);
465    }
466
467    #[test]
468    fn test_legal_detection() {
469        let det = ContextDetector::new();
470        let (ctx, conf) = det.analyze_text("The plaintiff filed a contract litigation case");
471        assert_eq!(
472            ctx,
473            ContextType::Legal,
474            "expected Legal, got {:?} (conf={conf:.2})",
475            ctx
476        );
477        assert!(conf > 0.5);
478    }
479
480    #[test]
481    fn test_chemical_detection() {
482        let det = ContextDetector::new();
483        let (ctx, conf) =
484            det.analyze_text("Organic synthesis of the catalyst compound via reaction");
485        assert_eq!(
486            ctx,
487            ContextType::Chemical,
488            "expected Chemical, got {:?} (conf={conf:.2})",
489            ctx
490        );
491        assert!(conf > 0.4);
492    }
493
494    #[test]
495    fn test_biological_detection() {
496        let det = ContextDetector::new();
497        let (ctx, conf) =
498            det.analyze_text("Gene expression in the cell affects protein synthesis via RNA");
499        assert_eq!(
500            ctx,
501            ContextType::Biological,
502            "expected Biological, got {:?} (conf={conf:.2})",
503            ctx
504        );
505        assert!(conf > 0.4);
506    }
507
508    #[test]
509    fn test_technical_detection() {
510        let det = ContextDetector::new();
511        let (ctx, conf) =
512            det.analyze_text("Optimizing the algorithm latency in the distributed system");
513        assert_eq!(
514            ctx,
515            ContextType::Technical,
516            "expected Technical, got {:?} (conf={conf:.2})",
517            ctx
518        );
519        assert!(conf > 0.5);
520    }
521
522    #[test]
523    fn test_general_fallback() {
524        let det = ContextDetector::new();
525        let (ctx, _conf) = det.analyze_text("Hello world");
526        assert_eq!(ctx, ContextType::General);
527    }
528
529    #[test]
530    fn test_roundtrip_metadata_bits() {
531        for &ct in ContextType::all() {
532            let bits = ct.to_metadata_bits();
533            let decoded = ContextType::from_metadata_bits(bits);
534            assert_eq!(decoded, ct, "roundtrip failed for {:?}", ct);
535        }
536    }
537}