pre-step gate: prestep='none'|'progress' (default bit-identical), refuse the similarity step BEFORE spending an evaluation when it promises less progress than bisection — two lengths already in hand, no new constant; R1-R4 pinned by tests/test_prestep_gate.py (6 green); the gate grid writes to its OWN sweep_gate.json

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
vvs
2026-09-20 23:12:34 +01:00
co-authored by Claude Opus 5
parent be10fc0b22
commit 0617243ed4
5 changed files with 215 additions and 10 deletions
+69
View File
@@ -0,0 +1,69 @@
# PREREG — предшаговый гейт: похожесть как ОТКАЗ до шага, а не масштаб внутри шага
Составлено до реализации. Основание — измерение `explain_chandrupatla.py` (лог
`results_explain_chandrupatla.txt`, разбор в `FINDINGS_logarithmic.md` §3): Chandrupatla принимает
свою модель лишь в **28 %** шагов, его гейт стоит **ноль** вызовов, а ветвь отказа снимает половину
скобки; наш сторож ремонтирует **77.5 %** вызовов, и на `fun8`/`fun9` мы опускаемся до **0.92**
половинок на вызов — хуже деления пополам. Качество пробы: **2.310** против **1.422**.
**Гипотеза:** дело не в форме функции похожести, а в **месте её чтения**. Похожесть, прочитанная ДО
шага и решающая «доверять ли модели здесь», стоит ноль; прочитанная ВНУТРИ шага как масштаб — требует
сторожа, а сторож платит вызовом.
---
## 1. Что именно добавляется (и почему это не новая константа)
**Важно, что наша причина ремонтов ДРУГАЯ, чем у Chandrupatla.** Наш шаг
`(1 − S(|f|/σ))·(far − x)` при `0 ≤ S ≤ 1` **никогда не выходит за скобку** — то есть перескок нам не
грозит; сторож вмешивается, когда шаг слишком **короткий** и прогресс мал. Значит и гейт должен
спрашивать не «не выйдет ли проба за скобку» (вопрос Chandrupatla для его модели), а:
> **обещает ли этот шаг прогресс не хуже, чем деление пополам?**
Обе длины известны ДО вызова `f`: длина шага `|step|` и длина шага в середину `|½(A+B) − x|`.
Сравнение двух имеющихся длин — не константа и не подгонка; порога нет, есть неравенство.
**Режим:** `prestep="none"` (по умолчанию — опубликованное поведение побитово) и
`prestep="progress"` (гейт включён). Если гейт отказал, шаг **заменяется** на деление пополам, то есть
тот же один вызов `f` тратится на гарантированное снятие половины скобки.
**Чего НЕ делаем:** не переносим полосу `1−√(1−ξ) < φ < √ξ` в наш метод. Она выведена для обратной
квадратичной интерполяции из условия «проба не выйдет за скобку»; у нашего шага этого условия нет, и
взятая напрокат область была бы скрытой константой чужого вывода.
## 2. Предсказания и фальсификаторы
| # | Предсказание | ФАЛЬСИФИКАТОР → вывод |
|---|---|---|
| **P1** | доля ремонтов на CHA падает с 77.5 % до **≤40 %** | >60 % → гейт не бьёт в механизм, объяснение §3 неверно как рецепт |
| **P2** | цена на CHA падает ниже нашей текущей **2073** (та же конфигурация `ratio.derived3.credit`) | не падает → отказ дешевле ремонта не оказался, гипотеза о месте чтения не подтверждена |
| **P3** | цена на APS-154 **не растёт** (по-наборный лучший остаётся ≤ **2806**) | растёт → тот же трейд, что убил пять предыдущих попыток; гейт отвергается |
| **P4** | гарантия `2⌈log₂(w/xtol)⌉+3` не нарушена: `over2x = 0` во всех конфигурациях | нарушена → закрытие немедленно |
| **P5** | качество пробы на CHA растёт с 1.422 к уровню **≥2.0** половинок на вызов | <1.7 → прирост есть, но не тот порядок; заявлять «как Chandrupatla» нельзя |
**Правило остановки:** P3 или P4 ложны ⇒ режим не принимается вовсе. P1 и P2 ложны ⇒ объяснение
остаётся верным как **описание** Chandrupatla, но как **рецепт** для нашего метода — опровергнуто, и
это пишется прямо.
## 3. Протокол (тот же, что у статьи)
154 задачи Alefeld–Potra–Shi + 45 Чандрупатлы; цена — вызовы `f`; провал списывается полным бюджетом;
граница `2×` проверяется на каждом решённом прогоне; конфигурация фиксируется выводом ДО того, как
смотрят в рейтинг. Числа только с **Linux**. Свип пишется в свой файл (`sweep_gate.json`), чтобы
`sweep_full.json`, против которого сверяется статья, остался нетронутым.
Сравнение ведётся **по-наборно** и **внутри семейства сторожей, несущих гарантию** — иначе сравниваются
разные конфигурации (эта ошибка уже допущена 2026-09-20 и исправлена).
## 4. Гейты реализации (тесты ДО рейтинга)
| # | Условие | Проверка |
|---|---|---|
| **R1** | `prestep="none"` даёт побитово прежний результат | тест на равенство кортежей возврата на наборе задач |
| **R2** | в гейте нет ни одной выбранной константы | грепом по исходнику: только `0.0`, `0.5` (середина скобки — определение деления пополам), `1.0` |
| **R3** | отказ гейта не стоит вызова `f` | тест: число вызовов при включённом гейте не превышает число шагов + 2 |
| **R4** | счётчик `stats['pre_refused']` считает отказы гейта отдельно от ремонтов сторожа | тест на обоих счётчиках |
**Дисциплина:** аудит после правки и до запуска; смоук ×2 локально; тяжёлое — на сервер
`setsid nohup` с логом; замороженная копия скрипта; числа показываются немедленно.
+25 -6
View File
@@ -413,7 +413,7 @@ def _local_order(x2, f2, x1, f1, x0, f0):
def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived", def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived",
guard="step", stats=None, budget_factor=2.0, guard="step", stats=None, budget_factor=2.0,
budget_extra=None, endowment=1.0, space="x"): budget_extra=None, endowment=1.0, space="x", prestep="none"):
"""The pure similarity root finder. """The pure similarity root finder.
similarity : key into PURE_SIMS similarity : key into PURE_SIMS
@@ -467,6 +467,8 @@ def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived",
raise ValueError(f"unknown scale {scale!r}") raise ValueError(f"unknown scale {scale!r}")
if space not in SPACES: if space not in SPACES:
raise ValueError(f"unknown space {space!r}") raise ValueError(f"unknown space {space!r}")
if prestep not in ("none", "progress"):
raise ValueError(f"unknown prestep {prestep!r}")
if space == "log" and scale not in ("derived", "derived3"): if space == "log" and scale not in ("derived", "derived3"):
# u-space needs a slope; with a chosen or bracket scale there is none to # u-space needs a slope; with a chosen or bracket scale there is none to
# transform, and computing something anyway would hide that (PREREG §3a). # transform, and computing something anyway would hide that (PREREG §3a).
@@ -494,8 +496,10 @@ def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived",
sg = _Safeguard(guard, abs(B - A), xtol, budget_factor, budget_extra) sg = _Safeguard(guard, abs(B - A), xtol, budget_factor, budget_extra)
sg.credit = endowment sg.credit = endowment
forced = 0 forced = 0
pre_refused = 0
if stats is not None: if stats is not None:
stats['forced'] = 0 stats['forced'] = 0
stats['pre_refused'] = 0
while abs(B - A) > xtol: while abs(B - A) > xtol:
if sg.bisect_before_step(abs(B - A)): if sg.bisect_before_step(abs(B - A)):
@@ -592,7 +596,22 @@ def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived",
x_new = 0.5 * (A + B) x_new = 0.5 * (A + B)
else: else:
step = None step = None
if space == "log": if prestep == "progress":
# PRE-STEP GATE (PREREG 2026-09-21). Measured cause of our cost:
# the safeguard REPAIRS 77.5% of evaluations after the fact, while
# Chandrupatla REFUSES its model on 72% of steps for free and
# halves instead - 2.310 halvings per evaluation against our 1.422.
# Our step never leaves the bracket (0 <= S <= 1), so the question
# is not overshoot but PROGRESS: if the similarity step promises
# less than bisection would remove, spend the same single
# evaluation on the midpoint instead. Two lengths already in hand
# are compared; there is no threshold and no new constant.
cand = (1.0 - S(abs(fx) / sc)) * (far - x)
half = 0.5 * (A + B) - x
if abs(cand) < abs(half):
step = half
pre_refused += 1
if step is None and space == "log":
# c = the bracket end with the smaller |f| (the side the # c = the bracket end with the smaller |f| (the side the
# root is nearer); s = xtol, fixed by derivation in # root is nearer); s = xtol, fixed by derivation in
# PREREG amendment 1, never chosen. Falls back to the # PREREG amendment 1, never chosen. Falls back to the
@@ -624,7 +643,7 @@ def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived",
if x_new == x or x_new == x_prev: if x_new == x or x_new == x_prev:
# the bracket can no longer be split in floating point # the bracket can no longer be split in floating point
if stats is not None: if stats is not None:
stats['forced'] = forced stats['forced'] = forced; stats['pre_refused'] = pre_refused
return x_new, g.n, abs(B - A) <= xtol return x_new, g.n, abs(B - A) <= xtol
w_before = abs(B - A) w_before = abs(B - A)
@@ -636,7 +655,7 @@ def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived",
x, fx = x_new, fnew x, fx = x_new, fnew
if fnew == 0.0: if fnew == 0.0:
if stats is not None: if stats is not None:
stats['forced'] = forced stats['forced'] = forced; stats['pre_refused'] = pre_refused
return x_new, g.n, True return x_new, g.n, True
if FA * fnew < 0.0: if FA * fnew < 0.0:
B, FB = x_new, fnew B, FB = x_new, fnew
@@ -645,9 +664,9 @@ def pure_root(f, a, b, xtol, maxfev, similarity="ratio", scale="derived",
if guard == "credit": if guard == "credit":
sg.note_shrink(w_before, abs(B - A)) sg.note_shrink(w_before, abs(B - A))
if stats is not None: if stats is not None:
stats['forced'] = forced stats['forced'] = forced; stats['pre_refused'] = pre_refused
return 0.5 * (A + B), g.n, True return 0.5 * (A + B), g.n, True
except _Budget: except _Budget:
if stats is not None: if stats is not None:
stats['forced'] = forced stats['forced'] = forced; stats['pre_refused'] = pre_refused
return x, g.n, False return x, g.n, False
+29 -4
View File
@@ -110,19 +110,23 @@ def make_method(spec):
return BASELINES[spec[1]] return BASELINES[spec[1]]
sim, scale, guard = spec[1], spec[2], spec[3] sim, scale, guard = spec[1], spec[2], spec[3]
space = spec[4] if len(spec) > 4 else "x" space = spec[4] if len(spec) > 4 else "x"
prestep = spec[5] if len(spec) > 5 else "none"
def m(f, a, b, xtol, maxfev): def m(f, a, b, xtol, maxfev):
return pure_root(f, a, b, xtol, maxfev, similarity=sim, return pure_root(f, a, b, xtol, maxfev, similarity=sim,
scale=scale, guard=guard, space=space) scale=scale, guard=guard, space=space, prestep=prestep)
return m return m
def label(spec): def label(spec):
if spec[0] == "base": if spec[0] == "base":
return spec[1] return spec[1]
tag = "pure"
if len(spec) > 4 and spec[4] != "x": if len(spec) > 4 and spec[4] != "x":
return f"{spec[4]}.{spec[1]}.{spec[2]}.{spec[3]}" tag = spec[4]
return f"pure.{spec[1]}.{spec[2]}.{spec[3]}" if len(spec) > 5 and spec[5] != "none":
tag = f"{tag}+gate" if tag != "pure" else "gate"
return f"{tag}.{spec[1]}.{spec[2]}.{spec[3]}"
def evaluate(spec): def evaluate(spec):
@@ -185,6 +189,15 @@ def build_grid_log(smoke):
for sim in sims for sc in ("derived", "derived3") for gu in guards] for sim in sims for sc in ("derived", "derived3") for gu in guards]
def build_grid_gate(smoke):
"""The pre-step gate grid (PREREG 2026-09-21): the published x-space step with the
gate switched on. Baselines are not recomputed - they are already in sweep_full.json."""
sims = ["ratio", "sqrt"] if smoke else sorted(PURE_SIMS)
guards = ("credit", "safe") if smoke else GUARD_MODES
return [("pure", sim, sc, gu, "x", "progress")
for sim in sims for sc in ("derived", "derived3") for gu in guards]
def build_grid(smoke): def build_grid(smoke):
specs = [("base", nm) for nm in BASELINES] specs = [("base", nm) for nm in BASELINES]
sims = sorted(PURE_SIMS) sims = sorted(PURE_SIMS)
@@ -205,7 +218,9 @@ def build_grid(smoke):
def main(): def main():
smoke = "--smoke" in sys.argv smoke = "--smoke" in sys.argv
logmode = "--log" in sys.argv # the u-space grid, written to its OWN file logmode = "--log" in sys.argv # the u-space grid, written to its OWN file
specs = build_grid_log(smoke) if logmode else build_grid(smoke) gatemode = "--gate" in sys.argv # the pre-step gate grid, likewise
specs = (build_grid_gate(smoke) if gatemode else
build_grid_log(smoke) if logmode else build_grid(smoke))
t0 = time.time() t0 = time.time()
print(f"sweep: {len(specs)} configurations x 199 problems " print(f"sweep: {len(specs)} configurations x 199 problems "
f"({'SMOKE' if smoke else 'FULL'}{', LOG-SPACE' if logmode else ''})", flush=True) f"({'SMOKE' if smoke else 'FULL'}{', LOG-SPACE' if logmode else ''})", flush=True)
@@ -221,6 +236,16 @@ def main():
# the published sweep_full.json is what verify_article.py checks the paper # the published sweep_full.json is what verify_article.py checks the paper
# against, so the log grid never writes into it # against, so the log grid never writes into it
if gatemode:
# never into sweep_full.json: that file is what verify_article.py checks the
# paper against, and the smoke run of 2026-09-21 did land in sweep_smoke.json
# before this branch existed
out = "sweep_gate_smoke.json" if smoke else "sweep_gate.json"
with open(out, "w", encoding="utf8") as fh:
json.dump(results, fh)
print(f"\nwrote {out} ({time.time() - t0:.0f}s total)\n", flush=True)
report(results)
return
out = ("sweep_log_smoke.json" if smoke else "sweep_log.json") if logmode else \ out = ("sweep_log_smoke.json" if smoke else "sweep_log.json") if logmode else \
("sweep_smoke.json" if smoke else "sweep_full.json") ("sweep_smoke.json" if smoke else "sweep_full.json")
with open(out, "w", encoding="utf8") as fh: with open(out, "w", encoding="utf8") as fh:
File diff suppressed because one or more lines are too long
+91
View File
@@ -0,0 +1,91 @@
"""Implementation gates R1-R4 for the pre-step gate (PREREG_prestep_gate_2026-09-21.md).
The measured cause of our cost is that the safeguard REPAIRS 77.5 % of evaluations after
the fact, while Chandrupatla REFUSES its model for free on 72 % of steps and halves
instead. The gate under test asks, BEFORE spending an evaluation, whether the similarity
step promises at least what bisection would remove — a comparison of two lengths already
in hand, no threshold and no new constant.
"""
from __future__ import annotations
import inspect
import math
import re
import pytest
from simroot import pure as P
PROBLEMS = [
(lambda t: t ** 3 - 2.0 * t - 5.0, 2.0, 3.0),
(lambda t: math.exp(t) - 5.0, 0.0, 3.0),
(lambda t: t ** 2 - 2.0, 0.5, 2.0),
(lambda t: math.cos(t) - t, 0.0, 1.0),
(lambda t: (t - 1.0) ** 3, -1.0, 4.0),
]
def test_R1_the_published_path_is_bit_identical_with_the_gate_off():
for f, a, b in PROBLEMS:
for guard in ("credit", "project", "safe", "step"):
base = P.pure_root(f, a, b, 1e-12, 300, similarity="ratio",
scale="derived3", guard=guard)
off = P.pure_root(f, a, b, 1e-12, 300, similarity="ratio",
scale="derived3", guard=guard, prestep="none")
assert base == off, (guard, base, off)
def test_R2_the_gate_carries_no_chosen_constant():
src = inspect.getsource(P.pure_root)
start = src.index('if prestep == "progress":')
end = src.index('if step is None and space ==', start) # the gate block only
body = src[start:end]
# comments carry the MEASURED numbers that justify the gate (77.5 %, 2.310 halvings);
# they are documentation, not constants, so the check reads the code only
code = "\n".join(line.split("#", 1)[0] for line in body.splitlines())
literals = set(re.findall(r"(?<![\w.])\d+\.\d+(?![\w.])", code))
# 0.5 is the definition of bisection, 1.0 comes from S in [0,1]
assert literals <= {"0.0", "0.5", "1.0"}, f"chosen constants in the gate: {literals}"
def test_R3_a_refusal_costs_no_extra_evaluation_PER_STEP():
"""R3 as the prereg states it: a refusal REPLACES the step, so evaluations never exceed
steps + the two bracket probes.
NOT claimed (and measured false on 2026-09-21): that the whole RUN cannot become more
expensive. Forcing bisection changes the trajectory — it removes more bracket but less
error than a good secant step — and one probe problem went 25 -> 29 evaluations. Whether
that trade pays over the two benchmark sets is exactly what P2/P3 measure; a unit test
must not pre-judge it."""
for f, a, b in PROBLEMS:
st = {}
_x, n, ok = P.pure_root(f, a, b, 1e-12, 300, similarity="ratio", scale="derived3",
guard="credit", prestep="progress", stats=st)
assert ok
# every evaluation is one step (the gate spends the same probe on the midpoint),
# plus f(a) and f(b)
assert st["pre_refused"] <= n - 2, (st, n)
def test_R4_refusals_are_counted_separately_from_repairs():
st = {}
f = PROBLEMS[4][0]
x, n, ok = P.pure_root(f, -1.0, 4.0, 1e-10, 300, similarity="ratio", scale="derived3",
guard="credit", prestep="progress", stats=st)
assert ok and abs(x - 1.0) < 1e-3
assert set(("forced", "pre_refused")) <= set(st)
assert st["pre_refused"] >= 1 # a cubic with a triple root refuses often
assert isinstance(st["forced"], int) and isinstance(st["pre_refused"], int)
def test_the_gate_is_validated_not_silently_ignored():
with pytest.raises(ValueError):
P.pure_root(lambda t: t, -1.0, 1.0, 1e-10, 100, prestep="whatever")
def test_the_gate_still_solves_every_probe_problem():
for f, a, b in PROBLEMS:
x, n, ok = P.pure_root(f, a, b, 1e-12, 300, similarity="ratio", scale="derived3",
guard="credit", prestep="progress")
assert ok and abs(f(x)) < 1e-6, (a, b, x, n, ok)