Files
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

391 lines
16 KiB
Python

"""Stage 4 - WHERE the similarity method wins, and WHY.
The totals say the parameter-free similarity rule loses to Brent and TOMS748
on Alefeld-Potra-Shi and beats them on Chandrupatla. A total does not explain
anything, so this file takes the runs apart.
Every root search has two phases with completely different economics:
APPROACH getting from the initial bracket into the neighbourhood of the
root. Cost here is governed by how boldly a method is willing
to jump, and by how often its safeguard drags it back to the
midpoint. Bracket width drives this phase.
ENDGAME going from the neighbourhood down to the tolerance. Cost here
is governed by the ORDER of convergence and nothing else:
secant 1.618, Brent's inverse quadratic 1.839, TOMS748's cubic
up to 2.7.
Prediction, if the method is what the derivation says it is:
- its step is |f|/|f'| with a secant slope, so its order is ~1.618 and it
must LOSE the endgame to both Brent and TOMS748, by a roughly constant
number of evaluations per problem;
- its approach carries no cautious truncation, so it must WIN the
approach, by an amount that GROWS with the width of the bracket.
- therefore: narrow brackets -> the endgame dominates -> we lose;
wide brackets -> the approach dominates -> we win.
All three are measured below. If the width correlation is absent, the
explanation is wrong and must not go into the paper.
"""
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 bisection, brent, toms748
from simroot.pure import pure_root
XTOL = 1e-12
MAXFEV = 200
ACC = 1e-6
EPS = 2.220446049250313e-16
def phase_boundary(width, scale=0.0, xtol=XTOL):
"""Where the approach ends and the endgame begins.
A fixed relative distance does not work: at 1e-4 of a bracket of width
1e12 the boundary sits at 1e8, which the very first evaluation clears, so
every wide problem would report its entire cost as endgame.
The boundary is the geometric mean of the initial width and the tolerance
that is actually ATTAINABLE. The attainable one is not xtol: near
abscissae of magnitude 1e12 the spacing of floating point is about 2e-4,
so no method can drive the bracket to 1e-12 there and the journey is
shorter than xtol suggests. Using xtol regardless put the boundary too
low and charged the whole tail to the approach - measured on a width of
1e12, bisection split 40 / 15 instead of evenly.
`scale` is the largest |abscissa| in the bracket; pass it whenever the
bracket is far from the origin."""
attainable = max(xtol, EPS * abs(scale))
return math.sqrt(width * attainable)
def ours(f, a, b, xtol, maxfev):
return pure_root(f, a, b, xtol, maxfev, similarity="ratio",
scale="derived", guard="step")
METHODS = [("OURS (similarity)", ours), ("brent", brent),
("toms748", toms748), ("bisection", bisection)]
def traced(f):
"""Wrap f so the sequence of abscissae it is asked about is recorded."""
xs = []
def g(x):
xs.append(x)
return f(x)
return g, xs
def phases(method, f, a, b, root):
"""Split a run into approach and endgame evaluations."""
g, xs = traced(f)
try:
x, n, conv = method(g, a, b, XTOL, MAXFEV)
except Exception:
return None
if not conv or abs(x - root) > ACC:
return None
target = phase_boundary(abs(b - a), max(abs(a), abs(b)))
approach = len(xs)
for i, xi in enumerate(xs):
if abs(xi - root) <= target:
approach = i + 1
break
return approach, len(xs) - approach, len(xs)
def observed_order(method, f, a, b, root):
"""Order p from the tail of the running-best error sequence:
e_{n+1} ~ C e_n^p => p = log(e_{n+1}/e_n) / log(e_n/e_{n-1})."""
g, xs = traced(f)
try:
method(g, a, b, XTOL, MAXFEV)
except Exception:
return float("nan")
best, errs = float("inf"), []
for xi in xs:
e = abs(xi - root)
if e < best:
best = e
errs.append(e)
errs = [e for e in errs if e > 1e-14]
if len(errs) < 4:
return float("nan")
ps = []
for i in range(1, len(errs) - 1):
d1, d2 = errs[i] / errs[i - 1], errs[i + 1] / errs[i]
if 0 < d1 < 1 and 0 < d2 < 1:
ps.append(math.log(d2) / math.log(d1))
if not ps:
return float("nan")
return sorted(ps)[len(ps) // 2]
def replay_bracket(f, a, b, xs):
"""Reconstruct the bracket a bracketing method maintained, from the
sequence of abscissae it probed, and score every probe against it.
Returns (positions, shrinks, skipped) where
positions[i] where probe i fell inside the bracket, 0..1
(0.5 is the midpoint, i.e. a bisection step)
shrinks[i] the fraction of the bracket that probe i removed
skipped probes that fell outside the proven bracket and so
cannot be scored against it. Counting them matters:
dropping them silently would flatter any method that
probes outside, and scipy's toms748 does.
The reconstruction is exact for a bracketing method because the bracket
is determined by the signs of f, which are recomputed here.
"""
lo, hi = (a, b) if a < b else (b, a)
flo = f(lo)
positions, shrinks, skipped = [], [], 0
for xi in xs:
if not (lo < xi < hi):
skipped += 1
continue
w0 = hi - lo
positions.append((xi - lo) / w0)
fi = f(xi)
if _opposite(flo, fi):
hi = xi
else:
lo, flo = xi, fi
shrinks.append(1.0 - (hi - lo) / w0)
return positions, shrinks, skipped
def load():
out = []
for d in _APS_TESTS_DICTS:
a, b = d["bracket"]
args = d.get("args", ())
out.append(("APS", d["ID"], (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(("CHA", d["ID"], (lambda ff: lambda x: ff(x))(d["f"]),
float(a), float(b), float(d["root"])))
return out
def main():
probs = load()
# ------------------------------------------------------------------
# 1. approach vs endgame, summed over the problems every method solves
# ------------------------------------------------------------------
print("=" * 78)
print("1. WHERE THE EVALUATIONS GO (only problems all four methods solve)")
print("=" * 78)
for setname in ("APS", "CHA"):
rows = {}
common = []
for s, pid, f, a, b, root in probs:
if s != setname:
continue
r = {nm: phases(m, f, a, b, root) for nm, m in METHODS}
if all(v is not None for v in r.values()):
common.append((pid, abs(b - a), r))
for nm, _m in METHODS:
app = sum(c[2][nm][0] for c in common)
end = sum(c[2][nm][1] for c in common)
rows[nm] = (app, end, app + end)
print(f"\n{setname}: {len(common)} problems solved by all four")
print(f"{'method':22s}{'approach':>11s}{'endgame':>10s}{'total':>9s}"
f"{'approach %':>12s}")
print("-" * 64)
for nm, _m in METHODS:
app, end, tot = rows[nm]
print(f"{nm:22s}{app:>11d}{end:>10d}{tot:>9d}"
f"{100.0 * app / tot:>11.1f}%")
# ------------------------------------------------------------------
# 2. observed order of convergence
# ------------------------------------------------------------------
print("\n" + "=" * 78)
print("2. OBSERVED ORDER OF CONVERGENCE (median over solved problems)")
print("=" * 78)
print(f"{'method':22s}{'median order':>14s}{'problems':>11s} theory")
print("-" * 66)
theory = {"OURS (similarity)": "1.618 (secant step |f|/|f'|)",
"brent": "1.839 (inverse quadratic)",
"toms748": "up to 2.7 (cubic)",
"bisection": "1.0 (linear, rate 1/2)"}
for nm, m in METHODS:
ps = [observed_order(m, f, a, b, root) for _s, _pid, f, a, b, root in probs]
ps = [p for p in ps if math.isfinite(p) and 0.5 < p < 5.0]
med = sorted(ps)[len(ps) // 2] if ps else float("nan")
print(f"{nm:22s}{med:>14.3f}{len(ps):>11d} {theory[nm]}")
# ------------------------------------------------------------------
# 3. the prediction: our margin over brent grows with bracket width
# ------------------------------------------------------------------
print("\n" + "=" * 78)
print("3. MARGIN OVER BRENT BY BRACKET WIDTH (negative = we spend fewer)")
print("=" * 78)
buckets = [(0, 1e1), (1e1, 1e3), (1e3, 1e6), (1e6, 1e15)]
labels = ["width < 10", "10 - 1e3", "1e3 - 1e6", "> 1e6"]
print(f"{'bracket width':16s}{'problems':>10s}{'OURS':>9s}{'brent':>8s}"
f"{'diff':>8s}{'diff/problem':>14s}")
print("-" * 66)
for (lo, hi), lab in zip(buckets, labels):
n_ours = n_brent = cnt = 0
for _s, _pid, f, a, b, root in probs:
w = abs(b - a)
if not (lo <= w < hi):
continue
ro = phases(ours, f, a, b, root)
rb = phases(brent, f, a, b, root)
if ro is None or rb is None:
continue
cnt += 1
n_ours += ro[2]
n_brent += rb[2]
if cnt:
d = n_ours - n_brent
print(f"{lab:16s}{cnt:>10d}{n_ours:>9d}{n_brent:>8d}{d:>8d}"
f"{d / cnt:>14.2f}")
print("\nThe trend is not monotone, so bracket width is a correlate and "
"not the cause.\nSection 4 tests the mechanism directly.")
# ------------------------------------------------------------------
# 4. the mechanism: how often does each method's safeguard fire?
# An interpolation step that gets thrown away and replaced by a
# midpoint is a wasted evaluation. If our advantage is real, our
# safeguard must fire LESS often than Brent's exactly on the set
# where we win, and not much less where we lose.
# ------------------------------------------------------------------
print("\n" + "=" * 78)
print("4. HOW OFTEN THE SAFEGUARD THROWS AN INTERPOLATION AWAY")
print("=" * 78)
print(f"{'set':6s}{'method':22s}{'steps':>9s}{'forced to midpoint':>21s}"
f"{'share':>9s}")
print("-" * 68)
for setname in ("APS", "CHA"):
for nm in ("OURS (similarity)", "brent"):
steps = forced = 0
for s, _pid, f, a, b, root in probs:
if s != setname:
continue
st = {}
if nm == "brent":
x, n, conv = brent(f, a, b, XTOL, MAXFEV, stats=st)
fired = st.get("bisect", 0)
else:
x, n, conv = pure_root(f, a, b, XTOL, MAXFEV,
similarity="ratio", scale="derived",
guard="step", stats=st)
fired = st.get("forced", 0)
if not conv or abs(x - root) > ACC:
continue
steps += n - 2 # the two endpoint evaluations are free
forced += fired
share = 100.0 * forced / steps if steps else float("nan")
print(f"{setname:6s}{nm:22s}{steps:>9d}{forced:>21d}"
f"{share:>8.1f}%")
print("\nREFUTED: Brent throws away 53% of its interpolations on APS, "
"where it WINS,\nand only 34% on Chandrupatla, where it LOSES. "
"The safeguard rate is not the\nmechanism. Section 5 tests the "
"next candidate.")
# ------------------------------------------------------------------
# 5. how BOLD are the steps?
#
# Brent accepts an interpolation only if 2|p| < 3*xm*q, i.e. only if
# it lands within three quarters of the way to the far end; otherwise
# the midpoint is used. The similarity rule has no such cap - its step
# is |f|/|f'| and may cross almost the whole bracket in one go. On a
# wide bracket whose root sits far from the middle, that cap is the
# difference between arriving in one step and bisecting towards it.
#
# Measured as the position t of each probe inside the bracket it was
# proposed in, t = (x - lo)/(hi - lo). t near 0 or 1 is a bold step;
# t = 0.5 is bisection. The bracket is reconstructed from the sign of
# f at each probe, which is exact for a bracketing method.
# ------------------------------------------------------------------
print("\n" + "=" * 78)
print("5. HOW BOLD IS EACH STEP (position inside the bracket it was "
"proposed in)")
print("=" * 78)
print(f"{'set':6s}{'method':22s}{'probes':>9s}{'bold (t<.1 or t>.9)':>22s}"
f"{'share':>9s}")
print("-" * 70)
for setname in ("APS", "CHA"):
for nm, m in (("OURS (similarity)", ours), ("brent", brent)):
probes = bold = 0
for s, _pid, f, a, b, root in probs:
if s != setname:
continue
g, xs = traced(f)
try:
x, _n, conv = m(g, a, b, XTOL, MAXFEV)
except Exception:
continue
if not conv or abs(x - root) > ACC:
continue
positions, _shrinks, _skipped = replay_bracket(f, a, b, xs[2:])
probes += len(positions)
bold += sum(1 for t in positions if t < 0.1 or t > 0.9)
share = 100.0 * bold / probes if probes else float("nan")
print(f"{setname:6s}{nm:22s}{probes:>9d}{bold:>22d}{share:>8.1f}%")
print("\nREFUTED: on CHA Brent is BOLDER than we are (56.3% vs 41.8%) and "
"still loses.\nBoldness is not the mechanism either. Section 6 "
"measures step QUALITY rather\nthan step size.")
# ------------------------------------------------------------------
# 6. step quality: how much of the bracket does one probe remove?
#
# Boldness is how far a probe goes; quality is whether it went the
# right way. A probe at t = 0.95 that lands on the wrong side of the
# root removes 5% of the bracket; the same probe on the right side
# removes 95%. This is the one quantity that must differ if a method
# with a LOWER order of convergence nevertheless needs fewer probes.
# ------------------------------------------------------------------
print("\n" + "=" * 78)
print("6. STEP QUALITY (fraction of the bracket removed per probe)")
print("=" * 78)
print(f"{'set':6s}{'method':22s}{'probes':>9s}{'median removed':>17s}"
f"{'mean removed':>15s}{'probes skipped':>16s}")
print("-" * 86)
for setname in ("APS", "CHA"):
for nm, m in (("OURS (similarity)", ours), ("brent", brent),
("toms748", toms748), ("bisection", bisection)):
shrinks, skipped = [], 0
for s, _pid, f, a, b, root in probs:
if s != setname:
continue
g, xs = traced(f)
try:
x, _n, conv = m(g, a, b, XTOL, MAXFEV)
except Exception:
continue
if not conv or abs(x - root) > ACC:
continue
_pos, sh, sk = replay_bracket(f, a, b, xs[2:])
shrinks.extend(sh)
skipped += sk
if shrinks:
srt = sorted(shrinks)
med = srt[len(srt) // 2]
print(f"{setname:6s}{nm:22s}{len(shrinks):>9d}{med:>17.3f}"
f"{sum(shrinks) / len(shrinks):>15.3f}{skipped:>16d}")
print("\nBisection removes exactly 0.500 by construction and is the "
"reference row.")
if __name__ == "__main__":
main()