Skip to main content

qualia_core_db/
clinical_engine.rs

1//! Clinical Decision Support Engine.
2//!
3//! Validated clinical risk scoring, pharmacological screening, and
4//! FHIR Observation validation — all as pure-Rust, zero-FFI computations.
5//!
6//! Risk models:
7//!   `qualia:computeRiskScore:framingham`  → `framingham_10yr_risk()`
8//!   `qualia:computeRiskScore:cha2ds2`     → `cha2ds2_vasc_score()`
9//!   `qualia:computeRiskScore:score2`      → `score2_risk()`
10//!
11//! Pharmacology:
12//!   `qualia:evaluateDrugInteraction`      → `check_drug_interactions()`
13//!   `qualia:checkContraindication`        → `check_contraindications()`
14//!
15//! Observation validation:
16//!   `qualia:validateFhirObservation`      → `validate_fhir_observation()`
17//!   `qualia:evaluateLongitudinalTrend`    → `longitudinal_trend()`
18//!   `qualia:evaluateGeneExpression`       → `evaluate_gene_expression()`
19
20// ─── Shared enumerations ──────────────────────────────────────────────────────
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum RiskCategory {
24    Low,
25    Moderate,
26    High,
27    VeryHigh,
28}
29
30// ─── Framingham 10-year CVD risk ──────────────────────────────────────────────
31// Anderson et al. 1991 / Wilson et al. 1998 (ATP III sex-specific log-linear model)
32
33#[derive(Debug, Clone)]
34pub struct FraminghamInput {
35    pub age: u8,
36    pub sex_male: bool,
37    pub total_cholesterol_mmol: f64,
38    pub hdl_cholesterol_mmol: f64,
39    pub systolic_bp: f64,
40    /// True if currently on antihypertensive medication.
41    pub bp_treated: bool,
42    pub current_smoker: bool,
43    pub diabetic: bool,
44}
45
46#[derive(Debug, Clone)]
47pub struct FraminghamResult {
48    /// Estimated 10-year absolute risk (0.0–1.0).
49    pub risk_10yr: f64,
50    pub category: RiskCategory,
51    pub log_score: f64,
52}
53
54pub fn framingham_10yr_risk(input: &FraminghamInput) -> FraminghamResult {
55    // Wilson 1998 coefficients (Table 2).
56    let (b_age, b_tc, b_hdl, b_sbp_unt, b_sbp_trt, b_smoke, b_diab, baseline_surv, mean_sum) =
57        if input.sex_male {
58            (
59                3.06117,
60                1.12370,
61                -0.93263,
62                1.93303,
63                1.99881,
64                0.65451,
65                0.57367,
66                0.88936_f64,
67                23.9802_f64,
68            )
69        } else {
70            (
71                2.32888,
72                1.20904,
73                -0.70833,
74                2.76157,
75                2.82263,
76                0.52873,
77                0.69154,
78                0.95012_f64,
79                26.1931_f64,
80            )
81        };
82
83    let sbp_coeff = if input.bp_treated {
84        b_sbp_trt
85    } else {
86        b_sbp_unt
87    };
88
89    let log_score = b_age * (input.age as f64).ln()
90        + b_tc * input.total_cholesterol_mmol.ln()
91        + b_hdl * input.hdl_cholesterol_mmol.ln()
92        + sbp_coeff * input.systolic_bp.ln()
93        + b_smoke * if input.current_smoker { 1.0 } else { 0.0 }
94        + b_diab * if input.diabetic { 1.0 } else { 0.0 };
95
96    let risk_10yr = (1.0 - baseline_surv.powf((log_score - mean_sum).exp())).clamp(0.0, 1.0);
97
98    let category = if risk_10yr < 0.10 {
99        RiskCategory::Low
100    } else if risk_10yr <= 0.20 {
101        RiskCategory::Moderate
102    } else {
103        RiskCategory::High
104    };
105
106    FraminghamResult {
107        risk_10yr,
108        category,
109        log_score,
110    }
111}
112
113// ─── CHA₂DS₂-VASc ────────────────────────────────────────────────────────────
114// Lip GY 2010 / ESC 2020 guidelines for non-valvular atrial fibrillation
115
116#[derive(Debug, Clone, Default)]
117pub struct Cha2ds2VascInput {
118    pub congestive_heart_failure: bool, // +1
119    pub hypertension: bool,             // +1
120    pub age_75_or_older: bool,          // +2
121    pub diabetes: bool,                 // +1
122    pub stroke_tia_history: bool,       // +2
123    pub vascular_disease: bool,         // +1
124    pub age_65_to_74: bool,             // +1
125    pub sex_female: bool,               // +1
126}
127
128#[derive(Debug, Clone)]
129pub struct Cha2ds2VascResult {
130    pub score: u8,
131    /// Annual stroke risk % from Lip 2010 cohort data.
132    pub annual_stroke_risk_pct: f64,
133    /// ESC 2020: men ≥2, women ≥3.
134    pub anticoagulation_recommended: bool,
135}
136
137pub fn cha2ds2_vasc_score(input: &Cha2ds2VascInput) -> Cha2ds2VascResult {
138    let score = input.congestive_heart_failure as u8
139        + input.hypertension as u8
140        + if input.age_75_or_older { 2 } else { 0 }
141        + input.diabetes as u8
142        + if input.stroke_tia_history { 2 } else { 0 }
143        + input.vascular_disease as u8
144        + input.age_65_to_74 as u8
145        + input.sex_female as u8;
146
147    let annual_risk = match score {
148        0 => 0.0,
149        1 => 1.3,
150        2 => 2.2,
151        3 => 3.2,
152        4 => 4.0,
153        5 => 6.7,
154        6 => 9.8,
155        7 => 9.6,
156        8 => 12.5,
157        _ => 15.2,
158    };
159
160    let anticoagulation_recommended = if input.sex_female {
161        score >= 3
162    } else {
163        score >= 2
164    };
165
166    Cha2ds2VascResult {
167        score,
168        annual_stroke_risk_pct: annual_risk,
169        anticoagulation_recommended,
170    }
171}
172
173// ─── SCORE2 ───────────────────────────────────────────────────────────────────
174// ESC CVD Risk Collaboration / SCORE2 Working Group 2021
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177pub enum Score2Region {
178    Low,
179    Moderate,
180    High,
181    VeryHigh,
182}
183
184#[derive(Debug, Clone)]
185pub struct Score2Input {
186    pub age: u8,
187    pub sex_male: bool,
188    pub systolic_bp: f64,
189    pub total_cholesterol_mmol: f64,
190    pub hdl_cholesterol_mmol: f64,
191    pub current_smoker: bool,
192    pub risk_region: Score2Region,
193}
194
195#[derive(Debug, Clone)]
196pub struct Score2Result {
197    pub risk_10yr_pct: f64,
198    pub category: RiskCategory,
199}
200
201pub fn score2_risk(input: &Score2Input) -> Score2Result {
202    let non_hdl = input.total_cholesterol_mmol - input.hdl_cholesterol_mmol;
203    let age_c = (input.age as f64 - 60.0) / 5.0;
204    let sbp_c = (input.systolic_bp - 120.0) / 20.0;
205    let chol_c = (non_hdl - 3.3) / 0.5;
206    let smoke = input.current_smoker as u8 as f64;
207
208    let (b_age, b_sbp, b_chol, b_smoke, baseline_surv) = if input.sex_male {
209        (0.3742_f64, 0.2628, 0.1401, 0.5865, 0.9605_f64)
210    } else {
211        (0.4648_f64, 0.3131, 0.1002, 0.7742, 0.9776_f64)
212    };
213
214    let linear = b_age * age_c + b_sbp * sbp_c + b_chol * chol_c + b_smoke * smoke;
215    let base_risk = 1.0 - baseline_surv.powf(linear.exp());
216
217    let calibrated_pct = (base_risk
218        * match input.risk_region {
219            Score2Region::Low => 0.71,
220            Score2Region::Moderate => 1.00,
221            Score2Region::High => 1.56,
222            Score2Region::VeryHigh => 2.27,
223        }
224        * 100.0)
225        .clamp(0.0, 100.0);
226
227    let category = if calibrated_pct < 5.0 {
228        RiskCategory::Low
229    } else if calibrated_pct <= 10.0 {
230        RiskCategory::Moderate
231    } else if calibrated_pct <= 20.0 {
232        RiskCategory::High
233    } else {
234        RiskCategory::VeryHigh
235    };
236
237    Score2Result {
238        risk_10yr_pct: calibrated_pct,
239        category,
240    }
241}
242
243// ─── Drug interaction screening ───────────────────────────────────────────────
244
245/// NCI CTCAE-style severity levels.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
247pub enum InteractionSeverity {
248    None = 0,
249    Minor = 1,
250    Moderate = 2,
251    Major = 3,
252    Contraindicated = 4,
253}
254
255#[derive(Debug, Clone)]
256pub struct DrugInteraction {
257    pub drug_a: u64,
258    pub drug_b: u64,
259    pub severity: InteractionSeverity,
260    pub mechanism: &'static str,
261}
262
263/// CYP450-based drug-drug interaction screening.
264/// Drug identifiers are `q_hash(rxnorm_name)`.
265pub fn check_drug_interactions(active_medications: &[u64]) -> Vec<DrugInteraction> {
266    // (drug_a, drug_b, severity, mechanism)
267    let pairs: &[(&str, &str, InteractionSeverity, &str)] = &[
268        (
269            "warfarin",
270            "ibuprofen",
271            InteractionSeverity::Major,
272            "CYP2C9 inhibition + antiplatelet effect → major bleeding",
273        ),
274        (
275            "warfarin",
276            "naproxen",
277            InteractionSeverity::Major,
278            "CYP2C9 inhibition + antiplatelet effect → major bleeding",
279        ),
280        (
281            "warfarin",
282            "aspirin",
283            InteractionSeverity::Moderate,
284            "Additive antiplatelet: monitor INR closely",
285        ),
286        (
287            "sertraline",
288            "phenelzine",
289            InteractionSeverity::Contraindicated,
290            "Serotonin syndrome risk (SSRI + MAOI)",
291        ),
292        (
293            "fluoxetine",
294            "selegiline",
295            InteractionSeverity::Contraindicated,
296            "Serotonin syndrome risk (SSRI + MAO-B)",
297        ),
298        (
299            "simvastatin",
300            "clarithromycin",
301            InteractionSeverity::Major,
302            "CYP3A4 inhibition → rhabdomyolysis risk",
303        ),
304        (
305            "atorvastatin",
306            "clarithromycin",
307            InteractionSeverity::Moderate,
308            "CYP3A4 inhibition → myopathy risk",
309        ),
310        (
311            "amiodarone",
312            "ciprofloxacin",
313            InteractionSeverity::Major,
314            "Additive QT prolongation → TdP risk",
315        ),
316        (
317            "methadone",
318            "azithromycin",
319            InteractionSeverity::Major,
320            "Additive QT prolongation → TdP risk",
321        ),
322        (
323            "lisinopril",
324            "spironolactone",
325            InteractionSeverity::Moderate,
326            "Hyperkalaemia risk (ACEi + K-sparing diuretic)",
327        ),
328        (
329            "ramipril",
330            "spironolactone",
331            InteractionSeverity::Moderate,
332            "Hyperkalaemia risk (ACEi + K-sparing diuretic)",
333        ),
334        (
335            "metformin",
336            "iohexol",
337            InteractionSeverity::Major,
338            "Lactic acidosis risk — hold metformin 48h before iodinated contrast",
339        ),
340        (
341            "lithium",
342            "ibuprofen",
343            InteractionSeverity::Major,
344            "NSAIDs reduce renal lithium clearance → toxicity",
345        ),
346        (
347            "lithium",
348            "diclofenac",
349            InteractionSeverity::Major,
350            "NSAIDs reduce renal lithium clearance → toxicity",
351        ),
352        (
353            "methotrexate",
354            "trimethoprim",
355            InteractionSeverity::Major,
356            "Additive folate antagonism → pancytopenia",
357        ),
358        (
359            "digoxin",
360            "amiodarone",
361            InteractionSeverity::Major,
362            "Amiodarone increases digoxin levels → toxicity",
363        ),
364        (
365            "clopidogrel",
366            "omeprazole",
367            InteractionSeverity::Moderate,
368            "CYP2C19 inhibition reduces clopidogrel activation",
369        ),
370        (
371            "tramadol",
372            "sertraline",
373            InteractionSeverity::Moderate,
374            "Serotonin syndrome + seizure threshold lowering",
375        ),
376    ];
377
378    let mut found = Vec::new();
379    for (i, &a) in active_medications.iter().enumerate() {
380        for &b in &active_medications[i + 1..] {
381            for &(na, nb, sev, mech) in pairs {
382                let ha = crate::q_hash(na);
383                let hb = crate::q_hash(nb);
384                if (a == ha && b == hb) || (a == hb && b == ha) {
385                    found.push(DrugInteraction {
386                        drug_a: a,
387                        drug_b: b,
388                        severity: sev,
389                        mechanism: mech,
390                    });
391                }
392            }
393        }
394    }
395    found
396}
397
398// ─── Contraindication checking ────────────────────────────────────────────────
399
400#[derive(Debug, Clone)]
401pub struct ContraindicationResult {
402    pub drug: u64,
403    pub condition_snomed: u64,
404    pub severity: InteractionSeverity,
405    pub reason: &'static str,
406}
407
408/// Checks a single medication (by q_hash name) against active conditions (SNOMED CT q_hashes).
409pub fn check_contraindications(
410    drug_hash: u64,
411    condition_hashes: &[u64],
412) -> Vec<ContraindicationResult> {
413    // (drug_name, snomed_name_as_hashed, severity, reason)
414    let table: &[(&str, &str, InteractionSeverity, &str)] = &[
415        (
416            "metformin",
417            "709044004",
418            InteractionSeverity::Contraindicated,
419            "CKD stage 4/5 (eGFR < 30): lactic acidosis risk",
420        ),
421        (
422            "nsaid",
423            "709044004",
424            InteractionSeverity::Major,
425            "CKD: NSAIDs worsen renal haemodynamics",
426        ),
427        (
428            "ibuprofen",
429            "709044004",
430            InteractionSeverity::Major,
431            "CKD: NSAIDs worsen renal haemodynamics",
432        ),
433        (
434            "naproxen",
435            "709044004",
436            InteractionSeverity::Major,
437            "CKD: NSAIDs worsen renal haemodynamics",
438        ),
439        (
440            "atenolol",
441            "195967001",
442            InteractionSeverity::Contraindicated,
443            "Asthma: non-selective beta-blockers precipitate bronchospasm",
444        ),
445        (
446            "propranolol",
447            "195967001",
448            InteractionSeverity::Contraindicated,
449            "Asthma: non-selective beta-blockers precipitate bronchospasm",
450        ),
451        (
452            "metoprolol",
453            "195967001",
454            InteractionSeverity::Moderate,
455            "Asthma: cardioselective beta-blocker — use with caution",
456        ),
457        (
458            "lithium",
459            "709044004",
460            InteractionSeverity::Contraindicated,
461            "CKD: lithium is renally cleared — nephrotoxicity risk",
462        ),
463        (
464            "clozapine",
465            "84989004",
466            InteractionSeverity::Major,
467            "Seizure disorder: clozapine lowers seizure threshold",
468        ),
469        (
470            "tramadol",
471            "84989004",
472            InteractionSeverity::Major,
473            "Seizure disorder: tramadol lowers seizure threshold",
474        ),
475        (
476            "warfarin",
477            "713078009",
478            InteractionSeverity::Major,
479            "Haemorrhagic stroke history: anticoagulation risk",
480        ),
481        (
482            "amiodarone",
483            "49436004",
484            InteractionSeverity::Major,
485            "Pulmonary disease: amiodarone pulmonary toxicity risk",
486        ),
487        (
488            "thalidomide",
489            "77386006",
490            InteractionSeverity::Contraindicated,
491            "Pregnancy: teratogen (Category X)",
492        ),
493        (
494            "isotretinoin",
495            "77386006",
496            InteractionSeverity::Contraindicated,
497            "Pregnancy: teratogen (Category X)",
498        ),
499        (
500            "methotrexate",
501            "77386006",
502            InteractionSeverity::Contraindicated,
503            "Pregnancy: teratogen — folate antagonist",
504        ),
505        (
506            "sildenafil",
507            "194828000",
508            InteractionSeverity::Contraindicated,
509            "Angina on nitrates: severe hypotension risk",
510        ),
511    ];
512
513    condition_hashes
514        .iter()
515        .filter_map(|&cond| {
516            table
517                .iter()
518                .find(|&&(dn, sn, _, _)| {
519                    crate::q_hash(dn) == drug_hash && crate::q_hash(sn) == cond
520                })
521                .map(|&(_, sn, sev, reason)| ContraindicationResult {
522                    drug: drug_hash,
523                    condition_snomed: crate::q_hash(sn),
524                    severity: sev,
525                    reason,
526                })
527        })
528        .collect()
529}
530
531// ─── FHIR Observation validation ─────────────────────────────────────────────
532
533#[derive(Debug, Clone)]
534pub struct FhirObservation {
535    /// LOINC code string, e.g. "4548-4".
536    pub loinc_code: String,
537    pub value: f64,
538    /// UCUM unit string, e.g. "%", "mmol/L".
539    pub unit_ucum: String,
540    pub reference_low: Option<f64>,
541    pub reference_high: Option<f64>,
542}
543
544#[derive(Debug, Clone, Copy, PartialEq, Eq)]
545pub enum ObservationStatus {
546    Normal,
547    Low,
548    High,
549    CriticalLow,
550    CriticalHigh,
551    Unknown,
552}
553
554#[derive(Debug, Clone)]
555pub struct FhirValidationResult {
556    pub is_valid: bool,
557    pub status: ObservationStatus,
558    /// HL7 interpretation code: N / L / H / LL / HH / U.
559    pub interpretation_code: &'static str,
560}
561
562pub fn validate_fhir_observation(obs: &FhirObservation) -> FhirValidationResult {
563    // (loinc_code, low, high, critical_low, critical_high)
564    const RANGES: &[(&str, f64, f64, f64, f64)] = &[
565        ("4548-4", 4.0, 5.7, 2.0, 15.0),       // HbA1c %
566        ("1558-6", 3.9, 5.5, 2.2, 25.0),       // Fasting glucose mmol/L
567        ("2093-3", 0.0, 5.17, 0.0, 15.0),      // Total cholesterol mmol/L
568        ("2089-1", 0.0, 3.36, 0.0, 12.0),      // LDL mmol/L
569        ("2085-9", 1.0, 3.0, 0.4, 5.0),        // HDL mmol/L
570        ("2571-8", 0.0, 1.7, 0.0, 10.0),       // Triglycerides mmol/L
571        ("62238-1", 60.0, 200.0, 5.0, 200.0),  // eGFR mL/min/1.73m²
572        ("9318-7", 0.0, 3.0, 0.0, 30.0),       // uACR mg/mmol
573        ("38483-4", 45.0, 90.0, 10.0, 1000.0), // Creatinine µmol/L
574        ("1742-6", 0.0, 41.0, 0.0, 1000.0),    // ALT U/L
575        ("1920-8", 0.0, 40.0, 0.0, 1000.0),    // AST U/L
576        ("718-7", 120.0, 170.0, 60.0, 200.0),  // Haemoglobin g/L
577        ("6690-2", 4.0, 11.0, 1.5, 30.0),      // WBC ×10⁹/L
578        ("30522-7", 0.0, 5.0, 0.0, 50.0),      // CRP mg/L
579        ("3016-3", 0.4, 4.0, 0.01, 100.0),     // TSH mIU/L
580        ("3024-7", 9.0, 19.0, 0.0, 50.0),      // Free T4 pmol/L
581        ("2143-6", 0.0, 500.0, 0.0, 2000.0),   // Cortisol AM nmol/L
582        ("1989-3", 50.0, 250.0, 10.0, 400.0),  // Vitamin D nmol/L
583        ("20448-7", 2.6, 24.9, 0.0, 200.0),    // Fasting insulin pmol/L
584        ("8480-6", 90.0, 140.0, 70.0, 220.0),  // Systolic BP mmHg
585        ("8462-4", 60.0, 90.0, 40.0, 140.0),   // Diastolic BP mmHg
586        ("8867-4", 50.0, 100.0, 30.0, 200.0),  // Resting HR bpm
587        ("80404-7", 20.0, 200.0, 0.0, 500.0),  // HRV RMSSD ms
588        ("59408-5", 95.0, 100.0, 85.0, 100.0), // SpO2 %
589        ("44261-6", 0.0, 4.0, 0.0, 27.0),      // PHQ-9 score
590        ("69737-5", 0.0, 4.0, 0.0, 21.0),      // GAD-7 score
591        ("93832-4", 70.0, 100.0, 40.0, 100.0), // Sleep efficiency %
592    ];
593
594    let range = RANGES
595        .iter()
596        .find(|(code, ..)| *code == obs.loinc_code.as_str());
597
598    let status = if let Some(&(_, low, high, crit_low, crit_high)) = range {
599        let v = obs.value;
600        if v < crit_low {
601            ObservationStatus::CriticalLow
602        } else if v > crit_high {
603            ObservationStatus::CriticalHigh
604        } else if v < low {
605            ObservationStatus::Low
606        } else if v > high {
607            ObservationStatus::High
608        } else {
609            ObservationStatus::Normal
610        }
611    } else if let (Some(low), Some(high)) = (obs.reference_low, obs.reference_high) {
612        let v = obs.value;
613        if v < low * 0.5 {
614            ObservationStatus::CriticalLow
615        } else if v > high * 2.0 {
616            ObservationStatus::CriticalHigh
617        } else if v < low {
618            ObservationStatus::Low
619        } else if v > high {
620            ObservationStatus::High
621        } else {
622            ObservationStatus::Normal
623        }
624    } else {
625        ObservationStatus::Unknown
626    };
627
628    let interp = match status {
629        ObservationStatus::Normal => "N",
630        ObservationStatus::Low => "L",
631        ObservationStatus::High => "H",
632        ObservationStatus::CriticalLow => "LL",
633        ObservationStatus::CriticalHigh => "HH",
634        ObservationStatus::Unknown => "U",
635    };
636
637    FhirValidationResult {
638        is_valid: status != ObservationStatus::Unknown,
639        status,
640        interpretation_code: interp,
641    }
642}
643
644// ─── Longitudinal trend analysis ─────────────────────────────────────────────
645
646#[derive(Debug, Clone)]
647pub struct TimePoint {
648    /// Unix timestamp in seconds.
649    pub timestamp_s: i64,
650    pub value: f64,
651}
652
653#[derive(Debug, Clone, Copy, PartialEq, Eq)]
654pub enum TrendDirection {
655    Improving,
656    Worsening,
657    Stable,
658    Insufficient,
659}
660
661#[derive(Debug, Clone)]
662pub struct TrendResult {
663    /// Ordinary least-squares slope in biomarker units per day.
664    pub slope_per_day: f64,
665    /// Coefficient of determination.
666    pub r_squared: f64,
667    /// Predicted value `forecast_days` after the last observation.
668    pub forecast: f64,
669    pub direction: TrendDirection,
670}
671
672/// OLS linear regression over a biomarker time series.
673/// `improvement_direction`: `1` = rising is good (e.g. eGFR), `-1` = falling is good (e.g. BP, HbA1c).
674pub fn longitudinal_trend(
675    series: &[TimePoint],
676    forecast_days: f64,
677    improvement_direction: i8,
678) -> TrendResult {
679    if series.len() < 2 {
680        let v = series.first().map(|p| p.value).unwrap_or(0.0);
681        return TrendResult {
682            slope_per_day: 0.0,
683            r_squared: 0.0,
684            forecast: v,
685            direction: TrendDirection::Insufficient,
686        };
687    }
688
689    let t0 = series[0].timestamp_s as f64;
690    let xs: Vec<f64> = series
691        .iter()
692        .map(|p| (p.timestamp_s as f64 - t0) / 86400.0)
693        .collect();
694    let ys: Vec<f64> = series.iter().map(|p| p.value).collect();
695    let n = xs.len() as f64;
696
697    let sx: f64 = xs.iter().sum();
698    let sy: f64 = ys.iter().sum();
699    let sxx: f64 = xs.iter().map(|x| x * x).sum();
700    let sxy: f64 = xs.iter().zip(ys.iter()).map(|(x, y)| x * y).sum();
701    let denom = n * sxx - sx * sx;
702
703    if denom.abs() < 1e-10 {
704        return TrendResult {
705            slope_per_day: 0.0,
706            r_squared: 1.0,
707            forecast: ys[0],
708            direction: TrendDirection::Stable,
709        };
710    }
711
712    let slope = (n * sxy - sx * sy) / denom;
713    let intercept = (sy - slope * sx) / n;
714    let y_mean = sy / n;
715
716    let ss_res: f64 = ys
717        .iter()
718        .zip(xs.iter())
719        .map(|(y, x)| (y - (intercept + slope * x)).powi(2))
720        .sum();
721    let ss_tot: f64 = ys.iter().map(|y| (y - y_mean).powi(2)).sum();
722    let r_squared = if ss_tot < 1e-10 {
723        1.0
724    } else {
725        1.0 - ss_res / ss_tot
726    };
727
728    let last_x = xs.last().copied().unwrap_or(0.0);
729    let forecast = intercept + slope * (last_x + forecast_days);
730
731    let direction = if slope.abs() < 0.001 {
732        TrendDirection::Stable
733    } else if (slope > 0.0 && improvement_direction > 0)
734        || (slope < 0.0 && improvement_direction < 0)
735    {
736        TrendDirection::Improving
737    } else {
738        TrendDirection::Worsening
739    };
740
741    TrendResult {
742        slope_per_day: slope,
743        r_squared,
744        forecast,
745        direction,
746    }
747}
748
749// ─── Gene expression evaluation ──────────────────────────────────────────────
750
751#[derive(Debug, Clone, Copy, PartialEq, Eq)]
752pub enum ExpressionDirection {
753    Upregulated,
754    Downregulated,
755    Unchanged,
756}
757
758#[derive(Debug, Clone)]
759pub struct GeneExpressionResult {
760    pub gene_id: u64,
761    pub fold_change: f64,
762    pub log2_fold_change: f64,
763    pub is_significant: bool,
764    pub direction: ExpressionDirection,
765}
766
767/// Evaluates normalised expression (RPKM/TPM) against a fold-change threshold.
768pub fn evaluate_gene_expression(
769    gene_id: u64,
770    baseline: f64,
771    treatment: f64,
772    fc_threshold: f64,
773) -> GeneExpressionResult {
774    let fold_change = if baseline > 1e-9 {
775        treatment / baseline
776    } else {
777        f64::INFINITY
778    };
779    let log2_fc = if fold_change.is_finite() {
780        fold_change.log2()
781    } else {
782        f64::INFINITY
783    };
784    let is_significant = fold_change >= fc_threshold
785        || (fold_change.is_finite() && fold_change <= 1.0 / fc_threshold);
786
787    let direction = if !is_significant {
788        ExpressionDirection::Unchanged
789    } else if fold_change >= 1.0 {
790        ExpressionDirection::Upregulated
791    } else {
792        ExpressionDirection::Downregulated
793    };
794
795    GeneExpressionResult {
796        gene_id,
797        fold_change,
798        log2_fold_change: log2_fc,
799        is_significant,
800        direction,
801    }
802}
803
804// ─── Renal Function Estimation ───────────────────────────────────────────────
805
806#[derive(Debug, Clone)]
807pub struct RenalInput {
808    pub age: u8,
809    pub sex_male: bool,
810    pub weight_kg: f64,
811    /// Serum creatinine in mg/dL
812    pub serum_creatinine: f64,
813}
814
815/// Computes Creatinine Clearance (CrCl) via Cockcroft-Gault equation.
816pub fn cockcroft_gault_crcl(input: &RenalInput) -> f64 {
817    let mut crcl = ((140.0 - input.age as f64) * input.weight_kg) / (72.0 * input.serum_creatinine);
818    if !input.sex_male {
819        crcl *= 0.85;
820    }
821    crcl
822}
823
824/// Computes eGFR using the 2021 CKD-EPI equation (creatinine, without race).
825pub fn ckd_epi_egfr(input: &RenalInput) -> f64 {
826    let k = if input.sex_male { 0.9 } else { 0.7 };
827    let a = if input.sex_male { -0.302 } else { -0.241 };
828
829    let scr_k = input.serum_creatinine / k;
830    let min_val = scr_k.min(1.0);
831    let max_val = scr_k.max(1.0);
832
833    let mut egfr =
834        142.0 * min_val.powf(a) * max_val.powf(-1.200) * 0.9938_f64.powf(input.age as f64);
835    if !input.sex_male {
836        egfr *= 1.012;
837    }
838    egfr
839}
840
841// ─── Pharmacokinetics (PK) ───────────────────────────────────────────────────
842
843#[derive(Debug, Clone)]
844pub struct PkOneCompartmentInput {
845    pub dose_mg: f64,
846    /// Volume of distribution (L)
847    pub volume_distribution_l: f64,
848    /// Clearance (L/hr)
849    pub clearance_l_hr: f64,
850    /// Time since dose (hours)
851    pub time_hr: f64,
852}
853
854#[derive(Debug, Clone)]
855pub struct PkResult {
856    /// Concentration at time t (mg/L)
857    pub concentration: f64,
858    /// Half-life (hours)
859    pub half_life_hr: f64,
860}
861
862/// Predicts drug concentration using a 1-compartment IV bolus model.
863pub fn one_compartment_pk_model(input: &PkOneCompartmentInput) -> PkResult {
864    let k_el = input.clearance_l_hr / input.volume_distribution_l;
865    let c0 = input.dose_mg / input.volume_distribution_l;
866    let concentration = c0 * (-k_el * input.time_hr).exp();
867    let half_life_hr = 0.693147 / k_el;
868
869    PkResult {
870        concentration,
871        half_life_hr,
872    }
873}
874
875// ─── SOFA Score (Sequential Organ Failure Assessment) ────────────────────────
876
877#[derive(Debug, Clone, Default)]
878pub struct SofaInput {
879    pub pao2_fio2_ratio: f64, // mmHg
880    pub platelets_10_9_l: f64,
881    pub bilirubin_mg_dl: f64,
882    pub map_mmhg: f64,            // Mean arterial pressure
883    pub dopamine_dose: f64,       // ug/kg/min
884    pub epinephrine_dose: f64,    // ug/kg/min
885    pub norepinephrine_dose: f64, // ug/kg/min
886    pub glasgow_coma_scale: u8,
887    pub creatinine_mg_dl: f64,
888    pub urine_output_ml_d: f64,
889}
890
891/// Evaluates acute sepsis morbidity via the SOFA score (0-24).
892pub fn sofa_score(input: &SofaInput) -> u8 {
893    let mut score = 0;
894
895    // Respiration
896    if input.pao2_fio2_ratio > 0.0 {
897        if input.pao2_fio2_ratio < 100.0 {
898            score += 4;
899        } else if input.pao2_fio2_ratio < 200.0 {
900            score += 3;
901        } else if input.pao2_fio2_ratio < 300.0 {
902            score += 2;
903        } else if input.pao2_fio2_ratio < 400.0 {
904            score += 1;
905        }
906    }
907
908    // Coagulation (Platelets)
909    if input.platelets_10_9_l > 0.0 {
910        if input.platelets_10_9_l < 20.0 {
911            score += 4;
912        } else if input.platelets_10_9_l < 50.0 {
913            score += 3;
914        } else if input.platelets_10_9_l < 100.0 {
915            score += 2;
916        } else if input.platelets_10_9_l < 150.0 {
917            score += 1;
918        }
919    }
920
921    // Liver (Bilirubin)
922    if input.bilirubin_mg_dl >= 12.0 {
923        score += 4;
924    } else if input.bilirubin_mg_dl >= 6.0 {
925        score += 3;
926    } else if input.bilirubin_mg_dl >= 2.0 {
927        score += 2;
928    } else if input.bilirubin_mg_dl >= 1.2 {
929        score += 1;
930    }
931
932    // Cardiovascular
933    if input.dopamine_dose > 15.0 || input.epinephrine_dose > 0.1 || input.norepinephrine_dose > 0.1
934    {
935        score += 4;
936    } else if input.dopamine_dose > 5.0
937        || (input.epinephrine_dose > 0.0 && input.epinephrine_dose <= 0.1)
938        || (input.norepinephrine_dose > 0.0 && input.norepinephrine_dose <= 0.1)
939    {
940        score += 3;
941    } else if input.dopamine_dose > 0.0 {
942        score += 2;
943    } else if input.map_mmhg > 0.0 && input.map_mmhg < 70.0 {
944        score += 1;
945    }
946
947    // Central Nervous System (GCS)
948    if input.glasgow_coma_scale > 0 {
949        if input.glasgow_coma_scale < 6 {
950            score += 4;
951        } else if input.glasgow_coma_scale <= 9 {
952            score += 3;
953        } else if input.glasgow_coma_scale <= 12 {
954            score += 2;
955        } else if input.glasgow_coma_scale <= 14 {
956            score += 1;
957        }
958    }
959
960    // Renal
961    if input.creatinine_mg_dl >= 5.0
962        || (input.urine_output_ml_d < 200.0 && input.urine_output_ml_d > 0.0)
963    {
964        score += 4;
965    } else if input.creatinine_mg_dl >= 3.5
966        || (input.urine_output_ml_d < 500.0 && input.urine_output_ml_d > 0.0)
967    {
968        score += 3;
969    } else if input.creatinine_mg_dl >= 2.0 {
970        score += 2;
971    } else if input.creatinine_mg_dl >= 1.2 {
972        score += 1;
973    }
974
975    score
976}
977
978// ─── Tests ────────────────────────────────────────────────────────────────────
979
980#[cfg(test)]
981mod tests {
982    use super::*;
983
984    #[test]
985    fn framingham_high_risk_male() {
986        let r = framingham_10yr_risk(&FraminghamInput {
987            age: 60,
988            sex_male: true,
989            total_cholesterol_mmol: 6.5,
990            hdl_cholesterol_mmol: 0.9,
991            systolic_bp: 162.0,
992            bp_treated: false,
993            current_smoker: true,
994            diabetic: true,
995        });
996        assert!(r.risk_10yr > 0.20, "Expected >20% got {:.2}", r.risk_10yr);
997        assert_eq!(r.category, RiskCategory::High);
998    }
999
1000    #[test]
1001    fn framingham_low_risk_female() {
1002        let r = framingham_10yr_risk(&FraminghamInput {
1003            age: 40,
1004            sex_male: false,
1005            total_cholesterol_mmol: 4.5,
1006            hdl_cholesterol_mmol: 1.8,
1007            systolic_bp: 115.0,
1008            bp_treated: false,
1009            current_smoker: false,
1010            diabetic: false,
1011        });
1012        assert!(r.risk_10yr < 0.10, "Expected <10% got {:.2}", r.risk_10yr);
1013        assert_eq!(r.category, RiskCategory::Low);
1014    }
1015
1016    #[test]
1017    fn cha2ds2_max_score() {
1018        let r = cha2ds2_vasc_score(&Cha2ds2VascInput {
1019            congestive_heart_failure: true,
1020            hypertension: true,
1021            age_75_or_older: true,
1022            diabetes: true,
1023            stroke_tia_history: true,
1024            vascular_disease: true,
1025            age_65_to_74: false,
1026            sex_female: true,
1027        });
1028        assert_eq!(r.score, 9);
1029        assert!(r.anticoagulation_recommended);
1030    }
1031
1032    #[test]
1033    fn cha2ds2_zero_male() {
1034        let r = cha2ds2_vasc_score(&Cha2ds2VascInput::default());
1035        assert_eq!(r.score, 0);
1036        assert!(!r.anticoagulation_recommended);
1037    }
1038
1039    #[test]
1040    fn drug_interaction_warfarin_ibuprofen() {
1041        let meds = vec![crate::q_hash("warfarin"), crate::q_hash("ibuprofen")];
1042        let found = check_drug_interactions(&meds);
1043        assert!(!found.is_empty());
1044        assert_eq!(found[0].severity, InteractionSeverity::Major);
1045    }
1046
1047    #[test]
1048    fn drug_interaction_no_false_positive() {
1049        let meds = vec![crate::q_hash("paracetamol"), crate::q_hash("lactulose")];
1050        let found = check_drug_interactions(&meds);
1051        assert!(found.is_empty());
1052    }
1053
1054    #[test]
1055    fn fhir_hba1c_normal() {
1056        let r = validate_fhir_observation(&FhirObservation {
1057            loinc_code: "4548-4".into(),
1058            value: 5.2,
1059            unit_ucum: "%".into(),
1060            reference_low: None,
1061            reference_high: None,
1062        });
1063        assert_eq!(r.status, ObservationStatus::Normal);
1064        assert_eq!(r.interpretation_code, "N");
1065    }
1066
1067    #[test]
1068    fn fhir_hba1c_high() {
1069        let r = validate_fhir_observation(&FhirObservation {
1070            loinc_code: "4548-4".into(),
1071            value: 8.5,
1072            unit_ucum: "%".into(),
1073            reference_low: None,
1074            reference_high: None,
1075        });
1076        assert_eq!(r.status, ObservationStatus::High);
1077        assert_eq!(r.interpretation_code, "H");
1078    }
1079
1080    #[test]
1081    fn trend_worsening_bp() {
1082        let series = vec![
1083            TimePoint {
1084                timestamp_s: 0,
1085                value: 120.0,
1086            },
1087            TimePoint {
1088                timestamp_s: 86400,
1089                value: 125.0,
1090            },
1091            TimePoint {
1092                timestamp_s: 172800,
1093                value: 130.0,
1094            },
1095            TimePoint {
1096                timestamp_s: 259200,
1097                value: 135.0,
1098            },
1099        ];
1100        let r = longitudinal_trend(&series, 7.0, -1);
1101        assert!(r.slope_per_day > 4.0);
1102        assert_eq!(r.direction, TrendDirection::Worsening);
1103        assert!(r.r_squared > 0.99);
1104    }
1105
1106    #[test]
1107    fn gene_expression_upregulated() {
1108        let r = evaluate_gene_expression(0xDEAD, 100.0, 350.0, 2.0);
1109        assert!(r.is_significant);
1110        assert_eq!(r.direction, ExpressionDirection::Upregulated);
1111        assert!((r.log2_fold_change - 1.807).abs() < 0.01);
1112    }
1113
1114    #[test]
1115    fn test_cockcroft_gault() {
1116        let input = RenalInput {
1117            age: 60,
1118            sex_male: true,
1119            weight_kg: 80.0,
1120            serum_creatinine: 1.2,
1121        };
1122        let crcl = cockcroft_gault_crcl(&input);
1123        assert!((crcl - 74.07).abs() < 0.1);
1124    }
1125
1126    #[test]
1127    fn test_ckd_epi() {
1128        let input = RenalInput {
1129            age: 60,
1130            sex_male: false,
1131            weight_kg: 70.0,
1132            serum_creatinine: 1.2,
1133        };
1134        let egfr = ckd_epi_egfr(&input);
1135        assert!((egfr - 51.5).abs() < 1.0);
1136    }
1137
1138    #[test]
1139    fn test_one_compartment_pk() {
1140        let input = PkOneCompartmentInput {
1141            dose_mg: 1000.0,
1142            volume_distribution_l: 50.0,
1143            clearance_l_hr: 5.0,
1144            time_hr: 10.0,
1145        };
1146        let pk = one_compartment_pk_model(&input);
1147        assert!((pk.concentration - 7.35).abs() < 0.1);
1148        assert!((pk.half_life_hr - 6.93).abs() < 0.1);
1149    }
1150
1151    #[test]
1152    fn test_sofa_score() {
1153        let input = SofaInput {
1154            pao2_fio2_ratio: 250.0, // 2 points
1155            platelets_10_9_l: 80.0, // 2 points
1156            bilirubin_mg_dl: 3.0,   // 2 points
1157            map_mmhg: 65.0,         // 1 point
1158            dopamine_dose: 0.0,
1159            epinephrine_dose: 0.0,
1160            norepinephrine_dose: 0.0,
1161            glasgow_coma_scale: 13, // 1 point
1162            creatinine_mg_dl: 2.5,  // 2 points
1163            urine_output_ml_d: 0.0,
1164        };
1165        let score = sofa_score(&input);
1166        assert_eq!(score, 10);
1167    }
1168}