Require reference ranges for published question labs

This commit is contained in:
hz
2026-07-29 01:28:37 -05:00
parent f082be38e0
commit c14ac1d65b
3 changed files with 289 additions and 0 deletions
+39
View File
@@ -148,6 +148,45 @@ public sealed class ChoicePoolTests
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void PublishedQuestionRequiresCompleteLabReferenceRanges()
{
var request = BuildRequest(5);
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Contains("requires a reference range", QuestionValidator.Validate(request));
request.Labs[0].Reference = "136–145 mEq/L";
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void DraftQuestionMaySaveLabWithoutReferenceRange()
{
var request = BuildRequest(5);
request.IsPublished = false;
request.Labs =
[
new QuestionLab { Name = "Sodium", Value = "128", Unit = "mEq/L", Reference = "" }
];
Assert.Null(QuestionValidator.Validate(request));
}
[Fact]
public void LabEntriesAlwaysRequireNamesAndValues()
{
var request = BuildRequest(5);
request.Labs = [new QuestionLab { Name = "", Value = "128", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
request.Labs = [new QuestionLab { Name = "Sodium", Value = "", Reference = "136–145 mEq/L" }];
Assert.Contains("lab name and value", QuestionValidator.Validate(request));
}
[Theory]
[InlineData("http://example.org/guideline", "HTTPS")]
[InlineData("https://example.org/guideline", null)]
@@ -68,11 +68,31 @@ public static class QuestionValidator
if (string.IsNullOrWhiteSpace(req.System)) return "system required";
if (string.IsNullOrWhiteSpace(req.Discipline)) return "discipline required";
if (req.Difficulty is < 1 or > 5) return "difficulty must be between 1 and 5";
var labValidation = ValidateLabs(req.Labs, req.IsPublished);
if (labValidation is not null) return labValidation;
var referenceValidation = ValidateReferences(req.References, req.IsPublished);
if (referenceValidation is not null) return referenceValidation;
return null;
}
public static string? ValidateLabs(IReadOnlyCollection<QuestionLab>? labs, bool requireComplete)
{
if (labs is null)
return "labs must be an array";
if (labs.Count > 50)
return "questions support at most fifty lab results";
foreach (var lab in labs)
{
if (string.IsNullOrWhiteSpace(lab.Name) || string.IsNullOrWhiteSpace(lab.Value))
return "lab name and value are required";
if (requireComplete && string.IsNullOrWhiteSpace(lab.Reference))
return $"published question lab '{lab.Name.Trim()}' requires a reference range";
}
return null;
}
public static string? ValidateReferences(IReadOnlyCollection<QuestionReference>? references, bool required)
{
if (references is null)
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""Audit or backfill missing question lab reference ranges.
Ranges are based primarily on the NBME Laboratory Reference Values published
April 2026. The command is dry-run by default and writes a recoverable MongoDB
backup plus an audit record before applying changes.
"""
import argparse
import copy
import uuid
from datetime import datetime, timezone
from pymongo import MongoClient
NBME = {
"sodium": "136–146 mEq/L", "serum sodium": "136–146 mEq/L",
"potassium": "3.5–5.0 mEq/L", "serum potassium": "3.5–5.0 mEq/L",
"chloride": "95–105 mEq/L", "serum chloride": "95–105 mEq/L",
"bicarbonate": "22–28 mEq/L", "serum bicarbonate": "22–28 mEq/L", "hco3-": "22–28 mEq/L",
"blood urea nitrogen": "7–18 mg/dL", "serum urea nitrogen": "7–18 mg/dL",
"urea nitrogen": "7–18 mg/dL", "bun": "7–18 mg/dL",
"creatinine": "0.6–1.2 mg/dL", "serum creatinine": "0.6–1.2 mg/dL",
"baseline creatinine": "0.6–1.2 mg/dL", "postprocedure day 1 creatinine": "0.6–1.2 mg/dL",
"postprocedure day 2 creatinine": "0.6–1.2 mg/dL",
"glucose": "Fasting 70–100; random <140 mg/dL", "serum glucose": "Fasting 70–100; random <140 mg/dL",
"fasting glucose": "70–100 mg/dL", "fingerstick glucose": "70–100 mg/dL",
"point-of-care glucose": "70–100 mg/dL", "plasma glucose during episode": "70–100 mg/dL fasting",
"calcium": "8.4–10.2 mg/dL", "magnesium": "1.5–2.0 mg/dL",
"phosphorus": "3.0–4.5 mg/dL", "albumin": "3.5–5.5 g/dL",
"ast": "12–38 U/L", "aspartate aminotransferase": "12–38 U/L",
"alt": "10–40 U/L", "alanine aminotransferase": "10–40 U/L",
"alkaline phosphatase": "25–100 U/L", "amylase": "25–125 U/L", "lipase": "13–60 U/L",
"total bilirubin": "0.1–1.0 mg/dL", "serum bilirubin": "0.1–1.0 mg/dL",
"direct bilirubin": "0.0–0.3 mg/dL", "lactate dehydrogenase": "45–200 U/L",
"serum osmolality": "275–295 mOsm/kg H2O", "serum uric acid": "3.0–8.2 mg/dL",
"creatine kinase": "Male 25–90; female 10–70 U/L",
"total cholesterol": "Normal <200 mg/dL", "ldl cholesterol": "<160 mg/dL",
"ldl-cholesterol": "<160 mg/dL", "hdl cholesterol": "40–60 mg/dL",
"triglycerides": "Normal <150 mg/dL", "serum ferritin": "Male 20–250; female 10–120 ng/mL",
"tsh": "0.4–4.0 mIU/L", "thyroid-stimulating hormone": "0.4–4.0 mIU/L",
"free t4": "0.9–1.7 ng/dL", "total t3": "100–200 ng/dL",
"serum ige": "0–380 IU/mL", "ph": "7.35–7.45", "pco2": "33–45 mm Hg",
"paco2": "33–45 mm Hg", "arterial pco2": "33–45 mm Hg",
"pao2": "75–105 mm Hg", "po2": "75–105 mm Hg", "arterial po2": "75–105 mm Hg",
"arterial ph": "7.35–7.45",
"hemoglobin": "Male 13.5–17.5; female 12.0–16.0 g/dL",
"hematocrit": "Male 41%–53%; female 36%–46%",
"mean corpuscular volume": "80–100 fL", "mcv": "80–100 fL",
"leukocyte count": "4,500–11,000/mm3", "white blood cell count": "4,500–11,000/mm3",
"wbc count": "4,500–11,000/mm3", "wbc": "4,500–11,000/mm3",
"platelet count": "150,000–400,000/mm3", "platelets": "150,000–400,000/mm3",
"segmented neutrophils": "54%–62%", "band neutrophils": "3%–5%",
"lymphocytes": "25%–33%", "blood eosinophils": "1%–3%",
"pt": "11–15 seconds", "prothrombin time": "11–15 seconds",
"ptt": "25–40 seconds", "activated partial thromboplastin time": "25–40 seconds",
"inr": "0.8–1.2", "d-dimer": "≤250 ng/mL", "fibrinogen": "200–400 mg/dL",
"reticulocyte count": "0.5%–1.5%", "cd4 count": "≥500/mm3",
"hemoglobin a1c": "≤6%", "erythrocyte sedimentation rate": "Male 0–15; female 0–20 mm/h",
"urine osmolality": "50–1200 mOsm/kg H2O", "urine specific gravity": "1.005–1.030",
"urine albumin/creatinine ratio": "<30 mg/g", "urine albumin-to-creatinine ratio": "<30 mg/g",
"urine albumin-creatinine ratio": "<30 mg/g", "urine protein": "Negative; total <150 mg/24 h",
"urine wbc": "0–5/hpf", "urine wbcs": "0–5/hpf", "urine white blood cells": "0–5/hpf",
"urine leukocytes": "0–5/hpf", "urine rbc": "0–2/hpf", "urine rbcs": "0–2/hpf",
"urine erythrocytes": "0–2/hpf", "rbcs": "0–2/hpf", "wbcs": "0–5/hpf",
"bmi": "Adult 19–25 kg/m2", "cd4+ t-lymphocyte count": "≥500/mm3",
"c-reactive protein": "<1.0 mg/dL", "lactate": "0.5–2.2 mmol/L",
"vitamin b12": "200–900 pg/mL", "haptoglobin": "30–200 mg/dL",
"25-hydroxyvitamin d": "20–50 ng/mL", "aldolase": "1.0–7.5 U/L",
"bnp": "<100 pg/mL", "high-sensitivity troponin i": "Assay-specific; typically <99th percentile",
"oxygen saturation": "95%–100%", "room-air oxygen saturation": "95%–100%",
"temperature": "36.5–37.5°C", "pulse": "60–100/min", "respirations": "12–20/min",
"blood pressure": "Adult <120/80 mm Hg", "left ventricular ejection fraction": "55%–70%",
"qrs duration": "≤120 milliseconds", "pr interval": "120–200 milliseconds",
"estimated glomerular filtration rate": "≥60 mL/min/1.73 m2", "estimated gfr": "≥60 mL/min/1.73 m2",
"salicylate": "Therapeutic 10–30 mg/dL", "c3": "90–180 mg/dL", "c4": "10–40 mg/dL",
"c1q": "12–22 mg/dL", "synovial fluid leukocyte count": "<200/mm3",
}
QUALITATIVE = {
"urinalysis", "peripheral blood smear", "serial cardiac troponins", "resting ecg between episodes",
"exercise treadmill test", "serum electrolytes", "urinalysis - leukocyte esterase",
"urinalysis - nitrites", "urinalysis - white blood cells", "compression ultrasonography",
"stool occult blood", "gastric biopsy", "urine toxicology screen", "repeat liver studies",
"synovial fluid gram stain", "synovial fluid bacterial culture", "synovial fluid crystal analysis",
"pulmonary function testing", "chest ct", "adrenal ct", "urine beta-hcg", "lithium level",
"serum beta-hydroxybutyrate", "antistreptolysin o titer", "fev1", "cbc",
"complete blood count", "comprehensive metabolic panel", "ecg", "echocardiogram",
"maternal hepatitis b surface antigen", "leukocyte esterase", "nitrite", "bacteria",
"urine blood", "urine leukocyte esterase", "urine nitrite", "urine casts",
"urine dipstick blood", "pregnancy test", "urine pregnancy test",
"potassium hydroxide preparation from scale scraping", "fetal karyotype",
"glucose and electrolytes", "cbc, ferritin, zinc, and venous lead",
}
CUSTOM = {
"urine sodium": "40–220 mEq/24 h (diet dependent)",
"hiv rna": "Not detected",
"urine erythrocytes": "0–2/hpf", "urine leukocytes": "0–5/hpf",
"urine calcium": "100–300 mg/24 h", "urine blood": "Negative",
"urine nitrite": "Negative", "urine leukocyte esterase": "Negative",
"urine dipstick blood": "Negative", "pregnancy test": "Negative",
"urine pregnancy test": "Negative", "urine beta-hcg": "Negative",
"urine casts": "None to rare hyaline casts",
"urine albumin-creatinine ratio": "<30 mg/g",
"urine albumin-to-creatinine ratio": "<30 mg/g",
"urine albumin/creatinine ratio": "<30 mg/g",
"urine protein": "Negative; total <150 mg/24 h",
"ascitic fluid albumin": "Interpret with serum albumin (SAAG)",
"ascitic fluid total protein": "Clinical-context dependent",
"home glucose values": "Individualized target; commonly 80–130 mg/dL premeal",
"oxygen saturation": "95%–100%", "estimated pulmonary artery systolic pressure": "15–30 mm Hg",
"pulmonary-to-systemic flow ratio (qp:qs)": "Approximately 1:1",
"glucose infusion rate": "Age- and clinical-context dependent",
"tuberculin skin test": "Positive at ≥5, ≥10, or ≥15 mm depending on risk",
"urine output": "Adult target ≥0.5 mL/kg/h",
}
def embedded_test(value):
lowered = value.lower().strip()
aliases = [
("thyroid-stimulating hormone", "thyroid-stimulating hormone"), ("complete metabolic panel", None),
("complete blood count", None), ("liver function tests", None), ("erythrocyte sedimentation rate", "erythrocyte sedimentation rate"),
("measured serum osmolality", "serum osmolality"), ("activated partial thromboplastin time", "activated partial thromboplastin time"),
("platelet count", "platelet count"), ("white blood cell count", "white blood cell count"),
("blood urea nitrogen", "blood urea nitrogen"), ("oxygen saturation", "oxygen saturation"),
("prothrombin time", "prothrombin time"),
("segmented neutrophils", "segmented neutrophils"), ("creatine kinase", "creatine kinase"),
("hematocrit", "hematocrit"), ("hemoglobin", "hemoglobin"), ("leukocyte count", "leukocyte count"),
("arterial ph", "ph"), ("serum lipase", "lipase"), ("serum albumin", "albumin"),
("total cholesterol", "total cholesterol"), ("urine output", "urine output"),
("tuberculin skin test induration", "tuberculin skin test"), ("hiv rna", "hiv rna"),
("cd4 count", "cd4 count"), ("pco2", "pco2"), ("po2", "po2"), ("sodium", "sodium"),
("potassium", "potassium"), ("chloride", "chloride"), ("bicarbonate", "bicarbonate"),
("hco3", "bicarbonate"), ("glucose", "glucose"), ("creatinine", "creatinine"),
("calcium", "calcium"), ("magnesium", "magnesium"), ("phosphorus", "phosphorus"),
("albumin", "albumin"), ("lipase", "lipase"), ("bands", "band neutrophils"),
("lymphocytes", "lymphocytes"), ("inr", "inr"), ("na ", "sodium"), ("cl ", "chloride"),
("k ", "potassium"), ("bun ", "bun"),
]
for prefix, key in aliases:
if lowered.startswith(prefix) or lowered.startswith("admission " + prefix) or lowered.startswith("repeat " + prefix):
return key
return None
def reference_for(name, value):
key = name.strip().lower()
if key == "laboratory result":
key = embedded_test(value)
if key is None:
if " normal" in value.lower() or "within reference range" in value.lower():
return "Normal/within the laboratory reference interval"
return None
if key in CUSTOM:
return CUSTOM[key]
if key in NBME:
return NBME[key]
if key in QUALITATIVE:
return "Expected: normal/negative unless otherwise specified"
return None
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--uri", required=True)
parser.add_argument("--database", required=True)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
client = MongoClient(args.uri, serverSelectionTimeoutMS=5000)
db = client[args.database]
questions = db["Questions"]
changes = []
unresolved = []
for question in questions.find({"Labs.0": {"$exists": True}}):
labs = question.get("Labs") or []
original_labs = copy.deepcopy(labs)
updated = False
for lab in labs:
if (lab.get("Reference") or "").strip():
continue
reference = reference_for(str(lab.get("Name") or ""), str(lab.get("Value") or ""))
if reference is None:
unresolved.append((question.get("ExternalId"), lab.get("Name"), lab.get("Value")))
else:
lab["Reference"] = reference
updated = True
if updated:
changes.append((question, original_labs, labs))
print(f"database={args.database} questions_to_update={len(changes)} unresolved_labs={len(unresolved)}")
for item in unresolved:
print(f"UNRESOLVED externalId={item[0]!r} name={item[1]!r} value={item[2]!r}")
if unresolved:
raise SystemExit("Refusing to apply while lab entries remain unresolved")
if not args.apply:
return
batch_id = f"lab-reference-{datetime.now(timezone.utc):%Y%m%dT%H%M%SZ}-{uuid.uuid4().hex[:8]}"
backups = db["QuestionLabReferenceBackups"]
audit = db["ContentMaintenance"]
now = datetime.now(timezone.utc)
for question, original_labs, labs in changes:
backups.insert_one({
"BatchId": batch_id, "QuestionId": question["_id"], "ExternalId": question.get("ExternalId"),
"Labs": original_labs, "BackedUpUtc": now,
})
questions.update_one({"_id": question["_id"]}, {"$set": {"Labs": labs, "UpdatedUtc": now}})
remaining = questions.count_documents({
"IsPublished": True,
"Labs": {"$elemMatch": {"$or": [
{"Reference": {"$exists": False}}, {"Reference": ""}, {"Reference": None}
]}}
})
audit.insert_one({
"BatchId": batch_id, "Operation": "BackfillQuestionLabReferences",
"Database": args.database, "QuestionsUpdated": len(changes),
"RemainingPublishedQuestionsWithMissingReferences": remaining, "CompletedUtc": now,
"Source": "NBME Laboratory Reference Values (April 2026)",
})
print(f"applied batch={batch_id} questions_updated={len(changes)} remaining_published={remaining}")
if remaining:
raise SystemExit("Post-update verification failed")
if __name__ == "__main__":
main()