use crate::{AbstainReason, Atom, Hyp, Observation, OntologySystem, Operator, Outcome};
pub struct KdigoAkiOperator {
pub version: String,
}
impl KdigoAkiOperator {
pub fn new(version: impl Into<String>) -> Self {
KdigoAkiOperator {
version: version.into(),
}
}
fn extract_baseline_creatinine(observations: &[Observation]) -> Option<f64> {
observations
.iter()
.find(|obs| obs.code == "LOINC:2160-0-baseline")
.or_else(|| observations.iter().find(|obs| obs.code == "LOINC:2160-0"))
.and_then(|obs| obs.value.as_f64())
.and_then(|val| if val > 0.0 { Some(val) } else { None })
}
fn extract_current_creatinine(observations: &[Observation]) -> Option<f64> {
observations
.iter()
.find(|obs| obs.code == "LOINC:2160-0-current")
.and_then(|obs| obs.value.as_f64())
}
fn extract_urine_output_rate(observations: &[Observation]) -> Option<f64> {
observations
.iter()
.find(|obs| obs.code == "LOINC:9192-0" || obs.code == "LOINC:9192-0-rate")
.and_then(|obs| obs.value.as_f64())
}
fn determine_stage(
baseline_cr: f64,
current_cr: Option<f64>,
uo_rate: Option<f64>,
) -> Option<(u32, &'static str)> {
let mut max_stage = 0;
let mut source = "none";
if let Some(cr) = current_cr {
let fold_change = cr / baseline_cr;
if fold_change >= 3.0 || (cr >= 4.0 && (cr - baseline_cr) >= 0.5) {
max_stage = 3;
source = "creatinine";
} else if fold_change >= 2.0 {
max_stage = 2;
source = "creatinine";
} else if fold_change >= 1.5 {
max_stage = 1;
source = "creatinine";
}
}
if let Some(rate) = uo_rate {
if rate < 0.3 {
if max_stage < 3 {
max_stage = 3;
source = "urine-output";
}
} else if rate < 0.5 {
if max_stage < 2 {
max_stage = 2;
source = "urine-output";
} else if max_stage < 1 {
max_stage = 1;
source = "urine-output";
}
}
}
if max_stage > 0 {
Some((max_stage, source))
} else {
None
}
}
fn stage_to_atom(&self, stage: u32, source: &str) -> Atom {
Atom {
system: OntologySystem::SNOMED,
code: format!("KDIGO-AKI-STAGE-{}", stage),
preferred_term: format!("AKI Stage {} ({})", stage, source),
version: self.version.clone(),
}
}
}
impl Operator for KdigoAkiOperator {
fn apply(&self, h: &Hyp, e: &crate::Evidence) -> Outcome<Hyp, AbstainReason> {
let baseline_cr = match Self::extract_baseline_creatinine(&e.observations) {
Some(cr) => cr,
None => {
return Outcome::Abstain(AbstainReason::InsufficientEvidence(
"baseline serum creatinine unknown or invalid (≤0); cannot compute AKI stage",
));
}
};
let current_cr = Self::extract_current_creatinine(&e.observations);
let uo_rate = Self::extract_urine_output_rate(&e.observations);
if uo_rate.is_some() {
return Outcome::Abstain(AbstainReason::InsufficientEvidence(
"urine output window duration unknown; cannot validate KDIGO temporal constraints (6-24h)",
));
}
match Self::determine_stage(baseline_cr, current_cr, None) {
Some((stage, source)) => {
let aki_atom = self.stage_to_atom(stage, source);
let mut new_atoms = h.atoms().to_vec();
new_atoms.push(aki_atom);
Outcome::Refined(Hyp::new(new_atoms))
}
None => {
Outcome::Refined(h.clone())
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Evidence, Observation, Provenance, ProvenanceOrigin};
use chrono::Utc;
use std::collections::BTreeMap;
fn test_evidence_with_observations(observations: Vec<Observation>) -> Evidence {
Evidence::new(
observations,
Provenance::new(
ProvenanceOrigin::new("test", "LOINC", "test"),
Utc::now(),
crate::Ver::new("test", "kdigo_aki", "0.1.0"),
BTreeMap::new(),
),
)
}
#[test]
fn test_kdigo_aki_stage_1_creatinine() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![
Observation::new("LOINC:2160-0-baseline", serde_json::json!(0.9)), Observation::new("LOINC:2160-0-current", serde_json::json!(1.5)), ];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
assert!(
h_prime <= h,
"INV-PS-03: refined hypothesis must refine input"
);
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("STAGE-1")));
}
Outcome::Abstain(_) => panic!("should refine, not abstain"),
}
}
#[test]
fn test_kdigo_aki_stage_2_creatinine() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![
Observation::new("LOINC:2160-0-baseline", serde_json::json!(1.0)),
Observation::new("LOINC:2160-0-current", serde_json::json!(2.5)), ];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
assert!(h_prime <= h);
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("STAGE-2")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_kdigo_aki_stage_3_creatinine() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![
Observation::new("LOINC:2160-0-baseline", serde_json::json!(1.0)),
Observation::new("LOINC:2160-0-current", serde_json::json!(3.5)), ];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("STAGE-3")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_kdigo_aki_stage_3_absolute_cr() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![
Observation::new("LOINC:2160-0-baseline", serde_json::json!(3.8)),
Observation::new("LOINC:2160-0-current", serde_json::json!(4.5)), ];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("STAGE-3")));
}
Outcome::Abstain(_) => panic!("should refine on absolute Cr criterion"),
}
}
#[test]
fn test_kdigo_aki_no_false_stage_3_on_chronic_ckd() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![
Observation::new("LOINC:2160-0-baseline", serde_json::json!(4.5)),
Observation::new("LOINC:2160-0-current", serde_json::json!(4.6)), ];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
let atoms = h_prime.atoms();
assert!(!atoms.iter().any(|a| a.code.contains("STAGE-3")));
assert_eq!(h_prime, h);
}
Outcome::Abstain(_) => panic!("should refine to stage 0"),
}
}
#[test]
fn test_kdigo_aki_abstain_uo_without_window() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![
Observation::new("LOINC:2160-0-baseline", serde_json::json!(1.0)),
Observation::new("LOINC:2160-0-current", serde_json::json!(1.0)),
Observation::new("LOINC:9192-0", serde_json::json!(0.4)), ];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Abstain(reason) => {
assert!(reason.message().contains("window duration unknown"));
}
Outcome::Refined(_) => panic!("should abstain on UO without window duration"),
}
}
#[test]
fn test_kdigo_aki_stage_0_creatinine_only() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![
Observation::new("LOINC:2160-0-baseline", serde_json::json!(1.0)),
Observation::new("LOINC:2160-0-current", serde_json::json!(1.0)), ];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
assert_eq!(h_prime, h, "no refinement when no AKI criteria met");
}
Outcome::Abstain(_) => panic!("should refine to identity, not abstain"),
}
}
#[test]
fn test_kdigo_aki_abstain_no_baseline() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![Observation::new(
"LOINC:2160-0-current",
serde_json::json!(1.5),
)];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Abstain(reason) => {
assert!(
reason
.message()
.contains("baseline serum creatinine unknown")
);
}
Outcome::Refined(_) => panic!("should abstain when baseline Cr missing"),
}
}
#[test]
fn test_kdigo_aki_monotonicity_preserved() {
let op = KdigoAkiOperator::new("0.1.0");
let observations = vec![
Observation::new("LOINC:2160-0-baseline", serde_json::json!(1.0)),
Observation::new("LOINC:2160-0-current", serde_json::json!(2.0)), ];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Refined(h_prime) => {
assert!(
h_prime <= h,
"INV-PS-03: refined hypothesis must refine input"
);
}
Outcome::Abstain(_) => panic!("test setup should not abstain"),
}
}
}