Skip to content

Move toward reusing OrdinaryDiffEqCore infrastructure#75

Open
oameye wants to merge 2 commits into
SciML:mainfrom
oameye:reuse-odecore-deoptions
Open

Move toward reusing OrdinaryDiffEqCore infrastructure#75
oameye wants to merge 2 commits into
SciML:mainfrom
oameye:reuse-odecore-deoptions

Conversation

@oameye
Copy link
Copy Markdown
Contributor

@oameye oameye commented May 11, 2026

Refs #23.

Replaces the custom IntegratorStats with SciMLBase.DEStats and moves
tstops / saveat / callback / advance_to_tstop off the top-level
OperatorSplittingIntegrator struct into a DEOptions
(OrdinaryDiffEqCore v4), via a new build_split_deoptions helper. This
aligns OperatorSplittingIntegrator more closely with the broader
OrdinaryDiffEq integrator interface. SplitSubIntegrator retains its
own lightweight IntegratorOptions for now.

Summary

  • Drop IntegratorStats; use SciMLBase.DEStats for both
    SplitSubIntegrator and OperatorSplittingIntegrator.
  • Introduce build_split_deoptions and store options in DEOptions
    instead of as individual fields on the integrator.
  • Update __init / accessors / tests to go through integrator.opts.

Replace custom IntegratorStats with SciMLBase.DEStats and move
tstops/saveat/callback/advance_to_tstop off the top-level integrator
struct into a DEOptions (OrdinaryDiffEqCore v4) via a new
build_split_deoptions helper. This aligns OperatorSplittingIntegrator
more closely with the broader OrdinaryDiffEq integrator interface.
SplitSubIntegrator retains its own lightweight IntegratorOptions.
Copy link
Copy Markdown
Collaborator

@termi-official termi-official left a comment

Choose a reason for hiding this comment

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

I think the most critical piece missing here is using the new tstops infrastructure from OrdinaryDiffEqCore v4 from SciML/OrdinaryDiffEq.jl#2869 and SciML/OrdinaryDiffEq.jl#3441 .

Comment thread src/integrator.jl Outdated
Comment thread src/integrator.jl
Comment on lines +189 to +219
return DEOptions(
1_000_000, # maxiters
save_everystep,
adaptive,
nothing, # abstol
nothing, # reltol
QT(failfactor),
tType(dtmax),
tType(dtmin),
DiffEqBase.ODE_DEFAULT_NORM,
LinearAlgebra.opnorm,
nothing, # save_idxs
tstops, saveat, d_discontinuities,
tstops_cache, saveat_cache, d_discontinuities_cache,
nothing, # userdata
false, 0, "ODE", # progress, progress_steps, progress_name
DiffEqBase.ODE_DEFAULT_PROG_MESSAGE,
:ode, # progress_id
true, false, # timeseries_errors, dense_errors
nothing, false, # delta, dense
save_on, save_start, save_end,
false, false, # save_noise, save_discretes
nothing, # save_end_user
callback,
isoutofdomain,
DiffEqBase.ODE_DEFAULT_UNSTABLE_CHECK,
verbose,
false, false, # calck, force_dtmin
advance_to_tstop,
false, # stop_at_next_tstop
)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Same as above. Most fields do not apply here.

Comment thread src/integrator.jl
tType = typeof(dt)

(!isadaptive(alg) && adaptive && verbose) &&
(!isadaptive(alg) && adaptive && (verbose isa Bool ? verbose : true)) &&
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

_inner_verbose?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

_inner_verbose goes Bool to DEVerbosity, but here I need a Bool out (the && guard and IntegratorOptions.verbose::Bool). Want a sibling _outer_verbose that does the reverse, or should I just require Bool at the API and let callers wrap?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Oh, I see. Just FYI we want to integrate later with the verbosity system, because the bool turned out to be quite a mess in the past (because we can just print all or nothing).

Comment thread src/integrator.jl
# Time helpers
tdir(integrator) =
integrator.tstops.ordering isa DataStructures.FasterForward ? 1 : -1
tdir(integrator::OperatorSplittingIntegrator) = integrator.tdir
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is this now consistent?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes. The field is set once in __init as tType(tstops_internal.ordering isa FasterForward ? 1 : -1), which is exactly what the old dynamic tdir returned.

Comment thread src/integrator.jl
Comment thread test/consistency.jl
Comment thread Project.toml Outdated
@termi-official
Copy link
Copy Markdown
Collaborator

Needs rebase

@oameye
Copy link
Copy Markdown
Contributor Author

oameye commented May 15, 2026

Essentially the step header and footer implementation, which has most of the time stepping logic, do not match anymore. Probably the easiest way here is to pull an LLM to analyze these parts for both packages and adjust in this package the relevant bits using the analysis.

LLM-generated analysis follows, per the suggestion above. I went through OrdinaryDiffEqCore v4's ODEIntegrator, DEOptions, loopheader!/_loopfooter!, modify_dt_for_tstops!, apply_step!, handle_callbacks!, and savevalues! against the corresponding pieces in this package. The skeleton already matches; most of the work is replacing operator-splitting-specific shims with the OrdinaryDiffEqCore machinery that landed in v4.

Ideal design

OperatorSplittingIntegrator is ODEIntegrator minus the bits that don't apply to splitting (FSAL, SDE noise, DAE init), plus the child-integrator tree. It reuses DEOptions, OrdinaryDiffEqCore's tstop targeting, its adaptive controller pattern, and its callback/savevalues path. SplitSubIntegrator is the same shape one level down. IntegratorOptions and IntegratorStats-like shims go away or shrink to what you've signed off on.

OrdinaryDiffEqCore machinery we should adopt

  • DEOptions everywhere. Outer integrator already uses it; the sub integrator should too. Delete the parallel IntegratorOptions.
  • next_step_tstop + tstop_target flag. modify_dt_for_tstops! sets it whenever it clips dt to hit a tstop (with the same 100eps slack OrdinaryDiffEqCore uses). fixed_t_for_tstop_error! becomes a one-liner that consumes the flag instead of doing a runtime distance check.
  • dtpropose field + apply_step! semantics. On accept, header should do update_uprev!, dt = dtpropose, then modify_dt_for_tstops! again. This is what makes adaptive splitting actually work.
  • Cached accept_step::Bool decision. Set in footer, read in header. Removes the duplicate should_accept_step calls.
  • Controller pattern. stepsize_controller! returns q; step_accept_controller!(integrator, alg, q) returns dtnew; calc_dt_propose! writes dtpropose. The existing controller field becomes a real controller_cache (PIController/IController).
  • force_stepfail propagation. Replace the hard error(...) in advance_solution_by! with successful_retcode(child) ? nothing : (parent.force_stepfail = true; return). Child failures flow through the existing reject path.
  • handle_callbacks! + savevalues!. Both currently absent. This is the Solution interface - what's missing? #23 work.
  • Stats in the footer, where the accept/reject decision is made. Lets us drop one of the two should_accept_step calls.
  • do_error_check gating on the inner solve loop's check_error.

What stays splitting-specific

The child tree (child_subintegrators, solution_indices, synchronizers), validate_time_point (it becomes a trivial invariant once tstop targeting is deterministic, but worth keeping as a cheap assertion), and try_snap_children_to_tstop! (the recursive snap is invoked off the new flag).

Concrete gaps (current vs ideal)

Area Current Gap
Tstop snap Runtime abs(ttmp - tstop) < 100eps No flag set by modify_dt_for_tstops!. just_hit_tstop added but unused.
Step size dtcache only No dtpropose. Adaptive can't restore the natural step after a tstop clip.
Accept decision Recomputed in header + footer No accept_step::Bool field.
Child failure Hard error(...) in advance_solution_by! Doesn't propagate via force_stepfail. This is what motivated _force_set_time! in StrangMarchuk (#73).
Controller stepsize_controller!/step_accept_controller! are stubs No q/dtnew threading; controller field unused.
Callbacks/saving initialize!/finalize! only No in-loop handle_callbacks!, no savevalues!.
Sub options IntegratorOptions (6 fields) Should reuse DEOptions.

Phased path

  • A (this PR): flag-based tstop targeting, dtpropose + apply_step!, cached accept_step, force_stepfail propagation on child failure, stats moved to footer. Self-contained; removes the StrangMarchuk _force_set_time! hack as a side effect.
  • B (follow-up, small): unify SplitSubIntegrator on DEOptions.
  • C (Solution interface - what's missing? #23 proper): real controller plumbing + callbacks + savevalues. Own PR.

Happy to do A here.

@termi-official
Copy link
Copy Markdown
Collaborator

Not sure what the best path forward is.

Initially I wanted to design the package in a way that reuses Core similar to DelayDiffEq direcly using the stepping logic similar to (https://github.com/SciML/OrdinaryDiffEq.jl/blob/master/lib/DelayDiffEq/src/integrators/interface.jl). @ChrisRackauckas is this the way to go or should we keep the custom stepping infrastructure here for the outer integrator? The curent struct is here

"""
OperatorSplittingIntegrator <: AbstractODEIntegrator
A variant of [`ODEIntegrator`](https://github.com/SciML/OrdinaryDiffEq.jl/blob/6ec5a55bda26efae596bf99bea1a1d729636f412/src/integrators/type.jl#L77-L123)
to perform operator splitting.
"""
mutable struct OperatorSplittingIntegrator{
fType,
algType,
uType,
tType,
pType,
heapType,
tstopsType,
saveatType,
callbackType,
cacheType,
solType,
subintTreeType,
childSolidxType,
childSyncType,
controllerType,
optionsType,
} <: SciMLBase.AbstractODEIntegrator{algType, true, uType, tType}
const f::fType
const alg::algType
u::uType # Master solution
uprev::uType # Master solution previous step
tmp::uType # Interpolation buffer
p::pType
t::tType # Current time
tprev::tType
dt::tType # Time step length used during time marching
dtcache::tType # Proposed time step length
const dtchangeable::Bool
tstops::heapType
_tstops::tstopsType
saveat::heapType
_saveat::saveatType
callback::callbackType
advance_to_tstop::Bool
last_step_failed::Bool
force_stepfail::Bool
isout::Bool
u_modified::Bool
cache::cacheType
sol::solType
# Tuple of SplitSubIntegrator nodes (one per top-level operator).
child_subintegrators::subintTreeType
child_solution_indices::childSolidxType # Tuple
child_synchronizers::childSyncType # Tuple
iter::Int
controller::controllerType
opts::optionsType
stats::IntegratorStats
tdir::tType
end
.

The main difficulty I faced so far for the intermediate subintegrators in the tree (

"""
SplitSubIntegrator <: AbstractODEIntegrator
An intermediate node in the operator-splitting subintegrator tree.
Each `SplitSubIntegrator` is self-contained: it knows its own solution indices,
its children's synchronizers, solution indices, and sub-integrators. It does
**not** carry an `f` field (operator information lives in the cache/algorithm).
## Fields
- `alg` — `AbstractOperatorSplittingAlgorithm` at this level
- `u` — local solution buffer for this sub-problem (may be a
view *or* an independent array, e.g. for GPU sub-problems)
- `uprev` — copy of `u` at the start of a step (for rollback)
- `u_master` — reference to the full master solution vector of the
outermost `OperatorSplittingIntegrator` (needed for sync)
- `t`, `dt`, `dtcache` — time tracking
`dtchangeable`, `stops`
- `iter` — step counter at this level
- `EEst` — error estimate (`NaN` for non-adaptive, `1.0` default
for adaptive)
- `controller` — step-size controller (or `nothing` for non-adaptive)
- `force_stepfail` — flag: current step must be retried
- `last_step_failed` — flag: previous step failed (double-failure detection)
- `status` — [`SplitSubIntegratorStatus`](@ref) for retcode communication
- `cache` — `AbstractOperatorSplittingCache` for the algorithm at
this level
- `child_subintegrators` — tuple of direct children (`SplitSubIntegrator` or
`DEIntegrator`)
- `solution_indices` — global indices (into parent `u`) **owned by this node**
- `child_solution_indices` — tuple of per-child global solution indices
- `child_synchronizers` — tuple of per-child synchronizer objects
"""
mutable struct SplitSubIntegrator{
algType,
uType,
tType,
tstopsType,
EEstType,
controllerType,
cacheType,
childSubintType,
solidxType,
childSolidxType,
childSyncType,
optionsType,
} <: SciMLBase.AbstractODEIntegrator{algType, true, uType, tType}
alg::algType
u::uType # local solution buffer
uprev::uType # local rollback buffer
u_master::uType # reference to outermost master u
t::tType
tprev::tType
dt::tType
dtcache::tType
const dtchangeable::Bool
tstops::tstopsType
iter::Int
EEst::EEstType
controller::controllerType
force_stepfail::Bool
last_step_failed::Bool
u_modified::Bool # TODO we can probably remove this
status::SplitSubIntegratorStatus
stats::IntegratorStats
cache::cacheType
child_subintegrators::childSubintType # Tuple
solution_indices::solidxType
child_solution_indices::childSolidxType # Tuple
child_synchronizers::childSyncType # Tuple
opts::optionsType
tdir::tType
end
) do not have continuous solutions and that part of the solution information is redundant. Right now I only use the solution objects to propagate information about the success or failure of an inner solve to the outer integrator. The intermediate subintegrators can essentially just perform adaptive time stepping with integrated tstops and synchronization of the local part of u. No events, no saving. Should we also force there through the Core header/footer logic or have keep a custom logic?

DEOptions everywhere. Outer integrator already uses it; the sub integrator should too. Delete the parallel IntegratorOptions.

I cannot comprehend this decision. There is some overlap in the options, but the full set of options is disjoint, as we may introduce OS specific options later on, which should not pollute the struct in Core.

  • next_step_tstop + tstop_target flag. modify_dt_for_tstops! sets it whenever it clips dt to hit a tstop (with the same 100eps slack OrdinaryDiffEqCore uses). fixed_t_for_tstop_error! becomes a one-liner that consumes the flag instead of doing a runtime distance check.

  • dtpropose field + apply_step! semantics. On accept, header should do update_uprev!, dt = dtpropose, then modify_dt_for_tstops! again. This is what makes adaptive splitting actually work.

On that point I fully agree that this should be fully adopted in some way. However, we must be careful about the interaction between the snapping logic and the new tstop state-machine to not end up in some corrupt state.

Cached accept_step::Bool decision. Set in footer, read in header. Removes the duplicate should_accept_step calls.

Why is this a problem (other that calling the header/footer code in Core directly)?

  • Controller pattern. stepsize_controller! returns q; step_accept_controller!(integrator, alg, q) returns dtnew; calc_dt_propose! writes dtpropose. The existing controller field becomes a real controller_cache (PIController/IController).

Agreed that this must be done, but as a separate PR.

  • force_stepfail propagation. Replace the hard error(...) in advance_solution_by! with successful_retcode(child) ? nothing : (parent.force_stepfail = true; return). Child failures flow through the existing reject path.

I think we have done this already in the other PR.

  • handle_callbacks! + savevalues!. Both currently absent. This is the Solution interface - what's missing? #23 work.

  • Stats in the footer, where the accept/reject decision is made. Lets us drop one of the two should_accept_step calls.

  • do_error_check gating on the inner solve loop's check_error.

I agree that this is also separate for now.

@termi-official
Copy link
Copy Markdown
Collaborator

cc @oscardssmith

@oameye
Copy link
Copy Markdown
Contributor Author

oameye commented May 23, 2026

Spent some time reading DelayDiffEq and Core to ground the design questions.

DDEIntegrator isn't <: ODEIntegrator. It just carries the field names Core reads (dtpropose, accept_step, opts::DEOptions, stats::DEStats, ...) and overrides four functions: perform_step!, loopfooter!, savevalues!, postamble!. Everything else is Core's, including the controller / save / callback / tstop machinery. The outer solve! and step! are ~15 line copies of Core's while loop.

Translating to splitting, the pattern fits:

  • perform_step! body = the existing advance_solution_by!.
  • loopfooter! override = rollback children to uprev on reject. Same shape as DelayDiffEq's inner-integrator rollback.
  • Save points and callbacks light up for free, because Core's _loopfooter! calls handle_callbacks! and _savevalues!, both gated by traits that handle "no k", "no noise" already.

On your other points: sub-integrators stay custom (no sol, no callbacks, nothing to save), which also resolves your DEOptions pushback, only the outer gets full DEOptions. Cached accept_step becomes free with delegation. dtpropose corruption stays away as long as modify_dt_for_tstops! is the only thing that clips, and the current fixed_t_for_floatingpoint_error! flow gets replaced rather than coexists.

The one capability that doesn't come free is continuous callbacks (they'd need integrator(t) between outer-step endpoints, exactly the issue #23 problem). Discrete callbacks and saveat aligned with tstops are fine.

If the structural-duck-typing path is right, happy to take a swing as a follow-up PR.

@termi-official
Copy link
Copy Markdown
Collaborator

Thanks for taking a more detailed look on the design.

DDEIntegrator isn't <: ODEIntegrator. It just carries the field names Core reads (dtpropose, accept_step, opts::DEOptions, stats::DEStats, ...) and overrides four functions: perform_step!, loopfooter!, savevalues!, postamble!. Everything else is Core's, including the controller / save / callback / tstop machinery. The outer solve! and step! are ~15 line copies of Core's while loop.

This was also my initial plan, but I have not adopted it yet for two reasons. First, when initially trying to adopt the design I was unsure about the invariants on the event and solution systems involved for the intermediate integrators. Second, I was (and still am) not 100% sure how to guarantee that new minor releases of Core do not cause this package to break, as we rely on internals this way. At least before OrdinaryDiffEq v7 has been released. With the new release I am still not sure what exactly marks the public interface here and all the invariants the integrator data structure has to fulfil. The additional complexity here is that the integrators interact with each other and thus create new invariants which might conflict with invariants assumed in Core -- which is why I pinged Oscar and Chris above. I hope they will find some time soon to comment on this discussion here.

perform_step! body = the existing advance_solution_by!.

Correct, but note that the signature is significantly different, which is why I have chosen a different name for now to underline this.

On your other points: sub-integrators stay custom (no sol, no callbacks, nothing to save), which also resolves your DEOptions pushback, only the outer gets full DEOptions. Cached accept_step becomes free with delegation. dtpropose corruption stays away as long as modify_dt_for_tstops! is the only thing that clips, and the current fixed_t_for_floatingpoint_error! flow gets replaced rather than coexists.

I also think so, but this needs confirmation from one of the OrdinaryDiffEqCore devs. Especially one tricky thing with higher order integrators is that the might integrate in both temporal directions to keep the number of stages small and this really needs some careful thinking here about the tstop integration. Unfortunately a bit of a chicken-egg problem here.

The one capability that doesn't come free is continuous callbacks (they'd need integrator(t) between outer-step endpoints, exactly the issue #23 problem)

We can also make this for free, but at least on the outer level this is almost always too expensive to perform. Note that we can simply implement the full evaluation of the GenericSplitFunction by walking the function tree and moving views into the leaf function calls. The remaining tricky part which I have not touched here yet is to distinguish between the different eval types (e.g. DAE eval vs ODE eval).

Is the update here urgent for you? If not, then I would like to wait for feedback from the Core devs first before moving on.

@oameye
Copy link
Copy Markdown
Contributor Author

oameye commented May 23, 2026

Is the update here urgent for you?

Nope not urgent. Just trying to push things along :)

@termi-official
Copy link
Copy Markdown
Collaborator

I moved some of the fixes for v7 into a separate PR (#81) so they become already available.

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.

2 participants