Repository navigation
fix(sens): peel negation instead of interpreting it, so not(A and B) reaches the same machinery as A and B (#234) - #264
Merged
Conversation
…reaches the same machinery as A and B (#234) _split_logical_atoms dropped a leading `!` and split what was under it, but kept a `not(...)` call whole. Negation is the one operator with two spellings here, and the whole one is the reading every real model got: _ast_to_exprtk renders <not/> as the call form (and <implies/> as `(not(a)) or (b)`), while this build's ExprTk rejects `!` outright (ERR007/ERR248) -- so no loadable model was getting the good reading either, which corrects the issue's framing. Kept whole, `not(((X<8.0) and (X>3.0)))` is neither a recognized clock threshold nor a single comparison whose residual can be rooted, so the model's analytic sensitivity RHS was declined and it ran on CVODES' difference quotient. On #232's window written as De Morgan's complement (closed form dX(6)/dk_boost = -1.3120451477): 18% wrong at rtol=1e-8 in 303 steps, and SimulationError at 1e-10. After: bit-identical to the `and` arm -- 178 steps and -1.312044990e+00 at 1e-8, 268 steps and -1.312045148e+00 at 1e-10. Peeling is sound for these callers because they ask only WHERE the branch flips: the core reads f-/f+ by evaluating the real RHS on each side of the located crossing, never by interpreting the condition. De Morgan is the warrant -- d(!(A&B)) is contained in dA union dB -- so the peeled reading names no surface the condition does not have, and names exactly the pair the un-negated spelling already registers. That is also the answer to #153's collision question: the negated spelling hands the solver no root the plain conjunction does not. The #49 event path still refuses a negated trigger and the two are not in tension: an event's dt*/dp is derived for a false->true edge, so that reduction orients each atom into a lower or upper bound and takes t* = max(lower); negation swaps those roles, and peeling there would return a confidently wrong number rather than a coarser one. Written up at both sites and tested. Corpus: 0 models move. No model in the 1,319-model BioModels corpus carries a <not/> or an <implies/> (126 carry <and/>, 18 <or/>, 13 <xor/>), and no .net/BNGL model in this tree carries a `not(` outside a comment. Probed main against the branch over the 255 condition-bearing corpus files (243 SBML + 12 .net, 252 distinct models; 230 load, 22 refuse identically on both sides, 188 carry a condition, 23,209 condition atoms): atoms, registered crossings, .so cache digests and gate verdicts identical on every one. Two adjacent disagreements of the same shape, in the same two functions, fixed here with tests: - !(t>=sigma) came back from the splitter as `(t>=sigma)`, which _relational_split_op does not read as a relational atom (it stops looking at depth > 0), so the `!` spelling of a CLOCK threshold was refused where not(t>=sigma) is now admitted. Peeling strips parens on both sides. - uncompensated_condition_reason's scan for a comparison outside an if() head watched `!` but not `not()`. A rate law of `not(X)` is a step at X=0 -- the same boolean-as-a-number idiom as (X>0), which that scan rejects -- but it carried no operator either pattern matched, so it was admitted, sympy differentiated ~X to a clean 1, and nothing warned. #232's acceptance criterion is now met in full: all four spellings of its one window return the same gradient, in the same number of steps, to the last digit.
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.
Closes #234.
_split_logical_atomsdropped a leading!and split what was under it, butkept a
not(...)call whole. Negation is the one operator with two spellingshere, and the whole one is the reading every real model got:
_ast_to_exprtkrenders
<not/>as the call form (and<implies/>as(not(a)) or (b)), whilethis build's ExprTk rejects
!outright (ERR007/ERR248— so the operatorspelling is reachable only at the string level, which is a correction to the
issue's framing: no loadable
.netmodel was getting the good reading either).Kept whole,
not(((X<8.0) and (X>3.0)))is neither a recognized clock thresholdnor a single comparison whose residual can be rooted, so
uncompensated_condition_reasondeclined the model's analytic sensitivity RHSand it ran on CVODES' internal difference quotient. Reproduced on #232's window
written as De Morgan's complement (
piecewise(k_base, not((X<8) and (X>3)), k_boost), closed formdX(6)/dk_boost = -1.3120451477):dX(6)/dk_boost(X<8) and (X>3)-1.312044990e+00(X<8) and (X>3)-1.312045148e+00not((X<8) and (X>3))— before-1.549415941e+00not((X<8) and (X>3))— beforeSimulationErrornot((X<8) and (X>3))— after-1.312044990e+00not((X<8) and (X>3))— after-1.312045148e+00After the change the negated arm is bit-identical to the
andarm — same stepcount, same doubles.
What changed
Negation is peeled rather than interpreted, in both spellings, through one
helper (
_strip_negation)._NOT_OPand_NOT_CALLmove up next to_LOGICALand are now defined together, so a reader of one cannot miss the other.
The issue's four checks
Does the
!-spelled reading generalise? Yes, and the warrant is De Morganrather than a docstring: ∂(¬(A∧B)) ⊆ ∂A ∪ ∂B, so the peeled reading names no
surface the condition does not have. The sense genuinely does not matter to
these callers — the core reads f⁻/f⁺ by evaluating the real RHS on each side of
the located crossing, never by interpreting the condition. Asserted against a
negated compound (not just a negated comparison) at the splitter, at the gate,
and end-to-end against the closed form.
Does the new pair collide the way #153 had to merge? No, and the reason is
structural rather than empirical: the pair a negated compound yields is exactly
the pair the un-negated spelling already registers, so it cannot collide
anywhere
(A and B)does not already. Locked as an equality between the twomodels'
state_switch_conditions.The event-trigger path still refuses negation, and that is not an
inconsistency. The two paths answer different questions. A rate-law switch
needs to know where the branch flips; an event's ∂t*/∂p is derived for a
false→true edge, so
_analyze_event_triggerorients every atom into a lower oran upper bound and takes
t* = max(lower). Negation swaps those roles, sopeeling there would return a confidently wrong number rather than a coarser one.
Written up at both sites, and the refusal is tested to still fire — it is a
whole-trigger text scan that runs before the splitter, so the peel is
unreachable from it.
Acceptance — the four spellings of #232's one window.
and, nestedpiecewise,orover the complement, andnot(... and ...)now all return thesame gradient, at every tolerance, in the same number of steps, to the last
digit.
SPELLINGSintest_sens_condition_spelling.pygrows its fourth arm andevery existing assertion in that class runs over it.
Corpus arm
The change lifts a decline, so it owes a corpus measurement. 0 models move.
No model in the 1,319-model BioModels corpus carries a
<not/>or an<implies/>(126 carry<and/>, 18<or/>, 13<xor/>), and no.net/BNGLmodel in this tree carries a
not(outside a comment — so this lifts a declinethat nothing committed exercises. Measured rather than assumed:
mainwasprobed against the branch over the 255 condition-bearing corpus files (243 SBML
.net, 252 distinct models; 230 load, 22 refuse identically on both sides,188 carry a condition, 23,209 condition atoms in total), recording for each the atoms the
scan produces, the crossings handed to the solver, the
.socache digest thatreads the same atoms, and the codegen gate's verdict and message. All four are
identical on every model.
Two adjacent disagreements, fixed here
Both are the same shape, in the same two functions, and both are covered by a
test:
!(t>=sigma)came back from the splitter as(t>=sigma), which_relational_split_opdoes not read as a relational atom at all (it stopslooking at depth > 0), so the
!spelling of a clock threshold was refusedwhere
not(t>=sigma)is now admitted. Peeling strips parens on both sides sothe two spellings converge on one string.
uncompensated_condition_reason's scan for a comparison outside anif()head watched
!but notnot(). A rate law ofnot(X)is a step atX=0—the same boolean-as-a-number idiom as
(X>0), which that scan rejects — butit carries no operator either pattern matched, so it was admitted, sympy
differentiated
~Xto a clean1, and nothing warned.Tests
TestOneWindowFourSpellings— the fourth arm, over every existing assertion.TestNegationIsPeeledNotInterpreted— the splitter's atoms for nine shapes(both spellings, nested negation, negation composed with a depth-0
connective, a
notthat does not wrap the part); the two spellings assertedequal to each other rather than to two expected lists; the model admitted
and both crossings registered; the pair equal to the plain conjunction's;
the clock/state partition unbroken under a negated clock threshold; the two
adjacent fixes; and the event path still refusing.
TestTheConditionScanTerminates(wasTestANegatedCompoundConditionTerminates)— the compute_model_codegen_hash raises RecursionError on MODEL0911047946, silently demoting it to the source-hash key #216 recursion guard, now anchored on what is genuinely irreducible: a
logical buried in a call argument, and an unbalanced
not(.TestTheGateAndTheDetectorsAgree—if(not(I>1),beta,0)*Imoves from"refused, claimed by neither detector" to "admitted, claimed by exactly one",
so the partition invariant still holds over it.
Full
python/testssuite green.