use crate::{AbstainReason, Atom, Hyp, Observation, OntologySystem, Operator, Outcome};
pub struct WellsPeOperator {
pub version: String,
}
impl WellsPeOperator {
pub fn new(version: impl Into<String>) -> Self {
WellsPeOperator {
version: version.into(),
}
}
fn calculate_wells_score(observations: &[Observation]) -> f64 {
let mut score = 0.0;
if observations
.iter()
.any(|obs| obs.code == "DVT-SIGNS" && obs.value.as_bool().unwrap_or(false))
{
score += 3.0;
}
if let Some(obs) = observations.iter().find(|obs| obs.code == "PE-LIKELY") {
if obs.value.as_bool().unwrap_or(false) {
score += 3.0;
}
}
if let Some(obs) = observations.iter().find(|obs| obs.code == "HEART-RATE") {
if let Some(hr) = obs.value.as_f64() {
if hr > 100.0 {
score += 1.5;
}
}
}
if observations
.iter()
.any(|obs| obs.code == "RECENT-IMMOB" && obs.value.as_bool().unwrap_or(false))
{
score += 1.5;
}
if observations
.iter()
.any(|obs| obs.code == "PRIOR-VTE" && obs.value.as_bool().unwrap_or(false))
{
score += 1.5;
}
if observations
.iter()
.any(|obs| obs.code == "HEMOPTYSIS" && obs.value.as_bool().unwrap_or(false))
{
score += 1.0;
}
if observations
.iter()
.any(|obs| obs.code == "MALIGNANCY" && obs.value.as_bool().unwrap_or(false))
{
score += 1.0;
}
score
}
fn determine_category(wells_score: f64) -> (&'static str, &'static str) {
if wells_score <= 4.0 {
("PE-UNLIKELY", "consider-d-dimer")
} else {
("PE-LIKELY", "ctpa-indicated")
}
}
fn category_to_atom(&self, category: &str, score: f64) -> Atom {
Atom {
system: OntologySystem::SNOMED,
code: format!("WELLS-PE-{}", category),
preferred_term: format!("Wells PE {}: score {:.1}", category, score),
version: self.version.clone(),
}
}
}
impl Operator for WellsPeOperator {
fn apply(&self, h: &Hyp, e: &crate::Evidence) -> Outcome<Hyp, AbstainReason> {
if !e.observations.iter().any(|obs| obs.code == "PE-LIKELY") {
return Outcome::Abstain(AbstainReason::InsufficientEvidence(
"PE gestalt assessment unavailable; clinician judgment required for Wells scoring",
));
}
let wells_score = Self::calculate_wells_score(&e.observations);
let (category, _next_step) = Self::determine_category(wells_score);
let pe_atom = self.category_to_atom(category, wells_score);
let mut new_atoms = h.atoms().to_vec();
new_atoms.push(pe_atom);
Outcome::Refined(Hyp::new(new_atoms))
}
}
#[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", "SNOMED", "test"),
Utc::now(),
crate::Ver::new("test", "wells_pe", "0.1.0"),
BTreeMap::new(),
),
)
}
#[test]
fn test_wells_pe_unlikely_low_score() {
let op = WellsPeOperator::new("0.1.0");
let observations = vec![
Observation::new("PE-LIKELY", serde_json::json!(false)), Observation::new("HEART-RATE", serde_json::json!(120.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);
let atoms = h_prime.atoms();
assert!(atoms.iter().any(|a| a.code.contains("PE-UNLIKELY")));
}
Outcome::Abstain(_) => panic!("should refine with Wells calculation"),
}
}
#[test]
fn test_wells_pe_unlikely_with_dvt_signs() {
let op = WellsPeOperator::new("0.1.0");
let observations = vec![
Observation::new("PE-LIKELY", serde_json::json!(true)), Observation::new("DVT-SIGNS", serde_json::json!(true)), Observation::new("HEART-RATE", serde_json::json!(120.0)), ];
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("PE-LIKELY")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_wells_pe_likely_high_score() {
let op = WellsPeOperator::new("0.1.0");
let observations = vec![
Observation::new("PE-LIKELY", serde_json::json!(true)), Observation::new("DVT-SIGNS", serde_json::json!(true)), Observation::new("HEART-RATE", serde_json::json!(120.0)), Observation::new("PRIOR-VTE", serde_json::json!(true)), ];
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("PE-LIKELY")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_wells_pe_all_criteria() {
let op = WellsPeOperator::new("0.1.0");
let observations = vec![
Observation::new("DVT-SIGNS", serde_json::json!(true)),
Observation::new("PE-LIKELY", serde_json::json!(true)),
Observation::new("HEART-RATE", serde_json::json!(120.0)),
Observation::new("RECENT-IMMOB", serde_json::json!(true)),
Observation::new("PRIOR-VTE", serde_json::json!(true)),
Observation::new("HEMOPTYSIS", serde_json::json!(true)),
Observation::new("MALIGNANCY", serde_json::json!(true)),
];
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("PE-LIKELY")));
}
Outcome::Abstain(_) => panic!("should refine"),
}
}
#[test]
fn test_wells_pe_no_criteria() {
let op = WellsPeOperator::new("0.1.0");
let observations = vec![
Observation::new("PE-LIKELY", serde_json::json!(false)), ];
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("PE-UNLIKELY")));
}
Outcome::Abstain(_) => panic!("should refine with zero score"),
}
}
#[test]
fn test_wells_pe_boundary_score_4() {
let op = WellsPeOperator::new("0.1.0");
let observations = vec![
Observation::new("PE-LIKELY", serde_json::json!(false)), Observation::new("DVT-SIGNS", serde_json::json!(true)), Observation::new("HEMOPTYSIS", serde_json::json!(true)), ];
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("PE-UNLIKELY")));
}
Outcome::Abstain(_) => panic!("should refine at boundary"),
}
}
#[test]
fn test_wells_pe_monotonicity() {
let op = WellsPeOperator::new("0.1.0");
let observations = vec![
Observation::new("PE-LIKELY", serde_json::json!(true)), Observation::new("HEART-RATE", serde_json::json!(120.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"),
}
}
#[test]
fn test_wells_pe_abstain_missing_gestalt() {
let op = WellsPeOperator::new("0.1.0");
let observations = vec![
Observation::new("DVT-SIGNS", serde_json::json!(true)),
Observation::new("HEART-RATE", serde_json::json!(120.0)),
];
let e = test_evidence_with_observations(observations);
let h = Hyp::unknown();
let outcome = op.apply(&h, &e);
match outcome {
Outcome::Abstain(reason) => {
assert!(matches!(reason, AbstainReason::InsufficientEvidence(_)));
if let AbstainReason::InsufficientEvidence(msg) = reason {
assert!(msg.contains("gestalt"));
}
}
Outcome::Refined(_) => panic!("should abstain when PE gestalt assessment missing"),
}
}
}