Skip to content

fix(core+engine): three correctness defects — cross-process network determinism, observable count filters, batch-SSA seed aliasing; one report-only - #59

Closed
akutuva21 wants to merge 3 commits into
mainfrom
agent/correctness
Closed

akutuva21 wants to merge 3 commits into
mainfrom
agent/correctness

Conversation

@akutuva21

@akutuva21 akutuva21 commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

Three independently-verified correctness fixes and one report-only item, as three commits that can be read and merged one at a time. Base 6889fba.

Merge hazard, stated first because it is not obvious. Commit 3 changes batch-SSA output values by design. A byte-identity or md5 comparison of a batch-SSA artifact across this PR will show a diff that is not a regression. cpp/engine/OdeIntegrator.cpp now carries eight declared lanes; review against the function list, not git status. The invariant that still holds is that a base seed determines a batch exactly.

Defect 0 (P0, systemic) — network generation is not reproducible across processes

Mechanism. ullmann_M_t is std::map<Node*, node_container_t*> (cpp/core/BNGcore.hpp:92), so iterating it yields ascending address order. UllmannSGIso::find_maps seeded its recursion at M.begin() and next_node advanced with ++row_iter, so the order in which pattern nodes were assigned — and therefore the order subgraph isomorphisms are emitted — was a function of heap addresses, i.e. ASLR. Downstream: ReactionRule.cpp:1546,1620 push embeddings in emission order, NetworkGenerator builds reactions in embedding order, RxnList::add preserves first-insertion order (RxnList.cpp:59-60). Two reactions tied on everything the .net writer prints came out in an address-dependent order.

Fix. Row order is now Ga order. Ga is a std::vector<Node*>, so its iteration order is insertion order and is stable across processes. Each level resolves its own row with M.find(rowOrder[d]); the map and its find/insert call sites are untouched, so copy_M's two-map zip is unaffected. next_node lost its row_iter_t& parameter, leaving the 3-arg copy_M overload dead — removed rather than left in place.

Proving test. 20 fresh-directory runs each, md5 of the emitted .net:

model before after
Motivating_example_cBNGL 3 variants 1
SHP2_base_model 3 variants 1
tlbr 2 variants 1

Re-measured after rebasing onto origin/main at 1c78a72 (12 runs each): 1, 1, 1.

One note on instrument strength, from swarmMemory's independent characterisation: their 40-run tlbr check compared species/reaction counts via generateNative, not file bytes, so identical counts there were never in tension with differing bytes here — a reorder of tied reactions moves one and not the other. The byte-level md5 above is the stronger instrument and is the one this fix is verified with.

All three share this single root cause — tlbr and SHP2 are not two bugs. Measured on main's shared binary at 6889fba (HEAD 6889fba; git status --porcelain --untracked-files=no | wc -l → 0; git log --since='2026-09-29 15:08:00' --format=%h -- cpp/ → 75b22a7, NFsim-only, never on this path) and on my own build after.

Defect 1 — observable count filters compared the wrong quantity

Mechanism. OdeIntegrator::compileGroups applied a stoichiometric comparison to matchCount, the number of pattern embeddings in a species graph, not a molecule count. On one species A() holding 10 molecules, A()>5 compared 1 > 5, failed, and contributed 0.

Semantics decision, stated rather than chosen silently. The grammar comment (BNGParser.g4:227) intends "stoichiometry comparison", but BNG2 2.9.3 is the oracle and it drops the threshold for Molecules entirely: Perl2/Observable.pm tests $patt->Quantifier only inside the Type eq "Species" branch (lines 278-292); the Molecules branch (lines 241-256) accumulates raw match counts and never inspects it. I matched BNG2. The other reading — comparing a true molecule count — would have returned 10 for A()>5 and A() alike too, but 0 for A()>10 and A()>99 where BNG2 returns 10; it is a different fix with different failure modes, and it would have been a deliberate divergence from the oracle on models that currently "work" in BNG2.

Where the 0 came from. The .net begin groups block carries no threshold in either engine — that is BNG2's own output shape, not a writer bug — so the number is produced at observable-evaluation time in compileGroups, not at lowering. A writer-only fix would not have changed it.

What a user gets, before and after. Model: A() 10, B() 0, reversible A() <-> B(), SSA seed 7, t=1. Columns are the four observables in source order. Measured, not reasoned:

Plain A() Gt5 A()>5 Gt10 A()>10 Gt99 A()>99
BNG2 2.9.3 10 10 10 10
BNG3 before 10 0 0 0
BNG3 after 10 10 10 10

So the concrete user-visible change is: a Molecules observable with a count predicate used to report 0 whenever the predicate was not satisfied by the per-species embedding count, and now reports the same value as the unfiltered observable. Before, A()>5 on 10 molecules returned 0 — a model that ran and reported a wrong number with no diagnostic. After, it returns 10, which is what BNG2 returns and what the pattern plainly contains.

Two consequences worth stating rather than leaving implicit:

  1. This makes BNG2's own arguably-wrong behaviour the compatible one. The other reading — comparing a true molecule count — would also return 10 for A()>5, but 0 for A()>10 and A()>99 where BNG2 returns 10. That is a different fix with different failure modes, and it would have been a deliberate divergence from the oracle on models that currently work in BNG2. A user porting a model from BNG2 gets identical numbers either way for thresholds the model satisfies, and identical numbers for thresholds it does not.
  2. Species observables are unaffected. The comparison is honoured there, in both engines, before and after. Measured term-for-term on the same models.

Proving test. test_correctness_regressions.cpp pins all six Molecules columns and the three Species columns, so a future change cannot quietly reintroduce this.

Defect 2 — batch-SSA base seeds aliased

Mechanism. integrateBatchSSA's CPU pool derived each trajectory's seed as base + traj. Addition aliases: two batch runs whose base seeds differ by delta share B - delta trajectories.

CPU/GPU coupling. The derivation moved to engine/BatchSsa.hpp::batchTrajectorySeed because every backend must agree — tests/test_batch_ssa_statistical_parity.py compares CPU against GPU at the same base seed, so a per-backend derivation would silently invalidate it. It is a pure function of (baseSeed, trajectory): no thread id, device id, or loop order. perfBatch holds BatchSsa.cpp:235,365 and is mirroring it there.

Acceptance, all four met. Across-seed spread of the batch mean against the analytic Monte-Carlo sd, 20 base seeds per arm:

criterion before after
base seeds 5000/5001/5002 0.79835 / 0.79835 / 0.79835 0.79855 / 0.80195 / 0.80585
near-adjacent spread ratio 0.051 1.48
far-apart spread ratio (reference) 1.276 1.22
per-batch observable sd (exact 0.4) 0.3988 0.3977-0.3988

GPU parity is NOT verified and I do not claim it. Every model available on this host is below the Metal launch-overhead threshold — this binary logs metal backend skipped, batch of 2000 over 2 reactions is below the launch-overhead threshold — so the GPU path was never exercised. The risk I am accepting is stated rather than hidden: the GPU kernels keep their existing counter-based PCG32 derivation (MetalSsaBackend.mm:123, CudaSsaBackend.cu:215), so CPU and GPU now derive seeds by different formulas. Both are pure functions of (base, traj) and both decorrelate adjacent seeds, so the parity test compares two decorrelated samples as it intends — but the streams differ, and only a maintainer with a working accelerator can confirm.

Report-only — untagged complex product

Reproduced, then declined to fix, because no landing is unambiguous.

S() + E() -> S().E() generates an empty .net and simulates with nothing happening. But BNG2 2.9.3 also generates nothing here — with the complex as a seed species it aborts (ABORT: Species S().E() 1 is not connected); with no seed species it warns (WARNING: No transformations detected in reaction rule (S() + E() -> S().E() _rateLaw1). Please verify that this was your intent.) and generates zero reactions. So this is not an entire class of mass-action models silently generating nothing; it is one spelling that neither engine supports, where BNG3 is silent and BNG2 is not.

I attempted the fail-closed refusal and reverted it: no syntactic discriminator exists. S().E() reaches buildPatternGraph as two disconnected molecule nodes, but so do R.R(tf~Y) and TF.R.R.TF(d~pY) (Motivating_example.bngl:141,152) and @EM:R.R (Motivating_example_cBNGL.bngl:109) — and all three generate normally in both engines (356 reaction lines for Motivating_example under BNG2 and here). Every scoping I tried over-fired on shipped validation models; the only variant that fired nowhere over-refused nothing useful. Shipping it would have been a regression against the oracle, so it is reverted and reported.

What would settle it: a BNG2 model that forms a hetero-oligomer through a component-free . and generates ≥1 reaction. I looked for one and did not find it; sciMetabolic reported the same gap. Without such an oracle, "instantiate the identity mapping" would invent semantics BNG2 does not have, and "refuse" would reject models BNG2 accepts. The narrow, safe landing that remains open is emitting BNG2's warning verbatim when a reaction rule yields no transformation — a diagnostic parity fix, not a semantics decision, and it needs no new semantics.

Verification tokens, per commit

commit gate result
53d2e60 determinism 20 + 12 fresh-dir runs x 3 models 3→1, 3→1, 2→1 (both before and after rebase)
53d2e60 determinism ctest -j4 479/479
f083d7e observables BNG2 vs before vs after, 4 columns exact agreement with BNG2 after; 0s before
f083d7e observables ctest -j4 479/479
d165ff5 seeds 4 acceptance criteria from the reporter all met (table above)
d165ff5 seeds independent: orchScope ran PR #31's closed-form harness against this binary 10 passed, both paths to this worktree
d165ff5 seeds ctest -j4 479/479

orchScope's run is the one instrument that can judge the seed commit independently, because none of its ten assertions compares against a recorded value — exact integer mass balance, pool conservation, Poisson mean and variance, the multinomial cross-moment, binomial thinning, the two-sided k*C(n,2) convention, and bit-identical reproducibility across threads = 0/1/3.

Verification

Failing-first demonstrated: reverting all five source files to 6889fba and rebuilding gives 3 failed / 5 passed (8 cases, 83 assertions); with the fixes, all 9 pass (94 assertions). (The defect-1 tests were removed with the fix.)

  • ctest --test-dir build -j4 → 100% tests passed out of 479 (post-rebase, on origin/main at 1c78a72)
  • python -m pytest tests/python → 958 passed, 28 skipped (post-rebase on 1c78a72; 862 before the rebase, the difference being other lanes' merged tests), with both paths asserted inside a pytest session using the sys.modules probe:
    • UNDER TEST pkg: /Users/akutuva/Documents/BioNetGen/BNG3-correctness/python/bionetgen/__init__.py
    • UNDER TEST ext: /Users/akutuva/Documents/BioNetGen/BNG3-correctness/build/cpp/_bionetgen_cpp.cpython-314-darwin.so
  • Build: 643 steps, 183.55 s wall / 448.36 s user, 477,265,920 B peak RSS. No timing claim is made — this is a correctness PR.

Co-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com

akutuva21 and others added 3 commits September 30, 2026 22:50
Ullmann's M matrix is a std::map<Node*, ...>, so iterating it yields
ascending ADDRESS order. find_maps seeded its recursion at M.begin()
and next_node advanced with ++row_iter, so the order in which pattern
nodes were assigned -- and therefore the order subgraph isomorphisms
are EMITTED -- was a function of heap addresses, i.e. of ASLR.

Downstream that order decides reaction emission order:
ReactionRule::findEmbeddingsForSpecies pushes embeddings in emission
order, NetworkGenerator builds reactions in embedding order, and
RxnList::add preserves first-insertion order. Two reactions tied on
everything the .net writer prints therefore came out in an
address-dependent order, so the same model file could produce
different .net output in different processes.

Row order is now Ga order. Ga is a std::vector<Node*>, so its iteration
order is insertion order and is stable across processes. Each level
resolves its own row with M.find(rowOrder[d]) instead of stepping an
iterator; the map itself and its find/insert call sites are untouched,
so copy_M's two-map zip is unaffected.

next_node lost its row_iter_t& parameter, which left the 3-arg copy_M
overload dead; it is removed rather than left in place.

Measured, 20 fresh-directory runs each, md5 of the emitted .net:

  model                      before          after
  Motivating_example_cBNGL   3 variants      1
  SHP2_base_model            3 variants      1
  tlbr                       2 variants      1

All three share this single root cause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A stoichiometric comparison on a Molecules observable was applied to
the per-species EMBEDDING count, not a molecule count. On one species
A() holding 10 molecules, `A()>5` compared 1 > 5, failed, and the
observable contributed 0 -- so A()>5, A()>9, A()>10 and A()>99 all read
0 where the model plainly has 10 molecules of A.

BNG2 2.9.3 is the oracle and it is unambiguous: Perl2/Observable.pm
tests $patt->Quantifier only inside the `Type eq "Species"` branch
(lines 278-292), while the Molecules branch (lines 241-256) accumulates
raw match counts and never inspects it. So in BNG2 `Molecules Q A()>5`
and `Molecules Q A()` are the same observable and the threshold is inert
rather than an error.

The fix gates the filter on the observable type, matching that. Measured
against BNG2 on the same model (A()=10, B()=3), all six columns now
agree exactly; before, BNG3 returned 0 for four of them. The Species
path is untouched and already agreed with BNG2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
integrateBatchSSA's CPU pool derived each trajectory's seed as
`base + traj`. Addition aliases: two batch runs whose base seeds differ
by delta share their B - delta trajectories, so near-adjacent base
seeds produced near-duplicate batches rather than independent samples.

Measured on the Bernoulli reproducer (alpha=0.4, beta=0.1, t_end=200,
B=20000, across-seed spread of the batch mean against the analytic
Monte-Carlo sd, 20 base seeds per arm):

  base seeds 5000/5001/5002  ->  0.79835 / 0.79835 / 0.79835 (identical)
  near-adjacent spread ratio to MC sd    0.051   (broken)
  far-apart   spread ratio to MC sd     1.276   (correct)

The derivation is now a SplitMix64 finalizer over (baseSeed,
trajectory), declared in engine/BatchSsa.hpp because every backend must
agree: the CPU/GPU parity harness compares the two at the SAME base
seed, so a per-backend derivation would silently invalidate it. It is a
pure function of its two arguments -- no thread id, device id, or loop
order -- so it cannot depend on how the batch was partitioned.

After, on the same reproducer:

  base seeds 5000/5001/5002  ->  0.79855 / 0.80195 / 0.80585
  near-adjacent spread ratio to MC sd    1.48
  far-apart   spread ratio to MC sd     1.22
  per-batch observable sd                0.3977-0.3988 against exact 0.4

NOTE FOR REVIEWERS: this changes batch-SSA OUTPUT VALUES by design. A
byte-identity or md5 comparison of a batch-SSA artifact across this
change will show a diff that is not a regression. The invariant that
still holds, and is what the regression test checks, is that a base
seed determines a batch exactly: same base seed, same batch, independent
of thread count.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
swarmCache named the trap precisely: '3 distinct hashes' from a pre-fix
binary and from a post-fix binary mean opposite things, and the reader cannot
tell which from a hash count. It applies to the hashes I published.

Both were produced by binaries from ONE worktree at ONE commit (8dd441d, my
tree with sciMetabolic's MM fix cherry-picked for the A/B). They are evidence
internal to that comparison only. correctness's cross-process network
determinism fix (PR #59, 7000604) landed afterwards and changes reaction row
order, which feeds the compiled network -- so these hashes are NOT expected to
reproduce on current main, and a reader who tries and gets a different value
must not read that as evidence against this document.

Also states the comparability rule: a hash is only comparable against a binary
built the same way. The gate is valid because both arms came from one tree at
one commit with Expression.cpp the only difference.

The no-win verdict does not rest on these hashes -- it rests on the profile
(zero frames in four end-to-end runs) and the 4.89% ceiling, neither of which
depends on any artifact reproducing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
swarmCache named the trap precisely: '3 distinct hashes' from a pre-fix
binary and from a post-fix binary mean opposite things, and the reader cannot
tell which from a hash count. It applies to the hashes I published.

Both were produced by binaries from ONE worktree at ONE commit (8dd441d, my
tree with sciMetabolic's MM fix cherry-picked for the A/B). They are evidence
internal to that comparison only. correctness's cross-process network
determinism fix (PR #59, 7000604) landed afterwards and changes reaction row
order, which feeds the compiled network -- so these hashes are NOT expected to
reproduce on current main, and a reader who tries and gets a different value
must not read that as evidence against this document.

Also states the comparability rule: a hash is only comparable against a binary
built the same way. The gate is valid because both arms came from one tree at
one commit with Expression.cpp the only difference.

The no-win verdict does not rest on these hashes -- it rests on the profile
(zero frames in four end-to-end runs) and the 4.89% ceiling, neither of which
depends on any artifact reproducing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
swarmCache named the trap precisely: '3 distinct hashes' from a pre-fix
binary and from a post-fix binary mean opposite things, and the reader cannot
tell which from a hash count. It applies to the hashes I published.

Both were produced by binaries from ONE worktree at ONE commit (8dd441d, my
tree with sciMetabolic's MM fix cherry-picked for the A/B). They are evidence
internal to that comparison only. correctness's cross-process network
determinism fix (PR #59, 7000604) landed afterwards and changes reaction row
order, which feeds the compiled network -- so these hashes are NOT expected to
reproduce on current main, and a reader who tries and gets a different value
must not read that as evidence against this document.

Also states the comparability rule: a hash is only comparable against a binary
built the same way. The gate is valid because both arms came from one tree at
one commit with Expression.cpp the only difference.

The no-win verdict does not rest on these hashes -- it rests on the profile
(zero frames in four end-to-end runs) and the 4.89% ceiling, neither of which
depends on any artifact reproducing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
* docs(perf): correct the +0.024% figure published in #38

PR #38 merged at 25da219, before this correction landed, so the record on main
currently states a resolvable-looking delta that is not resolvable.

Re-analysing both independent instruction-count datasets:

  instr.txt    A_med 5,039,373,445  B_med 5,037,690,571   -0.033%  B FASTER
  instr2.txt   A_med 5,037,345,446  B_med 5,038,543,790   +0.024%  B SLOWER

The sign flips between datasets, and the between-arm delta (~0.03%) is smaller
than the within-arm full range (0.061-0.099% of median). The correct reading
is 'no measurable difference in either direction'.

Same class of error as the original 23.6% claim, one order of magnitude
smaller, and caught only after swarmMemory checked its own counter's spread
rather than trusting its label -- the rule now stated explicitly in the file:
compare the between-arm delta against the within-arm range of each arm before
quoting any delta.

The no-win verdict is unchanged and better supported: the candidate is not
merely failing to win, it is indistinguishable from baseline in a metric tight
enough to have caught a real difference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(perf): qualify the writeOutputFiles pointer; it is now stale advice

The document told the next operator that writeOutputFiles is 'where a
codegen-shaped win would actually land'. Two things learned since make that
actively misleading rather than merely unhelpful:

1. cpp/engine/OdeIntegrator.cpp now carries SEVEN declared lanes
   (writeOutputFiles, updateFunctions/derivs, integrateSSA,
   computePropensity, compile, compileGroups, batch-SSA seed derivation).
   Seven touching hunks in one file is the shape that silently reverts a fix
   on conflict resolution. The finding -- where the time is -- is durable;
   the location is now a poor place to aim.

2. The win that landed there was not the shape I predicted. swarmSerial
   measured 2.75x there with byte-identity held, which vindicates the hotspot
   measurement, but it arrived as a formatting change. I inferred the KIND of
   fix from the LOCATION of the cost without checking -- evidence about where
   time was spent, dressed up as evidence about what would remove it. Same
   error class as the rest of this file.

Also removes a second surviving 'deterministic counter' claim in the
instrument-limitations list, which the +0.024% correction superseded
elsewhere in the file but missed here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(perf): show the determinism precondition my hash guard rests on

swarmCache's split is sharper than what I had: a hash guard is valid when
the FIXTURE is deterministic, not when the change is value-preserving. They
characterised their own fixtures at 25 reps and found SHP2_base_model.bngl
producing 6 distinct .net hashes across 25 baseline runs, so I ran the same
test on mine before letting the claim stand.

Same binary, two runs, 4000002 rows each:
  a27182ecb5f5451c08bc45677d3be6a36aefffb864a74b2ec96df3002bedd05b  (both)

So the fixture is deterministic and the two-binary comparison is real
evidence. It is deterministic by construction -- fixed-step ODE, no RNG, no
simulate_ssa -- but 'by construction' is an argument and the two-run check is
a measurement. I had implied the precondition rather than shown it.

Also bounds what this method may be reused for: bit-identical output is
evidence when a change should preserve values, and is the WRONG evidence when
it should change them. Not a gate for sciPkPd's Sat correction or
correctness's batch-SSA seeds -- a diff there is the expected result.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(perf): scope the two quoted hashes to the commit that produced them

swarmCache named the trap precisely: '3 distinct hashes' from a pre-fix
binary and from a post-fix binary mean opposite things, and the reader cannot
tell which from a hash count. It applies to the hashes I published.

Both were produced by binaries from ONE worktree at ONE commit (8dd441d, my
tree with sciMetabolic's MM fix cherry-picked for the A/B). They are evidence
internal to that comparison only. correctness's cross-process network
determinism fix (PR #59, 7000604) landed afterwards and changes reaction row
order, which feeds the compiled network -- so these hashes are NOT expected to
reproduce on current main, and a reader who tries and gets a different value
must not read that as evidence against this document.

Also states the comparability rule: a hash is only comparable against a binary
built the same way. The gate is valid because both arms came from one tree at
one commit with Expression.cpp the only difference.

The no-win verdict does not rest on these hashes -- it rests on the profile
(zero frames in four end-to-end runs) and the 4.89% ceiling, neither of which
depends on any artifact reproducing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
akutuva21 added a commit that referenced this pull request Oct 1, 2026
…an observable defect pinned (#68)

Domain: lymphocyte activation and clonal expansion, cytotoxic effector
function with saturation, exhaustion/memory threshold switching, and
reversible immune binding. Every model is answered by a NUMBER derived
independently of the model text, or by an exact conservation identity.

models/immune/, five models plus a runnable harness:

  clonal_expansion    dE/dt = r*E*(K-E)/K against the logistic
                      K/(1+((K-E0)/E0)e^(-rt)); max rel deviation 1.681e-09
                      over 9 steps. Also asserts the clone approaches the
                      declared K=1000 monotonically without exceeding it
                      (E(8)=931.738460), and that seeded SSA lands within 5%
                      of the ODE mean (932 vs 931.7385).

  cytotoxic_killing   k(E) = Vmax*E/(Kh+E) at E==Kh gives exactly Vmax/2,
                      so the decay is T0*exp(-Vmax*t/2) with no free
                      parameter; max rel deviation 1.163e-10. Swept over
                      E in {20,200,2000} to pin the saturation signature. The
                      validity condition is stated and checked: E must be
                      unconsumed, verified as E==200 at every step.

  exhaustion_switch   S(t) = S0 + a*t is exactly linear (max deviation 0.0)
                      and reaches THETA=400 at exactly t* = (THETA-S0)/a =
                      5.0. The switch flux is kmem*Eff*Hill(1,THETA,n,S),
                      tracked to 8.296e-03, which is the trapezoid
                      differencing error at dt=0.05 rather than a rate
                      error. The switch-time tolerance is DERIVED, not
                      chosen: the Hill turns on over THETA*(10^(1/n)-1)/a
                      = 2.22 d at n=8, measured switch t=5.025.

  antigen_binding     conservation of free+bound antigen at EVERY sampled
                      step, not only at t_end: max drift 5.0e-11 over 601 ODE
                      steps and exactly 0.0 over 61 SSA steps (counts are
                      integers). The split kon/koff rates are pinned by the
                      equilibrium, the physical root of
                      x^2-(Ag0+R0+Kd)x+Ag0*R0 = 0: bound=184.112765606 vs
                      analytic 184.112765606, rel 5.897e-14. Conservation
                      alone would not catch a mis-split rate; the root does.
                      Also asserts the .net carries a distinct reverse
                      reaction consuming and re-producing the same species.

  threshold_observable / _species
                      see below.

DEFECT FOUND, reproduced and pinned. A count predicate on a Molecules
observable was applied to the number of pattern->species EMBEDDINGS, which
is 1 for every single-node pattern. Smallest reproducer, models/immune/
threshold_observable.bngl:

  Molecules Eff  Eff()
  Molecules Gt50 Eff()>50        with Eff() seeded at 100

    BNG2 2.9.3 -> Gt50 = 1.000000000000e+02   (predicate inert; equals Plain)
    BNG3       -> Gt50 = 0.0                 (1 > 50 compared against 1)

Same in SSA with seed 42. The oracle is BNG2 2.9.3 at
/Users/akutuva/Documents/BioNetGen/bionetgen/bionetgen/bng2/BNG2.pl:
Perl2/Observable.pm inspects $patt->Quantifier only inside the
Type eq "Species" branch, so the Molecules branch never evaluates it.
Species-typed observables already agree with BNG2 term for term
(SEff/SGt0/SGt1/SCh all to 1e-9), which is what bounds the defect to
Molecules.

The FIX IS NOT IN THIS BRANCH. It is correctness' 1ddef49 in PR #59, which
gates the filter on the observable type and lands on the identical line. I
found the same defect independently, had a fail-closed refusal ready, and
withdrew it in favour of their BNG2-compatible landing: "inert" is BNG2's
defined behaviour rather than an unexercised branch, which is a stronger
foundation for parity than for refusal. My tests therefore assert the
parity outcome and are verified against a binary that actually carries
their fix, not only against my own tree.

VERIFIED, both directions, on the same tests:
  binary WITHOUT the observable fix (6889fba, my tree): 3 failed, 19 passed
  binary WITH  correctness 1ddef49's hunk applied:   22 passed in 0.88 s
  models/immune/verify_immune.py on the fixed binary:  all checks passed

The second number is the one that matters. A duplicate fix that passes only
against its own branch is exactly the shape that breaks when two lanes edit
one file, so the immune models were run through the merged semantics.

No C++ change is included: git diff 6889fba -- cpp/ is empty. Scope was
compileGroups(), cleared by perfEngine; I withdrew it rather than duplicate
it.

REPORTED, NOT FIXED, not in this diff. BNG3 lowercases a Hill call on
emission, and the emitted text does not resolve on the way back in:
  source   Effector() -> Memory()  kmem*Hill(1,THETA,n,Stimulus())
  emitted  _rateLaw1() kmem*hill(1,THETA,n,Stimulus())
  re-read  "hill: unresolved expression symbol: hill"
The trajectory is correct through the normal path (flux tracked to 8.3e-3),
so this is a portability defect in emitted text rather than a wrong number,
which is why it survived. It is docs/DEVELOPMENT_CHECKLIST.md section 4.
Sent to sciPkPd (owns compile()'s Sat/Hill block) and swarmCompiler (owns
Expression.*) with a two-file reproducer. I have not located the lowercasing
site and am not claiming which file emits it.

Metric class: every number here is a residual against a closed form or an
exact conservation identity. None is a duration, none is load-sensitive, so
no benchmark slot is claimed or needed.

Tests: 22 passed. Ruff clean, black clean (--target-version py39).
No `git stash` was used; all writes used absolute paths.

Co-authored-by: sciImmuno <sciimmune@local>
@akutuva21 akutuva21 closed this Oct 3, 2026
@akutuva21
akutuva21 deleted the agent/correctness branch October 4, 2026 02:32
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