Skip to content

Typed refusals: SparseJacobianUnavailableError and CodegenDeclinedError (#1313) - #1336

Open
Montana wants to merge 2 commits into
lanl:mainfrom
Montana:fix/1313-typed-refusals
Open

Montana wants to merge 2 commits into
lanl:mainfrom
Montana:fix/1313-typed-refusals

Conversation

@Montana

@Montana Montana commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1313.

What it adds

Two refusals bngsim makes by name now have types of their own. Each subclasses what was raised before, so an existing except keeps working.

type raised for subclass of
SparseJacobianUnavailableError force_sparse_linear_solver=True on a model with no sparse Jacobian for KLU SimulationError
CodegenDeclinedError codegen=True on a model codegen declines (e.g. functions that read each other, #621) BngsimError, so RuntimeError

The sparse refusal is a C++ type, bngsim::SparseJacobianUnavailable (new include/bngsim/refusals.hpp), thrown where cvode_simulator.cpp refuses. The core's exception translator, which already maps TimeoutError to SimulationTimeout, maps it to the Python type. run(), run_batch's threads and each batch row pass it through untouched, as they do SimulationTimeout.

The codegen decline: _codegen_refusal now returns the exception. A decline is CodegenDeclinedError, a statement about the model. A failed build (a compiler error, a timeout) stays a plain RuntimeError, which says nothing about the model, as the issue asks. The messages are unchanged.

-            throw std::runtime_error(
+            throw SparseJacobianUnavailable(
                 "force_sparse_linear_solver: this model cannot supply a sparse "

The harness (parity_checks/differential/_differential.py) reads both with isinstance instead of their text:

  • _sparse_declined(e) is isinstance(e, SparseJacobianUnavailableError);
  • _compiled catches CodegenDeclinedError.

The baseline's notes (_Declined: codegen=True requested, but codegen declined this model: …) are unchanged.

A bug this found: run_batch ignored the linear-solver flags

The batch test never reached the refusal: run_batch ran. _run_single_batch copied jacobian, the tolerances and codegen onto each row's options, but neither force_dense_linear_solver nor force_sparse_linear_solver. So every batch row ran whatever the auto rule picked. On a 60-species chain, a Simulator asked for the dense solver ran KLU in every row. run() always honoured both, and the steady-state batch already copies them. Each row now takes both:

                 opts.jacobian = self._jacobian
+                opts.force_dense_linear_solver = self._force_dense_linear_solver
+                opts.force_sparse_linear_solver = self._force_sparse_linear_solver

That has its own changelog fragment (1313.fixed.md).

Tests (python/tests/test_typed_refusals.py)

The positive tests fail on main because neither type exists. With the change:

  • both are exported and subclass what was raised before; CodegenDeclinedError is not a SimulationError;
  • species with no reactions, under force_sparse_linear_solver=True, jacobian="fd", raise SparseJacobianUnavailableError, still a SimulationError;
  • the same through run_batch with 2 threads arrives by its type, not as "Batch simulation 0 failed";
  • a solver failure is not the refusal;
  • the cyclic-function model raises CodegenDeclinedError;
  • a codegen build failure (injected in generate_combined_from_model) is a RuntimeError, not a decline;
  • the harness reads a reworded message of either as the refusal;
  • a batch row takes the solver it was asked for: {1} (KLU) under the auto rule, {0} (dense) under force_dense_linear_solver.

Mutations, each caught:

  • the C++ translator removed (rebuilt): 2 fail;
  • run() wrapping it;
  • the decline untyped;
  • a build failure typed as a decline;
  • the harness by text, for each type;
  • run_batch dropping the flags: 2 fail.

Suites: python/tests and parity_checks/tests give 17,325 passed, 171 skipped, 1 xfailed. clang-format 17.0.6 (the pinned hook) and ruff 0.14.0 are clean. The core stub is unchanged, since no binding changed.

Docs: both types are in docs/reference/api.md's exception list; changelog fragments 1313.added.md and 1313.fixed.md.

Conflicts: none. A trial merge with each open differential PR (#1315, #1318, #1319, #1320, #1321, #1322, #1323) is clean.

…or (lanl#1313)

force_sparse_linear_solver on a model with no sparse Jacobian for KLU,
and codegen=True on a model codegen declines, were a SimulationError and
a RuntimeError a caller told from a crash only by their text: the
path-agreement harness matched both, and a reworded message turned its
SKIP or UNSUPPORTED into an EXCEPTION.

- bngsim::SparseJacobianUnavailable (include/bngsim/refusals.hpp), thrown
  where cvode_simulator.cpp refuses, translated by the core to
  SparseJacobianUnavailableError(SimulationError), and passed through
  run(), run_batch's threads and each batch row untouched.
- _codegen_refusal returns the exception: CodegenDeclinedError(BngsimError)
  for a decline, a plain RuntimeError for a failed build, which says
  nothing about the model.
- Both exported; each subclasses what was raised before.
- The harness reads both with isinstance.

run_batch dropped force_dense_linear_solver and force_sparse_linear_solver
from every row, which ran whatever the auto rule picked (KLU, on a
60-species chain asked for dense); run() honoured both. Found because the
batch path never reached the refusal; each row now takes both.
@elyfmiller

Copy link
Copy Markdown
Collaborator

Thanks. We reviewed this independently, Opus 5.5 and Fable 5.1, each on our own build of 44c55fe merged onto main c0877fa (clean), on macOS arm64 with KLU and MIR on. Your merge of main this morning, 52b4911, has the same tree (b48ae3a3), so all of this holds for the current head. The change does what it says:

  • both refusals reach the caller by type, from run() and from run_batch, sequential or threaded;
  • a failed codegen build stays a plain RuntimeError;
  • the harness reads both by isinstance;
  • the run_batch fix is real: on main a batch row drops both solver flags.

We found no defect in the diff. There is one merge-order point with #1292, one pre-existing gap on a line this PR edits, and some low items. What we checked comes first, so it needn't be redone.

What we checked

check result
Fail before: the new file against main's package and core 8 failed. Seven fail on the missing names; the batch-row test fails on the bug itself, assert {1} == {0} (KLU in a row asked for dense).
After 8 passed. Also 8 under BNGSIM_CODEGEN_JIT=mir, under BNGSIM_CODEGEN_THRESHOLD=1, and under both.
Full python/tests, BNGSIM_SKIP_AUDIT=strict 16,395 passed, 166 skipped, 0 failed, exit 0
parity_checks/tests 926 passed, 33 skipped
Mutations, each caught
  • run() wraps the refusal;
  • the threads wrap it;
  • the row wraps it;
  • a row drops the sparse flag, the dense flag, or both;
  • the decline untyped;
  • _sparse_declined by text, or true for any SimulationError;
  • _compiled by text;
  • the C++ translator's catch removed (rebuilt): both sparse tests fail.
One more mutation is not caught (low 4).
Where the refusal arrives (force_sparse_linear_solver=True, jacobian="fd", species and no reactions) SparseJacobianUnavailableError, message unchanged and with no cause chain, from each of:
  • run(), and a second run();
  • run(steady_state=True);
  • run_batch, sequential and with 2 threads;
  • run() and run_batch with sensitivity_params.
steady_state() runs, since the march declines the sparse route, as its comment says.
Where the decline arrives Simulator(cycle, codegen=True), with and without sensitivity_params: CodegenDeclinedError. Both types pickle.
The batch fix On a 60-species chain, under no flag, force_sparse and force_dense, with codegen=None and codegen=True, each row takes the solver run() takes (1, 1, 0). Every row's species equal the single run's exactly.
With #1348 Its xor decline reaches codegen=True as CodegenDeclinedError, on cc and under MIR. #1348 records emit-time declines with _record_codegen_decline, so the two compose.
Read
  • every raise and reader of both messages;
  • _codegen_refusal and its two callers;
  • _ode_attempts, which re-raises anything not prefixed by _CVODE_FAILURES, so the refusal is never retried on FD;
  • the C++ catch blocks between the throw and the boundary (none rethrows by message);
  • the warm-path setup;
  • prepare_model_codegen and _source, which clear both notes at entry, so a stale build error can't mistype a decline;
  • the static core, so the header-only class matches in one module.
Gates ruff check and format, clang-format 17.0.6, ci/changelog.py check and mypy 1.13.0 all clean
Overlap Trial merges of the other 41 open PRs onto main + this PR. It adds one conflict, with #1379 (opened this morning): _simulator.py's import list, where each PR adds a name on the same line. Keeping both (AtolScaleWarning, CodegenDeclinedError) resolves it. Ruff is clean, and both PRs' test files pass together, 16 tests, under cc and MIR. No other PR conflicts (#1346 already conflicts with main), and none reads either refusal by its text.
CI on 52b4911 All green, Path-agreement included (run 40838947105, on c0877fa, which has #1338). No baseline note quotes the sparse refusal, and the three that quote the decline (MODEL1006230117) keep their first line.

1. Land #1292 first, or with this

Without #1292 the new type also fires when the caller set no flag. On main + this PR, a 60-species model with no reactions, run with jacobian="fd" and neither flag, raises:

SparseJacobianUnavailableError: force_sparse_linear_solver: this model cannot supply a sparse Jacobian …

This is #1296. The size and density rule sends the empty pattern to KLU, and #1292 adds sp.nnz > 0 to route_to_sparse_linear_solver so it goes dense. The new type's docstring and api.md say it's for force_sparse_linear_solver=True, which holds only once #1292 is in.

Fable built main + this PR + #1292 (its head f66d1e4; the merge is clean):

  • both PRs' test files pass, 14 tests;
  • the unflagged case runs dense under fd, auto and analytical;
  • the flagged refusal still arrives typed on every path in the table.

Each of us reproduced the unflagged case with our own script.

2. A threaded run_batch wraps a timeout

The thread pool's except (_simulator.py:5549) now passes this refusal and SensitivityUnsupportedError through, but not SimulationTimeout. On a 400-species chain with timeout=1e-9:

path type at the caller
run() SimulationTimeout
run_batch, sequential SimulationTimeout
run_batch, 2 threads SimulationError("Batch simulation 0 failed: Simulation exceeded wall-clock timeout…"), with SimulationTimeout as its cause

Main does the same, so this PR didn't cause it. But it's the line this PR edits, and the body says the threads pass the refusal through "as they do SimulationTimeout", which they don't. Adding the one name to the tuple fixes it, and a test with num_processors=2 and a tiny timeout would pin it.

That tuple now exists three times, in run(), the thread pool and the row, each with its own members. This gap is the drift. One module-level tuple of the typed exceptions that pass through, used in all three, would keep the next type from reaching two of the three.

Low

  1. The dense assertion reads the built-in LU only. Under BNGSIM_LAPACK_DENSE=1, kinds(force_dense_linear_solver=True) is {2}, the adaptive BLAS solver, so the test fails. No workflow sets the variable, and test_engine_choice_accessors.py:315 already counts 2 as dense. assert 1 not in kinds(force_dense_linear_solver=True) says what the test means: not KLU.
  2. The third branch of _codegen_refusal is unpinned. If "codegen built nothing and recorded no reason" is typed as CodegenDeclinedError, the new file still passes 8/8, and no test names that text.
  3. The harness still reads a third refusal by text. _differential.py:_declined classifies The issue #952 hold check refuses a sensitivity run at a bend: a continuous narrow pulse (max/abs or if()) is refused at rtol <= 1e-10, blamed on a jump it does not have #1229's run-time sensitivity refusal by "forward sensitivity" in str(e) on a SimulationError. It's outside Typed exceptions for "no sparse Jacobian for KLU" and "codegen declined this model" #1313's two cases, but it has the same fragility. An issue, or a line in the body saying it's left, would keep it tracked.
  4. The deprecated bngsim.prepare_codegen still raises a decline as a plain RuntimeError (_codegen.py:13039, "codegen declined {net_path}: …"). It's public, in __all__. Typing it, or saying the type covers codegen=True only, would close the gap.
  5. Two comments.
    • The translator's header comment (_bngsim_core.cpp:870–879) still names TimeoutError as the one exception translated in C++.
    • CodegenDeclinedError and the private _codegen.CodegenDeclined (a RuntimeError, also caught in _jax_rhs.py) are unrelated classes. A line in the new docstring would save a reader from guessing.

Not checked:

  • Linux and Windows beyond CI. No Windows leg runs the new file, so the translator has only run on macOS (here) and Linux (CI).
  • A shared-library build of the core. The Python build forces static.

This branch has not been deployed

No deployments
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.

Typed exceptions for "no sparse Jacobian for KLU" and "codegen declined this model"

2 participants