Skip to content

fix(sens): peel negation instead of interpreting it, so not(A and B) reaches the same machinery as A and B (#234) - #264

Merged
wshlavacek merged 1 commit into
mainfrom
fix/234-negated-compound-condition
Aug 10, 2026
Merged

wshlavacek merged 1 commit into
mainfrom
fix/234-negated-compound-condition

Conversation

@wshlavacek

Copy link
Copy Markdown
Collaborator

Closes #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 the operator
spelling is reachable only at the string level, which is a correction to the
issue's framing: no loadable .net model was getting the good reading either).

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
uncompensated_condition_reason declined the model's analytic sensitivity RHS
and 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 form dX(6)/dk_boost = -1.3120451477):

spelling rtol steps dX(6)/dk_boost rel err
(X<8) and (X>3) 1e-8 178 -1.312044990e+00 1.2e-07
(X<8) and (X>3) 1e-10 268 -1.312045148e+00 4.4e-10
not((X<8) and (X>3)) — before 1e-8 303 -1.549415941e+00 1.8e-01
not((X<8) and (X>3)) — before 1e-10 — SimulationError —
not((X<8) and (X>3)) — after 1e-8 178 -1.312044990e+00 1.2e-07
not((X<8) and (X>3)) — after 1e-10 268 -1.312045148e+00 4.4e-10

After the change the negated arm is bit-identical to the and arm — same step
count, same doubles.

What changed

Negation is peeled rather than interpreted, in both spellings, through one
helper (_strip_negation). _NOT_OP and _NOT_CALL move up next to _LOGICAL
and 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 Morgan
rather 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 two
models' 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_trigger orients every atom into a lower or
an upper bound and takes t* = max(lower). Negation swaps those roles, so
peeling 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, nested
piecewise, or over the complement, and not(... and ...) now all return the
same gradient, at every tolerance, in the same number of steps, to the last
digit. SPELLINGS in test_sens_condition_spelling.py grows its fourth arm and
every 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/BNGL
model in this tree carries a not( outside a comment — so this lifts a decline
that nothing committed exercises. Measured rather than assumed: main was
probed 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 in total), recording for each the atoms the
    scan produces, the crossings handed to the solver, the .so cache digest that
    reads 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_op does not read as a relational atom at all (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 so
    the two spellings converge on one string.
  • 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 carries no operator either pattern matched, so it was admitted, sympy
    differentiated ~X to a clean 1, 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 not that does not wrap the part); the two spellings asserted
    equal 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 (was TestANegatedCompoundConditionTerminates)
    — 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)*I moves from
    "refused, claimed by neither detector" to "admitted, claimed by exactly one",
    so the partition invariant still holds over it.

Full python/tests suite green.

…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.
@wshlavacek
wshlavacek merged commit e496728 into main Aug 10, 2026
8 checks passed
@wshlavacek
wshlavacek deleted the fix/234-negated-compound-condition branch August 10, 2026 12:44
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.

not(...) around a compound condition is declined where the ! spelling of the same condition is admitted

1 participant