Skip to content

parity: each model through every path that should give the same answer, and a tolerance sweep in fd_sens (#1257) - #1291

Merged
wshlavacek merged 10 commits into
lanl:mainfrom
Montana:feat/1257-differential-harness
Oct 27, 2026
Merged

wshlavacek merged 10 commits into
lanl:mainfrom
Montana:feat/1257-differential-harness

Conversation

@Montana

@Montana Montana commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1257, scoped as the maintainer review asked (#1257 (comment)).

What it does

A recurring bngsim bug is two code paths that should give the same answer and don't (#1182, #1227, #1228, #1235, #1254). This runs those probes for every model, every time:

check path reference
sens_vs_plain the state of a sensitivity run a plain run
ajac_vs_fdjac jacobian="fd" jacobian="auto"
klu_vs_dense force_sparse_linear_solver=True the dense solver, on the same Jacobian
cc_vs_interp codegen=True codegen=False
cc_vs_mir BNGSIM_CODEGEN_JIT=mir cc (on a MIR build)
locale the run under de_DE.UTF-8, in a child process under C

It also applies three rules for bugs whose answers are right, which no comparison of answers sees:

Where it lives, per the review

How the oracle stays careful

Every false positive seen so far came from the checking, not from bngsim. A disagreement counts only past max(1e3·rtol, 10 × the base's own drift at a tenfold tighter tolerance). Each cell is judged on the trajectory's own scale, past an absolute floor of the run's atol.

Running over the larger corpus first, as asked, changed four things:

  1. The sweep judges only columns above the solver's resolution. It first flagged mwc (its phi column) and the narrow pulses' position and width. Those columns are truly 0 and hold only the integration's error.
  2. A base not converged to 0.1% at a tighter tolerance is no reference, and its comparisons are skipped, saying why. MODEL2310250001 drives a species through a pole: its plain run moves 2.6% at a tenfold tighter tolerance, and libRoadRunner gives −1.4 to −4.6 at three tolerances.
  3. KLU is compared with dense on the same Jacobian. Against dense with the analytic Jacobian, an FD-Jacobian weakness read as a KLU one.
  4. A model that doesn't load, or whose plain run doesn't complete, has no reference. That belongs to rr_ode and sbml_semantic, so it's SKIP here; a PASS turning into SKIP still alerts.

The pytest file holds the gate to account both ways. Lorenz over t = 25 has an FD-Jacobian path 3.4e-4 from the base, past the 1e-5 that 1e3·rtol allows, and both are right. With the gate it isn't a finding; without it, it is.

Tests

  • Shapes: 134 tests in python/tests/test_differential_shapes.py:
    • every shape through every check;
    • the sensitivity RHS taken;
    • negative controls: a path off by more than its bound, a sparsity pattern missing a slot (on a fake model), a lying backend, a refusal, a trajectory smaller than atol, a shape or non-finite mismatch, and the conditioning gate.
  • Runner: 11 tests in parity_checks/tests/test_differential_run.py: rows and outcomes, a model with no rows, the manifests, the BioModels size cap, the locale stop, an unknown check.
  • Sweep: 7 tests in parity_checks/tests/test_fd_sens.py: a moving column, an unsettled trajectory, the rounding floor, judged columns only, a converged column, and how the sweep's verdict applies.

Mutations. The library: 9 of 9 caught.

mutation caught by
compare always passes the controls
no conditioning gate the Lorenz test
no atol floor the small-trajectory test
shape mismatch not caught the mismatch test
KLU never forced the #1254 strict xfail, which then passes
sparsity ignores the pattern the fake-model test
backend never checked the backend test
sensitivity RHS always reported analytic the #1214 strict xfail
a refusal read as a crash the refusal tests

The sweep: 4 of 4 caught (no settled gate, judged columns ignored, no rounding floor, sweep never applied).

Negative control on #1227. Against the commit just before #1242, fd_sens with the sweep flags pulse_triangle_clamp_narrow (k_clock moves 107%) and pulse_ramp_clamp_narrow (1.15e-3). Without the sweep, all 16 pulse rows pass. On main, all 16 pass with it.

Suites. parity_checks/tests: 921 passed, 18 skipped. The CI-config tests, the skip audit and the shapes: 295 passed, 2 xfailed. ruff is clean on everything touched.

False positives on the larger corpus

3,437 models, 30,933 rows: every .net in benchmarks/models/net, the 214 SBML event models, the 1,823 semantic cases and the 1,323 BioModels. 23,361 PASS, 7,288 SKIP, 217 UNSUPPORTED, 36 TIMEOUT (4 models at the 300 s cap), 12 EXCEPTION, 19 DIFF.

One model is a false positive: MODEL7743528808, 2 rows. Its paths are 2.15e-3 apart against a bound of 2.1e-3, and the difference is gone at a tenfold tighter tolerance. I left the gate as it is rather than fit it to one model. The case would sit in the baseline and never alert.

What it found on main

finding models tracked as
comma-locale misreads 13 #1235, fixed by #1238 (merged)
jacobian="fd" alone refused: "force_sparse_linear_solver: … Drop the flag", a flag never set. The automatic routing picks KLU, and the pattern has no nonzeros to color. semantic 01466, 01530–01533, 01564 #1296, fix in #1292
KLU's colored FD Jacobian steps each column by at least 1.5e-8 in absolute terms, so it fails or reads 3% off where dense FD and KLU analytic both run 4 BioModels, and #1254's equality #1254, fix in #1295
the answer changes with the output grid: 8× the points gives 5.3e7 of the peak off the converged run, with no error (the dense FD run, which first read 2.3e17, now fails loudly) BIOMD0000000810 #1297
a sensitivity run's state departs about 1% from a converged plain run, with no events MODEL2001200002 #1298
codegen copies an ExprTk implicit product into C BIOMD0000001042 #1212

Since opening

Compatibility

  • Nothing in python/bngsim changes, so no changelog fragment is required (ci/changelog.py required).
  • ci/test_floors.env: the floors move with the added tests, as the file asks. Python is 16,137 on main, 16,271 here; floor 16,261. Parity is 921 on main, 939 here; floor 929.
  • Baselines: baselines/differential.json comes from this PR's own nightly run (ALLOW_MISSING_BASELINE is set on pull requests), and fd_sens.json is refreshed from it for the eight new models. Until then they are listed as new, not alerted.

Performance

  • Pytest: 134 shape tests, about 7 s.
  • Nightly: about 3,400 s of worker time on an M3 Max for the job's corpus. On a 4-core runner that should be roughly half an hour, inside the job's 180-minute limit.
  • fd_sens: two more sensitivity runs per job for the sweep.

Remaining risks

  • The gate's constants (1e3·rtol, 10× drift, 1e-3 unconverged) were measured on these 3,437 models. A corpus of another kind could show another false positive. If so, it would land in the baseline, not alert.
  • The refusal text tests. _sparse_declined and codegen's "declined this model" are recognized by their text: bngsim raises no type of its own for them. A reworded message would turn a SKIP or UNSUPPORTED into an EXCEPTION, which alerts.
  • MIR: cc_vs_mir runs only on a MIR build; the nightly's runner has none, so it reads SKIP there.
  • Platform: checked on macOS arm64. The first CI night on Linux is the first run there.

Maintainer review: the three strongest objections

  1. "A path comparison with a tolerance gate will either miss bugs or flag noise." Every constant was set from a measured false positive, and each check has a test that it fails on the defect it is there to see (9 of 9 library mutations caught). On 3,437 models it reported one false positive. It also found three bugs no one had filed and two that were already open.
  2. "This duplicates fd_sens." It doesn't touch sensitivities against differences: those stay in fd_sens, which gains the sweep. The new suite compares bngsim's paths with each other, which no existing suite does.
  3. "Classifying refusals by message text is fragile." Agreed. The classifier is used only where bngsim has no exception type for the refusal, and a misread message fails loudly (EXCEPTION alerts) rather than silently. A SparseJacobianUnavailable type and a codegen-declined type would make it exact. That's a small follow-up I can open.

Diff (fd_sens_run.py, the one existing runner this changes; the new files are listed above)

diff --git a/parity_checks/fd_sens/fd_sens_run.py b/parity_checks/fd_sens/fd_sens_run.py
index d358cac6..06b8a989 100644
--- a/parity_checks/fd_sens/fd_sens_run.py
+++ b/parity_checks/fd_sens/fd_sens_run.py
@@ -106,6 +106,26 @@ REFERENCE_ENGINE = "finite_difference"
 # starts at the child's spawn, before the worker's.
 CAP_SHARE = 0.9
 
+# The tolerance sweep (issue #1257): the analytic run again at two tolerances
+# far tighter than the suite's. An analytic column converges with the
+# tolerance; a column on CVODES' difference quotient that does not converge
+# still moves there, which is how the narrow clamp pulse of #1227 showed (5x
+# off, and a difference of plain runs at one tolerance did not see it). Judged
+# only where the trajectory itself settles between the two: a model that
+# amplifies every difference moves its columns with the tolerance too, and no
+# oracle can judge those (LV).
+SWEEP_RTOLS = (1e-10, 1e-12)
+# atol at each, relative to its rtol
+SWEEP_ATOL_RATIO = 1e-2
+# The trajectory settled: the two runs' states this close on their peak.
+SWEEP_SETTLED = 1e-6
+# A column that moves more than this on its own peak between the two has not
+# converged.
+SWEEP_TOL = 1e-3
+# A column under this of the state's peak is zero to the arithmetic, and is
+# not asked to converge: erk_activation's 3.9e-23 beside a state of 3e6.
+SWEEP_ROUNDING = 1e-14
+
 
 def load_model(path: Path):
     import bngsim
@@ -359,6 +379,79 @@ def column_verdicts(analytic, reference, gap, x_max: float, values, atol: float,
     return out
 
 
+def tolerance_sweep(runs, params, judged=None) -> dict:
+    """The sweep's finding on ``runs``, ``[(species, sensitivities)]`` at
+    :data:`SWEEP_RTOLS` (loosest first): ``settled`` (the trajectory converged
+    between them), ``state_gap``, and per parameter column of ``judged`` (all of
+    ``params`` by default) above the state's rounding, how far it moved on its
+    own peak (``column_gaps``). ``moving`` names the columns past
+    :data:`SWEEP_TOL`, only where the trajectory settled."""
+    judged = set(params if judged is None else judged)
+    (x1, s1), (x2, s2) = (
+        (np.asarray(x, dtype=float), np.asarray(sx, dtype=float)) for x, sx in runs
+    )
+    if x1.shape != x2.shape or s1.shape != s2.shape:
+        return {"settled": False, "state_gap": float("inf"), "column_gaps": {}, "moving": []}
+    peak = float(np.nanmax(np.abs(x2))) if x2.size else 0.0
+    state_gap = float(np.nanmax(np.abs(x1 - x2)) / peak) if peak > 0.0 else 0.0
+    settled = bool(np.isfinite(state_gap) and state_gap <= SWEEP_SETTLED)
+    gaps = {}
+    for j, p in enumerate(params):
+        if p not in judged:
+            continue
+        col = s2[..., j]
+        size = float(np.nanmax(np.abs(col))) if col.size else 0.0
+        if not size > SWEEP_ROUNDING * max(peak, 1.0):
+            continue
+        gaps[p] = float(np.nanmax(np.abs(s1[..., j] - col)) / size)
+    moving = [p for p, g in gaps.items() if settled and not g <= SWEEP_TOL]
+    return {"settled": settled, "state_gap": state_gap, "column_gaps": gaps, "moving": moving}
+
+
+def _sweep_runs(path: Path, params: list[str], spec: dict):
+    import bngsim
+
+    out = []
+    for rtol in SWEEP_RTOLS:
+        model = load_model(path)
+        run = bngsim.Simulator(
+            model,
+            method="ode",
+            sensitivity_params=list(params),
+            sensitivity_method=spec["sens_method"],
+        ).run(
+            t_span=(spec["t_start"], spec["t_end"]),
+            n_points=spec["n_points"],
+            rtol=rtol,
+            atol=rtol * SWEEP_ATOL_RATIO,
+            max_steps=spec.get("max_steps", 100_000),
+        )
+        out.append((run.species, run.sensitivities))
+    return out
+
+
+def _apply_sweep(res: dict, sweep: dict) -> None:
+    """The sweep's verdict over the difference's, into ``res``: a column that
+    does not converge with the tolerance where the trajectory does is a DIFF,
+    whatever the difference said (it may pass, or have been unable to judge)."""
+    res["sweep_settled"] = sweep["settled"]
+    res["sweep_state_gap"] = sweep["state_gap"]
+    res["sweep_worst"] = max(sweep["column_gaps"].values(), default=0.0)
+    if sweep["moving"] and res.get("status") in ("pass", "bad_test", "diff"):
+        worst = max(sweep["column_gaps"][p] for p in sweep["moving"])
+        res["columns_diff"] = sorted(set(res.get("columns_diff") or []) | set(sweep["moving"]))
+        res.update(
+            status="diff",
+            value=max(float(res.get("value") or 0.0), worst),
+            why=(
+                f"the analytic columns {', '.join(sweep['moving'])} still move by "
+                f"{worst:.2e} of their peak between rtol {SWEEP_RTOLS[0]:g} and "
+                f"{SWEEP_RTOLS[1]:g}, where the trajectory settles to "
+                f"{sweep['state_gap']:.1e}: they do not converge with the tolerance"
+            ),
+        )
+
+
 def _judge(res: dict, analytic, reference, gap, species, values, spec: dict) -> None:
     """The verdict on two same-shaped tensors, into ``res``: which columns are
     judged, whether the reference converged where the verdict looks, then
@@ -512,6 +605,20 @@ def _worker(spec: dict, q) -> None:
         else:
             species = np.asarray(run.species, dtype=float)
             _judge(res, analytic, reference, gap, species, values, spec)
+        # The sweep asks of the columns the verdict judged, those above the
+        # solver's resolution: a column that is 0 to the integration (the
+        # position and width of a pulse whose area holds) reads its noise, and
+        # its noise moves with the tolerance.
+        judged = [p for p in params if p not in (res.get("columns_not_judged") or ())]
+        if (
+            spec.get("sweep", True)
+            and judged
+            and res.get("status") in ("pass", "bad_test", "diff")
+        ):
+            try:
+                _apply_sweep(res, tolerance_sweep(_sweep_runs(path, params, spec), params, judged))
+            except Exception as e:  # noqa: BLE001 - a sweep that does not run judges nothing
+                res["sweep_error"] = f"{type(e).__name__}: {str(e)[:200]}"
     except Exception as e:  # noqa: BLE001 - one model's failure is that row's EXCEPTION
         captured = capture_exception("bngsim", e)
         res.update(status="exception", exception=captured.text, exception_class=captured.cls)
@@ -549,6 +656,7 @@ def make_specs(jobs: list[dict], args) -> list[dict]:
                     # atol is absolute, and the atol/|p| floor with it.
                     "atol": float(job.get("atol", args.atol)),
                     "fd_tol": args.fd_tol,
+                    "sweep": not getattr(args, "no_sweep", False),
                     "param_cap": int(job.get("param_cap", args.param_cap)),
                     "sens_method": method,
                     "timeout": float(job.get("timeout", args.timeout)),
@@ -584,6 +692,10 @@ def to_result(spec: dict, r: dict, stamp: dict) -> JobResult:
         "resolvable_columns": r.get("resolvable_columns"),
         "columns_diff": r.get("columns_diff"),
         "sens_rhs_decline_reason": r.get("sens_rhs_decline_reason"),
+        "sweep_settled": r.get("sweep_settled"),
+        "sweep_state_gap": r.get("sweep_state_gap"),
+        "sweep_worst": r.get("sweep_worst"),
+        "sweep_error": r.get("sweep_error"),
         **(r.get("verdict") or {}),
     }
     return JobResult(
@@ -623,6 +735,7 @@ def main(argv=None) -> int:
         "is declared unconverged (BAD_TEST)",
     )
     ap.add_argument("--param-cap", type=int, default=DEFAULT_PARAM_CAP)
+    ap.add_argument("--no-sweep", action="store_true", help="leave the tolerance sweep out")
     ap.add_argument("--models", default="", help="comma-separated model_id substrings")
     args = ap.parse_args(argv)
 
@@ -659,6 +772,7 @@ def main(argv=None) -> int:
             "fd_atol": FD_ATOL,
             "fd_rel_step": FD_REL_STEP,
             "fd_tol": args.fd_tol,
+            "sweep_rtols": None if args.no_sweep else list(SWEEP_RTOLS),
             "param_cap": args.param_cap,
         },
         "hardware": asens.hardware_info(),

…lanl#1257)

An analytic column that still moves between rtol 1e-10 and 1e-12 where the
trajectory settles is a DIFF, whatever the difference of plain runs said:
before lanl#1242, triangle_clamp_narrow's k_clock column moved 107% there, and
the difference alone passed it. Asked only of the columns the verdict judges,
above the solver's resolution. Eight pulses over the clock, max/abs, clamp
and if(), narrow and wide, join the corpus.
lanl#1257)

A sensitivity run's state against a plain run's, the FD Jacobian against the
analytic one, KLU against dense, compiled against interpreted, cc against MIR,
a comma-decimal locale against C; and three rules for bugs whose answers are
right: a sensitivity run that does not complete, a sparsity pattern that
misses entries, a backend other than the one asked for. Shape models run on
every pull request (python/tests/test_differential_shapes.py); the corpus
runs nightly beside the parity suites, against a baseline.
@wilton-williams

Copy link
Copy Markdown

This rocks dude!

lanl#1257)

The answer must not depend on where it is read: 8x the output points, read at
the base's own sample times, is the base. Before lanl#1263 it found lanl#1244's pulse
train 7.5x the trajectory's peak off. The shapes run on the MIR leg too, the
one place cc against MIR and codegen=False on a MIR build both run. The
nightly's per-model cap is 900 s: nine models passed 300 s on the first CI
run, compiling cold. lanl#1214's xfail goes, fixed by lanl#1224.
…s KLU and cc (lanl#1257)

The sweep on the four narrow pulses runs with the parity tests, not only the
nightly: before lanl#1242 the clamp ones' k_clock columns moved there. lanl#1254's
xfail holds only where KLU is built: the MIR leg builds without it, skips the
check, and read that as an unexpected pass. A machine with no C compiler
skips the cc paths instead of reporting them as crashes.
…ial-harness

# Conflicts:
#	.github/workflows/mir.yml
#	ci/test_floors.env
…erpreter is a finding (lanl#1257)

klu_vs_dense left its "dense" reference to the automatic rule, which sends a
model of 50 species or more with a sparse Jacobian to KLU, and the base with
it: on those models the check was KLU against KLU (a 60-species chain reads
linear_solver 1 for the base, jacobian="fd" and the forced-sparse run alike).
99 of the BioModels under 1 MB route that way. The reference is now
force_dense_linear_solver=True on the same Jacobian. It reads DIFF on
BIOMD0000000338 and 339 at once (3.5% and 1.8% of the peak; own drift 1e-7):
past a discontinuity where CVODE's step collapses, KLU (their default route)
and dense take different branches. On 338 KLU ends Fg at 310.989 at every
rtol from 1e-6 to 1e-12; dense and libRoadRunner end it at 0.73-0.75, drifting
with the tolerance, and dense lands on KLU's branch at 1e-10. Which branch is
right is open; that the two solvers disagree is what the check is for.

backend_as_asked could not fail on its codegen=True half: _compiled read a
codegen=True run on ExprTk as "no compiler" and the rule as SKIP, while
cc_vs_interp compared the interpreter with itself. codegen=True builds cc or
MIR or raises, so ExprTk there is the finding. A missing compiler is now
recognized as bngsim reports it off Windows too (a cc that is not there,
macOS's cc shim without the Command Line Tools). cc_vs_mir checks its cc arm
ran cc.

A tenfold tighter run that does not complete no longer blanks the model as
no_base: its comparisons are SKIP, said why, and the rules that compare no
answers still run (MODEL1112050001). The sparsity rule leaves out a state its
one-interval run does not reach, as it leaves out a non-finite one. A
trajectory wholly under the absolute floor (1e3·atol: 16 BioModels in molar
units) read PASS whatever a path gave; a path inside the floor there is SKIP,
said why, and one past it is still judged. The sparsity rule steps a state on
its own scale: an absolute 1e-3 floor stepped a nanomolar state by its whole
size and hid a missing slot as unresolvable.

cc_vs_interp and backend_as_asked share their two runs. The shapes module
removes its temp directory. Docs: 53 fd_sens models, 10 checks, the grid check.
…ale (lanl#1257)

The sweep's two sensitivity runs at rtol 1e-10 and 1e-12 came after the
difference's, which may spend 90% of the job's cap, with no deadline: a slow
sweep ran the job into the scheduler's kill, a TIMEOUT where a verdict had
been reached. Each run is now bounded by the same deadline, and a sweep out of
time fails as a sweep and leaves the verdict.

A column was asked to clear an absolute 1e-14 (1e-14 of max(peak, 1)), in
the units of state per parameter: nanomolar_dimer's two columns, 1e-28 and
6e-20 per unit of parameters of 2e18 and 4e9, and 17% of its 1.5e-9 peak per
relative change, were both left out, and the sweep judged nothing there. A
column is now |p|·dx/dp against 1e-14 of the state's peak. All 106 fd_sens
cases still PASS locally, now with a judged column on every settled model.
)

The review commits change what the check reports, on the nightly's own
runner:

- 16 BioModels whose trajectories sit wholly under the absolute floor
  (1e3·atol) read SKIP, said why, on the six comparisons (15 on
  sens_vs_plain), where they read a PASS that could not fail.
- BIOMD0000000339 klu_vs_dense reads DIFF, 1.79e-2 of the peak, now that the
  reference is dense; before, it read exactly 0, KLU against KLU (lanl#1304). On
  this runner BIOMD0000000338's dense run lands on KLU's branch (1.12e-4, PASS).
- MODEL1112050001 and MODEL1112110002, whose tenfold tighter run fails, keep
  their rules that compare no answers (sparsity, backend, sens_runs) instead
  of reading SKIP on every row.
- MODEL1006230049, 053 and 077 finish inside the 900 s cap (TIMEOUT to PASS,
  SKIP on cc_vs_mir), with cc_vs_interp and backend_as_asked sharing two runs.

fd_sens.json is unchanged: its job ran clean against it.
@wshlavacek
wshlavacek merged commit b543f7b into lanl:main Oct 27, 2026
36 checks passed
Montana added a commit to Montana/bngsim that referenced this pull request Oct 27, 2026
lanl#1291's review commits re-recorded the differential baseline (run
40440170341) under the forced-dense klu_vs_dense. The merged baseline is
theirs for every existing row, plus this PR's 1,353 jacobian_values rows from
run 40448597362, with the tally recomputed; nothing else differs from lanl#1291's.
The runner's docstring takes both edits (lanl#1291's 8x-grid check, this PR's
fourth rule).
wshlavacek added a commit to Montana/bngsim that referenced this pull request Oct 28, 2026
…l#1254's xfail

The colored quotient's session pointer was a second field beside main's
dq_cvode_mem, which lanl#1002 added for exactly this, and it was set at the
two CVodeSetUserData sites rather than where the callback is chosen. It
is now dq_cvode_mem, set in setup_linsol_and_jac's colored branch as the
dense branch sets it. min_inc is formed by one helper, cvode_dq_min_inc,
which the dense quotient also uses; it returns false where
CVodeGetCurrentStep fails, where the colored callback used to carry on
with h = 0 and step a species at 0 by 0.

lanl#1291's strict xfail on equality-dwell / klu_vs_dense is dropped: this is
the fix it was waiting for.
wshlavacek added a commit to Montana/bngsim that referenced this pull request Oct 28, 2026
…a26f5b (lanl#1302, lanl#1304)

verdicts.json of scheduled run 40712357505 on main at 9a26f5b, byte for
byte. Three cases move, and no others:

- BIOMD0000000338|klu_vs_dense PASS -> EXCEPTION. Since lanl#1302, the
  forced-dense jacobian="fd" leg fails at t = 0.72 (CV_CONV_FAILURE) on
  every Linux runner; the KLU leg completes (ajac_vs_fdjac, the same KLU
  run bit for bit, passes). The model is lanl#1304's.
- BIOMD0000000385|ajac_vs_fdjac and |klu_vs_dense TIMEOUT -> PASS. The old
  file is lanl#1291's run, where the pool killed every row at the 900 s cap;
  with lanl#1321's per-check deadlines and the 1500 s cap these two finish.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants