use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::calculator::{CalcError, Calculator};
use crate::license::CalculatorLicense;
use crate::response::CalculationResponse;
pub const NAME: &str = "meld";
pub const LICENSE: CalculatorLicense = CalculatorLicense {
license: "Public-domain method - implemented from the primary literature (original MELD, 2001)",
source_url: "https://doi.org/10.1053/jhep.2001.22172",
};
pub const REFERENCE: &str = "Kamath PS, Wiesner RH, Malinchoc M, et al. A model to predict survival in patients with \
end-stage liver disease. Hepatology. 2001;33(2):464-470. doi:10.1053/jhep.2001.22172. Bounding \
rules per UNOS/OPTN policy.";
pub const BILIRUBIN_UMOL_PER_MGDL: f64 = 17.1;
pub const CREATININE_UMOL_PER_MGDL: f64 = 88.4;
pub const INPUT_FLOOR: f64 = 1.0;
pub const CREATININE_CAP: f64 = 4.0;
pub const SCORE_MIN: i32 = 6;
pub const SCORE_MAX: i32 = 40;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Unit {
#[serde(rename = "mg/dL")]
MgDl,
#[serde(rename = "umol/L")]
UmolL,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct MeldInput {
pub bilirubin: f64,
pub bilirubin_unit: Unit,
pub inr: f64,
pub creatinine: f64,
pub creatinine_unit: Unit,
pub dialysis_twice_past_week: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MeldOutcome {
pub score: i32,
pub raw_score: f64,
pub bilirubin_used: f64,
pub inr_used: f64,
pub creatinine_used: f64,
pub interpretation: String,
}
fn floored(value: f64) -> f64 {
value.max(INPUT_FLOOR)
}
pub fn compute(input: &MeldInput) -> Result<MeldOutcome, CalcError> {
if input.bilirubin <= 0.0 || input.inr <= 0.0 || input.creatinine <= 0.0 {
return Err(CalcError::InvalidInput(
"bilirubin, INR, and creatinine must be positive".into(),
));
}
if !input.bilirubin.is_finite() || !input.inr.is_finite() || !input.creatinine.is_finite() {
return Err(CalcError::InvalidInput("values must be finite".into()));
}
let bilirubin_mgdl = match input.bilirubin_unit {
Unit::MgDl => input.bilirubin,
Unit::UmolL => input.bilirubin / BILIRUBIN_UMOL_PER_MGDL,
};
let creatinine_mgdl = match input.creatinine_unit {
Unit::MgDl => input.creatinine,
Unit::UmolL => input.creatinine / CREATININE_UMOL_PER_MGDL,
};
let bilirubin_used = floored(bilirubin_mgdl);
let inr_used = floored(input.inr);
let mut creatinine_used = floored(creatinine_mgdl);
if input.dialysis_twice_past_week || creatinine_used > CREATININE_CAP {
creatinine_used = CREATININE_CAP;
}
let raw_score =
3.78 * bilirubin_used.ln() + 11.2 * inr_used.ln() + 9.57 * creatinine_used.ln() + 6.43;
let score = (raw_score.round() as i32).clamp(SCORE_MIN, SCORE_MAX);
let interpretation = format!(
"MELD score {score} (original 2001 model). Higher scores indicate greater 3-month \
mortality risk in end-stage liver disease; the score is bounded to {SCORE_MIN}-{SCORE_MAX} (UNOS). \
This is the original MELD - current UNOS/OPTN transplant allocation uses MELD-Na or MELD 3.0, \
which add serum sodium (and, for MELD 3.0, albumin and a sex term) and will differ from this value."
);
Ok(MeldOutcome {
score,
raw_score,
bilirubin_used,
inr_used,
creatinine_used,
interpretation,
})
}
pub fn build_response(input: &MeldInput) -> Result<CalculationResponse, CalcError> {
let o = compute(input)?;
let mut working = Map::new();
working.insert("bilirubin_mgdl_used".into(), json!(o.bilirubin_used));
working.insert("inr_used".into(), json!(o.inr_used));
working.insert("creatinine_mgdl_used".into(), json!(o.creatinine_used));
working.insert("raw_score".into(), json!(o.raw_score));
working.insert("meld_score".into(), json!(o.score));
working.insert("score_min".into(), json!(SCORE_MIN));
working.insert("score_max".into(), json!(SCORE_MAX));
Ok(CalculationResponse {
calculator: NAME.to_string(),
result: json!(o.score),
interpretation: o.interpretation,
working,
reference: REFERENCE.to_string(),
})
}
pub struct Meld;
impl Calculator for Meld {
fn name(&self) -> &'static str {
NAME
}
fn title(&self) -> &'static str {
"MELD Score (original, 2001)"
}
fn description(&self) -> &'static str {
"Model for End-Stage Liver Disease: 3-month mortality risk from bilirubin, INR, and creatinine (Kamath 2001)."
}
fn reference(&self) -> &'static str {
REFERENCE
}
fn license(&self) -> CalculatorLicense {
LICENSE
}
fn input_schema(&self) -> Value {
json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "MeldInput",
"type": "object",
"additionalProperties": false,
"required": [
"bilirubin",
"bilirubin_unit",
"inr",
"creatinine",
"creatinine_unit",
"dialysis_twice_past_week"
],
"properties": {
"bilirubin": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Total serum bilirubin, in the unit given by bilirubin_unit (floored to 1.0 mg/dL before the ln)"
},
"bilirubin_unit": {
"type": "string",
"enum": ["mg/dL", "umol/L"],
"description": "Unit of the bilirubin value",
"definition": {
"concept": "Bilirubin unit",
"statement": "MELD is defined with total bilirubin in mg/dL. UK laboratories report umol/L; the value is converted internally (1 mg/dL = 17.1 umol/L).",
"excludes": [
"Do NOT pass a umol/L value while labelling it mg/dL: the two differ by ~17x and the wrong label silently produces a grossly wrong MELD"
],
"source": {
"citation": "Kamath PS et al. Hepatology. 2001;33(2):464-470.",
"url": "https://doi.org/10.1053/jhep.2001.22172"
},
"status": "draft"
}
},
"inr": {
"type": "number",
"exclusiveMinimum": 0,
"description": "International normalised ratio (INR), dimensionless (floored to 1.0 before the ln)"
},
"creatinine": {
"type": "number",
"exclusiveMinimum": 0,
"description": "Serum creatinine, in the unit given by creatinine_unit (floored to 1.0 and capped at 4.0 mg/dL)"
},
"creatinine_unit": {
"type": "string",
"enum": ["mg/dL", "umol/L"],
"description": "Unit of the creatinine value",
"definition": {
"concept": "Creatinine unit",
"statement": "MELD is defined with creatinine in mg/dL. UK laboratories report umol/L; the value is converted internally (1 mg/dL = 88.4 umol/L).",
"excludes": [
"Do NOT pass a umol/L value while labelling it mg/dL: the two differ by ~88x and the wrong label silently produces a grossly wrong MELD"
],
"source": {
"citation": "Kamath PS et al. Hepatology. 2001;33(2):464-470.",
"url": "https://doi.org/10.1053/jhep.2001.22172"
},
"status": "draft"
}
},
"dialysis_twice_past_week": {
"type": "boolean",
"description": "True if the patient had dialysis twice or more in the past week, or 24h of CVVHD; forces creatinine to 4.0 mg/dL",
"definition": {
"concept": "Dialysis substitution rule",
"statement": "Per UNOS/OPTN policy, two or more dialysis sessions in the prior 7 days (or 24h of continuous venovenous haemodialysis) sets creatinine to 4.0 mg/dL regardless of the measured value, reflecting that dialysis artificially lowers serum creatinine.",
"source": {
"citation": "OPTN Policy 9 (liver allocation); Kamath PS et al. Hepatology. 2001;33(2):464-470.",
"url": "https://doi.org/10.1053/jhep.2001.22172"
},
"status": "draft"
}
}
}
})
}
fn calculate(&self, input: &Value) -> Result<CalculationResponse, CalcError> {
let parsed: MeldInput = serde_json::from_value(input.clone())
.map_err(|e| CalcError::InvalidInput(e.to_string()))?;
build_response(&parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn input(
bili: f64,
bili_unit: Unit,
inr: f64,
creat: f64,
creat_unit: Unit,
dialysis: bool,
) -> MeldInput {
MeldInput {
bilirubin: bili,
bilirubin_unit: bili_unit,
inr,
creatinine: creat,
creatinine_unit: creat_unit,
dialysis_twice_past_week: dialysis,
}
}
#[test]
fn worked_example_matches_known_meld() {
let o = compute(&input(2.0, Unit::MgDl, 1.5, 1.5, Unit::MgDl, false)).unwrap();
assert!((o.raw_score - 17.47).abs() < 0.05, "raw {}", o.raw_score);
assert_eq!(o.score, 17);
}
#[test]
fn values_below_one_are_floored() {
let o = compute(&input(0.3, Unit::MgDl, 0.9, 0.5, Unit::MgDl, false)).unwrap();
assert_eq!(o.bilirubin_used, 1.0);
assert_eq!(o.inr_used, 1.0);
assert_eq!(o.creatinine_used, 1.0);
assert_eq!(o.raw_score, 6.43);
assert_eq!(o.score, 6);
}
#[test]
fn creatinine_capped_at_four() {
let capped = compute(&input(2.0, Unit::MgDl, 1.5, 8.0, Unit::MgDl, false)).unwrap();
let at_cap = compute(&input(2.0, Unit::MgDl, 1.5, 4.0, Unit::MgDl, false)).unwrap();
assert_eq!(capped.creatinine_used, 4.0);
assert_eq!(capped.score, at_cap.score);
assert_eq!(capped.raw_score, at_cap.raw_score);
}
#[test]
fn dialysis_forces_creatinine_to_four() {
let dial = compute(&input(2.0, Unit::MgDl, 1.5, 1.0, Unit::MgDl, true)).unwrap();
let four = compute(&input(2.0, Unit::MgDl, 1.5, 4.0, Unit::MgDl, false)).unwrap();
assert_eq!(dial.creatinine_used, 4.0);
assert_eq!(dial.score, four.score);
assert_eq!(dial.raw_score, four.raw_score);
}
#[test]
fn score_clamped_to_six_minimum() {
let o = compute(&input(0.5, Unit::MgDl, 1.0, 0.8, Unit::MgDl, false)).unwrap();
assert_eq!(o.score, SCORE_MIN);
}
#[test]
fn score_clamped_to_forty_maximum() {
let o = compute(&input(50.0, Unit::MgDl, 10.0, 4.0, Unit::MgDl, true)).unwrap();
assert!(o.raw_score > 40.0, "raw {}", o.raw_score);
assert_eq!(o.score, SCORE_MAX);
}
#[test]
fn umol_units_match_equivalent_mgdl() {
let mgdl = compute(&input(2.0, Unit::MgDl, 1.5, 1.5, Unit::MgDl, false)).unwrap();
let umol = compute(&input(
2.0 * BILIRUBIN_UMOL_PER_MGDL,
Unit::UmolL,
1.5,
1.5 * CREATININE_UMOL_PER_MGDL,
Unit::UmolL,
false,
))
.unwrap();
assert_eq!(mgdl.score, umol.score);
assert!((mgdl.raw_score - umol.raw_score).abs() < 1e-9);
}
#[test]
fn rejects_nonpositive_and_nonfinite() {
assert!(compute(&input(0.0, Unit::MgDl, 1.5, 1.5, Unit::MgDl, false)).is_err());
assert!(compute(&input(2.0, Unit::MgDl, 0.0, 1.5, Unit::MgDl, false)).is_err());
assert!(compute(&input(2.0, Unit::MgDl, 1.5, -1.0, Unit::MgDl, false)).is_err());
assert!(compute(&input(f64::NAN, Unit::MgDl, 1.5, 1.5, Unit::MgDl, false)).is_err());
}
#[test]
fn schema_flags_unit_exclusions() {
let schema = Meld.input_schema();
let bili = &schema["properties"]["bilirubin_unit"]["definition"];
assert!(bili["excludes"][0].as_str().unwrap().contains("17x"));
let creat = &schema["properties"]["creatinine_unit"]["definition"];
assert!(creat["excludes"][0].as_str().unwrap().contains("88x"));
}
#[test]
fn dynamic_calculate_matches_typed() {
let value = json!({
"bilirubin": 2.0,
"bilirubin_unit": "mg/dL",
"inr": 1.5,
"creatinine": 1.5,
"creatinine_unit": "mg/dL",
"dialysis_twice_past_week": false
});
let dynamic = Meld.calculate(&value).unwrap();
let typed = build_response(&input(2.0, Unit::MgDl, 1.5, 1.5, Unit::MgDl, false)).unwrap();
assert_eq!(dynamic, typed);
assert_eq!(dynamic.result, json!(17));
}
}