// Copyright (c) 2026 Petr Balvín (https://petrbalvin.org) // SPDX-License-Identifier: MIT package integrate import ( "sourcedock.dev/petrbalvin/tensor/internal/base" "sourcedock.dev/petrbalvin/tensor/internal/core" ) import ( "errors" "math" ) // The variable-order stiff workhorse above IntegrateBDF2: the backward // differentiation formula of order one through five, with the step size // and the order both adapted every step in the VODE manner. Each step // interpolates a polynomial of degree k through the k most recent // states and the unknown end value and requires its derivative at the // new time to equal f, the same implicit relation BDF2 solves; the // Newton iteration, the LU machinery, the Hairer-Nørsett-Wanner initial // step probe and the step controller are the ones IntegrateBDF2 // already carries. // // The coefficients are the variable-step, divided-difference form: the // Newton form of the interpolating polynomial through (tNext, z) and // the stored back values, written per component from a small divided- // difference table over the stored times. The form was chosen over the // fixed-coefficient one because the package keeps a solution history // rather than a Nordsieck array, because the divided differences feed // the order selection (the a-priori error estimate per candidate order // falls out of the same table) and because the relation leaves the // Newton contract α·z − h·f(tNext, z) = β of odeNewton untouched. At // order two with equal steps the assembled α and β agree with // bdf2Coefficients to rounding, so the shipped BDF2 behaviour is the // special case the driver degrades to. // // The local error estimate is the Milne-type one: the gap between the // corrector and the degree-k predictor extrapolated from the k+1 // newest states, scaled by the constant that turns the gap into the // corrector's own error. The variable-step constant generalises the // 2/11 of bdf2Milne: with α the derivative weight of the new point and // S the span from tNext to the oldest predictor node, the estimate is // (z − seed)/(1 + α·S), which for equal steps of order two reproduces // 2/11 exactly. The order itself is chosen before the solve, from the // divided differences of the stored states: the (k+1)-th divided // difference approximates y^(k+1)/(k+1)!, and the candidate whose // implied optimal step is largest wins, with a margin so the order // does not flicker between neighbours. // // The first step is backward Euler, sized by the shared probe; the // order ramps up as the history accumulates, one level per step. // BDFVarStats reports what a variable-order run did: the accepted and // rejected steps and the highest order the driver reached. type BDFVarStats struct { Steps int Rejected int MaxOrder int } // BDFVarOptions tunes IntegrateBDFVar. RelTol ≤ 0 means 1e-6, AbsTol ≤ 0 // means 1e-9, MaxSteps ≤ 0 means 100000, the ODEOptions defaults. Stats, // when not nil, receives the run's counters. type BDFVarOptions struct { RelTol float64 AbsTol float64 MaxSteps int Stats *BDFVarStats } // bdfVarOrderMax is the highest order the driver raises to. bdfVarKeep // is the number of states held back: order k needs k back values for // its corrector, k+1 for its predictor and k+2 for the a-priori order // comparison, so seven states serve order five in every role. const ( bdfVarOrderMax = 5 bdfVarKeep = bdfVarOrderMax + 2 ) // IntegrateBDFVar integrates y' = f(t, y) from t0 to t1 with the // variable-step, variable-order BDF scheme of orders one through five // and returns y(t1). Backward integration works: a t1 < t0 simply // integrates in the negative direction. An exhausted step budget, a // collapsed step size, an f that returns a wrongly shaped state, or a // Newton iteration that cannot converge even as the step shrinks is an // error, never a silently truncated trajectory. func IntegrateBDFVar(f func(t float64, y *core.Array) (*core.Array, error), t0, t1 float64, y0 *core.Array, opts BDFVarOptions) (*core.Array, error) { end, err := integrateBDFVar("IntegrateBDFVar", f, t0, t1, y0, opts, bdfVarOrderMax, false) if err != nil { return nil, err } return arrayFromVector(end), nil } // integrateBDFVar drives the variable-order loop. maxOrder caps the // order adaptation and lockOrder pins the order at maxOrder once the // history ramp reaches it, which is the fixed-order hook the tests // drive; the public entry always asks for adaptive order five. func integrateBDFVar(name string, f func(t float64, y *core.Array) (*core.Array, error), t0, t1 float64, y0 *core.Array, opts BDFVarOptions, maxOrder int, lockOrder bool) ([]float64, error) { if maxOrder < 1 || maxOrder > bdfVarOrderMax { return nil, base.Errf("%s: maxOrder must be between 1 and %d, got %d", name, bdfVarOrderMax, maxOrder) } y, err := odeCheck(name, y0, nil) if err != nil { return nil, err } relTol, absTol, maxSteps := opts.RelTol, opts.AbsTol, opts.MaxSteps if relTol <= 0 { relTol = 1e-6 } if absTol <= 0 { absTol = 1e-9 } if maxSteps <= 0 { maxSteps = 100000 } n := len(y) h, err := bdf2InitialStep(name, f, t0, t1, y, &ODEOptions{RelTol: relTol, AbsTol: absTol}) if err != nil { return nil, err } var stats BDFVarStats hist := &bdfVarHistory{} hist.push(t0, y) // The implicit relation's right side, the predictor seed and the // divided-difference workspace live in reused buffers: all are fully // rewritten at the top of every step. The Newton result lands in a // per-solve scratch buffer that never touches the history window: // the order selection and the coefficient assembly reread the whole // window on a retry, so a rejected attempt must leave every stored // state intact. Only an accepted step copies the state into the // ring slot its push then occupies. beta := make([]float64, n) seed := make([]float64, n) zbuf := make([]float64, n) dd := make([]float64, bdfVarKeep) nodes := make([]float64, bdfVarKeep) spans := make([]float64, bdfVarKeep) ddTab := make([][]float64, bdfVarKeep) for level := range ddTab { ddTab[level] = make([]float64, n) } budget := odeBudget{max: maxSteps} w := &odeWork{} t := t0 carried := 1 for !odeArrived(t, t1) { if err := budget.spend(name, t, t1); err != nil { return nil, err } // Never step past t1; t1−t carries the integration direction. h = odeClampStep(h, t, t1) tNext := t + h hN := tNext - t var alpha, weight float64 var order int estimated := false if hist.n == 1 { // The very first step has no history and runs backward // Euler, seeded with the semi-implicit prediction: the // house starter IntegrateBDF2 begins with. alpha, weight, order = 1, hN, 1 copy(beta, y) fy, ferr := odeEval(name, f, tNext, y, n, &w.views) if ferr != nil { return nil, ferr } for i := range n { seed[i] = y[i] + hN*fy[i] } } else { order = min(carried, maxOrder, hist.n-1) switch { case lockOrder && hist.n > maxOrder: // The fixed-order contract: once the history ramp can // feed the requested order, every step runs at it. order = maxOrder case !lockOrder && hist.n >= 3: bdfVarDividedDifferences(hist, n, ddTab, dd, nodes) order = bdfVarPickOrder(order, maxOrder, hist.n, tNext, h, hist, y, absTol, relTol, ddTab, spans) } weight = 1 alpha = bdfVarCoefficients(order, tNext, hist, beta, seed, dd, nodes, spans) estimated = true } if nerr := odeNewton(name, f, w, tNext, alpha, weight, beta, seed, zbuf, absTol, relTol); nerr != nil { if errors.Is(nerr, errNewtonStalled) { // The implicit solve struggled: halve the step and // retry the same interval, within the step budget. h *= 0.5 continue } return nil, nerr } factor := 1.0 if estimated { // Milne-type local error estimate against the mixed // absolute and relative tolerance. _, tOldest := hist.back(order) c := 1 / (1 + alpha*(tNext-tOldest)) errNorm := 0.0 for i := range n { scale := absTol + relTol*math.Max(math.Abs(y[i]), math.Abs(zbuf[i])) ratio := c * (zbuf[i] - seed[i]) / scale errNorm += ratio * ratio } errNorm = math.Sqrt(errNorm/float64(n)) + 1e-10 if errNorm <= 1 { factor = min(2, max(0.2, 0.9*math.Pow(1/errNorm, 1/float64(order+1)))) } else { // Rejected: retry the same interval with a smaller step. stats.Rejected++ h *= max(0.1, min(1, 0.9*math.Pow(1/errNorm, 1/float64(order+1)))) continue } } // Accepted: the corrector is copied into the ring slot the push // fills and becomes the working state, so back(0) is always // (t, y) and the buffers flow without copying. slot := hist.y[hist.next] if slot == nil { slot = make([]float64, n) } copy(slot, zbuf) hist.push(tNext, slot) y = slot carried = order if order > stats.MaxOrder { stats.MaxOrder = order } stats.Steps++ prevT := t t = tNext h *= factor // Collapse is "t did not move": a span below the absolute time // scale is integrable, and an accepted step that arrives at the // end exactly is not a failure either. if t == prevT { return nil, base.Errf("%s: the step size shrank below the resolution of t at t=%g", name, prevT) } } if opts.Stats != nil { *opts.Stats = stats } return y, nil } // bdfVarHistory holds the last bdfVarKeep accepted states with their // times in a fixed ring. back(0) is the newest state, back(1) the one // before it, and so on; slots are recycled only once they are too old // to serve any order, so the buffers flow without copying. type bdfVarHistory struct { y [bdfVarKeep][]float64 t [bdfVarKeep]float64 next int n int } // push records an accepted state and its time as the new newest entry. func (h *bdfVarHistory) push(t float64, y []float64) { h.y[h.next], h.t[h.next] = y, t h.next = (h.next + 1) % bdfVarKeep if h.n < bdfVarKeep { h.n++ } } // back returns the state i steps behind the newest one. func (h *bdfVarHistory) back(i int) ([]float64, float64) { j := (h.next - 1 - i + bdfVarKeep) % bdfVarKeep return h.y[j], h.t[j] } // bdfVarCoefficients assembles the variable-step BDF relation of the // given order for a step to tNext from the newest history state. It // writes α's companions β and the Newton seed into the caller's // buffers and returns α: the implicit equation is α·z − f(tNext, z) = // β, the weight already scaled out. Both buffers are fully overwritten. // The seed is the degree-order polynomial through the order+1 newest // states evaluated at tNext, the predictor the error estimate reads. // All differences are signed, so backward integration needs no // separate path. // // The construction is the divided-difference (Newton) form: with nodes // x_0 = tNext and x_q = the q-th back time, the interpolating // polynomial's derivative at tNext is Σ_j c_j·Π_j where c_j are the // divided differences of the data (z at x_0, the back values after) // and Π_j the Newton basis products. Splitting c_j into its z part, // 1/Π_j, and its history part gives α = Σ 1/(tNext − x_m), the Lagrange // derivative weight of the new point, and β from the history-only // table, all from one per-component recursion. func bdfVarCoefficients(order int, tNext float64, hist *bdfVarHistory, beta, seed, dd, nodes, spans []float64) float64 { nodes[0] = tNext // The ring's nodes and value slices are the same for every // component: gather both once, on the stack, instead of walking the // ring inside the per-element loop. var backVals [bdfVarKeep][]float64 for q := range order + 1 { backVals[q], nodes[q+1] = hist.back(q) } // spans[m] is Π_m, the product of tNext − x_q over q < m: the // Newton basis value the level-m coefficients multiply. spans[0] = 1 for m := 1; m <= order; m++ { spans[m] = spans[m-1] * (tNext - nodes[m]) } alpha := 0.0 for m := 1; m <= order; m++ { alpha += 1 / (tNext - nodes[m]) } for i := range beta { // dd[q] starts as the value at node q: zero at tNext, the back // values after. One level of the recursion per Newton term; // level order leaves dd[0] holding the order-th divided // difference over the new point and dd[1] the one over the // stored values, which is the predictor's top coefficient. dd[0] = 0 for q := range order + 1 { dd[q+1] = backVals[q][i] } seed[i] = dd[1] betaSum := 0.0 for level := 1; level <= order; level++ { for q := range order + 2 - level { dd[q] = (dd[q+1] - dd[q]) / (nodes[q+level] - nodes[q]) } betaSum += dd[0] * spans[level-1] seed[i] += dd[1] * spans[level] } beta[i] = -betaSum } return alpha } // bdfVarDividedDifferences fills tab with the divided differences of // the stored back values alone: tab[level][i] is the level-th divided // difference of (y_n, y_{n-1}, …) over their times for component i. // The (order+1)-th entry approximates y^(order+1)/(order+1)! and is // what the a-priori order comparison reads. func bdfVarDividedDifferences(hist *bdfVarHistory, n int, tab [][]float64, dd, times []float64) { // The ring's times and value slices do not depend on the component: // gather both once, on the stack, instead of walking the ring // inside the per-element loops. var backVals [bdfVarKeep][]float64 for q := range hist.n { backVals[q], times[q] = hist.back(q) } for i := range n { for q := range hist.n { dd[q] = backVals[q][i] } for level := 1; level < hist.n; level++ { for q := range hist.n - level { dd[q] = (dd[q+1] - dd[q]) / (times[q+level] - times[q]) } tab[level][i] = dd[0] } } } // bdfVarPickOrder returns the order for the coming step. Every order // the history supports gets an a-priori optimal step: the local error // the divided differences predict, raised to the power that would // bring it to the tolerance. The scan runs from order 1 upward and a // candidate must beat the running best by a clear margin, so the // effective pick is the lowest order within 15 percent of the largest // predicted step: short histories and cheap coefficients win near // ties, and the order settles instead of flickering between equals. // The carried order survives the scan only before a second state is // held; after that some candidate always displaces it. func bdfVarPickOrder(carried, maxOrder, held int, tNext, h float64, hist *bdfVarHistory, y []float64, absTol, relTol float64, tab [][]float64, spans []float64) int { best, bestH := carried, 0.0 for j := 1; j <= min(maxOrder, held-1); j++ { hj := math.Abs(h) if j <= held-2 { e := bdfVarPriorNorm(j, tNext, hist, y, absTol, relTol, tab, spans) hj = math.Abs(h) * math.Pow(1/e, 1/float64(j+1)) } if hj > bestH*1.15 { best, bestH = j, hj } } return best } // bdfVarPriorNorm estimates the RMS error norm a step of size h at the // given order would produce: the (order+1)-th divided difference of // the stored states approximates y^(order+1)/(order+1)!, and the // order's local error scales that by the Newton basis product over α, // the same estimate the Milne constant formalises a posteriori. func bdfVarPriorNorm(order int, tNext float64, hist *bdfVarHistory, y []float64, absTol, relTol float64, tab [][]float64, spans []float64) float64 { spans[0] = 1 alpha := 0.0 for q := range order { _, tq := hist.back(q) spans[q+1] = spans[q] * math.Abs(tNext-tq) alpha += 1 / math.Abs(tNext-tq) } w := spans[order] / alpha norm := 0.0 for i := range y { scale := absTol + relTol*math.Abs(y[i]) ratio := math.Abs(tab[order+1][i]) * w / scale norm += ratio * ratio } return math.Sqrt(norm/float64(len(y))) + 1e-10 }