Skip to content

differential: metamorphic transforms of the model file (#1305) - #1316

Open
Montana wants to merge 4 commits into
lanl:mainfrom
Montana:fix/1305-metamorphic
Open

Montana wants to merge 4 commits into
lanl:mainfrom
Montana:fix/1305-metamorphic

Conversation

@Montana

@Montana Montana commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #1305.

Stacked on #1291. #1305 is blocked by #1291, which adds parity_checks/differential/, and this branch is built on it. Until #1291 merges, this PR also shows #1291's commits. Only the last commit, 1d6735c, is this PR's.

It also touches the same lines as #1315 (#1308): both add to CHECKS and to test_differential_shapes.py. Whichever merges second needs a small conflict resolution, keeping both sets of entries. I'll do it on whichever is left.

What it adds

Four metamorphic checks. Every path #1291 compares starts from one loaded model, so a bug in the loader, the model build or the parser makes them agree on the wrong answer. These transform the file instead, so the loader reads each model a second time, differently. They are in a new parity_checks/differential/_transforms.py, using libsbml for SBML and rewriting the blocks of a .net as text.

check the transform expected answer
reorder species and reactions reversed (and, in SBML, parameters and species references) unchanged, by name
rename parameters, observables, functions and species to fresh names (in SBML: species, parameters, compartments) unchanged, through the map back
split each reaction into two identical ones at half the rate: a parameter rate through a new parameter, a function rate through a new function, BNG's MM/Sat/Hill through their first argument unchanged
rescale_time every rate and rate rule ×3, the window ÷3 x'(t) = x(3t)

Each is judged with #1291's gate (Base.compare), aligned by species name, as the issue asks, since a transform changes the order of floating-point sums. Where a transform would not keep the answer for a model, the row is SKIP and says why:

  • rescale_time skips a model with events, the clock, a delay, rateOf, or math that reads a reaction's rate;
  • split skips a reaction whose rate is read by name elsewhere.

Base now also keeps its run's species names, because a Result leaves out a parameter that an event promotes into the state.

Over the corpus

5,412 rows: the four checks over the 53 .net jobs and the BioModels, run locally.

check PASS SKIP DIFF EXCEPTION
reorder 1,173 178 0 2
rename 1,260 93 0 0
split 1,084 269 0 0
rescale_time 896 457 0 0

Every SKIP gives its reason:

The two exceptions, triaged:

  1. BIOMD0000000908: the model, not bngsim. With its species reversed, it blows up past t ≈ 141 (states near −1e262) in both bngsim and RoadRunner (1.4e8 off). The base sits at the edge of an instability that reordering a sum is enough to cross.

  2. MODEL1812040005: a finding, filed as sbml: reversing listOfSpecies makes MODEL1812040005 fail (P below 0, a*P^z NaN) where the order written runs; RoadRunner runs both #1340. Reversing listOfSpecies alone is enough to reproduce it:

    • bngsim lets P go below 0, where a_hat = a*P^z is NaN, and fails at t = 0.106 (CV_REPTD_RHSFUNC_ERR).
    • In the order as written it holds P at exactly 0 for the whole run.
    • RoadRunner runs the reversed file to 3.6e-8 of bngsim's original answer.
    • The failure is the same with codegen=False and with jacobian="fd", and Model.rhs is identical in both orders at t = 0.

    So bngsim's answer depends on species order. That gets its own issue (to follow).

What the first corpus run caught in the transforms themselves. Each is now fixed and has a control:

  • 21 rename DIFFs: ids like null, In, time, NaN are reported as _ant_<id>, so the alignment now follows that spelling.
  • .net rate-law keywords (MM kcat Km) were treated as expressions.
  • A .net value with spaces was only partly renamed.
  • A Level 2 stoichiometryMath is not in libsbml's getListOfAllElements, so it is now walked explicitly.
  • Four rescale_time DIFFs (BIOMD0000000556, 245, 575, MODEL2306170002, up to 2.0 off) came from math reading a reaction's rate. A rate rule that reads one was scaled twice: BIOMD0000000556's APP came out 9×. RoadRunner showed the identical gaps, so this was the transform and not bngsim, and these models are now SKIP.

Tests (python/tests/test_differential_shapes.py)

  • The shape tests pick up the four new checks automatically: all 15 shapes pass or SKIP by name.
  • An autonomous SBML model, whose answer is kept by every transform. It has a rate rule, so the rescale of rate rules is exercised; every SBML shape above has an event, the clock or rateOf.
  • Negative controls: a transform applied wrongly reads DIFF, on a .net (equality-dwell) and on that SBML model:
    • reorder with the values left in the old order;
    • rename with two names crossed;
    • split with the whole rate on each half;
    • rescale_time with the rates scaled and not the window.
  • Fix controls, each failing without its fix:
    • a rate rule that reads a reaction's rate is refused for rescale_time;
    • a .net with MM kcat Km and a spaced parameter expression keeps its answer under all four;
    • an id bngsim respells (null → _ant_null) aligns under rename;
    • a model with events is SKIP for rescale_time, by name.

Mutations, each caught: in the check, the window not divided, the name map ignored, no alignment by name; in the transforms, rate rules not rescaled, events not refused, SBML references not renamed; and each of the four fixes above.

Suites: test_differential_shapes.py and parity_checks/tests/test_differential_run.py give 247 passed, 1 xfailed (#1291's #1254 xfail). ruff 0.14.0 is clean. The branch merges cleanly with the current lanl main.

Cost

Four more runs per model, on the base's window. Locally, the four checks over the whole corpus (5,412 rows) took 220 s on 8 workers (an M3 Max). I have not timed them on the CI runner.

Baseline

parity_checks/nightly/baselines/differential.json will be re-recorded from this PR's own nightly run, as #1305 asks, in a follow-up commit here. Until then the new rows read as new, which ALLOW_MISSING_BASELINE allows on a pull request.

Changes (this PR's commit, less the new _transforms.py and tests)

diff --git a/parity_checks/differential/_differential.py b/parity_checks/differential/_differential.py
index 5bf10534..9f3a82b1 100644
--- a/parity_checks/differential/_differential.py
+++ b/parity_checks/differential/_differential.py
@@ -62,14 +62,17 @@ one oracle with one set of rules (``parity_checks/fd_sens``).
 from __future__ import annotations
 
 import contextlib
+import functools
 import json
 import os
 import subprocess
 import sys
+import tempfile
 from dataclasses import dataclass, field
 from pathlib import Path
 
 import numpy as np
+from _transforms import TRANSFORMS, Inapplicable, transform
 
 #: How many times the run's rtol two correct paths may differ by, before the
 #: reference's own drift is counted: their errors are each about rtol, scaled by
@@ -101,6 +104,11 @@ CHECKS = (
     "grid",
     "sparsity_covers_jacobian",
     "backend_as_asked",
+    # Metamorphic (#1305): the model file transformed, the answer known.
+    "reorder",
+    "rename",
+    "split",
+    "rescale_time",
 )
 
 
@@ -209,6 +217,9 @@ class Base:
         result, sim = run(job)
         self.job = job
         self.x = np.asarray(result.species, dtype=float)
+        # The columns of x, by name: a Result's species, which leave out a
+        # parameter an event promotes into the state (Model.species_names has it).
+        self.species_names = list(result.species_names)
         self.backend = sim.codegen_backend
         self.drift = own_drift(job, self.x)
         self.limit = max(PATH_RTOL_FACTOR * job.rtol, DRIFT_FACTOR * self.drift)
@@ -521,6 +532,48 @@ def check_sparsity(base: Base) -> Verdict:
     return _guard(go)
 
 
+def check_transform(base: Base, kind: str) -> Verdict:
+    """The model file transformed (``_transforms.py``, #1305) and run again: the
+    answer must transform with it. Every other check here starts from the one
+    loaded model, so a bug in the loader, the model build or the parser makes
+    them agree on the wrong answer; this one reads the file a second way. Its
+    trajectories are taken by name and judged as every path is, with
+    ``Base.compare``: a transform changes the order of floating-point sums, so
+    equality is not what is asked."""
+
+    def go():
+        with tempfile.TemporaryDirectory(prefix=f"differential_{kind}_") as d:
+            try:
+                tr = transform(kind, base.job.path, Path(d))
+            except Inapplicable as e:
+                return Verdict("skip", note=f"{kind} does not apply: {e}")
+            c = tr.time_scale
+            job = Job(
+                **{
+                    **base.job.__dict__,
+                    "path": tr.path,
+                    "t_start": base.job.t_start / c,
+                    "t_end": base.job.t_end / c,
+                }
+            )
+            result, _ = run(job)
+        want = base.species_names
+        # An SBML id bngsim cannot spell as it is (null, In, time, NaN: a word
+        # its expressions reserve) is reported as _ant_<id>, and the map back
+        # from a fresh name gives the id itself.
+        spelled = {w[len("_ant_") :]: w for w in want if w.startswith("_ant_")}
+        got = [tr.names.get(n, n) for n in result.species_names]
+        got = [g if g in want else spelled.get(g, g) for g in got]
+        if sorted(got) != sorted(want):
+            odd = sorted(set(got) ^ set(want))[:5]
+            return Verdict("diff", None, f"{kind}: the species are not the base's: {odd}")
+        at = {n: i for i, n in enumerate(got)}
+        x = np.asarray(result.species, dtype=float)[:, [at[n] for n in want]]
+        return base.compare(x, f"{kind} ({tr.note}): ")
+
+    return _guard(go)
+
+
 def comma_locale() -> str | None:
     """A comma-decimal LC_NUMERIC this machine has, or None. Asked of a child
     process, so this one's locale is never set."""
@@ -594,6 +647,7 @@ def check_all(job: Job, locale_name: str | None, checks=CHECKS) -> dict[str, Ver
         "grid": check_grid,
         "sparsity_covers_jacobian": check_sparsity,
         "backend_as_asked": check_backend,
+        **{kind: functools.partial(check_transform, kind=kind) for kind in TRANSFORMS},
     }
     for name, fn in one.items():
         if name in checks:
diff --git a/parity_checks/differential/differential_run.py b/parity_checks/differential/differential_run.py
index 02cc1645..949e367a 100644
--- a/parity_checks/differential/differential_run.py
+++ b/parity_checks/differential/differential_run.py
@@ -7,6 +7,10 @@ run's, the FD Jacobian against the analytic one, KLU against the dense solver,
 compiled against interpreted, cc against MIR, a comma-decimal locale against C,
 and three rules for bugs whose answers are right (a sensitivity run refused, a
 sparsity pattern that misses entries, a backend other than the one asked for).
+Four more transform the model file itself, where the answer is known to follow,
+which the loader reads a second way (#1305, ``_transforms.py``): its species and
+reactions reordered, its names renamed, each reaction split in two at half the
+rate, and, where it does not read the clock, every rate and the window rescaled.
 One row per (model, check), its ``method`` the check:
 
     the path agrees with its reference .............. PASS

Plus parity_checks/differential/_transforms.py (new, 507 lines) and 177 lines of tests. No changelog fragment: this changes parity_checks/ and tests only, which ci/changelog.py does not ask an entry for.

Every path the differential suite compares is built from one loaded model, so
a bug in the loader, the model build or the parser makes them agree on the
wrong answer. Four checks transform the file instead, where the answer is
known to follow, and run it again (_transforms.py; libsbml for SBML, the
.net blocks rewritten as text):

* reorder: species and reactions (and SBML's parameters and species
  references) in reverse. Unchanged by name.
* rename: parameters, observables, functions and species (SBML: species,
  parameters, compartments) to fresh names. Unchanged by the map back.
* split: each reaction into two at half the rate (a parameter rate through
  a new parameter, a function's through a new function, BNG's MM/Sat/Hill
  through their first argument). Unchanged.
* rescale_time: every rate and rate rule times 3 and the window divided by
  3, where the model reads no clock, delay, rateOf, event or reaction rate.
  x'(t) = x(3t).

Each is judged with Base.compare, aligned by name; one that does not keep the
answer for a model is a SKIP that says why. Base keeps its run's species
names: a Result leaves out a parameter an event promotes.

Over the corpus (5,412 rows): 4,413 PASS, 997 SKIP, 0 DIFF, 2 EXCEPTION, both
reorder. BIOMD0000000908 blows up past t = 141 on a reordered sum in both
bngsim and RoadRunner. MODEL1812040005 is a finding: with its species
reversed bngsim lets P go below 0, where a*P^z is NaN, and fails at t = 0.106,
where in the order written it holds P at 0; RoadRunner runs the reversed file
to 3.6e-8 of bngsim's original answer.

Controls: each transform keeps an autonomous SBML model's answer (it has a
rate rule, so the rescale's is exercised), and done wrong on a .net and on
that model it reads DIFF (values left in the old order, two names crossed,
the whole rate on each half, the rates scaled and not the window). Each fix
the first corpus run asked for has a control that fails without it: a rate
rule reading a reaction rate refused (BIOMD0000000556 came out 9x), MM and a
spaced expression in a .net, an id bngsim respells as _ant_<id>.
…onent of a number alone (lanl#1305)

A model's checks share its cap (lanl#1321), and every check added since runs
after main's. The transforms ran in main's loop, ahead of locale: four
more runs that could tip a slow model's locale row (MODEL1006230049, 0053,
0077 already take 689-894 s), which the baseline holds as PASS, to TIMEOUT.

_subst matched names inside numeric literals: with a parameter named e1,
2e1*e1 became 2mt_p1*mt_p1, which does not load.

The branch also merges lanl/main, which it conflicted with (check_all's
deadline, lanl#1321).

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.

differential: metamorphic transforms of the model, the check a shared loader cannot fail together

2 participants