Skip to content

fix: event-time sensitivity guard misses state-dependent triggers reached through SBML (#52)#60

Merged
wshlavacek merged 1 commit into
mainfrom
fix/52-state-dependent-trigger-guard
Jul 26, 2026
Merged

fix: event-time sensitivity guard misses state-dependent triggers reached through SBML (#52)#60
wshlavacek merged 1 commit into
mainfrom
fix/52-state-dependent-trigger-guard

Conversation

@wshlavacek

Copy link
Copy Markdown
Collaborator

Closes #52.

The bug

The guard refuses forward sensitivities when an event's crossing time depends on a requested sensitivity parameter, and it decides that from the trigger's bound addresses. It compared them against species concentrations only.

ModelBuilder registers a species as an ExprTk variable only when its name is still free:

// src/model_builder.cpp — species names may collide with observable names
try { eval.define_variable(sp.name, &sp.concentration); }
catch (...) { /* already registered (e.g. as observable) — skip */ }

SBML models routinely give each species an observable of the same name, so that registration is skipped and a trigger token binds to &obs.total, never to &sp.concentration. Every SBML state-dependent trigger therefore slipped the guard and was answered instead of refused, with the event contributions missing entirely.

Confirmed directly on the reported shape — the SBML_EVENT fixture with its trigger swapped to S < 5:

species names: ['S']
obs names:     ['S']          <- the collision
guard reason:  None           <- no state dependence seen
!!! ANSWERED (should have been REFUSED)

On AMICI's neuron fixture (Izhikevich, trigger v > 30, which names no parameter but whose crossing time depends on a and b through the trajectory) the issue measured sensitivities 6x–135x off, uniformly in one direction, across all four parameters — while dirac and nested_events, whose triggers name a parameter outright, were correctly refused.

The fix

The guard now tests against every address that carries live state: species concentrations, observable totals, and rateOf accessors. An observable total is a linear functional of the state and a rateOf accessor is dx/dt, so a trigger reading either has a non-zero dt*/dp exactly as a concentration read does.

Refusing is unchanged as a policy — as the issue says, "the codebase already does it well" — this is only the coverage of what counts as state. The message now names which of the three it saw and why that implies a moving crossing time.

Tests

  • An SBML model whose trigger reads a species (the neuron shape) must be refused, through both run and compute_all_sensitivities. Both fail on the unmodified core with "DID NOT RAISE".
  • The test asserts the species/observable name collision explicitly, so if that binding ever changes the test says why it was written rather than silently becoming vacuous.
  • No false positives: the existing ModelBuilder-path state-dependent test still passes, as do fixed-time events, the parameter-valued reset, compute_all_sensitivities on a fixed-time model, and the discontinuity-trigger pulse model (n_events == 0, must stay allowed).

Full Python suite: 2575 passed, 89 skipped.

Note on the issue's item 3

The issue separately asks whether the jump terms are handled at a fixed event time — neuron assigns v = -c and u = d + u, contributing d/dc and d/dd independent of dt*/dp. That is orthogonal to this guard and already covered: test_parameter_valued_reset_matches_fd pins ∂h/∂p against finite differences for a parameter-valued assignment, and test_additive_bolus_is_continuous pins the ∂h/∂x = 1 case.

…ched through SBML (#52)

The guard refuses forward sensitivities when an event's crossing time depends on
a requested sensitivity parameter, and it decides that from the trigger's bound
addresses. It compared them against species concentrations only.

ModelBuilder registers a species as an ExprTk variable only when its name is
still free (model_builder.cpp), and SBML models routinely give each species an
observable of the same name — so the species registration is skipped and a
trigger token binds to &obs.total, never to &sp.concentration. Every SBML
state-dependent trigger therefore slipped the guard and was answered instead of
refused, with the event contributions missing entirely.

On AMICI's `neuron` fixture (Izhikevich, trigger `v > 30`, which names no
parameter but whose crossing time depends on `a` and `b` through the trajectory)
the returned sensitivities were 6x-135x off, uniformly in one direction, across
all four parameters — while the two fixtures whose triggers name a parameter
outright were correctly refused.

The guard now tests against every address that carries live state: species
concentrations, observable totals, and rateOf accessors. An observable total is
a linear functional of the state and a rateOf accessor is dx/dt, so a trigger
reading either has a non-zero dt*/dp exactly as a concentration read does.
Refusing is unchanged as a policy — this is only the coverage of what counts as
state — and the message now names which of the three it saw and why that implies
a moving crossing time.

Tests: an SBML model whose trigger reads a species (the `neuron` shape) must be
refused, through both `run` and `compute_all_sensitivities`, with an explicit
assertion of the species/observable name collision that caused the miss. Both
fail on the unmodified core with "DID NOT RAISE". The existing ModelBuilder-path
state-dependent test still passes, as do the no-false-positive cases: fixed-time
events, a parameter-valued reset, and the discontinuity-trigger pulse model.

Closes #52
@wshlavacek
wshlavacek merged commit b26180f into main Jul 26, 2026
7 checks passed
@wshlavacek
wshlavacek deleted the fix/52-state-dependent-trigger-guard branch July 26, 2026 08: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.

Event-time sensitivity guard misses state-dependent triggers, silently returning a tensor without event contributions

1 participant