Skip to main content

qualia_core_db/specialized_libs/medical_computing/
differential.rs

1//! Transparent Bayesian differential engine — a ranked *epistemic proposal*, never a diagnosis.
2//!
3//! HONESTY (CLAUDE.md §15 + repo stance "investigative proposals, not verdicts"):
4//! this computes a normalized naive-Bayes posterior over conditions given observed
5//! findings. The MATH is what this module implements and tests. The KNOWLEDGE BASE
6//! (priors + per-finding likelihoods) is **caller-supplied and non-authoritative** —
7//! its clinical validity is the caller's responsibility, stated plainly on every
8//! result via [`DIFFERENTIAL_EPISTEMIC_STATUS`]. No clinical fact is baked in.
9
10use super::MedicalError;
11use std::collections::HashMap;
12
13/// Honest epistemic label stamped on every [`DifferentialProposal`].
14pub const DIFFERENTIAL_EPISTEMIC_STATUS: &str = "Epistemic proposal — a ranked \
15differential computed by transparent naive-Bayes over a CALLER-SUPPLIED, \
16non-authoritative knowledge base. NOT a diagnosis. The clinical validity of the \
17knowledge base is the caller's responsibility.";
18
19const METHOD: &str = "normalized naive-Bayes posterior  P(condition | findings) \u{221d} prior \u{00b7} \u{220f} P(finding | condition)";
20
21/// One condition's probabilistic model within a caller-supplied knowledge base.
22#[derive(Debug, Clone)]
23pub struct ConditionModel {
24    pub condition_id: String,
25    /// Prior weight P(condition). Must be finite and > 0. Need not sum to 1 across
26    /// conditions — the posterior is normalized regardless.
27    pub prior: f64,
28    /// finding_id → P(finding present | condition), each in [0,1].
29    pub likelihoods: HashMap<String, f64>,
30}
31
32/// A caller-supplied, **non-authoritative** knowledge base for the Bayes engine.
33///
34/// # Illustrative example (NOT authoritative)
35/// Any example knowledge base constructed for tests or demos is explicitly
36/// illustrative. This engine coins no medical facts; callers own the clinical
37/// content and its validity.
38#[derive(Debug, Clone)]
39pub struct DiagnosticKnowledgeBase {
40    pub conditions: Vec<ConditionModel>,
41    /// Likelihood applied for an observed finding that a condition does not list.
42    /// The caller's modelling choice (0.5 is uninformative); documented, not authoritative.
43    pub unlisted_finding_likelihood: f64,
44}
45
46/// Posterior for one condition.
47#[derive(Debug, Clone)]
48pub struct ConditionPosterior {
49    pub condition_id: String,
50    pub prior: f64,
51    pub posterior: f64,
52}
53
54/// Ranked differential proposal. `ranked` is sorted descending by posterior; ties
55/// are broken by `condition_id` ascending for a deterministic order.
56#[derive(Debug, Clone)]
57pub struct DifferentialProposal {
58    /// Honest label — this is a proposal over a caller-supplied KB, never a diagnosis.
59    pub epistemic_status: &'static str,
60    pub method: &'static str,
61    pub observed_findings: Vec<String>,
62    pub ranked: Vec<ConditionPosterior>,
63}
64
65/// Compute the normalized posterior differential over `kb` given the present
66/// `observed` findings.
67///
68/// `observed` is the list of finding ids observed to be **present**; the posterior
69/// is proportional to `prior · Π P(finding | condition)` over those findings.
70///
71/// Fails closed ([`MedicalError`]) on an empty KB, a non-finite/non-positive prior,
72/// a likelihood outside [0,1], or when every condition's unnormalized posterior is
73/// zero (findings incompatible with the whole KB) — never returns a fabricated result.
74pub fn analyze_differential(
75    observed: &[String],
76    kb: &DiagnosticKnowledgeBase,
77) -> Result<DifferentialProposal, MedicalError> {
78    if kb.conditions.is_empty() {
79        return Err(MedicalError::InsufficientData(
80            "differential: knowledge base has no conditions".to_string(),
81        ));
82    }
83    if !(kb.unlisted_finding_likelihood.is_finite()
84        && (0.0..=1.0).contains(&kb.unlisted_finding_likelihood))
85    {
86        return Err(MedicalError::ValidationError(
87            "differential: unlisted_finding_likelihood must be in [0,1]".to_string(),
88        ));
89    }
90
91    // Validate each condition and compute its unnormalized posterior.
92    let mut unnorm: Vec<f64> = Vec::with_capacity(kb.conditions.len());
93    for cond in &kb.conditions {
94        if !(cond.prior.is_finite() && cond.prior > 0.0) {
95            return Err(MedicalError::ValidationError(format!(
96                "differential: condition '{}' has a non-finite or non-positive prior",
97                cond.condition_id
98            )));
99        }
100        let mut p = cond.prior;
101        for f in observed {
102            let l = match cond.likelihoods.get(f) {
103                Some(&v) => v,
104                None => kb.unlisted_finding_likelihood,
105            };
106            if !(l.is_finite() && (0.0..=1.0).contains(&l)) {
107                return Err(MedicalError::ValidationError(format!(
108                    "differential: condition '{}' likelihood for finding '{}' must be in [0,1]",
109                    cond.condition_id, f
110                )));
111            }
112            p *= l;
113        }
114        unnorm.push(p);
115    }
116
117    let sum: f64 = unnorm.iter().sum();
118    if sum <= 0.0 {
119        return Err(MedicalError::InsufficientData(
120            "differential: observed findings are incompatible with every condition \
121             (all posteriors zero); cannot rank"
122                .to_string(),
123        ));
124    }
125
126    let mut ranked: Vec<ConditionPosterior> = kb
127        .conditions
128        .iter()
129        .zip(unnorm.iter())
130        .map(|(cond, &u)| ConditionPosterior {
131            condition_id: cond.condition_id.clone(),
132            prior: cond.prior,
133            posterior: u / sum,
134        })
135        .collect();
136
137    ranked.sort_by(|a, b| {
138        b.posterior
139            .partial_cmp(&a.posterior)
140            .unwrap_or(std::cmp::Ordering::Equal)
141            .then_with(|| a.condition_id.cmp(&b.condition_id))
142    });
143
144    Ok(DifferentialProposal {
145        epistemic_status: DIFFERENTIAL_EPISTEMIC_STATUS,
146        method: METHOD,
147        observed_findings: observed.to_vec(),
148        ranked,
149    })
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    // ILLUSTRATIVE, NON-AUTHORITATIVE knowledge base used purely to exercise the math.
157    fn illustrative_kb() -> DiagnosticKnowledgeBase {
158        let mut flu = HashMap::new();
159        flu.insert("fever".to_string(), 0.9);
160        flu.insert("cough".to_string(), 0.8);
161        let mut cold = HashMap::new();
162        cold.insert("fever".to_string(), 0.2);
163        cold.insert("cough".to_string(), 0.6);
164        DiagnosticKnowledgeBase {
165            conditions: vec![
166                ConditionModel {
167                    condition_id: "influenza_like".to_string(),
168                    prior: 0.6,
169                    likelihoods: flu,
170                },
171                ConditionModel {
172                    condition_id: "common_cold".to_string(),
173                    prior: 0.4,
174                    likelihoods: cold,
175                },
176            ],
177            unlisted_finding_likelihood: 0.5,
178        }
179    }
180
181    #[test]
182    fn empty_kb_fails_closed() {
183        let kb = DiagnosticKnowledgeBase {
184            conditions: vec![],
185            unlisted_finding_likelihood: 0.5,
186        };
187        assert!(analyze_differential(&["fever".to_string()], &kb).is_err());
188    }
189
190    #[test]
191    fn hand_computed_posteriors() {
192        // unnorm(flu)=0.6*0.9*0.8=0.432 ; unnorm(cold)=0.4*0.2*0.6=0.048 ; sum=0.48
193        // post(flu)=0.9 ; post(cold)=0.1
194        let kb = illustrative_kb();
195        let obs = vec!["fever".to_string(), "cough".to_string()];
196        let p = analyze_differential(&obs, &kb).unwrap();
197        assert_eq!(p.ranked[0].condition_id, "influenza_like");
198        assert!((p.ranked[0].posterior - 0.9).abs() < 1e-9);
199        assert!((p.ranked[1].posterior - 0.1).abs() < 1e-9);
200    }
201}