// healthcare/domain/patient.rs
use chrono::{Date, Utc};
use std::collections::HashSet;
use uuid::Uuid;
/// Immutable snapshot of a patient's clinical state.
/// Constructed once per inference request; never mutated.
#[derive(Debug, Clone)]
pub struct PatientContext {
pub id: Uuid,
pub age: u8,
pub diagnoses: Vec<Diagnosis>,
pub vitals: Option<Vitals>,
pub risk_score: f64, // 0.0–1.0
pub flags: HashSet<Flag>,
}
impl PatientContext {
pub fn new(
id: Uuid,
age: u8,
diagnoses: Vec<Diagnosis>,
vitals: Option<Vitals>,
risk_score: f64,
flags: HashSet<Flag>,
) -> Result<Self, DomainError> {
if age == 0 {
return Err(DomainError::InvalidAge);
}
if !(0.0..=1.0).contains(&risk_score) {
return Err(DomainError::RiskScoreOutOfRange(risk_score));
}
Ok(Self { id, age, diagnoses, vitals, risk_score, flags })
}
pub fn is_high_risk(&self) -> bool { self.risk_score > 0.75 }
pub fn is_flagged(&self) -> bool { !self.flags.is_empty() }
pub fn primary_dx(&self) -> Option<&Diagnosis> { self.diagnoses.first() }
pub fn comorbidities(&self) -> &[Diagnosis] { &self.diagnoses[1..] }
pub fn has_critical_vitals(&self) -> bool {
self.vitals.as_ref().map_or(false, |v| v.is_critical())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Flag {
IcuCandidate,
Elderly,
Chronic,
HighRisk,
AutoFlagged,
}
#[derive(Debug, Clone)]
pub struct Diagnosis {
pub code: String, // ICD-10
pub description: String,
pub severity: Severity,
pub onset_date: Date<Utc>,
}
impl Diagnosis {
pub fn is_critical(&self) -> bool { self.severity == Severity::Critical }
pub fn is_chronic(&self) -> bool {
(Utc::today() - self.onset_date).num_days() > 365
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { Low, Moderate, High, Critical }
#[derive(Debug, Clone)]
pub struct Vitals {
pub recorded_at: chrono::DateTime<Utc>,
pub heart_rate: u16,
pub systolic_bp: u16,
pub diastolic_bp: u16,
pub spo2: f32,
pub temperature: f32,
}
impl Vitals {
pub fn abnormality_index(&self) -> f32 {
let checks: &[bool] = &[
!(60..=100).contains(&self.heart_rate),
!(90..=120).contains(&self.systolic_bp),
!(60..=80).contains(&self.diastolic_bp),
self.spo2 < 95.0,
!(36.1..=37.2).contains(&(self.temperature as f64)) as bool,
];
let abnormal = checks.iter().filter(|&&c| c).count() as f32;
abnormal / checks.len() as f32
}
pub fn is_critical(&self) -> bool {
self.abnormality_index() >= 0.6 || self.spo2 < 90.0
}
pub fn is_stale(&self) -> bool {
(Utc::now() - self.recorded_at).num_hours() > 6
}
}
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("age must be positive")]
InvalidAge,
#[error("risk score {0} is outside 0.0–1.0")]
RiskScoreOutOfRange(f64),
}
// healthcare/domain/patient.rs
use chrono::{Date, Utc};
use std::collections::HashSet;
use uuid::Uuid;
/// Immutable snapshot of a patient's clinical state.
/// Constructed once per inference request; never mutated.
#[derive(Debug, Clone)]
pub struct PatientContext {
pub id: Uuid,
pub age: u8,
pub diagnoses: Vec<Diagnosis>,
pub vitals: Option<Vitals>,
pub risk_score: f64, // 0.0–1.0
pub flags: HashSet<Flag>,
}
impl PatientContext {
pub fn new(
id: Uuid,
age: u8,
diagnoses: Vec<Diagnosis>,
vitals: Option<Vitals>,
risk_score: f64,
flags: HashSet<Flag>,
) -> Result<Self, DomainError> {
if age == 0 {
return Err(DomainError::InvalidAge);
}
if !(0.0..=1.0).contains(&risk_score) {
return Err(DomainError::RiskScoreOutOfRange(risk_score));
}
Ok(Self { id, age, diagnoses, vitals, risk_score, flags })
}
pub fn is_high_risk(&self) -> bool { self.risk_score > 0.75 }
pub fn is_flagged(&self) -> bool { !self.flags.is_empty() }
pub fn primary_dx(&self) -> Option<&Diagnosis> { self.diagnoses.first() }
pub fn comorbidities(&self) -> &[Diagnosis] { &self.diagnoses[1..] }
pub fn has_critical_vitals(&self) -> bool {
self.vitals.as_ref().map_or(false, |v| v.is_critical())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Flag {
IcuCandidate,
Elderly,
Chronic,
HighRisk,
AutoFlagged,
}
#[derive(Debug, Clone)]
pub struct Diagnosis {
pub code: String, // ICD-10
pub description: String,
pub severity: Severity,
pub onset_date: Date<Utc>,
}
impl Diagnosis {
pub fn is_critical(&self) -> bool { self.severity == Severity::Critical }
pub fn is_chronic(&self) -> bool {
(Utc::today() - self.onset_date).num_days() > 365
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { Low, Moderate, High, Critical }
#[derive(Debug, Clone)]
pub struct Vitals {
pub recorded_at: chrono::DateTime<Utc>,
pub heart_rate: u16,
pub systolic_bp: u16,
pub diastolic_bp: u16,
pub spo2: f32,
pub temperature: f32,
}
impl Vitals {
pub fn abnormality_index(&self) -> f32 {
let checks: &[bool] = &[
!(60..=100).contains(&self.heart_rate),
!(90..=120).contains(&self.systolic_bp),
!(60..=80).contains(&self.diastolic_bp),
self.spo2 < 95.0,
!(36.1..=37.2).contains(&(self.temperature as f64)) as bool,
];
let abnormal = checks.iter().filter(|&&c| c).count() as f32;
abnormal / checks.len() as f32
}
pub fn is_critical(&self) -> bool {
self.abnormality_index() >= 0.6 || self.spo2 < 90.0
}
pub fn is_stale(&self) -> bool {
(Utc::now() - self.recorded_at).num_hours() > 6
}
}
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("age must be positive")]
InvalidAge,
#[error("risk score {0} is outside 0.0–1.0")]
RiskScoreOutOfRange(f64),
}
// healthcare/domain/patient.rs
use chrono::{Date, Utc};
use std::collections::HashSet;
use uuid::Uuid;
/// Immutable snapshot of a patient's clinical state.
/// Constructed once per inference request; never mutated.
#[derive(Debug, Clone)]
pub struct PatientContext {
pub id: Uuid,
pub age: u8,
pub diagnoses: Vec<Diagnosis>,
pub vitals: Option<Vitals>,
pub risk_score: f64, // 0.0–1.0
pub flags: HashSet<Flag>,
}
impl PatientContext {
pub fn new(
id: Uuid,
age: u8,
diagnoses: Vec<Diagnosis>,
vitals: Option<Vitals>,
risk_score: f64,
flags: HashSet<Flag>,
) -> Result<Self, DomainError> {
if age == 0 {
return Err(DomainError::InvalidAge);
}
if !(0.0..=1.0).contains(&risk_score) {
return Err(DomainError::RiskScoreOutOfRange(risk_score));
}
Ok(Self { id, age, diagnoses, vitals, risk_score, flags })
}
pub fn is_high_risk(&self) -> bool { self.risk_score > 0.75 }
pub fn is_flagged(&self) -> bool { !self.flags.is_empty() }
pub fn primary_dx(&self) -> Option<&Diagnosis> { self.diagnoses.first() }
pub fn comorbidities(&self) -> &[Diagnosis] { &self.diagnoses[1..] }
pub fn has_critical_vitals(&self) -> bool {
self.vitals.as_ref().map_or(false, |v| v.is_critical())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Flag {
IcuCandidate,
Elderly,
Chronic,
HighRisk,
AutoFlagged,
}
#[derive(Debug, Clone)]
pub struct Diagnosis {
pub code: String, // ICD-10
pub description: String,
pub severity: Severity,
pub onset_date: Date<Utc>,
}
impl Diagnosis {
pub fn is_critical(&self) -> bool { self.severity == Severity::Critical }
pub fn is_chronic(&self) -> bool {
(Utc::today() - self.onset_date).num_days() > 365
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { Low, Moderate, High, Critical }
#[derive(Debug, Clone)]
pub struct Vitals {
pub recorded_at: chrono::DateTime<Utc>,
pub heart_rate: u16,
pub systolic_bp: u16,
pub diastolic_bp: u16,
pub spo2: f32,
pub temperature: f32,
}
impl Vitals {
pub fn abnormality_index(&self) -> f32 {
let checks: &[bool] = &[
!(60..=100).contains(&self.heart_rate),
!(90..=120).contains(&self.systolic_bp),
!(60..=80).contains(&self.diastolic_bp),
self.spo2 < 95.0,
!(36.1..=37.2).contains(&(self.temperature as f64)) as bool,
];
let abnormal = checks.iter().filter(|&&c| c).count() as f32;
abnormal / checks.len() as f32
}
pub fn is_critical(&self) -> bool {
self.abnormality_index() >= 0.6 || self.spo2 < 90.0
}
pub fn is_stale(&self) -> bool {
(Utc::now() - self.recorded_at).num_hours() > 6
}
}
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("age must be positive")]
InvalidAge,
#[error("risk score {0} is outside 0.0–1.0")]
RiskScoreOutOfRange(f64),
}
// healthcare/domain/patient.rs
use chrono::{Date, Utc};
use std::collections::HashSet;
use uuid::Uuid;
/// Immutable snapshot of a patient's clinical state.
/// Constructed once per inference request; never mutated.
#[derive(Debug, Clone)]
pub struct PatientContext {
pub id: Uuid,
pub age: u8,
pub diagnoses: Vec<Diagnosis>,
pub vitals: Option<Vitals>,
pub risk_score: f64, // 0.0–1.0
pub flags: HashSet<Flag>,
}
impl PatientContext {
pub fn new(
id: Uuid,
age: u8,
diagnoses: Vec<Diagnosis>,
vitals: Option<Vitals>,
risk_score: f64,
flags: HashSet<Flag>,
) -> Result<Self, DomainError> {
if age == 0 {
return Err(DomainError::InvalidAge);
}
if !(0.0..=1.0).contains(&risk_score) {
return Err(DomainError::RiskScoreOutOfRange(risk_score));
}
Ok(Self { id, age, diagnoses, vitals, risk_score, flags })
}
pub fn is_high_risk(&self) -> bool { self.risk_score > 0.75 }
pub fn is_flagged(&self) -> bool { !self.flags.is_empty() }
pub fn primary_dx(&self) -> Option<&Diagnosis> { self.diagnoses.first() }
pub fn comorbidities(&self) -> &[Diagnosis] { &self.diagnoses[1..] }
pub fn has_critical_vitals(&self) -> bool {
self.vitals.as_ref().map_or(false, |v| v.is_critical())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Flag {
IcuCandidate,
Elderly,
Chronic,
HighRisk,
AutoFlagged,
}
#[derive(Debug, Clone)]
pub struct Diagnosis {
pub code: String, // ICD-10
pub description: String,
pub severity: Severity,
pub onset_date: Date<Utc>,
}
impl Diagnosis {
pub fn is_critical(&self) -> bool { self.severity == Severity::Critical }
pub fn is_chronic(&self) -> bool {
(Utc::today() - self.onset_date).num_days() > 365
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { Low, Moderate, High, Critical }
#[derive(Debug, Clone)]
pub struct Vitals {
pub recorded_at: chrono::DateTime<Utc>,
pub heart_rate: u16,
pub systolic_bp: u16,
pub diastolic_bp: u16,
pub spo2: f32,
pub temperature: f32,
}
impl Vitals {
pub fn abnormality_index(&self) -> f32 {
let checks: &[bool] = &[
!(60..=100).contains(&self.heart_rate),
!(90..=120).contains(&self.systolic_bp),
!(60..=80).contains(&self.diastolic_bp),
self.spo2 < 95.0,
!(36.1..=37.2).contains(&(self.temperature as f64)) as bool,
];
let abnormal = checks.iter().filter(|&&c| c).count() as f32;
abnormal / checks.len() as f32
}
pub fn is_critical(&self) -> bool {
self.abnormality_index() >= 0.6 || self.spo2 < 90.0
}
pub fn is_stale(&self) -> bool {
(Utc::now() - self.recorded_at).num_hours() > 6
}
}
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("age must be positive")]
InvalidAge,
#[error("risk score {0} is outside 0.0–1.0")]
RiskScoreOutOfRange(f64),
}
// healthcare/application/risk_scorer.rs
use crate::domain::{Diagnosis, PatientContext, Vitals};
const WEIGHT_AGE: f64 = 0.20;
const WEIGHT_COMORBIDITY: f64 = 0.40;
const WEIGHT_VITALS: f64 = 0.30;
const WEIGHT_HISTORY: f64 = 0.10;
/// Trait allows injecting alternative scoring strategies
/// (Open/Closed Principle).
pub trait RiskScorer: Send + Sync {
fn score(&self, patient: &PatientInput) -> f64;
}
pub struct PatientInput<'a> {
pub age: u8,
pub records: &'a [MedicalRecord],
}
/// Weighted linear scorer — baseline implementation.
pub struct WeightedRiskScorer;
impl RiskScorer for WeightedRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let s = self.age_score(input.age)
+ self.comorbidity_score(input.records)
+ self.vitals_score(input.records)
+ self.history_score(input.records);
s.clamp(0.0, 1.0)
}
}
impl WeightedRiskScorer {
fn age_score(&self, age: u8) -> f64 {
match age {
0..=17 => 0.1 * WEIGHT_AGE,
18..=44 => 0.2 * WEIGHT_AGE,
45..=64 => 0.5 * WEIGHT_AGE,
65..=79 => 0.7 * WEIGHT_AGE,
_ => 1.0 * WEIGHT_AGE,
}
}
fn comorbidity_score(&self, records: &[MedicalRecord]) -> f64 {
let count: usize = records.iter()
.map(|r| r.diagnoses.len())
.sum();
(count as f64 / 10.0).min(1.0) * WEIGHT_COMORBIDITY
}
fn vitals_score(&self, records: &[MedicalRecord]) -> f64 {
let indices: Vec<f64> = records.iter()
.filter_map(|r| r.vitals.as_ref())
.map(|v| v.abnormality_index() as f64)
.collect();
if indices.is_empty() { return 0.0; }
(indices.iter().sum::<f64>() / indices.len() as f64) * WEIGHT_VITALS
}
fn history_score(&self, records: &[MedicalRecord]) -> f64 {
if records.iter().any(|r| r.hospitalization) { 0.8 } else { 0.1 }
* WEIGHT_HISTORY
}
}
/// Pediatric variant — overrides age weighting only
/// (Liskov Substitution: swappable with WeightedRiskScorer).
pub struct PediatricRiskScorer;
impl RiskScorer for PediatricRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let age_s = if input.age < 5 { 0.6 } else { 0.3 };
let base = WeightedRiskScorer;
// Replace age component, keep the rest
let without_age = base.score(input)
- WeightedRiskScorer.age_score(input.age);
(without_age + age_s * WEIGHT_AGE).clamp(0.0, 1.0)
}
}
// healthcare/application/pipeline.rs
use std::sync::Arc;
pub trait PipelineStep: Send + Sync {
fn process(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError>;
}
pub struct InferencePipeline {
steps: Vec<Arc<dyn PipelineStep>>,
}
impl InferencePipeline {
pub fn new(steps: Vec<Arc<dyn PipelineStep>>) -> Self {
assert!(!steps.is_empty(), "pipeline steps cannot be empty");
Self { steps }
}
pub fn run(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError> {
self.steps.iter().try_fold(ctx, |acc, step| {
step.process(acc)
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("step {step} returned an error: {source}")]
StepFailed { step: String, source: Box<dyn std::error::Error + Send> },
}
// healthcare/application/risk_scorer.rs
use crate::domain::{Diagnosis, PatientContext, Vitals};
const WEIGHT_AGE: f64 = 0.20;
const WEIGHT_COMORBIDITY: f64 = 0.40;
const WEIGHT_VITALS: f64 = 0.30;
const WEIGHT_HISTORY: f64 = 0.10;
/// Trait allows injecting alternative scoring strategies
/// (Open/Closed Principle).
pub trait RiskScorer: Send + Sync {
fn score(&self, patient: &PatientInput) -> f64;
}
pub struct PatientInput<'a> {
pub age: u8,
pub records: &'a [MedicalRecord],
}
/// Weighted linear scorer — baseline implementation.
pub struct WeightedRiskScorer;
impl RiskScorer for WeightedRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let s = self.age_score(input.age)
+ self.comorbidity_score(input.records)
+ self.vitals_score(input.records)
+ self.history_score(input.records);
s.clamp(0.0, 1.0)
}
}
impl WeightedRiskScorer {
fn age_score(&self, age: u8) -> f64 {
match age {
0..=17 => 0.1 * WEIGHT_AGE,
18..=44 => 0.2 * WEIGHT_AGE,
45..=64 => 0.5 * WEIGHT_AGE,
65..=79 => 0.7 * WEIGHT_AGE,
_ => 1.0 * WEIGHT_AGE,
}
}
fn comorbidity_score(&self, records: &[MedicalRecord]) -> f64 {
let count: usize = records.iter()
.map(|r| r.diagnoses.len())
.sum();
(count as f64 / 10.0).min(1.0) * WEIGHT_COMORBIDITY
}
fn vitals_score(&self, records: &[MedicalRecord]) -> f64 {
let indices: Vec<f64> = records.iter()
.filter_map(|r| r.vitals.as_ref())
.map(|v| v.abnormality_index() as f64)
.collect();
if indices.is_empty() { return 0.0; }
(indices.iter().sum::<f64>() / indices.len() as f64) * WEIGHT_VITALS
}
fn history_score(&self, records: &[MedicalRecord]) -> f64 {
if records.iter().any(|r| r.hospitalization) { 0.8 } else { 0.1 }
* WEIGHT_HISTORY
}
}
/// Pediatric variant — overrides age weighting only
/// (Liskov Substitution: swappable with WeightedRiskScorer).
pub struct PediatricRiskScorer;
impl RiskScorer for PediatricRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let age_s = if input.age < 5 { 0.6 } else { 0.3 };
let base = WeightedRiskScorer;
// Replace age component, keep the rest
let without_age = base.score(input)
- WeightedRiskScorer.age_score(input.age);
(without_age + age_s * WEIGHT_AGE).clamp(0.0, 1.0)
}
}
// healthcare/application/pipeline.rs
use std::sync::Arc;
pub trait PipelineStep: Send + Sync {
fn process(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError>;
}
pub struct InferencePipeline {
steps: Vec<Arc<dyn PipelineStep>>,
}
impl InferencePipeline {
pub fn new(steps: Vec<Arc<dyn PipelineStep>>) -> Self {
assert!(!steps.is_empty(), "pipeline steps cannot be empty");
Self { steps }
}
pub fn run(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError> {
self.steps.iter().try_fold(ctx, |acc, step| {
step.process(acc)
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("step {step} returned an error: {source}")]
StepFailed { step: String, source: Box<dyn std::error::Error + Send> },
}
// healthcare/application/risk_scorer.rs
use crate::domain::{Diagnosis, PatientContext, Vitals};
const WEIGHT_AGE: f64 = 0.20;
const WEIGHT_COMORBIDITY: f64 = 0.40;
const WEIGHT_VITALS: f64 = 0.30;
const WEIGHT_HISTORY: f64 = 0.10;
/// Trait allows injecting alternative scoring strategies
/// (Open/Closed Principle).
pub trait RiskScorer: Send + Sync {
fn score(&self, patient: &PatientInput) -> f64;
}
pub struct PatientInput<'a> {
pub age: u8,
pub records: &'a [MedicalRecord],
}
/// Weighted linear scorer — baseline implementation.
pub struct WeightedRiskScorer;
impl RiskScorer for WeightedRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let s = self.age_score(input.age)
+ self.comorbidity_score(input.records)
+ self.vitals_score(input.records)
+ self.history_score(input.records);
s.clamp(0.0, 1.0)
}
}
impl WeightedRiskScorer {
fn age_score(&self, age: u8) -> f64 {
match age {
0..=17 => 0.1 * WEIGHT_AGE,
18..=44 => 0.2 * WEIGHT_AGE,
45..=64 => 0.5 * WEIGHT_AGE,
65..=79 => 0.7 * WEIGHT_AGE,
_ => 1.0 * WEIGHT_AGE,
}
}
fn comorbidity_score(&self, records: &[MedicalRecord]) -> f64 {
let count: usize = records.iter()
.map(|r| r.diagnoses.len())
.sum();
(count as f64 / 10.0).min(1.0) * WEIGHT_COMORBIDITY
}
fn vitals_score(&self, records: &[MedicalRecord]) -> f64 {
let indices: Vec<f64> = records.iter()
.filter_map(|r| r.vitals.as_ref())
.map(|v| v.abnormality_index() as f64)
.collect();
if indices.is_empty() { return 0.0; }
(indices.iter().sum::<f64>() / indices.len() as f64) * WEIGHT_VITALS
}
fn history_score(&self, records: &[MedicalRecord]) -> f64 {
if records.iter().any(|r| r.hospitalization) { 0.8 } else { 0.1 }
* WEIGHT_HISTORY
}
}
/// Pediatric variant — overrides age weighting only
/// (Liskov Substitution: swappable with WeightedRiskScorer).
pub struct PediatricRiskScorer;
impl RiskScorer for PediatricRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let age_s = if input.age < 5 { 0.6 } else { 0.3 };
let base = WeightedRiskScorer;
// Replace age component, keep the rest
let without_age = base.score(input)
- WeightedRiskScorer.age_score(input.age);
(without_age + age_s * WEIGHT_AGE).clamp(0.0, 1.0)
}
}
// healthcare/application/pipeline.rs
use std::sync::Arc;
pub trait PipelineStep: Send + Sync {
fn process(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError>;
}
pub struct InferencePipeline {
steps: Vec<Arc<dyn PipelineStep>>,
}
impl InferencePipeline {
pub fn new(steps: Vec<Arc<dyn PipelineStep>>) -> Self {
assert!(!steps.is_empty(), "pipeline steps cannot be empty");
Self { steps }
}
pub fn run(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError> {
self.steps.iter().try_fold(ctx, |acc, step| {
step.process(acc)
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("step {step} returned an error: {source}")]
StepFailed { step: String, source: Box<dyn std::error::Error + Send> },
}
// healthcare/application/risk_scorer.rs
use crate::domain::{Diagnosis, PatientContext, Vitals};
const WEIGHT_AGE: f64 = 0.20;
const WEIGHT_COMORBIDITY: f64 = 0.40;
const WEIGHT_VITALS: f64 = 0.30;
const WEIGHT_HISTORY: f64 = 0.10;
/// Trait allows injecting alternative scoring strategies
/// (Open/Closed Principle).
pub trait RiskScorer: Send + Sync {
fn score(&self, patient: &PatientInput) -> f64;
}
pub struct PatientInput<'a> {
pub age: u8,
pub records: &'a [MedicalRecord],
}
/// Weighted linear scorer — baseline implementation.
pub struct WeightedRiskScorer;
impl RiskScorer for WeightedRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let s = self.age_score(input.age)
+ self.comorbidity_score(input.records)
+ self.vitals_score(input.records)
+ self.history_score(input.records);
s.clamp(0.0, 1.0)
}
}
impl WeightedRiskScorer {
fn age_score(&self, age: u8) -> f64 {
match age {
0..=17 => 0.1 * WEIGHT_AGE,
18..=44 => 0.2 * WEIGHT_AGE,
45..=64 => 0.5 * WEIGHT_AGE,
65..=79 => 0.7 * WEIGHT_AGE,
_ => 1.0 * WEIGHT_AGE,
}
}
fn comorbidity_score(&self, records: &[MedicalRecord]) -> f64 {
let count: usize = records.iter()
.map(|r| r.diagnoses.len())
.sum();
(count as f64 / 10.0).min(1.0) * WEIGHT_COMORBIDITY
}
fn vitals_score(&self, records: &[MedicalRecord]) -> f64 {
let indices: Vec<f64> = records.iter()
.filter_map(|r| r.vitals.as_ref())
.map(|v| v.abnormality_index() as f64)
.collect();
if indices.is_empty() { return 0.0; }
(indices.iter().sum::<f64>() / indices.len() as f64) * WEIGHT_VITALS
}
fn history_score(&self, records: &[MedicalRecord]) -> f64 {
if records.iter().any(|r| r.hospitalization) { 0.8 } else { 0.1 }
* WEIGHT_HISTORY
}
}
/// Pediatric variant — overrides age weighting only
/// (Liskov Substitution: swappable with WeightedRiskScorer).
pub struct PediatricRiskScorer;
impl RiskScorer for PediatricRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let age_s = if input.age < 5 { 0.6 } else { 0.3 };
let base = WeightedRiskScorer;
// Replace age component, keep the rest
let without_age = base.score(input)
- WeightedRiskScorer.age_score(input.age);
(without_age + age_s * WEIGHT_AGE).clamp(0.0, 1.0)
}
}
// healthcare/application/pipeline.rs
use std::sync::Arc;
pub trait PipelineStep: Send + Sync {
fn process(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError>;
}
pub struct InferencePipeline {
steps: Vec<Arc<dyn PipelineStep>>,
}
impl InferencePipeline {
pub fn new(steps: Vec<Arc<dyn PipelineStep>>) -> Self {
assert!(!steps.is_empty(), "pipeline steps cannot be empty");
Self { steps }
}
pub fn run(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError> {
self.steps.iter().try_fold(ctx, |acc, step| {
step.process(acc)
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("step {step} returned an error: {source}")]
StepFailed { step: String, source: Box<dyn std::error::Error + Send> },
}
// healthcare/application/service.rs
use std::sync::Arc;
use crate::domain::{PatientContext, AnalysisResult, Flag};
use crate::application::{
ContextBuilder, InferencePipeline,
PipelineError, Notifier, NotificationEvent,
};
/// High-level orchestrator.
/// All collaborators are injected — Dependency Inversion.
pub struct PatientAnalysisService {
builder: Arc<dyn ContextBuilder>,
pipeline: InferencePipeline,
notifiers: Vec<Arc<dyn Notifier>>,
}
impl PatientAnalysisService {
pub fn new(
builder: Arc<dyn ContextBuilder>,
pipeline: InferencePipeline,
notifiers: Vec<Arc<dyn Notifier>>,
) -> Self {
Self { builder, pipeline, notifiers }
}
pub async fn analyze(
&self,
input: AnalysisInput,
) -> Result<AnalysisResult, ServiceError> {
let ctx = self.builder.build(&input).await?;
let result = self.pipeline.run(ctx).map_err(ServiceError::Pipeline)?;
self.broadcast(NotificationEvent::Complete, Some(&result)).await;
Ok(AnalysisResult {
flagged: result.is_high_risk(),
model_version: env!("MODEL_VERSION").to_owned(),
created_at: chrono::Utc::now(),
context: result,
})
}
async fn broadcast(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) {
for notifier in &self.notifiers {
if let Err(e) = notifier.notify(event, ctx).await {
tracing::warn!(
notifier = std::any::type_name_of_val(notifier.as_ref()),
error = %e,
"notifier failed"
);
}
}
}
}
// healthcare/infrastructure/notifiers.rs
use async_trait::async_trait;
#[async_trait]
pub trait Notifier: Send + Sync {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError>;
}
pub struct SlackNotifier {
client: slack_api::Client,
channel: String,
}
#[async_trait]
impl Notifier for SlackNotifier {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError> {
let text = match ctx {
Some(c) => format!(
"{} | Patient {} | Risk {:.0}%",
event.emoji(),
c.id,
c.risk_score * 100.0
),
None => format!("{} Analysis failed", event.emoji()),
};
self.client
.post_message(&self.channel, &text)
.await
.map_err(|e| NotifierError::Slack(e.to_string()))
}
}
pub struct AuditNotifier {
logger: Arc<dyn tracing::Subscriber + Send + Sync>,
}
#[async_trait]
impl Notifier for AuditNotifier {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError> {
tracing::info!(
event = ?event,
patient = ctx.map(|c| c.id.to_string()),
risk = ctx.map(|c| c.risk_score),
"audit"
);
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum NotifierError {
#[error("slack: {0}")]
Slack(String),
#[error("webhook: {0}")]
Webhook(String),
}
// healthcare/application/service.rs
use std::sync::Arc;
use crate::domain::{PatientContext, AnalysisResult, Flag};
use crate::application::{
ContextBuilder, InferencePipeline,
PipelineError, Notifier, NotificationEvent,
};
/// High-level orchestrator.
/// All collaborators are injected — Dependency Inversion.
pub struct PatientAnalysisService {
builder: Arc<dyn ContextBuilder>,
pipeline: InferencePipeline,
notifiers: Vec<Arc<dyn Notifier>>,
}
impl PatientAnalysisService {
pub fn new(
builder: Arc<dyn ContextBuilder>,
pipeline: InferencePipeline,
notifiers: Vec<Arc<dyn Notifier>>,
) -> Self {
Self { builder, pipeline, notifiers }
}
pub async fn analyze(
&self,
input: AnalysisInput,
) -> Result<AnalysisResult, ServiceError> {
let ctx = self.builder.build(&input).await?;
let result = self.pipeline.run(ctx).map_err(ServiceError::Pipeline)?;
self.broadcast(NotificationEvent::Complete, Some(&result)).await;
Ok(AnalysisResult {
flagged: result.is_high_risk(),
model_version: env!("MODEL_VERSION").to_owned(),
created_at: chrono::Utc::now(),
context: result,
})
}
async fn broadcast(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) {
for notifier in &self.notifiers {
if let Err(e) = notifier.notify(event, ctx).await {
tracing::warn!(
notifier = std::any::type_name_of_val(notifier.as_ref()),
error = %e,
"notifier failed"
);
}
}
}
}
// healthcare/infrastructure/notifiers.rs
use async_trait::async_trait;
#[async_trait]
pub trait Notifier: Send + Sync {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError>;
}
pub struct SlackNotifier {
client: slack_api::Client,
channel: String,
}
#[async_trait]
impl Notifier for SlackNotifier {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError> {
let text = match ctx {
Some(c) => format!(
"{} | Patient {} | Risk {:.0}%",
event.emoji(),
c.id,
c.risk_score * 100.0
),
None => format!("{} Analysis failed", event.emoji()),
};
self.client
.post_message(&self.channel, &text)
.await
.map_err(|e| NotifierError::Slack(e.to_string()))
}
}
pub struct AuditNotifier {
logger: Arc<dyn tracing::Subscriber + Send + Sync>,
}
#[async_trait]
impl Notifier for AuditNotifier {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError> {
tracing::info!(
event = ?event,
patient = ctx.map(|c| c.id.to_string()),
risk = ctx.map(|c| c.risk_score),
"audit"
);
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum NotifierError {
#[error("slack: {0}")]
Slack(String),
#[error("webhook: {0}")]
Webhook(String),
}
// healthcare/application/service.rs
use std::sync::Arc;
use crate::domain::{PatientContext, AnalysisResult, Flag};
use crate::application::{
ContextBuilder, InferencePipeline,
PipelineError, Notifier, NotificationEvent,
};
/// High-level orchestrator.
/// All collaborators are injected — Dependency Inversion.
pub struct PatientAnalysisService {
builder: Arc<dyn ContextBuilder>,
pipeline: InferencePipeline,
notifiers: Vec<Arc<dyn Notifier>>,
}
impl PatientAnalysisService {
pub fn new(
builder: Arc<dyn ContextBuilder>,
pipeline: InferencePipeline,
notifiers: Vec<Arc<dyn Notifier>>,
) -> Self {
Self { builder, pipeline, notifiers }
}
pub async fn analyze(
&self,
input: AnalysisInput,
) -> Result<AnalysisResult, ServiceError> {
let ctx = self.builder.build(&input).await?;
let result = self.pipeline.run(ctx).map_err(ServiceError::Pipeline)?;
self.broadcast(NotificationEvent::Complete, Some(&result)).await;
Ok(AnalysisResult {
flagged: result.is_high_risk(),
model_version: env!("MODEL_VERSION").to_owned(),
created_at: chrono::Utc::now(),
context: result,
})
}
async fn broadcast(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) {
for notifier in &self.notifiers {
if let Err(e) = notifier.notify(event, ctx).await {
tracing::warn!(
notifier = std::any::type_name_of_val(notifier.as_ref()),
error = %e,
"notifier failed"
);
}
}
}
}
// healthcare/infrastructure/notifiers.rs
use async_trait::async_trait;
#[async_trait]
pub trait Notifier: Send + Sync {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError>;
}
pub struct SlackNotifier {
client: slack_api::Client,
channel: String,
}
#[async_trait]
impl Notifier for SlackNotifier {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError> {
let text = match ctx {
Some(c) => format!(
"{} | Patient {} | Risk {:.0}%",
event.emoji(),
c.id,
c.risk_score * 100.0
),
None => format!("{} Analysis failed", event.emoji()),
};
self.client
.post_message(&self.channel, &text)
.await
.map_err(|e| NotifierError::Slack(e.to_string()))
}
}
pub struct AuditNotifier {
logger: Arc<dyn tracing::Subscriber + Send + Sync>,
}
#[async_trait]
impl Notifier for AuditNotifier {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError> {
tracing::info!(
event = ?event,
patient = ctx.map(|c| c.id.to_string()),
risk = ctx.map(|c| c.risk_score),
"audit"
);
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum NotifierError {
#[error("slack: {0}")]
Slack(String),
#[error("webhook: {0}")]
Webhook(String),
}
// healthcare/application/service.rs
use std::sync::Arc;
use crate::domain::{PatientContext, AnalysisResult, Flag};
use crate::application::{
ContextBuilder, InferencePipeline,
PipelineError, Notifier, NotificationEvent,
};
/// High-level orchestrator.
/// All collaborators are injected — Dependency Inversion.
pub struct PatientAnalysisService {
builder: Arc<dyn ContextBuilder>,
pipeline: InferencePipeline,
notifiers: Vec<Arc<dyn Notifier>>,
}
impl PatientAnalysisService {
pub fn new(
builder: Arc<dyn ContextBuilder>,
pipeline: InferencePipeline,
notifiers: Vec<Arc<dyn Notifier>>,
) -> Self {
Self { builder, pipeline, notifiers }
}
pub async fn analyze(
&self,
input: AnalysisInput,
) -> Result<AnalysisResult, ServiceError> {
let ctx = self.builder.build(&input).await?;
let result = self.pipeline.run(ctx).map_err(ServiceError::Pipeline)?;
self.broadcast(NotificationEvent::Complete, Some(&result)).await;
Ok(AnalysisResult {
flagged: result.is_high_risk(),
model_version: env!("MODEL_VERSION").to_owned(),
created_at: chrono::Utc::now(),
context: result,
})
}
async fn broadcast(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) {
for notifier in &self.notifiers {
if let Err(e) = notifier.notify(event, ctx).await {
tracing::warn!(
notifier = std::any::type_name_of_val(notifier.as_ref()),
error = %e,
"notifier failed"
);
}
}
}
}
// healthcare/infrastructure/notifiers.rs
use async_trait::async_trait;
#[async_trait]
pub trait Notifier: Send + Sync {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError>;
}
pub struct SlackNotifier {
client: slack_api::Client,
channel: String,
}
#[async_trait]
impl Notifier for SlackNotifier {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError> {
let text = match ctx {
Some(c) => format!(
"{} | Patient {} | Risk {:.0}%",
event.emoji(),
c.id,
c.risk_score * 100.0
),
None => format!("{} Analysis failed", event.emoji()),
};
self.client
.post_message(&self.channel, &text)
.await
.map_err(|e| NotifierError::Slack(e.to_string()))
}
}
pub struct AuditNotifier {
logger: Arc<dyn tracing::Subscriber + Send + Sync>,
}
#[async_trait]
impl Notifier for AuditNotifier {
async fn notify(
&self,
event: NotificationEvent,
ctx: Option<&PatientContext>,
) -> Result<(), NotifierError> {
tracing::info!(
event = ?event,
patient = ctx.map(|c| c.id.to_string()),
risk = ctx.map(|c| c.risk_score),
"audit"
);
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum NotifierError {
#[error("slack: {0}")]
Slack(String),
#[error("webhook: {0}")]
Webhook(String),
}
// healthcare/domain/patient.rs
use chrono::{Date, Utc};
use std::collections::HashSet;
use uuid::Uuid;
/// Immutable snapshot of a patient's clinical state.
/// Constructed once per inference request; never mutated.
#[derive(Debug, Clone)]
pub struct PatientContext {
pub id: Uuid,
pub age: u8,
pub diagnoses: Vec<Diagnosis>,
pub vitals: Option<Vitals>,
pub risk_score: f64, // 0.0–1.0
pub flags: HashSet<Flag>,
}
impl PatientContext {
pub fn new(
id: Uuid,
age: u8,
diagnoses: Vec<Diagnosis>,
vitals: Option<Vitals>,
risk_score: f64,
flags: HashSet<Flag>,
) -> Result<Self, DomainError> {
if age == 0 {
return Err(DomainError::InvalidAge);
}
if !(0.0..=1.0).contains(&risk_score) {
return Err(DomainError::RiskScoreOutOfRange(risk_score));
}
Ok(Self { id, age, diagnoses, vitals, risk_score, flags })
}
pub fn is_high_risk(&self) -> bool { self.risk_score > 0.75 }
pub fn is_flagged(&self) -> bool { !self.flags.is_empty() }
pub fn primary_dx(&self) -> Option<&Diagnosis> { self.diagnoses.first() }
pub fn comorbidities(&self) -> &[Diagnosis] { &self.diagnoses[1..] }
pub fn has_critical_vitals(&self) -> bool {
self.vitals.as_ref().map_or(false, |v| v.is_critical())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Flag {
IcuCandidate,
Elderly,
Chronic,
HighRisk,
AutoFlagged,
}
#[derive(Debug, Clone)]
pub struct Diagnosis {
pub code: String, // ICD-10
pub description: String,
pub severity: Severity,
pub onset_date: Date<Utc>,
}
impl Diagnosis {
pub fn is_critical(&self) -> bool { self.severity == Severity::Critical }
pub fn is_chronic(&self) -> bool {
(Utc::today() - self.onset_date).num_days() > 365
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { Low, Moderate, High, Critical }
#[derive(Debug, Clone)]
pub struct Vitals {
pub recorded_at: chrono::DateTime<Utc>,
pub heart_rate: u16,
pub systolic_bp: u16,
pub diastolic_bp: u16,
pub spo2: f32,
pub temperature: f32,
}
impl Vitals {
pub fn abnormality_index(&self) -> f32 {
let checks: &[bool] = &[
!(60..=100).contains(&self.heart_rate),
!(90..=120).contains(&self.systolic_bp),
!(60..=80).contains(&self.diastolic_bp),
self.spo2 < 95.0,
!(36.1..=37.2).contains(&(self.temperature as f64)) as bool,
];
let abnormal = checks.iter().filter(|&&c| c).count() as f32;
abnormal / checks.len() as f32
}
pub fn is_critical(&self) -> bool {
self.abnormality_index() >= 0.6 || self.spo2 < 90.0
}
pub fn is_stale(&self) -> bool {
(Utc::now() - self.recorded_at).num_hours() > 6
}
}
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("age must be positive")]
InvalidAge,
#[error("risk score {0} is outside 0.0–1.0")]
RiskScoreOutOfRange(f64),
}
// healthcare/domain/patient.rs
use chrono::{Date, Utc};
use std::collections::HashSet;
use uuid::Uuid;
/// Immutable snapshot of a patient's clinical state.
/// Constructed once per inference request; never mutated.
#[derive(Debug, Clone)]
pub struct PatientContext {
pub id: Uuid,
pub age: u8,
pub diagnoses: Vec<Diagnosis>,
pub vitals: Option<Vitals>,
pub risk_score: f64, // 0.0–1.0
pub flags: HashSet<Flag>,
}
impl PatientContext {
pub fn new(
id: Uuid,
age: u8,
diagnoses: Vec<Diagnosis>,
vitals: Option<Vitals>,
risk_score: f64,
flags: HashSet<Flag>,
) -> Result<Self, DomainError> {
if age == 0 {
return Err(DomainError::InvalidAge);
}
if !(0.0..=1.0).contains(&risk_score) {
return Err(DomainError::RiskScoreOutOfRange(risk_score));
}
Ok(Self { id, age, diagnoses, vitals, risk_score, flags })
}
pub fn is_high_risk(&self) -> bool { self.risk_score > 0.75 }
pub fn is_flagged(&self) -> bool { !self.flags.is_empty() }
pub fn primary_dx(&self) -> Option<&Diagnosis> { self.diagnoses.first() }
pub fn comorbidities(&self) -> &[Diagnosis] { &self.diagnoses[1..] }
pub fn has_critical_vitals(&self) -> bool {
self.vitals.as_ref().map_or(false, |v| v.is_critical())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Flag {
IcuCandidate,
Elderly,
Chronic,
HighRisk,
AutoFlagged,
}
#[derive(Debug, Clone)]
pub struct Diagnosis {
pub code: String, // ICD-10
pub description: String,
pub severity: Severity,
pub onset_date: Date<Utc>,
}
impl Diagnosis {
pub fn is_critical(&self) -> bool { self.severity == Severity::Critical }
pub fn is_chronic(&self) -> bool {
(Utc::today() - self.onset_date).num_days() > 365
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { Low, Moderate, High, Critical }
#[derive(Debug, Clone)]
pub struct Vitals {
pub recorded_at: chrono::DateTime<Utc>,
pub heart_rate: u16,
pub systolic_bp: u16,
pub diastolic_bp: u16,
pub spo2: f32,
pub temperature: f32,
}
impl Vitals {
pub fn abnormality_index(&self) -> f32 {
let checks: &[bool] = &[
!(60..=100).contains(&self.heart_rate),
!(90..=120).contains(&self.systolic_bp),
!(60..=80).contains(&self.diastolic_bp),
self.spo2 < 95.0,
!(36.1..=37.2).contains(&(self.temperature as f64)) as bool,
];
let abnormal = checks.iter().filter(|&&c| c).count() as f32;
abnormal / checks.len() as f32
}
pub fn is_critical(&self) -> bool {
self.abnormality_index() >= 0.6 || self.spo2 < 90.0
}
pub fn is_stale(&self) -> bool {
(Utc::now() - self.recorded_at).num_hours() > 6
}
}
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("age must be positive")]
InvalidAge,
#[error("risk score {0} is outside 0.0–1.0")]
RiskScoreOutOfRange(f64),
}
// healthcare/domain/patient.rs
use chrono::{Date, Utc};
use std::collections::HashSet;
use uuid::Uuid;
/// Immutable snapshot of a patient's clinical state.
/// Constructed once per inference request; never mutated.
#[derive(Debug, Clone)]
pub struct PatientContext {
pub id: Uuid,
pub age: u8,
pub diagnoses: Vec<Diagnosis>,
pub vitals: Option<Vitals>,
pub risk_score: f64, // 0.0–1.0
pub flags: HashSet<Flag>,
}
impl PatientContext {
pub fn new(
id: Uuid,
age: u8,
diagnoses: Vec<Diagnosis>,
vitals: Option<Vitals>,
risk_score: f64,
flags: HashSet<Flag>,
) -> Result<Self, DomainError> {
if age == 0 {
return Err(DomainError::InvalidAge);
}
if !(0.0..=1.0).contains(&risk_score) {
return Err(DomainError::RiskScoreOutOfRange(risk_score));
}
Ok(Self { id, age, diagnoses, vitals, risk_score, flags })
}
pub fn is_high_risk(&self) -> bool { self.risk_score > 0.75 }
pub fn is_flagged(&self) -> bool { !self.flags.is_empty() }
pub fn primary_dx(&self) -> Option<&Diagnosis> { self.diagnoses.first() }
pub fn comorbidities(&self) -> &[Diagnosis] { &self.diagnoses[1..] }
pub fn has_critical_vitals(&self) -> bool {
self.vitals.as_ref().map_or(false, |v| v.is_critical())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Flag {
IcuCandidate,
Elderly,
Chronic,
HighRisk,
AutoFlagged,
}
#[derive(Debug, Clone)]
pub struct Diagnosis {
pub code: String, // ICD-10
pub description: String,
pub severity: Severity,
pub onset_date: Date<Utc>,
}
impl Diagnosis {
pub fn is_critical(&self) -> bool { self.severity == Severity::Critical }
pub fn is_chronic(&self) -> bool {
(Utc::today() - self.onset_date).num_days() > 365
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { Low, Moderate, High, Critical }
#[derive(Debug, Clone)]
pub struct Vitals {
pub recorded_at: chrono::DateTime<Utc>,
pub heart_rate: u16,
pub systolic_bp: u16,
pub diastolic_bp: u16,
pub spo2: f32,
pub temperature: f32,
}
impl Vitals {
pub fn abnormality_index(&self) -> f32 {
let checks: &[bool] = &[
!(60..=100).contains(&self.heart_rate),
!(90..=120).contains(&self.systolic_bp),
!(60..=80).contains(&self.diastolic_bp),
self.spo2 < 95.0,
!(36.1..=37.2).contains(&(self.temperature as f64)) as bool,
];
let abnormal = checks.iter().filter(|&&c| c).count() as f32;
abnormal / checks.len() as f32
}
pub fn is_critical(&self) -> bool {
self.abnormality_index() >= 0.6 || self.spo2 < 90.0
}
pub fn is_stale(&self) -> bool {
(Utc::now() - self.recorded_at).num_hours() > 6
}
}
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("age must be positive")]
InvalidAge,
#[error("risk score {0} is outside 0.0–1.0")]
RiskScoreOutOfRange(f64),
}
// healthcare/domain/patient.rs
use chrono::{Date, Utc};
use std::collections::HashSet;
use uuid::Uuid;
/// Immutable snapshot of a patient's clinical state.
/// Constructed once per inference request; never mutated.
#[derive(Debug, Clone)]
pub struct PatientContext {
pub id: Uuid,
pub age: u8,
pub diagnoses: Vec<Diagnosis>,
pub vitals: Option<Vitals>,
pub risk_score: f64, // 0.0–1.0
pub flags: HashSet<Flag>,
}
impl PatientContext {
pub fn new(
id: Uuid,
age: u8,
diagnoses: Vec<Diagnosis>,
vitals: Option<Vitals>,
risk_score: f64,
flags: HashSet<Flag>,
) -> Result<Self, DomainError> {
if age == 0 {
return Err(DomainError::InvalidAge);
}
if !(0.0..=1.0).contains(&risk_score) {
return Err(DomainError::RiskScoreOutOfRange(risk_score));
}
Ok(Self { id, age, diagnoses, vitals, risk_score, flags })
}
pub fn is_high_risk(&self) -> bool { self.risk_score > 0.75 }
pub fn is_flagged(&self) -> bool { !self.flags.is_empty() }
pub fn primary_dx(&self) -> Option<&Diagnosis> { self.diagnoses.first() }
pub fn comorbidities(&self) -> &[Diagnosis] { &self.diagnoses[1..] }
pub fn has_critical_vitals(&self) -> bool {
self.vitals.as_ref().map_or(false, |v| v.is_critical())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Flag {
IcuCandidate,
Elderly,
Chronic,
HighRisk,
AutoFlagged,
}
#[derive(Debug, Clone)]
pub struct Diagnosis {
pub code: String, // ICD-10
pub description: String,
pub severity: Severity,
pub onset_date: Date<Utc>,
}
impl Diagnosis {
pub fn is_critical(&self) -> bool { self.severity == Severity::Critical }
pub fn is_chronic(&self) -> bool {
(Utc::today() - self.onset_date).num_days() > 365
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Severity { Low, Moderate, High, Critical }
#[derive(Debug, Clone)]
pub struct Vitals {
pub recorded_at: chrono::DateTime<Utc>,
pub heart_rate: u16,
pub systolic_bp: u16,
pub diastolic_bp: u16,
pub spo2: f32,
pub temperature: f32,
}
impl Vitals {
pub fn abnormality_index(&self) -> f32 {
let checks: &[bool] = &[
!(60..=100).contains(&self.heart_rate),
!(90..=120).contains(&self.systolic_bp),
!(60..=80).contains(&self.diastolic_bp),
self.spo2 < 95.0,
!(36.1..=37.2).contains(&(self.temperature as f64)) as bool,
];
let abnormal = checks.iter().filter(|&&c| c).count() as f32;
abnormal / checks.len() as f32
}
pub fn is_critical(&self) -> bool {
self.abnormality_index() >= 0.6 || self.spo2 < 90.0
}
pub fn is_stale(&self) -> bool {
(Utc::now() - self.recorded_at).num_hours() > 6
}
}
#[derive(Debug, thiserror::Error)]
pub enum DomainError {
#[error("age must be positive")]
InvalidAge,
#[error("risk score {0} is outside 0.0–1.0")]
RiskScoreOutOfRange(f64),
}
// healthcare/application/risk_scorer.rs
use crate::domain::{Diagnosis, PatientContext, Vitals};
const WEIGHT_AGE: f64 = 0.20;
const WEIGHT_COMORBIDITY: f64 = 0.40;
const WEIGHT_VITALS: f64 = 0.30;
const WEIGHT_HISTORY: f64 = 0.10;
/// Trait allows injecting alternative scoring strategies
/// (Open/Closed Principle).
pub trait RiskScorer: Send + Sync {
fn score(&self, patient: &PatientInput) -> f64;
}
pub struct PatientInput<'a> {
pub age: u8,
pub records: &'a [MedicalRecord],
}
/// Weighted linear scorer — baseline implementation.
pub struct WeightedRiskScorer;
impl RiskScorer for WeightedRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let s = self.age_score(input.age)
+ self.comorbidity_score(input.records)
+ self.vitals_score(input.records)
+ self.history_score(input.records);
s.clamp(0.0, 1.0)
}
}
impl WeightedRiskScorer {
fn age_score(&self, age: u8) -> f64 {
match age {
0..=17 => 0.1 * WEIGHT_AGE,
18..=44 => 0.2 * WEIGHT_AGE,
45..=64 => 0.5 * WEIGHT_AGE,
65..=79 => 0.7 * WEIGHT_AGE,
_ => 1.0 * WEIGHT_AGE,
}
}
fn comorbidity_score(&self, records: &[MedicalRecord]) -> f64 {
let count: usize = records.iter()
.map(|r| r.diagnoses.len())
.sum();
(count as f64 / 10.0).min(1.0) * WEIGHT_COMORBIDITY
}
fn vitals_score(&self, records: &[MedicalRecord]) -> f64 {
let indices: Vec<f64> = records.iter()
.filter_map(|r| r.vitals.as_ref())
.map(|v| v.abnormality_index() as f64)
.collect();
if indices.is_empty() { return 0.0; }
(indices.iter().sum::<f64>() / indices.len() as f64) * WEIGHT_VITALS
}
fn history_score(&self, records: &[MedicalRecord]) -> f64 {
if records.iter().any(|r| r.hospitalization) { 0.8 } else { 0.1 }
* WEIGHT_HISTORY
}
}
/// Pediatric variant — overrides age weighting only
/// (Liskov Substitution: swappable with WeightedRiskScorer).
pub struct PediatricRiskScorer;
impl RiskScorer for PediatricRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let age_s = if input.age < 5 { 0.6 } else { 0.3 };
let base = WeightedRiskScorer;
// Replace age component, keep the rest
let without_age = base.score(input)
- WeightedRiskScorer.age_score(input.age);
(without_age + age_s * WEIGHT_AGE).clamp(0.0, 1.0)
}
}
// healthcare/application/pipeline.rs
use std::sync::Arc;
pub trait PipelineStep: Send + Sync {
fn process(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError>;
}
pub struct InferencePipeline {
steps: Vec<Arc<dyn PipelineStep>>,
}
impl InferencePipeline {
pub fn new(steps: Vec<Arc<dyn PipelineStep>>) -> Self {
assert!(!steps.is_empty(), "pipeline steps cannot be empty");
Self { steps }
}
pub fn run(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError> {
self.steps.iter().try_fold(ctx, |acc, step| {
step.process(acc)
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("step {step} returned an error: {source}")]
StepFailed { step: String, source: Box<dyn std::error::Error + Send> },
}
// healthcare/application/risk_scorer.rs
use crate::domain::{Diagnosis, PatientContext, Vitals};
const WEIGHT_AGE: f64 = 0.20;
const WEIGHT_COMORBIDITY: f64 = 0.40;
const WEIGHT_VITALS: f64 = 0.30;
const WEIGHT_HISTORY: f64 = 0.10;
/// Trait allows injecting alternative scoring strategies
/// (Open/Closed Principle).
pub trait RiskScorer: Send + Sync {
fn score(&self, patient: &PatientInput) -> f64;
}
pub struct PatientInput<'a> {
pub age: u8,
pub records: &'a [MedicalRecord],
}
/// Weighted linear scorer — baseline implementation.
pub struct WeightedRiskScorer;
impl RiskScorer for WeightedRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let s = self.age_score(input.age)
+ self.comorbidity_score(input.records)
+ self.vitals_score(input.records)
+ self.history_score(input.records);
s.clamp(0.0, 1.0)
}
}
impl WeightedRiskScorer {
fn age_score(&self, age: u8) -> f64 {
match age {
0..=17 => 0.1 * WEIGHT_AGE,
18..=44 => 0.2 * WEIGHT_AGE,
45..=64 => 0.5 * WEIGHT_AGE,
65..=79 => 0.7 * WEIGHT_AGE,
_ => 1.0 * WEIGHT_AGE,
}
}
fn comorbidity_score(&self, records: &[MedicalRecord]) -> f64 {
let count: usize = records.iter()
.map(|r| r.diagnoses.len())
.sum();
(count as f64 / 10.0).min(1.0) * WEIGHT_COMORBIDITY
}
fn vitals_score(&self, records: &[MedicalRecord]) -> f64 {
let indices: Vec<f64> = records.iter()
.filter_map(|r| r.vitals.as_ref())
.map(|v| v.abnormality_index() as f64)
.collect();
if indices.is_empty() { return 0.0; }
(indices.iter().sum::<f64>() / indices.len() as f64) * WEIGHT_VITALS
}
fn history_score(&self, records: &[MedicalRecord]) -> f64 {
if records.iter().any(|r| r.hospitalization) { 0.8 } else { 0.1 }
* WEIGHT_HISTORY
}
}
/// Pediatric variant — overrides age weighting only
/// (Liskov Substitution: swappable with WeightedRiskScorer).
pub struct PediatricRiskScorer;
impl RiskScorer for PediatricRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let age_s = if input.age < 5 { 0.6 } else { 0.3 };
let base = WeightedRiskScorer;
// Replace age component, keep the rest
let without_age = base.score(input)
- WeightedRiskScorer.age_score(input.age);
(without_age + age_s * WEIGHT_AGE).clamp(0.0, 1.0)
}
}
// healthcare/application/pipeline.rs
use std::sync::Arc;
pub trait PipelineStep: Send + Sync {
fn process(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError>;
}
pub struct InferencePipeline {
steps: Vec<Arc<dyn PipelineStep>>,
}
impl InferencePipeline {
pub fn new(steps: Vec<Arc<dyn PipelineStep>>) -> Self {
assert!(!steps.is_empty(), "pipeline steps cannot be empty");
Self { steps }
}
pub fn run(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError> {
self.steps.iter().try_fold(ctx, |acc, step| {
step.process(acc)
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("step {step} returned an error: {source}")]
StepFailed { step: String, source: Box<dyn std::error::Error + Send> },
}
// healthcare/application/risk_scorer.rs
use crate::domain::{Diagnosis, PatientContext, Vitals};
const WEIGHT_AGE: f64 = 0.20;
const WEIGHT_COMORBIDITY: f64 = 0.40;
const WEIGHT_VITALS: f64 = 0.30;
const WEIGHT_HISTORY: f64 = 0.10;
/// Trait allows injecting alternative scoring strategies
/// (Open/Closed Principle).
pub trait RiskScorer: Send + Sync {
fn score(&self, patient: &PatientInput) -> f64;
}
pub struct PatientInput<'a> {
pub age: u8,
pub records: &'a [MedicalRecord],
}
/// Weighted linear scorer — baseline implementation.
pub struct WeightedRiskScorer;
impl RiskScorer for WeightedRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let s = self.age_score(input.age)
+ self.comorbidity_score(input.records)
+ self.vitals_score(input.records)
+ self.history_score(input.records);
s.clamp(0.0, 1.0)
}
}
impl WeightedRiskScorer {
fn age_score(&self, age: u8) -> f64 {
match age {
0..=17 => 0.1 * WEIGHT_AGE,
18..=44 => 0.2 * WEIGHT_AGE,
45..=64 => 0.5 * WEIGHT_AGE,
65..=79 => 0.7 * WEIGHT_AGE,
_ => 1.0 * WEIGHT_AGE,
}
}
fn comorbidity_score(&self, records: &[MedicalRecord]) -> f64 {
let count: usize = records.iter()
.map(|r| r.diagnoses.len())
.sum();
(count as f64 / 10.0).min(1.0) * WEIGHT_COMORBIDITY
}
fn vitals_score(&self, records: &[MedicalRecord]) -> f64 {
let indices: Vec<f64> = records.iter()
.filter_map(|r| r.vitals.as_ref())
.map(|v| v.abnormality_index() as f64)
.collect();
if indices.is_empty() { return 0.0; }
(indices.iter().sum::<f64>() / indices.len() as f64) * WEIGHT_VITALS
}
fn history_score(&self, records: &[MedicalRecord]) -> f64 {
if records.iter().any(|r| r.hospitalization) { 0.8 } else { 0.1 }
* WEIGHT_HISTORY
}
}
/// Pediatric variant — overrides age weighting only
/// (Liskov Substitution: swappable with WeightedRiskScorer).
pub struct PediatricRiskScorer;
impl RiskScorer for PediatricRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let age_s = if input.age < 5 { 0.6 } else { 0.3 };
let base = WeightedRiskScorer;
// Replace age component, keep the rest
let without_age = base.score(input)
- WeightedRiskScorer.age_score(input.age);
(without_age + age_s * WEIGHT_AGE).clamp(0.0, 1.0)
}
}
// healthcare/application/pipeline.rs
use std::sync::Arc;
pub trait PipelineStep: Send + Sync {
fn process(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError>;
}
pub struct InferencePipeline {
steps: Vec<Arc<dyn PipelineStep>>,
}
impl InferencePipeline {
pub fn new(steps: Vec<Arc<dyn PipelineStep>>) -> Self {
assert!(!steps.is_empty(), "pipeline steps cannot be empty");
Self { steps }
}
pub fn run(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError> {
self.steps.iter().try_fold(ctx, |acc, step| {
step.process(acc)
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("step {step} returned an error: {source}")]
StepFailed { step: String, source: Box<dyn std::error::Error + Send> },
}
// healthcare/application/risk_scorer.rs
use crate::domain::{Diagnosis, PatientContext, Vitals};
const WEIGHT_AGE: f64 = 0.20;
const WEIGHT_COMORBIDITY: f64 = 0.40;
const WEIGHT_VITALS: f64 = 0.30;
const WEIGHT_HISTORY: f64 = 0.10;
/// Trait allows injecting alternative scoring strategies
/// (Open/Closed Principle).
pub trait RiskScorer: Send + Sync {
fn score(&self, patient: &PatientInput) -> f64;
}
pub struct PatientInput<'a> {
pub age: u8,
pub records: &'a [MedicalRecord],
}
/// Weighted linear scorer — baseline implementation.
pub struct WeightedRiskScorer;
impl RiskScorer for WeightedRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let s = self.age_score(input.age)
+ self.comorbidity_score(input.records)
+ self.vitals_score(input.records)
+ self.history_score(input.records);
s.clamp(0.0, 1.0)
}
}
impl WeightedRiskScorer {
fn age_score(&self, age: u8) -> f64 {
match age {
0..=17 => 0.1 * WEIGHT_AGE,
18..=44 => 0.2 * WEIGHT_AGE,
45..=64 => 0.5 * WEIGHT_AGE,
65..=79 => 0.7 * WEIGHT_AGE,
_ => 1.0 * WEIGHT_AGE,
}
}
fn comorbidity_score(&self, records: &[MedicalRecord]) -> f64 {
let count: usize = records.iter()
.map(|r| r.diagnoses.len())
.sum();
(count as f64 / 10.0).min(1.0) * WEIGHT_COMORBIDITY
}
fn vitals_score(&self, records: &[MedicalRecord]) -> f64 {
let indices: Vec<f64> = records.iter()
.filter_map(|r| r.vitals.as_ref())
.map(|v| v.abnormality_index() as f64)
.collect();
if indices.is_empty() { return 0.0; }
(indices.iter().sum::<f64>() / indices.len() as f64) * WEIGHT_VITALS
}
fn history_score(&self, records: &[MedicalRecord]) -> f64 {
if records.iter().any(|r| r.hospitalization) { 0.8 } else { 0.1 }
* WEIGHT_HISTORY
}
}
/// Pediatric variant — overrides age weighting only
/// (Liskov Substitution: swappable with WeightedRiskScorer).
pub struct PediatricRiskScorer;
impl RiskScorer for PediatricRiskScorer {
fn score(&self, input: &PatientInput) -> f64 {
let age_s = if input.age < 5 { 0.6 } else { 0.3 };
let base = WeightedRiskScorer;
// Replace age component, keep the rest
let without_age = base.score(input)
- WeightedRiskScorer.age_score(input.age);
(without_age + age_s * WEIGHT_AGE).clamp(0.0, 1.0)
}
}
// healthcare/application/pipeline.rs
use std::sync::Arc;
pub trait PipelineStep: Send + Sync {
fn process(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError>;
}
pub struct InferencePipeline {
steps: Vec<Arc<dyn PipelineStep>>,
}
impl InferencePipeline {
pub fn new(steps: Vec<Arc<dyn PipelineStep>>) -> Self {
assert!(!steps.is_empty(), "pipeline steps cannot be empty");
Self { steps }
}
pub fn run(
&self,
ctx: PatientContext,
) -> Result<PatientContext, PipelineError> {
self.steps.iter().try_fold(ctx, |acc, step| {
step.process(acc)
})
}
}
#[derive(Debug, thiserror::Error)]
pub enum PipelineError {
#[error("step {step} returned an error: {source}")]
StepFailed { step: String, source: Box<dyn std::error::Error + Send> },
}