qualia_core_db/specialized_libs/medical_computing/
differential.rs1use super::MedicalError;
11use std::collections::HashMap;
12
13pub 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#[derive(Debug, Clone)]
23pub struct ConditionModel {
24 pub condition_id: String,
25 pub prior: f64,
28 pub likelihoods: HashMap<String, f64>,
30}
31
32#[derive(Debug, Clone)]
39pub struct DiagnosticKnowledgeBase {
40 pub conditions: Vec<ConditionModel>,
41 pub unlisted_finding_likelihood: f64,
44}
45
46#[derive(Debug, Clone)]
48pub struct ConditionPosterior {
49 pub condition_id: String,
50 pub prior: f64,
51 pub posterior: f64,
52}
53
54#[derive(Debug, Clone)]
57pub struct DifferentialProposal {
58 pub epistemic_status: &'static str,
60 pub method: &'static str,
61 pub observed_findings: Vec<String>,
62 pub ranked: Vec<ConditionPosterior>,
63}
64
65pub 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 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 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 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}