# Guide 22: Clinical Scoring Engines (NEWS2, GCS, SOFA, qSOFA)

## What Are Clinical Scoring Systems?

In medicine, a single vital sign (like heart rate = 110) doesn't tell you much on its own — it could be a patient exercising or a patient in septic shock. **Clinical scoring systems** combine multiple vital signs and lab values into a single number that predicts how sick a patient is and whether they need urgent intervention.

Think of it like a weather severity index: temperature alone doesn't tell you if a storm is dangerous, but combining temperature, wind speed, pressure, and humidity gives you a meaningful risk score.

This project implements four clinical scoring systems:

| Score | Full Name | What It Measures | Parameters | Alert Threshold |
|-------|-----------|-----------------|------------|-----------------|
| **NEWS2** | National Early Warning Score 2 | General deterioration risk | 7 vital signs | Score >= 7 → Emergency |
| **GCS** | Glasgow Coma Scale | Level of consciousness | 3 components (Eye, Verbal, Motor) | Total <= 8 → Severe (Critical) |
| **qSOFA** | Quick SOFA | Bedside sepsis screen | 3 criteria | >= 2 criteria met → Screen positive |
| **SOFA** | Sequential Organ Failure Assessment | Organ dysfunction severity | 6 organ systems | Delta >= 2 from baseline → Sepsis |

---

## How Scoring Engines Work (Architecture)

All four scoring engines follow the same pattern: they're Kafka consumers that react to observation events.

```
Observation recorded (HTTP POST)
        │
        ▼
   PostgreSQL + Outbox
        │
        ▼ (OutboxRelay)
   Kafka topic: observation.recorded
        │
        ├──► SepsisEngineService     → QsofaDetector    → qSOFA evaluation
        ├──► News2ScoringService     → News2Detector    → NEWS2 score
        ├──► GcsScoringService       → GcsDetector      → GCS score
        │                                                    │
        │                               Kafka: gcs.scored ◄──┘
        │                                    │
        ├──► SofaScoringService      → SofaDetector      → SOFA score
        ├──► TrendAnalyzerService    → TrendDetector     → Rate-of-change
        └──► WarningAlertService     → WarningEvaluator  → Warning alerts
```

Each engine is a separate Kafka consumer group, so they process the same observation event independently and in parallel. A single heart rate reading can simultaneously trigger NEWS2 recalculation, qSOFA re-evaluation, trend analysis, and warning threshold checking.

### The Two-Class Pattern: Calculator + Detector

Each scoring system is split into two classes:
- **Calculator** (static, pure logic): Contains the scoring rules — "heart rate 45 scores 3 points in NEWS2." No database, no Redis, no side effects. Easy to unit test.
- **Detector** (stateful, orchestration): Manages Redis state, calls the calculator, persists scores to PostgreSQL, creates alerts, publishes events. Contains all the infrastructure plumbing.

---

## NEWS2 — National Early Warning Score 2

NEWS2 combines 7 vital sign parameters into a single risk score (0–20+). Higher scores indicate greater deterioration risk.

### The 7 Parameters and Their Scoring

```csharp
public static class News2Calculator
{
    public static readonly IReadOnlyList<string> ParameterCodes = new[]
    {
        "RESP_RATE", "SPO2", "SYSTOLIC_BP", "HEART_RATE",
        "AVPU", "TEMP_C", "SUPPLEMENTAL_O2"
    };

    public static int ScoreRespRate(decimal value) => value switch
    {
        <= 8  => 3,   // Dangerously low
        <= 11 => 1,
        <= 20 => 0,   // Normal range
        <= 24 => 2,
        _     => 3    // Dangerously high
    };

    public static int ScoreHeartRate(decimal value) => value switch
    {
        <= 40  => 3,
        <= 50  => 1,
        <= 90  => 0,   // Normal range
        <= 110 => 1,
        <= 130 => 2,
        _      => 3
    };

    // ... similar for SpO2, SystolicBp, Temperature, Consciousness, SupplementalO2
}
```

Each parameter scores 0–3 points. The total score determines the risk level:

| Total Score | Risk Level | Alert |
|-------------|-----------|-------|
| 0–4 | LOW | No alert |
| 5–6 or any single param = 3 | MEDIUM / LOW_MEDIUM | `NEWS2_WARNING` (Warning) |
| >= 7 | HIGH | `NEWS2_EMERGENCY` (Critical) |

### How NEWS2 Aggregates Over Time

The 7 parameters rarely arrive simultaneously. A nurse records respiratory rate at 14:01, heart rate at 14:03, blood pressure at 14:05. The `News2Detector` uses Redis to accumulate parameters until all 7 are present:

```
14:01  RESP_RATE=18 → Redis: news2:{enc}:RESP_RATE = {value:18, score:0}  (1/7 present)
14:03  HEART_RATE=95 → Redis: news2:{enc}:HEART_RATE = {value:95, score:1} (2/7 present)
14:05  SYSTOLIC_BP=115 → Redis: news2:{enc}:SYSTOLIC_BP = ...              (3/7 present)
...    (more parameters arrive)
14:12  TEMP_C=37.5 → Redis: all 7 present → compute score = 5 (MEDIUM)
         → persist News2Score to PostgreSQL
         → create NEWS2_WARNING alert
```

Each Redis key has a **4-hour TTL**. If no new respiratory rate arrives within 4 hours, that parameter expires and the next NEWS2 calculation waits for a fresh reading.

### Consciousness Resolution: GCS-First, AVPU-Fallback

The consciousness parameter prefers GCS (Glasgow Coma Scale) over AVPU (a simpler alert/voice/pain/unresponsive scale). If GCS components are cached in Redis, they're used. Otherwise, the AVPU value is used:

```csharp
private async Task<int?> ResolveConsciousnessScoreAsync(Guid encounterId)
{
    // Try GCS first
    var gcsValues = await cache.StringGetAsync(GcsCalculator.AllComponentKeys(encounterId));
    if (gcsValues.All(v => v.HasValue))
    {
        var total = GcsCalculator.ComputeTotal(eye, verbal, motor)!.Value;
        return News2Calculator.ScoreConsciousnessFromGcs(total);
    }

    // Fallback to AVPU
    var avpuVal = await cache.StringGetAsync(
        News2Calculator.ParameterKey(encounterId, "AVPU"));
    return cached?.Score;
}
```

---

## GCS — Glasgow Coma Scale

GCS measures level of consciousness through 3 components:

| Component | Range | What It Assesses |
|-----------|-------|-----------------|
| Eye (E) | 1–4 | Eye opening response |
| Verbal (V) | 1–5 | Best verbal response |
| Motor (M) | 1–6 | Best motor response |
| **Total** | **3–15** | **Sum of all three** |

```csharp
public static string ClassifyGcs(int total) => total switch
{
    <= 8  => "SEVERE",     // Coma — Critical alert
    <= 12 => "MODERATE",   // Warning alert
    _     => "MILD"        // 13-15 — Normal or near-normal
};
```

### Component Assembly

Like NEWS2, GCS components may arrive separately. The `GcsDetector` caches each component in Redis and computes the total when all 3 are present:

```
14:00  GCS_EYE=3    → Redis: gcs:{enc}:GCS_EYE = 3     (1/3)
14:00  GCS_VERBAL=4 → Redis: gcs:{enc}:GCS_VERBAL = 4   (2/3)
14:01  GCS_MOTOR=5  → Redis: gcs:{enc}:GCS_MOTOR = 5    (3/3)
         → Total = 12 (MODERATE)
         → Persist GcsScore to PostgreSQL
         → Create GCS_WARNING alert
         → Publish gcs.scored to Kafka (for SOFA CNS rescoring)
         → Re-evaluate qSOFA altered mentation
```

### Downstream Effects

GCS has the most downstream effects of any scoring engine:

1. **GCS alert**: SEVERE (total <= 8) → Critical alert, MODERATE (9–12) → Warning alert
2. **Kafka `gcs.scored` event**: Triggers SOFA CNS organ re-scoring
3. **NEWS2 consciousness**: Replaces AVPU with GCS-derived score
4. **qSOFA altered mentation**: GCS < 15 counts as one of the 3 qSOFA criteria

---

## qSOFA — Quick SOFA (Sepsis Screen)

qSOFA is a bedside screening tool with 3 binary criteria:

| Criterion | Threshold | Meaning |
|-----------|-----------|---------|
| Respiratory rate | >= 22 /min | Breathing fast |
| Systolic blood pressure | <= 100 mmHg | Blood pressure low |
| Altered mentation | GCS < 15 or AVPU >= 1 | Not fully alert |

If **2 or more criteria** are met simultaneously, a `QSOFA_SCREEN` warning alert fires, recommending SOFA labs be ordered.

### Sliding Window with Redis TTL

Each criterion has a **30-minute TTL** in Redis. If a criterion is met, the key is set with a 30-minute expiry. If not met, the key is deleted immediately:

```csharp
if (QsofaCalculator.MeetsCriterion(observationCode, value))
    await cache.StringSetAsync(key, value.ToString(), TimeSpan.FromSeconds(1800));
else
    await cache.KeyDeleteAsync(key);
```

This means a patient's respiratory rate of 24 (meets criterion) at 14:00 expires automatically at 14:30 if no new high respiratory rate arrives. The qSOFA score naturally decreases as criteria expire.

### Alert Deduplication

qSOFA uses `INSERT ... WHERE NOT EXISTS` to prevent duplicate screen alerts:

```sql
INSERT INTO clinical_alerts (...)
SELECT ... WHERE NOT EXISTS (
    SELECT 1 FROM clinical_alerts
    WHERE encounter_id = @encounterId
      AND alert_type = 'QSOFA_SCREEN'
      AND status IN ('OPEN', 'ESCALATED')
)
```

If an open qSOFA screen already exists for this encounter, no duplicate is created.

---

## SOFA — Sequential Organ Failure Assessment

SOFA is the most complex scoring system. It assesses 6 organ systems, each scored 0–4:

| Organ System | Data Source | Score 0 | Score 4 |
|-------------|------------|---------|---------|
| Respiratory | PaO2/FiO2 ratio (or SpO2/FiO2 fallback) | >= 400 | < 100 with mechanical ventilation |
| Coagulation | Platelet count | >= 150 k/µL | < 20 k/µL |
| Liver | Bilirubin | < 1.2 mg/dL | >= 12 mg/dL |
| Cardiovascular | MAP and vasopressor dose | MAP >= 70, no vasopressors | High-dose epinephrine/norepinephrine |
| CNS | GCS total (via `gcs.scored` Kafka event) | GCS 15 | GCS < 6 |
| Renal | Creatinine and urine output | Creatinine < 1.2 | Creatinine >= 5.0 or urine < 200 mL/day |

Total SOFA score: 0–24 (sum of all organ scores).

### Baseline and Delta

SOFA doesn't alert on the absolute score — it alerts on the **change from baseline**:

1. **Baseline established**: When >= 4 organ systems have data, the first score becomes the baseline
2. **Delta calculated**: Every subsequent score computes `delta = current total - baseline total`
3. **Alerts**:
   - Delta >= 2 → `SOFA_SEPSIS` (Critical) — indicates acute organ dysfunction, triggers a sepsis bundle
   - Delta = 1 → `SOFA_WARNING` (Warning)

This is clinically important because a patient with chronic kidney disease might have a baseline SOFA of 4. A score of 4 is not alarming for them — but a sudden jump to 6 (delta = 2) indicates new organ dysfunction.

### Lab Staleness Tracking

Lab values (platelets, bilirubin, creatinine) arrive infrequently — sometimes only once per day. The `SofaLabCache` tracks how old each value is:

| Age | Status | Behavior |
|-----|--------|----------|
| < 12 hours | Current | Used as-is |
| 12–24 hours | Stale | Used but flagged in `staleness_flags` JSONB |
| > 24 hours | Expired | Organ score set to 0 (assume normal) |

### SpO2/FiO2 Fallback

Many ward patients don't have arterial blood gas (PaO2) measurements. When PaO2 is unavailable but SpO2 (pulse oximetry) is available, the SOFA detector uses a validated proxy ratio (Rice et al., 2007):

```csharp
if (pao2 is not null && fio2 is not null)
    respiratory = SofaCalculator.ScoreRespiratory(pao2, fio2, onMechanicalVent);
else if (_options.UseSpO2FiO2Fallback && spo2 is not null && fio2 is not null)
    respiratory = SofaCalculator.ScoreRespiratoryFromSpo2(spo2, fio2, onMechanicalVent);
```

---

## How the Scores Connect

The scoring engines are not independent — they feed into each other:

```
Observation arrives
     │
     ├── NEWS2: uses RESP_RATE, SPO2, SYSTOLIC_BP, HEART_RATE, TEMP_C,
     │         SUPPLEMENTAL_O2, and consciousness (from GCS or AVPU)
     │
     ├── qSOFA: uses RESP_RATE, SYSTOLIC_BP, and altered mentation (from GCS)
     │
     ├── GCS: uses GCS_EYE, GCS_VERBAL, GCS_MOTOR
     │    │
     │    └── publishes gcs.scored ──► SOFA (CNS organ)
     │                             ──► NEWS2 (consciousness)
     │                             ──► qSOFA (altered mentation)
     │
     ├── SOFA: uses labs + vitals + GCS + medications
     │    │
     │    └── SOFA_SEPSIS (delta >= 2) ──► Sepsis Bundle (Guide 23)
     │
     └── Trend: uses HEART_RATE, RESP_RATE, SYSTOLIC_BP, TEMP_C, SPO2
```

---

## Common Patterns Across All Engines

### Alert Suppression

Warning-level alerts check for suppression before firing:

```csharp
if (alertType == AlertType.News2Warning)
{
    var suppression = _services.GetRequiredService<IAlertSuppressionService>();
    if (await suppression.IsSuppressedAsync(encounterId, alertType, ct))
        return false;
}
```

When a clinician acknowledges a WARNING alert, a Redis suppression key is set for 30 minutes. During that window, the same alert type won't fire again for that encounter. CRITICAL alerts are never suppressed.

### Duplicate Prevention

Every alert creation uses `INSERT ... WHERE NOT EXISTS` to prevent duplicates. If an open alert of the same type already exists for the encounter, no new alert is created.

### Prometheus Metrics

Every engine increments counters and records timing:

```csharp
using var timer = _metrics.SofaScoringDuration.NewTimer();
// ... compute score ...
_metrics.SofaScoresTotal.WithLabels(alertCreated ? "true" : "false").Inc();
```

---

## Key Takeaways

- **Scoring engines are Kafka consumers** — each runs independently, processing the same observation events in parallel
- **Redis aggregates parameters over time** — observations arrive individually; Redis holds partial state until all parameters are present
- **Calculator + Detector separation** — pure scoring logic is testable without infrastructure; orchestration logic handles Redis, PostgreSQL, and Kafka
- **TTL-based expiry prevents stale scores** — parameters automatically expire (30 minutes for qSOFA, 4 hours for NEWS2, 24 hours for SOFA labs)
- **Scoring engines cascade** — GCS feeds into NEWS2, qSOFA, and SOFA. SOFA delta >= 2 triggers sepsis bundles. One observation can ripple through multiple scoring pipelines.
- **Alert suppression prevents alarm fatigue** — warning alerts are silenced for 30 minutes after acknowledgment; critical alerts always fire
