SEP 39 — A native Adams march: the Multistep step loop in the compiled backend#

SEP

39

Title

A native Adams march: the Multistep step loop in the compiled backend

Author

John Travers

Drafting agent

Opus 5.5 (2026-10-28)

Status

Final

Review status

Reviewed

Reviewers

John Travers (human); GPT-6 (AI)

Type

Change

Created

2026-10-28

Resolution

Accepted 2026-10-28 by John Travers, after his human review, the GPT-6 AI review and the response to it, his decisions Q1–Q14, R9 and R12, and the refresh of Part I against LupoLab/solprop#56 (Part III). The proposal lands with its implementation’s single pull request. Final 2026-10-30 by John Travers, after phase 5’s HIP qualification on an AMD Instinct MI325X, with the native performance work that followed (LupoLab/solprop#59).

Requires

SEP 16 (the march, its engine, ADR-043); SEP 25 (agreement to the error bound); SEP 29 (native z tables and the history export)

Supersedes

—

Superseded-by

—

Implementation

Opus 5.5 (AI) for John Travers, branch feature/sep-0039-native-adams-march (LupoLab/solprop#58), based on LupoLab/solprop#56 and on main once it merged: started 2026-10-28; phases 0–5 complete (2026-10-30; phase 5 on NVIDIA A40s, an AMD Instinct MI325X and an NVIDIA H200); HIP builds take the native march by default from LupoLab/solprop#59

Manual pages

The Adams methods

Part I — Proposal#

1. Abstract#

Move the step loop of Multistep() from solprop’s Python AdamsMarch into solprop-native. That loop is the step-size controller, the variable-order choice, the proposal caps and landings, the coefficient tables and the stage scalars of every trial. On the native backend a run of accepted steps would then cross the nanobind seam once per host event: a save plane, a boundary-monitor check, a statistics row, the end of a march. Today, on the head of LupoLab/solprop#56, it crosses \(k + 2\) to \(k + 5\) times per trial, where \(k\) is the number of scalar tables read (typically four to six). It also spends 40–50 µs of Python per trial on an Apple M5 Max and 170–210 µs on an AMD EPYC 7763. At drafting, before LupoLab/solprop#55, those figures were 110–140 µs and 390–510 µs.

The recommended design (option B, §6.2) keeps every policy in solprop. solprop hands the backend one record holding every controller and landing parameter, plus the events at which it must return. Native then executes an algorithm that this SEP (§4) and the kernel contract specify in full. The Python march stays: it is the reference, and it still runs on numpy_ref and numba_cpu.

The case for it, from a profiling study of 2026-10-28 and its re-measurement at the refresh (§3.3):

  • At drafting the Python side of a trial cost 110–140 µs on an Apple M5 Max and 390–510 µs on an AMD EPYC 7763: 1.5 to 1.7 source evaluations on the EPYC for the cheap mode-averaged capillaries. LupoLab/solprop#55 has since removed more than half of it. What remains is 38–51 µs (M5) and 170–210 µs (EPYC), or 9–17 % and 15–22 % of a single-mode trial.

  • The native march would remove about nine tenths of what remains: 6–7 % of a single-mode run with phase 1 alone, and a further 2–11 % with the march, by case and machine (§6.1).

  • On device builds the host’s share of a trial is far larger (§6.7), and the march is what removes it.

Success means:

  • on the tracking set’s capillaries, the native march takes the same steps as the Python march on the same engine (a diagnostic, never a promise; SEP 25);

  • it meets each case’s accuracy envelope;

  • with a quiet boundary monitor, its host cost per trial is at most 15 µs on the M5 and 40 µs on the EPYC;

  • it is nowhere slower than the Python march beyond the A/B spread, including on expensive sources and runs that return every step (§7.6).

With the native-side work of §6.9, nearly all of which has landed, the estimates at one thread are:

  • the single-mode helium capillary comes within 1.15–1.2 times of RungeKutta() on both machines. At sixteen threads on the M5 it is already level.

  • the argon blue-shift capillary stays 1.8–1.9 times behind on the M5 and about three times behind on the EPYC. It takes more source calls than Runge–Kutta does. On the EPYC the native Adams passes, not the host, are now the larger part of the gap (§3.3): native work that needs no SEP.

This SEP changes no default, and it does not decide the capillary default.

2. Motivation and scope#

The use case is the default integrator of prop_capillary, which is Multistep() at rtol 1e-6 (ADR-055). SEP 37’s phase 0 found RungeKutta() faster on 8 of 14 capillary rows at one thread and on 11 of 14 at sixteen. Multistep won the four-mode rdw and the noiseless comb-to-continuum. The maintainers kept the capillary default and asked where Multistep’s per-step time goes.

The 2026-10-28 profiling study of the native CPU backend measured that time (§3.3, with hardware). Its findings, at drafting:

  • Host cost per trial. A Multistep trial spends 110–140 µs in Python on the M5 and 390–510 µs on the EPYC. The EPYC’s host side alone is 0.4–0.5 ms of a 1.1–2.1 ms trial.

  • Runge–Kutta’s host cost is 10 µs per call on the M5 and 40–50 µs on the EPYC, spread over six calls a step.

  • Native passes. Beyond its one source call, a trial’s native passes cost 0.9 to 3.9 source calls by machine and case. Part of that is being removed by ordinary optimisations (§6.9).

  • The Python pieces on the EPYC, per step:

    • the stage-scalar packing, twice per trial: 130–156 µs;

    • the coefficient tables: 126–134 µs;

    • the compensation tables: 34 µs;

    • the controller’s decision: 39–47 µs;

    • the accept: about 20 µs;

    • the rate weights: 22 µs.

  • Moving the step loop to native removes the largest remaining host item on every single-mode case. native/AGENTS.md keeps the controller in solprop, which is why this is a SEP.

At the refresh (head of LupoLab/solprop#56, §3.3):

  • The tables are one native call, and the stage scalars are one bundle packed once.

  • Per helium trial on the EPYC, the largest remaining Python pieces are:

    • the stage arrays, about 113 µs instrumented, of which the bundle’s Clenshaw reads are 73;

    • the decision and the accept call’s wrapper, about 48;

    • the rate weights, 26;

    • the landing and the approach, 30;

    • the order choice and the stencil, 17.

  • The Python host time per trial, measured lean, is 212 µs for helium and 151–177 µs for argon on the EPYC, and 51 and 38 µs on the M5.

  • The native passes’ cost beyond the source has become the larger item on the EPYC. There, the predict and correct passes take 844 µs of a helium trial and 304 µs of an argon one, against a source of about 290 and 200 µs. On the M5 they take 269 and 85 µs, against about 155 and 110 µs.

Who needs it. Every native-backend run with Multistep() on a source cheap enough for the host to matter. The method is the default of prop_capillary, prop_radial and prop_planar. That covers:

  • the mode-averaged and single-mode capillaries;

  • scans of them, where the same small run repeats many times;

  • device builds, where SEP 16 found that “about 26 launches and 0.2 ms of host work” per trial is what a cheap source cannot hide.

The maintainer wants every bit of performance available here. This SEP is the largest host-side lever, and §6.9 places it among the native levers.

In scope:

  • a pure, data-driven controller core in solprop, which the Python march and the native march’s tests share (phase 0);

  • native coefficient tables and native stage scalars (phase 1). These are useful on their own and are the first half of the march;

  • the native march for the exponential formulation (the default) on the resident engine (phase 2), over:

    • diagonal operators, whether constant, an expansion or a z table, with the transported compensation;

    • the carrier frame;

    • the in-stage collar and the quiet collar;

    • dense observation and hard kinks;

    • step_planes, max_calls and statistics rows;

    • the boundary monitor;

    • snapshots and resume;

  • the interaction formulation’s fused path, and replayed schedules of single runs (phase 3);

  • the resident coupled-flow sections and moving maps (phase 4). The aim is to cover every configuration that has a resident engine (Q2);

  • device builds (phase 5), qualified before Final (Q6);

  • the seam: a march-policy record, the scalar tables, the host events, one metadata field;

  • the amendment of native/AGENTS.md’s policy rule and of ADR-043 (§6.3).

Not in scope:

  • Any change to the method, the controller’s algorithm, its constants or any default. The native march executes the Python march’s algorithm, and native/AGENTS.md forbids tuning the controller for speed.

  • The capillary default, and every other default. Outside this SEP’s scope (decided, Q10).

  • Fewer steps on the argon blue-shift cases. Multistep takes 11 169 calls there against Runge–Kutta’s 10 105, so no overhead work wins that case (§4.1). That needs a method change, not an execution change.

  • Runge–Kutta’s host loop. Its cost per call is small. A native Runge–Kutta march is not planned, and can be taken up another time if it is needed (decided, Q8).

  • The fixed 90–190 ms per run that goes to metadata, provenance, output and plane observation. It is the same for both methods. This draft adds it to the roadmap as its own item.

  • The general Adams path (ComparisonMultistep: PECE, explicit Adams–Bashforth, matched pairs), lockstep batches, and a compiled march for numba_cpu (§6.6).

  • A controller resident on a GPU. On device builds the controller runs on the host thread (§6.7).

  • The native optimisations of §6.9. They need no SEP and proceed independently. That includes the cost of the exponential Adams passes themselves, which on the EPYC is now larger than the host’s share (§3.3).

3. Context#

3.1 The march today#

solprop.backends.multistep.AdamsMarch is the step loop of every backend (stepper.md, “Low-call Adams methods”). ADR-043 (decisions.md) split it from its arithmetic:

AdamsMarch keeps the controller – the stencil, orders, rejection, restarts, the call budget, the ledger, schedules and lockstep – and hands every state-sized operation of its fused and exponential paths to an engine.

The reason it gave: “one controller for every backend keeps the order, rejection and lifecycle rules in one place, tested once.”

On native the engine is _ResidentAdams (native/src/solprop_native/backend.py). The history, the predictor, the source, the corrector and the defect norms are resident. GeneralWorkspace::adams_exponential_trial (native/src/general.cpp) evaluates the source in C++ inside the same call: it runs the stage pipeline with the GIL released and returns at most three norms. Since the drafting, the trial runs as two fused passes around its source and the accept as one (LupoLab/solprop#55). What Python still does per exponential trial, as of LupoLab/solprop#56:

  1. hoists the anchor. This is normally a cached Clenshaw value, with no native call.

  2. forms all of the trial’s coefficient tables in one native helper call (_core.trial_tables, the strict lagrange::trial_tables; LupoLab/solprop#55). On a varying operator it also forms the endpoint’s rate weights, which is one more Clenshaw call, uncached (_rate_value).

  3. forms one stage-scalar bundle (translate.stage.scalars): one native Clenshaw call per scalar table whose cache misses, typically four to six. The bundle is packed into one stage row and repeated into the Runge–Kutta-shaped stage arrays the trial binding takes (_repeated_arrays). The lab-frame sample reuses the step’s arrays with the \(\Phi\) increment zeroed (_resident_sample). z tables, the separable operator, SEP 6’s dispersive channels and moving maps keep the general packing (_trial_arrays, _weight_rows).

  4. forms the carrier shift from the reference element’s chord, on the host, through Numba’s Clenshaw.

  5. makes the one native trial call, whose binding converts 28 arguments.

  6. runs the controller (_stencil, _decide, _choose_order, _reject, _factor, limit_step), the proposal caps (_capped, _quiet_capped, _collar_reentry) and the landing rule (stepper.schedule.landing, _approach), which now search float tuples with bisect.

  7. makes one native accept call when the trial is accepted.

  8. adds a table_evaluate call, and a table_rate call when compensated, on a z-table run.

  9. runs the driver’s per-step bookkeeping:

    • _record on every attempt (history row, progress), whose backend hook calls the native promote_fsal on every accepted step;

    • _take_step_stats on every accepted step (the monitor’s due/skip, statistics rows);

    • _natural_stop;

    • committing() around the accept.

So an exponential trial crosses the seam \(k + 2\) times when rejected and \(k + 4\) times when accepted, where \(k\) is the number of scalar tables it reads. A compensated trial adds one crossing for the rate.

The low-storage march of SEP 24 already makes a step one native call (_ResidentLowStorage.step). It only replays a prescribed schedule and keeps no estimate (ADR-050), so it has no controller to move. It is the precedent for the commit pattern (§6.5.7), not for policy.

3.2 What the earlier proposals measured and left#

  • SEP 14, phase 2. A Kokkos Adams attempt on the canonical capillary took 0.67 ms, of which 0.29 ms (43 %) was the host march. The native step was “left for a later change”.

  • SEP 16.

    • On a device, compiled Clenshaw and Lagrange helpers took the canonical trial’s host work from 0.50 ms to 0.13 ms.

    • On the coupled wall, a quarter of a step (0.37 ms) was “the controller’s and the tables’ Python, which a device’s faster kernels will not shrink”.

  • SEP 25 §6.3 notes that Kokkos “still pays host table evaluation, controller work and records” per trial.

  • backends.md §3.5 rejected a compiled step driver because the per-call overhead was below the noise floor. That was measured on Runge–Kutta, where a step makes six calls and spreads the cost. An Adams step makes one call and pays all of it.

3.3 The measurements this SEP starts from#

Method. Measured on main at 6216178, as complete runs of three tracking cases, timed by the benchmark worker after its warm-up, with the suite’s thread pools and the passive OpenMP policy (ADR-056). The cases:

  • he_rdw_averaged: helium on a pressure gradient, shape (1, 1, 1, 2561). Its expansion operator gives the exponential trial the transported compensation.

  • argon_blueshift_averaged: argon at constant pressure, shape (1, 1, 1, 1601).

  • rdw: four modes on the helium gradient, shape (1, 4, 1, 2561).

How the costs were taken:

  • Native time per labelled kernel: a host-only Kokkos Tools timer.

  • Host time: timers wrapped around the march’s Python pieces. The host gap is wall time less Kokkos time.

  • Hardware:

    • Apple M5 Max: macOS, Homebrew Clang 23.1.3, -mcpu=native, a shared workstation, so these numbers are indicative;

    • AMD EPYC 7763: Linux, 16 cores of one NUMA domain, GCC 16.2, glibc 2.34, OpenBLAS 0.3.34, FFTW 3.3.11, -march=native.

  • A/B comparisons: fresh processes, interleaved over three rounds, medians.

  • The run’s fixed host cost is excluded from the per-trial figures below. It is 90–190 ms per run (122 ms on the EPYC) for metadata, provenance, output and plane observation, and it is the same for both methods.

Per trial (µs), one thread. “Passes” are the exponential trial’s native work beyond its source: phase, moments and predictor (with the compensation), corrector, defect norms, history, mask and copies. RK’s figure is per source call, with its host share in brackets.

Case

Machine

Source

Passes

Host

Trial

RK per call

he_rdw_averaged

Apple M5 Max

174

306

140

623

219 (10)

argon_blueshift_averaged

Apple M5 Max

121

110

108

338

138 (9)

rdw

Apple M5 Max

2631

1352

195

4123

2505 (34)

rdw, 16 threads

Apple M5 Max

1382

971

228

2508

1083 (34)

he_rdw_averaged

AMD EPYC 7763

323

1274

≈500

≈2100

441 (49)

argon_blueshift_averaged

AMD EPYC 7763

239

475

≈405

≈1120

291 (39)

Notes on the table:

  • The pieces come from separate profiles, so a row’s sum differs from its trial by up to 3 %.

  • The single-column cases do not thread at sixteen: they lie below the stage pool’s size threshold (8192 elements on macOS, 4096 on Linux). Their sixteen-thread host costs were 134 µs (helium) and 111 µs (argon) on the M5.

  • On the EPYC the host side is a quarter to a third of a trial.

  • The coefficient tables alone cost 126–134 µs a step on the EPYC and 15–34 µs on the M5.

Complete runs, baseline (s).

Case

Machine, threads

Multistep()

RungeKutta()

he_rdw_averaged

Apple M5 Max, 1

0.967

0.705

he_rdw_averaged

Apple M5 Max, 16

0.973

0.692

argon_blueshift_averaged

Apple M5 Max, 1

3.863

1.451

argon_blueshift_averaged

Apple M5 Max, 16

3.840

1.465

rdw

Apple M5 Max, 16

4.627

11.60

he_rdw_averaged

AMD EPYC 7763, 1

3.150

1.389

argon_blueshift_averaged

AMD EPYC 7763, 1

12.61

3.012

rdw

AMD EPYC 7763, 1

22.03

59.06

rdw

AMD EPYC 7763, 16

21.32

47.43

he_rdw_averaged

AMD EPYC 7763, 16, shared node

3.392

1.654

argon_blueshift_averaged

AMD EPYC 7763, 16, shared node

12.60

2.969

The rdw RK figure on the M5 at sixteen threads comes from a profile run and was not interleaved with the others.

Steps and calls.

  • argon_blueshift_averaged:

    • Multistep: 11 168 trials, 11 169 source calls.

    • Runge–Kutta: 1 684 steps, 10 105 calls.

  • he_rdw_averaged:

    • Multistep: about 1 400 trials.

    • Runge–Kutta: about 2 700–2 900 calls.

  • rdw:

    • Multistep: about 1 800 trials.

    • Runge–Kutta: about 10 500 calls.

The helium and rdw counts are inferred from each run’s time and its per-trial or per-call cost, so read them as about ±5 %. Phase 0 replaces them with counted values, in a public benchmark entry (§7.6). The counted values below show that the rdw Runge–Kutta count was overestimated by about 2.5 times.

At the refresh: the head of LupoLab/solprop#56 (a500d2d), 2026-10-28. LupoLab/solprop#51, #55 and #56 had landed: the φ-functions’ stopping test, Range2 tiling, the host-cost work, the fused two-pass trial, and SLEEF vector maths on x86-64 (§6.9).

Method.

  • The benchmark cases at their registry definitions, run by benchmarks.worker.timed_run after its warm-up, with the suite’s thread pools and passive policy.

  • Fresh processes, Multistep and Runge–Kutta interleaved in alternating order over three rounds, medians reported.

  • h2_raman_averaged runs at a length fraction of 0.4, and rdw at 0.25.

  • Host time per trial is measured lean: the march’s wall time less its native adams_* calls, with no other wrappers.

  • Steps and calls are counted, not inferred.

  • The AMD EPYC 7763 machine is as at drafting (GCC 16.2, SLEEF’s AVX2 functions), on 16 cores of one NUMA domain of a node shared with other jobs.

  • The Apple M5 Max (Homebrew Clang 23.1.3, FFTW with NEON codelets, the system libm) was otherwise idle.

  • The EPYC’s sixteen-thread rows scattered by more than their effects on the shared node, several of them slower than at one thread, so only its one-thread rows are used.

Complete runs, one thread (s), and the host and native time per trial (µs).

Case

Machine

Multistep()

RungeKutta()

MS / RK

Host

Native

he_rdw_averaged

Apple M5 Max

0.815

0.650

1.25

51

448

argon_blueshift_averaged

Apple M5 Max

2.920

1.419

2.06

38

213

h2_raman_averaged (0.4)

Apple M5 Max

1.755

1.311

1.34

40

263

rdw (0.25)

Apple M5 Max

1.711

2.736

0.63

129

3 730

he_rdw_averaged

AMD EPYC 7763

2.126

1.528

1.39

212

1 182

argon_blueshift_averaged

AMD EPYC 7763

9.17 (7.83–9.47)

2.460

3.7 (3.2–3.9)

177

632

h2_raman_averaged (0.4)

AMD EPYC 7763

4.820

2.146

2.25

173

695

rdw (0.25)

AMD EPYC 7763

4.157

5.440

0.76

372

9 245

At sixteen threads on the M5:

Case

Multistep()

RungeKutta()

MS / RK

he_rdw_averaged

0.636

0.650

0.98

argon_blueshift_averaged

2.474

1.381

1.79

h2_raman_averaged (0.4)

1.697

1.652

1.03

rdw (0.25)

0.862

1.256

0.69

Steps and calls, counted (accepted/rejected, then source calls):

Case

Multistep()

RungeKutta()

he_rdw_averaged

1 434/3, 1 438

480/1, 2 887

argon_blueshift_averaged

11 166/2, 11 169

1 684/0, 10 105

h2_raman_averaged (0.4)

5 417/2, 5 420

1 308/0, 7 849

rdw (0.25)

393/1, 395

175/0, 1 051

Where a trial’s native time goes. Per trial, from a host-only Kokkos label timer (µs). “Source” is the rest of the Kokkos-active time.

Case

Machine

Predict pass

Correct pass

Source

he_rdw_averaged

Apple M5 Max

169

100

≈155

argon_blueshift_averaged

Apple M5 Max

51

34

≈110

he_rdw_averaged

AMD EPYC 7763

557

287

≈290

argon_blueshift_averaged

AMD EPYC 7763

192

112

≈200

On the EPYC the two Adams passes now cost 1.5 to 2.9 source evaluations a trial, while the host costs 0.6 to 0.9. Their per-element cost is 2.9–3.8 times the M5’s, against about 1.9 times for the source. That is native work outside this SEP (§6.9): the march removes the host’s share, not the passes’.

Prototypes. Every prototype took the baseline’s accepted and rejected steps on every case, with final fields within 4e-10 of the baseline. They are listed with their results in §6.9.

3.4 The rules this SEP meets#

  • native/AGENTS.md, Product and architecture:

    The layers are solprop -> Python backend adapter -> nanobind seam -> a Python-free C++20 core. Native code never imports Python or solprop concepts and never owns user-facing policy.

  • The same file, Numerical and performance discipline: “never tune the step-size controller to gain speed”; and a trial “performs no allocation, planning, logging, Python call or unplanned fence”.

  • ADR-043: one controller for every backend (§3.1).

  • ADR-047: “Native code is not interrupted: a signal waits for a native call to return.”

  • ADR-054: the controller, scalars and schedules are binary64 on the host. A native controller keeps both properties: binary64 arithmetic, run on the host thread.

  • SEP 0 §7: backends in order (numpy_ref, then numba_cpu, then native), and parity across all three at Final or an exception written into the accepted SEP.

4. Theory#

This section is the algorithm the native march executes, written so that it can be implemented and checked without reading backends/multistep.py. The Python march is its reference implementation, and a disagreement between the two is a defect in one of them. Notation follows SEP 13 and SEP 16:

  • \(z\) is the position in m and \(h\) a step in m.

  • \(\theta = (z' - z)/h\) is a normalised position inside the step anchored at \(z\).

  • \(x = \Phi(z + h) - \Phi(z)\) is the step’s exponent, in the internal convention (conventions.md).

4.1 Why the host matters: the cost of a run#

A Multistep trial costs

\[ T_{\mathrm{MS}} = C_N + C_A + C_H, \]

where:

  • \(C_N\) is the source evaluation;

  • \(C_A\) is the Adams passes;

  • \(C_H\) is the host side.

A Runge–Kutta call costs \(T_{\mathrm{RK}} = C_N' + c_H\), where \(C_N'\) includes the stage’s entry propagator and \(c_H\) is the host’s share of one call. Multistep is faster when

\[ \frac{n_{\mathrm{MS}}}{n_{\mathrm{RK}}} < \frac{T_{\mathrm{RK}}}{T_{\mathrm{MS}}}, \]

with \(n\) the source calls of each method. The drafting measurements of §3.3 gave these ratios:

Case

\(n_{\mathrm{MS}}/n_{\mathrm{RK}}\)

\(T_{\mathrm{RK}}/T_{\mathrm{MS}}\), M5

\(T_{\mathrm{RK}}/T_{\mathrm{MS}}\), EPYC

The same with \(C_A = C_H = 0\), M5 / EPYC

he_rdw_averaged

≈0.50

0.35

0.21

1.26 / 1.37

argon_blueshift_averaged

1.105

0.41

0.26

1.14 / 1.22

rdw (M5 at 16 threads)

≈0.17

0.43

—

0.78 / —

The refresh’s counted runs (§3.3, one thread) give these, with the fixed cost per run (0.09 s on the M5, 0.12 s on the EPYC) taken off before dividing:

Case

\(n_{\mathrm{MS}}/n_{\mathrm{RK}}\)

\(T_{\mathrm{RK}}/T_{\mathrm{MS}}\), M5

\(T_{\mathrm{RK}}/T_{\mathrm{MS}}\), EPYC

The same with \(C_A = C_H = 0\), M5 / EPYC

he_rdw_averaged

0.498

0.38

0.35

1.25 / 1.68

argon_blueshift_averaged

1.105

0.52

0.29

1.20 / 1.16

rdw (0.25)

0.376

0.61

0.49

—

Three conclusions follow, and they hold at the refresh.

  • Helium: Multistep makes half the calls and loses only through \(C_A + C_H\). At drafting the host was \(C_H/T_{\mathrm{MS}}\) = 22 % of a trial on the M5 and 24 % on the EPYC. At the refresh it is 10 % and 15 %, and the passes \(C_A\) are 53 % and 60 %.

  • Argon: even at zero overhead Multistep would be at most 4–9 % faster (1.105 against 1.16–1.20), and any real \(C_A\) loses that. That case needs fewer steps.

  • rdw: Multistep already wins, and cutting \(C_A\) or \(C_H\) only widens the margin.

4.2 The march’s policy and state#

The policy \(\mathcal{P}\) is everything the algorithm reads but never changes. solprop forms it once per run, from the method (Multistep), the run’s stepper settings (kernel-contract.md §7) and the march’s module constants:

Symbol

Meaning

Source today

\(q_{\max}\)

highest predictor order, 1–6

Multistep.order (5)

—

variable order

Multistep.variable_order (true)

\(s\), \(g\), \(f_{\min}\)

safety, growth cap, smallest factor

Multistep.safety (0.8), max_growth (2), min_factor (0.2)

\(g_{\mathrm{rej}}\)

growth cap of a rejection’s factor

the literal 0.9 in AdamsMarch._reject

\(H\)

order hysteresis

ORDER_HYSTERESIS (1.2, backends.multistep)

\(R\)

consecutive rejections before a history restart

RESTART_AFTER (3, backends.multistep)

\(c\), \(P_k\)

collar stability share; PEC negative-real interval of order \(k\)

COLLAR_STABILITY (1, backends.multistep); PEC_REAL_INTERVAL (stepper.multistep)

\(\rho\)

the in-stage collar’s largest rate, \(\max\alpha/2\) in m⁻¹; 0 without one, outside the exponential formulation, or under the split scheme

AdamsMarch._collar_rate

\(C_\kappa\)

carrier phase cap, rad, when a carrier frame is active

CARRIER_PHASE_CAP (1, stepper.multistep)

\(B\)

the call budget, or none

Multistep.max_calls

—

equal approach; interval cap

Multistep.equal_approach; interval_cap

\(h_0\), \(h_{\min}\), \(h_{\max}\), \(\sigma\)

first proposal, floor, cap, stretch

dz_init, dz_min, dz_max, stretch

\(z_{\mathrm{end}}\)

end of the run

z_end

\(\mathcal{Z}_s\), \(\mathcal{Z}_k\), \(\mathcal{Z}_b\)

save planes, hard kinks, interval edges

save_planes (empty when planes are interpolated), linear.hard_kinks, linear.phi_breakpoints (or its first and last entry without the interval cap)

\(u\)

the landing tolerance in units in the last place

LANDING_ULPS (8, backends.driver; repeated as _LANDING_ULPS in stepper.schedule, which phase 0 makes one constant)

\(r_{\mathrm{tol}}\), \(a_{\mathrm{tol}}\), \(w\), region

the norm’s scale and the bins it covers

PIController.scale: rtol, atol, weights and region, the only controller fields Multistep() reads (the PI gains and factors are Runge–Kutta’s and unused here)

\(I_Q\)

the quiet collar’s step cap

BoundaryMonitor.interval

The state \(\mathcal{S}\) is everything the algorithm changes:

  • the position \(z\) and the proposal \(h_p\);

  • the order \(q\);

  • the history entries \((z_j, \iota_j, \kappa_j)\), oldest first, with \(\iota_j\) the identity (accepted or predicted) and \(\kappa_j\) the engine row;

  • the consecutive rejections \(r\);

  • whether the last attempt was rejected, \(\lambda\);

  • whether the collar was quiet at the last attempt, \(w_Q\);

  • the call ledger: startup, predictor, rejected, accepted_steps, rejected_steps, restarts, failure_restarts, dense_observations, and the order of every accepted step.

Two inputs come from the host at every event: the collar’s quiet flag \(Q\), set by the boundary monitor, and the event bounds of §4.8.

Comparison semantics. Wherever this section writes \(\min\) or \(\max\) of two values, it means exactly what the reference does. \(\min(a, b)\) is \(b\) if \(b < a\), else \(a\). \(\max(a, b)\) is \(b\) if \(b > a\), else \(a\). \(\arg\max\) over a list keeps the first element unless a later one compares strictly greater. These forms decide what a NaN does, and they equal C++’s std::min and std::max of two arguments. A comparison with a NaN is false.

4.3 One attempt#

Given \(\mathcal{S}\) and a march target \(z_T\):

  1. Budget. The attempt costs \(c = [\text{history empty}] + 1\) calls. If \(B\) is set and the calls charged so far plus \(c\) exceed \(B\), stop with the event budget. The host raises ResponseBudgetError with the last accepted position and state.

  2. Start. If the history is empty:

    • add one to startup, before the evaluation, as the reference does: a call that fails has still been charged (R4);

    • evaluate the source once at the accepted state;

    • set the history to \([(z, \text{accepted}, 0)]\);

    • set \(q = 1\) under variable order, otherwise \(q = q_{\max}\).

  3. Proposal. Apply three caps in turn:

    • The carrier cap. With a carrier frame active, let \(e = \min(z + h_p, z_{\mathrm{end}})\) and let \(\phi\) be the chord phase of the reference element over \([z, e]\). If \(\phi > C_\kappa\), set \(h_1 = (e - z)\,C_\kappa/\phi\); otherwise \(h_1 = h_p\).

    • The quiet cap. \(h_2 = \min(h_1, I_Q)\) while \(Q\); otherwise \(h_2 = h_1\).

    • Collar re-entry. If the collar was quiet at the last attempt and is not now, and \(\rho > 0\), set \(h_3 = \min(h_2, c P_q/\rho)\); otherwise \(h_3 = h_2\). Then record the current \(Q\) as \(w_Q\).

  4. Landing (§4.6). This gives the step \(h\), its end \(z_e\), and whether \(z_e\) is a hard kink.

  5. Stencil.

    • Nodes \(\theta_j = (z_j - z)/h\) and rows \(\kappa_j\), newest first; \(n\) entries held.

    • The step’s order is \(q' = \min(q, n)\).

    • It may rise when the last attempt was not rejected and more entries are held than it uses: \(\mathrm{rising} = \neg\lambda \wedge n > q'\).

    • The scored orders are \(O = [q']\), followed, under variable order, by \(q' - 1\) and then \(q' + 1\), each kept when \(1 \le k \le q_{\max}\), \(k \le n\) and (\(k < q'\) or rising).

  6. Trial.

    • Add one to predictor, before anything is evaluated, as the reference does.

    • Form the coefficient tables of §4.7 from the nodes.

    • Evaluate the source once.

    • Reduce the scaled defect norms \(\varepsilon_k\) for \(k \in O\) (SEP 16 §4.2; stepper.md). The scale is $r_{\mathrm{tol}},w,\lVert u_n \rVert

      • a_{\mathrm{tol}}\(, with \)u_n$ the accepted state before the step, over the norm mask.

  7. Decide (§4.4), and record the attempt (§4.8).

Steps 6 and 7 are the engine’s and the controller’s; everything else is the march’s.

A prescribed schedule (phase 3) is AdamsMarch._replay_to, with its own stopping, landing and kink rules (R6). The state adds the schedule’s cursor \(m\), which is StepDriver._schedule_index; a load rebuilds it from the position (_schedule_index_at), as today. The march uses:

  • the schedule’s precomputed edges \(E\) (FixedSchedule.edges, the running sum of its steps);

  • the slack \(\delta\) = replay_tolerance(schedule, z_T). This is \(\max(u, n_{\mathrm{steps}})\) units in the last place of the larger of \(z_T\) and the schedule’s length, because a sum of \(n\) rounded steps drifts by about \(n\) ulps.

While \(m < n_{\mathrm{steps}}\) and \(z < z_T - \delta\), each attempt:

  1. takes \(h\) = steps[m] and \(z_e = E_{m+1}\), with no caps and no landing rule;

  2. treats \(z_e\) as a kink when some hard kink lies within \(\delta\) of it;

  3. runs steps 1, 2, 5 and 6, accepts every finite norm, and appends it to the accepted norms as _replay_to does;

  4. advances \(m\) in the same commit as the position.

A non-finite norm is the event diverged, which the host raises as ConvergenceError with the reference’s message. If \(z < z_T - \delta\) when the schedule is used up, the event is exhausted, which the host raises as the reference’s BackendError. The adaptive test reached is not used on a replay: the schedule ending 12 ulps short of 1e-3 in replay_tolerance’s own example must still count as arriving.

4.4 Acceptance, rejection and the step-size factor#

The factor an order-\(k\) defect allows is SEP 13’s elementary controller: the exponent is the defect’s own, \(1/(k+1)\) [1, 5].

\[\begin{split} F(\varepsilon, k, \gamma) = \begin{cases} \gamma & \varepsilon \le 0, \\ \min\!\big(\gamma,\ \max(f_{\min},\ s\,\varepsilon^{-1/(k+1)})\big) & \text{otherwise.} \end{cases} \end{split}\]

The next proposal is limit_step:

\[ h' = \min(h\,f,\ h_{\max}). \]

If \(h' < h_{\min}\), stop with the event floor. The host raises ConvergenceError with the reference’s message.

Let \(\varepsilon = \varepsilon_{q'}\). Reject when \(\neg(\varepsilon \le 1)\); a NaN rejects. On a rejection:

  1. add one to rejected, rejected_steps and \(r\), and set \(\lambda\);

  2. if \(r \ge R\) and more than one entry is held, empty the history, set \(r = 0\) and add one to failure_restarts;

  3. take the factor \(f = f_{\min}\) if \(\varepsilon\) is not finite, otherwise \(F(\varepsilon, q', g_{\mathrm{rej}})\);

  4. set \(h_p = \) limit_step\((h, f)\).

On an acceptance, in one commit (§6.5.7):

  1. keep the \(q_{\max}\) newest entries;

  2. give the new sample the lowest engine row the kept entries do not use;

  3. accept in the engine: the step’s finish, boundary treatments included, and the store of the new sample with its state and rate on a compensated history;

  4. append \((z + h, \text{predicted}, \text{row})\);

  5. add one to accepted_steps, append \(q'\) to the ledger’s orders, and set \(r = 0\);

  6. choose the stored order \(q_s\), which the next stencil starts from, and the control order \(q_c\) with its norm \(\varepsilon_c\), which set the next proposal’s exponent (§4.5);

  7. set \(h_p = \) limit_step\((h, F(\varepsilon_c, q_c, g))\), set \(q = q_s\), and clear \(\lambda\);

  8. move to \(z = z_e\). At a hard kink, empty the history and add one to restarts.

A defect in the reference’s commit order. AdamsMarch._decide tests the floor in step 7 before it moves the position in step 8. A floor raised there leaves the adopted state at \(z + h\) with the position still at \(z\), and the stored order already changed by _choose_order. The general path’s _commit has the same order.

It can happen. A landing may be shorter than \(h_{\min}\), since landing never consults the floor, and the next proposal is formed from the step taken. So an accepted landing shorter than \(h_{\min}/g\) always raises the floor.

The Runge–Kutta march does neither: it commits the state and position first, and grows its next proposal from the proposal rather than from the landed step.

Phase 0 moves the position into the commit before the floor is tested, in both paths, so that the state, the position, the history and the stored order always agree, and the native march follows that order. Before the floor is raised, the accepted attempt’s row, the kink restart and the ledger are published to the host like any other accepted step (§6.5.7, R3). The rule itself stays for now. If the implementation finds short landings in practice, it may change the rule, recording the change and its evidence in the phase report (decided, Q13).

4.5 Order choice#

Under a fixed order the two orders differ: \(q_s = q_{\max}\), but \(q_c = q'\) and \(\varepsilon_c = \varepsilon_{q'}\). This is AdamsMarch._choose_order, which stores method.order and returns the step’s own order.

At startup and after a restart, \(q'\) is below \(q_{\max}\) until the history fills. The exponent is then the step’s own, not \(q_{\max}\)’s. With Multistep(order=5, variable_order=False), one sample held and \(\varepsilon = 0.25\), the factor is 1.6; taking \(q_{\max}\) would give 1.0079 (the AI review’s probe, R1).

Under variable order \(q_s = q_c\) is the chosen order \(q_n\) below, and \(\varepsilon_c = \varepsilon_{q_n}\). Every scored order \(k\) has the weight

\[\begin{split} W_k = \min\!\big(s\,\varepsilon_k^{-1/(k+1)},\ \Sigma_k\big), \qquad \Sigma_k = \begin{cases} \dfrac{c\,P_k}{\rho\,h} & \rho > 0 \text{ and } \neg Q, \\[4pt] \infty & \text{otherwise.} \end{cases} \end{split}\]
  • \(\varepsilon_k^{-1/(k+1)}\) is taken as \(\infty\) when \(\varepsilon_k \le 0\).

  • \(\Sigma_k\) is the step the in-stage collar leaves order \(k\) stable at (SEP 16 phase 6; ADR-057 for the quiet collar).

  • \(b = \arg\max_{k \in O} W_k\), taken in \(O\)’s order.

The march adopts \(q_n = b\) when \(b \ne q'\) and \(W_b > H\,W_{q'}\); otherwise \(q_n = q'\). The cap \(\Sigma_k\) weighs only the choice, and the step is the chosen order’s defect’s (SEP 16 phase 6). Without the cap, near the collar’s limit the choice was decided by rounding. That is why §4.9 treats order changes as knife edges.

4.6 Landings, caps and equal approach#

The landing rule is stepper.schedule.landing (stepper.md §3). Its tolerance is \(\tau = u\,\mathrm{ulp}(\max(|z|, |z_{\mathrm{end},T}|))\), where:

  • \(\mathrm{ulp}(y)\) is the spacing of binary64 at \(y\) (numpy.spacing);

  • \(z_{\mathrm{end},T}\) is the run’s end when planes are interpolated, and the march target \(z_T\) otherwise.

The rule:

  • Caps. \((h_3, \text{none})\), \((h_{\max}, \text{max step})\) and (the width of the interval of \(\mathcal{Z}_b\) containing \(z\), interval width).

  • Landings. The first hard kink, the first save plane, and \(z_{\mathrm{end},T}\), each the first at or beyond \(z + \tau\) (bisect_left).

  • Choice. Both sets choose the shortest, with ties going to the later of none < max step < interval width < hard kink < save plane < end.

  • If the shortest landing gap is within the shortest cap, land on it.

  • Otherwise, if it is within \(\min(\sigma \cdot \text{cap}, h_{\max})\), stretch the step to land on it.

  • Otherwise take the cap and land nowhere.

Equal approach. If the step lands nowhere and equal approach is on, find the next forced landing \(a\): the nearest of \(z_T\), the next save plane and the next kink strictly beyond \(z + \tau'\) (bisect_right), with \(\tau' = u\,\mathrm{ulp}(\max(|z|, |z_T|))\), or none if that nearest is not beyond \(z + \tau'\). If \(h < a - z < 2h\), take \(h = (a - z)/2\), end at \(z + h\), with no kink.

Kinks. Otherwise the end is the landing’s target, or \(z + h\). It is a kink when:

  • it is interior, meaning it lies below \(z_{\mathrm{end}} - \tau''\); and

  • either the landing was a hard kink, or some kink lies within \(\tau''\) of it.

Here \(\tau'' = u\,\mathrm{ulp}(\max(|z_e|, 1))\).

4.7 Coefficient tables#

Every table is a small dense computation on at most \(q_{\max} + 2 \le 8\) nodes.

Lagrange coefficients. For distinct nodes \(\vartheta_0, \dots, \vartheta_{m-1}\) they are the matrix \(L\) with \(\ell_i(\theta) = \sum_p L_{ip}\theta^p\) and \(\ell_i(\vartheta_j) = \delta_{ij}\). That is, \(L = V^{-\mathsf{T}}\) for the Vandermonde matrix \(V_{jp} = \vartheta_j^p\). The integrated weights are

\[ w_i = \int_0^1 \ell_i(\theta)\,\mathrm{d}\theta = \sum_p \frac{L_{ip}}{p+1}. \]

They are exact for polynomials of degree below \(m\) on any distinct nodes [1, 2].

The exponential trial (SEP 16 §4.2) integrates the interpolant against the flow. The moments are

\[ \mu_p(x) = \int_0^1 e^{(1-\theta)x}\,\theta^p\,\mathrm{d}\theta = p!\,\varphi_{p+1}(x) \]

[3, 4]. The engine forms them per element. The host supplies:

  • the predictor’s \(L\) over \((\theta_0, \dots, \theta_{q'-1})\);

  • the corrector’s \(L\) over \((1, \theta_0, \dots, \theta_{q'-1})\);

  • for every scored order \(k\), the factored defect: the divided-difference coefficients \(\gamma_t = 1/\prod_{s\ne t}(p_t - p_s)\) over \(p = (1, \theta_0, \dots, \theta_{k-1})\), and the ascending monomial coefficients of \(\pi_k(\theta) = \prod_{j<k}(\theta - \theta_j)\).

The transported compensation (ADR-042) adds:

  • the spans \(1 - \theta_j\);

  • the integrated weights over the predictor’s nodes;

  • the integrated weights over the corrector’s nodes;

  • each scored order’s corrector weights less its predictor weights.

The interaction formulation’s fused path (phase 3) needs the predictor’s integrated weights, the corrector’s, and the same defect rows (fused_tables).

Accuracy. No useful conditioning bound holds over the histories the march admits (R7):

  • a landing may shorten a step by any ratio while an ordinary save plane keeps the history;

  • rejections compound \(f_{\min}\);

  • max_growth, min_factor and equal_approach are settings.

With four unit steps followed by a trial shortened to \(10^{-10}\), the corrector’s nodes are \((1, 0, -10^{10}, \dots, -4\times10^{10})\). Their monomial Vandermonde matrix has an \(\infty\)-norm condition estimate of about \(2\times10^{53}\). That bound says nothing useful about the error of the algorithms actually used (products of roots and divided differences), or of the weights a trial applies.

The native tables use the algorithm of stepper.multistep (_basis_rows). Since LupoLab/solprop#55 the native backend forms all of a trial’s tables in one strict C++ call, lagrange::trial_tables (bound as _core.trial_tables), which performs the plain composition’s operations in the same order. The host march on native already reads its tables from it, and the native march calls the same function in-process. The numpy_ref and numba_cpu helpers still compose the tables from _basis_rows. None of them is held to a condition-number budget. Instead, every helper record’s tables are compared with an exact oracle: the same tables computed in rational arithmetic from the binary64 nodes, with Python’s fractions.Fraction and no new dependency (§7.2).

This SEP proposes no new restart or admission rule for extreme node ratios. If the corpus shows the reference losing accuracy on histories the march admits, that is reported to the author as a question of numerical policy.

4.8 Stage scalars along z, and the host events#

Stage scalars. A trial evaluates its source at \(z + h\) and reads the stage scalars there (kernel-contract.md; translate.stage): the weights of \(\Phi\) and of the prefactor, the density, the taper scale, the Raman decay rates and densities, the Kerr density, the plasma channels’ densities and pre-ionised fractions, and the dispersive Kerr densities. The flow section and weights, and a moving guide’s transform weights, are phase 4.

Each scalar is a piecewise Chebyshev table \(s(z) = \sum_n a^{(i)}_n T_n(\xi)\) on interval \(i\), evaluated by Clenshaw’s recurrence. The weights of an expansion operator are integrated Chebyshev tables, and the increment’s weights are \(W(z_1) - W(z_0)\). A varying operator’s rate \(D(z)\) is the derivative of the same representation, taken on the closing side at a kink (translate.linop.phi_rate, before=True). A z table is already evaluated natively into term slots (SEP 29 §6.6).

The native march evaluates every table in C++ with the strict Clenshaw helper that the native backend’s Helpers already use (_core.clenshaw_points). Its bundles therefore equal the ones the backend forms today through translate.stage.scalars_at, to the bit. That is a structural property, tested exactly (§7.1). The tables are uploaded once per run.

One input is formed differently. The carrier frame’s chord is evaluated today on the host through Numba’s Clenshaw (CarrierChord.exponent). Native forms it from its resident \(\Phi\) terms, or from a z table’s slots, so the carrier cap and the carrier shift may differ from the host’s by rounding.

Host events. The march returns to the host only when the host has work, and every return is drained completely (R5).

  • At entry, the host does what march_to does today before its loop:

    • it restores a march held ahead of a dense observation;

    • it returns at once if a natural stop is due (_natural_stop_due) or the target is reached;

    • otherwise it passes the bounds below.

    No call is made when no attempt is due.

  • Before each attempt, native tests the budget (§4.3, step 1).

  • After each attempt, accepted or rejected, it tests the call bound, and returns any error.

  • After each accepted attempt, it tests the accepted-step events, with the host’s own expressions:

Event

Native test after an accepted attempt

target reached

reached(z, z_T): \(z \ge z_T - \tau\)

crossed (dense observation)

\(z > z_T\) with planes interpolated

host step

the accepted-step count since entry reaches \(n_e\), the smallest of: the steps to the next statistics row (row_period), to the next natural stop (step_planes), and to the boundary monitor’s next cadence check (1 while an edge is busy, otherwise CADENCE less the steps since its last check)

monitor distance

\(z - z_{\mathrm{last}} \ge I_{\mathrm{check}}\), the monitor’s own expression, or its first check

The other returns have no event step:

Return

When it is tested

call bound

after any attempt: the attempts or the wall time since entry reach the call’s bounds (§6.5.7)

budget

before an attempt (§4.3, step 1)

floor

when a proposal is formed, after a rejection or an acceptance (§4.4)

diverged, exhausted

on a replay (§4.3)

error

wherever a native failure occurs. The steps committed before it stand, and the error is returned rather than thrown (§6.5.7)

Every attempt writes one row into a host buffer: end, step, norm (with a non-finite norm written as \(\infty\), as _record does), whether it was accepted, its order, and its wall time since entry. A call can return:

  • with no rows (the budget, or an error, at its first attempt);

  • with rows but no acceptance (rejections up to the call bound);

  • with accepted steps followed by rejections.

Draining. Inside the commit of §6.5.7, the host ingests every row exactly once, in order. It appends the step-history row, and for an accepted row applies that step’s accounting exactly once: the monitor’s skip, the statistics and natural-stop counters, and _accepted_norms on a replay.

Only one accepted row can have host work: the event step, the accepted attempt after which native returned with an accepted-step event. By construction no earlier accepted row had any. For the event step the host runs the reference’s own sequence, _take_step_stats then _natural_stop, unchanged. So coincident events are serviced in the reference’s order and exactly once: for example, a plane that is also a statistics row and a monitor check, or a natural stop due on a dense crossing.

A return for any other reason has no event step, and its accepted rows take the skip path only. The other reasons are the call bound, the budget, the floor, divergence, exhaustion and an error.

After the commit, the host:

  1. makes the progress callbacks, one per attempt in order, so a reporter’s display updates once per call (Q12);

  2. writes the debug lines;

  3. does the event step’s work;

  4. raises the error last, if there is one.

So every statistics row, monitor check and natural stop falls on the accepted step it falls on today.

The backend’s accepted-step hook is not replayed. On native, _on_accepted_step calls promote_fsal, which scores the quadrature alarm from the step’s buffers. The native march does that scoring itself after each accepted step, where the buffers are current, and the host’s ingestion does not call the hook.

4.9 What “the same steps” means#

On the native backend, both marches read:

  • their norms from the same native kernels;

  • their tables from the same strict lagrange::trial_tables (§4.7);

  • their stage scalars from the same strict Clenshaw helper (§4.8).

They differ only in:

  • the carrier chord, and so the carrier cap and shift (§4.8);

  • the order of the scalar operations that combine the norms, wherever the two implementations order them differently.

On one build, with deterministic transform plans (SOLPROP_FFTW_PLAN=estimate), the two marches are therefore expected to take the same steps and to produce the same fields bit for bit outside the carrier frame. Under the default measured plans, two runs of the same march already differ by up to about 5e-10 on the helium gradient case, because FFTW’s planner picks codelets by timing (LupoLab/solprop#54). This expectation is a diagnostic that phase 2 records; it is never asserted (SEP 25).

How far the norms differ is measured per case, not assumed (R8). Phase 1 records, on every qualification case, the distribution of \(|\Delta\varepsilon|/\varepsilon\) between trials run with the host’s inputs and with the native ones. It is expected to be zero outside the carrier frame.

The controller’s own arithmetic is the same IEEE operations on the same inputs: strict, with the platform’s pow (§6.5.1) [7, 8]. Its outputs are discontinuous functions of the norms: acceptance at \(\varepsilon = 1\), adoption at \(W_b = H W_{q'}\), and the landing and kink tolerances.

A decision flips only when the two marches’ inputs straddle a threshold, that is, when its margin is below their difference. Such flips are rare, but they are not impossible. After one flip the two trajectories differ by more than rounding, and they agree only within the case’s accuracy envelope (R10).

So, as SEP 25 §4.4 rules for adaptive runs, “the same steps” is:

  • expected: the same accepted and rejected sequence, orders and landings on every qualification case;

  • recorded: with the smallest decision margin of each run;

  • never asserted: the assertions are an accuracy envelope on the fields and exact structural contracts (§7).

A prescribed schedule with a fixed order has no decisions, so it is a matched computation that agrees to a rounding budget (§7.2).

5. Proposed interface#

No call or setting changes. A user’s script, configuration and output read as before. On the native backend, Multistep() runs through the native march whenever the configuration is admitted (§6.6). Otherwise it runs through the Python march as today and warns once per run with a new SolpropWarning subclass in solprop.exceptions, the one public addition, naming the reason (Q2).

The run’s backend record says which march ran, so that a timing or a rounding difference can be traced. It is one more entry in the settings the native backend’s metadata() reports, which BackendRecord stores outside the resume key, as it stores the thread pools:

BackendRecord.settings["adams_march"]: "native" | "host"   # absent for Runge–Kutta runs

The record is written when the run starts, so a resumed file names the march that started the run, as it names the backend.

A private keyword on the native backend’s constructor (adams_march="host") forces the Python march. For the benchmark harness, which builds backends through solprop.backends.select, the same switch is a private environment variable read once at import. Its users:

  • the parity tests;

  • the benchmark harness’s A/B comparisons;

  • anyone developing the controller, who can change it in Python and run it on native without a rebuild.

It is not part of the documented interface.

6. Implementation strategies#

6.1 Three options, costed#

(A) The controller and the march fully in C++, with the Python AdamsMarch kept as the reference. Native carries the algorithm and its constants. Its defaults (safety, hysteresis, restart count, stability intervals, the landing rule’s tolerances) are written in C++ beside the Python ones, and the host passes only the method’s fields and the run’s settings.

(B) Policy as data, mechanism native. solprop forms one record \(\mathcal{P}\) per run (§4.2) holding every parameter and constant the algorithm reads. That includes those that are module constants today: the hysteresis, the restart count, the stability intervals, the carrier cap, the rejection’s growth cap and the landing tolerance. At every entry solprop also passes the event bounds and the quiet flag (§4.8).

Native holds no default and no constant of its own. It refuses a record that lacks a field or holds one out of range, by name. It advances under the algorithm of §4 to the next host event. The algorithm is written in the kernel contract and versioned by SCHEMA_VERSION like every other part of the seam, and the Python march stays its reference.

(C) No controller move. Python keeps the decision. Native does two things:

  • Forms the coefficient tables from the nodes inside the trial call.

  • Evaluates the stage scalars from tables uploaded once. Python passes only the trial’s two positions and its phase increment.

The trial and the accept stay separate calls. Fusing the accept into the next trial call was considered and dropped (R2). The accepted state would wait for a next trial that may never come: a save plane, a natural stop, a spent budget, the end of the run or an interruption can intervene, and a monitor verdict can change the next trial’s collar setting after the state must already have been adopted. Phase 2 removes those crossings anyway, because it decides and commits inside the native call.

What each option takes off a trial, per trial, from today’s host cost (§3.3).

Option C. Its parts:

  • the tables become a native helper (−15 to −34 µs on the M5, −126 to −168 µs on the EPYC with the compensation’s tables);

  • the stage scalars are evaluated natively (−130 to −156 µs on the EPYC);

  • the rate weights go native (−22 µs).

The decision (39–47 µs on the EPYC), the march loop, _record, the step bookkeeping and two crossings per accepted trial stay.

Options A and B remove all of it but a residual:

  • the controller’s scalar work in C++, a few µs;

  • one crossing per host event. Events come every eighth accepted step or more often by the monitor’s distance on a quiet window, and at every step while an edge is busy.

  • the bulk records, about 1–2 µs an attempt.

Per-trial savings:

C

A

B

Saving per trial, Apple M5 Max (helium / argon), µs

50–80 / 40–70

125–135 / 93–103

125–135 / 93–103

Saving per trial, AMD EPYC 7763 (helium / argon), µs

265–335 / 240–280

460–485 / 365–390

460–485 / 365–390

Host time left per trial, and the run-level effect:

C

A

B

Host time left per trial, M5 / EPYC, µs

40–90 / 125–235

5–15 / 15–40

5–15 / 15–40

Seam crossings

two per accepted trial

one per host event

one per host event

Run-level saving alone, helium (EPYC, of 3.15 s)

0.38–0.48 s

0.65–0.69 s

0.65–0.69 s

Run-level saving alone, argon (EPYC, of 12.6 s)

2.7–3.1 s

4.1–4.4 s

4.1–4.4 s

Ownership, rules and risk:

C

A

B

Who owns the parameters

solprop

native, with solprop mirroring it

solprop; native has none

native/AGENTS.md

kept

broken: native owns policy

kept in intent; its wording amended (§6.3)

ADR-043

kept

replaced

amended: one algorithm, two executions, Python the reference

Risk

leaves a third of the EPYC’s host cost

two sets of constants that drift apart

a specification and a trace test to maintain

The M5 savings for C take the M5’s measured table cost (15–34 µs) and scale the other EPYC pieces by the measured 3.5–4 times. The helium figures include the compensation’s tables and rate weights; argon has a constant operator and needs neither. The residual for A and B assumes events every eighth accepted step or so. Busy monitors, step_planes = 1 and row_period = 1 return every step, which phase 2 measures (§7.6, R12).

What B adds over C. Accepting a second implementation of the controller rests on B’s gain over a completed phase 1, not over today (R12). From the same pieces:

Case

Apple M5 Max

AMD EPYC 7763

he_rdw_averaged

45–85 µs per trial, 0.06–0.12 s per run

125–220 µs per trial, 0.18–0.31 s per run

argon_blueshift_averaged

25–65 µs per trial, 0.28–0.73 s per run

85–150 µs per trial, 0.95–1.7 s per run

That is 7–14 % of the helium run and 9–23 % of the argon run once the items of §6.9 have landed. These are estimates; phase 2 measures them with N1–N7 held fixed (§7.6).

The same costing at the refresh. LupoLab/solprop#55 took the tables, most of the packing and the landing searches out of the Python per trial, so the remaining host cost is smaller and both phases save less. The pieces come from §3.3’s per-piece profiles, scaled to the lean host time:

  • C (phase 1) removes the stage arrays (bundle, packing, sample), the rate weights, the tables call’s crossing, the hoist and the carrier shift. It adds back the native evaluation, a few µs.

  • B over C (phase 2) removes the rest but the residual of 5–15 µs (M5) or 15–40 µs (EPYC).

C

B over C

B over today

Saving per trial, Apple M5 Max (helium / argon), µs

25–29 / 15–19

10–20 / 6–16

36–46 / 22–33

Saving per trial, AMD EPYC 7763 (helium / argon), µs

90–110 / 60–80

70–95 / 65–90

170–195 / 135–160

Run-level, Apple M5 Max, one thread (helium / argon)

4–5 % / 6–7 %

2–3.5 % / 2.5–6 %

6.5–8 % / 8.5–12.5 %

Run-level, AMD EPYC 7763, one thread (helium / argon)

6–7.5 % / 7–10 %

5–6.5 % / 8–11 %

11.5–13 % / 16–20 %

The case for B over C is therefore about half what it was at drafting: 2–11 % of a single-mode run on the CPU, by case and machine. The author decided to build every phase with this figure in hand (2026-10-28). The no-regression rule of §7.6 is the guard against the march costing more than it saves. On device builds the comparison is different in kind (§6.7).

6.2 Recommendation: B, built on C#

B and A cost the same in time and in C++, since both put the whole loop in native code. They differ in who owns the numbers:

  • Under A, a change to a constant, or a default chosen in C++, can drift from the Python march. A reviewer has to read C++ to know what a run did.

  • Under B, there is one source of every number (solprop’s record), one specification of the algorithm (the kernel contract), and a test that drives both implementations over the same norm sequences and compares every output exactly (§7.1).

C is not an alternative to B. It is B’s first phase: the tables and the stage scalars must be native before the loop can be. As a stopping point, C is weaker for three reasons:

  • On the EPYC it leaves 125–235 µs of Python per trial, still 0.5–0.7 source calls on the cheap cases. At the refresh it would leave about 100–115 µs, half a source call.

  • It keeps two crossings per accepted trial.

  • On a GPU it keeps the Python decision between the norm read-back and the next launch.

Recommended: B, built in the phases of §11 with C’s pieces first.

6.3 Does B satisfy native/AGENTS.md?#

The rule has two parts.

“Never imports Python or solprop concepts.” B satisfies this. The record is plain data validated at the seam, as a KernelProblem is. The march calls no Python, and the events return to the host instead.

“Never owns user-facing policy.” By intent, B satisfies this too:

  • Every choice a user can see or set is made in solprop: the method and its fields, the tolerances, the defaults, the landings, admission and refusal, warnings, the monitor’s verdicts, and the error messages.

  • Native executes an algorithm that the contract specifies in full, as it already executes the stage arithmetic the contract specifies.

The rule’s wording is ambiguous, though. The controller’s structure is behaviour a user observes in the step counts: that the order never rises after a rejection, that three rejections restart the history. A later reader could take B as a breach.

This SEP therefore amends the rule, in phase 2, to:

Native code never imports Python or solprop concepts and never owns user-facing policy: it may execute an algorithm the kernel contract specifies in full – the Adams march’s controller, order choice and landings among them (SEP 39) – with every parameter handed over by solprop and solprop’s implementation as the reference, and it never chooses or defaults a parameter itself.

“Never tune the step-size controller to gain speed” stays as it is. B tunes nothing.

ADR-043 is amended by a new ADR in phase 2. Its reason, “one controller for every backend … tested once”, becomes:

  • one specification of the controller, in the kernel contract;

  • one reference implementation, solprop’s;

  • one native execution, held to the reference by exact decision-trace tests and by accuracy envelopes on whole runs.

Option A would break the rule and need its removal. That is the main reason it is not recommended.

6.4 Layers and the seam#

Layer

What it holds

Physics (P)

Nothing changes.

Translation (T)

Uploads the scalar tables once per run, as a new leaf of the native problem (phase 1).

Backends (B), solprop

stepper.multistep gains MarchPolicy, a frozen dataclass of §4.2’s fields, formed by march_policy(method, settings, …). backends.multistep gains a pure controller core (§11, phase 0), which AdamsMarch calls.

Backends (B), native adapter

_NativeAdamsMarch in solprop_native.backend. It configures the native march once per run, calls it per march, applies the host events, and maps events to solprop’s exceptions.

Backends (B), C++

The march, beside GeneralWorkspace’s Adams methods. It owns the state \(\mathcal{S}\) and reuses the resident engine’s trial and accept passes.

Driver (D)

run_problem and the record are unchanged. The backend record’s settings gain adams_march.

The seam changes:

  • Phase 0. Output schema +1, for the collar flag in MarchSnapshot (§6.5.9).

  • Phase 1. Kernel schema +1:

    • the scalar tables leaf;

    • the trial call takes the positions, the nodes and the scored orders instead of the tables and the stage arrays, and writes one stage row instead of the Runge–Kutta tableau’s rows (§6.5.6).

    • A contract tuple SCALAR_TABLES_FIELDS is added.

  • Phase 2. Kernel schema +1:

    • MarchPolicy and its tuple MARCH_POLICY_FIELDS;

    • the driver’s bulk bookkeeping hooks, which join DRIVER_SURFACE (for example _record_attempts, and a counted form of _take_step_stats; the names are the implementer’s).

    • check_build() checks the new tuples at import, as it does the others.

  • Phase 3. The interaction formulation’s fused trial needs no seam change. A replay passes the schedule’s steps and edges, which the stepper settings already carry.

The exact schema numbers are those current at each phase. At the refresh of 2026-10-28 the kernel schema is 38 and the output schema 27. ADR-058 restarts the output schema at 1 and renames the kernel’s constant KERNEL_SCHEMA_VERSION before the first tag; this SEP’s bumps are relative to whatever is current.

The step-loop protocol. ADR-058 reserves two seams for change before 1.0: “the two step loops that one march protocol would replace”, and the split of the integrator from the controller. This SEP touches both, and is meant to make them easier:

  • The policy record is that split for the Adams march. It holds every number the algorithm reads, separate from the norm’s scale (PIController.scale).

  • The native march returns to the host only at events. A later protocol shared by the Runge–Kutta and Adams loops should express those events, rather than the per-trial calls of today.

A redesign of the protocol that changes the event set is that later change’s work. It would amend the kernel-contract section this SEP writes.

6.5 The native march, item by item#

6.5.1 The controller#

§4.3–§4.5, in binary64 on the host thread (ADR-054), as scalar C++ in a host-only translation unit of its own. Its inputs are the norms the engine already reduces.

Strict on every build (R8). That unit is compiled with contraction off and without fast-math on every build, through a per-source compile option rather than fp.hpp’s macros. The macros do not suffice here:

  • SOLPROP_NATIVE_STRICT_FUNCTION alone is empty under Clang, which needs SOLPROP_NATIVE_STRICT_BLOCK in the body;

  • both macros are empty under CUDA and HIP, including their host pass.

A test in the style of tests/native/test_fp.cpp checks, on each supported compiler, that a product and a sum in that unit round separately. The compilers are GCC, Clang, the Windows toolchain, and in phase 5 the CUDA and HIP host compilers.

One elementary function. The controller calls only pow, from the platform’s C library, as Python’s float power does.

The norm’s scale must be the engine’s own: adams_scale from PIController.scale, which today’s _device_scale path already forms natively. A controller with another scale runs on the host march. The PI gains are Runge–Kutta’s; the Adams march has always used the elementary factor of §4.4.

6.5.2 Variable order#

§4.5. The collar weighting needs only \(\rho\), \(c\), \(P_k\), \(Q\) and \(h\), all of which the march holds. The carrier cap needs the reference element’s chord phase over \([z, e]\). Native already evaluates the reference element’s \(\Phi\) from its resident terms, or from a z table’s slots, so the chord is formed natively. Today the host forms it for ExponentialWeights.

6.5.3 Startup, rejections, hard kinks, restarts#

§4.3–§4.6:

  • A failure restart and a kink restart empty the history. The next attempt starts it again, paying one startup call.

  • A rejection leaves the accepted history untouched, as the engine’s held trial already guarantees (SEP 13 §6.1).

6.5.4 Landings and dense observation#

§4.6, with the save planes, kinks, interval edges and run end in \(\mathcal{P}\), and the march target passed per call. Under interpolated planes:

  1. the march returns crossed when an accepted step passes \(z_T\);

  2. the host holds the engine, observes and records as today (_ahead, the engine’s hold, observe, restore; ADR-036, ADR-039);

  3. the next call resumes from the held state.

6.5.5 Varying operators#
  • z tables. Their native slots (SEP 29 G1) are read in-process instead of through _ZTables.load, which today makes a table_evaluate call per trial, and a table_rate call when compensated. The least-recently-used slot set moves into the march (phase 2; phase 1 keeps _ZTables).

  • Rate weights. For an expansion, the closing-side derivative of the integrated weight tables. The host precomputes the derivative tables at translation and uploads them with the scalar tables (phase 1). Today this is an uncached Clenshaw call per compensated trial (_rate_value). For a z table, the rate slot native already evaluates.

  • The transported compensation. The engine’s existing passes (ADR-042), with the spans and weights of §4.7 formed natively. The sample’s rate row is stored by the accept from the endpoint rate the trial formed.

6.5.6 Stage scalars along gas profiles#

Three ways were weighed:

  • Evaluate natively (recommended). §4.8: the same strict Clenshaw pass, so the bundles are identical.

  • Tabulate on a fine grid at translation. Rejected: the values would differ beyond rounding, the grid would need its own error control, and memory grows with profile detail.

  • Keep on the host. Rejected: since LupoLab/solprop#55 it is the largest single host item (the stage arrays were 94 of a helium trial’s 233 instrumented µs on the EPYC, the bundle’s Clenshaw reads 58 of them), and it would need a host callback per trial, which native/AGENTS.md forbids from native code.

One stage row. The trial binding still takes Runge–Kutta-shaped stage arrays: the Adams passes read row n_stages of the stage buffer it shares with Runge–Kutta, and the step and the lab-frame sample are uploaded separately. LupoLab/solprop#54 left the one-row form to this SEP, because it is a seam change. In phase 1 the native evaluation writes the one row the Adams passes read straight into the stage staging. z tables keep _ZTables until phase 2. The separable operator, SEP 6’s dispersive channels and moving maps keep the general packing until phase 4.

The Raman decay rates already drive a native rebuild of the transformed kernel spectrum when they change (prepare_raman_stage), so nothing moves there.

6.5.7 Commits, signals, errors and the monitor#

Commits. Each accepted step commits in native code, before the next attempt: the state, the history, the position and the ledger change together. On the host, one committing() guard spans the whole exchange (R3):

  1. the native call;

  2. the synchronisation of the host’s position, proposal, order, history and ledger with the native march;

  3. the ingestion of every returned row (§4.8, draining).

A signal that arrives during the call is held until all three are done, and only then raised (ADR-047). Without the guard around the call itself, solprop._interrupts would raise as soon as Python resumed, with native several commits ahead of the host’s records. SEP 24’s atomic step guards its native call the same way.

User code runs only after the guard: the progress callbacks, the debug lines and the event step’s work (§4.8). A callback that raises therefore finds the host’s records complete and in step with the native state, and the run loop’s terminal checkpoint writes a consistent state, position, history and ledger. The floor (§4.4) follows the same rule: the accepted attempt, its kink restart and its ledger are published before the floor is raised.

Signals. A signal waits for the native call to return (ADR-047), so a call is bounded:

  • by its attempts: the size of the row buffer, 256;

  • by its wall time, from a monotonic clock read once per attempt: 0.1 s (decided, Q3).

Neither is a user setting. A crossing costs an estimated 20–40 µs.

  • In default runs the monitor’s cadence returns at least every eighth accepted step, so the call bound rarely binds.

  • When it does, 256 attempts of a trial of 0.3–1 ms take 0.08–0.3 s, and the crossing is at most 0.05 % of that. For a 50 µs trial it would be 0.3 %.

Phase 2 measures the crossings actually made and their cost (§7.6, R12).

A scheduler’s SIGTERM or a keyboard interrupt is then served within that bound, plus one trial.

Errors. A native failure is caught inside the march: in an evaluation, a reduction, an accept or a scalar-table evaluation. The march stops at the last commit and returns an error record (R4) that holds:

  • the stage: the startup evaluation, the trial’s evaluation, the trial’s reductions, the accept, or the stage scalars;

  • the category: a code for the exception class the host march would raise there. These are the classes the adapter raises today: ConvergenceError from an envelope failure (_envelope_errors), BackendError, and the built-in errors of the bindings. The class is never inferred from the message.

  • the message.

The ledger returned with it keeps the charges already made. A startup or trial call is charged before it is evaluated (§4.3), so a failed call is counted, as the reference counts it. The host:

  1. synchronises the ledger;

  2. drains the committed rows;

  3. raises the class the code names.

An exception thrown through nanobind in the middle of a call would lose the records of steps already committed.

ConvergenceError and ResponseBudgetError are raised by the host from the floor, diverged and budget events, with the reference’s messages. The budget error carries the last accepted state.

The boundary monitor keeps its policy in Python (solprop.monitor): its cadence, its thresholds, the quiet verdict and the stop.

  • The march returns when a check is due (§4.8). Busy edges therefore return every step, and quiet ones every CADENCE steps or by distance.

  • The backend reads the monitor’s numbers itself (LupoLab/solprop#49): it hands the host five numbers and the split’s removal (monitor_reading), so a check costs one crossing and a few µs of Python.

  • The quiet flag \(Q\) is passed at every entry. A change of verdict reaches the stages through _collar_changed, as today.

6.5.8 The call ledger, records and metadata#
  • The ledger. The ledger counters accumulate natively and are read at every event into CallLedger. The orders of the accepted steps come in the attempt rows, so the ledger counts the calls actually made (SEP 25 §4.4).

  • The step history’s rows. These are _record’s rows. Each attempt’s wall time is the call’s start, read on the host with perf_counter, plus the native monotonic offset.

  • Progress. The reporter receives one step call per attempt, in order, in bulk after each native call. Its display therefore updates once per call and lags by at most one call (Q12).

  • The per-step debug log of the Python march is kept. When the DEBUG level is enabled, the host writes one line per attempt from the rows at each event, guarded by isEnabledFor, so the lines cost nothing otherwise (decided, Q12).

  • The record and the configuration hash are unchanged. The backend record’s settings add adams_march (§5), outside the resume key.

6.5.9 Resume snapshots#

MarchSnapshot keeps its fields (ADR-046; SEP 29 §6.6) and gains one: the collar’s quiet flag at the last attempt, \(w_Q\) (R9). At an event the host reads the march state:

  • the position and the proposal;

  • the order and the rejection count;

  • \(\lambda\) and \(w_Q\);

  • the entries’ positions, identities and rows.

It reads the engine’s rows through the existing adams_rows_to_host, losslessly. A load installs both, restores \(w_Q\), then calls reset on the held trial. A snapshot is taken only at an event with nothing ahead of it, as today.

Why \(w_Q\). It decides the collar’s re-entry cap (§4.3). Today’s snapshot does not hold it, so a resumed run starts with it false and skips a cap the uninterrupted run would take. And begin_run does not reset it, so a backend reused for another run starts with the last run’s value. Phase 0 adds the field (output schema +1) and resets it in begin_run, in the Python march first (decided, R9).

Where a snapshot continues.

Snapshot from

Native march

Host march on native

numpy_ref, numba_cpu

Native march

yes

yes: the same engine and rows

yes, except a frozen-residual coupled flow’s history (applied rows), which the host engines refuse by name, so it restarts, as today

Host march on native

yes

yes

the same as the row above

numpy_ref, numba_cpu

yes, except a frozen-residual flow’s history (weights_<row>), refused by name, as today

the same as the column to the left

yes

The existing named refusals stay. No conversion between the two frozen-residual forms is proposed.

What a resume promises (R10).

  • The transfer is exact. The rows, positions, identities, slots, order, controller state and \(w_Q\) equal the stored ones after a round trip (SEP 29 R17).

  • The continuation is an adaptive run. A resumed run meets the case’s accuracy envelope against a converged reference, as SEP 25 §4.4 rules. It need not take the uninterrupted run’s steps.

  • A rounding budget applies only to matched comparisons: a fixed-order schedule with matched history and plane handling.

6.6 Backend order, exceptions and refusals#

The method is not new. Multistep() already runs on numpy_ref, numba_cpu and native, and the Python AdamsMarch is its oracle on all three. This SEP adds a native execution of the existing march, as SEP 16 added the native engine. The backend order of SEP 0 §7 is met as follows:

  • numpy_ref first (phase 0). The controller is factored into a pure, data-driven core and the policy record, with no change in behaviour. The decision-trace corpus of §7.1 is generated from it.

  • numba_cpu keeps the Python march. This is the exception this SEP records:

    • Its per-trial Python cost is of the same kind.

    • But a compiled march there would have to call FFTW plans and the stage pipeline from nopython code, which is the compiled step driver backends.md §3.5 decided against.

    • Compiling only the decision would save about a tenth of the host cost and keep every crossing.

    • numba_cpu is being demoted, and backend parity of the march is not needed. No roadmap item is added (decided, Q9).

  • Native gets the march (phases 1–5).

Admission (R11). One matrix decides how each configuration runs on the native backend at Final. It covers CPU and device builds alike, since phase 5 comes before Final (Q6). A configuration is matched on its formulation, its corrector and interpolant, whether its engine is resident, its norm scale and the build, never on the method’s type alone. ComparisonMultistep is a subclass of Multistep, and its one-order-higher PEC variants take the same resident path.

Configuration

At Final

Phase

Multistep(), exponential, on a constant, expansion or z-table operator, with the transported compensation, carrier frame, collars, interpolated planes, kinks and budget

native march

2 (CPU), 5 (device)

Multistep(formulation="interaction")

native march

3, 5

ComparisonMultistep with the one-order-higher PEC corrector (corrector="pec", corrector_order = order + 1), fixed or variable order, with the Adams or the Hermite interpolant

native march: the resident fused path, whose observation already takes the Hermite rule

3, 5

A prescribed schedule, single run

native march

3, 5

A coupled flow whose sections all lie on the eigenbasis route, at most 16 channels; a moving transform map

native march

4, 5

ComparisonMultistep’s general path: PECE, explicit Adams–Bashforth, matched pairs

warned host march (host arrays, as today)

—

A coupled flow off the eigenbasis route or wider than 16 channels (the host engine, ADR-044)

warned host march

—

A norm scale other than PIController.scale

warned host march

—

Lockstep batch replay (LockstepRunner, replay_step)

warned host march

—

The untransported compensation (a private test witness); the private adams_march="host" switch (§5)

host march, no warning

—

Everything _refuse_adams and _refuse_unsupported refuse today: TDSE, single precision, CF4, a batch axis, the interaction formulation beside a coupled flow; on device builds the carrier frame and z tables

refused by name, unchanged

—

The warning (decided, Q2). A run that takes the warned host march warns once, when the backend is constructed, with a SolpropWarning subclass, for example HostMarchWarning. It names the reason and the native march’s coverage, and the choice is recorded in adams_march.

This is not a fallback between backends: both marches execute the same method on the same backend, and each meets the case’s accuracy envelope (R10). The same already holds when _adams_engine returns the host engine for a flow off the eigenbasis route (ADR-044). Warnings are errors in the test suite, so a test of a warned configuration on native asserts the warning.

While the phases are under way, the rows of later phases run the warned host march, devices until phase 5 included. That is a state of the branch, not of Final.

Inside the march, a record with a missing or out-of-range field is refused by name with a BackendError before anything is allocated.

6.7 GPU builds#

On a device build the march runs on the host thread. For each trial it:

  1. forms the tables and scalars in C++ (a few µs);

  2. uploads them in one copy, from a pinned host buffer instead of today’s two unpinned ones;

  3. launches the trial’s kernels;

  4. reads back at most three norms (one planned synchronisation, already there today);

  5. decides, then launches the accept.

The divisor is already formed on the device (adams_upload_divisor), so the only read-back is the norms.

SEP 16 measured 0.13–0.2 ms of host Python per trial on a device, against kernels of 3–9 µs. LupoLab/solprop#55 has since cut the host’s share by more than half on the CPU, and the march removes nearly all of what is left.

The fused trial of LupoLab/solprop#55 also runs on devices, with blocks of one element. The trial’s own work is two launches around the source, and the accept is one. The source’s own launches remain; phase 5 counts them. Graph capture would address them later. A controller resident on the device is out of scope (§10).

Device qualification follows the standing policy (backends.md): in one pass, in a GPU segment with its own budget, as phase 5 and before Final (Q6). Until then a device build runs the host march, records it and warns (Q2).

6.8 Cost: time, memory, code#

  • Memory. The attempt-row buffer (256 × 7 doubles), the march state (a few hundred bytes) and the scalar tables (kilobytes) are all listed in memory_ledger(). Nothing state-sized is added.

  • Time. This is the per-trial saving of §6.1. It scales with the number of trials, not with the grid, so it matters most on cheap single-mode sources and least on expensive multimode ones.

  • Code.

    • About 1 500–2 500 lines of C++: the march, the tables, the scalar evaluation, the events, the bindings and the C++ tests.

    • About 500 lines of Python: the controller core, the policy record and the adapter.

    • A parity test matrix.

The lasting cost is two implementations of one algorithm. Any change to the controller becomes a seam change: a schema bump, and both halves changed in the same commit. The host march stays available on native for controller research.

6.9 The native work that needs no SEP, and where the march sits#

These items change no algorithm and leave results unchanged to rounding, or exactly. They follow the working agreement of AGENTS.md, each with a benchmark ledger entry. At the refresh of 2026-10-28 all but N6 had landed as ordinary changes, three of them together in LupoLab/solprop#55. The table keeps the draft’s description and its prototype’s figures, with the status and the merged change’s own measurements beside them.

Item

Results

Prototype’s gain

Status and the landed change’s measurement

N1. Fewer threaded regions. The trial as three passes: phase, magnitude, moments and prediction as one reduction; the source; correction and defect norms as one reduction, with the divisor inline. The restore copy becomes a view swap, and the accept’s mask and store become one pass. Cheap passes stay separate where they do not thread.

rounding

rdw at 16 threads: −25 % (M5), −29 % (EPYC). Nothing at one thread, where the passes are compute-bound.

Landed with N2 (LupoLab/solprop#55): two passes around the source, blocked by 32 elements. At 16 threads rdw, helium and argon −31/−23/−23 % (EPYC) and −25/−25/−10 % (M5); within 2 % at one thread.

N2. A threading size class for the Adams passes. Sized by elements × moments: per element the passes cost about ten times a stage entry, so the stage pool’s threshold leaves them serial on every single-column case.

rounding (reduction order)

On top of N1 at 16 threads: helium −24 points (M5, a tie with RK), −8 (EPYC); argon 0 (M5), −7 (EPYC).

Landed as CpuOperation::adams (four threads from 8,192 element-rows, the pool from 65,536), in N1’s figures.

N3. Range2 tiling. Host tiles of (1, 256) instead of Kokkos’ default, which gives OpenMP ⌈rows/2⌉ tiles.

exact

rdw at 16 threads: −14 % (M5), −19 % (EPYC). It also reaches Runge–Kutta’s Range2 kernels.

Landed (LupoLab/solprop#55) with tiles of about 64 samples: rdw at 16 threads −21 % (EPYC), −16 to −18 % (M5).

N4. The φ-functions’ stopping test and bookkeeping. Squared magnitudes, exact 2^-k and 1/n tables, no pow, log2 or hypot.

rounding (bit-identical on rdw on the EPYC)

One thread: −4 to −6 % (M5), −8 to −11 % (EPYC).

Landed (LupoLab/solprop#51): one thread on the M5, argon −7 %, helium −9 %.

N5a. The newest sample’s carry is the flow. \(e^{(1-\theta_0)x}\) with \(\theta_0 = 0\) is \(e^{x}\), already formed, so one of the compensation’s six exponentials per element is free.

exact

about 1/6 of the compensation’s exponentials (≈40 µs of the helium trial on the EPYC; estimated)

Landed in N1’s change, bit for bit.

N5b. Vector exp/sincos. Over a pass that writes all of a step’s exponentials to a buffer. The draft named glibc’s libmvec on Linux and Accelerate’s vForce on macOS.

the draft expected a few units in the last place per exponential, requalified under SEP 25

Estimated −0.25 to −0.35 ms per helium trial on the EPYC.

Landed differently (LupoLab/solprop#56, ADR-059): SLEEF’s 1.0-ulp functions on x86-64 only; Arm keeps the system libm, since SLEEF’s AdvSIMD was 11.6 % slower on the M5. Within the 1-ulp bound the budgets already assume. One thread on the EPYC: −8.8 % geometric mean over eleven tracking cases.

N6. -fno-math-errno.

not value-identical under GCC: with the same steps the final helium field moved by 1.9e-10 relative and argon’s by 9e-14 (GCC 16.2), within SEP 25’s rules but to be stated when adopted

−1.9 to −2.7 % alone, −12 to −14 % with N4 (EPYC, one thread)

Not adopted; a roadmap item.

N7. Host micro-work. The factored defect rows without numpy.polynomial.polyfromroots (25 → 5 µs a call, coefficients within 2 ulp), and the resident trial’s stage rows packed once (identical arrays).

rounding / exact

−19 and −10 µs a step (M5)

Landed and extended (LupoLab/solprop#55): every table in one native call, one stage row repeated, the sample reusing the step’s arrays, the landing searches on tuples. Host per trial 471 → 208 µs (helium) and 374 → 151 µs (argon) on the EPYC, 179 → 65 and 131 → 51 µs on the M5.

Where the march sits. The table below estimates complete runs with everything done:

  • the column “N items” applies N1–N7 (§3.3’s measured prototype combinations where they exist, plus the estimates above for N5);

  • the next column adds the native march (B), whose saving is the host cost left after N7, less a residual of 5–15 µs (M5) or 15–40 µs (EPYC) per trial, times the trial count of §3.3.

Ranges carry the spread of the measurements and of the unmeasured items. These are estimates, not measurements.

Case

Machine

Threads

MS today

MS, N items

MS, N + march

RK today

MS / RK after

he_rdw_averaged

M5 Max

1

0.967

0.86–0.90

0.69–0.75

0.705

0.98–1.06

he_rdw_averaged

M5 Max

16

0.973

0.665–0.69

0.50–0.54

0.692

0.72–0.78

argon_blueshift_averaged

M5 Max

1

3.863

3.18–3.30

2.31–2.54

1.451

1.6–1.75

argon_blueshift_averaged

M5 Max

16

3.840

3.25–3.50

2.35–2.70

1.465

1.6–1.85

rdw

M5 Max

1

≈7.5

≈6.9–7.2

≈6.6–6.9

≈26.5

≈0.25–0.26

rdw

M5 Max

16

4.627

3.08–3.28

2.73–2.94

11.60

0.24–0.25

he_rdw_averaged

EPYC 7763

1

3.150

2.21–2.35

1.64–1.81

1.389

1.2–1.3

he_rdw_averaged

EPYC 7763

16

3.392

2.12–2.46

1.55–1.93

1.654

0.95–1.15

argon_blueshift_averaged

EPYC 7763

1

12.61

9.8–10.0

6.1–6.6

3.012

2.0–2.2

argon_blueshift_averaged

EPYC 7763

16

12.60

8.45–9.1

4.8–5.7

2.969

1.6–1.9

rdw

EPYC 7763

1

22.03

16.9–18.3

15.8–17.4

59.06

0.27–0.29

rdw

EPYC 7763

16

21.32

12.7–13.8

11.6–12.9

47.43

0.24–0.27

Notes on the table:

  • The M5 rdw rows at one thread are derived from the per-trial profile; no complete one-thread run was timed.

  • The EPYC sixteen-thread rows for the two averaged cases come from a node shared with another job, so read them against their own baseline.

  • The rdw host cost on the EPYC was not measured cleanly. It is taken as 1.4 times helium’s, the M5’s ratio.

What the table shows:

  • On the Apple M5 Max, everything together brings the helium mode-averaged case level with Runge–Kutta at one thread, and about 1.3 times ahead at sixteen. Argon stays 1.6–1.9 times behind. rdw is four times ahead.

  • On the AMD EPYC 7763, helium is 1.2–1.3 times behind at one thread and about level at sixteen. Argon is about twice as slow. rdw is 3.5–4 times ahead.

  • The native march is the largest single item on the single-mode cases on Linux. It takes 0.54–0.57 s from helium’s run (17–18 % of today’s time) and 3.4–3.7 s from argon’s (27–29 %). On the M5 it takes 0.15–0.17 s and 0.76–0.90 s.

  • Runge–Kutta gains too, and the table does not count it. It would gain from N3 (multi-row cases at sixteen threads), N6 (a few per cent) and N5b on its propagators. A native Runge–Kutta march would save its own 10 µs (M5) or 40–50 µs (EPYC) per call: about 0.13 s of helium’s 1.39 s and 0.39 s of argon’s 3.0 s on the EPYC. Such a march is not planned (§10, Q8).

  • On short runs the fixed 90–190 ms per run is then a large share of both methods’ time: 0.12 s of a 0.7 s helium run.

At the refresh. With the N items landed, measured, and the two phases of this SEP estimated from §6.1’s refreshed costing (“B over today”):

Case

Machine

Threads

MS measured

Drafting’s estimate for N items

MS, + phases 1–2

RK measured

MS / RK after

he_rdw_averaged

M5 Max

1

0.815

0.86–0.90

0.75–0.76

0.650

1.15–1.17

he_rdw_averaged

M5 Max

16

0.636

0.665–0.69

0.57–0.59

0.650

0.88–0.91

argon_blueshift_averaged

M5 Max

1

2.920

3.18–3.30

2.55–2.67

1.419

1.80–1.88

argon_blueshift_averaged

M5 Max

16

2.474

3.25–3.50

2.10–2.22

1.381

1.52–1.61

he_rdw_averaged

EPYC 7763

1

2.126

2.21–2.35

1.85–1.88

1.528

1.21–1.23

argon_blueshift_averaged

EPYC 7763

1

9.17

9.8–10.0

7.4–7.7

2.460

3.0–3.1

What it shows:

  • The landed items beat their estimates on every row.

  • Runge–Kutta gained as well. The vector maths took 18 % off argon’s Runge–Kutta run on the EPYC. So the remaining gap there is wider than the drafting estimated.

  • What the march takes. Both phases of this SEP together take 11.5–13 % of helium’s run and 16–20 % of argon’s on the EPYC, and 6.5–8 % and 8.5–12.5 % on the M5.

  • The largest single item on the EPYC is now the Adams passes, 844 µs of a helium trial (§3.3). That is native work outside this SEP.

  • rdw. Its host time is 129 µs per trial on the M5 and 372 µs on the EPYC, of trials of 4 and 10 ms, so the march takes 2–3 % from it. It stays 1.4–1.6 times ahead of Runge–Kutta.

7. Verification and validation#

Tolerances follow SEP 25 and testing.md. Exact equality is used only for structural contracts, and every rounding budget is fixed before the comparison. All runs are ten-step windows or short guides that finish in seconds, as AGENTS.md requires.

7.1 Exact structural contracts#

  • Decision traces (R1, R8). The controller is tested on its decisions, separately from the arithmetic that feeds them.

    • The oracle. The corpus is generated in phase 0 from the reference as it stands before the refactor. The current AdamsMarch is driven through a stub engine that returns prescribed norms. The refactored core and the native march must both reproduce it, so a mistake in the extraction cannot be blessed in both halves.

    • Decisions on supplied scores. Each decision is exercised on identical scalar inputs: an acceptance on its norm, an order adoption on its weights, a landing on its gaps and caps, a restart on its counts. On these the two implementations agree exactly:

      • the accept;

      • the stored and control orders;

      • the restart;

      • every ledger counter;

      • the landing (step, end, kind, kink).

      The threshold witnesses live here, with no pow between input and comparison: norms at \(1 \pm\) one ulp, \(0\), \(\infty\) and NaN; weights tied, and at \(H\) exactly; gaps within \(\tau\) of a plane, a kink, a kink on a plane, and at the stretch limit.

    • The scalar formulas. \(F\) and \(W_k\) are compared over the corpus’s inputs. They are exact where both halves call the same C library’s pow (Linux, macOS). Elsewhere the bound is one ulp, stated in the test.

    • Whole traces. Sequences through startup, growth, rejection and restart are compared with margin-aware expectations. A step whose decision margin is below 8 ulp of its threshold is reported, not compared.

    The traces cover:

    • fixed-order startup and post-restart growth at orders 2–6 (R1);

    • variable order with the collar cap binding, and quiet;

    • three and four consecutive rejections;

    • the budget at its edge;

    • the floor, including an accepted landing shorter than \(h_{\min}/g\);

    • equal approach;

    • the carrier cap;

    • non-default max_growth, min_factor and equal_approach.

  • Stage scalars. The native bundles equal scalars_at’s field for field on constant, gradient, profile, multi-gas, Raman and plasma problems, because they use the same strict Clenshaw helper (§4.8).

  • Events (R5). Each event falls at the accepted step where the host march acts, and each accepted step is accounted exactly once. Checked by counting rows, checks and stops on runs that take the same steps:

    • statistics rows at row_period 1, 3 and 7;

    • natural stops at step_planes 1, 2 and 5;

    • monitor checks, busy and quiet;

    • dense crossings, including one where a natural stop stays due;

    • a plane that is also a statistics row and a monitor check;

    • max_calls = 1;

    • returns with rejections only, and with accepted steps followed by rejections;

    • a return for the call bound.

  • Lossless state (R9, R10). After a round trip in either direction, a snapshot’s rows, positions, identities, slots, order, controller state and \(w_Q\) equal the stored ones exactly (SEP 29 R17), and a load does not take the restart path. The frozen-residual refusals of §6.5.9 stay, with their names. A backend reused for a second run starts with \(w_Q\) and \(\lambda\) cleared.

  • The ledger (R4) counts the calls actually made, checked against the source’s own counter. That includes a startup evaluation that fails, a trial evaluation that fails, and a failure after the source has returned, each also through a snapshot and resume of its ledger.

7.2 Rounding budgets and the table oracle#

  • Coefficient tables (R7). The native tables (_core.trial_tables) and the reference’s (the composition the numpy_ref and numba_cpu helpers use) are both compared with exact tables. These are computed in rational arithmetic (fractions.Fraction) from the same binary64 nodes, for exponential_tables, compensation_tables, factored_defect_rows and fused_tables.

    • The node sets come from real histories, not only random ones: landings that shorten a step by \(10^{-1}\) to \(10^{-10}\) with equal_approach=False, rejection cascades up to the restart, non-default growth and smallest factors, orders 1–6.

    • The error is measured in the weights a trial applies: per row, the sum of absolute errors over the sum of absolute exact weights.

    • The bound is finite and informative: the native error must not exceed the larger of four times the reference’s error on the same node set and 16 ulp.

    • The report. The reference’s own error is reported for every node set. Where it is large, that is a finding for the author (§4.7), not a test to loosen.

  • Matched marches. A prescribed schedule at a fixed order, with matched startup and restart history and matched plane handling, on the native march and on the host march over the same engine. The fields at every plane must agree to a budget from the step count and the per-step rounding of SEP 25 §4.3. This is the R-comparison SEP 25 asks for (phase 3, when replay is native).

  • The norms’ spread (R8). Phase 1 records, per qualification case, the distribution of \(|\Delta\varepsilon|/\varepsilon\) between trials with host-formed and natively formed inputs, under deterministic transform plans. It is expected to be zero outside the carrier frame (§4.9). These are the inputs of §4.9’s margins.

7.3 Accuracy envelopes for adaptive runs and resume#

The native march against the host march on the same native engine. Across the rows of §6.6’s matrix, the cases are:

  • a constant operator;

  • an expansion gradient with the transported compensation;

  • a z table;

  • the carrier frame;

  • the in-stage collar near its stability limit;

  • the quiet collar;

  • interpolated planes;

  • a hard kink;

  • max_calls;

  • tolerances tight enough to reject;

  • the restart after three rejections.

Adaptive resumes (R10) run the same cases stopped part-way, including a stop and resume across a quiet-to-active collar transition (R9).

Each case’s envelope is set in its test against a tighter or stored converged reference, times a safety factor fixed before measuring (SEP 25 §4.4); rtol and atol alone do not bound the global error. Step and call counts are recorded, together with the smallest decision margin of each run. A difference in steps is reported in the phase report, never asserted against.

7.4 The existing suite#

The resident-Adams tests run once under each march:

  • tests/backends/test_resident_adams.py;

  • test_exponential_varying.py;

  • test_exponential_routes.py;

  • test_exponential_march.py;

  • test_carrier_frame.py;

  • the resume, monitor and signal tests;

  • in phase 4, test_exponential_coupled.py.

Each is parametrised by adams_march, so every check the host march passes on native is also a check on the native march. Tests that patch the Python march’s internals keep the host march.

7.5 Lifecycle#

  • Signals (R3). Signals are injected:

    • during a long native call;

    • at the return boundary, after native has committed several steps and before the host has ingested them.

    Each is honoured within the call bound. The terminal checkpoint’s state, position, step history and ledger are checked together, and a resume continues the history.

  • Exceptions in user code (R3). A progress reporter that raises on the first ingested row, and an accepted landing that hits the floor, leave the same consistent checkpoint.

  • Native errors (R4). A failure in the middle of a call leaves the committed steps recorded, and raises the class its error record names. Startup, trial, reduction and accept failures are each injected.

  • Replay (R6, phase 3):

    • the 160-step schedule that ends 12 ulps short of its length, which must count as arriving;

    • kinks on edges;

    • a schedule too short for its target (exhausted);

    • a resume part-way through, with the cursor rebuilt from the position.

7.6 Benchmarks#

The A/B harness, python -m benchmarks ab, compares the two marches on the same build and revision, with N1–N7 held fixed and the march chosen per arm (R12). The harness gains a per-arm environment, or the private switch is read from the environment once at import.

What each phase reports:

  • end-to-end time;

  • attempts, source calls and host events per run, counted, not inferred;

  • residual host time per trial.

Cases:

  • he_rdw_averaged, argon_blueshift_averaged, h2_raman_averaged, rdw;

  • a short window of ctc, for an expensive source;

  • frequent events: a busy monitor, step_planes = 1 and row_period = 1.

Each is run at one thread and the production width, interleaved in fresh processes, on an Apple M5 Max and an AMD EPYC 7763 (or the machines then current, recorded by hardware model).

Lengths. Runs use --length fractions chosen so that each arm finishes in seconds on the slower machine, and named in the ledger entry: short guides, as AGENTS.md requires. Complete tracking runs stay with SEP 38’s release procedure and are not part of this SEP’s qualification.

Baseline. Phase 0 records the baseline in benchmarks.md: the invocation, the lengths, the counted attempts and calls per case, and the host cost by piece. The refresh’s measurements of §3.3 are that baseline. The lean host timer they used is committed with it, so that the invocation can be repeated. The decision to go on to phase 2 then rests on public numbers. Each later phase adds its own entry.

Acceptance rule (decided, R12). On every case above, the native march must not be slower than the host march by more than the A/B’s own measured spread, about 2 %. That includes the expensive-source and frequent-event cases. A regression beyond that blocks Final until it is understood and fixed. The rule is judged where the A/B’s own spread is of that size: an idle machine, or one NUMA domain held alone. A shared node’s rows, such as the EPYC’s sixteen-thread rows at the refresh (§3.3), are reported but do not decide.

8. Documentation#

  • stepper.md, “Low-call Adams methods”. Where the march runs on each backend, the policy record and the events.

  • kernel-contract.md. A new section, “The Adams march”: §4’s algorithm as the normative text, the policy record’s fields, the events, the attempt rows, the scalar tables leaf. §7 links to it.

  • backends.md §4. The native march, its admission, and the GPU behaviour.

  • decisions.md. A new ADR amending ADR-043 (§6.3).

  • native/AGENTS.md. The amended rule (§6.3).

  • The manual page the Adams integrator. A short paragraph under its limitations or costs. On native the march runs in the compiled backend and adams_march records it; the warning says when it does not; and results meet the same accuracy envelope as the host march’s. Its resume paragraph, which says a resumed run agrees with the uninterrupted one “to rounding”, is restated as SEP 25 §4.4 rules: the transfer is exact, and the continuation meets the accuracy envelope (R10).

  • The manual page choosing a stepper. Re-measured rows once the phases land.

  • CHANGELOG.md. A line citing this SEP.

No tutorial or worked example is needed: nothing a user writes changes.

9. Compatibility and impact#

  • Defaults. None changes. Multistep() stays the method, with its fields and constants.

  • Results.

    • On the native backend, the two marches read the same tables and stage scalars. Only the carrier chord, and the order of the controller’s scalar operations, can differ by rounding (§4.9; measured, §7.2).

    • A decision can flip at a knife edge, after which the run meets the case’s accuracy envelope rather than the host march’s steps (§4.9, R10).

    • Validated results elsewhere are unchanged: numpy_ref and numba_cpu run the same Python march, refactored without behaviour change in phase 0.

  • Seam. Kernel schema +1 in phase 1 and +1 in phase 2. New contract tuples SCALAR_TABLES_FIELDS and MARCH_POLICY_FIELDS, and additions to DRIVER_SURFACE.

  • Output. Output schema +1 in phase 0, for the collar flag \(w_Q\) in MarchSnapshot (R9). The march’s name goes into the backend record’s free-form settings and needs no bump. A snapshot written before phase 0 lacks the flag. It is treated as any older schema is: breaking changes are allowed before the first release, and there are no compatibility shims (ADR-051, ADR-058).

  • Rules. native/AGENTS.md’s policy rule is amended, and ADR-043 is amended by a new ADR (§6.3). The step-loop protocol and the integrator–controller split that ADR-058 reserves for change are touched as §6.4 describes.

  • Warnings. A run on native that the native march does not cover warns (HostMarchWarning or similar, §6.6).

  • What it breaks:

    • ADR-043’s single executed controller;

    • Python-level introspection of a native run’s march: the debug lines arrive per call rather than as each step is taken, and monkeypatching AdamsMarch methods applies only to the host march;

    • signal latency, now up to one call bound (0.1 s) instead of one trial;

    • progress granularity, now per call.

  • Luna.jl. No difference to record.

10. Alternatives considered#

  • Option A, the controller owned by C++ (§6.1). The same speed as B, but it puts the policy in native code and keeps two sets of constants.

  • Option C alone (§6.1). At drafting it kept a third of the EPYC’s host cost; at the refresh it keeps about half of what is left, and two crossings per accepted trial. It is kept as phase 1.

  • A native loop that calls back into Python for each decision. It saves nothing, since the crossings stay. It also holds the GIL across native work, and native/AGENTS.md forbids calling Python from native code.

  • A Numba-compiled controller called from the Python loop. It saves the decision’s 39–47 µs on the EPYC and nothing else.

  • A lagged decision. ADR-004’s lagged controller lets a device run ahead of the host by deciding on the previous trial’s norm. For Adams it changes the algorithm: a trial would start before its predecessor is accepted, which wastes a source call on every rejection and changes the step sequence. That breaks the “no change to the method” scope.

  • A controller resident on the device, in a persistent kernel or graph. This is the next step for device builds, but the launches and the norm read-back are a separate problem, and it would be its own SEP.

  • Tabulating the stage scalars on a fine grid (§6.5.6).

  • Moving the Runge–Kutta march native. RK’s host cost is 10 µs (M5) and 40–50 µs (EPYC) per call, and its controller (the lagged PI controller, the stretch, the start rule) is another algorithm to specify. Not pursued: it can be taken up another time, reusing this SEP’s policy-record pattern, if it is needed (Q8).

  • Retuning the controller for fewer steps on the argon case. That is a method change, outside this SEP, and native/AGENTS.md forbids it as a speed measure.

11. Phases#

Every phase is on one branch, feature/sep-0039-native-adams-march, and reaches main in one pull request at Final (SEP 0). Each phase runs targeted checks, and the full gate runs in CI.

Phase 0: the controller core in solprop.

  • MarchPolicy and march_policy.

  • The general path (_attempt, _correct, _commit, _next_order), which serves only ComparisonMultistep, moved out of AdamsMarch into its own class (Q7).

  • A pure core of the controller (stencil, decide, reject, choose_order, the caps, approach and landing) over a policy and a state. AdamsMarch calls it.

  • No change in behaviour, except two fixes:

    • the commit order of §4.4, with a regression test for an accepted landing shorter than \(h_{\min}/g\);

    • the collar flag \(w_Q\), kept in MarchSnapshot and reset by begin_run (§6.5.9; output schema +1).

    The existing Adams tests pass unchanged on numpy_ref and numba_cpu.

  • The decision-trace corpus, generated from the reference before the refactor (§7.1, R1), and the table oracle’s corpus (§7.2, R7).

  • The baseline benchmark entry: invocation, lengths, counted attempts and calls, host cost by piece (§7.6, R12).

  • The kernel-contract section, drafted.

Verified by the existing suite, and by the refactored core reproducing the pre-refactor corpus exactly.

Phase 1: native tables and stage scalars (option C’s pieces).

  • The scalar tables leaf, with derivative tables for the rate weights.

  • Native coefficient tables formed from the nodes inside the trial call. Since LupoLab/solprop#55 this is the existing lagrange::trial_tables called in-process.

  • Native stage scalars, the rate weights and the carrier chord.

  • Lean trial arguments: the positions, the nodes and the scored orders, with one stage row written into the staging the Adams passes read (§6.5.6).

  • The trial and the accept stay separate calls (R2).

  • Kernel schema +1.

  • The Python march still decides.

Verified by §7.1’s scalar equality, §7.2’s table oracle and the norms’ spread, the existing suite, and the benchmarks. Expected, at the refresh, −90 to −110 µs per helium trial and −60 to −80 µs per argon trial on the EPYC, and −25 to −29 and −15 to −19 µs on the M5 (§6.1). At drafting the expectation was −265 to −335 µs per helium trial on the EPYC. Phase 1 stays in this SEP and lands with the rest in its one pull request (decided, Q11).

Phase 2: the native march, for the exponential formulation on diagonal operators, with everything of §6.5:

  • the events;

  • commits, signals and errors;

  • snapshots;

  • metadata;

  • admission and the host march elsewhere.

Also in phase 2: the new ADR, the native/AGENTS.md amendment, the documentation of §8, and kernel schema +1. The two marches are compared on the same revision with N1–N7 held fixed, under §7.6’s acceptance rule. Verified by §7.1, §7.3, §7.4, §7.5 and §7.6.

Phase 3: the interaction formulation’s fused path (Q4), and replayed schedules natively, with replay’s own cursor, slack, kink and exhaustion rules (§4.3, R6). Single runs only; lockstep batches keep replay_step on the host (Q14). Verified by §7.2’s matched marches, §7.4 and §7.5’s replay cases.

Phase 4: coupled flows and moving maps, natively, for every flow the resident engine executes (Q2). The flow section and weights, the block’s end weights and the transform weights per position. Verified by test_exponential_coupled.py under both marches.

Phase 5: device builds, inside this SEP and before Final (Q6), with a GPU budget the maintainer approves when the phase starts. The march on CUDA and HIP builds, in one GPU segment under the standing device policy: the parity tests with -m gpu, and ten-step timings against the host march.

The native items of §6.9 proceed independently. Each phase’s benchmarks are measured on main as it then stands, and the report says which of N1–N7 had landed.

12. Open questions#

The author decided every question below on 2026-10-28, before review. Each decision is recorded beside its question, and the proposal above follows it.

  • Q1. Amend native/AGENTS.md’s rule with the wording of §6.3, or keep it as it is and record B as an exception in the new ADR? Decided 2026-10-28 (John Travers): amend.

  • Q2. Configurations outside the native march’s admission: run the host march and record it, or refuse them by name on native? Decided 2026-10-28 (John Travers): run the host march with a warning, and aim to cover every configuration (§6.6).

  • Q3. The call bound. A scheduler allows seconds, but a keyboard interrupt wants tenths. Decided 2026-10-28 (John Travers): 256 attempts and 0.1 s of wall time, not a user setting (§6.5.7).

  • Q4. Does the interaction formulation’s fused path deserve the native march (phase 3)? It is not the default, but on SEP 37’s capillary rows it scored 1.45/1.47 times the fastest against the exponential formulation’s 2.15/2.26, and the controller is shared. Decided 2026-10-28 (John Travers): yes, in phase 3.

  • Q5. Coupled flows and moving maps (phase 4): native, or left on the host march? Answered by Q2: native, for every flow the resident engine executes. What has no resident engine is Q14.

  • Q6. Device builds: qualify in this SEP’s phase 5 or in a later device SEP? Decided 2026-10-28 (John Travers): in phase 5, before Final, with a GPU budget approved when the phase starts.

  • Q7. Whether phase 0’s refactor should also simplify AdamsMarch itself. The general path, _attempt, _correct, _commit and _next_order, which serve only ComparisonMultistep, could move into their own class. Decided 2026-10-28 (John Travers): yes, in phase 0, with no change in behaviour.

  • Q8. A native Runge–Kutta march in a follow-up SEP, reusing the policy-record pattern, so that the default comparison is between equally optimised marches? Decided 2026-10-28 (John Travers): no. It can be taken up another time if it is needed.

  • Q9. A compiled march for numba_cpu? Decided 2026-10-28 (John Travers): no. Backend parity of the march is not needed, and numba_cpu is being demoted, so no roadmap item is added (§6.6).

  • Q10. The capillary default. Decided 2026-10-28 (John Travers): outside this SEP’s scope; it changes no default.

  • Q11. Should phase 1, which needs no SEP on its own, land ahead of acceptance as an ordinary optimisation pull request? Decided 2026-10-28 (John Travers): no. It lands with the whole SEP.

  • Q12. Progress and logging granularity. Is one progress update and one debug line per event enough, or should the attempt rows be logged one by one at debug level? Decided 2026-10-28 (John Travers): progress once per call; one debug line per attempt, written from the rows when DEBUG is enabled (§6.5.8).

  • Q13. Should an accepted landing shorter than \(h_{\min}/g\) raise the floor (§4.4)? The Adams march forms its next proposal from the step taken, so it does; the Runge–Kutta march grows from its proposal and does not. Decided 2026-10-28 (John Travers): phase 0 fixes the commit order. The rule may be changed in this SEP if the implementation finds a reason, recorded in the phase report.

  • Q14. Which configurations may still run the warned host march at Final (following Q2)? Proposed: only those with no resident engine or no data form for the native march:

    • ComparisonMultistep’s general path;

    • a coupled flow whose sections are off the eigenbasis route or wider than 16 channels (ADR-044);

    • a controller whose norm scale is not PIController.scale;

    • lockstep batch replay.

    The untransported compensation is a private test witness and does not warn. Decided 2026-10-28 (John Travers): as proposed. The proposal also listed “device builds, until phase 5”. With phase 5 before Final (Q6), that is a state of the branch, not of Final, and it moves to §6.6’s matrix (R11).

13. References#

  1. E. Hairer, S. P. Nørsett and G. Wanner, Solving Ordinary Differential Equations I: Nonstiff Problems, 2nd ed., Springer (1993), sections II.4 and III.5. doi:10.1007/978-3-540-78862-1

  2. P. N. Brown, G. D. Byrne and A. C. Hindmarsh, “VODE: a variable-coefficient ODE solver,” SIAM J. Sci. Stat. Comput. 10, 1038 (1989). doi:10.1137/0910062

  3. M. Hochbruck and A. Ostermann, “Exponential integrators,” Acta Numerica 19, 209 (2010). doi:10.1017/S0962492910000048

  4. S. M. Cox and P. C. Matthews, “Exponential time differencing for stiff systems,” J. Comput. Phys. 176, 430 (2002). doi:10.1006/jcph.2002.6995

  5. G. Söderlind, “Automatic control and adaptive time-stepping,” Numerical Algorithms 31, 281 (2002). doi:10.1023/A:1021160023092

  6. N. J. Higham, Accuracy and Stability of Numerical Algorithms, 2nd ed., SIAM (2002), chapter 22 (Vandermonde systems). doi:10.1137/1.9780898718027

  7. D. Goldberg, “What every computer scientist should know about floating-point arithmetic,” ACM Comput. Surv. 23, 5 (1991). doi:10.1145/103162.103163

  8. IEEE Standard for Floating-Point Arithmetic, IEEE Std 754-2019 (2019). doi:10.1109/IEEESTD.2019.8766229

  9. J.-M. Muller et al., Handbook of Floating-Point Arithmetic, 2nd ed., Birkhäuser (2018), on the accuracy of elementary-function libraries. doi:10.1007/978-3-319-76526-6

  10. C. R. Trott et al., “Kokkos 3: programming model extensions for the exascale era,” IEEE Trans. Parallel Distrib. Syst. 33, 805 (2022). doi:10.1109/TPDS.2021.3097283

Part II — Implementation record#

Phase 0 — report#

Scope.

  • Phase 0, the controller core in solprop, 2026-10-28/29.

  • Opus 5.5 (AI) for John Travers, on feature/sep-0039-native-adams-march, based on LupoLab/solprop#56 at a500d2d.

  • The decision-trace oracle was committed first, before any code changed. Then came the core, the general path and the fixes together, then the host timer, the baseline and the contract draft.

Delivered.

  • The oracle.

    • tests/backends/adams_traces.py drives the march through a stub backend and engine on scripted norms, and records every stencil, decision, proposal, order, landing, restart and ledger counter.

    • tests/data/generate_adams_traces.py wrote tests/data/adams_traces.json.gz from a500d2d: 35 scenarios and about 2 900 attempts, gzip-compressed to 313 kB, the floats exact. It refuses to overwrite the file without --force.

    • The scenarios: fixed-order startup and post-restart growth at orders 2–6, variable order with the collar cap on and quiet, rejection cascades and failure restarts, the budget’s edge, the floor after rejections and after a short landing, equal approach on and off, the carrier cap, non-default factors, threshold norms (1 and its neighbours, 0, ∞, NaN), the interval cap, kinks on and off planes, dense observation, replay (the 160-step schedule 12 ulps short, kinks on edges, exhaustion, divergence) and eight random settings.

    • tests/backends/test_adams_traces.py requires the march to reproduce it.

  • The policy record. MarchPolicy and march_policy are in src/solprop/stepper/multistep.py. RESTART_AFTER, ORDER_HYSTERESIS and COLLAR_STABILITY move there from backends.multistep. The rejection’s growth cap becomes a named constant, REJECTION_GROWTH. LANDING_ULPS is one public constant in stepper.schedule, which backends.driver imports; the duplicate is gone.

  • The core. src/solprop/backends/adams_core.py: targets, factor, stencil, stable_factor, order_weight, choose_order, rejection, carrier_capped, quiet_capped, collar_reentry, next_landing, approach and replay_kink. Each is extracted operation for operation. AdamsMarch keeps thin methods over them, those the tests and the general path call.

  • The general path (Q7). GeneralPath in src/solprop/backends/multistep_general.py holds the comparison variants’ attempt, correction, commit and next order, with _Trial, _defect_at and _norm_function. The march creates it only for those variants.

  • The fixes.

    • Commit order (§4.4). An acceptance moves the position, the kink restart, the stored order and last_rejected inside its commit before the next proposal is tested, in both paths (AdamsMarch._propose). A floor found then is raised once the attempt is recorded (_raise_floor): by march_to, by _replay_to and by the lockstep runner after their records, and by _afford before any later attempt, so that it cannot be passed over.

    • The collar flag (R9). MarchSnapshot.collar_was_quiet is written to and read from the HDF5 snapshot, restored by load and cleared by begin_run, with any pending floor. Output schema 28; the tests that pin the schema follow.

  • Tests.

    • The corpus, with the short-landing floor scenario checked against the oracle as the fix changes it: the accepted attempt recorded, the position at its plane.

    • The flag across the file, its absence refused like the other attributes’, a new run clearing it, and a restored flag deciding the re-entry cap.

    • tests/backends/test_adams_table_oracle.py, the exact rational oracle of §7.2.

  • The baseline. The measurements of §3.3 are recorded in benchmarks.md, “Exponential Adams host cost: the SEP 39 baseline”. The lean host timer is python -m benchmarks.adams_host.

  • The contract. kernel-contract.md §11, “The Adams march”, drafted: the policy record’s fields, the state, an attempt, and what a native execution returns.

Deviations from the proposal.

  1. The first proposal is not a policy field. The record holds no \(h_0\): the algorithm reads the proposal from the state, which the driver sets, through its start-up rule (auto_init) when no dz_init is given.

  2. The norm’s scale is not in the record. It is the controller’s of kernel-contract §7, which the engine already applies to the norms it reduces. §4.2 lists it as something the algorithm reads; it reads it through the norms.

  3. The quiet interval. The Python march still reads the monitor’s interval through the driver (StepDriver._quiet_capped), so its own record leaves quiet_interval empty. The native adapter fills it when it configures a run, from the attached monitor (phase 2).

  4. The oracle covers the exponential path. Its stub engine answers the exponential trial. The fused trial (phase 3) and the general path are covered by the existing suite, which passes unchanged. The general path’s half of the commit-order fix goes through the same _propose as the exponential path’s, and has no test of its own.

  5. The corpus is compressed, at 313 kB against 2.5 MB of JSON; json keeps its floats exact.

Decisions.

  • march_policy takes keywords, not the stepper settings. stepper may not import translate, which imports it.

  • The general path is a held object, not a mixin. backends.multistep_general imports from backends.multistep, and the march creates it lazily.

  • The flag’s dataclass field defaults to false; the file reader requires it. A snapshot written before schema 28 is therefore ignored with a warning, and the resumed march restarts its history (ADR-051).

Verification.

  • The corpus: all 35 traces reproduced after the refactor, then 34 unchanged and the 35th changed exactly as the fix describes.

  • Targeted modules on the Apple M5 Max, under xdist:

    • the core alone: 693 passed;

    • the general path: 374 passed, 1 skipped;

    • the fixes and the schema: 1 121 passed. Fifteen setup errors (fixture 'backend_name' not found) came from mixing test directories in one xdist call, and those modules passed alone.

  • The changed modules: basedpyright reports no errors, and ruff, numpydoc lint, codespell and their doctests pass.

  • The table oracle, over the 2 090 distinct stencils of the corpus and the review’s history of four unit steps and a trial of \(10^{-10}\):

    • every record (Numba and native) is within its bound of the exact tables;

    • the plain composition’s own error is at most \(4.5\times10^{-16}\) of a row’s weights, about two ulp, on every table. The reference does not inherit the monomial Vandermonde’s conditioning (§4.7); the test now holds it to 16 ulp.

  • Not run: the full gate and the docs build, which CI runs.

Sources checked. No constant or citation is new. The moved constants keep their values and docstrings.

Limitations. Every execution is still the Python march. The oracle’s scenarios use a stub engine, so the norms are scripted, not computed.

Findings for the roadmap, outside this SEP:

  1. The Adams passes on x86-64. On the AMD EPYC 7763 the exponential trial’s predict and correct passes cost 2.9–3.8 times the M5’s per element, against 1.9 times for the source. They are 844 µs of a helium trial (§3.3), the largest single item left on Linux.

  2. Threading on a shared node. On a shared 16-core domain of that machine, he_rdw_averaged and h2_raman_averaged ran slower at sixteen threads than at one, with both steppers. Worth re-measuring on an idle node before it is called a defect.

Handoff to phase 1.

  • The trial binding’s arguments are in bindings.cpp (about lines 1839–1905), and its stage rows are read at row n_stages (general.cpp, about lines 6412 and 6447).

  • _ResidentAdams.exponential_trial and _resident_arrays are the host side to replace.

  • The scalar tables are the PiecewiseChebyshev leaves that translate.stage.scalars reads.

  • The baseline to beat is §3.3’s.

Phase 1 — report#

Scope.

  • Phase 1, native stage scalars and the lean trial, 2026-10-29.

  • Opus 5.5 (AI) for John Travers, on the same branch (b97bdbd, c164e3b).

Delivered.

  • The tables, natively (native/include/solprop_native/stage_tables.hpp). Where a resident exponential trial’s stage rows are one bundle repeated, the native backend uploads the run’s scalar tables once (NativeBackend._stage_tables, through PiecewiseChebyshev.packed, the batch pass’s own view of the expansions): the weights of a constant or expanded \(\Phi\) and prefactor, the density, the taper, the Raman decay rates and densities, the Kerr density, the plasma channels’ densities and fractions, and an expansion’s rate table. The native state evaluates them by the reads of translate.stage.scalars, through the same strict Clenshaw helper in the same order.

  • The lean trial. adams_exponential_trial_at takes the anchor, the step, the nodes, the history’s rows and the scored orders, and forms in-process the coefficient tables (lagrange::trial_tables), the step’s stage row and the lab-frame sample’s, and the endpoint rate (stage::rate). Only the nodes cross.

  • The host side. _ResidentAdams.staged selects the lean call; z tables, separable operators, SEP 6’s dispersive channels, moving maps and coupled flows keep the host’s packing until phase 4. The carrier frame’s shift is still formed on the host.

  • Bindings for the tests: adams_stage_scalars and adams_stage_rate.

  • benchmarks.adams_host --host-stages restores the host packing, for this phase’s A/B.

Deviations from the proposal.

  1. No kernel-schema bump, no SCALAR_TABLES_FIELDS. The tables are the problem’s own PiecewiseChebyshev leaves, which the native backend already receives; they are packed and uploaded by the native backend’s adapter, not carried in a new KernelProblem leaf. No contract field moved, so phase 2’s bump (schema 39) is the SEP’s only one.

  2. The carrier chord stays on the host. It runs only with the carrier frame, which the native march refuses until it forms the chord natively (phase 4’s list).

Verification.

  • tests/backends/test_native_stage_tables.py: the native bundles equal translate.stage.scalars field for field, exactly (constant, gradient, profile, multi-gas, Raman, plasma), and the endpoint rate equals phi_rate.

  • On one build with SOLPROP_FFTW_PLAN=estimate, the staged march’s steps and fields equal the host-staged march’s bit for bit on four cases and seven routes (a recorded expectation, §4.9; the norms’ spread is zero).

  • The backend modules on the Apple M5 Max: 313 passed and one failure, the helper ownership test, which counted the host’s table calls and now counts the native ones.

Measured (interleaved fresh processes, medians of three, the same steps in both arms):

machine

case

host µs/trial, host packing → staged

wall

AMD EPYC 7763, 1 thread

he_rdw_averaged

211 → 66

−9.8 %

argon_blueshift_averaged

150 → 59

−13.9 %

h2_raman_averaged (0.4)

169 → 58

−7.7 %

rdw (0.25)

358 → 178

−2.7 %

Apple M5 Max, 1 thread

he_rdw_averaged

43 → 12

−5.0 %

argon_blueshift_averaged

30 → 11

−8.5 %

h2_raman_averaged (0.4)

36 → 11

−4.4 %

rdw (0.25)

88 → 46

−0.8 %

The EPYC node was shared (load 81–93), so its walls carry that node’s noise; the host times are the timer’s own. The gain is about what §6.1 estimated for phase 1 (−110 and −60 µs a trial on helium and argon).

Handoff to phase 2. The march loop’s engine calls are adams_start, the staged trial and adams_exponential_accept; the state the march must hand back is AdamsMarch’s (§4.2).

Phase 2 — report#

Scope.

  • Phase 2, the native march for the exponential formulation, 2026-10-29.

  • Opus 5.5 (AI) for John Travers (b1290af, 124c3d7). LupoLab/solprop#56 merged to main during the phase; the branch took main (d3af6df) and #58 now targets it.

Delivered.

  • The march (native/include/solprop_native/adams_march.hpp, native/src/adams_march.cpp): Policy, State, the decisions transcribed from adams_core operation for operation (Python’s min/max NaN semantics included), the engine interface, and run, which performs §4’s attempt loop to an event: reached, host step, monitor, bound (256 attempts, 0.1 s on a steady clock), budget, floor or a native error recorded with its stage and category. The unit is compiled with -ffp-contract=off on every compiler that contracts, and calls only pow.

  • The engines (bindings.cpp): ResidentEngine over the general state and the phase-1 tables (startup, staged trial, accept with promote_fsal), with a test hook that fails a chosen stage (adams_march_fault); ScriptedEngine and ScriptedMarch, which replay the decision corpus’s norms.

  • The adapter (_NativeAdamsMarch, a subclass of AdamsMarch): the admission of §6.6 with HostMarchWarning; one committing() per call around the native run, the state’s adoption and the rows’ booking; progress, debug lines and the event step’s statistics and natural stop after it; the errors raised last, with the reference’s messages. A snapshot, a load and a new run are the Python march’s own.

  • The driver gains _record_attempt and _skip_step; BoundaryMonitor.steps_to_check and checked_at give a call its monitor bounds.

  • The seam (kernel schema 39): the two hooks join DRIVER_SURFACE, with _take_step_stats, _natural_stop and _natural_stop_due frozen beside them; MARCH_POLICY_FIELDS is checked exactly (a field either way is refused by name) and ADAMS_MARCH_SURFACE names the march’s methods the adapter relies on.

  • Docs: ADR-060 (amending ADR-043), the native/AGENTS.md rule in §6.3’s words, kernel-contract §11 normative, stepper.md, backends.md §4, the Adams guide (coverage, cost, the resume envelope), the changelog.

Deviations from the proposal.

  1. Bulk hooks are per attempt. The driver books each row through _record_attempt, not one bulk call: a call’s rows are few (an event comes within 256 attempts), and the per-row hook keeps the reference’s bookkeeping in one place.

  2. The monitor’s first check is the monitor’s own (steps_to_check returns one while it has not checked); the bounds’ first_check stays false.

  3. No adams_march_state/adams_march_load. The state crosses with every call, so a snapshot is the Python march’s and nothing native needs saving.

  4. benchmarks/ab.py has no per-arm environment. The A/B used benchmarks.adams_host --host-march in interleaved fresh processes instead.

Verification.

  • The decision corpus through the native march on its own norms (test_native_march_traces.py): all 35 scenarios, each call bound to one attempt so that every stencil, decision, proposal, landing, restart and ledger counter is compared with the reference’s exactly, and again with the production bound, comparing the attempts and the end state.

  • Lifecycle (test_native_march_lifecycle.py, §7.5): failures injected at the startup, a trial and an accept raise their class at the last accepted step, with the checkpoint and the march’s state at one position and a resume that meets the accuracy envelope; a progress reporter’s exception and SIGTERM during a call likewise; the floor and the budget stop both marches at the same step with the same message.

  • Admission (test_native_march_admission.py): the native routes run without a warning, the uncovered ones warn naming the reason, the private switch is quiet, and the metadata records the march.

  • The unit (test_adams_march.cpp): it rounds a product and a sum separately (checked at -O3 on the Apple M5 Max: without the per-file flag it fuses), refuses a policy by the field at fault, and keeps its ledger on a steady engine.

  • Same build, estimate plans (§4.9): the native march’s steps, ledger and fields equal the Python march’s bit for bit on the four tracking cases (recorded, not asserted).

  • The backend and driver modules touching Adams runs on the M5 Max; the full gate on CI.

Measured (interleaved fresh processes, medians of three; every arm took the same steps and calls; “host” is solprop’s march on the phase-1 staged trial, “before” the march with host packing, as before phase 1):

machine, threads

case

host µs/trial: before → host → native

wall vs host

wall vs before

Apple M5 Max, 1

he_rdw_averaged

43 → 13 → 0.6

−3.3 %

−7.6 %

argon_blueshift_averaged

30 → 11 → 0.4

−6.0 %

−13.6 %

h2_raman_averaged (0.4)

37 → 11 → 0.5

−3.9 %

−11.6 %

rdw (0.25)

91 → 47 → 2.7

−0.6 %

+0.4 %

Apple M5 Max, 16

he_rdw_averaged

47 → 13 → 0.6

−4.2 %

−13.2 %

argon_blueshift_averaged

32 → 12 → 0.4

−9.2 %

−17.2 %

h2_raman_averaged (0.4)

39 → 12 → 0.6

−6.2 %

−16.2 %

rdw (0.25)

171 → 122 → 1.9

−2.2 %

−4.5 %

AMD EPYC 7763, 1

he_rdw_averaged

208 → 65 → 1.7

−6.1 %

−14.0 %

argon_blueshift_averaged

148 → 58 → 1.1

−11.5 %

−24.2 %

h2_raman_averaged (0.4)

168 → 58 → 1.2

−9.5 %

−22.7 %

The M5 was otherwise idle but for one indexing process (load 2.3–4.6). The EPYC rows are from a shared node (load 80–97, the task’s cores spread over its NUMA domains): its one-thread walls are consistent, its sixteen-thread ones bimodal and not reported here. rdw on that node read 6–9 % slower natively at both widths, against host times of 185 → 17 µs a trial. Re-timed on a quiet node (load 3–5, sixteen cores of one NUMA domain), nine rounds: −1.7 % at one thread (medians); at sixteen, +2.7 % by medians and −3.3 % by minima, inside that node’s run-to-run spread of ±6 %. The host time saved there, 280 µs on each of 394 trials, is 3 % of the run: below what the node can resolve, and no regression is measurable.

The gain is §6.1’s estimate for phase 2 on helium and argon (60–85 and 50–75 µs a trial on the EPYC), and the native march’s own host time, 0.4–2.7 µs a trial, is the adapter’s per-call work and the rows’ booking.

Mac performance leads. The residual host time on the M5 is 0.4–0.6 µs a trial on the mode-averaged cases: the per-call Python (bounds, state dictionaries, the rows’ booking), amortised over the attempts between events. On rdw a statistics row falls due every step, so every attempt is its own call (2–3 µs). A cheaper booking path (one _record_attempts call per call’s rows) would trim the latter; nothing else on the host is above a microsecond a trial.

What a native run spends now, sampled with macOS sample over twelve whole he_rdw_averaged runs at one thread (each run’s translation included): the native kernels 58 %, the system libm’s exp and sincos 16 %, FFTW 11 %, Python and NumPy 13 % (mostly the per-run translation). The libm share is the Adams predict pass’s and the stage entry’s complex exponentials, element by element: on Apple silicon the vector maths layer keeps the libm (ADR-059: SLEEF’s AdvSIMD functions gained nothing there, and Accelerate’s vForce a fifth at 1.7 ulp). It is the largest single item left on the M5, and the lead for Mac tuning; a symbolised profile of the native share is the next step (the shipped _core is stripped).

Phase 3 — report#

Scope. Phase 3, the interaction formulation, the one-order-higher PEC comparisons and replayed schedules natively, 2026-10-29 (115c12d). Opus 5.5 (AI) for John Travers.

Delivered.

  • The fused trial staged. stage::fused_weights forms fused_tables’ predictor, corrector and defect rows over the strict lagrange::basis_rows, operation for operation; adams_fused_trial_at reads the stage row from the uploaded tables, which interaction-formulation runs now upload too. The Python march takes it as well (_ResidentAdams.fused_trial when staged).

  • The fused path in the native march. ResidentEngine runs the fused trial and accept for Multistep(formulation="interaction") and for ComparisonMultistep with corrector="pec", corrector_order = order + 1 (Adams or Hermite interpolant); Engine::accept receives the kept history, which the fused accept transports.

  • Replay (§4.3): the schedule’s steps and edges at configuration, the cursor (the driver’s schedule index) in the state, the slack (replay_tolerance) in the bounds; kinks within the slack restart, a non-finite norm returns diverged (raised with the reference’s message, unrecorded) and exhaustion is the host’s BackendError, as before. Lockstep batches keep replay_step on the host (Q14).

Deviations. The fused trial’s tables are formed natively rather than shipped (a small extension of phase 1’s scope to the interaction formulation).

Verification.

  • The corpus’s four replay scenarios (the 160-step schedule 12 ulps short, kinks on edges, exhaustion, divergence) run through the scripted native march and match the reference exactly, messages included; every corpus scenario is asserted native.

  • Same build, estimate plans: the native march, the staged Python march and the host-packed Python march agree bit for bit in the interaction formulation on the four tracking cases, and with PEC4/5 (both interpolants) on two (recorded). The staged-march test holds both formulations to a rounding bound.

  • The resident, helper-ownership, z-table and driver modules on the M5 Max.

Measured (Apple M5 Max, interaction formulation; medians of three, the same steps in every arm):

threads

case

host µs/trial: before → host → native

wall vs host

wall vs before

1

he_rdw_averaged

41 → 12 → 0.5

−4.0 %

−11.8 %

argon_blueshift_averaged

33 → 11 → 0.5

−5.4 %

−16.7 %

h2_raman_averaged (0.4)

40 → 11 → 0.5

−5.6 %

−18.3 %

rdw (0.25)

85 → 43 → 1.5

−1.3 %

−2.9 %

16

he_rdw_averaged

41 → 12 → 0.5

−3.9 %

−12.9 %

argon_blueshift_averaged

33 → 11 → 0.4

−8.6 %

−18.2 %

h2_raman_averaged (0.4)

42 → 11 → 0.5

−34.1 %

−15.0 %

rdw (0.25)

165 → 119 → 1.5

−2.1 %

−4.7 %

Mac lead. h2_raman_averaged at sixteen threads took 2.24 s on the Python march with the staged trial in all three rounds, against 1.74 s with the host packing and 1.48 s natively: the staged host march is slower there than the unstaged one, which nothing in its arithmetic explains. Suspected: the OpenMP workers’ idle policy (ADR-056) meeting the shorter host gaps between native calls. Not investigated; the native march, the default, does not show it.

Phase 4 — report#

Scope. Phase 4, every route staged and native, 2026-10-29 (2ddea85, f8ccd06, 86e6c55). Opus 5.5 (AI) for John Travers.

Delivered.

  • Separable operators (the Cartesian, line and planar routes) stage as a constant: the distance and the position, a unit prefactor, no span (_trial_arrays’ packing).

  • SEP 6’s dispersive channels: their densities are stage tables of their own.

  • z tables: the slot cache moves into the native state (stage::ZSlots), one cache for both marches and every consumer, with the rate slot’s memo; _ZTables keeps its interface over it. The stage rows read e_1 - e_0 and e_1 over the slots, loaded as _weight_rows loads them.

  • Coupled flows (the eigenbasis route, at most 16 channels) and moving maps: the flow’s section (its anchor’s) and a frozen residual’s weights, and a moving guide’s map weights, are sectioned Chebyshev rows formed natively (stage::Sections: NumPy’s chebvander recursion, strict, in the section flow_section and transform_section find, with the step’s own section kept as flow_weights keeps it). Beside a flow the lab-frame sample is a stage anchored at the step’s end, as _resident_sample forms it.

  • The carrier frame (phase 2’s row): the chord’s imaginary part at the reference element (a constant’s rate, or an expansion’s terms) uploads with the tables; the engine forms the shift, the rows’ rotations and the cap’s phase, and returns the accepted step’s rotations for dense observation. A z table’s chord stays solprop’s.

What remains on solprop’s march, with the warning: the comparison methods’ general path; a coupled flow on the host engine (off the eigenbasis route or wider than 16 channels, ADR-044); a norm scale other than PIController’s; a z table’s carrier frame; lockstep batch replay.

Deviations from the matrix (§6.6).

  1. A z table’s carrier frame is on solprop’s march (warned), where the matrix puts it on the native one: its chord is the table’s value at the reference element, which the native state would have to evaluate per position from the table’s element column. Not built; a small follow-up if a z-table carrier run is ever wanted.

  2. Lockstep batch replay was at first not warned; it is now: the native batch marks its Adams members’ marches as host-stepped (refusal, so the metadata says host) and warns once, as the matrix asks.

Verification.

  • Every capability route is staged, and its native scalars equal solprop’s exactly (test_native_stage_tables.py, the flow’s and the maps’ rows included); a staged march takes the host-packed march’s steps, fields within a rounding bound, in both formulations where the formulation is admitted.

  • Same build, estimate plans: the coherent wall, the taper coupling (92 attempts), the moving guide (211) and the separable routes take the Python march’s steps with fields bit for bit; the carrier frame’s hydrogen capillary takes its steps, fields bit for bit on a short window and 5.7e-16 apart on a longer one (the chord’s rounding: the reference’s tensordot sums in its BLAS’s order).

  • The z-table, coupled-flow, moving-guide, carrier-frame, dispersive and driver modules on the M5 Max; the full gate on CI.

Phase 5 — report (CUDA on NVIDIA A40s; HIP to come)#

Scope. Device builds, 2026-10-29: CUDA on NVIDIA A40s only (John Travers’s budget for the night: no rented devices). Opus 5.5 (AI) for John Travers.

Delivered.

  • CUDA builds take the native march. The march is host code; its engine calls the device kernels the Python march calls, one norm read-back a trial as before. z tables and the carrier frame stay refused on devices by name, as before.

  • HIP builds keep the warned host march until their own qualification (§6.7).

  • Two compile fixes found building for the A40:

    1. general.cpp (from LupoLab/solprop#56, already on main): nvcc refuses a device lambda’s first capture of a view inside if constexpr; the host-only branch is now guarded for host compilers alone. Device builds of main do not compile without it.

    2. nvcc 12.8’s front end re-emitted the fused tables’ constant std::array bounds as unsigned long(...), which GCC refuses, where the exponential tables’ twin is emitted plainly (not reproduced in isolation); their host-side scratch is sized at run time instead. It was first misread as GCC refusing the strict-function attribute there: that attribute went (harmlessly, since the fused tables only subtract), and the sections’ recursion became a free strict function.

Verification (NVIDIA A40, CUDA 12.8.1, GCC 14.4 host, AMPERE86; one job):

  • The native package’s device tests: 31 passed. solprop’s device tests: 63 passed.

  • The native march’s modules on the device build (the decision traces, the lifecycle and admission tests, the staged-march comparisons, the resident-Adams and resume modules, test_native_gpu.py): 203 passed. Four failed, each a configuration device builds refuse by name: the carrier frame’s admission test (now skipped on devices) and three z-table resume cases that were not device-aware before this SEP (now skipped there too).

Measured (A40, interleaved fresh processes, medians of three; the same steps in both arms):

case

host µs/trial: Python march → native

wall

he_rdw_averaged (0.3)

64 → 2.2

−6.7 %

argon_blueshift_averaged (0.1)

61 → 1.6

−10.6 %

rdw (0.1)

83 → 5.0

−1.8 %

A trial costs about a millisecond on this device, whose FP64 rate is a sixtieth of its FP32’s, so the host’s share is small there; on the FP64 devices SEP 16 measured (kernels of 3–9 µs), the same saving is most of the trial.

Not done. The pinned single upload a trial (§6.7, item 2) was not built: the trial still uploads its scalars as before. HIP (AMD MI325X) and H100-class timings wait for a GPU budget.

Phase 5 — report, part 2 (HIP on an AMD Instinct MI325X; an NVIDIA H200)#

Scope. HIP builds, 2026-10-29: one AMD Instinct MI325X (gfx942, 304 compute units) with a ROCm 7 prerelease stack (HIP 7.14, AMD Clang 23), within the maintainer’s GPU budget for the night. Opus 5.5 (AI) for John Travers.

Delivered.

  • HIP builds take the native march, as CUDA builds do: the refusal that kept them on solprop’s Python march with a HostMarchWarning is gone. z tables and the carrier frame stay refused on devices by name.

  • A HIP compile fix. Since the x86-64 Adams lanes, the device predict pass declared a flag only the host lanes read; Clang (-Werror, -Wunused-variable) refused the HIP build. GCC’s host pass of nvcc does not warn on the constant, so CUDA builds were not affected. The flag is marked [[maybe_unused]].

  • The pinned single upload a trial (§6.7, item 2), which part 1 left undone, has since been built: a trial’s scalars cross in one copy from a ring of pinned buffers.

Verification (AMD Instinct MI325X, HIP, AMD_GFX942, band staging on; one session):

  • The native package’s device tests: 31 passed. solprop’s device tests: 63 passed (one skipped: mlx). The native march’s modules on the device build (as in part 1): 203 passed, 7 skipped (the z-table and coupled-interaction refusals). The native radial and backend modules: 103 passed.

  • On the same device the previous HIP default fails those lanes: 71 of the targeted modules, 6 native device tests and 27 of solprop’s, every one the HostMarchWarning that the suites raise as an error.

  • The native and the Python march on the same build took the same steps and calls on every case below, and their fields were bitwise equal.

Measured (MI325X, python -m benchmarks ab --device and benchmarks.adams_host, interleaved fresh processes, medians of three; the same steps in both arms):

case

host µs/trial: Python march → native

wall, native vs Python march

he_rdw_averaged (0.3)

22 → 1.3

−2.9 %

argon_blueshift_averaged (0.1)

17 → 0.8

−3.4 %

rdw (0.1)

40 → 3.0

−2.0 %

h2_raman_averaged (0.4)

15 → 0.5

−3.0 %

radial_filament_256 (0.5)

29 → 2.8

−0.8 %

A trial there is 160 to 550 µs of device time in 25 to 70 kernels of a few microseconds, which the host launches one by one, so the Python march’s 15 to 40 µs a trial was a smaller share than SEP 16’s measurements suggested. The rest of a trial was launch and synchronisation overhead in the native backend’s own device code, which the performance work that followed reduced (the benchmark ledger, “Device launches and the Adams passes on an AMD Instinct MI325X”).

On an NVIDIA H200 (CUDA 13.1, GCC 14.3 host, HOPPER90; the same session’s tree): the native package’s device tests 31 passed, solprop’s 63 (one skipped, mlx), the native march’s modules 203 passed and 7 skipped, the native radial and backend modules 103. The native march against the Python march on the same build (three rounds, the same steps):

case

host µs/trial: Python march → native

wall

he_rdw_averaged (0.3)

49 → 2.2

−15 %

argon_blueshift_averaged (0.1)

44 → 1.2

−21 %

rdw (0.1)

70 → 5.2

−4 %

h2_raman_averaged (0.4)

41 → 0.8

−23 %

radial_filament_256 (0.1)

65 → 4.7

−3 %

The H200’s host ran the Python march at about twice the MI325X host’s cost a trial, which is most of the difference between the two devices’ walls here.

Not done. HIP’s device sanitizer does not exist (ROCm ships none), so the CUDA sanitizers’ role is not filled on HIP.

Handoff. Phase 5 is complete on both vendors’ devices (NVIDIA A40 and H200, AMD Instinct MI325X); the SEP is ready for Final.

Closing record. Every phase is merged and reported, and the native march runs on the native CPU build, CUDA and HIP. Q9 records that numba_cpu has no march, so solprop’s Python march is the reference there. The manual’s Adams methods page documents the native march and links this SEP. Every open question was decided before acceptance (§12). The one item left is a device sanitizer for HIP, which ROCm does not ship; until one exists, the CUDA sanitizers cover the shared device code. The roadmap keeps a device-resident controller, which the SEP left open, and the findings outside its scope that the phase reports named: the fixed host cost of a run and -fno-math-errno.

Part III — History#

  • 2026-10-28: Drafted by Opus 5.5 for John Travers, from a profiling study of the exponential Multistep() on the native CPU backend (Apple M5 Max and AMD EPYC 7763). Recommends option B (policy as data, mechanism native), built on option C’s native tables and stage scalars.

  • 2026-10-28: The author’s decisions recorded:

    • Q1: amend native/AGENTS.md’s policy rule;

    • Q2: uncovered configurations run the host march with a warning, and the SEP aims to cover every configuration (§6.6; phase 4 is no longer optional; Q5 answered; Q14 added);

    • Q11: phase 1 stays in the SEP.

  • 2026-10-28: The author’s decisions on the remaining questions recorded:

    • Q3: the call bound is 256 attempts and 0.1 s;

    • Q4: the interaction formulation is covered in phase 3;

    • Q6: device builds are phase 5, before Final;

    • Q7: phase 0 moves the general path into its own class;

    • Q8: no native Runge–Kutta march;

    • Q9: no numba_cpu march and no roadmap item, since numba_cpu is being demoted;

    • Q10: no default changes, outside scope;

    • Q12: one debug line per attempt at DEBUG;

    • Q13: phase 0 fixes the commit order, and the rule may change if the implementation finds a reason;

    • Q14: the warned host-march list as proposed.

  • 2026-10-28: AI review by GPT-6 (R1–R12) recorded by the author. The drafting agent (Opus 5.5) answered every point under Response to review and revised Part I.

  • 2026-10-28: The author accepted the two proposals left open by the response: the collar flag \(w_Q\) is kept in MarchSnapshot and reset by begin_run (R9), and the native march may not be slower than the host march beyond the A/B spread of about 2 % (R12).

  • 2026-10-28: Refreshed by Opus 5.5 against the head of LupoLab/solprop#56 (a500d2d), after LupoLab/solprop#51 and #55 had merged, so that Part I describes the code it will be implemented on. No decision of the author’s is changed. The changes:

    • Measurements. §3.3 keeps the drafting tables and adds counted re-measurements on both machines. These cover walls, steps and calls, the lean host time per trial, and where a trial’s native time goes. The rdw Runge–Kutta call count had been inferred about 2.5 times too high.

    • Derived figures. §1, §2, §4.1, §6.1, §6.2, §6.9, §10 and §11 restate their figures from those measurements. The host cost left per trial is about half the drafting’s, so B’s gain over phase 1 is 2–11 % of a single-mode CPU run.

    • §6.9. It records which items landed, and how.

    • §3.1. The per-trial host work is rewritten to the current path.

    • §4.7–§4.9 and §7.2. On native both marches now read the same strict tables and Clenshaw values. Only the carrier chord differs, so the same steps are expected bit for bit on one build under estimate plans: recorded, never asserted.

    • §4.2. It gains the norm’s region and where each constant lives.

    • §4.4. The floor fix also covers the stored order and the general path’s commit.

    • §4.6. It corrects the landing search to “at or beyond \(z + \tau\)”, and the equal approach’s to “strictly beyond”.

    • §5 and §6.4. The march’s name goes into the backend record’s settings rather than a new RunMetadata field: the record that holds how a run executed, outside the resume key, with no output-schema bump. §6.4 also records the current schema numbers, ADR-058’s restart of the output schema, and how this SEP relates to ADR-058’s reserved step-loop protocol.

    • §6.5.5–§6.5.7 and §6.7. They gain the uncached rate weights, the one-row stage staging, the monitor reading that has landed, and the fused trial on devices.

    • §7.6. The no-regression rule is judged on an idle machine or a domain held alone.

  • 2026-10-28: Reviewed by John Travers (human), who accepted the proposal and asked for it to be implemented on a branch based on LupoLab/solprop#56. Before setting the status, the drafting agent (Opus 5.5) made a final read of the refreshed Part I against SEP 0’s acceptance criteria and the code at a500d2d. That read is the source of §4.6’s landing-search correction; it found nothing else to change.

  • 2026-10-28: Accepted by John Travers. Part I is frozen from here; deviations go into the phase reports of Part II.

  • 2026-10-28: In progress. Implementation started by Opus 5.5 for John Travers on feature/sep-0039-native-adams-march, based on LupoLab/solprop#56, with one draft pull request opened early so that CI tests each push.

  • 2026-10-30: Final, set by John Travers in LupoLab/solprop#59, after phase 5’s report, part 2 (the HIP qualification), and the closing record. Archived.

AI review — 2026-10-28#

Reviewer: GPT-6 (AI), acting through Codex. Reviewed all of Part I and the associated index and roadmap changes on PR #52 at 40c34af. This review changes only the reviewer metadata and appends findings here; it does not revise Part I or record a human review. The author’s decisions in §12 remain the author’s.

Assessment: option B is a reasonable architecture, and keeping the host march available on the same engine gives it a useful verification path. The proposal needs the following corrections and contract decisions before acceptance. P1 marks a correctness or lifecycle issue to resolve before implementing the affected phase; P2 marks a specification or qualification issue to resolve before accepting the corresponding promise. All findings are open for the author’s response.

Evidence and limits. The review compared the specification with the current march, controller, landing rule, stage tables, native adapter, interrupt guard, monitor and snapshot code, and with SEPs 0, 16, 25 and 29. Small executable probes confirmed R1 and R6 and the conditioning example in R7; no propagation benchmark or native CPU/GPU qualification was run for this review. The existing virtual environment did not contain solprop-native. The moment identity in §4.7 agrees with equation (2.10) of Hochbruck and Ostermann’s author manuscript: putting k = p + 1 gives the stated factorial. The Lagrange/Vandermonde identity was also checked against Higham’s explanation. The other bibliography entries and the profiling measurements were not independently verified. The issues below concern execution and qualification, not a proposed change to the propagation equations.

  1. R1 — P1: distinguish the stored order from the order controlling startup growth (§§4.4–4.5, 7.1, phase 0). With fixed order, §4.5 sets q_n = q_max, then §4.4 uses that order in F. The current AdamsMarch._choose_order instead stores method.order for the next stencil but returns current as the factor’s exponent order. At startup and after a restart, current can be smaller than q_max. A probe with Multistep(order=5, variable_order=False), one available history sample and norm 0.25 gives a factor of 1.6 in the reference, versus 1.0079368399158986 under the draft’s formula. This changes steps away from any rounding threshold. Please specify these two orders separately and add fixed-order startup and post-restart traces for orders 2–6. Preserve the pre-refactor reference as an oracle: generating the whole corpus only from the newly extracted core could bless this same error in both halves.

  2. R2 — P1: define how phase 1 commits an acceptance before the next trial exists (§6.1 option C, §6.5.7, phase 1). Fusing a trial’s accept into the next trial call leaves the accepted state pending. Today _decide calls the engine’s accept before moving the position, recording statistics, checking the monitor or returning a plane. There may be no next trial: a save plane, natural stop, exhausted call budget, final endpoint or interruption can intervene. A monitor verdict can also change the next trial’s collar setting after the current state must already have been adopted. Please specify a flush/commit operation and its ownership at every such boundary, or retain a separate accept until phase 2 can decide and commit inside the native call. Include a one-step run ending at a plane, an immediately exhausted budget and a monitor change in phase 1’s tests; measure the crossings actually left by that design.

  3. R3 — P1: enter the signal guard before native mutation and make host publication complete before callbacks (§§4.4, 4.8, 6.5.7, 7.5). The text puts the host’s row bookkeeping inside committing() after the native call. solprop._interrupts._handle raises immediately outside that guard, including when Python resumes from a native call that has already committed several steps. The low-storage precedent guards the native call itself as well as the position update. Please require one guard spanning native execution, synchronization of the host position/proposal/history/ledger and ingestion of all returned rows. Also separate ingestion from user progress callbacks: _record invokes _progress.step, whose exception is not deferred by committing(). A callback failing on the first replayed row must not leave the native state several commits ahead of the host records. The accepted-step floor fix needs the same complete publication rule, including the accepted attempt row and kink restart, before raising. Inject signals at the return boundary and exceptions from callbacks and from the floor path; verify the terminal checkpoint’s state, position, rows and counters together.

  4. R4 — P1: preserve call charges when source evaluation fails (§§4.3, 6.5.7–6.5.8, 7.1). The algorithm adds startup after the startup evaluation and predictor after the trial and its reductions. The reference increments them before engine.start and engine.exponential_trial, respectively. A source call that raises, or succeeds before a later reduction fails, has still spent work. Rolling back to the last committed ledger or incrementing only after success undercounts it, which matters when that ledger is saved and a budgeted run resumes. Please distinguish accepted-state commits from attempted-call charges, specify the failure stage and exception category in the returned error record, and preserve the reference’s charging boundary. A message alone cannot reliably select the original exception type. Add independent call-counter tests for startup failure, predictor failure and failure after the source returns, including export/resume of their ledgers.

  5. R5 — P1: make event draining total for empty, rejected and coincident returns (§§4.8, 6.4–6.5, 7.1). The event table is headed “after an accepted step”, but a call bound can end a rejected attempt, and a budget/error event can return without an attempt or without any acceptance. The prescribed unconditional final _take_step_stats/_natural_stop then counts a step that did not happen. Conversely, a call can finish after accepted steps followed by rejections, so its last attempt is not its last accepted step. Please specify entry checks, per-attempt checks, zero-row and zero-acceptance returns, and exactly-once accounting of each accepted step. Define how simultaneous plane, monitor, statistics, natural-stop and call-bound events are all serviced, including a dense crossing whose natural stop remains due. State how _record’s backend hook is handled: native _on_accepted_step currently calls promote_fsal, so replaying that hook in bulk is not automatically an inert record operation. Finally reconcile §6.5.8’s per-attempt progress callbacks with Q12’s “progress once per call”. Test max_calls=1, rejection-only returns, accepted-then-rejected returns, and several coincident events.

  6. R6 — P1: specify replay’s separate stopping and kink tolerances (§§4.3, 4.6, 4.8, phase 3). Replacing only the proposal and landing steps does not describe the existing replay. _replay_to uses replay_tolerance(schedule, target), with max(LANDING_ULPS, schedule.n_steps) ulps, and uses the same slack to identify replayed kinks. It also advances _schedule_index. The draft otherwise leaves the adaptive reached test in force and omits the replay cursor from its state description. The existing documented witness FixedSchedule(np.full(160, 1e-3 / 160)) ends 12 ulps below 1e-3 in the review probe: adaptive reached is false, while the replay test succeeds. Following the draft literally can report exhaustion for this valid schedule. Please specify the cursor, edge arithmetic, replay tolerance, exhaustion and restoration rules, with tests for accumulated-edge drift, kinks and a resume part-way through the schedule.

  7. R7 — P2: qualify coefficient tables over the nodes the march actually permits (§§4.7, 7.2). The claimed modest conditioning does not follow from the default growth and rejection factors. They are configurable, multiple rejections compound a shrink, and a forced landing can shorten a step by an arbitrary ratio without discarding history at an ordinary save plane. equal_approach=False is admitted. For example, four preceding unit steps followed by a trial shortened to 1e-10 give corrector nodes [1, 0, -1e10, -2e10, -3e10, -4e10]; their monomial Vandermonde has an infinity-norm condition estimate of about 2.05e53 in the probe, despite having only six nodes. Multiplying that estimate by operation count and unit roundoff gives no useful accuracy test. This is not itself evidence that the existing root-product algorithm fails: its error needs analysis appropriate to that algorithm and to the weights ultimately applied. Please remove the unsupported conditioning claim, include actual landing/rejection histories and non-default factors in the corpus, and require finite, informative bounds supported by an independent polynomial or higher-precision oracle. Any new admission limit or restart rule would be a numerical-policy decision for the author, not an implementation convenience.

  8. R8 — P2: make the exact-arithmetic promises consistent with the build and threshold contracts (§§4.8–4.9, 6.5.1, 6.7, 7.1). SOLPROP_NATIVE_STRICT_FUNCTION alone is empty under Clang; the function body also needs SOLPROP_NATIVE_STRICT_BLOCK. Both macros are empty under CUDA/HIP, including the CUDA host pass (native/include/solprop_native/fp.hpp). Thus that annotation alone does not establish the promised strict controller on every phase-5 build. Also, §7.1 permits a one-ulp pow difference while still demanding identical order and landing decisions at exact thresholds. A small weight or proposal difference can change precisely those branches. Please specify the host compilation and elementary-function requirements, and separate exact decision tests on identical supplied scalar scores from numerical tests that independently compute those scores. Either ensure common arithmetic for threshold witnesses or define margin-aware expectations. Verify strictness on each supported compiler. The nominal 1e-14 norm difference in §4.9 also needs a case-specific basis, especially after R7.

  9. R9 — P2: bound the snapshot portability promise and account for quiet collar state (§§4.2, 6.5.9, 7.1, phase 4). Changing the march on the same resident engine is different from changing engines. A frozen-residual native history exports applied rows; HostHistory.load_rows refuses those, while _ResidentAdams.load_rows refuses the host engine’s weights_<row> format. Therefore retaining the existing format cannot by itself make every phase-4 snapshot continue on numpy_ref or numba_cpu without restart. Please give a support matrix and retain the existing named refusal, or explicitly design and qualify a conversion with its memory cost. Separately, §4.2 lists w_Q as march state, but neither the listed snapshot fields nor the current MarchSnapshot stores _collar_was_quiet. Its value controls the re-entry cap. State whether load restores it, reconstructs it from monitor state or deliberately resets it, and test a stop/resume across a quiet-to-active transition. Verify that a reused backend starts with the specified flags too.

  10. R10 — P2: use accuracy envelopes for ordinary adaptive resume (§§4.9, 6.5.9, 7, 9). The unconditional promise that a resume agrees with an uninterrupted run “to rounding” contradicts SEP 25 §4.4, which explicitly classifies ordinary resumed-versus-uninterrupted comparisons as accuracy comparisons. The native and host marches can compute different tables and proposed steps, and can then choose different orders or acceptances, even if the serialized state was copied losslessly. Please keep exact checks for the snapshot transfer, reserve rounding budgets for matched fixed-order schedules with matched history and plane handling, and add adaptive resume accuracy cases against a converged reference. Throughout the interface and compatibility wording, replace agreement “to the run’s tolerance” with the qualified per-case accuracy envelope: local rtol/atol do not bound the global error. This follows the policy already cited by the proposal.

  11. R11 — P2: make admission a single consistent matrix (§§2, 6.6, Q14). Q14 permits the warned host march for ComparisonMultistep’s general path, but §6.6 lists that same path as “Refused by name, unchanged”. The current _refuse_adams does not impose that blanket refusal; AdamsMarch uses HostHistory when the fused/exponential engine is unavailable. Moreover, ComparisonMultistep is a subclass of Multistep, and some comparison configurations use the fused resident path, including the Hermite dense interpolant covered by test_resident_adams.py. A type-membership check alone does not enforce the stated scope. Please enumerate admission by formulation, corrector/interpolant, resident-engine capability, scale and build, distinguishing native march, warned host march, private unwarned witnesses and pre-existing backend refusals. Record device rows at Final after phase 5, rather than leaving “until phase 5” as a final exception.

  12. R12 — P2: make the performance decision reproducible and independent of the other optimisations (§§3.3, 6.1, 6.9, 7.6, 11). The measurements are useful motivation, but the draft links no committed timing ledger or profiling artifact for the baseline, and infers some trial counts from timings. The estimated gain of B over completed phase 1 is the relevant evidence for accepting a second controller implementation. Please name the reproducible benchmark invocation/configuration, exact measured window, measured attempt and source counts, and public ledger entries. Compare the two marches on the same implementation revision with N1–N7 held fixed, and report end-to-end time and event frequency as well as residual host cost; busy monitors and step_planes=1 return every step. State the acceptance rule for regressions on expensive-source and frequent-event cases. The claim that call bounds cost “at most 0.04 %” assumes the time bound is limiting; the 256-attempt bound can instead dominate cheap trials. Measure it. Finally, §7.6’s “then complete runs” must name short-guide lengths that finish in seconds, consistently with §7 and the repository’s qualification rule, rather than authorize full-length tracking problems by implication.

The author should answer R1–R12 in a separate response subsection and revise Part I thereafter, as SEP 0 requires. This review does not accept the SEP or change its status from Draft.

Response to review — 2026-10-28#

The drafting agent, Opus 5.5, checked each point against the code before answering. All twelve are taken. Part I is revised as follows; section numbers are those of the revised text.

  1. R1: taken. Confirmed in AdamsMarch._choose_order: under a fixed order it stores method.order but returns the step’s own order for the factor.

    • §4.4 (step 6–7) and §4.5 now distinguish the stored order \(q_s\) from the control order \(q_c\) and its norm \(\varepsilon_c\). The review’s probe is quoted in §4.5.

    • §7.1’s corpus is generated from the reference before the refactor, through a stub engine, and both the refactored core and the native march must reproduce it. Its traces cover fixed-order startup and post-restart growth at orders 2–6.

    • Phase 0 is updated to match.

  2. R2: taken, by dropping the fusion. Option C and phase 1 keep the trial and the accept as separate calls (§6.1, §11). The flush that fusion would need belongs at exactly the boundaries the review lists, and phase 2 removes those crossings anyway, by deciding and committing inside the native call.

    • Option C’s figures are recomputed without the fused accept: −265 to −335 µs per helium trial on the EPYC, two crossings per accepted trial.

  3. R3: taken. Confirmed: committing() only defers inside its block, and _record calls the progress reporter.

    • §6.5.7 now requires one guard spanning the native call, the synchronisation of the host’s position, proposal, order, history and ledger, and the ingestion of every row.

    • User code runs only after the guard: progress callbacks, debug lines and the event step’s work. The floor publishes the accepted attempt, its kink restart and the ledger before it raises (§4.4).

    • §7.5 injects signals during a call and at the return boundary, and exceptions from a progress reporter and from the floor. It checks the terminal checkpoint’s state, position, history and ledger together.

  4. R4: taken. Confirmed: the reference charges startup and predictor before engine.start and engine.exponential_trial.

    • §4.3 now charges before evaluating.

    • §6.5.7’s error record carries the stage, a category code for the exception class (never parsed from the message), and the message, with the ledger as charged.

    • §7.1 counts calls independently through startup, trial and post-source failures, including a snapshot and resume of their ledgers.

  5. R5: taken. §4.8 now specifies:

    • the entry checks (as march_to’s, so no call is made when no attempt is due);

    • the per-attempt, per-accepted-attempt and pre-attempt tests;

    • returns with no rows, rows without acceptance, and acceptances followed by rejections;

    • exactly-once accounting of each accepted row;

    • the single event step, for which the host runs the reference’s own _take_step_stats and _natural_stop, so coincident events (including a natural stop due on a dense crossing) are serviced in the reference’s order.

    The event table is split into accepted-step events and the other returns, which have no event step. _on_accepted_step is not replayed: on native it calls promote_fsal, whose quadrature scoring the native march performs itself after each acceptance. Progress is reconciled with Q12: one callback per attempt, delivered in bulk, so a display updates once per call (§4.8, §6.5.8). The tests the review asks for are in §7.1.

  6. R6: taken. Confirmed: _replay_to uses replay_tolerance for both stopping and kinks, and advances _schedule_index; a load rebuilds the cursor with _schedule_index_at. §4.3 now specifies:

    • the cursor;

    • the precomputed edges;

    • the slack \(\max(u, n_{\mathrm{steps}})\) ulps;

    • the stopping, kink and exhausted rules;

    • the restoration on load.

    reached is explicitly not used on a replay. §7.5 adds the 160-step witness, kinks on edges, exhaustion and a resume part-way. Phase 3 refers to them.

  7. R7: taken. The conditioning claim is removed. §4.7 gives the review’s example and says why a condition-number budget is uninformative here.

    • §7.2 replaces the budget with an exact rational oracle (fractions.Fraction, no new dependency).

    • The node sets come from real landing and rejection histories, with non-default factors and equal_approach=False.

    • The error is measured in the applied weights. The bound is finite and relative to the reference’s own error (at most four times it, or 16 ulp).

    • No restart or admission rule is proposed. If the reference proves inaccurate on admissible histories, that is reported to the author as a numerical-policy question (§4.7).

  8. R8: taken. Confirmed in fp.hpp: SOLPROP_NATIVE_STRICT_FUNCTION is empty under Clang, and both macros are empty when __CUDACC__ or __HIPCC__ is defined, the host pass included.

    • §6.5.1 puts the controller in a host-only translation unit compiled with contraction off by a per-source option on every build, checked on each supported compiler. It names pow from the platform’s C library as its only elementary function.

    • §7.1 separates exact decision tests on identical supplied scores (where the threshold witnesses live) from numerical tests of \(F\) and \(W_k\). Those are exact with a common C library and at one ulp otherwise, with margin-aware whole traces.

    • §4.9’s nominal \(10^{-14}\) is removed. Phase 1 measures the norms’ spread per case (§7.2).

  9. R9: taken, with one proposal for the author.

    • §6.5.9 now gives a support matrix. It keeps today’s named refusals between the native applied rows and the host engines’ weights_<row> for frozen-residual flows, and proposes no conversion.

    • On \(w_Q\), confirmed: neither MarchSnapshot nor load keeps _collar_was_quiet, and begin_run does not reset it. The SEP now proposes restoring it from the snapshot (one new field, output schema +1 in phase 0) and resetting it in begin_run, fixed in the Python march first.

    • Reconstructing it from the monitor’s state at the plane was considered and not proposed. The plane’s own check may change the flag after the last attempt, so it would not reproduce the uninterrupted run’s cap.

    • §7.1 and §7.3 test the quiet-to-active resume and a reused backend.

    Whether to snapshot \(w_Q\) (proposed) or deliberately reset it is the author’s call. Decided 2026-10-28 (John Travers): snapshot it and reset it in begin_run, as proposed.

  10. R10: taken. §6.5.9 states the three levels:

    • the transfer is exact;

    • an adaptive continuation meets the case’s accuracy envelope against a converged reference;

    • a rounding budget applies only to matched fixed-order schedules.

    “To the run’s tolerance” is replaced by the per-case accuracy envelope throughout (§4.9, §6.6, §9). §7.3 adds adaptive resume cases. §8 restates the Adams manual page’s resume paragraph, which today says “to rounding”, in those terms.

  11. R11: taken. Confirmed: _refuse_adams does not refuse the general path, and the one-order-higher PEC comparison variants take the resident fused path, the Hermite interpolant included. §6.6 replaces the list and the wrong “refused by name” line with one matrix. The matrix:

    • is keyed on formulation, corrector and interpolant, resident engine, norm scale and build;

    • distinguishes the native march, the warned host march, the unwarned private paths and the existing refusals;

    • records device builds at Final.

    Q14’s “device builds, until phase 5” is moved there as a state of the branch. This is a consistency correction under Q6, not a change to the author’s Q14 decision.

  12. R12: taken.

    • §6.1 adds the estimate the review asks for: B’s gain over a completed phase 1. That is 125–220 µs per helium trial and 85–150 µs per argon trial on the EPYC, or 7–14 % and 9–23 % of the runs once §6.9’s items have landed. It also notes that frequent events return every step.

    • §7.6 now compares the two marches on the same build and revision, with N1–N7 held fixed (a per-arm environment for ab). It reports end-to-end time, counted attempts, calls and events, and residual host time.

    • The cases add an expensive source (a short ctc window) and frequent events (a busy monitor, step_planes = 1, row_period = 1).

    • The acceptance rule is no regression beyond the A/B’s measured spread, about 2 %, on any case.

    • Runs use --length fractions that finish in seconds; complete tracking runs stay with SEP 38’s release procedure.

    • The “0.04 %” figure is replaced by an estimate that names which bound binds, to be measured in phase 2 (§6.5.7).

    • Phase 0 records the baseline publicly (§3.3, §7.6). The profiling study’s numbers remain the motivation; the counted entry is the evidence.

    The 2 % rule is a proposal; the author may set another. Decided 2026-10-28 (John Travers): the 2 % rule, as proposed.