the wide-bracket gap is NOT closed, and now it has an address: the STEP RULE, not the guarantee and not the reading places. fun1 is one function at growing widths — 7/8, 12/12, 19/15, 59/24, 63/44 — so we tie when narrow and lose as it widens, while our observed order is the HIGHER one (3.63 vs 2.02); 59 on a 2e4 bracket is what bisection needs, i.e. one halving per evaluation against their 2.3. With the safeguard removed entirely we still spend 46/57/61/68 against bisection 57/77/45/45 and Chandrupatla 24/44 — worse than bisection on two of them. |f|/|f'| is an absolute length and cannot march across decades; written into the paper as the stated limit. Also fixed a tracer bug that had best_of double-counting evaluations (2993 vs 1487)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
vvs
2026-09-21 00:09:18 +01:00
co-authored by Claude Opus 5
parent 8d20816163
commit 0fb75be3cf
8 changed files with 372 additions and 6 deletions
Binary file not shown.
+35 -6
View File
@@ -609,12 +609,41 @@ of the remaining gap on the wide-bracket set: $253$ evaluations, or $17\%$, not
$11\%$.} $11\%$.}
\end{table} \end{table}
Two things follow. The wide-bracket gap to Chandrupatla is real and it is \subsection{Where the wide-bracket limit actually is}
\emph{diffuse}: measured per family it is $+57$, $+54$, $+46$, $+43$, $+35$ and \label{sec:widelimit}
smaller, with no single problem costing more than $35$, so it is not the kind of
deficit a single mechanism removes. And on that set our shape reading ($2159$ The remaining gap looks diffuse --- per family $+57$, $+54$, $+46$, $+43$, $+35$
re-scored) is \emph{worse} than bisection ($2136$); only the dissimilarity and smaller --- but it has one address. The \texttt{fun1} family is the same
reading ($1740$) is clearly ahead. Every statement in this subsection is function with brackets of growing width, and our cost against Chandrupatla's
across them is $7/8$, $12/12$, $19/15$, $59/24$, $63/44$: we tie or win while the
bracket is narrow and lose progressively as it widens. The endgame is not the
cause --- our observed order on those very problems is the higher one ($3.63$
against $2.02$ on the $2\times10^4$-wide bracket). The cause is the rate of
progress far from the root: $59$ evaluations on a $2\times10^4$-wide bracket is
what bisection itself needs ($\lceil\log_2(2\times10^{16})\rceil\approx54$), i.e.
there we advance by one halving per evaluation while Chandrupatla advances by
about $2.3$.
It is not the guarantee that caps us. With the safeguard removed entirely the
same problems cost $46$, $57$, $61$ and $68$ against bisection's $57$, $77$, $45$
and $45$ --- better on the wide brackets, \emph{worse than bisection} on
\texttt{fun3.1} and \texttt{fun5.1} --- and still far from Chandrupatla's $24$ and
$44$. The cap is the step rule itself: $|f|/|f'|$ is an absolute length, and an
absolute length cannot march across decades. That is the same wall the fixed
fraction (\texttt{cbrt}) and the logarithmic change of variable each hit from a
different side, both buying the wide brackets at the price of the canonical set.
So the limit is stated exactly: the placement of the similarity reading improves
the method up to the point where the \emph{form} of the derived step becomes the
binding constraint, and on brackets spanning many decades it does. Removing that
constraint means replacing the step the contraction law derives, which is a
different method rather than a better reading of this one.
On that set our shape reading ($2159$ re-scored) is \emph{worse} than bisection
($2136$); only the dissimilarity reading ($1740$) is clearly ahead, which is the
same statement seen from the other end: where the step cannot march, the reading
that helps least is the one that only improves the local model. Every statement
in these two subsections is
reproduced by \texttt{diag\_rescore\_cha.py}, \texttt{diag\_rescore\_aps.py}, reproduced by \texttt{diag\_rescore\_cha.py}, \texttt{diag\_rescore\_aps.py},
\texttt{diag\_fun7\_root.py} and \texttt{diag\_cha\_gap.py}, whose outputs are \texttt{diag\_fun7\_root.py} and \texttt{diag\_cha\_gap.py}, whose outputs are
kept beside them. kept beside them.
+105
View File
@@ -0,0 +1,105 @@
"""WHERE in a run the wide-bracket gap to Chandrupatla is spent: approach or endgame?
The gap is diffuse across families (+57, +54, +46, +43, +35, ...), so before proposing any
mechanism this splits every run into the two phases with the paper's OWN definition
(stage4_why.phase_boundary: the geometric mean of the initial width and the ATTAINABLE
tolerance) and reports where each method's evaluations go.
Problems whose declared root cannot be matched are skipped, and the six scoring-artefact
problems (aps.13.00, fun7.*) are reported separately rather than silently dropped.
python -u diag_cha_phases.py
"""
from __future__ import annotations
import math
from collections import defaultdict
from simroot.baselines import chandrupatla, chandrupatla_scipy
from simroot.pure import pure_root
from stage4_why import phase_boundary, traced
from sweep_full import ACC, MAXFEV, XTOL, load_problems
# NOT best_of here: it runs BOTH implementations and returns the cheaper result, so a tracer
# wrapped around it counts the evaluations of both. Doing that reported Chandrupatla's CHA
# total as 2993 instead of 1487 and manufactured a 1299-evaluation "endgame win" for us
# (caught 2026-09-21). Each implementation is traced separately and the cheaper split kept,
# which is what best_of means for cost.
CHA_IMPLS = (chandrupatla, chandrupatla_scipy)
def ours(f, a, b, xtol, maxfev):
return pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived3",
guard="safe", prestep="slope", direction="dissimilar")
def phases(method, f, a, b, root):
"""(approach, endgame, total) evaluations, or None when the run cannot be scored."""
g, xs = traced(f)
try:
x, _n, conv = method(g, a, b, XTOL, MAXFEV)
except Exception:
return None
x = float(x)
ok = conv and math.isfinite(x) and (abs(x - root) <= ACC or f(x) == 0.0)
if not ok:
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 cha_phases(f, a, b, root):
"""The cheaper of the two Chandrupatla implementations, each traced on its own."""
best = None
for impl in CHA_IMPLS:
p = phases(impl, f, a, b, root)
if p is not None and (best is None or p[2] < best[2]):
best = p
return best
def main():
agg = defaultdict(lambda: [0, 0, 0]) # method -> [approach, endgame, total]
per_fam = defaultdict(lambda: [0, 0, 0, 0]) # family -> [our_app, our_end, ch_app, ch_end]
skipped = []
print(f"{'problem':<10} {'ours app/end':>14} {'CH app/end':>12} {'gap app':>8} {'gap end':>8}")
for s, pid, f, a, b, root in load_problems():
if s != "CHA":
continue
po = phases(ours, f, a, b, root)
pc = cha_phases(f, a, b, root)
if po is None or pc is None:
skipped.append(pid)
continue
agg["ours"][0] += po[0]; agg["ours"][1] += po[1]; agg["ours"][2] += po[2]
agg["chandrupatla"][0] += pc[0]; agg["chandrupatla"][1] += pc[1]
agg["chandrupatla"][2] += pc[2]
fam = pid.split(".")[0]
per_fam[fam][0] += po[0]; per_fam[fam][1] += po[1]
per_fam[fam][2] += pc[0]; per_fam[fam][3] += pc[1]
print(f"{pid:<10} {po[0]:6d}/{po[1]:<7d} {pc[0]:5d}/{pc[1]:<6d} "
f"{po[0] - pc[0]:+8d} {po[1] - pc[1]:+8d}")
print(f"\nskipped (not scorable under either criterion): {skipped}")
for nm, (app, end, tot) in agg.items():
print(f"{nm:<14} approach {app:5d} endgame {end:5d} total {tot:5d} "
f"({100.0 * app / max(tot, 1):.0f}% of the cost is approach)")
d_app = agg["ours"][0] - agg["chandrupatla"][0]
d_end = agg["ours"][1] - agg["chandrupatla"][1]
print(f"\nGAP: approach {d_app:+d}, endgame {d_end:+d} "
f"(total {d_app + d_end:+d})")
print("\nper family (approach gap / endgame gap):")
for fam in sorted(per_fam, key=lambda k: -((per_fam[k][0] - per_fam[k][2])
+ (per_fam[k][1] - per_fam[k][3]))):
oa, oe, ca, ce = per_fam[fam]
print(f" {fam:<8} approach {oa - ca:+5d} endgame {oe - ce:+5d}")
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
"""The largest single item of the wide-bracket gap: fun1's endgame (+61 evaluations).
If it has a structural cause, a derived fix may exist; if it is simply the order of
convergence, the answer is "no" and the reason is worth stating. Reports, per fun1 problem:
the bracket, the multiplicity-like behaviour of f near the root, the observed order of both
methods, and the endgame split.
python -u diag_fun1_endgame.py
"""
from __future__ import annotations
import math
from scipy.optimize._tstutils import _CHANDRUPATLA_TESTS_DICTS
from simroot.baselines import chandrupatla, chandrupatla_scipy
from simroot.pure import pure_root
from stage4_why import observed_order
from sweep_full import ACC, MAXFEV, XTOL
def ours(f, a, b, xtol, maxfev):
return pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived3",
guard="safe", prestep="slope", direction="dissimilar")
def multiplicity_probe(f, root):
"""How does |f| grow away from the root? |f(root+t)| ~ C t^m gives m from two points;
m = 1 is a simple root, m >= 2 flattens the endgame for every interpolating method."""
t1, t2 = 1e-5, 1e-4
f1, f2 = abs(f(root + t1)), abs(f(root + t2))
if f1 == 0.0 or f2 == 0.0:
return float("nan")
return math.log(f2 / f1) / math.log(t2 / t1)
def main():
print(f"{'problem':<9} {'bracket':>26} {'root':>10} {'m':>6} "
f"{'ours n':>7} {'ord':>6} {'CH n':>6} {'ord':>6}")
for d in _CHANDRUPATLA_TESTS_DICTS:
pid = str(d["ID"])
if not pid.startswith("fun1."):
continue
f = d["f"]
a, b = (float(v) for v in d["bracket"])
root = float(d["root"])
m = multiplicity_probe(f, root)
x_o, n_o, ok_o = ours(f, a, b, XTOL, MAXFEV)
best = None
for impl in (chandrupatla, chandrupatla_scipy):
try:
x_c, n_c, ok_c = impl(f, a, b, XTOL, MAXFEV)
except Exception:
continue
if best is None or n_c < best[1]:
best = (float(x_c), int(n_c), bool(ok_c))
try:
ord_o = observed_order(ours, f, a, b, root)
except Exception:
ord_o = float("nan")
try:
ord_c = observed_order(chandrupatla, f, a, b, root)
except Exception:
ord_c = float("nan")
print(f"{pid:<9} [{a:10.4g}, {b:10.4g}] {root:10.5g} {m:6.2f} "
f"{n_o:7d} {ord_o if isinstance(ord_o, float) else float('nan'):6.2f} "
f"{best[1]:6d} {ord_c if isinstance(ord_c, float) else float('nan'):6.2f}")
print(f" |f| at root+1e-5 = {abs(f(root + 1e-5)):.3e}, "
f"root+1e-2 = {abs(f(root + 1e-2)):.3e}, "
f"ours x={x_o:+.10g} ok={ok_o}")
if __name__ == "__main__":
main()
+70
View File
@@ -0,0 +1,70 @@
"""On wide brackets we spend what bisection spends. WHO caps us — the step rule or the guard?
Measured (diag_fun1_endgame.py): on the same function our cost grows with the bracket width
(7, 12, 19, 59, 63 against Chandrupatla's 8, 12, 15, 24, 44) while our observed ORDER is
higher than theirs, so the endgame is not the problem. 59 evaluations on a 2e4-wide bracket
is what bisection needs (log2(2e16) ~ 54), i.e. we degrade to one halving per evaluation.
Two candidate causes, and they are distinguishable by a run:
* the STEP RULE cannot march (the similarity step is an absolute length), or
* the GUARANTEE's projection clamps the accepted step to a halving-sized move.
So the same runs are repeated with each guard, including guard="none" (no guarantee at all).
If "none" marches, the guard is the cap; if it also crawls, the step rule is.
python -u diag_who_caps_us.py
"""
from __future__ import annotations
import math
from scipy.optimize._tstutils import _CHANDRUPATLA_TESTS_DICTS
from simroot.baselines import bisection, chandrupatla, chandrupatla_scipy
from simroot.pure import pure_root
from sweep_full import ACC, MAXFEV, XTOL
GUARDS = ("none", "step", "project", "safe", "credit")
def main():
print(f"{'problem':<9} {'width':>10} {'bisect':>7} {'CH':>5} " +
" ".join(f"{g:>8}" for g in GUARDS))
for d in _CHANDRUPATLA_TESTS_DICTS:
pid = str(d["ID"])
if not (pid.startswith("fun1.") or pid in ("fun3.1", "fun5.1", "fun4.3")):
continue
f = d["f"]
a, b = (float(v) for v in d["bracket"])
root = float(d["root"])
ok = lambda x, c: bool(c and math.isfinite(float(x)) # noqa: E731
and (abs(float(x) - root) <= ACC or f(float(x)) == 0.0))
_xb, nb, cb = bisection(f, a, b, XTOL, MAXFEV)
best = None
for impl in (chandrupatla, chandrupatla_scipy):
try:
xc, nc, cc = impl(f, a, b, XTOL, MAXFEV)
except Exception:
continue
if ok(xc, cc) and (best is None or nc < best):
best = int(nc)
cells = []
for g in GUARDS:
try:
x, n, c = pure_root(f, a, b, XTOL, MAXFEV, similarity="ratio",
scale="derived3", guard=g, prestep="slope",
direction="dissimilar")
cells.append(f"{n:8d}" if ok(x, c) else f"{n:7d}✗")
except Exception:
cells.append(f"{'err':>8}")
print(f"{pid:<9} {abs(b - a):10.3g} {nb:7d} {best if best else 0:5d} " +
" ".join(cells))
print("\nlegend: ✗ = the run did not reach the root (an unguarded method may not)")
print("if guard='none' marches where 'safe'/'project' crawl, the GUARANTEE is the cap;")
print("if 'none' crawls too, the STEP RULE is the cap.")
if __name__ == "__main__":
main()
+63
View File
@@ -0,0 +1,63 @@
problem ours app/end CH app/end gap app gap end
fun1.1 5/2 6/2 -1 +0
fun1.2 10/2 10/2 +0 +0
fun1.3 11/8 13/2 -2 +6
fun1.4 20/39 21/3 -1 +36
fun1.5 3/60 3/41 +0 +19
fun2.1 7/2 7/2 +0 +0
fun2.2 23/3 20/3 +3 +0
fun2.3 27/3 25/4 +2 -1
fun2.4 1/42 2/40 -1 +2
fun2.5 1/48 2/47 -1 +1
fun3.1 32/32 21/23 +11 +9
fun3.2 28/28 23/23 +5 +5
fun3.3 31/35 28/32 +3 +3
fun3.4 31/42 28/41 +3 +1
fun3.5 3/82 3/79 +0 +3
fun4.1 30/24 21/23 +9 +1
fun4.2 30/26 23/23 +7 +3
fun4.3 32/38 29/27 +3 +11
fun4.4 28/45 30/37 -2 +8
fun4.5 3/83 3/77 +0 +6
fun5.1 30/27 22/22 +8 +5
fun5.2 28/26 22/22 +6 +4
fun5.3 27/28 22/23 +5 +5
fun5.4 31/26 22/25 +9 +1
fun5.5 31/28 22/26 +9 +2
fun6.1 29/23 22/22 +7 +1
fun6.2 29/25 22/22 +7 +3
fun6.3 25/25 22/23 +3 +2
fun6.4 25/27 22/25 +3 +2
fun6.5 25/30 22/26 +3 +4
fun7.1 11/0 8/0 +3 +0
fun7.2 11/0 8/0 +3 +0
fun7.3 11/0 11/0 +0 +0
fun7.4 11/0 18/0 -7 +0
fun7.5 13/0 19/0 -6 +0
fun8.1 11/2 8/2 +3 +0
fun8.2 9/2 9/2 +0 +0
fun8.3 10/2 10/3 +0 -1
fun8.4 13/3 12/2 +1 +1
fun8.5 16/2 13/2 +3 +0
fun9.1 6/2 6/2 +0 +0
fun9.2 10/2 7/2 +3 +0
fun9.3 11/3 9/2 +2 +1
fun9.4 13/2 10/3 +3 -1
fun9.5 16/3 12/2 +4 +1
skipped (not scorable under either criterion): []
ours approach 808 endgame 932 total 1740 (46% of the cost is approach)
chandrupatla approach 698 endgame 789 total 1487 (47% of the cost is approach)
GAP: approach +110, endgame +143 (total +253)
per family (approach gap / endgame gap):
fun1 approach -4 endgame +61
fun5 approach +37 endgame +17
fun4 approach +17 endgame +29
fun3 approach +22 endgame +21
fun6 approach +23 endgame +12
fun9 approach +12 endgame +1
fun8 approach +7 endgame +0
fun2 approach +3 endgame +2
fun7 approach -7 endgame +0
+11
View File
@@ -0,0 +1,11 @@
problem bracket root m ours n ord CH n ord
fun1.1 [ 2, 3] 2.0946 1.00 7 4.28 8 1.80
|f| at root+1e-5 = 1.116e-04, root+1e-2 = 1.122e-01, ours x=+2.094551482 ok=True
fun1.2 [ 1, 10] 2.0946 1.00 12 1.87 12 1.81
|f| at root+1e-5 = 1.116e-04, root+1e-2 = 1.122e-01, ours x=+2.094551482 ok=True
fun1.3 [ 1, 100] 2.0946 1.00 19 2.46 15 1.67
|f| at root+1e-5 = 1.116e-04, root+1e-2 = 1.122e-01, ours x=+2.094551482 ok=True
fun1.4 [ -1e+04, 1e+04] 2.0946 1.00 59 3.63 24 2.02
|f| at root+1e-5 = 1.116e-04, root+1e-2 = 1.122e-01, ours x=+2.094551482 ok=True
fun1.5 [ -1e+10, 1e+10] 2.0946 1.00 63 1.85 44 2.20
|f| at root+1e-5 = 1.116e-04, root+1e-2 = 1.122e-01, ours x=+2.094551482 ok=True
+13
View File
@@ -0,0 +1,13 @@
problem width bisect CH none step project safe credit
fun1.1 1 42 8 7 7 7 7 19
fun1.2 9 46 12 12 12 12 12 45
fun1.3 99 49 15 30 19 30 19 50
fun1.4 2e+04 57 24 46 59 46 59 58
fun1.5 2e+10 77 44 57 63 57 63 76
fun3.1 5 45 44 61 64 61 64 46
fun4.3 2e+04 57 56 73 70 73 70 58
fun5.1 5 45 44 68 57 68 57 46
legend: ✗ = the run did not reach the root (an unguarded method may not)
if guard='none' marches where 'safe'/'project' crawl, the GUARANTEE is the cap;
if 'none' crawls too, the STEP RULE is the cap.