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

240 lines
10 KiB
Python

"""SIM root finding - the similarity-induced step, in the formulation of
"Parameter-Free Similarity Control" (Sitnikov).
Mapping of the article's machinery onto root finding
----------------------------------------------------
article here
------- ----
input x the target value y* = 0 ("the expectation")
prototype c_k an evaluated value f(x_k) (value space)
prototype memory y_k the abscissa x_k that produced it
similarity s(x, c_k) similarity of f(x_k) to zero
softmax weights w_k eq. (2)
prediction sum w_k y_k the root estimate, eq. (3)
So a root finder IS the article's controller run backwards: it is asked "which
x is associated with the outcome 0?" and answers by normalized similarity
aggregation over its memory of evaluated points.
Two levels of prototype are implemented:
POINT prototypes x_hat = sum_k w_k x_k
Direct transcription of eq. (3). Exactness note: on a linear f this
recovers the root exactly only for the inverse-distance similarity;
every other similarity function biases it. Measured, not assumed.
PAIR prototypes x_hat = sum_ij w_ij r_ij , r_ij = secant root of (i,j)
Each bracketing PAIR of memory points is a local rule ("if the outcome
changes sign between x_i and x_j then the root is near r_ij"), exactly
parallel to a fuzzy rule table entry. This is the level at which the
method departs from anything classical: regula falsi is the special
case of a single pair, and the stale-endpoint failure of regula falsi
disappears because a pair containing a large-|f| stale endpoint is by
construction dissimilar to the target and is down-weighted.
Parameter freedom
-----------------
Similarity needs a scale in value space. It is taken from the data in play -
the largest |f| currently in memory - never from a chosen constant. There is
no sigma, no temperature, no threshold, no tuned coefficient anywhere below.
"""
from __future__ import annotations
from .baselines import _opposite, _same_sign # 2026-09-21 fix
import math
from .baselines import Counter, _Budget
EPS = 2.220446049250313e-16
# --------------------------------------------------------------------------
# similarity functions on the value axis, all taking already-normalised
# residuals z = f / scale and returning a similarity in a comparable range
# --------------------------------------------------------------------------
def sim_neg_sq(z):
"""Negative squared Euclidean distance to the target, then softmax.
This is the article's default (eq. 6) and its benchmark choice."""
return -z * z
def sim_neg_abs(z):
"""Negative distance to the target, then softmax."""
return -abs(z)
def sim_inv(z):
"""Inverse distance. Returned as a log-similarity so the caller's softmax
reproduces plain inverse-distance weights. This is the unique member of
the family that is exact on a linear f (see module docstring)."""
return -math.log(abs(z) + EPS)
def sim_inv_sq(z):
return -2.0 * math.log(abs(z) + EPS)
def sim_cosine(z):
"""Cosine similarity between the 2-vectors (1, f_k) and (1, 0), i.e. the
article's eq. (4) applied to the value axis lifted to 2-D."""
return 1.0 / math.sqrt(1.0 + z * z)
SIMILARITIES = {
"neg_sq": sim_neg_sq,
"neg_abs": sim_neg_abs,
"inv": sim_inv,
"inv_sq": sim_inv_sq,
"cosine": sim_cosine,
}
def _softmax(scores):
m = max(scores)
ex = [math.exp(s - m) for s in scores]
tot = sum(ex)
if tot == 0.0 or not math.isfinite(tot):
n = len(ex)
return [1.0 / n] * n
return [e / tot for e in ex]
# --------------------------------------------------------------------------
# SIM root finder
# --------------------------------------------------------------------------
def sim_root(f, a, b, xtol, maxfev, similarity="neg_sq", prototypes="pair",
memory=None, guard=False):
"""Similarity-induced bracketed root finder.
similarity : key into SIMILARITIES
prototypes : "point" - eq. (3) verbatim, prototypes are evaluated points
"pair" - rule-level prototypes, similarity on the value axis
"pair2d" - rule-level prototypes, similarity on the value axis
AND the abscissa axis (support tightness)
memory : None = unbounded memory of every evaluated point;
an int k = keep only the k most recent points.
Unbounded is the default; the truncation is an ablation knob,
not a tuned parameter.
guard : enable the parameter-free safeguard. A similarity step is
taken only while the bracket keeps halving; the moment a
whole halving has failed to happen, one bisection step is
forced. The only constant involved is 1/2, which is the
definition of bisection rather than a tuned value, and it
bounds the worst case at twice bisection. Compare: Brent,
Ridders, TOMS748 and ITP all need a safeguard here too, and
ITP's carries three tuning constants (kappa1, kappa2, n0).
"""
sfun = SIMILARITIES[similarity]
two_d = prototypes == "pair2d"
g = Counter(f, maxfev)
x = 0.5 * (a + 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
# memory of every evaluated point
mem = [(a, fa), (b, fb)]
guard_width = abs(b - a) # width at the last acknowledged halving
pending = False # a similarity step has been tried since then
while abs(b - a) > xtol:
lo, hi = (a, b) if a < b else (b, a)
width = hi - lo
if guard:
if width <= 0.5 * guard_width:
guard_width = width # the halving happened; nothing owed
pending = False
elif pending:
# a similarity step was already spent without halving the
# bracket -> pay it back with one bisection step
x = 0.5 * (a + b)
fx = g(x)
mem.append((x, fx))
pending = False
if fx == 0.0:
return x, g.n, True
if _opposite(fa, fx):
b, fb = x, fx
else:
a, fa = x, fx
continue
pending = True
# the memory is the WHOLE evaluation history, including points that
# have fallen outside the current bracket: a stale point is not
# deleted, it is merely dissimilar to the target and therefore
# carries little weight. (Restricting the memory to the bracket
# leaves exactly the two endpoints and collapses the method onto
# regula falsi - verified in stage 1.)
live = mem if memory is None else mem[-memory:]
scale = max(abs(fi) for (_, fi) in live)
if scale == 0.0:
return live[0][0], g.n, True
if prototypes == "point":
scores = [sfun(fi / scale) for (_, fi) in live]
w = _softmax(scores)
x_new = sum(wi * xi for wi, (xi, _) in zip(w, live))
else: # pair prototypes
cand, scores = [], []
for i in range(len(live)):
xi, fi = live[i]
for j in range(i + 1, len(live)):
xj, fj = live[j]
if fj == fi:
continue
r = (xi * fj - xj * fi) / (fj - fi)
if not math.isfinite(r) or not (lo < r < hi):
# a rule whose conclusion falls outside the proven
# bracket is inadmissible, not merely unlikely
continue
cand.append(r)
# a pair is similar to the target when BOTH of its
# endpoints produced outcomes similar to the target
sc = sfun(fi / scale) + sfun(fj / scale)
if two_d:
# ...and when its support is tight. Without this
# term a flat tail (|f| ~ 1e-16 at 39 units from
# the root) looks maximally similar to the target
# and the method stalls there - measured, not
# assumed. Both axes are normalised by the data
# in play (largest |f| in memory; current bracket
# width), exactly as the article normalises its
# input space so that each axis contributes
# equally.
sc += sfun(abs(xi - xj) / width)
scores.append(sc)
if not cand:
x_new = 0.5 * (a + b)
else:
w = _softmax(scores)
x_new = sum(wi * ri for wi, ri in zip(w, cand))
if not math.isfinite(x_new) or not (lo < x_new < hi):
x_new = 0.5 * (a + b)
# never propose a point already in memory (would waste the budget)
if any(x_new == xi for (xi, _) in mem):
x_new = 0.5 * (a + b)
if any(x_new == xi for (xi, _) in mem):
# the bracket can no longer be split in floating point.
# Report success only if it is actually inside the
# tolerance; otherwise this is stagnation and must be
# reported as a failure, not counted as a win.
return x_new, g.n, abs(b - a) <= xtol
x = x_new
fx = g(x)
mem.append((x, fx))
if fx == 0.0:
return x, g.n, True
if _opposite(fa, fx):
b, fb = x, fx
else:
a, fa = x, fx
return 0.5 * (a + b), g.n, True
except _Budget:
return x, g.n, False