Skip to content

codegen: write C for what ExprTk means, or decline before compiling (#1212) - #1348

Merged
elyfmiller merged 7 commits into
lanl:mainfrom
Montana:fix/1212-codegen-exprtk-only
Oct 30, 2026
Merged

elyfmiller merged 7 commits into
lanl:mainfrom
Montana:fix/1212-codegen-exprtk-only

Conversation

@Montana

@Montana Montana commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #1212.

What was wrong

The C emitter (python/bngsim/_codegen.py) copied into the generated C parts of a rate law that C has no meaning for:

  • ExprTk functions with no C counterpart:
    • in the issue: frac, inrange, iclamp, roundn, root, logn, sinc, equal, cot, sec, csc, ncdf, deg2rad, rad2deg;
    • also from exprtk.hpp's base_function_list: not_equal, deg2grad, grad2deg, mul, mand, mor.
  • Table reads: a table read as tfun_kf, tfun_kf () or tfun_kf\t(). The old pass matched only tfun_kf().
  • Dotted names: a name with a dot (k.f), which ExprTk's lexer reads as one name.
  • Implicit products: for example 2tfun_kf()/2 and 2k.f/2, emitted as 2.0tfun_kf.
  • Physical constants: five of the seven (_R, _NA, _kB, _h, _F). Only _pi and _e were mapped, so k*_R was emitted as _R.

With cc, the compile failed. Automatic codegen fell back, but only after paying for the compile. codegen=True refused with the compiler's error. The MIR JIT refused by name, so under automatic codegen the run failed where cc fell back.

Two functions compiled but gave a different answer. C has hypot and log2, but not ExprTk's:

  • ExprTk's hypot is the unscaled sqrt(a*a + b*b). The interpreter gives hypot(1e200, 1e200) = inf; C's gives 1.4e200.
  • ExprTk's log2 is log(v)/ln 2. It differed from C's log2 in the last place at 82 of 300 random values.

One more problem was found while testing. _topological_function_order found a table read only as tfun_<table>(. A function reading the table by any other spelling could therefore be emitted before the function its index is, and would read func[] unset. The cc source didn't compile in those cases, so this never ran, but it would have once the spelling compiled.

The fix

C for each function. Each is transcribed from its *_impl in exprtk.hpp, or from the trinary node for inrange/iclamp. Both sides build with -ffp-contract=off, so the C computes the interpreter's double bit for bit.

  • Where an argument is read more than once or a table is needed, it is a bngsim_ helper in a new prelude block, _EXPRTK_FUNCTION_C_LINES: frac, inrange, iclamp, roundn, root, sinc, equal, not_equal, hypot.
  • The rest are one expression, written in place by _c_engine_call: logn, log2, cot, sec, csc, ncdf, the four angle conversions, and the variadic mul/mand/mor.
  • ExprTk's constants (log2, sqrt2, pi_180, _180_pi) are spelled with the same digits as in exprtk.hpp, so both compilers round them to the same double.

The names.

  • _insert_implicit_products writes out the * that ExprTk's commutative_inserter reads: number–name, number–(, name–number, )–number, )–name, and name–name. ExprTk's reserved words (and, or, if…) are never operands.
  • The identifier pass reads a dotted name as one name. A dotted name the model doesn't have is read by its parts, as before.
  • tfun_<table> goes into the lookup, so every spelling resolves to data->tfun_eval(...). This replaces _embedded_tfun_resolver, which rewrote tfun_X() in the translated text afterwards.
  • _topological_function_order reads the same names: dotted names, and every spelling of a table read.
  • _R, _NA, _kB, _h and _F are written as their repr(), the same double. test_builtin_constants.py already pins those values to the C++ ones.

The decline. A name the translator leaves unresolved raises CodegenDeclined, naming it, while the expression is still in hand. Allowed through are:

  • a <math.h> call whose ExprTk function of the same name is the same call (_C_CALLS_AS_WRITTEN);
  • a helper the prelude defines (_PRELUDE_HELPERS, read off the prelude);
  • the exponent of a number.

prepare_model_codegen and prepare_model_codegen_source record this as a decline, not a failure. No compiler ever sees the source, and both backends fall back the same way. What still declines is ExprTk's word operators (xor, nand, …), which nothing translates.

I chose the allowlist from measurement. I logged every unknown name the translator passed through, across a full suite run with forced codegen (16,536 tests) and the 1,500-model corpus. Every call left on the list is a <math.h> name of the same meaning, or one the fix now translates.

 # _translate_expr_to_c
+    result = _insert_implicit_products(expr)
     result = _normalize_exprtk_operators(result)
     ...
         entry = lookup.get(name)
         if entry is None:
-            return m.group(0)
+            if "." in name:
+                return _IDENT_PART_OR_EMPTY_CALL_RE.sub(lambda pm: _repl(pm, start), m.group(0))
+            if _in_a_number(start):
+                return m.group(0)  # the exponent of a number: `e3` in `1.5e3`
+            if (empty_call is None and (name in _C_CALLS_AS_WRITTEN or name in _PRELUDE_HELPERS)
+                    and _CALL_AHEAD_RE.match(text, base + m.end())):
+                return m.group(0)
+            raise CodegenDeclined(f"codegen has no C for {name!r} in the expression ... (issue #1212)")

 # _emit_function_lines
-    embedded_tfun = _embedded_tfun_resolver(tfun_call_by_name)
+    for table, (tf_id, idx_c) in tfun_call_by_name.items():
+        lookup.setdefault(f"tfun_{table}", (f"data->tfun_eval({tf_id}, {idx_c}, data->tfun_ctx)", True))

Tests

python/tests/test_codegen_exprtk_functions.py (153 tests). The oracle is the interpreter.

  • Bit for bit: each function goes through _expr_to_c, compiled with the shipped prelude, against compute_derivs, compared as doubles (NaN = NaN, sign of zero counts). The rows hit each branch: both sides of trunc, range edges, iclamp's midpoint, half-way roundn with out-of-range digit counts, root of a negative with odd, even and fractional n, sinc around DBL_EPSILON, equal on both sides of its 1e-10 scale, NaN where comparisons decide.
  • Negative control that hypot and log2 need transcribing: C's own versions disagree with ExprTk's at one of those rows.
  • Coverage: every name in exprtk.hpp's base_function_list is handled, except like and swap, which aren't numeric.
  • Whole models: for each function and constant, a .net run under codegen=True (cc), and under MIR when built, against codegen=False.
  • Implicit products and names: the rewrite itself, text it must leave alone, and compiled against the interpreter.
  • Tables: all ten spellings of a table read are emitted after the table's index and run compiled as the canonical call runs interpreted. The fixture declares h first, so only the dependency edge puts it after.
  • Decline: xor/nand decline before compile_rhs is reached (it is monkeypatched to raise). They are recorded as a decline with no error, codegen=True refuses by name, and the interpreter runs the model.

test_codegen_jit_prelude.py::TestACallThePreludeDoesNotDeclareIsRefused used equal/frac as its bare-call fixtures, as the issue anticipated. It now pins MirJit's refusal on a hand-built source: the source generated for a law calling exp, with the call renamed. The source is handed to the JIT on the model. A new test asserts the emitter itself now writes bngsim_equal/bngsim_frac.

The new file is added to the mir.yml and windows-tail.yml run lists.

Against main (9a26f5b), 116 of the 153 new tests fail, each for the reason above: the compiler's error, MIR's refusal by name, or the missing helper.

Mutations: 17 of 18 are caught. Each was a one-line change to the fix:

  • each transcription's comparison, constant or branch;
  • the hypot/log2 libm swap;
  • mand to or;
  • each implicit-product rule and the reserved words;
  • the number-exponent guard;
  • the table lookup and the table edge in the order;
  • dotted names;
  • the constants;
  • the decline handler.

The one survivor, >= to > in sinc's guard, is equivalent: at v = DBL_EPSILON, sin(v)/v is exactly 1.0. Dropping the guard altogether is caught.

Measured

main this branch
The issue's five files under BNGSIM_CODEGEN_JIT=mir BNGSIM_CODEGEN_THRESHOLD=1 38 failed / 633 0 failed / 633
The same five under forced cc 633 passed 633 passed
Full suite under forced MIR (#1217) 97 failed 54 failed, none new
Full suite, default configuration — 16,549 passed; 1 failure, test_a_relative_interpreter_stays_in_its_venv, the known /tmp venv artifact on macOS

Corpus. Of 1,500 corpus files (1,323 BioModels SBML, 177 .net), 1,468 load. Their generated sources number 2,936, plain and sensitivity. Each is byte-identical to main's outside the prelude, and no model newly declines.

Noticed, not changed

  • Interpreter reads a table one evaluation late. In a model built through ModelBuilder, a function that reads a table-backed function by its name, declared before that function or its index, gets the table one evaluation late: compute_derivs at A = 1.5, 0.5, 1.5 returns 0, 7, 3 for 7, 3, 7. A .net holding the same functions in the same order is evaluated right. The new table test compares against the canonical spelling for that reason. I can file it separately.

  • Sensitivity source depends on earlier models. Main's sensitivity-RHS source for BIOMD0000000469, 472 and 473 depends on which models were emitted earlier in the same process. Fresh processes agree, on main and here. This is unrelated to this change.

  • MSVC's interpreter uses ExprTk's pre-C++11 branch. MSVC reports __cplusplus as 199711L, and the build has no /Zc:__cplusplus, so ExprTk built there takes its pre-C++11 branch:

    • trunc is (long long)v, undefined past 2^63; the Windows interpreter gives frac(1e300) = 1e300;
    • asinh, acosh, atanh, expm1 and log1p are log formulas, not the std:: functions.

    The bit-for-bit rows stay inside long long range, where both builds agree. Codegen's pass-through of those five to C was already as it is on Windows before this PR.

…anl#1212)

The C emitter copied parts of a rate law into the generated C that C has
no meaning for: ExprTk's frac, inrange, iclamp, roundn, root, logn, sinc,
equal, not_equal, cot, sec, csc, ncdf, deg2rad, rad2deg, deg2grad,
grad2deg, mul, mand and mor; a table read as `tfun_kf`, `tfun_kf ()` or
`2tfun_kf()`; a name with a dot (`k.f`); and the physical constants _R,
_NA, _kB, _h and _F. cc refused the source after automatic codegen had
paid for the compile; the MIR JIT refused it, so automatic codegen fell
over where cc fell back.

- Each function is C transcribed from its *_impl in exprtk.hpp (helpers
  in the prelude where an argument is read twice or a table is needed),
  bit for bit against the interpreter. hypot and log2 are transcribed
  too: C's differ from ExprTk's (hypot(1e200, 1e200) is inf to the
  engine; log2 differs in the last place at 82 of 300 random values).
- ExprTk's implicit products get their `*`; names may hold a dot; a table
  is read through the lookup by any spelling, and the function order
  reads the same names, so a reader of a table is emitted after its
  index (it could come first and read it unset).
- Any name still without C declines with a named reason while the
  expression is in hand, before any compiler sees it; prepare_* record it
  as a decline, not a failure.

Forced MIR (BNGSIM_CODEGEN_THRESHOLD=1): 97 failures on main, 54 here,
none new; the issue's five files go from 38 failures to 0. The corpus
(1,468 models, plain and sensitivity sources) emits byte-identical C
outside the prelude.
…kip (lanl#1212)

MSVC reports __cplusplus as 199711L, so ExprTk built there takes its
pre-C++11 trunc, (long long)v, which is undefined past 2**63: the Windows
interpreter gave frac(1e300) = 1e300. The rows stay where both builds
agree. The MIR whole-model tests become one test, so a build without MIR
skips once rather than 27 times (the suite's skip ceiling). Rename a
local mypy read as the later names string.
@elyfmiller

Copy link
Copy Markdown
Collaborator

Thanks. We reviewed this independently, Opus 5.5 and Fable 5.1, each on our own build of 00a4321 merged onto main c0877fa (clean), on macOS arm64 with MIR on. We found no correctness defect in what it translates:

  • every transcription matches exprtk.hpp;
  • the implicit-product pairs are commutative_inserter's;
  • the generated C is unchanged outside the prelude.

Two cases of #1212's own class still reach the compiler, both on main already. Three low items follow. What we checked comes first, so it needn't be redone.

What we checked

check result
Transcriptions, read beside the vendored exprtk.hpp line for line: frac (std::trunc), roundn (pow10_size − 1, clamped after the cast), root, sinc (DBL_EPSILON), equal/not_equal (scale × 1e-10), hypot (unscaled), logn, log2, cot/sec/csc, ncdf = 0.5·erfc(−(v/√2)), d2r/r2d, d2g/g2d = v·(10.0/9.0) and v·(9.0/10.0), inrange/iclamp from the trinary node (NaN included), mand/mor. The four constants have the same digits.
Implicit products the 12 pairs are exactly commutative_inserter's 12 rules. The parser's ignore set is reserved_words, case-insensitive, as the PR models it.
The allowlist against the engine bngsim registers ln, mratio, rint, sign, tgamma, time, and round as std::round with ExprTk's builtin disabled. Each is mapped, rewritten or allowed. The engine-call rewrite writes only floor, fmod, bngsim_max/min and arithmetic, so the decline never fires on the translator's own output.
Where a decline goes the five _emit_function_lines call sites have no wrapping try, so a decline reaches prepare_model_codegen/_source and is recorded as one. The RHS is emitted first.
The new test file on main 127 collected: 92 failed, 34 passed, 1 skipped (a MIR-only case). With this PR: 127 passed under cc and 127 under BNGSIM_CODEGEN_JIT=mir. The prelude tests, run-list coverage and skip audit: 81 passed.
The issue's five files under BNGSIM_CODEGEN_JIT=mir BNGSIM_CODEGEN_THRESHOLD=1 main: 38 failed, 595 passed. This PR: 633 passed, as under forced cc. Matches the body.
mir.yml's list (37 files) under forced MIR 1,486 passed
Full python/tests default, BNGSIM_SKIP_AUDIT=strict: 16,516 passed, 0 failed. Under forced MIR: 53 failed, 16,466 passed, no crashed worker. The 53 are exactly #1350's 15-file set; none is a translation failure.
Generated C against main's, prelude cut 1,688 local models (BioModels SBML and .net): 1,685 identical, 0 different, and 3 with no source on either (the existing cyclic-function decline). No model newly declines.
roundn with n = 1e10, −1e10, NaN, 20, 2.6 the interpreter, cc and MIR agree. The cast saturates the same way on both sides on arm64.
Mutations hypot via libm: 1 fails (its bit row). Reserved words as operands: 3 fail. The number-exponent guard dropped: 5 fail (the constants then decline).
Gates changelog fragment 786 characters and well-formed; ruff and mypy 1.13.0 clean

1. Implicit products the engine reads beyond the inserter's table

The interpreter also multiplies three shapes that commutative_inserter's table doesn't list:

  • a bracket after a bracket;
  • a bracket after a variable;
  • 2. before a name.

The rewrite leaves those shapes as written. No name is unresolved, so the new decline doesn't fire, and the compiler gets them. A .net function f() (<expr>)*Sobs with A = 3, B = 5:

expression interpreter emitted C codegen=True
(A)(B) 15 ((p[0])(p[1])) cc: the build fails; MIR: c2mir fails ("called object is not a function")
A(B) 15 (p[0](p[1])) the build fails
A(B+1) 18 (p[0](p[1]+1.0)) the build fails
2.A 6 (2.p[0]) cc: the build fails; MIR: c2mir fails ("wrong number: 2.p")
2A, (A)B (in the table) 6, 15 2.0*p[0], (p[0])*p[1] right

These are on main too, so they aren't a regression. But they're #1212's failure class left standing. Under automatic codegen, cc pays a failed compile before falling back, and the MIR JIT refuses outright.

We'd suggest:

  • adding )→( to the pairs;
  • inserting * where a name the lookup knows isn't callable is followed by (;
  • widening the hint to \d\.?\s*[A-Za-z_(\[{] and [)\]}]\s*\(.

Or at least decline those shapes. 37 other adversarial spellings we tried agree bit for bit, and 7 the engine itself rejects.

2. A model name inside a number literal

The new _in_a_number guard is consulted only when lookup.get(name) misses. A name that is also the exponent letter of a literal is still replaced. A .net function f() 2e-3*e*Xobs, with a parameter named e, is emitted on main and here as:

func[0] = 2p[0]-3*p[0]*obs[0];  /* f */

codegen=True then raises "the codegen build failed" (clang: invalid suffix 'p' on integer constant), and automatic codegen falls back after paying for the compile. Each of us reproduced this.

The controls come out right:

  • 2E-3*k*Xobs and k*e*Xobs;
  • 1.5e3*e*Xobs, whose name match is e3, which misses the lookup and so reaches the guard. Checking _in_a_number(base + m.start()) before the lookup, and returning the text unchanged, fixes it. Only .net text reaches it: the Antimony and SBML paths canonicalize 2e-3 to 0.002 first.

Low

  1. The body's counts. The new file collects 127 tests, not 153. Against main without MIR, 92 fail rather than "116 of the 153". The second commit probably changed the count; the table should give the head's numbers, and say whether the "fails on main" build had MIR.
  2. A comment. The pair table is introduced as "the token pairs ExprTk multiplies". That's the inserter's set, not the engine's (see 1).
  3. A tolerance. The trajectory comparisons at rtol 1e-9 (the runs are at 1e-10) carry no stated reason.

Not checked: Windows/MSVC (ExprTk's pre-C++11 branch there, which the body notes) and Linux beyond CI; the sensitivity half of the body's 2,936 sources (we compared the 1,688 plain ones); perf_ab (the change is emit-time Python only).

elyfmiller and others added 4 commits October 30, 2026 01:10
…nt's letter in its number (lanl#1212)

Review items on lanl#1348:

- A variable, or a bracketed group, before an opening bracket is a product
  to ExprTk's parser (post_variable_process and post_bracket_process in
  exprtk.hpp): `k(x + 1)`, `g()(x)`, `(a)(b)`. _insert_implicit_products
  now writes the `*` there, knowing from the lookup which names are
  variables, and writes square and curly brackets as round ones. After a
  call, a bracket opens a second statement, whose value the engine gives
  for the whole expression (`k*exp(-x)(y)` is y). There is no C for that,
  and it declines. The hint now lets `2.A` and `2.(A)` reach the tokenizer.
- The number guard runs before the lookup, so a parameter named `e` is no
  longer written into the exponent of `2e-3`.
- The pair table's comment says the lexer multiplies those pairs and the
  parser two more.
- The trajectory comparisons give the reason for rtol 1e-9.
…iteral (lanl#1212)

The comment over the exponent-letter rows said only a .net reaches the
translator with a literal such as 2e-3. The SBML loader writes a number as
repr(v), which is e-notation below 1e-4 and from 1e16, and BIOMD0000001042
has <cn type="e-notation">1<sep/>-10</cn> beside a parameter e: main wrote
`1p[7]-10`, and its codegen build failed. A model of the same law through
Model.from_sbml_string now runs compiled as it runs interpreted.
@elyfmiller

Copy link
Copy Markdown
Collaborator

Following up on our review above: we pushed items 1, 2, 4 and 5 as two commits on top, aefcd28 and c091905. You then merged main and added a0fc972. We merged main again (98224d2) to bring in #1413, whose macos-15 checks are now required, and we're merging now that CI is green on it. #1413 changes no code under test, only runners and two comment lines in the tests, so the runs below on main 49c2931 + a0fc972 stand for it. The transcriptions, the decline and its plumbing stand as you wrote them. Item 3 is below.

What the engine does, measured

Item 1 rests on what ExprTk's parser does with a bracket, so we measured it with compute_derivs before writing anything (A = 3, B = 5, k = 0.5, function g = A + 1):

  • After a variable, it's a product (post_variable_process): A(B), k(B), _pi(B) and g(B) are products, because the engine registers a model's functions, observables, parameters and constants as variables. So is g()(B), since strip_empty_parens drops the ().

  • After a bracketed group, it's a product too (post_bracket_process): (A)(B), (A)[B](C). Both products take the precedence of a written *: A(B)^2 is 75 and 2/A(B) is 10/3.

  • After a call, it isn't a product. The bracket opens a second statement, and the engine returns that statement's value for the whole expression:

    • sin(A)(B) and k*exp(-A)(B) are both 5;
    • 2*abs(A)(B) + 1 is 6;
    • time()(B), if(…)(B), not(A > 1)(B) and 2*tfun_kf()(Atot) do the same.

    Inside a group the engine refuses it. So a )→( pair in the lexer's table would have been silently wrong after a call.

  • Square and curly brackets group like round ones. [A + B]*k is 4, but the translator copied them into the C.

What the commits change

  • _codegen.py, item 1. _insert_implicit_products takes the lookup:
    • It writes the * after a variable and after a group, and writes [ ] and { } as ( ).
    • A variable is a name whose lookup entry eats (), other than time and a table, which are calls.
    • It tracks whether each open bracket belongs to a call. After a call's bracket, an opening bracket declines with CodegenDeclined, naming the call, before any compiler sees it.
    • The hint now matches 2.A, 2.(A), a bracket after a bracket, and a variable before one.
  • _codegen.py, item 2. _in_a_number runs before the lookup, so a parameter e beside 2e-3 stays out of the exponent. It looks only at a name right after a digit or a dot.
  • Item 4. The pair table's comment says it is the lexer's table, and names the parser's two products.
  • The tests:
    • products against the interpreter, bit for bit (a0fc972 holds the three that call libm to 4 ulp; see below): 26 new rows, including every shape in our review, A()(B), sin(A(B)), the precedence cases, the brackets, and (A > 1) and (B)(C);
    • 9 controls that stay calls or numbers, with the literals 2.e3, 2.e-3A, 1.e5, .5e2A and 3.E+2 read as numbers;
    • unit tests with a lookup: variable, call, decline, and a table;
    • 11 rows for item 2 under a parameter e or E, the .net law k*(1 + 2e-3*e*Atot) end to end under cc and MIR, and an SBML law with BIOMD0000001042's <cn type="e-notation">1<sep/>-10</cn> beside a parameter e (c091905);
    • 7 whole-model laws in _LAWS (cc and MIR), with a function g added to _NET;
    • a third _NO_C entry, k*exp(-Atot)(Atot).
  • Item 5. A comment at the runs gives the reason for rtol=1e-9, atol=1e-12. We measured the gap they allow for: on macOS arm64, every law's compiled and interpreted trajectories agree bit for bit, under cc and MIR (34 of 34 laws). The bounds are for a platform whose compiled law rounds a bit apart.
  • The changelog fragment names the new shapes and the decline (976 characters).

a0fc972: a libm call held to 4 ulp

Our c091905 failed both Windows legs (MIR codegen and the non-NFsim tail) on one row, the control sin (A). On Windows, the C library's sin(2.5) is an ulp off the engine's. a0fc972 holds the three product rows that call libm (sin(A(B)), exp(A)B and sin (A)) to 4 ulp, and adds a control. We agree with it. A misparse of any of the three is a compile error or exp(A*B), far outside 4 ulp. The per-function rows and the whole-model laws keep their own bounds, and they passed on Windows.

What we checked

check result
The test file on 00a4321's codegen (the installed package's _codegen.py swapped, so the child runs see it too) 68 failed, 146 passed at a0fc972, the same set under cc and under BNGSIM_CODEGEN_JIT=mir (a0fc972's control passes either way). At c091905 it was the same 68, and the same again with the file dropped into main + 00a4321's tree. 21 of them are the three unit tests with a lookup, which fail only on the new argument. The other 47: 24 of the 26 new products (2. A and A^2(B) were already right), the 5 written-out rows, 4 item 2 rows, the .net law under cc and MIR, the SBML law, the 7 new laws and their MIR twin, the table's decline, and the exp decline's two _NO_C tests.
The file on main 49c2931 + a0fc972, with test_codegen_jit_prelude.py 238 passed under cc, 238 under MIR
Mutations of aefcd28, each against a0fc972's file 10 of 10 caught. No group product: 17 fail. No variable product: 25. Number guard dropped: 20. A call's bracket not declined: 9. time as a variable: 1. A table as a variable: 1. and/or as calls: 1. No [ ] rewrite: 10. Hint without 2.: 6. No empty call: 4.
Full python/tests, BNGSIM_SKIP_AUDIT=strict On main 49c2931 + a0fc972, two builds. With a complete venv: 17,155 passed, 143 skipped, 1 failed. The failure is test_power_edge_without_a_case.py::test_sir_v5_with_a_singular_exponent_is_refused[dt_2023]: its sensitivity source came back None once under -n 6, when the wall-clock derivation budget ran out. It passes alone and under -n 6 on reruns, on this merge and on main, and this PR doesn't touch it. The other build: 16,719 passed, 433 skipped, that test among the passes. Its one failure is test_run_tests_sh's walk-up probe, which needs jax and diffrax, and its audit flags only pybind11; that venv lacks all three.
Generated C of the 1,688 local models, 00a4321's against the commits', prelude cut One source changes, BIOMD0000001042, which now builds (below)
Gates ruff 0.14.0 check and format, mypy 1.13.0 (the hook's arguments), ci/changelog.py check, git diff --check: clean
Overlap Trial merges of the other 32 open PRs onto main 6d4db53 + 98224d2: all clean, and none touches this PR's files
CI 20 checks, all green, on 98224d2: the same checks as on a0fc972, with the arm64 macOS legs now on macos-15. The two Windows legs that failed on c091905 pass.

BIOMD0000001042, and a sentence of ours

Our review said only .net text reaches item 2, because the Antimony and SBML loaders write 2e-3 as 0.002. That was wrong. The SBML loader writes a number as repr(v), which is e-notation below 1e-4 and from 1e16.

BIOMD0000001042 has <cn type="e-notation">1<sep/>-10</cn> beside a parameter e. Main and 00a4321 write (obs[0]+1p[7]-10) in its function D, and its codegen build fails. It is the only codegen build in parity_checks/nightly/baselines/differential.json that a compiler refuses. BIOMD0000000385's times out instead. With the commits it builds under cc and MIR, and matches the interpreter exactly.

We ran the harness's own checks on the nightly's window. Four of its baseline rows move once this merges:

  • backend_as_asked and cc_vs_interp, EXCEPTION to PASS;
  • sens_runs, UNSUPPORTED to PASS on the analytic sensitivity RHS;
  • sens_vs_plain, SKIP to PASS (8.5e-11 of the trajectory's peak).

These are expected, not alerts.

Item 3: the body's counts

At 00a4321 the file collects 127 tests. Dropped into main's tree, 93 fail and 34 pass. Our review's 92 came from a copy outside python/tests, where test_the_list_covers_every_exprtk_function_c_does_not_have skips for want of exprtk.hpp. At a0fc972, it collects 214.

Not changed here: the engine's own reading of a bracket after a call drops the call without a word. That's #1415.

@elyfmiller
elyfmiller merged commit f1f66f3 into lanl:main Oct 30, 2026
20 checks passed
wshlavacek pushed a commit that referenced this pull request Oct 31, 2026
…nal (#1217)

Run with every model forced through the MIR JIT
(BNGSIM_CODEGEN_JIT=mir BNGSIM_CODEGEN_THRESHOLD=1), 53 tests fail on main
with #1348's codegen fix in. 39 test the cc backend's own artifact or name.
12 more fail under a forced threshold alone, because they need a small model
to run interpreted. Each now carries a skipif from _codegen_backend.py, with
a reason conftest declares.

Three expose engine-dependent answers, filed separately. They get strict
xfails under a lowered threshold, not skips.
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.

codegen copies ExprTk functions, table calls and implicit products it has no C for into the source: cc pays a failed compile, the MIR JIT refuses

2 participants