use serde::{Deserialize, Serialize};
use crate::{AbstainReason, Atom, Hyp, OntologySystem, Outcome, Provenance};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Observation {
pub code: String,
pub value: serde_json::Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
impl Observation {
pub fn new(code: impl Into<String>, value: serde_json::Value) -> Self {
Self {
code: code.into(),
value,
unit: None,
source: None,
}
}
pub fn with_unit(mut self, unit: impl Into<String>) -> Self {
self.unit = Some(unit.into());
self
}
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Evidence {
pub observations: Vec<Observation>,
pub provenance: Provenance,
}
impl Evidence {
pub fn new(observations: Vec<Observation>, provenance: Provenance) -> Self {
Self {
observations,
provenance,
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
serde_json::from_str(json)
}
}
pub fn abstract_evidence(e: &Evidence) -> Hyp {
let mut atoms = Vec::new();
for obs in &e.observations {
if let Some(atom) = parse_observation_code(&obs.code, &e.provenance) {
atoms.push(atom);
}
}
if atoms.is_empty() {
Hyp::unknown()
} else {
Hyp::new(atoms)
}
}
fn parse_observation_code(code: &str, prov: &Provenance) -> Option<Atom> {
let parts: Vec<&str> = code.splitn(2, ':').collect();
if parts.len() != 2 {
return None;
}
let system_str = parts[0];
let code_part = parts[1];
let system = match system_str {
"SNOMED" => OntologySystem::SNOMED,
"LOINC" => OntologySystem::LOINC,
"RxNorm" => OntologySystem::RxNorm,
"ICD11" => OntologySystem::ICD11,
_ => return None,
};
let version = prov.version.build.clone();
Some(Atom {
system,
code: code_part.to_string(),
preferred_term: format!("{} ({})", code_part, system_str),
version,
})
}
pub fn is_consistent_with(h: &Hyp, e: &Evidence) -> bool {
let e_abstracted = abstract_evidence(e);
let e_atoms = e_abstracted.atoms();
for h_atom in h.atoms() {
let found_compatible = e_atoms.iter().any(|e_atom| {
h_atom.system == e_atom.system
&& h_atom.code == e_atom.code
&& h_atom.version == e_atom.version
});
if !found_compatible {
return false;
}
}
true
}
pub trait Operator: Send + Sync {
fn apply(&self, h: &Hyp, e: &Evidence) -> Outcome<Hyp, AbstainReason>;
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use chrono::Utc;
fn test_observation() -> Observation {
Observation::new("LOINC:2160-0", serde_json::json!(98.0))
.with_unit("mg/dL")
.with_source("Epic LIS")
}
fn test_provenance() -> Provenance {
use crate::ProvenanceOrigin;
let origin = ProvenanceOrigin::new("external_lab_api", "LOINC", "2160-0");
let mut metadata = BTreeMap::new();
metadata.insert("lab_system".to_string(), serde_json::json!("epic_lis"));
Provenance::new(
origin,
Utc::now(),
crate::Ver::new("clinlat", "lab_ingest", "0.1.0"),
metadata,
)
}
#[test]
fn test_observation_creation() {
let obs = test_observation();
assert_eq!(obs.code, "LOINC:2160-0");
assert_eq!(obs.value, serde_json::json!(98.0));
assert_eq!(obs.unit, Some("mg/dL".to_string()));
assert_eq!(obs.source, Some("Epic LIS".to_string()));
}
#[test]
fn test_observation_without_unit_or_source() {
let obs = Observation::new("SNOMED:67822003", serde_json::json!(true));
assert_eq!(obs.code, "SNOMED:67822003");
assert_eq!(obs.unit, None);
assert_eq!(obs.source, None);
}
#[test]
fn test_evidence_creation() {
let observations = vec![test_observation()];
let provenance = test_provenance();
let evidence = Evidence::new(observations, provenance);
assert_eq!(evidence.observations.len(), 1);
assert_eq!(evidence.observations[0].code, "LOINC:2160-0");
assert!(!evidence.provenance.metadata.is_empty());
}
#[test]
fn test_evidence_multiple_observations() {
let observations = vec![
Observation::new("LOINC:2160-0", serde_json::json!(98.0)).with_unit("mg/dL"),
Observation::new("SNOMED:6797001", serde_json::json!(120)).with_unit("mmHg"),
];
let evidence = Evidence::new(observations, test_provenance());
assert_eq!(evidence.observations.len(), 2);
assert_eq!(evidence.observations[0].code, "LOINC:2160-0");
assert_eq!(evidence.observations[1].code, "SNOMED:6797001");
}
#[test]
fn test_evidence_json_serialization() {
let evidence = Evidence::new(vec![test_observation()], test_provenance());
let json = evidence.to_json().expect("serialization failed");
assert!(json.contains("\"code\":\"LOINC:2160-0\""));
assert!(json.contains("\"observations\""));
assert!(json.contains("\"provenance\""));
}
#[test]
fn test_evidence_json_round_trip() {
let original = Evidence::new(vec![test_observation()], test_provenance());
let json = original.to_json().expect("serialization failed");
let restored = Evidence::from_json(&json).expect("deserialization failed");
assert_eq!(original.observations.len(), restored.observations.len());
assert_eq!(original.observations[0].code, restored.observations[0].code);
assert_eq!(original.provenance.origin, restored.provenance.origin);
}
#[test]
fn test_observation_with_array_value() {
let obs = Observation::new("CUSTOM:array_test", serde_json::json!([1, 2, 3]));
assert_eq!(obs.value, serde_json::json!([1, 2, 3]));
}
#[test]
fn test_observation_with_string_value() {
let obs = Observation::new("CUSTOM:text_test", serde_json::json!("clinical finding"));
assert_eq!(obs.value, serde_json::json!("clinical finding"));
}
#[test]
fn test_abstract_evidence_single_observation() {
let observations =
vec![Observation::new("LOINC:2160-0", serde_json::json!(98.0)).with_unit("mg/dL")];
let provenance = test_provenance();
let evidence = Evidence::new(observations, provenance);
let hyp = abstract_evidence(&evidence);
assert!(!hyp.atoms().is_empty());
assert_eq!(hyp.atoms().len(), 1);
let atom = &hyp.atoms()[0];
assert_eq!(atom.system, crate::OntologySystem::LOINC);
assert_eq!(atom.code, "2160-0");
}
#[test]
fn test_abstract_evidence_multiple_observations() {
let observations = vec![
Observation::new("LOINC:2160-0", serde_json::json!(98.0)).with_unit("mg/dL"),
Observation::new("SNOMED:67822003", serde_json::json!(true)),
];
let provenance = test_provenance();
let evidence = Evidence::new(observations, provenance);
let hyp = abstract_evidence(&evidence);
assert_eq!(hyp.atoms().len(), 2);
let systems: std::collections::HashSet<_> = hyp.atoms().iter().map(|a| a.system).collect();
assert!(systems.contains(&crate::OntologySystem::LOINC));
assert!(systems.contains(&crate::OntologySystem::SNOMED));
}
#[test]
fn test_abstract_evidence_empty_observations() {
let observations = vec![];
let provenance = test_provenance();
let evidence = Evidence::new(observations, provenance);
let hyp = abstract_evidence(&evidence);
assert_eq!(hyp.atoms().len(), 0);
assert!(hyp == Hyp::unknown());
}
#[test]
fn test_abstract_evidence_invalid_code_format() {
let observations = vec![
Observation::new("invalid_code", serde_json::json!(1)),
Observation::new("LOINC:2160-0", serde_json::json!(98.0)),
];
let provenance = test_provenance();
let evidence = Evidence::new(observations, provenance);
let hyp = abstract_evidence(&evidence);
assert_eq!(hyp.atoms().len(), 1);
assert_eq!(hyp.atoms()[0].code, "2160-0");
}
#[test]
fn test_abstract_evidence_version_from_provenance() {
use crate::ProvenanceOrigin;
let observations = vec![Observation::new("SNOMED:67822003", serde_json::json!(true))];
let origin = ProvenanceOrigin::new("test_source", "SNOMED", "67822003");
let prov = Provenance::new(
origin,
Utc::now(),
crate::Ver::new("clinlat", "test_op", "1.2.3"),
BTreeMap::new(),
);
let evidence = Evidence::new(observations, prov);
let hyp = abstract_evidence(&evidence);
assert!(!hyp.atoms().is_empty());
let atom = &hyp.atoms()[0];
assert_eq!(atom.version, "1.2.3");
}
#[test]
fn test_is_consistent_with_exact_match() {
let atom = Atom {
system: crate::OntologySystem::LOINC,
code: "2160-0".to_string(),
preferred_term: "Glucose (LOINC)".to_string(),
version: "0.1.0".to_string(),
};
let hyp = Hyp::new(vec![atom]);
let observations = vec![Observation::new("LOINC:2160-0", serde_json::json!(98.0))];
let prov = test_provenance();
let evidence = Evidence::new(observations, prov);
assert!(is_consistent_with(&hyp, &evidence));
}
#[test]
fn test_is_consistent_with_multiple_atoms_and_observations() {
let atom1 = Atom {
system: crate::OntologySystem::LOINC,
code: "2160-0".to_string(),
preferred_term: "Glucose (LOINC)".to_string(),
version: "0.1.0".to_string(),
};
let atom2 = Atom {
system: crate::OntologySystem::SNOMED,
code: "67822003".to_string(),
preferred_term: "Hypoxemia (SNOMED)".to_string(),
version: "0.1.0".to_string(),
};
let hyp = Hyp::new(vec![atom1, atom2]);
let observations = vec![
Observation::new("LOINC:2160-0", serde_json::json!(98.0)).with_unit("mg/dL"),
Observation::new("SNOMED:67822003", serde_json::json!(true)),
];
let prov = test_provenance();
let evidence = Evidence::new(observations, prov);
assert!(is_consistent_with(&hyp, &evidence));
}
#[test]
fn test_is_consistent_with_unknown_hypothesis() {
let hyp = Hyp::unknown();
let observations = vec![Observation::new("LOINC:2160-0", serde_json::json!(98.0))];
let prov = test_provenance();
let evidence = Evidence::new(observations, prov);
assert!(is_consistent_with(&hyp, &evidence));
}
#[test]
fn test_is_consistent_with_mismatched_atoms() {
let atom = Atom {
system: crate::OntologySystem::LOINC,
code: "2160-0".to_string(),
preferred_term: "Glucose (LOINC)".to_string(),
version: "0.1.0".to_string(),
};
let hyp = Hyp::new(vec![atom]);
let observations = vec![Observation::new("SNOMED:67822003", serde_json::json!(true))];
let prov = test_provenance();
let evidence = Evidence::new(observations, prov);
assert!(!is_consistent_with(&hyp, &evidence));
}
#[test]
fn test_is_consistent_with_empty_evidence() {
let atom = Atom {
system: crate::OntologySystem::LOINC,
code: "2160-0".to_string(),
preferred_term: "Glucose (LOINC)".to_string(),
version: "0.1.0".to_string(),
};
let hyp = Hyp::new(vec![atom]);
let observations = vec![];
let prov = test_provenance();
let evidence = Evidence::new(observations, prov);
assert!(!is_consistent_with(&hyp, &evidence));
}
#[test]
fn test_is_consistent_with_evidence_subset() {
let atom = Atom {
system: crate::OntologySystem::LOINC,
code: "2160-0".to_string(),
preferred_term: "Glucose (LOINC)".to_string(),
version: "0.1.0".to_string(),
};
let hyp = Hyp::new(vec![atom]);
let observations = vec![
Observation::new("LOINC:2160-0", serde_json::json!(98.0)),
Observation::new("SNOMED:67822003", serde_json::json!(true)),
];
let prov = test_provenance();
let evidence = Evidence::new(observations, prov);
assert!(is_consistent_with(&hyp, &evidence));
}
#[test]
fn test_is_consistent_with_galois_adjunction_property() {
let atom = Atom {
system: crate::OntologySystem::LOINC,
code: "2160-0".to_string(),
preferred_term: "Glucose (LOINC)".to_string(),
version: "0.1.0".to_string(),
};
let hyp = Hyp::new(vec![atom]);
let observations = vec![Observation::new("LOINC:2160-0", serde_json::json!(98.0))];
let prov = test_provenance();
let evidence = Evidence::new(observations, prov);
let e_abstracted = abstract_evidence(&evidence);
assert!(is_consistent_with(&hyp, &evidence));
assert_eq!(e_abstracted.atoms().len(), 1);
assert_eq!(hyp.atoms().len(), 1);
let e_atom = &e_abstracted.atoms()[0];
let h_atom = &hyp.atoms()[0];
assert_eq!(e_atom.system, h_atom.system);
assert_eq!(e_atom.code, h_atom.code);
assert_eq!(e_atom.version, h_atom.version);
}
}
#[cfg(test)]
mod proptest_galois_laws {
use super::*;
use crate::{OntologySystem, ProvenanceOrigin};
use proptest::prelude::*;
use std::collections::BTreeMap;
fn system_strategy() -> impl Strategy<Value = OntologySystem> {
prop_oneof![
Just(OntologySystem::SNOMED),
Just(OntologySystem::LOINC),
Just(OntologySystem::RxNorm),
Just(OntologySystem::ICD11),
]
}
fn system_token_strategy() -> impl Strategy<Value = &'static str> {
prop_oneof![Just("SNOMED"), Just("LOINC"), Just("RxNorm"), Just("ICD11"),]
}
pub(crate) fn atom_strategy() -> impl Strategy<Value = Atom> {
(
system_strategy(),
"[0-9]{4,5}",
"[A-Z][a-z]{3,8}",
"0\\.[0-9]\\.[0-9]",
)
.prop_map(|(system, code, preferred_term, version)| Atom {
system,
code,
preferred_term,
version,
})
}
pub(crate) fn hyp_strategy() -> impl Strategy<Value = Hyp> {
prop::collection::vec(atom_strategy(), 0..3).prop_map(|atoms| {
if atoms.is_empty() {
Hyp::unknown()
} else {
Hyp::new(atoms)
}
})
}
fn observation_code_strategy() -> impl Strategy<Value = String> {
(system_token_strategy(), "[0-9]{3,5}").prop_map(|(sys, code)| format!("{}:{}", sys, code))
}
pub(crate) fn evidence_strategy() -> impl Strategy<Value = Evidence> {
(
prop::collection::vec(observation_code_strategy(), 0..4),
"[0-9]{1,3}",
)
.prop_map(|(codes, val_str)| {
let observations: Vec<Observation> = codes
.into_iter()
.map(|code| Observation::new(code, serde_json::json!(val_str.clone())))
.collect();
let origin = ProvenanceOrigin::new("test_gen", "SNOMED", "synthetic");
let prov = Provenance::new(
origin,
chrono::Utc::now(),
crate::Ver::new("clinlat", "proptest", "0.2.0"),
BTreeMap::new(),
);
Evidence::new(observations, prov)
})
}
fn monotone_evidence_pair() -> impl Strategy<Value = (Evidence, Evidence)> {
(
prop::collection::vec(observation_code_strategy(), 1..3),
prop::collection::vec(observation_code_strategy(), 0..3),
)
.prop_map(|(base_codes, extra_codes)| {
let origin = ProvenanceOrigin::new("test_gen", "SNOMED", "synthetic");
let prov = Provenance::new(
origin,
chrono::Utc::now(),
crate::Ver::new("clinlat", "proptest", "0.2.0"),
BTreeMap::new(),
);
let base_obs: Vec<Observation> = base_codes
.iter()
.map(|c| Observation::new(c.clone(), serde_json::json!("1")))
.collect();
let mut full_obs = base_obs.clone();
full_obs.extend(
extra_codes
.iter()
.map(|c| Observation::new(c.clone(), serde_json::json!("1"))),
);
(
Evidence::new(base_obs, prov.clone()),
Evidence::new(full_obs, prov),
)
})
}
pub(crate) fn comparable_hyp_pair() -> impl Strategy<Value = (Hyp, Hyp)> {
(
prop::collection::vec(atom_strategy(), 0..5), prop::collection::vec(atom_strategy(), 0..4), )
.prop_map(|(general_atoms, extra_atoms)| {
let h_general = if general_atoms.is_empty() {
Hyp::unknown()
} else {
Hyp::new(general_atoms.clone())
};
let mut specific_atoms = general_atoms;
specific_atoms.extend(extra_atoms);
specific_atoms.sort_by(|a, b| a.code.cmp(&b.code));
specific_atoms.dedup_by(|a, b| a.code == b.code);
let h_specific = if specific_atoms.is_empty() {
Hyp::unknown()
} else {
Hyp::new(specific_atoms)
};
(h_general, h_specific)
})
.prop_filter("h_general ⊑ h_specific", |(h_general, h_specific)| {
let g_codes: std::collections::HashSet<_> =
h_general.atoms().iter().map(|a| &a.code).collect();
let s_codes: std::collections::HashSet<_> =
h_specific.atoms().iter().map(|a| &a.code).collect();
g_codes.is_subset(&s_codes)
})
}
proptest! {
#[test]
fn prop_upper_adjoint_inflationary(e in evidence_strategy()) {
let alpha_e = abstract_evidence(&e);
prop_assert!(is_consistent_with(&alpha_e, &e),
"Upper-adjoint inflationary law violated: e ⊑_γ γ(α(e))");
}
#[test]
fn prop_alpha_monotone(pair in monotone_evidence_pair()) {
let (e_sub, e_full) = pair;
let alpha_sub = abstract_evidence(&e_sub);
let alpha_full = abstract_evidence(&e_full);
let sub_atoms: std::collections::HashSet<&Atom> = alpha_sub.atoms().iter().collect();
let full_atoms: std::collections::HashSet<&Atom> = alpha_full.atoms().iter().collect();
prop_assert!(sub_atoms.is_subset(&full_atoms),
"α monotonicity violated: atoms(α(e_sub))={:?} should be a subset of atoms(α(e_full))={:?}",
sub_atoms, full_atoms);
}
#[test]
fn prop_gamma_antitone_in_hyp(pair in monotone_evidence_pair()) {
let (e_sub, e_full) = pair;
let h_general = abstract_evidence(&e_sub);
let h_specific = abstract_evidence(&e_full);
if is_consistent_with(&h_specific, &e_full) {
prop_assert!(is_consistent_with(&h_general, &e_full),
"γ antitonicity in h violated: e is consistent with h_specific but not h_general");
}
}
#[test]
fn prop_gamma_antitone_with_hand_crafted_hyps(
(h_general, h_specific) in comparable_hyp_pair(),
e in evidence_strategy(),
) {
if is_consistent_with(&h_specific, &e) {
prop_assert!(is_consistent_with(&h_general, &e),
"γ antitonicity violated with hand-crafted hyps: e is consistent with h_specific but not h_general; h_general atoms: {:?}, h_specific atoms: {:?}",
h_general.atoms(), h_specific.atoms());
}
}
#[test]
fn prop_abstraction_from_empty_is_unknown(_unit in Just(())) {
let empty_obs = Evidence::new(
vec![],
Provenance::new(
ProvenanceOrigin::new("empty", "N/A", "none"),
chrono::Utc::now(),
crate::Ver::new("clinlat", "test", "0.1.0"),
BTreeMap::new(),
),
);
let hyp = abstract_evidence(&empty_obs);
prop_assert_eq!(hyp, Hyp::unknown(),
"Empty evidence should abstract to unknown hypothesis");
}
#[test]
fn prop_unknown_consistent_with_all(e in evidence_strategy()) {
let unknown = Hyp::unknown();
prop_assert!(is_consistent_with(&unknown, &e),
"Unknown hypothesis should be consistent with all evidence");
}
#[test]
fn prop_abstraction_completeness(
codes in prop::collection::vec(observation_code_strategy(), 1..4),
) {
let mut distinct_codes = codes.clone();
distinct_codes.sort();
distinct_codes.dedup();
let observations: Vec<Observation> = codes
.iter()
.map(|code| Observation::new(code.clone(), serde_json::json!(1)))
.collect();
let origin = ProvenanceOrigin::new("test", "SNOMED", "code");
let prov = Provenance::new(
origin,
chrono::Utc::now(),
crate::Ver::new("clinlat", "test", "0.1.0"),
BTreeMap::new(),
);
let evidence = Evidence::new(observations, prov);
let hyp = abstract_evidence(&evidence);
prop_assert_eq!(hyp.atoms().len(), distinct_codes.len(),
"Each distinct valid observation code should produce exactly one atom");
prop_assert!(is_consistent_with(&hyp, &evidence),
"Abstraction should be consistent with evidence");
}
#[test]
fn prop_alpha_deterministic(e in evidence_strategy()) {
let alpha_1 = abstract_evidence(&e);
let alpha_2 = abstract_evidence(&e);
prop_assert_eq!(alpha_1, alpha_2,
"α should be deterministic: two calls on the same evidence must agree");
}
#[test]
fn prop_atom_set_consistency(h in hyp_strategy()) {
let observations: Vec<Observation> = h
.atoms()
.iter()
.filter_map(|atom| {
let sys_token = match atom.system {
OntologySystem::SNOMED => "SNOMED",
OntologySystem::LOINC => "LOINC",
OntologySystem::RxNorm => "RxNorm",
OntologySystem::ICD11 => "ICD11",
OntologySystem::Unstructured => return None,
};
Some(Observation::new(
format!("{}:{}", sys_token, atom.code),
serde_json::json!(1),
))
})
.collect();
if observations.len() != h.atoms().len() {
return Ok(());
}
let origin = ProvenanceOrigin::new("rt", "SNOMED", "code");
let version = h
.atoms()
.first()
.map(|a| a.version.clone())
.unwrap_or_else(|| "0.0.0".to_string());
let prov = Provenance::new(
origin,
chrono::Utc::now(),
crate::Ver::new("clinlat", "test", &version),
BTreeMap::new(),
);
let evidence = Evidence::new(observations, prov);
let uniform_version = h
.atoms()
.iter()
.all(|a| a.version == version);
if uniform_version {
prop_assert!(is_consistent_with(&h, &evidence),
"Round-trip α(γ_atoms(h)) should be consistent with h when versions align");
}
}
}
}