Files
tensor/integrate/odebdfvar_test.go
T
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

299 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package integrate
import (
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
import (
"math"
"testing"
)
// TestIntegrateBDFVarStiff is the demonstration pin: on y' =
// −10^5(y − cos t) the variable-order driver lands on the exact y(1) =
// (k²·cos 1 + k·sin 1)/(k² + 1) inside half the step budget BDF2
// needed, having raised to order five on the smooth tail.
func TestIntegrateBDFVarStiff(t *testing.T) {
const k = 1e5
var stats BDFVarStats
end, err := IntegrateBDFVar(stiffCosine(k), 0, 1, mustFloats(t, []float64{0}),
BDFVarOptions{MaxSteps: 2000, Stats: &stats})
if err != nil {
t.Fatalf("IntegrateBDFVar: %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)
}
t.Logf("stiff run: %d steps, %d rejected, max order %d", stats.Steps, stats.Rejected, stats.MaxOrder)
if stats.MaxOrder != 5 {
t.Fatalf("max order reached = %d, want 5 on the smooth tail", stats.MaxOrder)
}
if stats.Steps > 1000 {
t.Fatalf("the run took %d steps, want well inside the 2000-step budget BDF2 needed", stats.Steps)
}
}
// TestIntegrateBDFVarOrderAdapts instruments the order counter: the
// first accepted steps run at order one (nothing else has history), so
// a run that ends with order five must have climbed the ladder, and on
// the same stiff problem it must spend far fewer steps than an
// order-one-locked run, which is what step and order adaptation buy.
func TestIntegrateBDFVarOrderAdapts(t *testing.T) {
const k = 1e5
var adaptive, locked BDFVarStats
if _, err := IntegrateBDFVar(stiffCosine(k), 0, 1, mustFloats(t, []float64{0}),
BDFVarOptions{MaxSteps: 50000, Stats: &adaptive}); err != nil {
t.Fatalf("IntegrateBDFVar adaptive: %v", err)
}
if _, err := integrateBDFVar("TestIntegrateBDFVarOrderAdapts", stiffCosine(k), 0, 1,
mustFloats(t, []float64{0}), BDFVarOptions{MaxSteps: 50000, Stats: &locked}, 1, true); err != nil {
t.Fatalf("IntegrateBDFVar order-one locked: %v", err)
}
t.Logf("adaptive run: %d steps, locked run: %d steps", adaptive.Steps, locked.Steps)
if adaptive.Steps < 8 || locked.Steps < 8 {
t.Fatalf("implausible step counts: adaptive %d, locked %d", adaptive.Steps, locked.Steps)
}
if adaptive.MaxOrder != 5 {
t.Fatalf("adaptive run reached order %d, want 5", adaptive.MaxOrder)
}
if locked.MaxOrder != 1 {
t.Fatalf("locked run reached order %d, want 1 throughout", locked.MaxOrder)
}
if adaptive.Steps*3 > locked.Steps {
t.Fatalf("the adaptive run took %d steps against the locked run's %d: order adaptation did not engage",
adaptive.Steps, locked.Steps)
}
}
// TestIntegrateBDFVarAccuracy checks the adaptive driver on a smooth
// problem against the analytic decay, over a full oscillator period
// with a two-dimensional state, and backwards in time.
func TestIntegrateBDFVarAccuracy(t *testing.T) {
end, err := IntegrateBDFVar(decay, 0, 1, mustFloats(t, []float64{1}),
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
if err != nil {
t.Fatalf("IntegrateBDFVar: %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 := IntegrateBDFVar(oscillator, 0, 2*math.Pi, mustFloats(t, []float64{1, 0}),
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
if err != nil {
t.Fatalf("IntegrateBDFVar 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))
}
back, err := IntegrateBDFVar(decay, 1, 0, mustFloats(t, []float64{math.Exp(-1)}),
BDFVarOptions{RelTol: 1e-8, AbsTol: 1e-12})
if err != nil {
t.Fatalf("IntegrateBDFVar backward: %v", err)
}
if math.Abs(back.FloatAt(0)-1) > 1e-5 {
t.Fatalf("backward y(0) = %.14g, want 1 ± 1e-5", back.FloatAt(0))
}
}
// TestBDFVarCoefficientsMatchBDF2 pins the coefficient recurrence: at
// order two the divided-difference form must reproduce the shipped
// bdf2Coefficients, on equal steps and on skewed ones, in α, β and the
// predictor seed alike.
func TestBDFVarCoefficientsMatchBDF2(t *testing.T) {
patterns := []struct{ tNext, t, tNm1, tNm2 float64 }{
{3, 2, 1, 0},
{1.3, 0.75, 0.4, -0.1},
{5, 1, 0.5, -2},
}
vals := []float64{2.5, -3, 7} // y at tNm2, tNm1, t
for _, p := range patterns {
hist := &bdfVarHistory{}
for i, tt := range []float64{p.tNm2, p.tNm1, p.t} {
hist.push(tt, []float64{vals[i]})
}
beta := make([]float64, 1)
seed := make([]float64, 1)
alpha := bdfVarCoefficients(2, p.tNext, hist, beta, seed,
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
beta2 := make([]float64, 1)
seed2 := make([]float64, 1)
alpha2 := bdf2Coefficients(p.t, p.tNext, p.tNm1, p.tNm2, []float64{vals[2]},
[]float64{vals[1]}, []float64{vals[0]}, beta2, seed2)
tol := func(v float64) float64 { return 1e-12 * math.Max(1, math.Abs(v)) }
if math.Abs(alpha-alpha2) > tol(alpha2) {
t.Fatalf("pattern %v: alpha = %.16g, bdf2 gives %.16g", p, alpha, alpha2)
}
if math.Abs(beta[0]-beta2[0]) > tol(beta2[0]) {
t.Fatalf("pattern %v: beta = %.16g, bdf2 gives %.16g", p, beta[0], beta2[0])
}
if math.Abs(seed[0]-seed2[0]) > tol(seed2[0]) {
t.Fatalf("pattern %v: seed = %.16g, bdf2 gives %.16g", p, seed[0], seed2[0])
}
}
}
// TestBDFVarMilneConstantMatchesBDF2 pins the variable-step Milne
// constant against the shipped bdf2Milne at order two.
func TestBDFVarMilneConstantMatchesBDF2(t *testing.T) {
patterns := []struct{ tNext, t, tNm1, tNm2 float64 }{
{3, 2, 1, 0},
{1.3, 0.75, 0.4, -0.1},
{5, 1, 0.5, -2},
}
for _, p := range patterns {
hist := &bdfVarHistory{}
for _, tt := range []float64{p.tNm2, p.tNm1, p.t} {
hist.push(tt, []float64{0})
}
alpha := bdfVarCoefficients(2, p.tNext, hist, make([]float64, 1), make([]float64, 1),
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
_, tOldest := hist.back(2)
got := 1 / (1 + alpha*(p.tNext-tOldest))
want := bdf2Milne(p.t, p.tNext, p.tNm1, p.tNm2)
if math.Abs(got-want) > 1e-14*math.Max(1, math.Abs(want)) {
t.Fatalf("pattern %v: milne constant %.16g, bdf2Milne gives %.16g", p, got, want)
}
}
}
// TestIntegrateBDFVarFixedOrderLinear pins the exactness of the fixed
// orders on y' = k·t^(k−1), whose solution y = t^k only order k
// reproduces exactly: the k-step formula carries the k-th derivative
// the problem is built from, and any lower order drops it, so each
// locked run must land on 1 at the end AND report that it ran at the
// locked order, which together rule out a hook that silently
// integrates at order 1.
func TestIntegrateBDFVarFixedOrderLinear(t *testing.T) {
for order := 1; order <= 5; order++ {
k := float64(order)
f := func(t float64, y *core.Array) (*core.Array, error) {
out, err := core.Zeros(core.Float, 1)
if err != nil {
return nil, err
}
out.SetFloatAt(0, k*math.Pow(t, k-1))
return out, nil
}
stats := &BDFVarStats{}
end, err := integrateBDFVar("TestIntegrateBDFVarFixedOrderLinear", f, 0, 1,
mustFloats(t, []float64{0}), BDFVarOptions{MaxSteps: 10000, Stats: stats}, order, true)
if err != nil {
t.Fatalf("locked order %d: %v", order, err)
}
if math.Abs(end[0]-1) > 5e-5 {
t.Fatalf("locked order %d: y(1) = %.16g, want 1", order, end[0])
}
if stats.MaxOrder != order {
t.Fatalf("locked order %d ran at max order %d", order, stats.MaxOrder)
}
}
}
// TestBDFVarExactPolynomialPerOrder drives the coefficient recurrence
// directly: a single order-k step from exact history on the degree-k
// polynomial p(t) = t^k must return p at the new time to rounding, on
// skewed steps, because the variable-step formula is exact for degree
// k when the past is exact.
func TestBDFVarExactPolynomialPerOrder(t *testing.T) {
const tNext = 1.3
// Back-value times on skewed step gaps, newest first.
patterns := [][6]float64{
{1, 0.7, 0.35, 0.1, -0.2, -1},
{1, 0.9, 0.75, 0.5, 0.2, -0.1},
}
for order := 1; order <= 5; order++ {
for _, g := range patterns {
times := g[:order+1]
hist := &bdfVarHistory{}
for _, tt := range times {
hist.push(tt, []float64{math.Pow(tt, float64(order))})
}
beta := make([]float64, 1)
seed := make([]float64, 1)
w := &odeWork{}
alpha := bdfVarCoefficients(order, tNext, hist, beta, seed,
make([]float64, bdfVarKeep), make([]float64, bdfVarKeep), make([]float64, bdfVarKeep))
z := make([]float64, 1)
err := odeNewton("TestBDFVarExactPolynomialPerOrder",
func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{float64(order) * math.Pow(t, float64(order-1))}, 1)
}, w, tNext, alpha, 1, beta, seed, z, 1e-13, 1e-13)
if err != nil {
t.Fatalf("order %d gaps %v: odeNewton: %v", order, times, err)
}
want := math.Pow(tNext, float64(order))
if math.Abs(z[0]-want) > 1e-11*math.Max(1, math.Abs(want)) {
t.Fatalf("order %d gaps %v: z = %.16g, want %.16g to rounding", order, times, z[0], want)
}
}
}
}
// TestIntegrateBDFVarErrors 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, and a
// nonsensical order cap is refused.
func TestIntegrateBDFVarErrors(t *testing.T) {
y0 := mustFloats(t, []float64{1})
same, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 1, 1, y0, BDFVarOptions{}, 5, false)
if err != nil {
t.Fatalf("zero span: %v", err)
}
if math.Abs(same[0]-1) > 0 {
t.Fatalf("zero span moved the state to %v", same[0])
}
wrongShape := func(t float64, y *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 1}, 2)
}
if _, err := IntegrateBDFVar(wrongShape, 0, 1, y0, BDFVarOptions{}); err == nil {
t.Fatal("expected an error when f returns the wrong shape")
}
matrixState := mustFloats(t, []float64{1, 1}, 1, 2)
if _, err := IntegrateBDFVar(decay, 0, 1, matrixState, BDFVarOptions{}); err == nil {
t.Fatal("expected an error for a rank-2 state")
}
if _, err := IntegrateBDFVar(decay, 0, 1, mustFloats(t, nil), BDFVarOptions{}); err == nil {
t.Fatal("expected an error for an empty state")
}
if _, err := IntegrateBDFVar(decay, 0, 1, y0, BDFVarOptions{MaxSteps: 2}); err == nil {
t.Fatal("expected an error for an exhausted step budget")
}
if _, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 0, 1, y0, BDFVarOptions{}, 6, false); err == nil {
t.Fatal("expected an error for an order cap above five")
}
if _, err := integrateBDFVar("TestIntegrateBDFVarErrors", decay, 0, 1, y0, BDFVarOptions{}, 0, false); err == nil {
t.Fatal("expected an error for an order cap below one")
}
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 := IntegrateBDFVar(boom, 0, 1, y0, BDFVarOptions{}); err == nil {
t.Fatal("expected the operator error to propagate")
}
// An f that survives the two probe evaluations and fails on the
// starter's own evaluation is refused at once.
calls := 0
counted := func(t float64, y *core.Array) (*core.Array, error) {
calls++
if calls > 2 {
return nil, base.Errf("detector tripped")
}
return core.FromFloats([]float64{0}, 1)
}
if _, err := IntegrateBDFVar(counted, 0, 1, mustFloats(t, []float64{1}), BDFVarOptions{}); err == nil {
t.Fatal("expected the starter's f failure to surface")
}
}