// 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 ( "math" "strings" "testing" ) // stiffCosine returns f for y' = −k(y − cos t), the canonical stiff // problem: a slow forcing with a transient decaying at rate k. func stiffCosine(k float64) func(t float64, y *core.Array) (*core.Array, error) { return func(t float64, y *core.Array) (*core.Array, error) { return core.FromFloats([]float64{-k * (y.FloatAt(0) - math.Cos(t))}, 1) } } // TestIntegrateBDF2Stiff is the demonstration the stiff solver exists // for: y' = −10^5(y − cos t) carries a transient of width 10^−5 under // a slow forcing, and BDF2 crosses it and follows the forcing to t=1 // inside a 2000-step budget, landing on the exact solution // y(1) = (k²·cos 1 + k·sin 1)/(k² + 1). func TestIntegrateBDF2Stiff(t *testing.T) { const k = 1e5 end, err := IntegrateBDF2(stiffCosine(k), 0, 1, mustFloats(t, []float64{0}), ODEOptions{MaxSteps: 2000}) if err != nil { t.Fatalf("IntegrateBDF2: %v", err) } want := (k*k*math.Cos(1) + k*math.Sin(1)) / (k*k + 1) if math.Abs(end.FloatAt(0)-want) > 1e-6 { t.Fatalf("y(1) = %.14g, want %.14g", end.FloatAt(0), want) } } // TestIntegrateBDF2StiffBeatsExplicit shows the same problem is out // of reach for the explicit pair: stability pins DOPRI to steps of // order 1/k, so a 5000-step budget dies a fifth of the way in. func TestIntegrateBDF2StiffBeatsExplicit(t *testing.T) { _, err := IntegrateODE(stiffCosine(1e5), 0, 1, mustFloats(t, []float64{0}), ODEOptions{MaxSteps: 5000}) if err == nil { t.Fatal("explicit DOPRI was expected to exhaust its step budget on the stiff problem") } if !strings.Contains(err.Error(), "MaxSteps=5000") { t.Fatalf("want a step-budget error, got %v", err) } } // TestBDF2FixedStepOrder verifies the second order of the underlying // formula directly: with exact history on y' = −y and uniform steps, // halving h must quarter the global error. The Milne constant for // equal steps is pinned to 2/11 along the way. func TestBDF2FixedStepOrder(t *testing.T) { if got := bdf2Milne(1, 2, 0, -1); math.Abs(got-2.0/11) > 1e-12 { t.Fatalf("bdf2Milne for equal steps = %.14g, want 2/11", got) } errAt := func(steps int) float64 { h := 1.0 / float64(steps) alpha := 1.5 / h now := 0.0 yn := []float64{1} yNm1 := []float64{math.Exp(h)} // exact history at t−h w := &odeWork{} for range steps { tNext := now + h beta := []float64{2*yn[0]/h - yNm1[0]/(2*h)} z := make([]float64, 1) err := odeNewton("TestBDF2FixedStepOrder", decay, w, tNext, alpha, 1, beta, []float64{math.Exp(-tNext)}, z, 1e-13, 1e-13) if err != nil { t.Fatalf("odeNewton: %v", err) } yNm1 = yn yn = z now = tNext } return math.Abs(yn[0] - math.Exp(-1)) } e20, e40 := errAt(20), errAt(40) if e20 < 1e-12 { t.Skipf("error already at round-off (%v)", e20) } ratio := e20 / e40 if ratio < 3 || ratio > 5.2 { t.Fatalf("error ratio over a halved step = %.2g, want ≈ 4 for a second-order scheme", ratio) } } // TestIntegrateBDF2Accuracy checks the adaptive driver on a smooth // problem against the analytic decay at a tolerance far below the // default, and over a full oscillator period with a two-dimensional // state, exercising the vector Newton path. func TestIntegrateBDF2Accuracy(t *testing.T) { end, err := IntegrateBDF2(decay, 0, 1, mustFloats(t, []float64{1}), ODEOptions{RelTol: 1e-8, AbsTol: 1e-12}) if err != nil { t.Fatalf("IntegrateBDF2: %v", err) } if math.Abs(end.FloatAt(0)-math.Exp(-1)) > 1e-5 { t.Fatalf("y(1) = %.14g, want %.14g ± 1e-5", end.FloatAt(0), math.Exp(-1)) } oscillator := func(t float64, y *core.Array) (*core.Array, error) { return core.FromFloats([]float64{y.FloatAt(1), -y.FloatAt(0)}, 2) } full, err := IntegrateBDF2(oscillator, 0, 2*math.Pi, mustFloats(t, []float64{1, 0}), ODEOptions{RelTol: 1e-8, AbsTol: 1e-12}) if err != nil { t.Fatalf("IntegrateBDF2 oscillator: %v", err) } if math.Abs(full.FloatAt(0)-1) > 1e-4 || math.Abs(full.FloatAt(1)) > 1e-4 { t.Fatalf("full period = (%.10g, %.10g), want (1, 0)", full.FloatAt(0), full.FloatAt(1)) } } // TestIntegrateBDF2Backward integrates the decay backwards from t=1 // to t=0; the signed-step formulation must return the start value. func TestIntegrateBDF2Backward(t *testing.T) { end, err := IntegrateBDF2(decay, 1, 0, mustFloats(t, []float64{math.Exp(-1)}), ODEOptions{RelTol: 1e-8, AbsTol: 1e-12}) if err != nil { t.Fatalf("IntegrateBDF2 backward: %v", err) } if math.Abs(end.FloatAt(0)-1) > 1e-5 { t.Fatalf("backward y(0) = %.14g, want 1 ± 1e-5", end.FloatAt(0)) } } // TestIntegrateBDF2Errors pins the error contract: a degenerate span // returns the initial state unchanged, a wrong-shaped f, a rank-2 // state, an empty state and an exhausted step budget are errors. func TestIntegrateBDF2Errors(t *testing.T) { y0 := mustFloats(t, []float64{1}) same, err := IntegrateBDF2(decay, 1, 1, y0, ODEOptions{}) if err != nil { t.Fatalf("zero span: %v", err) } if math.Abs(same.FloatAt(0)-1) > 0 { t.Fatalf("zero span moved the state to %v", same.FloatAt(0)) } wrongShape := func(t float64, y *core.Array) (*core.Array, error) { return core.FromFloats([]float64{1, 1}, 2) } if _, err := IntegrateBDF2(wrongShape, 0, 1, y0, ODEOptions{}); err == nil { t.Fatal("expected an error when f returns the wrong shape") } matrixState := mustFloats(t, []float64{1, 1}, 1, 2) if _, err := IntegrateBDF2(decay, 0, 1, matrixState, ODEOptions{}); err == nil { t.Fatal("expected an error for a rank-2 state") } if _, err := IntegrateBDF2(decay, 0, 1, mustFloats(t, nil), ODEOptions{}); err == nil { t.Fatal("expected an error for an empty state") } if _, err := IntegrateBDF2(decay, 0, 1, y0, ODEOptions{MaxSteps: 2}); err == nil { t.Fatal("expected an error for an exhausted step budget") } boom := func(t float64, y *core.Array) (*core.Array, error) { if t > 0.5 { return nil, base.Errf("detector tripped") } return core.MulF(y, -1), nil } if _, err := IntegrateBDF2(boom, 0, 1, y0, ODEOptions{}); err == nil { t.Fatal("expected the operator error to propagate") } } // TestBDF2InitialStepBackwardProbe pins the probe direction: on a // backward span the initial-step probe must sample the dynamics at // t0 − h0, not extrapolate forward, so every evaluation time either // sits before t0 or the probe is wrong. func TestBDF2InitialStepBackwardProbe(t *testing.T) { var calls []float64 f := func(tt float64, y *core.Array) (*core.Array, error) { calls = append(calls, tt) return mustFloats(t, []float64{0}), nil } h, err := bdf2InitialStep("TestBDF2InitialStep", f, 5, 0, []float64{1}, &ODEOptions{}) if err != nil { t.Fatalf("bdf2InitialStep: %v", err) } if h >= 0 { t.Fatalf("backward span must yield a negative first step, got %g", h) } backward := false for _, c := range calls { if c < 5 { backward = true } } if !backward { t.Fatalf("the probe never stepped backward from t0 = 5, evaluated at %v", calls) } }