Repository navigation
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
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.cppnow carries eight declared lanes; review against the function list, notgit 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_tisstd::map<Node*, node_container_t*>(cpp/core/BNGcore.hpp:92), so iterating it yields ascending address order.UllmannSGIso::find_mapsseeded its recursion atM.begin()andnext_nodeadvanced 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,1620push embeddings in emission order,NetworkGeneratorbuilds reactions in embedding order,RxnList::addpreserves first-insertion order (RxnList.cpp:59-60). Two reactions tied on everything the.netwriter prints came out in an address-dependent order.Fix. Row order is now
Gaorder.Gais astd::vector<Node*>, so its iteration order is insertion order and is stable across processes. Each level resolves its own row withM.find(rowOrder[d]); the map and itsfind/insertcall sites are untouched, socopy_M's two-map zip is unaffected.next_nodelost itsrow_iter_t¶meter, leaving the 3-argcopy_Moverload dead — removed rather than left in place.Proving test. 20 fresh-directory runs each, md5 of the emitted
.net:Motivating_example_cBNGLSHP2_base_modeltlbrRe-measured after rebasing onto
origin/mainat1c78a72(12 runs each): 1, 1, 1.One note on instrument strength, from
swarmMemory's independent characterisation: their 40-runtlbrcheck compared species/reaction counts viagenerateNative, 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 —
tlbrandSHP2are not two bugs. Measured on main's shared binary at6889fba(HEAD6889fba;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::compileGroupsapplied a stoichiometric comparison tomatchCount, the number of pattern embeddings in a species graph, not a molecule count. On one speciesA()holding 10 molecules,A()>5compared1 > 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 forMoleculesentirely:Perl2/Observable.pmtests$patt->Quantifieronly inside theType eq "Species"branch (lines 278-292); theMoleculesbranch (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 forA()>5andA()alike too, but 0 forA()>10andA()>99where 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
.netbegin groupsblock 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 incompileGroups, not at lowering. A writer-only fix would not have changed it.What a user gets, before and after. Model:
A() 10,B() 0, reversibleA() <-> B(), SSA seed 7, t=1. Columns are the four observables in source order. Measured, not reasoned:Plain A()Gt5 A()>5Gt10 A()>10Gt99 A()>99So the concrete user-visible change is: a
Moleculesobservable 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()>5on 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:
A()>5, but 0 forA()>10andA()>99where 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.Speciesobservables 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.cpppins all sixMoleculescolumns and the threeSpeciescolumns, 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 asbase + traj. Addition aliases: two batch runs whose base seeds differ by delta shareB - deltatrajectories.CPU/GPU coupling. The derivation moved to
engine/BatchSsa.hpp::batchTrajectorySeedbecause every backend must agree —tests/test_batch_ssa_statistical_parity.pycompares 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.perfBatchholdsBatchSsa.cpp:235,365and 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:
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.netand 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()reachesbuildPatternGraphas two disconnected molecule nodes, but so doR.R(tf~Y)andTF.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 forMotivating_exampleunder 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;sciMetabolicreported 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
53d2e60determinism53d2e60determinismctest -j4f083d7eobservablesf083d7eobservablesctest -j4d165ff5seedsd165ff5seedsorchScoperan PR #31's closed-form harness against this binaryd165ff5seedsctest -j4orchScope'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-sidedk*C(n,2)convention, and bit-identical reproducibility acrossthreads = 0/1/3.Verification
Failing-first demonstrated: reverting all five source files to
6889fbaand 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, onorigin/mainat1c78a72)python -m pytest tests/python→ 958 passed, 28 skipped (post-rebase on1c78a72; 862 before the rebase, the difference being other lanes' merged tests), with both paths asserted inside a pytest session using thesys.modulesprobe:UNDER TEST pkg: /Users/akutuva/Documents/BioNetGen/BNG3-correctness/python/bionetgen/__init__.pyUNDER TEST ext: /Users/akutuva/Documents/BioNetGen/BNG3-correctness/build/cpp/_bionetgen_cpp.cpython-314-darwin.soCo-Authored-By: Claude Opus 4.8 (1M context) noreply@anthropic.com