Files
sim-numerics/stage16_blend.py
vvsandClaude Opus 5 68df1d25db Sign tests by product underflowed: FIXED everywhere (branch, not master - the paper is not yet updated). FA * fnew < 0.0 with FA ~ -1e-200, fnew ~ 1e-150 is -0.0 and '-0.0 < 0.0' is False, so on flat functions (x^19) the bracket update moved the wrong end, the root left the bracket and the run still reported convergence - the proposed rule returned 4.9e-9, bisection 3.5e-9, ITP 1.6e-8 with a claimed 1e-12 bracket; the 1e-6 harness scored them solved. Same defect in our bisection, false-position family, Ridders, Brent, ITP and twelve copies in stage scripts, simroot/sim.py and one test; all replaced by _opposite / _same_sign, pinned by test_sign_underflow.
Impact, every sweep regenerated: the paper's key configurations are unchanged to the evaluation (proposed 2806/3220, credit 3568/3020, Chandrupatla, TOMS 748, Brent, bisection); 46 stated numbers move (ITP CHA 2857 -> 2896, ITP oracle 2611 -> 2647, record CHA figures by 14-18, the grid means by ~1%) and one finding weakens: the measured-order exponent of section 7.1 was cheaper in 29 of 60 far cells and 7 of 7 safeguards; with the fix 12 of 61 and 6 of 7, and on the wide-bracket set it now loses in every safeguard and drops 5 problems.

PREREG G1-G3 outcome on the fixed code: G2 (repeating step pattern, period from the data) PASSES Y1-Y4 and is ACCEPTED by the stop rule - proposed rule APS 2806 -> 2782 (below Chandrupatla's 2787), CHA 3220 -> 3169, both 6026 -> 5951, per problem vs Chandrupatla on APS 95/20/38 p = 8e-7. G1 fails Y1/Y2 (its Y3 miss was the sign bug), G3 fails Y2, A fails Y2. Regression 887 pass; the two red tests are the paper-number check (awaiting the paper update) and test_blend (fixed since, copy in stage16).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:49:01 +01:00

280 lines
11 KiB
Python

"""Stage 16 - similarity used to IMPROVE an existing method, not to replace it.
Everything that worked in this project worked that way. The credit safeguard
did not produce a new root finder that beats the field; it took regula falsi,
the secant rule and inverse quadratic interpolation and made each of them
solve 193 of 199 with a near-bisection worst case. Building a method from
similarity landed second behind Chandrupatla 1997; grafting a measured
quantity onto existing methods was the part that paid.
This stage tries the one graft that comes straight from the similarity
literature and has not been tried here: **similarity replaces a hard
partition by a graded weight**.
Every strong bracketing method contains such a partition:
Brent three empirical inequalities decide interpolation OR bisection
Chandrupatla the criterion 1-sqrt(1-xi) < phi < sqrt(xi) decides the same
In both, the quantity being tested is continuous and the decision taken on it
is a step function. The similarity move is to keep the quantity and drop the
step:
r = (how far outside the safe region the interpolation falls), 0 = safe
w = S(r), S the tent similarity, S(0) = 1, no constant
d = w * d_interpolation + (1 - w) * d_bisection
At r = 0 this is exactly the original method. At r >= 1 it is exactly
bisection. In between, where the original flips discontinuously, it blends.
The comparison is against the SAME implementation with the blend switched
off, so the difference can only be the blend. Our Brent is separately
validated against scipy.optimize.brentq to 0.2% of evaluations.
"""
from __future__ import annotations
from simroot.baselines import _opposite, _same_sign # 2026-09-21 fix
import math
from scipy.optimize._tstutils import (_APS_TESTS_DICTS,
_CHANDRUPATLA_TESTS_DICTS)
from simroot.baselines import Counter, _Budget, brent, chandrupatla
XTOL, MAXFEV, ACC = 1e-12, 200, 1e-6
# the tent similarity of the paper: S(0) = 1, S(1) = 0, no constant
TENT = lambda r: max(0.0, 1.0 - r)
def brent_blend(f, a, b, xtol, maxfev, mode="blend"):
"""Brent's method with its accept/reject replaced by a similarity blend.
The only change from simroot.baselines.brent is the four lines marked
below; with mode="off" the two are the same algorithm. mode is a name, not a
boolean carrying three states."""
g = Counter(f, maxfev)
b_ = b
try:
fa, fb = g(a), g(b)
if fa == 0.0:
return a, g.n, True
if fb == 0.0:
return b, g.n, True
c, fc = a, fa
d = e = b - a
while True:
if _same_sign(fb, fc):
c, fc = a, fa
d = e = b - a
if abs(fc) < abs(fb):
a, fa = b, fb
b, fb = c, fc
c, fc = a, fa
tol1 = 2.0 * 2.22e-16 * abs(b) + 0.5 * xtol
xm = 0.5 * (c - b)
if abs(xm) <= tol1 or fb == 0.0:
return b, g.n, True
if abs(e) >= tol1 and abs(fa) > abs(fb):
s = fb / fa
if a == c:
p = 2.0 * xm * s
q = 1.0 - s
else:
q = fa / fc
r = fb / fc
p = s * (2.0 * xm * q * (q - r) - (b - a) * (r - 1.0))
q = (q - 1.0) * (r - 1.0) * (s - 1.0)
if p > 0.0:
q = -q
p = abs(p)
bound = min(3.0 * xm * q - abs(tol1 * q), abs(e * q))
if mode == "project":
# The lesson from the root-finding safeguard, applied to
# somebody else's method: when the interpolation violates
# the safety condition, do not discard it and do not
# average it with the midpoint - keep as much of it as the
# condition allows, by clamping it to the boundary.
if 2.0 * p < bound:
e, d = d, p / q
elif bound > 0.0 and q != 0.0:
e, d = d, math.copysign(0.5 * bound / abs(q),
p / q if q else xm)
else:
d = e = xm
elif mode == "off":
if 2.0 * p < bound:
e, d = d, p / q
else:
d = e = xm
else:
# ---- the graft -------------------------------------
# Brent's own test is 2p < bound. The ratio of the two
# is continuous; the decision on it is a step. Keep the
# ratio, weight by similarity, blend the two candidate
# steps. r = 0 reproduces Brent exactly, r >= 1 gives
# bisection exactly.
r_trust = (2.0 * p / bound) if bound > 0.0 else math.inf
w = TENT(r_trust) if math.isfinite(r_trust) else 0.0
d_int = p / q if q != 0.0 else xm
e, d = d, w * d_int + (1.0 - w) * xm
# ----------------------------------------------------
else:
d = e = xm
a, fa = b, fb
b = b + (d if abs(d) > tol1 else math.copysign(tol1, xm))
b_ = b
fb = g(b)
except _Budget:
return b_, g.n, False
def chandrupatla_blend(f, a, b, xtol, maxfev, mode="blend"):
"""Chandrupatla's method with its acceptance region softened the same way.
Its test is 1 - sqrt(1-xi) < phi < sqrt(xi). The distance outside that
interval, scaled by its own width, is the continuous quantity; the
decision on it is a step."""
g = Counter(f, maxfev)
x1, x2 = b, a
f1 = f2 = 0.0
try:
f1, f2 = g(x1), g(x2)
if f1 == 0.0:
return x1, g.n, True
if f2 == 0.0:
return x2, g.n, True
x3, f3 = x2, f2
t = 0.5
while True:
x = x1 + t * (x2 - x1)
fx = g(x)
if (fx > 0.0) == (f1 > 0.0):
x3, f3 = x1, f1
x1, f1 = x, fx
else:
x3, f3 = x2, f2
x2, f2 = x1, f1
x1, f1 = x, fx
xm, fm = (x1, f1) if abs(f1) < abs(f2) else (x2, f2)
if fm == 0.0:
return xm, g.n, True
tol = 2.0 * 2.22e-16 * abs(xm) + 0.5 * xtol # 2026-09-21 fix, see baselines.chandrupatla
span = abs(x2 - x1)
if span == 0.0:
return xm, g.n, True
tl = tol / span
if tl > 0.5:
return xm, g.n, True
t = 0.5
if x3 != x2 and f3 != f2:
xi = (x1 - x2) / (x3 - x2)
ph = (f1 - f2) / (f3 - f2)
if 0.0 < xi < 1.0:
lo = 1.0 - math.sqrt(1.0 - xi)
hi = math.sqrt(xi)
d1, d2, d3 = f1 - f2, f1 - f3, f2 - f3
inside = lo < ph < hi
if d1 != 0.0 and d2 != 0.0 and d3 != 0.0:
t_iqi = ((f1 / d1) * (f3 / (-d3))
+ ((x3 - x1) / (x2 - x1)) * (f1 / d2)
* (f2 / d3))
if mode == "off":
if inside:
t = t_iqi
else:
# distance outside the safe interval, in units of
# the interval's own width
width = hi - lo
if width > 0.0:
out = max(0.0, lo - ph, ph - hi) / width
w = TENT(out)
t = w * t_iqi + (1.0 - w) * 0.5
elif inside:
t = t_iqi
if not math.isfinite(t):
t = 0.5
t = min(max(t, tl), 1.0 - tl)
except _Budget:
return (x1 if abs(f1) < abs(f2) else x2), g.n, False
def load():
out = []
for d in _APS_TESTS_DICTS:
a, b = d["bracket"]
args = d.get("args", ())
out.append(("APS", (lambda ff, aa: lambda t: ff(t, *aa))(d["f"], args),
float(a), float(b), float(d["root"])))
for d in _CHANDRUPATLA_TESTS_DICTS:
a, b = d["bracket"]
out.append(("CHA", (lambda ff: lambda t: ff(t))(d["f"]),
float(a), float(b), float(d["root"])))
return out
def measure(method, probs):
t = {"APS": 0, "CHA": 0}
ok = {"APS": 0, "CHA": 0}
worst = 0
for s, f, a, b, root in probs:
try:
x, n, conv = method(f, a, b, XTOL, MAXFEV)
except Exception:
x, n, conv = float("nan"), MAXFEV, False
good = bool(conv and math.isfinite(x) and abs(x - root) <= ACC)
t[s] += n if good else MAXFEV
ok[s] += good
if good:
worst = max(worst, n)
return t, ok, worst
def main():
probs = load()
rows = [
("brent, as published", lambda f, a, b, xt, mx: brent(f, a, b, xt, mx)),
("brent, blend off (same code)",
lambda f, a, b, xt, mx: brent_blend(f, a, b, xt, mx, mode="off")),
("brent + similarity blend",
lambda f, a, b, xt, mx: brent_blend(f, a, b, xt, mx, mode="blend")),
("brent + projection onto the bound",
lambda f, a, b, xt, mx: brent_blend(f, a, b, xt, mx,
mode="project")),
("chandrupatla, as published",
lambda f, a, b, xt, mx: chandrupatla(f, a, b, xt, mx)),
("chandrupatla, blend off",
lambda f, a, b, xt, mx: chandrupatla_blend(f, a, b, xt, mx,
mode="off")),
("chandrupatla + similarity blend",
lambda f, a, b, xt, mx: chandrupatla_blend(f, a, b, xt, mx,
mode="blend")),
]
print(f"{'method':34s}{'APS':>8s}{'CHA':>8s}{'both':>8s}"
f"{'solved':>10s}{'worst':>8s}")
print("-" * 78)
base = {}
for name, m in rows:
t, ok, worst = measure(m, probs)
both = t["APS"] + t["CHA"]
base[name] = both
print(f"{name:34s}{t['APS']:>8d}{t['CHA']:>8d}{both:>8d}"
f"{ok['APS'] + ok['CHA']:>7d}/199{worst:>8d}", flush=True)
print("\nThe two 'blend off' rows must reproduce the published methods -"
"\nif they do not, the blend is being compared against a different"
"\nalgorithm and the experiment is void.")
for pub, off, on in (("brent, as published", "brent, blend off (same code)",
"brent + similarity blend"),
("chandrupatla, as published", "chandrupatla, blend off",
"chandrupatla + similarity blend")):
drift = abs(base[off] - base[pub]) / base[pub]
gain = (base[pub] - base[on]) / base[pub]
print(f"\n {pub.split(',')[0]:14s} control drift {drift * 100:5.2f}%"
f" blend changes the total by {gain * 100:+6.2f}%")
if __name__ == "__main__":
main()