Skip to content

fix(integrators): RKF45 node + KlPl SRK diffusion weights#841

Merged
chaoming0625 merged 1 commit into
masterfrom
fix/audit-20260619-integrators-ode-sde
Jun 18, 2026
Merged

fix(integrators): RKF45 node + KlPl SRK diffusion weights#841
chaoming0625 merged 1 commit into
masterfrom
fix/audit-20260619-integrators-ode-sde

Conversation

@chaoming0625

@chaoming0625 chaoming0625 commented Jun 18, 2026

Copy link
Copy Markdown
Member

Fresh review of brainpy/integrators ode/sde.

Critical — RKF45 6th-stage node c6 was 1/3 instead of 1/2, silently collapsing the integrator to order 1 for any time-dependent RHS (verified: order 1.0 -> 5.0 on dy/dt=cos(t)).

High — KlPl strong SRK scheme's final-stage diffusion weights were wrong (g1+g2 != I1), so it did not converge; corrected to g1=I1-I11/sqrt(h), g2=I11/sqrt(h).

Regression tests added incl. order/convergence checks (226 passed in-scope). Findings: docs/issues-found-20260619-integrators-ode-sde.md.

Summary by Sourcery

Fix numerical correctness issues in the RKF45 ODE integrator and KlPl SRK SDE scheme and add regression tests and documentation for the integrators audit.

Bug Fixes:

  • Correct the RKF45 Butcher tableau last node from 1/3 to 1/2 to restore fifth-order accuracy for time-dependent ODEs.
  • Fix the KlPl strong SRK final-stage diffusion weights so the scheme satisfies the diffusion consistency condition and converges.
  • Align the generated KlPl step code with the corrected diffusion weights to remove the spurious mixed-integral term from g1.

Enhancements:

  • Add convergence-order regression tests for RKF45 on a time-dependent ODE to guard against future tableau regressions.
  • Add KlPl SDE convergence and integrator-vs-reference consistency tests driven by shared Wiener paths.
  • Document the results of the ODE/SDE integrator audit, including recorded-but-unfixed low-severity issues, in a new markdown report.

- RKF45 6th-stage node was c6=1/3 instead of 1/2, silently collapsing the
  method to order 1 for any time-dependent f (verified order 1.0 -> 5.0) (Critical)
- KlPl (srk_scalar) final-stage diffusion weights were wrong so g1+g2 != I1
  and the scheme did not converge; corrected to g1=I1-I11/sqrt(h), g2=I11/sqrt(h) (High)

Findings recorded in docs/issues-found-20260619-integrators-ode-sde.md
@chaoming0625 chaoming0625 merged commit 03affbf into master Jun 18, 2026
2 of 5 checks passed
@sourcery-ai

sourcery-ai Bot commented Jun 18, 2026

Copy link
Copy Markdown

Reviewer's Guide

Fixes two numerical correctness bugs in the ODE/SDE integrators (RKF45 node and KlPl SRK diffusion weights) and adds regression tests plus an audit note documenting the findings and environment.

Flow diagram for KlPl SRK final diffusion update

flowchart LR
  subgraph Final_stage_diffusion_update
    I1["I1 (Wiener increment)"]
    I11["I11 (iterated integral)"]
    dt_sqrt["dt_sqrt"]

    I1 --> g1_calc
    I11 --> g1_calc
    dt_sqrt --> g1_calc
    I11 --> g2_calc
    dt_sqrt --> g2_calc

    g1_calc["g1 = I1 - I11 / dt_sqrt"] --> sum_g
    g2_calc["g2 = I11 / dt_sqrt"] --> sum_g

    sum_g["g1 + g2 = I1 (correct g · dW term)"] --> y_update
    y_update["y_new = x + dt * f_H0s1 + g1 * g_H1s1 + g2 * g_H1s2"]
  end
Loading

File-Level Changes

Change Details Files
Correct RKF45 Butcher tableau node so the adaptive RKF45 method attains its intended 5th-order accuracy for time-dependent right-hand sides.
  • Update RKF45 C-vector 6th node from 1/3 to 0.5 with an explanatory comment about the consistency condition and order degradation.
  • Add a convergence-order helper that runs adaptive RK methods in fixed-step mode on a time-dependent scalar ODE with known exact solution.
  • Add regression tests that check RKF45 achieves empirical order >4 on dy/dt=cos(t) and directly assert the last C-node evaluates to 0.5.
brainpy/integrators/ode/adaptive_rk.py
brainpy/integrators/ode/ode_method_adaptive_rk_test.py
Fix KlPl strong SRK SDE scheme’s final-stage diffusion weights so the integrator converges with the expected strong order on linear SDEs.
  • Change generated KlPl diffusion weights to g1 = I1 - I11/dt_sqrt and g2 = I11/dt_sqrt and update the in-code comment to explain the consistency condition g1+g2=I1.
  • Add NumPy reference implementation of the corrected KlPl step driven by fixed Wiener paths and an exact GBM solution helper.
  • Add regression tests that (1) assert the generated KlPl step code contains the corrected weights and no longer uses I10, (2) measure strong convergence rate ~1.0 on a linear SDE, and (3) confirm the compiled KlPl integrator matches the NumPy reference on mirrored random draws.
brainpy/integrators/sde/srk_scalar.py
brainpy/integrators/sde/srk_scalar_test.py
Document the results of a broader ODE/SDE integrators audit, including this PR’s critical and high-severity fixes and several recorded low-severity issues.
  • Add markdown document summarizing audit scope, environment, severity breakdown, detailed descriptions of the RKF45 node and KlPl diffusion bugs, reproductions, and their fixes.
  • Record several low-severity, not-yet-fixed findings (dead code, minor tableau inconsistencies, controller exponent, doc typos, already-fixed prior issues) for future cleanup.
docs/issues-found-20260619-integrators-ode-sde.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • In TestRKF45NodeFix.test_rkf45_node_value, instead of eval(str(adaptive_rk.RKF45.C[-1])) (which is a bit brittle and mixes types), consider normalizing C entries to a numeric type (e.g., float or fractions.Fraction) and asserting directly on that value.
  • The KlPl regression test test_generated_weights_corrected relies on parsing the generated step code as a string; this could be fragile if code formatting or names change, so consider exposing the computed diffusion weights or tableau data as structured attributes and asserting on those instead.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `TestRKF45NodeFix.test_rkf45_node_value`, instead of `eval(str(adaptive_rk.RKF45.C[-1]))` (which is a bit brittle and mixes types), consider normalizing `C` entries to a numeric type (e.g., `float` or `fractions.Fraction`) and asserting directly on that value.
- The KlPl regression test `test_generated_weights_corrected` relies on parsing the generated step code as a string; this could be fragile if code formatting or names change, so consider exposing the computed diffusion weights or tableau data as structured attributes and asserting on those instead.

## Individual Comments

### Comment 1
<location path="brainpy/integrators/sde/srk_scalar_test.py" line_range="98-107" />
<code_context>
+    return _X0 * np.exp((_A - _B * _B / 2) * t + _B * w_total)
+
+
+def _ref_klpl_terminal(n, dW, dI0, t_end=1.0):
+    """NumPy reference of the *corrected* KlPl step, driven by a fixed path."""
+    dt = t_end / n
+    ds = np.sqrt(dt)
+    x = _X0
+    for i in range(n):
+        I1 = dW[i]
+        I11 = 0.5 * (I1 ** 2 - dt)
+        f1 = _A * x
+        g1s1 = _B * x
+        h1s2 = x + dt * f1 + ds * g1s1
+        g1s2 = _B * h1s2
+        gg1 = I1 - I11 / ds
+        gg2 = I11 / ds
+        x = x + dt * f1 + gg1 * g1s1 + gg2 * g1s2
+    return x
+
+
</code_context>
<issue_to_address>
**suggestion:** Unused `dI0` argument in `_ref_klpl_terminal` makes the reference step ambiguous

`_ref_klpl_terminal` takes `dI0` but never uses it. Since the production integrator samples both `I1` and `I0` and the test explicitly constructs `dI0`, this can cause confusion and makes it easier for the reference path to drift from the real scheme if `I0` becomes relevant later. Please either remove `dI0` from the signature/call sites, or clearly document that it is intentionally unused and why. If `I0` should affect the strong scheme, consider incorporating it into the reference update so the test covers all stochastic inputs.

Suggested implementation:

```python
def _ref_klpl_terminal(n, dW, t_end=1.0):
    """NumPy reference of the *corrected* KlPl step, driven by a fixed path.

    Note
    ----
    This reference only depends on the first-level Wiener increments ``I1 = dW``.
    If the production scheme later uses additional iterated integrals (e.g. ``I0``),
    this reference must be updated accordingly so that tests cover all stochastic inputs.
    """
    dt = t_end / n
    ds = np.sqrt(dt)
    x = _X0
    for i in range(n):
        I1 = dW[i]
        I11 = 0.5 * (I1 ** 2 - dt)
        f1 = _A * x
        g1s1 = _B * x
        h1s2 = x + dt * f1 + ds * g1s1
        g1s2 = _B * h1s2
        gg1 = I1 - I11 / ds
        gg2 = I11 / ds
        x = x + dt * f1 + gg1 * g1s1 + gg2 * g1s2
    return x

```

The function signature change will require updating all call sites in `brainpy/integrators/sde/srk_scalar_test.py` (and anywhere else it might be imported) that currently pass `dI0`:

1. Replace calls like `_ref_klpl_terminal(n, dW, dI0, t_end=...)` with `_ref_klpl_terminal(n, dW, t_end=...)`.
2. If `dI0` is only used to satisfy the old signature and not used elsewhere in the test, it can be removed from the surrounding test code; otherwise, keep it for any other checks that explicitly validate `I0`-related behaviour.
</issue_to_address>

### Comment 2
<location path="brainpy/integrators/ode/ode_method_adaptive_rk_test.py" line_range="137-138" />
<code_context>
+            bm.disable_x64()
+        self.assertGreater(order, 4.0, msg=f'RKF45 order={order:.2f}, errs={errs}')
+
+    def test_rkf45_node_value(self):
+        self.assertAlmostEqual(float(eval(str(adaptive_rk.RKF45.C[-1]))), 0.5)
</code_context>
<issue_to_address>
**suggestion:** Avoid `eval(str(...))` when checking the RKF45 node constant

Since `C[-1]` is already numeric, `eval(str(...))` is unnecessary and brittle. You can compare `adaptive_rk.RKF45.C[-1]` directly (or wrap with `float(...)` if needed) to keep the test simpler and avoid surprises if `C`’s representation changes.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +98 to +107
def _ref_klpl_terminal(n, dW, dI0, t_end=1.0):
"""NumPy reference of the *corrected* KlPl step, driven by a fixed path."""
dt = t_end / n
ds = np.sqrt(dt)
x = _X0
for i in range(n):
I1 = dW[i]
I11 = 0.5 * (I1 ** 2 - dt)
f1 = _A * x
g1s1 = _B * x

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Unused dI0 argument in _ref_klpl_terminal makes the reference step ambiguous

_ref_klpl_terminal takes dI0 but never uses it. Since the production integrator samples both I1 and I0 and the test explicitly constructs dI0, this can cause confusion and makes it easier for the reference path to drift from the real scheme if I0 becomes relevant later. Please either remove dI0 from the signature/call sites, or clearly document that it is intentionally unused and why. If I0 should affect the strong scheme, consider incorporating it into the reference update so the test covers all stochastic inputs.

Suggested implementation:

def _ref_klpl_terminal(n, dW, t_end=1.0):
    """NumPy reference of the *corrected* KlPl step, driven by a fixed path.

    Note
    ----
    This reference only depends on the first-level Wiener increments ``I1 = dW``.
    If the production scheme later uses additional iterated integrals (e.g. ``I0``),
    this reference must be updated accordingly so that tests cover all stochastic inputs.
    """
    dt = t_end / n
    ds = np.sqrt(dt)
    x = _X0
    for i in range(n):
        I1 = dW[i]
        I11 = 0.5 * (I1 ** 2 - dt)
        f1 = _A * x
        g1s1 = _B * x
        h1s2 = x + dt * f1 + ds * g1s1
        g1s2 = _B * h1s2
        gg1 = I1 - I11 / ds
        gg2 = I11 / ds
        x = x + dt * f1 + gg1 * g1s1 + gg2 * g1s2
    return x

The function signature change will require updating all call sites in brainpy/integrators/sde/srk_scalar_test.py (and anywhere else it might be imported) that currently pass dI0:

  1. Replace calls like _ref_klpl_terminal(n, dW, dI0, t_end=...) with _ref_klpl_terminal(n, dW, t_end=...).
  2. If dI0 is only used to satisfy the old signature and not used elsewhere in the test, it can be removed from the surrounding test code; otherwise, keep it for any other checks that explicitly validate I0-related behaviour.

Comment on lines +137 to +138
def test_rkf45_node_value(self):
self.assertAlmostEqual(float(eval(str(adaptive_rk.RKF45.C[-1]))), 0.5)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Avoid eval(str(...)) when checking the RKF45 node constant

Since C[-1] is already numeric, eval(str(...)) is unnecessary and brittle. You can compare adaptive_rk.RKF45.C[-1] directly (or wrap with float(...) if needed) to keep the test simpler and avoid surprises if C’s representation changes.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests labels Jun 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant