Skip to content

fix(convert): carry #89's stable tQSSA root into the SBML export - #94

Merged
wshlavacek merged 1 commit into
mainfrom
feat/sbml-mm-stable-root
Jul 28, 2026
Merged

fix(convert): carry #89's stable tQSSA root into the SBML export#94
wshlavacek merged 1 commit into
mainfrom
feat/sbml-mm-stable-root

Conversation

@wshlavacek

Copy link
Copy Markdown
Collaborator

Follow-up to #92 (merged), which took the stable tQSSA root everywhere except the
SBML exporter. This is that last site.

Summary

_mm_formula in python/bngsim/convert/_sbml_writer.py was the one site
deliberately left out of #92, on the reading that an interchange artifact should
stay a faithful transcription of the canonical literature formula.

That reading does not survive the measurement. SBML MathML is evaluated by the
consumer, in float64 — so the exported law kept performing the same
0.5*(delta + D) subtraction that #92 had just stopped the engine performing.
Once the two sides disagree, the conversion gate says so.

Measurement

tests/data/mm_tqssa_stiff.net (the fixture #92 adds, |δ|/√(4·Km·S) ~ 1e7),
with #92's engine fix in place:

textbook export stable export
RHS vs source model 2.17e-02 0.0 (bit-for-bit)
conversion gate L2 fail, L3 fail, ok=False L0–L3 pass, ok=True
libRoadRunner on the artifact CV_ERR_FAILURE, no trajectory agrees with bngsim to 2.9e-11
_faithful_sbml_text (bng_parity oracle) REFUSED (delta > 1e-6 tol) ACCEPTED
reaction rate at t=0 vs mpmath (50 dps) 2.09e-02 1.90e-16

So net2sbml was emitting a model that its own validator marks not-ok, that
RoadRunner cannot integrate, and that parity_checks/bng_parity/net_roadrunner.py
silently drops as unfaithful — an unscored REFERENCE_FAILED row rather than a
loud failure.

Rate accuracy in RoadRunner across the #89 sweeps, textbook → piecewise:

sweep textbook piecewise
stiff fixture (ratio ~1e7) 2.086e-02 1.900e-16
corpus-like (ratio ~1) 9.112e-17 9.112e-17
Km ≫ S,E 1.876e-13 1.099e-18
Km ≪ S,E (deep saturation) 2.663e-03 1.376e-17
delta > 0 large (S ≫ E,Km) 9.867e-17 9.867e-17

Rows 2 and 5 are bit-identical — the delta >= 0 branch keeps the textbook
path exactly, so nothing moves where the corpus lives.

Change

The export now carries the same branch as _mm_sfree_c_lines, spelled as the
L3v2 piecewise the writer already targets:

sFree = piecewise(0.5*(delta + D), delta >= 0, 2*Km*S/(D - delta))

It omits only that helper's (D - delta) > 0 degenerate guard, which is
unreachable once delta < 0 (D >= 0 forces D - delta > 0) and which the
enclosing max(..., 0) backstops anyway.

Why the piecewise is cheaper than it looks

Both concerns that kept this out of #92 turned out to be already-settled:

  • It is not a new construct for this writer. The target is L3v2 chosen for
    exactly this MathML (_SBML_VERSION), piecewise is already in
    _EXPRTK_SUPPORTED_CALLS, and every BNGL if() already exports as one.
  • L4 cannot regress, because it never passed: L4 is already inconclusive
    on every MM model — pinned by the existing test_full_gate_l4_is_non_gating
    since the law already contains max, and _validate.py lists Max and
    Piecewise in the same _NONSMOOTH tuple.

And the new branch is removable, unlike the max already in the law: both arms
agree to the last ulp at delta == 0 (measured across ±1e-3 … ±1e-12, worst
1.11e-16), so no consumer's integrator gains a discontinuity to resolve.

The cost that is real is size: the emitted document grows 6519 → 12121 bytes
on the stiff fixture, because sFree appears twice in the law and SBML has no
let binding. A functionDefinition would fix that; it is a larger structural
change (it moves L1) and is deliberately not in this PR.

Tests

  • test_mm_tqssa_stiff_rhs_exact — RHS delta on the stiff fixture. Measured
    2.17e-02 against the textbook form, exactly 0.0 against the piecewise.
  • test_mm_tqssa_stiff_full_gate_passes — the end-to-end tripwire: with the
    textbook root, L2 and L3 both fail and report.ok is False.

Both verified to fail without the change and pass with it.

Full python/tests suite: 2763 passed, 114 skipped. pre-commit (ruff,
ruff-format, mypy) clean. libRoadRunner 2.9.2 used for the consumer checks.

Per native-tests.yml, python/tests is covered by the pre-push hook rather than
by a workflow, so both new tests ran there (and locally) but will not appear in
this PR's checks.

`_mm_formula` was the one site deliberately left out of #89, on the reading
that an interchange artifact should stay a faithful transcription of the
canonical literature formula. That reading does not survive the measurement:
SBML MathML is evaluated by the *consumer*, in float64, so the exported law
performed the same `0.5*(delta + D)` subtraction the engine had just stopped
performing.

With #89 in place the two sides disagree, and the gate says so. On
tests/data/mm_tqssa_stiff.net (ratio ~1e7), textbook -> stable:

  RHS vs source model         2.17e-02  ->  0.0 (bit-for-bit)
  conversion gate             L2/L3 fail, ok=False  ->  L0-L3 pass, ok=True
  libRoadRunner               CV_ERR_FAILURE  ->  agrees with bngsim to 2.9e-11
  parity _faithful_sbml_text  REFUSED (> 1e-6 tol)  ->  ACCEPTED

So net2sbml was emitting a model its own validator marks not-ok, that
RoadRunner cannot integrate, and that the bng_parity RoadRunner oracle
silently drops as unfaithful.

The export now carries the same branch as `_mm_sfree_c_lines`, spelled as the
L3v2 piecewise the writer already targets:

  sFree = piecewise(0.5*(delta + D), delta >= 0, 2*Km*S/(D - delta))

omitting only that helper's `(D - delta) > 0` degenerate guard, unreachable
once delta < 0 and backstopped by the enclosing max(..., 0).

The piecewise costs less than it looks. It is not a new construct here: the
target is L3v2 chosen for exactly this MathML, `piecewise` is already in
_EXPRTK_SUPPORTED_CALLS, and every BNGL `if()` already exports as one. It does
not move L4 either, which is *already* inconclusive on MM (pinned by
test_full_gate_l4_is_non_gating) because the law already contains `max`, and
_validate.py lists Max and Piecewise in the same _NONSMOOTH tuple. Unlike that
`max` - a genuine kink - this branch is removable: both arms agree to the last
ulp at delta == 0 (measured across +/-1e-3 .. +/-1e-12, worst 1.11e-16), so no
consumer's integrator gains a discontinuity. The real cost is size: 6519 ->
12121 bytes on the stiff fixture, since sFree appears twice and SBML has no
`let` binding.

Where the corpus lives nothing moves: at ratio ~1 and on the delta >= 0 branch
the emitted law is bit-identical to the textbook one, so both corpus MM models
export unchanged.
@wshlavacek
wshlavacek merged commit 658fd93 into main Jul 28, 2026
1 check passed
@wshlavacek
wshlavacek deleted the feat/sbml-mm-stable-root branch July 28, 2026 05:17
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