Skip to content

Fixed explicit solves with compiled loops

For long JAX solves, set RunConfig(fixed_loop="scan") to run the fixed explicit mesh through one jax.lax.scan. This avoids tracing a Python copy of every step. It supports Euler, Heun, RK4 and Dormand–Prince, with the same fixed-step updates as the default eager driver. A spatial discretization or nonlocal model can supply the RHS just as an ODE can; the usual stability and discretization requirements still apply.

"""Differentiate a fixed-mesh solve without unrolling its internal steps.

Requires the optional JAX extra. Run ``uv run python examples/fixed_scan.py``.
"""

from __future__ import annotations

import numpy as np


def main() -> None:
    """Check a long fixed solve and its derivative against exponential decay."""
    import jax  # noqa: PLC0415
    import jax.numpy as jnp  # noqa: PLC0415

    from op_engine import CoreSolver, ModelCore  # noqa: PLC0415
    from op_engine.core_solver import RunConfig  # noqa: PLC0415
    from op_engine.model_core import ModelCoreOptions  # noqa: PLC0415

    def final_value(rate: jax.Array) -> jax.Array:
        # Requested outputs and internal step size are static under jit.
        core = ModelCore(
            1,
            1,
            np.asarray([0.0, 10.0]),
            options=ModelCoreOptions(store_history=False),
        )
        core.set_initial_state(jnp.asarray([[1.0]], dtype=rate.dtype))
        CoreSolver(core).run(
            lambda _time, state: -rate * state,
            config=RunConfig(method="rk4", fixed_max_step=0.01, fixed_loop="scan"),
        )
        return core.get_current_state()[0, 0]

    enable_x64 = getattr(jax, "enable_x64", None)
    if enable_x64 is None:
        enable_x64 = jax.experimental.enable_x64
    with enable_x64():
        value, derivative = jax.jit(jax.value_and_grad(final_value))(
            jnp.asarray(0.2, dtype=jnp.float64)
        )
    expected = np.exp(-2.0)
    np.testing.assert_allclose(value, expected, rtol=1e-9)
    np.testing.assert_allclose(derivative, -10.0 * expected, rtol=1e-9)
    print(  # noqa: T201 - command-line example output
        f"Final value: {float(value):.6f}; rate derivative: {float(derivative):.6f}"
    )


if __name__ == "__main__":
    main()

The example checks the solution and parameter derivative against exponential decay. Its 1,000 internal steps do not require 1,000 output records. Requested output times, fixed_max_step, state shape and declared forcing breakpoints are static under JIT. Initial states and RHS parameters can be dynamic arrays. Changing the static mesh can trigger recompilation. Normal JAX restrictions apply to the RHS; Python control flow depending on traced values is unsupported.

fixed_loop="unroll" remains the default. "auto" selects a registered namespace adapter and otherwise retains the eager path. "scan" raises if an adapter is unavailable; JAX is registered lazily. NumPy and CPU PyTorch retain eager execution unless an adapter is explicitly registered. No JAX dependency is added to ordinary core installs.

fixed_checkpoint=True rematerializes scan stages in reverse mode when the adapter supports it. It can reduce stored intermediates at additional runtime cost; the benefit depends on the model. A scan may still materialize internal states before selecting output boundaries, so benchmark memory for large states. ModelCoreOptions(store_history=False) discards the adopted output history.

This is a fixed explicit driver. Adaptive, implicit, IMEX and stochastic solves do not use fixed_loop; unsupported selections raise. For a previously discovered adaptive mesh use adaptive replay. Fixed solves do not create an AdaptiveStepSchedule or adaptive provenance. The FlepiMoP2 provider's prepared execution has its own loop configuration; this option configures CoreSolver.run directly.

Known right-continuous forcing jumps split steps, including jumps between requested outputs, and incoming endpoint stages use the left limit. Scan execution preserves this contract and the fixed_max_step bound. It does not add error control, positivity enforcement or a scientific accuracy guarantee.

Compile-time measurements

The existing replay benchmark also measures live fixed solves in fresh CPU processes, including tracing, compilation, execution and memory metadata:

uv run python scripts/benchmark_replay.py --execution fixed --methods dopri5 \
  --steps 50 100 200 --loops scan unroll --derivatives grad --timeout 120

These are optional measurements, not CI timing thresholds. The regression test checks one scan and a constant traced graph size at 50 and 200 steps. A small linear example can expose driver overhead; its timing is not a performance claim for a larger scientific model.