Files
sim-numerics/tests/test_credit_guard.py
T

247 lines
9.9 KiB
Python

"""The credit safeguard: the budget earned instead of scheduled.
The scheduled projection needs a multiplier, and the argument for 2 was
circular. This one banks the surplus a step delivers beyond a halving and
lends only what has been banked, so the only constant left is the endowment,
and one bit is the smallest integer for which the rule is not degenerate.
What must hold, and is asserted here: the stronger guarantee is real, the
endowment is minimal, and the cost of the stronger guarantee is stated rather
than hidden.
"""
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.pure import _Safeguard, pure_root
XTOL, MAXFEV, ACC = 1e-12, 200, 1e-6
def problems():
out = []
for d in _APS_TESTS_DICTS:
a, b = d["bracket"]
args = d.get("args", ())
out.append(((lambda ff, aa: lambda x: ff(x, *aa))(d["f"], args),
float(a), float(b), float(d["root"])))
for d in _CHANDRUPATLA_TESTS_DICTS:
a, b = d["bracket"]
out.append(((lambda ff: lambda x: ff(x))(d["f"]),
float(a), float(b), float(d["root"])))
return out
ALL = problems()
def sweep(guard):
total = solved = over = 0
for f, a, b, root in ALL:
try:
x, n, conv = pure_root(f, a, b, XTOL, MAXFEV, similarity="ratio",
scale="derived3", guard=guard)
except Exception:
x, n, conv = float("nan"), MAXFEV, False
ok = bool(conv and math.isfinite(x) and abs(x - root) <= ACC)
total += n if ok else MAXFEV
solved += ok
if ok and n > math.ceil(math.log2(abs(b - a) / XTOL)) + 4:
over += 1
return total, solved, over
def test_the_credit_guard_meets_the_near_minimal_bound_everywhere():
"""bisection + one step, on all 199 problems. The scheduled projection
is allowed twice that and uses it on 19 of them."""
_t, solved, over = sweep("credit")
assert solved == 193, solved
assert over == 0, over
def test_the_scheduled_projection_really_does_use_its_larger_budget():
"""If this ever drops to zero the two guards have converged and the
multiplier stopped mattering - which would be worth knowing."""
_t, solved, over = sweep("project")
assert solved == 193, solved
assert over > 10, over
def test_the_stronger_guarantee_costs_and_the_cost_is_stated():
"""No free lunch: the paper must not claim the credit rule is both
cheaper and safer."""
t_credit, _s, _o = sweep("credit")
t_sched, _s2, _o2 = sweep("project")
assert t_credit > t_sched, (t_credit, t_sched)
assert (t_credit - t_sched) / t_sched < 0.15, (t_credit, t_sched)
def test_a_zero_endowment_is_degenerate():
"""Why one bit is the minimum: with no endowment the allowance on the
first step is exactly half the bracket, which forces the midpoint."""
sg = _Safeguard("credit", 1.0, XTOL)
assert sg.credit == 1.0
sg.credit = 0.0
assert sg.project_credit(0.9, 0.0, 1.0) == pytest.approx(0.5)
# with the endowment the same step is left alone
sg.credit = 1.0
assert sg.project_credit(0.9, 0.0, 1.0) == pytest.approx(0.9)
def test_credit_never_goes_negative_and_banks_surplus():
sg = _Safeguard("credit", 1.0, XTOL)
sg.credit = 0.0
sg.note_shrink(1.0, 1.0) # no shrink at all
assert sg.credit == 0.0 # clamped, never a debt
sg.note_shrink(1.0, 0.125) # three bits of shrink
assert sg.credit == pytest.approx(2.0)
@pytest.mark.parametrize("c0,steps_over", [(0.25, 1), (0.5, 1), (1.0, 1),
(2.0, 2), (3.0, 3)])
def test_the_guarantee_is_bisection_plus_ceil_of_the_endowment(c0, steps_over):
"""The relation that makes the endowment determined rather than chosen."""
worst = 0
for f, a, b, root in ALL:
try:
x, n, conv = pure_root(f, a, b, XTOL, MAXFEV, similarity="ratio",
scale="derived3", guard="credit",
endowment=c0)
except Exception:
continue
if conv and math.isfinite(x) and abs(x - root) <= ACC:
half = math.ceil(math.log2(abs(b - a) / XTOL))
worst = max(worst, (n - 2) - half)
assert worst == steps_over, (c0, worst, steps_over)
def test_one_bit_is_the_cheapest_endowment_that_promises_one_step():
"""Why C0 = 1 is forced and not picked: every C0 in (0,1] gives the same
one-step guarantee, and 1 is the most permissive of them, hence the
cheapest. If a smaller endowment ever became cheaper, the argument that
the constant is determined would collapse."""
def cost(c0):
t = 0
for f, a, b, root in ALL:
try:
x, n, conv = pure_root(f, a, b, XTOL, MAXFEV,
similarity="ratio", scale="derived3",
guard="credit", endowment=c0)
except Exception:
x, n, conv = float("nan"), MAXFEV, False
ok = conv and math.isfinite(x) and abs(x - root) <= ACC
t += n if ok else MAXFEV
return t
c_one = cost(1.0)
for smaller in (0.1, 0.25, 0.5):
assert cost(smaller) > c_one, smaller
def test_the_scheduled_multiplier_has_no_such_determination():
"""The contrast that decides which safeguard the paper should propose.
The multiplicative family has no smallest non-trivial member: 1 is
degenerate and everything above it is admissible with a different cost, so
the multiplier is genuinely free."""
def cost(fac):
t = 0
for f, a, b, root in ALL:
try:
x, n, conv = pure_root(f, a, b, XTOL, MAXFEV,
similarity="ratio", scale="derived3",
guard="project", budget_factor=fac)
except Exception:
x, n, conv = float("nan"), MAXFEV, False
ok = conv and math.isfinite(x) and abs(x - root) <= ACC
t += n if ok else MAXFEV
return t
degenerate = cost(1.0)
assert degenerate > cost(2.0) # 1 is degenerate
assert cost(1.25) < cost(2.0) # and 2 is not the best either
def test_the_endowment_cannot_be_earned_and_so_cannot_be_computed():
"""Why C0 is a licence rather than a quantity to be derived from the run.
Credit is earned only by a step that beats a halving, and such a step is
permitted only when credit already exists. Starting from zero the
mechanism is locked: the allowance is exactly half the bracket, which
forces the midpoint, which delivers exactly one bit, which banks nothing.
The method is then bisection and stays bisection - so no amount of running
can produce the evidence that would justify a larger endowment."""
sg = _Safeguard("credit", 1.0, XTOL)
sg.credit = 0.0
probe = None
for _ in range(30):
probe = sg.project_credit(0.99, 0.0, 1.0) # a bold step is proposed
assert probe == pytest.approx(0.5) # ...and always refused
sg.note_shrink(1.0, max(probe, 1.0 - probe))
assert sg.credit == 0.0 # nothing is ever banked
# end to end: zero endowment reproduces bisection's cost
from simroot.baselines import bisection
t_zero = t_bis = 0
for f, a, b, root in ALL:
for meth, acc in ((lambda: pure_root(f, a, b, XTOL, MAXFEV,
similarity="ratio",
scale="derived3", guard="credit",
endowment=0.0), "z"),
(lambda: bisection(f, a, b, XTOL, MAXFEV), "b")):
try:
x, n, conv = meth()
except Exception:
x, n, conv = float("nan"), MAXFEV, False
ok = conv and math.isfinite(x) and abs(x - root) <= ACC
if acc == "z":
t_zero += n if ok else MAXFEV
else:
t_bis += n if ok else MAXFEV
assert abs(t_zero - t_bis) / t_bis < 0.01, (t_zero, t_bis)
def test_a_large_credit_cannot_overflow_the_exponent():
"""Found by the algorithm audit. The allowance is w * 2^(C-1) and C
accumulates the log of every shrink without bound, so the exponentiation
overflowed a double at C ~ 1025 and the OverflowError escaped - only the
budget exception is caught in pure_root. It is reachable: sixty
nine-decade steps reach C = 1012, and a bracket of width 1e300 with a
tolerance of 1e-8 has about 1023 bits of shrink available."""
sg = _Safeguard("credit", 1.0, XTOL)
for c in (1.0, 100.0, 1024.0, 1100.0, 1e6):
sg.credit = c
assert sg.project_credit(0.9, 0.0, 1.0) == 0.9, c
# and the accumulation that gets there must not raise either
sg.credit = 1.0
w = 1.0
for _ in range(60):
sg.note_shrink(w, w * 1e-9)
w *= 1e-9
assert sg.credit > 1000.0
assert sg.project_credit(0.9, 0.0, 1.0) == 0.9
def test_a_very_wide_bracket_is_solved_end_to_end():
"""The same defect, reached through the public entry point."""
x, n, conv = pure_root(lambda t: t - 1.0, -1e150, 1e150, 1e-12, 4000,
similarity="ratio", scale="derived3",
guard="credit")
assert conv and abs(x - 1.0) < 1e-6, (x, n, conv)
def test_credit_needs_no_tolerance_at_all():
"""Unlike the scheduled version it reads only widths, so there is no
n_max, no eps, and nothing fixed in advance from the initial bracket."""
f = lambda t: t * t - 2.0
x, _n, conv = pure_root(f, 0.0, 2.0, XTOL, MAXFEV, similarity="ratio",
scale="derived3", guard="credit")
assert conv and abs(x - math.sqrt(2.0)) <= 1e-8