diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5bb3b..919159e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,20 @@ All notable changes to EigenScript are documented here. by pointing the instrument at itself. ### Fixed +- **`report_value` no longer calls a monotone converging sequence + `diverging` (#674).** The value channel's raw-step divergence test (#422) + judged a window's steps "non-vanishing" when the recent half's mean + magnitude was at least *half* the older half's — admitting any geometric + decay with ratio `r^5 >= 0.5`, i.e. `r ≳ 0.871`, so `x *= 0.90` / + `x *= 0.95` (every real iterative solver's operating regime) read + `diverging` while converging to zero. A geometric decay's steps DO vanish + — their sum converges — so the bar is now "not shrinking": the recent + half must be at least the older half (minus 1e-9 of fp-rounding slack). + Linear runaways (constant steps) and polynomial/geometric growth (growing + steps) still read `diverging`; decaying approaches fall through to the + relative verdicts and settle as `converged`. Regression test + `test_report_value_convergence.eigs` (suite [50j3]) pins the issue repro + (ratios 0.88/0.90/0.95 converge) against the genuine-divergence cases. - **`file_exists` / `ls` / `mkdir` / `getcwd` / `exe_path` are now trace-recorded (#585).** These fs builtins predated the tape and ran live under `EIGS_REPLAY`: a program branching on `file_exists of p` replayed diff --git a/docs/OBSERVER.md b/docs/OBSERVER.md index 74a5a5a..f1fa646 100644 --- a/docs/OBSERVER.md +++ b/docs/OBSERVER.md @@ -142,7 +142,8 @@ binding). Use `report` to ask *how determined*; use `report_value` to ask **The raw-step signal (#422).** Relative normalization erases exactly two classes, so the value channel also keeps the window of *raw* steps `Δv` and asks one structural question of it: are the steps **non-vanishing** (the -recent half's mean magnitude at least half the older half's, above an +recent half's mean magnitude not below the older half's — a geometric +decay's steps shrink with the value and DO vanish, #674 — above an fp-noise floor of `4·ε·(1+|x|)`)? Non-vanishing **same-sign** steps sum without bound — an additive or polynomial runaway (`x → x + c` seeded large) whose `Δv/|x|` vanishes below the deadband is `diverging`, not `converged`. diff --git a/src/eigenscript.c b/src/eigenscript.c index 4af0824..6dbee12 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -473,12 +473,21 @@ static double observer_slot_vr_get(const ObserverSlot *s, size_t offset_back) { * the fp-noise floor (a settled double wobbles by ULPs whose signs are * meaningless — 4·ε·(1+|v|) scales the floor to the value's magnitude), and * both hinge on the same question the relative channel cannot ask: are the - * raw steps NON-VANISHING (recent-half mean magnitude ≥ half the older-half - * mean)? Non-vanishing same-sign steps sum without bound → diverging, no - * matter how large |v| already is; non-vanishing sign-alternating steps are - * a perpetual oscillation, no matter how small the deadband-relative step. - * A damped (decaying-step) trajectory fails non-vanishing and falls through - * to the relative verdicts — converging approaches stay 'converged'. */ + * raw steps NON-VANISHING (recent-half mean magnitude not below the + * older-half mean)? Non-vanishing same-sign steps sum without bound → + * diverging, no matter how large |v| already is; non-vanishing + * sign-alternating steps are a perpetual oscillation, no matter how small + * the deadband-relative step. A damped (decaying-step) trajectory fails + * non-vanishing and falls through to the relative verdicts — converging + * approaches stay 'converged'. + * #674: the bar is "not shrinking", not "shrinking slower than 2x per + * half-window". The old 0.5 factor admitted any geometric decay with ratio + * r^half ≥ 0.5 (r ≳ 0.871 for a 10-window) as "non-vanishing", so a + * monotone approach to zero — every real iterative solver — read + * 'diverging'. A geometric decay's steps DO vanish (their sum converges); + * only non-shrinking steps (a linear runaway's constant Δv, a polynomial + * or geometric runaway's growing Δv) sum without bound. The 1e-9 slack + * absorbs fp rounding in the two half-sums for exactly-constant steps. */ static int observer_slot_raw_nonvanishing(const ObserverSlot *s) { if (s->v_window_count < OBSERVER_WINDOW_N) return 0; double floor_eps = 4.0 * DBL_EPSILON * (1.0 + fabs(s->last_value)); @@ -489,7 +498,7 @@ static int observer_slot_raw_nonvanishing(const ObserverSlot *s) { recent /= (double)half; older /= (double)(OBSERVER_WINDOW_N - half); if (older <= floor_eps || recent <= floor_eps) return 0; - return recent >= 0.5 * older; + return recent >= older * (1.0 - 1e-9); } static int observer_slot_raw_diverging(const ObserverSlot *s) { diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 2017d35..b427453 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2071,6 +2071,11 @@ check_eigs_suite "value-channel diverging/sub-deadband oscillation, trajectory-o "test_trajectory.eigs" "TRAJECTORY_ALL_PASS" 1 echo "" +echo "[50j3] report_value convergence classification (#674) (6 checks)" +check_eigs_suite "slow geometric decay converges (not diverging), geometric/linear growth still diverging" \ + "test_report_value_convergence.eigs" "REPORT_VALUE_CONVERGENCE_ALL_PASS" 1 +echo "" + echo "[50k] UTF-8 codepoints (16 checks)" check_eigs_suite "utf8: decode/len/at/char_at over byte strings + structural validation (published vectors)" \ "test_utf8.eigs" "UTF8_ALL_PASS" 1 diff --git a/tests/test_report_value_convergence.eigs b/tests/test_report_value_convergence.eigs new file mode 100644 index 0000000..0325326 --- /dev/null +++ b/tests/test_report_value_convergence.eigs @@ -0,0 +1,60 @@ +# #674 — report_value must not call a monotone CONVERGING sequence 'diverging'. +# +# The value channel's raw-step divergence test (#422) is meant for runaways +# whose steps do not vanish (linear/polynomial/geometric growth — the sum of +# same-sign steps has no bound). A geometric decay x *= r with r in (0,1) +# converges to zero for EVERY r; its steps shrink with it, so it must never +# read 'diverging' no matter how close r is to 1. The old "recent >= 0.5 * +# older" bar admitted any r ≳ 0.871 as "non-vanishing", covering essentially +# every real iterative solver. These checks pin the issue repro plus the +# genuine-divergence cases the raw-step test exists for. +# All-pass prints REPORT_VALUE_CONVERGENCE_ALL_PASS. + +fails is 0 +define check(tag, got, want) as: + if got == want: + print of f" PASS: {tag}" + if got != want: + print of f" FAIL: {tag} (got {str of got}, want {str of want})" + fails is fails + 1 + +# 1. The #674 repro: slow geometric decays to zero. 300 iterations, exactly +# as the issue's repro runs them — x is still well above machine epsilon +# for r = 0.95, yet the sequence is converging. +rates is [0.88, 0.90, 0.95] +for i in range of (len of rates): + r is rates[i] + x is 1.0 + for j in range of 300: + x is (x * r) + 0.0 + check of [f"decay ratio {r} converges (not diverging)", report_value of x, "converged"] + +# 2. Genuine divergence must still read 'diverging': geometric GROWTH +# (ratio >= 1, growing magnitude, growing steps). +g is 1.0 +s is 0 +loop while s < 40: + g is g * 1.05 + s is s + 1 +check of ["geometric growth (r=1.05) is diverging", report_value of g, "diverging"] + +# 3. Linear runaway: constant non-vanishing same-sign steps (the #422 case). +a is 1.0 +s2 is 0 +loop while s2 < 40: + a is a + 7.0 + s2 is s2 + 1 +check of ["linear runaway is diverging", report_value of a, "diverging"] + +# 4. Fast decay keeps its existing label (no regression on r <= 0.84). +h is 1.0 +s3 is 0 +loop while s3 < 60: + h is h * 0.5 + s3 is s3 + 1 +check of ["fast decay (r=0.5) stays converged", report_value of h, "converged"] + +if fails == 0: + print of "REPORT_VALUE_CONVERGENCE_ALL_PASS" +if fails != 0: + print of f"REPORT_VALUE_CONVERGENCE_FAILED ({str of fails})"