Repository navigation
test(immune): five population-dynamics models with closed forms, and an observable count-predicate defect pinned - #68
Merged
Conversation
…an observable defect pinned
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.
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.
Immune population-dynamics models with closed forms, and an observable count-predicate defect pinned.
No C++ in this diff.
git diff 6889fba -- cpp/is empty. The one defect I found is already fixed by @correctness in PR #59 (commit1ddef49), on the identical line, and I withdrew my own fix rather than duplicate it — details below.What this adds
Five small immune models (
models/immune/, all <200 species) plus a runnable harness. Every model is answered by a number derived independently of the model text, or by an exact conservation identity. Nothing here is a plausibility check.clonal_expansionK/(1+((K-E0)/E0)e^(-rt))fordE/dt = r*E*(K-E)/KK=1000, monotone, never overshootsE(8)=931.738460932vs931.7385(0.03%)cytotoxic_killingk(E)=Vmax*E/(Kh+E)atE==Khis exactlyVmax/2, so the decay isT0*exp(-Vmax*t/2)with no free parametertest_kill_flux_saturates_in_the_effector_countE == 200at every step, assertedexhaustion_switchS(t)=S0+a*tTHETA=400at exactlyt* = (THETA-S0)/aS==THETAatt=5.0, analytict*=5.0kmem*Eff*Hill(1,THETA,n,S)dt=0.05, not a rate error)THETA*(10^(1/n)-1)/at=5.025antigen_bindingt_endkon/koffrates, pinned by the physical root ofx²-(Ag0+R0+Kd)x+Ag0*R0=0bound=184.112765606vs analytic184.112765606, rel 5.897e-14.netcarries a distinct reverse reaction consuming/reproducing the same speciesThe binding case is the one that matters most for the checklist's §4 trap: conservation alone cannot catch a mis-split rate, because a rule that dropped or doubled
koffstill conserves the pool. Only the equilibrium root distinguishes them, which is why both are asserted.Defect found: count predicate on a
MoleculesobservableOdeIntegrator::compileGroups()applied the predicate to the number of pattern→species embeddings, which is 1 for every single-node pattern.Smallest reproducer —
models/immune/threshold_observable.bngl:Same in SSA with seed 42. This is the "runs and is wrong" shape: no error, a plausible column of zeros.
Oracle, and why the defect is bounded to
Molecules. BNG2 2.9.3 at/Users/akutuva/Documents/BioNetGen/bionetgen/bionetgen/bng2/BNG2.pl.Perl2/Observable.pminspects$patt->Quantifieronly inside theType eq "Species"branch; theMoleculesbranch never evaluates it. I verifiedSpecies-typed observables already agree with BNG2 term for term (SEff/SGt0/SGt1/SChall to 1e-9), which is what confines the defect toMolecules.Why the fix is not in this PR
@correctness independently found the same defect and landed
1ddef49in PR #59, on the same line, gating the filter on the observable type. I had a fail-closed refusal written and ready. I withdrew it:Perl2/Observable.pmsimply never inspects the quantifier on that path. An unexercised branch is a weaker foundation for refusal than for parity, and the oracle is what settles it.compileGroups()to me by name, so the collision was avoidable but still real.My tests therefore assert the parity outcome, and I verified them against a binary that actually carries their fix rather than only against my own branch:
The second number is the one that matters. A duplicate fix that passes only against its own branch is precisely what breaks when two lanes touch one file, so the models were run through the merged semantics, not each half separately.
Reported, not fixed, not in this diff
BNG3 lowercases a
Hillcall on emission, and the emitted text does not resolve on the way back in:The trajectory is correct through the normal path (flux tracked to 8.3e-3 against the closed form), so this is a portability defect in emitted text, not a wrong number — which is why it survived. It is exactly
docs/DEVELOPMENT_CHECKLIST.md§4: a claim about emitted text that is not a claim about behaviour, except here the behaviour is the opposite of what would be claimed.Sent to @sciPkPd (owns
compile()'s Sat/Hill block) and @swarmCompiler (ownsExpression.*) with a two-file reproducer. I have not located the lowercasing site and am not claiming which file emits it.Expression.cpp:333accepts both spellings at evaluation time, which points at the emission side, but that is a lead and I did not confirm it. I also have no measurement on whetherSat/MMshare the problem and claim nothing either way.What I could not assess here
Sat/MMround-trip. Not exercised by my models, so untested rather than clean.75b22a7,cpp/nfsim/NFinput/NFinput_fromCompiled.cpponly) —git log --since='2026-09-29 15:08:00' --format=%h -- cpp/returns that alone — which this lane never reaches.Verification
These tests drive the
bng_cppCLI and never importbionetgen, so neither conftest-guard variant (#53/#56) nor theimportorskiphazard affects them; noUNDER TEST pkg/extline applies because no extension is loaded.Metric class, declared per Main's rule: every number above 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. No timing is reported.
No
git stashwas used at any point. All writes used absolute paths.git status --porcelainis empty; zero unmerged files; the three pre-existing stash entries were not touched.Merge note: this branch adds no lines to
tests/cpp/CMakeLists.txt, so it does not add to the eight-way contention inOdeIntegrator.cpp. Rebase onto currentmainis expected but trivial.🤖 Generated with Claude Code