AI proposes. Physics simulator validates. Safety gate decides.
A simulator-gated fault-diagnosis and action-selection system for chemical process control, built on a first-principles exothermic CSTR with a cooling jacket. The project combines classical monitoring, physics residuals, fault diagnosis, an LLM proposer, counterfactual simulator rollouts, and an independent true-state interlock.
Status: complete — final sealed evaluation finished. The frozen system was
evaluated once on untouched seeds 100–109: 110 plant runs, 101 first-alarm
agent scenarios, 149 cached LLM responses, and zero response failures. No
post-reveal tuning or reruns are permitted. Canonical evidence is under
results/final/; the release summary is in docs/FINAL_RESULTS.md.
The final result is deliberately honest: the simulator gate blocked 31 unsafe first LLM proposals, but the LLM proposer did not outperform the simple baselines overall. It tied always-continue on safety (0.6436), while always-max-cool achieved the best safety (0.6832) and the fixed rule table the lowest mean regret (224.26). The main engineering result is therefore not “the LLM won”; it is that simulator gating made proposed actions auditable and prevented many unsafe executions, while still exposing model/hypothesis misses instead of hiding them.
python -m venv .venv
# Windows: .\.venv\Scripts\Activate.ps1
pip install -r requirements-lock.txt
pip install -e .
pytest -q # 105 tests in the frozen release
# On a source-only clone, data-dependent integration tests skip until artifacts are generated.
python scripts/day1_verification.py # steady state, eigenvalues, S-curve (the gate)
python scripts/make_figures.py # week-1 headline runs + figure
python scripts/calibrate_confounders.py # benign-disturbance survivability sweep
python scripts/make_week2_figures.py # sensor-drift headline + fault fingerprints
python scripts/generate_dataset.py --split train # 110-run labelled dataset
python scripts/run_detection.py # detection benchmark (Part A+B+C)
python scripts/run_diagnosis.py # diagnosis: support table + ablation
python scripts/make_week4_figure.py # diagnosis ablation + per-severity recall
python scripts/develop_llm_prompt.py # LLM: build/freeze bundle + plan (API only with --confirm)
python scripts/run_llm_diagnosis.py # Week 5: single validation pass (already frozen)
python scripts/build_agent_scenarios.py # Week 6: first-alarm scenarios (offline)
python scripts/run_agent_offline.py # Week 6: dry plan; add --confirm for baselines/oracle
python scripts/develop_agent_prompt.py # Week 6: dry plan; add --confirm for LLM dev
python scripts/run_agent_eval.py # Week 6 validation harness (already completed)
python scripts/make_final_figures.py # rebuild release figures from frozen JSON only
python scripts/run_scenario.py configs/scenarios/faults/f1_drift_severe.yaml
# Final sealed evaluation is already complete. Do not rerun it for a new score.Every run writes trajectory.parquet, a provenance manifest.json (config
SHA-256s, seed, git SHA + dirty flag, SHA-256 of the parquet file itself,
and numpy/scipy/pandas/PyYAML/pyarrow versions), and an
envelope_report.json (including the dense solver-resolution peak
temperature) to out/runs/<run_id>/. Same config + same seed →
bit-identical trajectory, enforced by test.
Three-state exothermic CSTR (A → B) with a dynamic cooling jacket: states (C_A, T, T_j), manipulated variable q_c (coolant flow), canonical literature reactor parameters plus a constructed-and-verified jacket. The design point (0.5 mol/L, 350 K, 300 K at q_c = 59.81 L/min) is an open-loop-unstable saddle (leading eigenvalue +3.10 /min, doubling time 13 s) with three coexisting steady states — the controller is load-bearing and cooling loss is genuinely dangerous. Sensors are noisy and sampled (composition analyser deliberately slow at 1 min); the safety interlock is a separate layer acting on true state with a latching max-cooling + feed-cut action.
Single-loop PI from T to q_c cannot stabilise this plant — every tuning ends
in a growing reactor–jacket limit cycle; a cascade (the real-world
architecture for jacketed reactors) is required. The loop has essentially no
phase budget: a 0.2-min measurement filter or any valve slew limit
destabilises it. Setpoint authority is asymmetric (+2 K achievable, +5 K
physically not). The dynamic coolant-supply-temperature margin (~+2–3 K) is
about a third of the static one (+7 K). And the trip system's real guarantee
is termination to cold shutdown from every studied state — peak temperature
is set by the state at trip, not by cooling capacity. Numbers, methods, and
the two anti-windup bugs found along the way: docs/DESIGN.md. Every
departure from the original spec — including the formal proof that the
specified single-loop PI cannot work — is registered with evidence in
docs/DEVIATIONS.md.
Five fault classes, each entering the loop through one of three hooks (sensor
transform_measurement, plant on_step, actuator transform_actuation),
with the interlock called on true state in between so it can never be blinded
by a faulty sensor. Ground truth is recorded at injection — a manifest fault
block plus per-step fault_active/fault_value columns — never inferred from
the trajectory.
Confounder calibration was the week's sharpest correction. The roadmap's
±10 % feed-composition confounder trips the plant 10/10 (the week-1
dynamic-margin finding predicted it); even +5 % trips 1/10, and the +5 K
feed-temperature confounder trips 2/10 — contradicting a week-1 single-seed
pass that happened to survive. Both positive bounds were cut to the measured
trip-free limit (+4 % and +4 K). Other measured findings: F2 fouling exhausts
control authority at f ≈ 0.88–0.91 (dynamic, slightly earlier than the static
prediction), and its rate trip out-runs the saturation alarm meant to warn of
it; F3 stiction is invisible in temperature until a cliff at J≈40 locks the
valve, after which the outcome is a stochastic quench-vs-runaway split (3/10
seeds trip). Full numbers, the fault matrix, and the calibration table:
docs/DESIGN.md §§8–12. Every bound reduction and interface change is
registered in docs/DEVIATIONS.md (D5–D7).
The train dataset is 110 runs (11 conditions × 10 seeds): on seeds 0–9, every mild fault was a clean 0/10 negative, the runaway severe faults were 10/10, and F3 severe was a deliberate 3/10. The sealed eval later showed that realised trip outcomes can shift across unseen seeds. The train/eval seed split (0–9 / 100–109) is fixed before any detector exists — no detector, in any week, is tuned on eval seeds.
The full architecture was frozen before seeds 100–109 were generated. The
reveal produced 110 runs, 39 trips, 101 first-alarm scenarios, and
9 no-alarm runs. All learned components were fit only from the train root;
the LLM action bundle remained fixed at
04568a87e823f2d0d267fae4fb126dbfd775c9dd7d68e575b575e92880ebab85.
| proposer | safety rate ↑ | mean regret ↓ | trips averted |
|---|---|---|---|
| B1 always max-cool | 0.6832 | 241.02 | 11 |
| B2 diagnosis→action table | 0.6634 | 224.26 | 9 |
| LLM first proposal | 0.6436 | 243.67 | 7 |
| B0 always continue | 0.6436 | 251.86 | 7 |
| LLM after one retry | 0.6337 | 244.40 | 6 |
The strong 31-scenario validation result did not generalise: LLM-first safety fell from 0.7742 to 0.6436 and no longer beat the baselines. The retry again made the policy worse. This is recorded as a failed prediction, not reframed. The gate nevertheless made 31 genuine saves against LLM-first proposals, with 7 gate misses and 37 over-blocks. F4 severe remained unmitigable for every proposer, confirming that catastrophic fast coolant loss belongs to the independent interlock rather than the action agent.
Supporting final perception results remained coherent: the frozen RF
diagnoser scored macro-F1 0.6832 on 1,025 eligible eval windows; F1/F2/F5
were strong, mild F3/F4 remained weak, and F4 severe was still not scorable.
The final actuator residual again caught F4 severe 10/10 fault-only at a
median 0.3 min while MSPC caught 0/10. Full tables, hashes, and interpretation:
docs/FINAL_RESULTS.md and docs/DESIGN.md §§28–32.
Before the sealed reveal, the frozen action layer was validated once on 31
first-alarm scenarios from seeds 7–9. LLM-first achieved safety 0.7742,
mean regret 98.71, and 5 trips averted; it tied always-max-cool on safety
while using less costly actions. That result justified the final sealed test,
but it is not the final headline because it did not survive the larger
101-scenario evaluation. The validation cache and audit remain frozen under
results/week6/ as evidence of the development-to-generalisation gap.
The LLM (claude-sonnet-5, forced structured outputs, a frozen six-example
prompt bundle developed only on seeds 0–6) was dropped into the frozen Week-4
harness and validated exactly once on seeds 7–9: 640 calls, zero failures.
Two findings that cut in opposite directions. (1) The LLM lands clearly
below the classical baseline on pooled macro-F1 — 0.595 vs the random forest's
0.706 — with the gap concentrated in the mild actuator faults, which the
cautious prompt folds into nominal (F3 mild recall 0.0). (2) Physics
residuals help the LLM far more than they helped the classical models: +0.121
macro-F1 (2.2× the RF's gain, 7× the logistic regression's), rescuing the
dangerous F1-severe case from 0.091 to 0.545, and the LLM is meaningfully more
confounder-robust than the classical models (nominal recall 0.847 vs 0.72).
The frozen bundle, the full 640-response cache, and the validation results are
committed in results/week5/; offline re-scoring from the cache reproduces
every number and the results hash (asserted in tests). Full analysis:
docs/DESIGN.md §§21–23; refuted predictions and process notes in
docs/DEVIATIONS.md (D15–D16).
Detection answers "is something wrong"; diagnosis answers "which fault". A window-level classifier ({nominal, F1–F5}, mild+severe pooled, 5-min windows) is trained classically — logistic regression and random forest — on train seeds 0–6 and validated on 7–9, eval sealed. The support table is emitted and verified first: F4 severe has no complete pre-trip window (it trips too fast) and is marked NOT SCORABLE rather than claimed as diagnosed.
The primary ablation is raw vs raw+residual (same raw block in both;
residuals added only in the second). Physics residuals add modest value on top
of raw (+0.017 logreg, +0.054 random forest macro-F1). Raw+residual performs
best for both models; residual-only is below raw for logistic regression but
slightly above raw for random forest, so residuals are complementary overall but
their standalone value depends on the classifier. Recall is
reported by severity, which cuts both ways: F1 mild 0.94 but F1 severe only
0.55 (the dangerous case is the weaker one — D13); F3 severe 0.76 but F3 mild
0.31. F5 is trivially diagnosed (recall 1.0) via the frozen sensor's zero
variance — the honest inverse of Week 3, where F5 was the hard detection case
(D14). Nominal recall is 0.72, so ~28 % of benign confounder windows are
misdiagnosed as faults, mostly the mild actuator faults. Full numbers, the
support and ablation tables, and confirmed/refuted predictions: docs/DESIGN.md
§§17–20; refutations in docs/DEVIATIONS.md (D13–D14).
The detection layer, classical baseline first. A static PCA / T² / SPE monitor is fit on nominal data; physics residuals (one-step model prediction + a command-vs-flow actuator residual) are built alongside. Both are calibrated under a locked split (fit 0–6, calibrate 7–8, report false-alarm rate on seed 9), scored pre-trip, eval sealed.
Three honest findings. (1) The classical baseline can't hold its 1 %
false-alarm budget on held-out nominal — it comes in at 4.0 %, driven by T²,
because benign confounders shift the operating point along normal correlation
directions and look like faults (a documented DoD deviation, D12). Physics
residuals are ~3× cleaner (1.25 % union; process/actuator individually pass).
(2) Raw per-run detection rate is false-alarm-contaminated, so results are
reported on a paired, confounder-controlled metric — fault run alarms while
its matched same-seed nominal (identical confounders + noise) stays silent. The
benchmark proves the contamination intrinsically: the actuator residual
"detects" the analyser-freeze fault 10/10 while being mechanically blind to it
(bit-identical to nominal on the actuator channel). (3) The clean,
confounder-controlled win is the fast fault: on severe coolant loss the plant
trips before the classical monitor's persistence gate can fire (MSPC 0/10),
while the actuator residual catches it 10/10 fault-only at ~0.4 min. No
single family dominates (F1 severe → T², F2 → SPE + energy residual, F3 severe →
SPE, F4 severe → actuator residual, F5 → essentially nobody) — that measured
complementarity is the empirical case for the eventual LLM-with-residuals
ablation, which must use the paired metric. Numbers, the full table, and the
confirmed/refuted predictions: docs/DESIGN.md §§13–16; deviations and the
metric correction in docs/DEVIATIONS.md (D9–D12).
configs/ plant, envelope, scenarios (all parameters live here)
safeplant/models/ CSTR ODEs, Jacobian, steady-state tools
safeplant/sim/ sensors (seeded noise), closed-loop integrator
safeplant/control/ PI + cascade with anti-windup, ramping, filtering
safeplant/safety/ envelope checker (pure), independent latching interlock
safeplant/run/ scenario runner: parquet log + provenance manifest
safeplant/faults/ three-hook fault interface + F1–F5 library + registry
safeplant/detect/ MSPC baseline (PCA/T²/SPE) + physics residuals + benchmark
safeplant/diagnose/ honest windowing + feature blocks + classical classifiers
safeplant/llm_diagnose/ renderer, schema, frozen bundle, cache, client, scorer
results/week5/ FROZEN: diagnosis bundle, 640-response cache, validation results
results/week6/ FROZEN: action bundle, caches, audits, scenarios, validation results
results/final/ FROZEN: sealed eval dataset summaries, scenarios, baselines, LLM cache/audits
scripts/ verification, dataset/evaluation harnesses, and reproducible figures
configs/scenarios/faults/ the 11-condition fault matrix (+ nominal)
configs/confounders.yaml calibrated benign-disturbance bounds (locked)
tests/ 105 tests, including frozen-artifact and final-eval integrity checks
docs/ specs, FINAL_RESULTS.md, DESIGN.md, DEVIATIONS.md, figures
- Simulation only. No real plant connection or actuation exists; the LLM cannot import or call the executor directly.
- Temperature is the safety proxy. Pressure, relief, vessel integrity, quench systems, and human operating procedures are not modelled.
- The gate is not a formal safety proof. On the final split it saved 31 unsafe LLM-first proposals but missed 7 because the hypothesis set and plant model can be wrong.
- Fast catastrophic utility loss remains outside agent authority. F4 severe was unsafe for every proposer; the independent interlock is the backstop.
- The action menu is deliberately small. It tests bounded action selection, not free-form operating procedure generation or multi-step planning.
- API responses are nondeterministic. The committed caches are the canonical evidence; rerunning for a preferable result would violate the evaluation protocol.
- This is an engineering research prototype, not a certified safety system.
The release manifest at results/final/release_manifest.json hashes every
canonical final artifact. Key anchors:
- final dataset data hash:
9fdc048da32dc4f0b9941c81468be8b3188f263d677eade17f1a363ce4664682 - frozen action bundle:
04568a87e823f2d0d267fae4fb126dbfd775c9dd7d68e575b575e92880ebab85 - final LLM results hash:
5709d2d358a24eebf8fc116b366851528581136d2cb8fcc97b07c9f622f5f955
cache_llm.jsonl and llm_audit.jsonl preserve the exact final API outputs
and executed outcomes. scripts/make_final_figures.py reads only frozen JSON
artifacts; it performs no fitting, simulation, or API calls.



