# 2026-04-27 — Scram `X_exit` redefinition: shutdown-margin halfspace

## What changed

Replaced the scram-mode `X_exit` predicate from
`n <= 1e-4 AND T_f <= T_f0 + 50 C` to a single linear halfspace
`shutdown_margin`:

    alpha_f * T_f + alpha_c * T_c <= -rho_SDM - U_SCRAM + alpha_f*T_f0 + alpha_c*T_c0
                                  ≈ 0.002972

with `rho_SDM = 0.01` (1% Δk/k tech-spec floor).

Files touched:
- `reachability/predicates.json` — added `shutdown_margin` under
  `safety_limits`; updated `mode_definitions.q_scram.X_exit_predicate`
  and `X_safe_predicate`; left a `_X_exit_history` field for forensics.
- `code/scripts/reach/reach_scram_pj.jl` — added `RHO_SDM`, `SDM_RHS`
  constants; reach loop now reports ρ-bounds and the halfspace LHS sup
  per probe horizon; `.mat` output gets `sdm_lhs_hi`, `rho_max`,
  `sdm_ok` per horizon plus a global `sdm_rhs`, `rho_sdm`.

## Why

1. **Power threshold was nonlinear in PJ state.** In the prompt-jump
   reduction, `n = Λ * sum(λ_i*C_i) / (β - ρ)` — ρ depends on `T_f`,
   `T_c`. So `n ≤ 1e-4` is *not* a halfspace in the reach state. We
   were reconstructing it post-hoc on every probe, which works for a
   diagnostic check but doesn't compose with halfspace-based discharge.
2. **`T_f ≤ T_f0 + 50` was infeasible-by-construction at 60 s.** Decay
   heat (`Q_sg = 3% P0`) plus a fuel time constant `M_f*C_f / hA ≈ 0.3 s`
   means `T_f` rapidly equilibrates with `T_c`, but the system loses
   only ~5 °C in 60 s under constant decay — nowhere near the threshold,
   and never going back up either. The bound was dressing, not work.
3. **Shutdown margin is the actual NRC criterion.** Tech specs phrase
   scram success in Δk/k, not in flux. And ρ is *linear* in
   `(T_f, T_c)` post-scram (constant `u = U_SCRAM`), so it's a clean
   single-row halfspace.

## Result

`reach_scram_pj.jl` discharges `shutdown_margin` at all three probe
horizons (10, 30, 60 s), with massive margin:

| T (s) | reach-sets | wall (s) | ρ at horizon              | discharged |
|-------|------------|----------|---------------------------|------------|
| 10    | 6919       | 98.6     | [-0.0507, -0.0504]        | ✓          |
| 30    | 9900       | 130.5    | [-0.0506, -0.0503]        | ✓          |
| 60    | 12340      | 164.2    | [-0.0503, -0.0500]        | ✓          |

Required: `ρ ≤ -0.01`. Actual: `|ρ| ≈ 5%`. The Doppler/moderator
contributions vary by ~3% of `U_SCRAM`, so the margin is dominated by
rod worth — exactly what physical intuition predicts.

`.mat` output: `results/reach_scram_pj_result.mat`.

## Subtlety I tripped on

Hand-derived `SDM_RHS = 0.00402` using rounded `T_F0=320`, `T_C0=300`.
Actual values from `pke_params`: `T_C0 = 308.35`, `T_F0 = 328.35`
(because `DT_CORE = P0/(W_M*C_C) = 36.7 °C`, not 20). The script
computes `SDM_RHS` from constants so the run was correct (0.002972),
but the predicate JSON had the stale 0.00402. Fixed by switching the
`rhs_expr` to a symbolic form. **Rule for next time:** if a constant
in `predicates.json` is derivable from `pke_params`, write it as a
symbolic expression, not a baked number — that's what `T_cold0`,
`T_c0`, `T_standby` are for.

## What's still open

- `X_safe_predicate` still says "fuel_centerline AND cold_leg_subcooled"
  but the reach script doesn't actually discharge those over the
  trajectory — only at the probe horizons. Not a problem for the demo
  (T_f and T_cold are monotone after scram), but the formal obligation
  is reach-AVOID, not just reach. Worth a follow-up: discharge the
  invariant halfspaces over the *full flowpipe*, not the endpoint.
- `prompt_critical_margin_heatup` is the controller-specific PJ-validity
  predicate. Scram has its own analogous concern (PJ valid only when
  `β - ρ > 0` with margin). Trivially satisfied here (ρ ≈ -0.05, far
  from `β = 0.0065`), but worth a parallel `prompt_critical_margin_scram`
  predicate for completeness — would document the assumption rather than
  leave it implicit.

## Graduation candidates

- The "rhs_expr should be symbolic when derivable" rule probably belongs
  in `code/CLAUDE.md` near the predicate-loading section. Hold for now —
  one occurrence isn't a pattern yet.
