Files
tensor/optim/leastsqfit_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

610 lines
20 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 optim
import (
"math"
"math/big"
"strings"
"testing"
"sourcedock.dev/petrbalvin/tensor/internal/base"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// linearPair returns the determined linear pair r(p) = (p₀ + 2p₁ − 3,
// 2p₀ + p₁ − 4) with its constant Jacobian, the model whose
// Gauss-Newton step is exact.
func linearPair() (func(*core.Array) (*core.Array, error), func(*core.Array) (*core.Array, error)) {
residual := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{
p.FloatAt(0) + 2*p.FloatAt(1) - 3,
2*p.FloatAt(0) + p.FloatAt(1) - 4,
}, 2)
}
jacobian := func(*core.Array) (*core.Array, error) {
return core.FromFloats([]float64{1, 2, 2, 1}, 2, 2)
}
return residual, jacobian
}
// TestLevenbergMarquardtFitConverged pins the Fit surface on the model
// the plain entry point already covers: same point, same chi2, and a
// status that says converged.
func TestLevenbergMarquardtFitConverged(t *testing.T) {
residual, jacobian := linearPair()
p0 := mustFloats(t, []float64{0, 0}, 2)
res, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Tolerance: 1e-14})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", res.Status)
}
if math.Abs(res.Parameters.FloatAt(0)-5.0/3) > 1e-8 || math.Abs(res.Parameters.FloatAt(1)-2.0/3) > 1e-8 {
t.Fatalf("p = (%.12g, %.12g), want (5/3, 2/3)", res.Parameters.FloatAt(0), res.Parameters.FloatAt(1))
}
if res.Chi2 > 1e-16 {
t.Fatalf("chi2 = %v, want 0", res.Chi2)
}
if res.Covariance != nil {
t.Fatal("a fit that did not ask for a covariance returned one")
}
legacyP, legacyChi2, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian, Tolerance: 1e-14})
if err != nil {
t.Fatalf("LevenbergMarquardt: %v", err)
}
if legacyP.FloatAt(0) != res.Parameters.FloatAt(0) || legacyP.FloatAt(1) != res.Parameters.FloatAt(1) {
t.Fatal("the two entry points returned different parameters")
}
if legacyChi2 != res.Chi2 {
t.Fatalf("the two entry points reported chi2 %v and %v", legacyChi2, res.Chi2)
}
}
// TestLevenbergMarquardtGradientExit pins the gradient test: with the
// chi2 tolerance set far below anything the fit can reach, the only
// exit that can fire after the first accepted step is ‖Jᵀr‖∞. The
// residual call count proves it: the start and the one trial make
// two, and a third call would mean the run walked into a second
// solve, which the gradient test forbids.
func TestLevenbergMarquardtGradientExit(t *testing.T) {
residual, jacobian := linearPair()
calls := 0
counted := func(p *core.Array) (*core.Array, error) {
calls++
return residual(p)
}
p0 := mustFloats(t, []float64{0, 0}, 2)
// Lambda 1e-300 leaves the damping factor 1+λ bit-identically 1,
// so the first step is the exact Gauss-Newton one and the run
// reaches the solution in one acceptance.
res, err := LevenbergMarquardtFit(counted, p0, LMOptions{
Jacobian: jacobian,
Tolerance: 1e-30,
GradTol: 1e-7,
Lambda: 1e-300,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", res.Status)
}
if calls != 2 {
t.Fatalf("the residual was evaluated %d times, want 2 (the gradient test must stop the run before its second solve)", calls)
}
if math.Abs(res.Parameters.FloatAt(0)-5.0/3) > 1e-8 || math.Abs(res.Parameters.FloatAt(1)-2.0/3) > 1e-8 {
t.Fatalf("p = (%.12g, %.12g), want (5/3, 2/3)", res.Parameters.FloatAt(0), res.Parameters.FloatAt(1))
}
}
// TestLevenbergMarquardtStepExit pins the step test: a linear
// residual whose first accepted step is negligible against the
// point's own scale converges on that step, with the chi2 tolerance
// far too tight to claim the exit.
func TestLevenbergMarquardtStepExit(t *testing.T) {
residual := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{10 * (p.FloatAt(0) - 10)}, 1)
}
jacobian := func(*core.Array) (*core.Array, error) {
return core.FromFloats([]float64{10}, 1, 1)
}
counted := func(count *int) func(*core.Array) (*core.Array, error) {
return func(p *core.Array) (*core.Array, error) {
*count++
return residual(p)
}
}
stepCalls := 0
res, err := LevenbergMarquardtFit(counted(&stepCalls), mustFloats(t, []float64{10.001}, 1), LMOptions{
Jacobian: jacobian,
Tolerance: 1e-15,
StepTol: 0.5,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", res.Status)
}
if stepCalls != 2 {
t.Fatalf("the step-exit run evaluated the residual %d times, want 2", stepCalls)
}
if math.Abs(res.Parameters.FloatAt(0)-10) > 1e-4 {
t.Fatalf("p = %.12g, want 10", res.Parameters.FloatAt(0))
}
// Without the step test the same run needs the chi2 tolerance, and
// at 1e-6 that fires one acceptance later: three evaluations.
wideCalls := 0
wide, err := LevenbergMarquardtFit(counted(&wideCalls), mustFloats(t, []float64{10.001}, 1), LMOptions{
Jacobian: jacobian,
Tolerance: 1e-6,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit without StepTol: %v", err)
}
if wide.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", wide.Status)
}
if wideCalls != 3 {
t.Fatalf("the control run evaluated the residual %d times, want 3", wideCalls)
}
}
// TestLevenbergMarquardtFitStalled pins the stalled contract: a
// parameter the residual never sees makes the normal equations
// singular on the first iteration, and the fit reports the start
// point back with FitStalled instead of an error. The legacy entry
// point keeps its historical error for the same run.
func TestLevenbergMarquardtFitStalled(t *testing.T) {
residual := func(p *core.Array) (*core.Array, error) {
return core.FromFloats([]float64{p.FloatAt(1) - 1, p.FloatAt(1) - 1}, 2)
}
jacobian := func(*core.Array) (*core.Array, error) {
return core.FromFloats([]float64{0, 1, 0, 1}, 2, 2)
}
p0 := mustFloats(t, []float64{5, 5}, 2)
res, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitStalled {
t.Fatalf("status = %d, want FitStalled", res.Status)
}
if res.Parameters.FloatAt(0) != 5 || res.Parameters.FloatAt(1) != 5 {
t.Fatalf("p = (%.12g, %.12g), want the untouched start", res.Parameters.FloatAt(0), res.Parameters.FloatAt(1))
}
if res.Chi2 != 32 {
t.Fatalf("chi2 = %v, want 32", res.Chi2)
}
if _, _, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian}); err == nil {
t.Fatal("the legacy entry point must keep reporting the singular solve as an error")
}
}
// TestLevenbergMarquardtFitBudget pins the budget report: a fit given
// two iterations of a problem that needs more returns its last point
// with FitBudget and no error, and the legacy entry point refuses the
// same run unless AllowBudgetExit is set.
func TestLevenbergMarquardtFitBudget(t *testing.T) {
xData := []float64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
residual := func(p *core.Array) (*core.Array, error) {
a, b, c := p.FloatAt(0), p.FloatAt(1), p.FloatAt(2)
out := core.New(core.Float, len(xData))
for i := range len(xData) {
out.RawFloats()[i] = 3*math.Exp(-0.5*float64(xData[i])) + 0.5 -
(a*math.Exp(-b*float64(xData[i])) + c)
}
return out, nil
}
p0 := mustFloats(t, []float64{20, 5, 20}, 3)
res, err := LevenbergMarquardtFit(residual, p0, LMOptions{MaxIterations: 2})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitBudget {
t.Fatalf("status = %d, want FitBudget", res.Status)
}
if res.Parameters == nil {
t.Fatal("a budget stop must still report its last point")
}
if _, _, err := LevenbergMarquardt(residual, p0, LMOptions{MaxIterations: 2}); err == nil {
t.Fatal("the legacy entry point must refuse a budget stop without AllowBudgetExit")
}
if _, _, err := LevenbergMarquardt(residual, p0, LMOptions{MaxIterations: 2, AllowBudgetExit: true}); err != nil {
t.Fatalf("LevenbergMarquardt with AllowBudgetExit: %v", err)
}
}
// weightedModel builds the three-observation linear model r(p) = y −
// Xp with its Jacobian, small enough for the exact rational referent.
func weightedModel() (func(*core.Array) (*core.Array, error), func(*core.Array) (*core.Array, error)) {
x := [][]float64{{1, 0}, {0, 1}, {1, 1}}
y := []float64{1.2, 0.7, 2.4}
residual := func(p *core.Array) (*core.Array, error) {
out := core.New(core.Float, 3)
for i := range 3 {
out.RawFloats()[i] = y[i] - (x[i][0]*p.FloatAt(0) + x[i][1]*p.FloatAt(1))
}
return out, nil
}
jacobian := func(*core.Array) (*core.Array, error) {
return core.FromFloats([]float64{-1, 0, 0, -1, -1, -1}, 3, 2)
}
return residual, jacobian
}
// ratSolve solves a x = b exactly over the rationals by elimination
// with nonzero pivots; a singular system returns nil. The inputs are
// copied: big.Rat values are pointers, and the elimination mutates
// every entry it touches.
func ratSolve(a, b [][]*big.Rat) [][]*big.Rat {
n := len(a)
m := make([][]*big.Rat, n)
for i := range n {
m[i] = make([]*big.Rat, 0, len(a[i])+len(b[i]))
for _, v := range a[i] {
m[i] = append(m[i], new(big.Rat).Set(v))
}
for _, v := range b[i] {
m[i] = append(m[i], new(big.Rat).Set(v))
}
}
zero := new(big.Rat)
for col := range n {
piv := -1
for row := col; row < n; row++ {
if m[row][col].Cmp(zero) != 0 {
piv = row
break
}
}
if piv < 0 {
return nil
}
m[col], m[piv] = m[piv], m[col]
inv := new(big.Rat).Inv(m[col][col])
for j := col; j < len(m[col]); j++ {
m[col][j].Mul(m[col][j], inv)
}
for row := range n {
if row == col || m[row][col].Cmp(zero) == 0 {
continue
}
f := new(big.Rat).Set(m[row][col])
for j := col; j < len(m[row]); j++ {
m[row][j].Sub(m[row][j], new(big.Rat).Mul(f, m[col][j]))
}
}
}
out := make([][]*big.Rat, n)
for i := range n {
out[i] = m[i][n:]
}
return out
}
// ratFroms converts float columns into exact rationals.
func ratFroms(vs []float64) []*big.Rat {
out := make([]*big.Rat, len(vs))
for i, v := range vs {
out[i] = new(big.Rat).SetFloat64(v)
}
return out
}
// ratColumn turns a vector into the one-column right-hand side
// ratSolve takes.
func ratColumn(vs []*big.Rat) [][]*big.Rat {
out := make([][]*big.Rat, len(vs))
for i, v := range vs {
out[i] = []*big.Rat{v}
}
return out
}
// ratMatrixInvert inverts a small square rational matrix.
func ratMatrixInvert(a [][]*big.Rat) [][]*big.Rat {
n := len(a)
id := make([][]*big.Rat, n)
for i := range n {
row := make([]*big.Rat, n)
for j := range n {
row[j] = new(big.Rat)
if i == j {
row[j].SetInt64(1)
}
}
id[i] = row
}
return ratSolve(a, id)
}
// TestLevenbergMarquardtWeights pins the weighted fit against an
// exact rational referent: chi2 becomes rᵀC⁻¹r, the parameters
// minimise it, and the diagonal and matrix forms of Sigma agree on
// the same weighting.
func TestLevenbergMarquardtWeights(t *testing.T) {
residual, jacobian := weightedModel()
cov := []float64{
2, 1, 0,
1, 3, 1,
0, 1, 2,
}
sigma, err := core.FromFloats(cov, 3, 3)
if err != nil {
t.Fatal(err)
}
p0 := mustFloats(t, []float64{0, 0}, 2)
res, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: sigma})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Status != FitConverged {
t.Fatalf("status = %d, want FitConverged", res.Status)
}
// The exact referent: r = y − Xp, so the weighted optimum solves
// (XᵀC⁻¹X) p = XᵀC⁻¹y.
xf := [][]float64{{1, 0}, {0, 1}, {1, 1}}
yf := []float64{1.2, 0.7, 2.4}
cr := make([][]*big.Rat, 3)
for i := range 3 {
cr[i] = ratFroms(cov[i*3 : i*3+3])
}
cinv := ratMatrixInvert(cr)
if cinv == nil {
t.Fatal("the referent covariance is singular")
}
xr := make([][]*big.Rat, 3)
for i := range 3 {
xr[i] = ratFroms(xf[i])
}
yr := ratFroms(yf)
cinvX := ratSolve(cr, xr)
normal := make([][]*big.Rat, 2)
for i := range 2 {
normal[i] = make([]*big.Rat, 2)
for j := range 2 {
s := new(big.Rat)
for k := range 3 {
s.Add(s, new(big.Rat).Mul(xr[k][i], cinvX[k][j]))
}
normal[i][j] = s
}
}
rhs := make([]*big.Rat, 2)
cinvY := ratSolve(cr, ratColumn(yr))
if cinvY == nil {
t.Fatal("the referent solve failed")
}
for i := range 2 {
s := new(big.Rat)
for k := range 3 {
s.Add(s, new(big.Rat).Mul(xr[k][i], cinvY[k][0]))
}
rhs[i] = s
}
wantP := ratSolve(normal, ratColumn(rhs))
if wantP == nil {
t.Fatal("the referent normal equations are singular")
}
for j := range 2 {
got := new(big.Rat).SetFloat64(res.Parameters.FloatAt(j))
d := new(big.Rat).Sub(got, wantP[j][0])
d.Abs(d)
if d.Cmp(new(big.Rat).SetFloat64(1e-10)) > 0 {
t.Fatalf("parameter %d = %.12g, want %s", j, res.Parameters.FloatAt(j), wantP[j][0].FloatString(12))
}
}
// The weighted chi2: rᵀC⁻¹r at the returned point.
rAt := make([]*big.Rat, 3)
for k := range 3 {
rAt[k] = new(big.Rat).SetFloat64(yf[k] - (xf[k][0]*res.Parameters.FloatAt(0) + xf[k][1]*res.Parameters.FloatAt(1)))
}
cinvR := make([]*big.Rat, 3)
for k := range 3 {
s := new(big.Rat)
for j := range 3 {
s.Add(s, new(big.Rat).Mul(cinv[k][j], rAt[j]))
}
cinvR[k] = s
}
chiRef := new(big.Rat)
for k := range 3 {
chiRef.Add(chiRef, new(big.Rat).Mul(rAt[k], cinvR[k]))
}
chiFloat, _ := chiRef.Float64()
if math.Abs(res.Chi2-chiFloat) > 1e-12*math.Max(1, math.Abs(chiFloat)) {
t.Fatalf("chi2 = %.17g, want %.17g", res.Chi2, chiFloat)
}
// The weighted answer must differ from the unweighted one, or the
// test proves nothing about the weighting.
plain, _, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian})
if err != nil {
t.Fatalf("the unweighted fit: %v", err)
}
if plain.FloatAt(0) == res.Parameters.FloatAt(0) && plain.FloatAt(1) == res.Parameters.FloatAt(1) {
t.Fatal("the weighted and unweighted fits returned identical parameters")
}
// The diagonal form weights by the same matrix's diagonal, and the
// legacy entry point carries Sigma too.
variance, err := core.FromFloats([]float64{2, 3, 2}, 3)
if err != nil {
t.Fatal(err)
}
diagRes, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: variance})
if err != nil {
t.Fatalf("the diagonal Sigma fit: %v", err)
}
if diagRes.Status != FitConverged {
t.Fatalf("the diagonal Sigma status = %d, want FitConverged", diagRes.Status)
}
wp, _, err := LevenbergMarquardt(residual, p0, LMOptions{Jacobian: jacobian, Sigma: sigma})
if err != nil {
t.Fatalf("the legacy weighted fit: %v", err)
}
if wp.FloatAt(0) != res.Parameters.FloatAt(0) || wp.FloatAt(1) != res.Parameters.FloatAt(1) {
t.Fatal("the legacy weighted fit returned different parameters")
}
}
// TestLevenbergMarquardtWeightedCovariance pins the covariance under
// Sigma: (JᵀC⁻¹J)⁻¹ against the exact rational inverse, entry by
// entry.
func TestLevenbergMarquardtWeightedCovariance(t *testing.T) {
residual, jacobian := weightedModel()
cov := []float64{
2, 1, 0,
1, 3, 1,
0, 1, 2,
}
sigma, err := core.FromFloats(cov, 3, 3)
if err != nil {
t.Fatal(err)
}
xf := [][]float64{{1, 0}, {0, 1}, {1, 1}}
cr := make([][]*big.Rat, 3)
for i := range 3 {
cr[i] = ratFroms(cov[i*3 : i*3+3])
}
xr := make([][]*big.Rat, 3)
for i := range 3 {
xr[i] = ratFroms(xf[i])
}
cinvX := ratSolve(cr, xr)
normal := make([][]*big.Rat, 2)
for i := range 2 {
normal[i] = make([]*big.Rat, 2)
for j := range 2 {
s := new(big.Rat)
for k := range 3 {
s.Add(s, new(big.Rat).Mul(xr[k][i], cinvX[k][j]))
}
normal[i][j] = s
}
}
want := ratMatrixInvert(normal)
if want == nil {
t.Fatal("the referent normal matrix is singular")
}
res, err := LevenbergMarquardtFit(residual, mustFloats(t, []float64{0, 0}, 2), LMOptions{
Jacobian: jacobian,
Sigma: sigma,
RequestCovariance: true,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit: %v", err)
}
if res.Covariance == nil {
t.Fatal("the requested covariance is missing")
}
if res.Covariance.NDim() != 2 || res.Covariance.Shape()[0] != 2 || res.Covariance.Shape()[1] != 2 {
t.Fatalf("covariance shape %s, want 2×2", base.ShapeText(res.Covariance.Shape()))
}
for i := range 2 {
for j := range 2 {
got := res.Covariance.FloatAt(i*2 + j)
wantFloat, _ := want[i][j].Float64()
if math.Abs(got-wantFloat) > 1e-9*math.Max(1, math.Abs(wantFloat)) {
t.Fatalf("covariance (%d, %d) = %.17g, want %.17g", i, j, got, wantFloat)
}
mirror := res.Covariance.FloatAt(j*2 + i)
if got != mirror {
t.Fatalf("covariance (%d, %d) = %.17g against (%d, %d) = %.17g", i, j, got, j, i, mirror)
}
}
}
}
// TestLevenbergMarquardtUnweightedCovariance pins the unweighted
// covariance (JᵀJ)⁻¹ against the exact rational inverse, including on
// the perfect start whose fit never enters the iteration loop.
func TestLevenbergMarquardtUnweightedCovariance(t *testing.T) {
residual, jacobian := weightedModel()
xf := [][]float64{{1, 0}, {0, 1}, {1, 1}}
xr := make([][]*big.Rat, 3)
for i := range 3 {
xr[i] = ratFroms(xf[i])
}
xtX := make([][]*big.Rat, 2)
for i := range 2 {
xtX[i] = make([]*big.Rat, 2)
for j := range 2 {
s := new(big.Rat)
for k := range 3 {
s.Add(s, new(big.Rat).Mul(xr[k][i], xr[k][j]))
}
xtX[i][j] = s
}
}
want := ratMatrixInvert(xtX)
if want == nil {
t.Fatal("the referent normal matrix is singular")
}
starts := [][]float64{{0, 0}, {1.2, 0.7}}
for _, s := range starts {
res, err := LevenbergMarquardtFit(residual, mustFloats(t, s, 2), LMOptions{
Jacobian: jacobian,
RequestCovariance: true,
})
if err != nil {
t.Fatalf("LevenbergMarquardtFit(%v): %v", s, err)
}
if res.Covariance == nil {
t.Fatalf("LevenbergMarquardtFit(%v): the requested covariance is missing", s)
}
for i := range 2 {
for j := range 2 {
got := res.Covariance.FloatAt(i*2 + j)
wantFloat, _ := want[i][j].Float64()
if math.Abs(got-wantFloat) > 1e-9*math.Max(1, math.Abs(wantFloat)) {
t.Fatalf("start %v: covariance (%d, %d) = %.17g, want %.17g", s, i, j, got, wantFloat)
}
}
}
}
}
// TestLevenbergMarquardtSigmaErrors pins the Sigma contract: shapes,
// positive variances, exact symmetry and positive definiteness are
// all checked before the fit moves.
func TestLevenbergMarquardtSigmaErrors(t *testing.T) {
residual, jacobian := weightedModel()
p0 := mustFloats(t, []float64{0, 0}, 2)
cases := []struct {
name string
sigma *core.Array
want string
}{
{"wrong length", mustFloats(t, []float64{1, 2}, 2), "one variance per residual"},
{"wrong shape", mustFloats(t, []float64{1, 0, 0, 1, 0, 0}, 2, 3), "shape (2, 3)"},
{"zero variance", mustFloats(t, []float64{1, 0, 1}, 3), "positive variances"},
{"negative variance", mustFloats(t, []float64{1, -2, 1}, 3), "positive variances"},
}
for _, tc := range cases {
if _, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: tc.sigma}); err == nil {
t.Fatalf("%s: want an error", tc.name)
} else if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("%s: error %q, want it to mention %q", tc.name, err, tc.want)
}
}
asymmetric := mustFloats(t, []float64{2, 1, 0, 1, 3, 1, 2, 1, 2}, 3, 3)
if _, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: asymmetric}); err == nil {
t.Fatal("an asymmetric Sigma: want an error")
} else if !strings.Contains(err.Error(), "symmetric") {
t.Fatalf("asymmetric Sigma: error %q, want it to mention symmetry", err)
}
indefinite := mustFloats(t, []float64{1, 2, 2, 1}, 2, 2)
wrongRows := mustFloats(t, []float64{1, 0, 0, 1}, 2, 2)
if _, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: indefinite}); err == nil {
t.Fatal("an indefinite Sigma: want an error")
}
if _, err := LevenbergMarquardtFit(residual, p0, LMOptions{Jacobian: jacobian, Sigma: wrongRows}); err == nil {
t.Fatal("a Sigma of the wrong row count: want an error")
}
}