Skip to content

fix(stage4): fail loudly on out-of-range and non-finite immunogenicity_score (LRF-1) - #292

Merged
Gavin-Borges merged 3 commits into
mainfrom
fix/lrf1-immunogenicity-score-range-guard
Aug 26, 2026
Merged

fix(stage4): fail loudly on out-of-range and non-finite immunogenicity_score (LRF-1)#292
Gavin-Borges merged 3 commits into
mainfrom
fix/lrf1-immunogenicity-score-range-guard

Conversation

@Gavin-Borges

@Gavin-Borges Gavin-Borges commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes LRF-1. Gavin ruled hard-fail on the failure mode; this implements it, and then a three-lens adversarial review of this branch found three further defects, two of them in the work this branch had already published. All are fixed here.

The defect

immunogenicity_score is documented and consumed downstream as a [0, 1] probability - calibration, thresholding, the ranked CSV. Nothing enforced it.

models/xgb_50feature_integrated.joblib is an XGBClassifier with objective='rank:pairwise', whose predict_proba returns raw margins, not probabilities. Verified directly: it loads with n_features_in_=50 and returns column-1 values from -5.70 to 2.54. In results/local_test_sweep_2026-08-21/merged_leaderboard.csv a mode-50 sweep records mean_score between -4.38 and -4.20 across 40 rows (5 seeds x 8 panels), against rf+mode50's normal 0.686 to 0.702.

That run was still recorded as a success. The mechanism is checkable in tracked code rather than only in that ledger: scripts/batch_experiment_runner.py sets status = "SUCCESS" on the existence of a ranked CSV alone, with no check on the values inside it.

The fix

A range guard at the single point every scoring branch in functions/stage4_immunogenicity_scoring.py converges - after the joblib, PyTorch, prototype and degenerate-constant branches, and before calibration, thresholding and the ranked CSV write. Out-of-range and non-finite values raise RuntimeError naming the model path and the observed range.

No caller-side change was needed: batch_experiment_runner.py's except Exception is broad and pipeline.py's run_pipeline carries no try of its own, so the error propagates and the false SUCCESS becomes an honest FAILED: RuntimeError with the message in notes.

What the adversarial review changed

1. The guard passed NaN silently. (x < 0) | (x > 1) is False for NaN, so a NaN score flowed straight through to ranking - as undetectable downstream as the negative margin the guard exists to stop. Verified reachable rather than assumed: _load_pytorch_model divides by scaler_scale + 1e-10 with no finiteness check, and sigmoid(NaN) is NaN. The predicate is now ~np.isfinite(...) | (x < 0.0) | (x > 1.0), and the message reports the non-finite count separately from the observed finite range so min()/max() are never formatted over a NaN.

2. A false attribution in the CHANGELOG entry, retracted. It said api/main.py's guard was one "the register that raised this item described as ready-made". The register's LRF-1 row says no such thing - it prescribes only "a range assertion at the write boundary, not a retrain", and never mentions api/main.py. That phrase occurs once in the entire tree, in a session planning document that was itself correcting it. This is instance #5's exact shape from .claude/rules/third-party-claims.md: inherited from a planning doc and published without re-verification. The clause is removed rather than reattributed.

3. A compression hazard, corrected. The entry carried a bare -4.38 where the register measured -4.38 to -4.20 across 40 rows - rule 5 of that same file. It now states the range, cites the ledger, and replaces the unsourced status=SUCCESS assertion with the tracked-code mechanism above.

Tests

The original single test pinned neither half of the range check: its fixture is mixed (15 margins below 0, one above 1), so either clause alone still fired and both mutants survived. Caught by a mutation matrix, not by inspection.

  • Parametrized onto each side separately (shift=-5.0 fully below 0, shift=+100.0 fully above 1), each with an in-test assertion that the fixture has not drifted off its side.
  • New test_joblib_branch_raises_on_nan_score.
  • New test_joblib_branch_accepts_exact_bounds - exact 0.0 and 1.0 are valid probabilities, pinning the strict </> against a <=/>= mutant.
  • The raises test now also asserts no ranked CSV was written, pinning the guard's stated design point that it fires before any output.

Each of the three clauses is now individually pinned: deleting any one fails its test.

_FakeRankingModel was also made faithful - it now returns rows summing to 1.0 as the real ranker does, which is precisely what makes the bad output look probability-shaped on casual inspection.

Verification

  • tests/test_stage4_scoring.py: 41 passed.
  • Adjacent stage-4 / pipeline / API / CLI suites: 52 passed.
  • Full local pre-push gate: all fast tests passed.
  • ruff clean, mypy clean.
  • Integrity harness unchanged at 150 PASS / 0 WARN / 2 FAIL / 7 SKIP (the two FAILs are the standing C1/C2 rulings).

Behaviour change, stated plainly

This turns a silent wrong value into a loud crash on a scoring path. That is the intended ruling. The mode-50 path that today reports status=SUCCESS while writing mean_score = -4.38 will now fail that trial explicitly. No certified or public number is affected - feature_mode=50 is documented "Experimental".

immunogenicity_score had no range check between a model's predict_proba
output and everything downstream that treats it as a [0, 1] probability -
calibration, thresholding, the ranked CSV, and (once promoted) api/main.py's
response model. A model loaded with a ranking objective returns raw margins
outside [0, 1] instead of a probability, and nothing caught it:
models/xgb_50feature_integrated.joblib is an XGBClassifier with
objective='rank:pairwise', and a mode-50 sweep was observed writing
mean_score=-4.38 while scripts/batch_experiment_runner.py recorded the trial
as status=SUCCESS.

Fails loudly instead, raising RuntimeError naming the model path and the
observed range, at the single point every scoring branch converges (before
calibration, before the ranked CSV is written). Ruling: hard fail, not
warn-and-clip - converts today's silent wrong value into an immediate,
unambiguous failure rather than a value nothing downstream can tell apart
from a genuine low-confidence score.

scripts/batch_experiment_runner.py already catches and records
status=FAILED: RuntimeError with the message in notes for any pipeline
exception, so this needed no caller-side change to turn the false SUCCESS
into an honest FAILED - verified by reading that except block directly
rather than assumed.

Not a copy-paste of api/main.py's guard, which the register that raised this
item described as ready-made: that is a Pydantic Field(ge=0.0, le=1.0) on an
API response model, not on this array write, so the bound transfers but the
mechanism does not.

The PyTorch path is unaffected by construction (_load_pytorch_model applies
a sigmoid, which cannot exceed [0, 1]) - confirmed by reading
_load_pytorch_model directly. New regression test uses a _FakeRankingModel
mirroring the ranking objective's raw-margin output and confirms the joblib
path now raises; the existing parametrized test_joblib_branch_scores (4
feature-layout cases, all in-range) continues to pass unmodified, confirming
no regression on the legitimate path.

Signed-off-by: Gavin Borges <gavinmborges1104@gmail.com>
Resolves the CHANGELOG.md conflict in the [Unreleased] 'Fixed' section, where
both sides appended entries: LRF-1's stage-4 range guard on this branch, and the
D7 provenance-digest, B3 Zenodo-checksum, branch-protection, B1 leave-one-out and
fuzzing.yml entries that landed on main via PR #288 and #291. Both blocks are
kept; nothing is dropped.

Verified after resolution: the branch differs from main by exactly the three
LRF-1 files (CHANGELOG.md, functions/stage4_immunogenicity_scoring.py,
tests/test_stage4_scoring.py), +75 lines and no deletions, so no content that
arrived on main was lost in the merge. No conflict markers and no banned
non-ASCII characters remain in CHANGELOG.md.

Signed-off-by: Gavin Borges <gavinmborges1104@gmail.com>
…a false attribution

Three defects found by an adversarial review of this branch, two of them in the
work this branch had already published.

1. The guard passed NaN. `(x < 0) | (x > 1)` is False for NaN, so a NaN score
   flowed straight through calibration, thresholding and ranking - as undetectable
   downstream as the negative margin the guard exists to stop. Verified reachable
   rather than assumed: `_load_pytorch_model` divides by `scaler_scale + 1e-10`
   with no finiteness check, and sigmoid(NaN) is NaN. The predicate is now
   `~np.isfinite(...) | (x < 0.0) | (x > 1.0)`, and the message reports the
   non-finite count separately from the observed finite range, so `min()`/`max()`
   are never formatted over a NaN.

2. The CHANGELOG entry misattributed a characterisation to the register that
   raised this item. It said `api/main.py`'s guard was one 'the register ...
   described as ready-made'. The register's LRF-1 row says no such thing - it
   prescribes only 'a range assertion at the write boundary, not a retrain', and
   never mentions `api/main.py`. The phrase occurs once in the whole tree, in a
   session planning document that was itself correcting it. This is instance #5's
   exact shape from `.claude/rules/third-party-claims.md`: inherited from a
   planning doc and published without re-verification. The clause is removed
   rather than reattributed.

3. The same entry compressed the register's measured range '-4.38 to -4.20 across
   40 rows' to a bare '-4.38', which rule 5 of that same file names as the
   compression hazard. It now states the range, cites the ledger it came from, and
   replaces the unsourced 'status=SUCCESS' assertion with the mechanism that is
   checkable in tracked code: the runner sets SUCCESS on the ranked CSV existing,
   with no check on the values inside it.

Tests: the single mixed fixture pinned neither half of the range check - it held
15 margins below 0 and one above 1, so either clause alone still fired and both
mutants survived. It is now parametrized onto each side separately (shift -5.0 and
+100.0), with an in-test assertion that the fixture has not drifted off its side,
plus new cases for NaN and for exact 0.0/1.0 being accepted. Each of the three
clauses is now individually pinned. 41 passed in test_stage4_scoring.py, 52 in the
adjacent stage-4/pipeline/API/CLI suites; ruff and mypy clean.

Signed-off-by: Gavin Borges <gavinmborges1104@gmail.com>
@Gavin-Borges Gavin-Borges changed the title fix(stage4): raise on out-of-range immunogenicity_score (LRF-1) fix(stage4): fail loudly on out-of-range and non-finite immunogenicity_score (LRF-1) Aug 26, 2026
@Gavin-Borges
Gavin-Borges merged commit f0237c9 into main Aug 26, 2026
20 checks passed
@Gavin-Borges
Gavin-Borges deleted the fix/lrf1-immunogenicity-score-range-guard branch August 26, 2026 04:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant