Skip to content

perf(sbml): keep the GH #88 step bound only where the roots provably cannot reach (#274) - #277

Merged
wshlavacek merged 1 commit into
mainfrom
perf/274-narrow-periodic-step-bound
Aug 10, 2026
Merged

wshlavacek merged 1 commit into
mainfrom
perf/274-narrow-periodic-step-bound

Conversation

@wshlavacek

Copy link
Copy Markdown
Collaborator

Closes #274.

#262 measured that no corpus model's answer the GH #88 periodic step bound
changes, and pre-committed to the consequence: narrow it to the shapes roots
cannot reach, or retire it. This narrows it. Retiring would have been wrong,
and the fixture below is why — it is the first thing in the suite to witness the
bound's necessity since #259 removed the old one.

The bound and the #72/#231/#259 roots address the same hazard — an integrator
step spanning a schedule edge — by different means. Where a root already forces
the stop, max_step is pure cost: it shortens every step over the whole horizon,
not just the ones near an edge.

The predicate

Value-position reachability from the ODE RHS through the assignment rules,
pruning every piecewise condition that is a registered root. A time-dependent
floor/ceiling/modulo influences the RHS two ways, and only one of them is
rooted:

  • through a piecewise condition — exposure = piecewise(D, frac < w, 0). A
    root on that condition forces a stop exactly at the edge, so the branch cannot
    be stepped over and the bound adds nothing. Pruned.
  • as a value — dose = D * frac, or k = k0 * i with i = floor(time/24).
    The sawtooth jump is an RHS discontinuity in its own right, at an instant no
    relational brackets. The bound is what keeps a step from spanning it.

Corpus: kept on 10 of 25, dropped on 15, cutting 1,015,702 internal steps
to 519,472 (-49%)
with no answer moving.

model (bound dropped) steps before after saved
MODEL0406793751 526,655 297,155 44%
MODEL0406553884 439,367 176,919 60%
MODEL1708310001 13,078 9,325 29%
MODEL0847869198 521 310 40%

MODEL1708310001 is the check that matters. It is the model #259 gave the roots
to, and it has an exact segmented oracle (953.07) confirming they resolve its
schedule. The predicate drops its bound without being told, and that model's
oracle test — numbers unchanged — now passes with no bound at all.

Why not a root count

The tempting predicate, "apply the bound only when the model registered no
roots"
, is unsound — measurably, not theoretically. Three corpus models are
rooted yet carry a periodic disc node reaching the RHS ungated:
BIOMD0000000577, BIOMD0000000589, MODEL1006230027. The clearest is
BIOMD0000000589, 15 of whose 16 schedule conditions read i == 0, i == 1, …
on i := floor(time/24) — equalities, which the inequality-only emitters
cannot root, on a step function that genuinely moves with time.

_EQ_GATED_PULSE makes that concrete, and is the acceptance case #274 asked for:
the corpus has the shape but no instance where it costs an answer. Its pulse is
gated by an equality on a floor while an unrelated time < 5 threshold is
rooted, so the model has roots and still needs the bound.

arm y(10) vs exact 36.788 steps
bound on, rtol 1e-9 36.788 2.7e-07 1,891
bound on, rtol 1e-11 36.788 1.1e-09 2,451
bound off, rtol 1e-9 182.212 +395% 294
bound off, rtol 1e-11 182.212 +395% 463

Tol-stably wrong is the signature of a pulse never sampled at all. A root-count
predicate returns 182.212 here.

Notes for whoever extends this

  • The reachability walk is deliberately one pass carrying context down, not
    two passes compared afterwards. libsbml returns a fresh Python wrapper from
    every getChild() call, so id(node) is not stable across traversals and any
    "was this node also seen over there" test silently answers no — which reads as
    full root coverage and drops the bound everywhere. A first cut of the
    measurement did exactly that and reported 2/25 kept, including six zero-root
    models; impossible on its face, which is what exposed it.
  • Pruning uses only the time scan's emitters, so a piecewise gated on integrated
    state is treated as ungated and keeps the bound. Conservative in the safe
    direction: a needless bound costs steps, a wrongly dropped one costs a silent
    wrong answer.
  • _periodic_time_disc_max_step keeps its pre-Narrow or retire the GH #88 periodic step bound: no corpus model needs it, but a root-count predicate is not a sound narrowing #274 behaviour when called without
    the registered-condition set, so a caller that has not run the root scan cannot
    drop a bound it has no basis to drop.

Test churn

Model._periodic_disc_max_step is now None for the 15 dropped models, so four
tests asserting "a bound is derived" needed rewriting. Each inverted rather
than weakened, and the correctness assertions they carried are untouched and
still pass — which is the evidence the drops were safe:

Verification

Full suite 4,180 passed, 30 skipped, run against this branch rebased onto
#276 and with the C extension rebuilt for its src/model.cpp /
src/model_builder.cpp changes. ruff check / format --check clean.

…cannot reach (#274)

#262 measured that no corpus model's answer the periodic step bound changes, and
pre-committed to the consequence: narrow it to the shapes roots cannot reach, or
retire it. This narrows it. Retiring would have been wrong -- see the fixture
below, which is the first thing in the suite to witness the bound's necessity
since #259 removed the old one.

The bound and the #72/#231/#259 roots address the same hazard -- an integrator
step spanning a schedule edge -- by different means. Where a root already forces
the stop, max_step is pure cost: it shortens every step over the whole horizon,
not just the ones near an edge.

## The predicate

Value-position reachability from the ODE RHS through the assignment rules,
pruning every piecewise condition that IS a registered root. A time-dependent
floor/ceiling/modulo influences the RHS two ways and only one of them is rooted:

  * through a piecewise CONDITION -- `exposure = piecewise(D, frac < w, 0)`. A
    root on that condition forces a stop exactly at the edge, so the branch
    cannot be stepped over and the bound adds nothing. Pruned.
  * as a VALUE -- `dose = D * frac`, or `k = k0 * i` with `i = floor(time/24)`.
    The sawtooth jump is an RHS discontinuity in its own right, at an instant no
    relational brackets. The bound is what keeps a step from spanning it.

Corpus: kept on 10 of the 25, dropped on 15, cutting 1,015,702 internal steps to
519,472 (-49%) with no answer moving -- MODEL0406553884 -60%, MODEL0406793751
-44%, MODEL0847869198 -40%, MODEL1708310001 -29%.

That last one is the check that matters. It is the model #259 gave the roots to,
and it has an exact segmented oracle (953.07) confirming they resolve its
schedule. The predicate drops its bound without being told, and the existing
oracle test -- unchanged in its numbers -- now passes with no bound at all.

## Why not a root count

The tempting predicate, "apply the bound only when the model registered no
roots", is unsound, and measurably rather than theoretically so. Three corpus
models are rooted yet carry a periodic disc node reaching the RHS ungated:
BIOMD0000000577, BIOMD0000000589, MODEL1006230027. The clearest is
BIOMD0000000589, 15 of whose 16 schedule conditions read `i == 0`, `i == 1`, ...
on `i := floor(time/24)` -- equalities, which the inequality-only emitters cannot
root, on a step function that genuinely moves with time.

`_EQ_GATED_PULSE` makes that concrete and is the acceptance case #274 asked for,
since the corpus has the shape but no instance where it costs an answer. Its
pulse is gated by an equality on a floor while an unrelated `time < 5` threshold
IS rooted, so the model has roots and still needs the bound:

  bound on,  rtol 1e-9    y(10) =  36.788   vs exact 36.788   1,891 steps
  bound on,  rtol 1e-11   y(10) =  36.788   vs exact 36.788   2,451 steps
  bound off, rtol 1e-9    y(10) = 182.212   +395%               294 steps
  bound off, rtol 1e-11   y(10) = 182.212   +395%               463 steps

Tol-stably wrong is the signature of a pulse never sampled at all. A root-count
predicate returns 182.212 here.

## Measurement notes, for whoever extends this

The reachability walk is deliberately ONE pass carrying context down, not two
passes compared afterwards. libsbml returns a fresh Python wrapper from every
getChild() call, so id(node) is not stable across traversals and any "was this
node also seen over there" test silently answers no -- which reads as full root
coverage and drops the bound everywhere. A first cut of the measurement did
exactly that and reported 2/25 kept, including six zero-root models, which is
impossible on its face and is what exposed it.

Pruning uses only the time scan's emitters, so a piecewise gated on integrated
state is treated as ungated and keeps the bound. Conservative in the safe
direction: the cost of a needless bound is steps, the cost of a wrongly dropped
one is a silent wrong answer.

`_periodic_time_disc_max_step` keeps its pre-#274 behaviour when called without
the registered-condition set, so a caller that has not run the root scan cannot
drop a bound it has no basis to drop.

## Test churn

Model._periodic_disc_max_step is now None for the 15 dropped models, so four
tests asserting "a bound is derived" needed rewriting. Each inverted rather than
weakened, and the correctness assertions they carried are untouched and still
pass -- which is the evidence the drops were safe:

  - the derivation arithmetic (window/3) moved to _EQ_GATED_PULSE, a fixture that
    still earns a bound;
  - _floor_dose_sbml now asserts the opposite -- its edge is a rootable
    inequality, so #274 declines -- while its closed-form tests run unbounded and
    still hit the oracle;
  - MODEL1708310001's oracle test asserts `is None` and keeps its 953.07;
  - MODEL0847869198 compares default-unbounded against a forced 0.05 ceiling,
    well under the 0.333 the pre-#274 loader derived, so the agreement cannot be
    an artifact of too coarse a ceiling.
@wshlavacek
wshlavacek merged commit 0c83642 into main Aug 10, 2026
4 checks passed
@wshlavacek
wshlavacek deleted the perf/274-narrow-periodic-step-bound branch August 10, 2026 18:39
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.

Narrow or retire the GH #88 periodic step bound: no corpus model needs it, but a root-count predicate is not a sound narrowing

1 participant