178 lines
7.4 KiB
Python
178 lines
7.4 KiB
Python
"""Regression suite for simroot.pure and for the benchmark harness.
|
|
|
|
Each audited defect is pinned as a test, then the contracts the method is
|
|
allowed to claim in the paper - and only those.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
import pytest
|
|
from scipy.optimize._tstutils import (_APS_TESTS_DICTS,
|
|
_CHANDRUPATLA_TESTS_DICTS)
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from simroot.baselines import bisection, itp
|
|
from simroot.pure import GUARDS, PURE_SIMS, pure_root
|
|
|
|
XTOL = 1e-12
|
|
MAXFEV = 200
|
|
ACC = 1e-6
|
|
|
|
SMALL = [
|
|
("x^2-2", lambda x: x * x - 2.0, 0.0, 2.0, math.sqrt(2.0)),
|
|
("cos-x", lambda x: math.cos(x) - x, 0.0, 1.0, 0.7390851332151607),
|
|
("cubic", lambda x: x ** 3 - x - 2.0, 1.0, 2.0, 1.5213797068045676),
|
|
("x^10-1", lambda x: x ** 10 - 1.0, 0.0, 1.3, 1.0),
|
|
("flat", lambda x: math.exp(-x) * (x - 1.0), -1.0, 40.0, 1.0),
|
|
("atan", lambda x: math.atan(x), -1.0, 3.0, 0.0),
|
|
]
|
|
|
|
# the guards that are ALLOWED to claim a worst-case bound
|
|
SOUND_GUARDS = ("bracket", "both")
|
|
|
|
|
|
def _problems(dicts, chandrupatla=False):
|
|
out = []
|
|
for d in dicts:
|
|
a, b = float(d["bracket"][0]), float(d["bracket"][1])
|
|
args = () if chandrupatla else d.get("args", ())
|
|
f = (lambda ff, aa: lambda x: ff(x, *aa))(d["f"], args)
|
|
out.append((d["ID"], f, a, b, float(d["root"])))
|
|
return out
|
|
|
|
|
|
APS = _problems(_APS_TESTS_DICTS)
|
|
CHA = _problems(_CHANDRUPATLA_TESTS_DICTS, chandrupatla=True)
|
|
|
|
|
|
def two_x_bisection_bound(a, b):
|
|
return 2 * math.ceil(math.log2(abs(b - a) / XTOL)) + 4
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# audited defects, pinned
|
|
# --------------------------------------------------------------------------
|
|
@pytest.mark.parametrize("guard", SOUND_GUARDS)
|
|
def test_bug_pending_flag_gave_three_x_not_two_x(guard):
|
|
"""The bracket guard cleared `pending` on the halving branch and then
|
|
returned early, so TWO similarity steps were granted after every halving
|
|
instead of one - a 3x bisection guarantee dressed up as 2x. Chandrupatla
|
|
fun4.3 caught it: 139 evaluations against a bound of 114."""
|
|
d = next(x for x in _CHANDRUPATLA_TESTS_DICTS if x["ID"] == "fun4.3")
|
|
a, b = float(d["bracket"][0]), float(d["bracket"][1])
|
|
x, n, conv = pure_root(d["f"], a, b, XTOL, MAXFEV,
|
|
similarity="ratio", scale="derived", guard=guard)
|
|
assert conv and abs(x - float(d["root"])) <= ACC
|
|
assert n <= two_x_bisection_bound(a, b), f"{n} evaluations, 3x regression"
|
|
|
|
|
|
def test_step_guard_makes_no_worst_case_claim():
|
|
"""The step guard is cheap but unsound: a sequence creeping towards a
|
|
non-root with halving steps never trips it. The suite must NOT assert a
|
|
bound for it - this test exists so that nobody adds one later without
|
|
noticing that the benchmark reports real violations."""
|
|
violations = 0
|
|
for _pid, f, a, b, root in CHA:
|
|
x, n, conv = pure_root(f, a, b, XTOL, MAXFEV, similarity="ratio",
|
|
scale="derived", guard="step")
|
|
if conv and abs(x - root) <= ACC and n > two_x_bisection_bound(a, b):
|
|
violations += 1
|
|
assert violations > 0, ("the step guard no longer violates the 2x bound; "
|
|
"if that is a real improvement, promote it to "
|
|
"SOUND_GUARDS and prove it")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# contracts
|
|
# --------------------------------------------------------------------------
|
|
@pytest.mark.parametrize("guard", SOUND_GUARDS)
|
|
def test_sound_guards_respect_the_bound_on_every_benchmark_problem(guard):
|
|
"""The only worst-case claim the paper may make."""
|
|
bad = []
|
|
for pid, f, a, b, root in APS + CHA:
|
|
x, n, conv = pure_root(f, a, b, XTOL, MAXFEV, similarity="ratio",
|
|
scale="derived", guard=guard)
|
|
if conv and abs(x - root) <= ACC and n > two_x_bisection_bound(a, b):
|
|
bad.append((pid, n, two_x_bisection_bound(a, b)))
|
|
assert not bad, f"{guard} exceeded the 2x bound on {bad[:5]}"
|
|
|
|
|
|
@pytest.mark.parametrize("sim", sorted(PURE_SIMS))
|
|
@pytest.mark.parametrize("prob", SMALL, ids=[p[0] for p in SMALL])
|
|
def test_derived_scale_with_a_sound_guard_always_converges(sim, prob):
|
|
_, f, a, b, root = prob
|
|
x, n, conv = pure_root(f, a, b, XTOL, 400, similarity=sim,
|
|
scale="derived", guard="both")
|
|
assert conv, f"pure.{sim}.both did not converge"
|
|
assert abs(x - root) <= 1e-8
|
|
|
|
|
|
@pytest.mark.parametrize("guard", GUARDS)
|
|
def test_never_leaves_the_bracket(guard):
|
|
for _pid, f, a, b, _root in SMALL:
|
|
x, _n, _c = pure_root(f, a, b, XTOL, MAXFEV, guard=guard)
|
|
assert min(a, b) - 1e-9 <= x <= max(a, b) + 1e-9
|
|
|
|
|
|
def test_rejects_unknown_settings_loudly():
|
|
f = lambda t: t * t - 2.0
|
|
with pytest.raises(ValueError):
|
|
pure_root(f, 0.0, 2.0, XTOL, MAXFEV, similarity="no-such")
|
|
with pytest.raises(ValueError):
|
|
pure_root(f, 0.0, 2.0, XTOL, MAXFEV, scale="no-such")
|
|
with pytest.raises(ValueError):
|
|
pure_root(f, 0.0, 2.0, XTOL, MAXFEV, guard="no-such")
|
|
with pytest.raises(ValueError):
|
|
pure_root(f, 3.0, 4.0, XTOL, MAXFEV) # no sign change
|
|
|
|
|
|
def test_budget_is_never_exceeded():
|
|
for budget in (0, 1, 2, 5, 30):
|
|
for guard in GUARDS:
|
|
_x, n, _c = pure_root(lambda t: math.exp(-t) * (t - 1.0),
|
|
-1.0, 40.0, XTOL, budget, guard=guard)
|
|
assert n <= budget
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# the competitor must be sound too, or the benchmark is worthless
|
|
# --------------------------------------------------------------------------
|
|
@pytest.mark.parametrize("n0", [0, 1, 2, 5])
|
|
def test_itp_respects_its_own_published_guarantee(n0):
|
|
"""ITP's theorem: it never needs more iterations than bisection plus n0.
|
|
Checking the theorem is the strongest validation available for the
|
|
implementation without a reference to diff against.
|
|
|
|
The permitted count is n_half + n0 + 1, not n_half + n0: the induction
|
|
gives bracket_j <= 2^(n_max-j+1)*eps, so the width first drops to 2*eps at
|
|
j = n_max, and the iterations actually run j = 0 .. n_max, which is
|
|
n_max + 1 of them. Stating it as n_max flagged 3 of 154 problems by
|
|
exactly one iteration - the signature of an indexing mistake in the test
|
|
rather than of a broken implementation."""
|
|
bad = []
|
|
for pid, f, a, b, root in APS:
|
|
if not a < b:
|
|
continue
|
|
allowed = math.ceil(math.log2((b - a) / XTOL)) + n0 + 1
|
|
x, n, conv = itp(f, a, b, XTOL, 400, kappa1=0.2 / (b - a), n0=n0)
|
|
if conv and abs(x - root) <= ACC and (n - 2) > allowed:
|
|
bad.append((pid, n - 2, allowed))
|
|
assert not bad, f"ITP broke its own bound on {bad[:5]}"
|
|
|
|
|
|
def test_bisection_is_the_reference_ceiling_everyone_beats():
|
|
"""Sanity check on the harness: if the new method were not actually
|
|
solving anything, this is the comparison that would expose it."""
|
|
tot_pure = tot_bis = 0
|
|
for _pid, f, a, b, root in APS:
|
|
_x, n1, c1 = pure_root(f, a, b, XTOL, MAXFEV, similarity="ratio",
|
|
scale="derived", guard="both")
|
|
_y, n2, c2 = bisection(f, a, b, XTOL, MAXFEV)
|
|
tot_pure += n1 if c1 else MAXFEV
|
|
tot_bis += n2 if c2 else MAXFEV
|
|
assert tot_pure < tot_bis, (tot_pure, tot_bis)
|