From bfa3584e371b9435d97ce3b28ea93333bacd52e7 Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Wed, 5 Aug 2026 14:04:45 -0500 Subject: [PATCH] observer: route numeric predicates to the value channel (#861, #864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The entropy channel cannot be a convergence detector for numbers. H is a function of |x| alone, so every clause on it is a clause on MAGNITUDE: `entropy < h_low` made all of |x| in [77, 1e307] a permissive region (a geometric runaway certified `converged` at x ~= 2.9e5, a linear one exited `loop while not converged` at y = 88) and every limit in [~0.013, 76] a dead zone (Newton's method to sqrt(2) could never certify). The same computation targeting 5000, 5 and 0.005 got three different verdicts. Measured by tests/test_convergence_oracle.eigs: 19/27 against analytic ground truth, FP=3 FN=5. The fix: when a binding's most recent observed assignment is numeric (new ObserverSlot.v_last), the six predicate words and `report` read the VALUE channel — relative steps Dv/(1+|v|), which is the standard mixed-tolerance stopping criterion (|Dx| <= atol + rtol|x|, atol = rtol) with the settle deadband as the tolerance. Non-numeric bindings keep the entropy classifiers; the entropy MEASUREMENT (where/why/how, snapshots, container folds, the tape) is untouched everywhere. `report_value` IS the routed classifier now, so no two surfaces can disagree about one numeric trajectory — the tape/DAP/step surfaces already classified from the value channel, so live `report` used to disagree with `--step` about the same run; it no longer can. `classify of [t, "entropy"]` reaches the entropy classifier explicitly (builtins.c routes the two channels by name). Post-routing score: 25/27, FP=2 FN=0, recall 9/9. The two misses are the irreducible floor of any finite-window detector — sequences settled at the deadband but ~1e-2 from their limits — and are documented as the tolerance semantics, with the harmonic series as the proof that vanishing steps do not imply a limit. `converged` is documented as a stopping criterion, never a proof. The numeric band definitions (eigenscript.c, obs_num_*): - converged: full window, every |rel| < dh_zero, raw guards clean - stable: full window, every |rel| < dh_small, no strong flips - equilibrium: full window, |mean| < dh_zero, variance < dh_zero^2 - improving: >=4 samples, MONOTONE raw steps whose mean AND max contract to <= 0.7x the older half (a summable tail — genuinely closing on a limit) - diverging: saturation ceiling (any fill), or #422 raw non-vanishing same-sign steps - oscillating: deadband sign-flips; raw perpetual alternation; or window-scale folding (>= 2 reversals, |net| <= 0.3 x path, motion above the deadband) — a sinusoid sampled slower than its half-period folds back without per-sample flips Quiescent lattice: converged implies equilibrium AND stable. Motion bands exclusive. Every clause above was forced by a failing test or the corpus during development: the mean-only contraction called a period-4 cycle improving (alignment artifact -> max clause); non-monotone jitter flickered a lab sensor into improving (-> monotone clause); the full-window raw tests let a 6-step runaway answer diverging=false (-> partial from 4); the bounded-oscillation clause without a settle guard called cos's settled fixed point oscillating (-> all-under-deadband guard, caught by corpus case 5 within one run). Loop halting (vm.c, BOTH copies of the stall check): the stall backstop drops its `ent >= h_low` clause. That clause compensated for the old defect — quiet-at-low-entropy used to be where the entropy `converged` fired — and with the routing it made a bare loop around a runaway INFINITE (quiet dH at low entropy, predicate correctly refusing, #772 having removed the unconditional cap). New contract: `converged` ends an observer loop when the value settles; `stalled` ends it after 100 quiet iterations without certification; __loop_exit__ says which. Closes #864 as a consequence: the COMPARISON.md showcase now exits via the predicate in 13 iterations (was 102 via the hidden stall, reported "stalled"); the recommended named form to a mid-range limit terminates in 19 iterations (used to hang forever); the 15-line settled-plus-hold recipe PREDICATES.md prescribed is deleted — the honest recipe is the two-line named loop plus a cap only for genuinely-divergent inputs. Substantially moots #862 for live classification: the H(x) == H(1/x) == H(-x) level sets no longer decide any numeric verdict (the signal keeps the blind spot; PREDICATES.md now characterizes it, reachable only via classify-entropy and non-numeric bindings). Consumer fallout, audited program-by-program in the observer corpus (goldens re-captured, 14/14): - dynamics solvers: all answers still correct; iteration counts shift both ways; results land at the deadband tolerance rather than the accidental over-iteration the dead zone forced (structural_observer's 3.0018 vs 3.0000000008 — tighter answers now need the knob, not an accident). - dynamics physics: "x OSCILLATES, energy settles" reads correctly again (the window-scale oscillation test); life's population counts certify. - observer_predicates: a decaying signal reads `improving` all the way down instead of churning stable->diverging->moving through entropy magnitude regions. - lib/simulation.eigs analyze_stability: two pre-existing defects fixed (an artificial 0->first-element seed jump dominating the window; a label map missing "moving"/"opaque"). Test re-pins are annotated in place; the inversions are the fix (LE1/LE2 literally swapped: the runaway loop used to exit "normal" via the defect and the constant loop "stalled" via the dead zone). The corpus gate's baselines move 19->25 with per-case bands re-pinned — the deliberate, measured edit the gate exists to force. test_dispatch's counter loops decay instead of double (the doubling form only terminated through the defect) with the decaying variable observed last (a bare predicate reads the last-observed binding). Docs: SPEC.md (routing, halting contract, report, value-channel definitions, honesty bound), PREDICATES.md (routing section with the numeric definitions and tolerance semantics; entropy formulas re-scoped to the entropy route; canonical examples re-measured — Newton CERTIFIES now; recipe rewritten), COMPARISON.md (the showcase claim is now demonstrated by its own example), OBSERVER.md (routing banner; the convergence-detection bullet), README.md (the improving footnote). Doc gate 76/76 byte-for-byte. Gates: release 3787/3787 x2 (plus two mid-development full passes), ASan+UBSan detect_leaks=1 green with the LeakSanitizer tally at its 0 floor, jit-smoke, freestanding-check, observer corpus 14/14. The JIT needed no mirror: vm_slot_predicate is the only dispatcher and the JIT has no predicate involvement. Closes #861 Closes #864 Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 12 +- docs/COMPARISON.md | 12 +- docs/OBSERVER.md | 30 +- docs/PREDICATES.md | 317 ++++++++------- docs/SPEC.md | 60 ++- lib/simulation.eigs | 13 +- src/builtins.c | 5 +- src/eigenscript.c | 381 ++++++++++++++---- src/eigenscript.h | 10 + src/vm.c | 26 +- .../golden/EigenScript__idioms.out | 2 +- .../golden/EigenScript__numerical.out | 32 +- .../golden/EigenScript__observer.out | 2 +- .../EigenScript__observer_predicates.out | 48 +-- .../EigenScript__structural_observer.out | 12 +- .../observer_corpus/golden/dynamics__life.out | 8 +- .../golden/dynamics__physics.out | 2 +- .../golden/dynamics__solve.out | 12 +- .../iLambdaAi__test_halting_descent.out | 8 +- .../iLambdaAi__test_report_alignment.out | 10 +- .../golden/iLambdaAi__test_stable_band.out | 4 +- tests/run_all_tests.sh | 52 ++- tests/test_convergence_oracle.eigs | 50 ++- tests/test_dispatch.eigs | 13 +- tests/test_halting_descent.eigs | 14 +- tests/test_halting_stall.eigs | 12 +- tests/test_loop_exit.eigs | 7 +- tests/test_observer_coherence.eigs | 10 +- tests/test_observer_slots.eigs | 11 +- tests/test_observer_value_signal.eigs | 10 +- tests/test_predicate_matrix.eigs | 102 +++-- tests/test_report_alignment.eigs | 127 +++--- tests/test_simulation.eigs | 6 +- tests/test_stable_band.eigs | 9 +- tests/test_step.sh | 12 +- tests/test_windowed_diverging.eigs | 28 +- tests/test_windowed_equilibrium.eigs | 9 +- tests/test_windowed_improving.eigs | 25 +- tests/test_windowed_oscillating.eigs | 9 +- tests/test_windowed_stable.eigs | 13 +- 40 files changed, 992 insertions(+), 533 deletions(-) diff --git a/README.md b/README.md index 88c8b6a7..60a8fca6 100644 --- a/README.md +++ b/README.md @@ -159,11 +159,13 @@ epsilon / remembered-previous / max-iteration boilerplate every numeric loop in Python or JS hand-rolls — see [docs/COMPARISON.md → Convergence loops](docs/COMPARISON.md#convergence-loops-boilerplate-you-stop-writing). -> **What `improving` and the rest actually mean.** EigenScript's observer -> rests on a specific idea — a value locating itself *from the inside*, -> with no external goal — so the trajectory words don't always match a -> naive "smaller is better" reading. The full model, including the -> resolution knob (`set_observer_thresholds`), is in +> **What `improving` and the rest actually mean.** For numbers the +> trajectory words read the value's own motion (#861): `converged` is the +> standard stopping criterion — steps settled under a tolerance — and +> `improving` means the steps are contracting toward a limit. For +> non-numeric values the observer's entropy reading applies — a value +> locating itself *from the inside*, with no external goal. The full +> model, including the resolution knob (`set_observer_thresholds`), is in > [docs/OBSERVER.md](docs/OBSERVER.md); the precise predicate semantics > (what `converged` requires, how it differs from `equilibrium`, the > N-step window) are in [docs/PREDICATES.md](docs/PREDICATES.md). diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 04ce57d3..5cf1d901 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -326,9 +326,15 @@ print of ("sqrt(2) = " + (str of (newton_sqrt of 2))) sqrt(2) = 1.414213562373095 ``` -`converged` reads the loop's last-assigned value and is true once its trend has -flattened. (Use it inside a function so the loop gets a fresh binding to watch — -see `examples/observer_vs_boilerplate.eigs`.) The next section is *why* this +`converged` reads the loop's last-assigned value and fires once a full +window of its relative steps sits under the settle deadband — the standard +mixed-tolerance stopping criterion, built in (#861). This run exits +through the predicate itself in 13 iterations; the deadband is the +tolerance (`set_observer_thresholds`), and an input that genuinely +diverges ends via the observer's stall backstop with +`__loop_exit__ == "stalled"` instead of hanging or lying. (Use the loop +inside a function so it gets a fresh binding to watch — see +`examples/observer_vs_boilerplate.eigs`.) The next section is *why* this works; you can reach for it long before you need the theory. ## What has no equivalent elsewhere: the observer diff --git a/docs/OBSERVER.md b/docs/OBSERVER.md index 2fefc682..f9815c1e 100644 --- a/docs/OBSERVER.md +++ b/docs/OBSERVER.md @@ -138,8 +138,18 @@ you put the thresholds — see [Resolution](#resolution). ### Two signals: entropy vs. value (`report` vs. `report_value`) -`report`/the bare predicates classify the trajectory of **`entropy(value)`** — -the information content, not the number. That is the right signal for "how +**Since #861 the predicate words and `report` ROUTE: numeric bindings +answer from the value channel described below; non-numeric bindings (and +the explicit `classify of [t, "entropy"]`) answer from the entropy +channel.** The routing exists because of everything this section +documents — the entropy signal's lossiness for "has the value settled" +was measured at 19/27 against an analytic convergence corpus, vs 25/27 +for the value channel (`tests/test_convergence_oracle.eigs`). The +paragraphs below describe the two signals themselves; where they say +`report` reads entropy, that is now true only for non-numeric bindings. + +`report`/the bare predicates historically classified the trajectory of +**`entropy(value)`** — the information content, not the number. That is the right signal for "how *determined* is this value," but it is a *lossy proxy* for "has this value *settled*": because `where` is non-monotonic (the watershed below), the entropy signal goes flat in mid-magnitude regions, so a real value oscillation @@ -321,14 +331,14 @@ point, not an accident: not a snapshot raises `type_mismatch` — a bare value silently classifying as "no trajectory" is exactly the hole the snapshot exists to close. -- **Convergence is detected on the value channel, not entropy.** A - monotonically *exploding* value has falling-then-flat entropy, so `report` - labels a runaway solver `converged` (measured: `grow` = `x*1.5` for 40 steps - → `report` = `converged`). Only `report_value` keeps a converging solver and a - diverging one apart — same run reads `moving`. This is the [`report` vs - `report_value`](#two-signals-entropy-vs-value-report-vs-report_value) - distinction made load-bearing: a contract the entropy signal would silently - pass. +- **Convergence is detected on the value channel — now by every surface + (#861).** A monotonically *exploding* value has falling-then-flat + entropy, so the old entropy-routed `report` labeled a runaway solver + `converged` (measured: `grow` = `x*1.5` for 40 steps). Since #861 + `report` and the predicate words route numerics to the value channel, + so the same run reads `diverging` on every surface — the distinction + this bullet used to warn about is now enforced by the runtime rather + than left to the caller's channel choice. - **Contract-convergence means "settled to the step deadband" (`dh_zero`, default `1e-3`), not arbitrary precision.** Newton reaches machine-epsilon diff --git a/docs/PREDICATES.md b/docs/PREDICATES.md index 1807ebe9..b528ec45 100644 --- a/docs/PREDICATES.md +++ b/docs/PREDICATES.md @@ -25,6 +25,61 @@ a **window** of the last `N` observations rather than the instantaneous runtime simplified five of the six to single-step checks, which flickered under noise — see "Pointwise behavior replaced" in each section.) +## Which channel answers (#861) + +The predicate words and `report` are **routed**: a binding whose most +recent observed assignment is **numeric** answers from the **value +channel** — the classifier below, over relative steps `Δv/(1+|v|)`; every +other binding (strings, containers) answers from the **entropy channel**, +the windowed formulas in "The six predicates". `report_value of x` is the +value-channel classifier by name (identical to the routed words on a +numeric binding — the two surfaces cannot disagree); `classify of +[t, "entropy"]` reaches the entropy classifier by name. The entropy +MEASUREMENT — `where`, `why`, `how`, trajectory snapshots, container +folds, the tape — is unchanged everywhere. + +Why: entropy is a function of `|x|` alone, so any clause on it is a +clause on magnitude. Measured against 27 analytically-known sequences +(`tests/test_convergence_oracle.eigs`), the entropy channel scored 19/27 +— `converged` fired across the whole region `|x| ∈ [77, 1e307]` (a +geometric runaway certified at `x ≈ 2.9e5`), could never fire for limits +in `[~0.013, 76]` (Newton's method to `sqrt(2)` was uncertifiable), and +gave the same computation targeting 5000, 5 and 0.005 three different +verdicts. The value channel scores 25/27; the two misses are the +irreducible tolerance floor, not defects (see the honesty bound below). + +**The numeric definitions** (window `N = 10` relative steps +`rel = Δv/(1+|v|)`, raw steps `Δv` kept alongside — #422): + +| band | fires when | +|---|---| +| `converged` | full window, every `\|rel\| < dh_zero`, raw guards clean | +| `stable` | full window, every `\|rel\| < dh_small`, no strong consecutive sign flips | +| `equilibrium` | full window, `\|mean(rel)\| < dh_zero`, `variance(rel) < dh_zero²` | +| `improving` | ≥ 4 samples, **monotone** raw steps whose mean *and* max contract to ≤ 0.7× the older half — a summable (geometric-class) tail, genuinely closing on a limit | +| `diverging` | value at the saturation ceiling (any window fill), or non-vanishing same-sign raw steps (a linear/polynomial runaway whose `Δv/\|v\| → 0` is still unbounded) | +| `oscillating` | ≥ 4 deadband sign-flips of `rel`; or non-vanishing raw alternation (a perpetual oscillation below the deadband is still an oscillation); or **window-scale folding** — ≥ 2 direction reversals with net travel ≤ 0.3× path length and motion above the deadband (a sinusoid sampled slower than its half-period) | + +The quiescent lattice on this route: `converged ⊂ equilibrium` and +`converged ⊂ stable` (all-under-deadband satisfies all three). The rest +bands exclude the raw structure tests; the motion bands are mutually +exclusive. `report` resolves the canonical priority `oscillating → +diverging → improving → converged → equilibrium → stable → moving`. + +**The honesty bound.** `converged` is a **stopping criterion, not a +proof**: it means *settled at the deadband* — every recent step below the +tolerance — which is the strongest claim a finite window supports. +Vanishing steps do not imply a limit (the harmonic series' steps vanish; +its sum does not), so a slow-enough divergence will eventually read +`stable` and, at extreme patience, `converged`; and a slowly-converging +sequence reads `converged` while still `~1e-2` from its limit (corpus +cases 6 and 8). The deadband is the tolerance knob: +`set_observer_thresholds of [dh_zero, dh_small, h_low]` — `dh_zero` is +the settle tolerance, `dh_small` the small-motion band; the raw +STRUCTURE tests are deliberately threshold-free (a perpetual ±5 swing is +an oscillation at any tolerance). `h_low` affects only the entropy +route. + ## Inputs Every predicate reads the same observer state on the most recently @@ -73,12 +128,13 @@ FLIPS = ceil(N / 3) = 4 min sign-flips in the window for oscillating If the window does not yet hold enough samples, **every predicate returns `false`** — "we haven't seen enough yet to claim anything." The minimum is -`N` for the full-window predicates (`converged`, `stable`, `equilibrium`) -and `3` for the trajectory predicates (`improving`, `diverging`, -`oscillating`). A two-write program can never report any predicate true; -this is the single most important difference from the old pointwise rule, -which fired on the first step. **One exception**: `diverging` fires at the -numeric ceiling regardless of window fill — see the saturation rule below. +`N` for the rest bands (`converged`, `stable`, `equilibrium`) on both +routes, `4` for the value route's motion bands (the half-split needs two +samples per half) and `3` for the entropy route's. A two-write program can +never report any predicate true; this is the single most important +difference from the old pointwise rule, which fired on the first step. +**One exception**: `diverging` fires at the numeric ceiling regardless of +window fill — see the saturation rule below. ## Opaque rule (#708): function-valued bindings @@ -110,27 +166,20 @@ as **diverging, and in no rest band**: | predicate | at the ceiling | |---|---| -| `converged`, `equilibrium`, `stable` | forced `false` — the rest bands | -| `improving` | forced `false` (see below) | +| `converged`, `equilibrium`, `stable`, `improving` | forced `false` — a value at the clamp is not resting or approaching | | `diverging` | forced `true`, **including on a partial window** | -| `oscillating` | unchanged — evaluated normally | +| `oscillating` | evaluated normally — a value flipping `±1e308` reads as the oscillation it is, and `report`'s priority resolves it to `oscillating` | -Note that a *pure* sign-flip at the ceiling (`z is 0 - z` at `1e308`) is -invisible to the entropy channel — `H(x) ≡ H(−x)`, so `dH` is 0 and -`oscillating` stays false; `report` answers `diverging` while -`report_value` answers `oscillating`. That disagreement is the entropy -signal's blind spot (#862), not the saturation rule. - -and `report` / `report_value` both answer `diverging`. `improving` is -gated because `H` decreases as `|x|` grows past 1, so a runaway climbing -toward the ceiling shows a run of negative `dH` — leaving it open would -just move the wrong answer one band over, since `report` tries `improving` -before `converged`. `oscillating` is left alone so a value flipping -`±1e308` still reads as the oscillation it is. +Since #861 both `report` and `report_value` run the same classifier, so +they agree here by construction. (The `H(x) ≡ H(−x)` blindness that once +made them disagree at the ceiling — #862 — still exists in the entropy +SIGNAL, but no numeric classification reads it anymore; it is visible +only through `classify of [t, "entropy"]` and for non-numeric bindings, +whose values have no ceiling.) `diverging` is claimed before the partial-window guard because the evidence is the value's *position*, not the shape of the (flattened) -window — this is the one place a predicate fires on fewer than 3 samples. +window — this is the one place a predicate fires on fewer than 4 samples. **A literal `±1e308` that never overflowed reads `diverging` too.** The runtime cannot distinguish a saturated value from one deliberately @@ -163,16 +212,20 @@ All six predicates are now windowed (the #202 series is complete): The "Pointwise behavior replaced" note under each predicate records the single-step rule that the windowed version superseded. -All six predicates above (and `report`) classify the trajectory of -**`entropy(value)`**. `report_value of x` (#294) is a sibling that runs the -same windowed logic on the **value's own** relative step `Δv/(1+|x|)` instead -of its entropy — answering "has the number stopped moving" rather than "how -determined is it." It is the right tool when the value oscillates in a -flat-entropy region (where the entropy signal reads `stable`); see -[`docs/OBSERVER.md`](OBSERVER.md) ("Two signals"). Vocabulary: -`oscillating`/`converged`/`stable`/`moving`/`equilibrium`. +Since #861 the kinds dispatch by route: a numeric binding answers from +the value-channel definitions in "Which channel answers" above; the +windowed-entropy formulas in the next section serve non-numeric bindings +and the explicit `classify of [t, "entropy"]` channel. `report_value of +x` (#294) is the value-channel classifier by name — on a numeric binding +it and the predicate words are one classifier and cannot disagree. + +## The six predicates — the ENTROPY route -## The six predicates +**These formulas classify the trajectory of `entropy(value)`. Since #861 +they answer only for non-numeric bindings (strings, containers) and the +explicit entropy channel; numeric bindings use the value-channel +definitions above.** The traces and design notes are kept because the +mechanics still run — on the signal they were always sound for. ### `converged` (kind 0) @@ -440,22 +493,29 @@ rather than stated only here — which is how it survived. ## Canonical examples -### Iterating at a low-entropy value → `converged` +### A held constant certifies — at any magnitude ```eigenscript x is 1000000 for i in range of 12: - x is 1000000 # same value 12 times → window fills with dH=0 + x is 1000000 # same value 12 times → window fills with zero steps if converged: - print of "converged" # YES — full quiet window AND low entropy (large magnitude) + print of "converged" # YES — and the same holds for 5, 42, or 0.005 ``` -### Newton's sqrt on an information-rich fixed point → `equilibrium` +Before #861, whether a constant could certify depended on its magnitude +(`entropy < h_low` admitted only `|x| > ~76` and `|x| < ~0.013`). The +value route reads motion, so magnitude is irrelevant. -`sqrt(2)` settles to `1.41421…` and `dH → 0`, so the window goes quiet and -zero-mean — but `1.41421…` is information-rich (`entropy >= h_low`), so it -reports `equilibrium`, not `converged`. See -`tests/test_windowed_converged.eigs` WC4. +### Newton's sqrt certifies `converged` (#861) + +`sqrt(2)` settles to `1.41421…`; once a full window of relative steps sits +under the deadband it reports **`converged`** — the textbook example of +convergence is certifiable. (Before #861 the `entropy < h_low` clause +blocked every limit in `[~0.013, 76]`, and this section documented the +blindness as intended behavior: "it reports `equilibrium`, not +`converged`." See `tests/test_windowed_converged.eigs` WC4, now pinned to +the certification.) ### Short trajectories never fire @@ -502,27 +562,32 @@ more than one binding is observed in scope. ### Gentle, monotonic convergence -`loop while not converged` reads as the obvious convergence idiom, and it -is correct — but only for a value that converges *gently and -monotonically*. Three properties of the entropy model surprise real solvers -(all surfaced building `dynamics`, the observer-heavy dynamical-systems lab -that is the first heavy consumer of the windowed predicates; findings -F-DYN-2 and F-DYN-6). Every trace below is a real run. +`loop while not converged` reads as the obvious convergence idiom, and +since #861 it is correct for any numeric value that actually settles — +gently, steeply, oscillating on the way in, at any magnitude. What +remains worth knowing: the tolerance semantics (settled ≠ arrived), the +observation-cadence rule, and the divergent-input guard. Every trace +below is a real run. ### Regime tracks per-step `dH`, not distance to the limit -A predicate classifies the *trajectory* by `|dH|` against `dh_zero` / -`dh_small`. A quantity that is still moving but observed in tiny per-step -increments has `|dH| < dh_zero` and reads settled — `equilibrium` or even -`converged` — while still far from its limit: +A predicate classifies the *trajectory* by its steps against the +deadband. A quantity that is still moving but observed in tiny per-step +increments has every relative step under `dh_zero` and reads settled — +while still far from its limit: ```eigenscript x is 100.0 for i in range of 20: - x is x * 0.999 # genuine motion, but each step is tiny -report of x # "converged" — yet x is still ~98, not ~0 + x is x * 0.999 # genuine motion, but each step is ~0.1% +report of x # "converged" — settled AT THE DEADBAND; x is ~98, not ~0 ``` +This is the tolerance semantics, not a defect: `converged` means every +recent step is below the tolerance, and per-step motion of 0.1% at the +default `dh_zero = 0.001` is exactly the boundary. Tighten the deadband +(`set_observer_thresholds`) or fix the cadence: + The lesson is about **observation cadence**: observe at a rate matched to the dynamics, not once per micro-step. The robust pattern is to advance the system several substeps *unobserved* and observe the quantity once per @@ -531,133 +596,79 @@ runs `SUB` integration substeps `unobserved`, then observes once per frame — without this, a damped oscillator, a diverging one, and a steady oscillation all read `equilibrium` alike). -### Entropy peaks at `|value| = 1`, so a shrinking value can read `diverging` - -Entropy is the binary entropy of `p = 1/(1+|x|)` -(`compute_entropy_impl`, eigenscript.c): it is **highest at `|x| = 1`**, -where it reaches the maximum `1.0` — the *horizon* — and falls toward 0 as -`|x| → 0` or `|x| → ∞`. It is exactly `0` at `|x| = 0`, the home point, -which is the formula's own limit there. (Before #412 the runtime -special-cased `|x| == 1.0` to entropy `0`, the opposite of the formula's -value; that special case is gone — see -[OBSERVER.md](OBSERVER.md#settled-decisions-formerly-rough-edges). `0` and -`1` are the two ends of the scale, not two names for the same thing.) -So a value decaying from a large magnitude *toward* 1 -has **rising** entropy — `dH > 0` — and reads `diverging`, not `improving`, -even though it is "getting smaller": +### The entropy-peak artifacts are gone from numeric classification (#861) + +Entropy is the binary entropy of `p = 1/(1+|x|)` — highest at `|x| = 1` +(the *horizon*), falling toward 0 as `|x| → 0` or `|x| → ∞`. On the old +entropy route this made a value shrinking toward 1 read `diverging` +(rising H) and a runaway growing from 1 read `improving` (falling H) — +both artifacts of the signal, not the motion. The value route reads the +motion: ```eigenscript x is 100.0 for i in range of 13: - x is x * 0.7 # 100 → ~1: entropy climbs 0.10 → ~1.0 -report of x # "diverging" (rising information content) + x is x * 0.7 # 100 → ~1: steps contracting toward a limit +report of x # "improving" ``` -The mirror case matters more, because it is what a runaway looks like: a -value *growing* away from 1 has **falling** entropy — `dH < 0` — and reads -`improving`, the opposite of what is happening to it. - ```eigenscript x is 1.0 for i in range of 13: - x is x * 1.43 # 1 → ~105: entropy falls ~1.0 → 0.10 -report of x # "improving" (falling information content) + x is x * 1.43 # 1 → ~105: non-vanishing same-sign steps +report of x # "diverging" ``` -Both readings are the entropy signal doing exactly what it is defined to -do; neither is a statement about magnitude. This is why the value channel -(`report_value of x`) exists, and why `improving` is force-cleared once a -runaway reaches the saturation ceiling (see the saturation rule above) — -otherwise the wrong answer merely moves one band over. - -Do not equate "value decreasing" with `improving`/`converging`. The -observer measures information content, not magnitude; "more determined" -means lower entropy, which for `|x| > 1` means moving *away* from 1. - -### A fast residual settles at `equilibrium`, not `converged` - -`converged` is the strict band — on top of `equilibrium`'s zero-mean motion -it requires every `|dH| < dh_zero` *and* low entropy across the whole -window. A value **held** at a low-entropy constant from the start reaches -it (a value pinned at `0.0` for ten observations reports `converged`). But -a residual that *decays* into rest does **not**: empirically it reads -`equilibrium` and stays there — the real Gauss-Seidel residual below holds -`equilibrium` even once `change == 0`, verified out to iteration 25, long -after its window has gone quiet. The settling *history*, not just the final -value, decides `converged` vs `equilibrium` — so for an iterative residual, -do not wait for `converged`; treat `equilibrium` as settled too. Real -Gauss-Seidel on a 3×3 system `Ax = b` (`dynamics/solve.eigs`): +The formula and its horizon property are unchanged in the MEASUREMENT +(`where is x` at `1.0` is still the maximum) and still classify +non-numeric bindings; `H(x) ≡ H(1/x) ≡ H(−x)` (#862) remains a blind +spot of that signal, reachable via `classify of [t, "entropy"]`. -``` -# observing the residual `change`, report per iteration: - iter 1 change=0.921875 report=stable - iter 4 change=0.00854… report=stable - iter 7 change=1.67e-05 report=stable - iter 8 change=2.09e-06 report=equilibrium <- solved (x ≈ [1,1,1]) - iter 9+ change → 0 report=equilibrium (stays equilibrium, even - at change == 0 — never - reaches "converged") -``` +### An iterative residual certifies directly (#861) -So `loop while not converged` here **never terminates** — it runs to the -iteration cap on a system solved by iteration 8 (the recipe below fixes -this). +A residual that decays into rest now reads `converged` once its window +settles — the pre-#861 behavior this section used to document (Gauss- +Seidel's residual pinned at `equilibrium` forever, `loop while not +converged` running to the cap on a solved system) was the dead zone. +`dynamics/solve.eigs`' solvers exit through the predicate itself. -### An oscillatory residual flickers settled mid-swing +### Mid-swing samples read `oscillating`, then certify -A residual swinging toward its limit (PageRank power iteration) shows a -*single* `equilibrium` reading mid-swing, well before it is actually -settled. Real PageRank on a 3-node graph (`dynamics/solve.eigs`): +A residual swinging toward its limit (PageRank power iteration) reads +`oscillating` while the swings dominate and certifies once a full window +sits under the deadband. The pre-#861 flicker — a single spurious +`equilibrium` at iteration 2, requiring a debounce-and-hold recipe — came +from the entropy signal's instantaneous fallback; the routed classifier +does not produce it. -``` - iter 2 change=0.1667 report=equilibrium <- transient! true answer - is ~25 iters away - iter 4 change=0.0833 report=stable - iter 5 change=0.0417 report=stable - ... (stable / equilibrium / improving alternate as it swings) ... - iter 27 change=4.07e-05 report=equilibrium <- genuinely settled -``` +### Convergence-loop recipe -A naive "stop on the first settled reading" quits at iteration 2 with -`change ≈ 0.17` — completely wrong. The fix is to **debounce**: require the -settled reading to *hold* for several consecutive iterations; a transient -blip resets the count. +```eigenscript +loop while not (converged of x): # named form: reads x, whatever else is assigned + x is next_step of x +``` -### Robust convergence-loop recipe +Two cases still deserve a guard: -Combine the two fixes — settled = `converged` OR `equilibrium`, plus a hold -counter. This is exactly what `dynamics/solve.eigs` uses across Jacobi, -Gauss-Seidel, power iteration, and PageRank (`HOLD = 3`): +- **Input that may genuinely diverge.** `converged` (correctly) never + fires on a runaway. The bare form (`loop while not converged`) carries + the observer stall backstop — ~100 quiet iterations end the loop with + `__loop_exit__ == "stalled"` — but the **named** form deliberately does + not (it must not false-halt on the global alias), so give it an + absolute cap: ```eigenscript -define settled(status) as: - if status == "converged": - return 1 - if status == "equilibrium": - return 1 - return 0 - -hold is 0 it is 0 -loop while hold < 3: # require the settled reading to hold 3× - # advance the system, then assign the residual you test (`change`) LAST, - # immediately before `report` — a bare predicate / report reads the most - # recently assigned top-level value (see Inputs: `g_last_observer`), so an - # intervening assignment repoints it. - change is next_residual of state - status is report of change - if (settled of status) == 1: - hold is hold + 1 - else: - hold is 0 +loop while not (converged of x): + x is next_step of x it is it + 1 - if it >= max_iters: # always keep an absolute cap as a backstop - hold is 3 + if it >= max_iters: + throw of "did not settle" ``` -Use the bare `loop while not converged` only for a value you know converges -gently and monotonically; for any iterative residual, reach for the -settled-plus-hold form above. +- **Tolerance tighter than the default.** `converged` fires at the + deadband (`dh_zero`, default 0.1% relative). For a tighter answer, + lower it first: `set_observer_thresholds of [1e-6, 1e-5, 0.1]`. ## Cost diff --git a/docs/SPEC.md b/docs/SPEC.md index dbbe4241..9bf01dba 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1120,14 +1120,18 @@ print of converged 1 ``` -(The starting value matters: the predicate reads the observed value's -entropy, which is highest for magnitudes near 1 and low for both tiny -and huge magnitudes — a loop seeded with an already-low-entropy value -like `100` converges immediately.) - -**Convergence-halting is opt-in.** A `loop while` is auto-halted on -observer convergence (a settled, high-entropy value for ~100 iterations) -**only when its condition is observer-based** — i.e. references a +For a **numeric** binding the predicates classify the value's own +trajectory (#861): the observed signal is the relative step +`Δv/(1+|v|)` — the standard mixed-tolerance stopping criterion, with +the settle deadband as the tolerance — so the starting value and the +limit's magnitude do not matter. A loop converging to `5`, `5000` or +`0.005` certifies identically. Non-numeric bindings (strings, +containers) classify their entropy trajectory as before; the entropy +MEASUREMENT (`where is x`) is unchanged for everything. + +**Convergence-halting is opt-in.** A `loop while` is auto-halted on a +quiet observer trajectory (~100 iterations without motion, at any +entropy) **only when its condition is observer-based** — i.e. references a predicate, as in `loop while not converged`. A plain loop whose condition is an ordinary expression (`loop while i < n`, `loop while not done`) is **never** halted by the observer; it runs until @@ -1136,9 +1140,15 @@ explicitly armed sandbox budget (`sandbox_run`'s `max_iter`); ordinary execution never truncates a loop. This keeps loop termination compositional: a plain loop can't be cut short by what its body — or a function it calls — happens to assign to the global observer. - -**`report of x`** names the most specific band true of the same entropy -trajectory, resolving `oscillating` → `diverging` → `improving` → +The division of labour (#861): `converged` ends an observer loop when the +value settles at the deadband; `stalled` ends it when 100 quiet +iterations pass without certification (a runaway pinned at the +saturation ceiling, sub-deadband drift); `__loop_exit__` records which +one happened. + +**`report of x`** names the most specific band true of the same +trajectory the predicates read (value channel for numerics, entropy +otherwise — #861), resolving `oscillating` → `diverging` → `improving` → `converged` → `equilibrium` → `stable`. At a full window it agrees with the bare predicates by construction: it either names a band whose predicate is true, or — when a full window matches none of them — returns `moving`. The @@ -1222,15 +1232,25 @@ diverging 0 ``` -**The value channel** (`report_value of x`) classifies the value's own -trajectory rather than its entropy, over a 10-sample window of relative -steps `Δv/(1+|v|)` — labels `oscillating`, `diverging`, `converged`, -`stable`, `moving`, `equilibrium`. Two raw-step rules (#422) run before the -relative verdicts: non-vanishing same-sign steps are `diverging` (an -additive runaway whose relative step vanishes is still unbounded), and -non-vanishing alternating steps are `oscillating` (a perpetual oscillation -below the relative deadband is still an oscillation); decaying steps settle -as usual. +**The value channel** (`report_value of x`) is, since #861, the same +classifier the predicate words and `report` use on numeric bindings — +the two surfaces cannot disagree about one trajectory. Over a 10-sample +window of relative steps `Δv/(1+|v|)`: `converged` is a full window all +under the settle deadband; `stable` all under the small-motion band; +`equilibrium` zero-mean, variance under deadband²; `improving` monotone +steps contracting geometrically (a summable tail — genuinely closing on +a limit); `diverging` the #422 raw rule (non-vanishing same-sign steps — +an additive runaway whose relative step vanishes is still unbounded) or +a value at the saturation ceiling; `oscillating` deadband sign-flips, +non-vanishing alternation (a perpetual oscillation below the deadband is +still an oscillation), or window-scale folding (net travel small against +path length — a sinusoid sampled slower than its half-period). +`converged` is a **stopping criterion, not a proof**: vanishing steps do +not imply a limit (the harmonic series' steps vanish; its sum does not +converge), so it means *settled at the deadband* — the strongest claim a +finite window supports. The deadband is the tolerance knob +(`set_observer_thresholds`); the structure rules are deliberately +threshold-free. **Trajectories cross call boundaries as snapshots** (#421). Observer state is binding-identity — a value passed to a function arrives with no history — diff --git a/lib/simulation.eigs b/lib/simulation.eigs index eb7ac6dc..ddc16ef4 100644 --- a/lib/simulation.eigs +++ b/lib/simulation.eigs @@ -44,14 +44,21 @@ define simulate_to_equilibrium(step_fn, state, max_steps) as: # ---- analyze_stability: classify behavior of a time series via observer ---- define analyze_stability(time_series) as: - tracker is 0 + # #861: seed from the first element — `tracker is 0` planted an artificial + # 0 -> first-value jump whose step dominated the whole 10-sample window, + # so a constant series read "moving" for its entire life. + tracker is time_series[0] statuses is [] - for i in range of (len of time_series): + local i is 1 + loop while i < (len of time_series): tracker is time_series[i] append of [statuses, report of tracker] + i is i + 1 - phase_counts is {"improving": 0, "diverging": 0, "stable": 0, "equilibrium": 0, "oscillating": 0, "converged": 0} + # "moving" (#735) and "opaque" (#708) postdate this map; without them a + # label the observer actually answers was silently dropped from the vote. + phase_counts is {"improving": 0, "diverging": 0, "stable": 0, "equilibrium": 0, "oscillating": 0, "converged": 0, "moving": 0, "opaque": 0} for i in range of (len of statuses): s is statuses[i] if has_key of [phase_counts, s]: diff --git a/src/builtins.c b/src/builtins.c index 2269da87..45840fee 100644 --- a/src/builtins.c +++ b/src/builtins.c @@ -759,8 +759,11 @@ Value* builtin_classify(Value *arg) { t ? val_type_name(t->type) : "none"); return make_null(); } + /* #861: the explicit channels must not route — "entropy" answers from the + * entropy classifier even for a numeric trajectory (that is the point of + * asking for it by name); "value" was always the numeric classifier. */ const char *label = (strcmp(channel, "entropy") == 0) - ? observer_slot_report(&s) + ? observer_slot_report_entropy(&s) : observer_slot_report_value(&s); Value *out = make_str(label ? label : "equilibrium"); free(s.dh_window); diff --git a/src/eigenscript.c b/src/eigenscript.c index 609e1b6d..28eb9efc 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -449,33 +449,42 @@ static double observer_slot_vr_get(const ObserverSlot *s, size_t offset_back) { * 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. */ +/* #861: the raw tests run on PARTIAL windows too, from 4 samples (2+2 after + * the half split). The motion bands have always been early-warning — the + * entropy trajectory predicates fired from 3 samples — and requiring a full + * 10-window here would mean a 6-step monotone runaway answers `diverging` + * false where the old semantics said true. The full-window requirement + * stays where it belongs: on the REST bands, which certify. */ static int observer_slot_raw_nonvanishing(const ObserverSlot *s) { - if (s->v_window_count < OBSERVER_WINDOW_N) return 0; + size_t cnt = s->v_window_count; + if (cnt < 4) return 0; double floor_eps = 4.0 * DBL_EPSILON * (1.0 + fabs(s->last_value)); - size_t half = OBSERVER_WINDOW_N / 2; + size_t half = cnt / 2; double recent = 0.0, older = 0.0; - for (size_t i = 0; i < half; i++) recent += fabs(observer_slot_vr_get(s, i)); - for (size_t i = half; i < OBSERVER_WINDOW_N; i++) older += fabs(observer_slot_vr_get(s, i)); + for (size_t i = 0; i < half; i++) recent += fabs(observer_slot_vr_get(s, i)); + for (size_t i = half; i < cnt; i++) older += fabs(observer_slot_vr_get(s, i)); recent /= (double)half; - older /= (double)(OBSERVER_WINDOW_N - half); + older /= (double)(cnt - half); if (older <= floor_eps || recent <= floor_eps) return 0; return recent >= older * (1.0 - 1e-9); } static int observer_slot_raw_diverging(const ObserverSlot *s) { if (!observer_slot_raw_nonvanishing(s)) return 0; + size_t cnt = s->v_window_count; double first = observer_slot_vr_get(s, 0); - for (size_t i = 1; i < OBSERVER_WINDOW_N; i++) + for (size_t i = 1; i < cnt; i++) if (observer_slot_vr_get(s, i) * first <= 0.0) return 0; return 1; } static int observer_slot_raw_oscillating(const ObserverSlot *s) { if (!observer_slot_raw_nonvanishing(s)) return 0; + size_t cnt = s->v_window_count; const int FLIPS = (OBSERVER_WINDOW_N + 2) / 3; double floor_eps = 4.0 * DBL_EPSILON * (1.0 + fabs(s->last_value)); int flips = 0; - for (size_t i = 0; i + 1 < OBSERVER_WINDOW_N; i++) { + for (size_t i = 0; i + 1 < cnt; i++) { double a = observer_slot_vr_get(s, i); double b = observer_slot_vr_get(s, i + 1); if (a * b < 0.0 && fabs(a) > floor_eps && fabs(b) > floor_eps) flips++; @@ -495,6 +504,7 @@ void observer_slot_record_value(ObserverSlot *s, double v) { } s->last_value = v; s->v_used = 1; + s->v_last = 1; /* #861: this binding's current trajectory is numeric */ } /* #694: grow e->obs to cover `idx`. Defined with the #607 module-env MT @@ -533,6 +543,13 @@ void observer_slot_update(Env *e, int idx, Value *newval) { if (newval && newval->type == VAL_NUM) { ObserverSlot *s = env_obs_slot(e, idx); if (s) observer_slot_record_value(s, newval->data.num); + } else { + /* #861: a non-numeric assignment ends the numeric trajectory's claim + * on this binding — predicates fall back to the entropy channel until + * a number is assigned again (the recorded windows are kept, matching + * report_value's documented history semantics). */ + ObserverSlot *s = env_obs_slot(e, idx); + if (s) s->v_last = 0; } } @@ -586,8 +603,269 @@ static int observer_slot_saturated(const ObserverSlot *s) { return (s && s->v_used && fabs(s->last_value) >= EIGS_NUM_MAX) ? 1 : 0; } +/* ==== #861: the numeric predicate family — the value channel as the ==== + * ==== authority for numeric bindings. ==== + * + * The entropy channel cannot be a convergence detector for numbers. H is a + * function of |x| alone, so any clause on H is a clause on MAGNITUDE: the + * `entropy < h_low` term in `converged` made every |x| > ~76 a permissive + * region (a geometric runaway certified `converged` at x ~= 2.9e5) and + * every limit in [~0.013, 76] a dead zone (Newton's method to sqrt(2) + * could never certify). Measured by tests/test_convergence_oracle.eigs: + * entropy channel 19/27 against analytic ground truth, value channel + * 25/27. The same computation targeting 5000, 5 and 0.005 got three + * different verdicts from the entropy channel — the units decided. + * + * The value channel's relative step Δv/(1+|v|) is the standard numerical + * stopping criterion (|Δx| <= atol + rtol·|x| with atol = rtol), which is + * what `converged` should have meant all along. So: when a binding's most + * recent observed assignment is numeric (v_last), the six predicate words + * and `report` read the value trajectory below. Non-numeric bindings + * (strings, containers) keep the entropy classifiers — entropy is the only + * signal they have, and none of the measured failures involve them. + * + * The entropy MEASUREMENT is untouched: `where`/`why`/`how`, trajectory + * snapshots, container folds, and the tape all record exactly what they + * recorded before. This routes classification; it does not change what is + * measured. (The tape/DAP/step surfaces already classified from the value + * channel — tape_classify_at has called observer_slot_report_value since + * it existed — so live `report` used to disagree with `--step` on the + * same trajectory. It no longer does.) + * + * Honesty bound, documented in PREDICATES.md: vanishing steps do not + * imply a limit (the harmonic series' steps vanish; its sum does not + * converge), so `converged` is a STOPPING CRITERION — "settled at the + * deadband" — not a proof. No finite-window detector can do better. */ + +/* Window flags: every |rel step| under dh_zero / dh_small. */ +static void obs_num_flags(const ObserverSlot *s, int *all_zero, int *all_small) { + size_t cnt = s->v_window_count; + *all_zero = 1; *all_small = 1; + for (size_t i = 0; i < cnt; i++) { + double w = fabs(observer_slot_v_get(s, i)); + if (w >= g_obs_dh_zero) *all_zero = 0; + if (w >= g_obs_dh_small) *all_small = 0; + } +} + +/* Relative-step sign-flip oscillation — the head test report_value has + * always run (flips above the deadband, >= FLIPS of them). */ +static int obs_num_rel_oscillating(const ObserverSlot *s) { + size_t cnt = s->v_window_count; + if (cnt < 3) return 0; + const int FLIPS = (OBSERVER_WINDOW_N + 2) / 3; + int flips = 0; + for (size_t i = 0; i + 1 < cnt; i++) { + double a = observer_slot_v_get(s, i); + double b = observer_slot_v_get(s, i + 1); + if (a * b < 0.0 && fabs(a) > g_obs_dh_zero && fabs(b) > g_obs_dh_zero) flips++; + } + return (flips >= FLIPS) ? 1 : 0; +} + +/* Bounded oscillation at the WINDOW scale (#861): a sinusoid sampled a few + * times per period flips step-sign only at each half-period — 2 reversals + * per period, under the FLIPS threshold the per-sample tests use — so the + * canonical oscillator read "moving". The defining property that separates + * oscillation from drift is that the path folds back on itself: net travel + * is small against path length. Full window, motion above the fp floor + * (non-vanishing — a damped approach falls through to the settle bands), + * at least 2 direction reversals, and |net| <= 0.3 x path. The 0.3 bound: + * a pure sampled sinusoid nets ~0 over any full window; a drift-with- + * wiggle nets ~its path and stays out. */ +static int obs_num_bounded_oscillating(const ObserverSlot *s) { + size_t cnt = s->v_window_count; + if (cnt < OBSERVER_WINDOW_N) return 0; + /* No non-vanishing gate here, deliberately: an underdamped oscillator's + * x DECAYS while oscillating, and its oscillation is the fact worth + * reporting mid-flight. The handoff to the settle bands is the all-under- + * deadband guard below — cos's fixed-point iteration alternates sign all + * the way down, and once its steps sit under dh_zero it is SETTLED, not + * oscillating (the corpus's case 5 caught exactly that misfire). */ + { + int az, asm_; + obs_num_flags(s, &az, &asm_); + if (az) return 0; + } + double floor_eps = 4.0 * DBL_EPSILON * (1.0 + fabs(s->last_value)); + double net = 0.0, path = 0.0, prev = 0.0; + int reversals = 0, have_prev = 0; + for (size_t i = 0; i < cnt; i++) { + double d = observer_slot_vr_get(s, i); + net += d; + path += fabs(d); + if (fabs(d) > floor_eps) { + if (have_prev && d * prev < 0.0) reversals++; + prev = d; + have_prev = 1; + } + } + if (path <= floor_eps) return 0; + return (reversals >= 2 && fabs(net) <= 0.3 * path) ? 1 : 0; +} + +static int obs_num_oscillating(const ObserverSlot *s) { + return obs_num_rel_oscillating(s) || observer_slot_raw_oscillating(s) + || obs_num_bounded_oscillating(s); +} + +/* Divergence: position at the saturation ceiling (claimed before the window + * guard — the evidence is where the value IS, and the clamp has already + * flattened the window), or the #422 raw test: non-vanishing same-sign + * steps sum without bound no matter how small Δv/|v| looks. */ +static int obs_num_diverging(const ObserverSlot *s) { + if (observer_slot_saturated(s)) return 1; + return observer_slot_raw_diverging(s); +} + +/* Improving: the step magnitudes are CONTRACTING — recent-half mean |Δv| + * at most CONTRACT x the older-half mean. Contraction at any ratio < 1 + * per step means the remaining travel is a summable (geometric-class) + * tail: the trajectory is genuinely closing on a limit, which is what + * "improving" should claim for a number. The 0.7 half-window bar (~0.93 + * per step over a 10-window) is what separates that class from + * harmonic-family stagnation, whose step ratio -> 1: harmonic's half- + * window ratio at n=60 is ~0.91 and rising toward 1, so it stays outside + * the band instead of being promised a limit it does not have. */ +static int obs_num_improving(const ObserverSlot *s) { + if (observer_slot_saturated(s)) return 0; + size_t cnt = s->v_window_count; + if (cnt < 4) return 0; /* early-warning band: same + * 4-sample floor as the raw + * tests, not the rest bands' + * full window */ + int all_zero, all_small; + obs_num_flags(s, &all_zero, &all_small); + if (all_zero) return 0; /* already settled -> converged's claim */ + if (obs_num_oscillating(s)) return 0; /* motion bands are exclusive */ + /* Monotone: every raw step shares one sign. "Improving" claims a clean + * approach toward a limit; measurement jitter (mixed signs) can land a + * chance half-window contraction — a lab sensor reading 45.2, 44.8, + * 44.9, 44.7, 44.85 flickered into `improving` on its final step and + * broke a trailing-rest count. A genuine approach (Newton, geometric + * decay) is monotone; a damped oscillation belongs to `oscillating`. */ + { + double first = observer_slot_vr_get(s, 0); + for (size_t i = 1; i < cnt; i++) + if (observer_slot_vr_get(s, i) * first <= 0.0) return 0; + } + const double CONTRACT = 0.7; + double floor_eps = 4.0 * DBL_EPSILON * (1.0 + fabs(s->last_value)); + size_t half = cnt / 2; + double recent = 0.0, older = 0.0, recent_max = 0.0, older_max = 0.0; + for (size_t i = 0; i < half; i++) { + double m = fabs(observer_slot_vr_get(s, i)); + recent += m; + if (m > recent_max) recent_max = m; + } + for (size_t i = half; i < cnt; i++) { + double m = fabs(observer_slot_vr_get(s, i)); + older += m; + if (m > older_max) older_max = m; + } + recent /= (double)half; + older /= (double)(cnt - half); + if (older <= floor_eps) return 0; /* nothing to contract from */ + if (recent <= floor_eps) return 0; /* motion already died — the + * quiescent bands' claim, not + * "improving": improving means + * STILL MOVING and contracting */ + /* Both the mean AND the largest step must contract. The mean alone is + * alignment-sensitive: a period-4 cycle (0, +27, 0, -27, ...) can land + * two spikes in the recent half and three in the older, "contracting" + * the mean by chance. A cycle's largest step never shrinks; a genuine + * geometric approach shrinks its largest step along with its mean. */ + return (recent <= older * CONTRACT && recent_max <= older_max * CONTRACT) ? 1 : 0; +} + +/* Converged: the mixed-tolerance stopping criterion — a full window of + * relative steps all under the settle deadband — with the raw-step + * structure tests as guards (a linear runaway's Δv/(1+|v|) vanishes while + * its raw steps do not). */ +static int obs_num_converged(const ObserverSlot *s) { + if (observer_slot_saturated(s)) return 0; + if (s->v_window_count < OBSERVER_WINDOW_N) return 0; + int all_zero, all_small; + obs_num_flags(s, &all_zero, &all_small); + if (!all_zero) return 0; + if (observer_slot_raw_diverging(s) || observer_slot_raw_oscillating(s)) return 0; + return 1; +} + +/* Equilibrium: balanced motion — window mean ~ 0 and variance under the + * deadband², individual steps possibly larger. converged => equilibrium + * (all-under-deadband forces both), preserving the quiescent lattice. */ +static int obs_num_equilibrium(const ObserverSlot *s) { + if (observer_slot_saturated(s)) return 0; + if (s->v_window_count < OBSERVER_WINDOW_N) return 0; + if (observer_slot_raw_diverging(s) || observer_slot_raw_oscillating(s)) return 0; + double sum = 0.0; + for (size_t i = 0; i < OBSERVER_WINDOW_N; i++) sum += observer_slot_v_get(s, i); + double mean = sum / (double)OBSERVER_WINDOW_N; + if (fabs(mean) >= g_obs_dh_zero) return 0; + double var = 0.0; + for (size_t i = 0; i < OBSERVER_WINDOW_N; i++) { + double d = observer_slot_v_get(s, i) - mean; + var += d * d; + } + var /= (double)OBSERVER_WINDOW_N; + return (var < g_obs_dh_zero * g_obs_dh_zero) ? 1 : 0; +} + +/* Stable: small motion — every relative step under dh_small, no strong + * consecutive sign flips. May carry steady drift (harmonic-class vanishing + * steps live here: real motion, no certified limit). converged => stable. */ +static int obs_num_stable(const ObserverSlot *s) { + if (observer_slot_saturated(s)) return 0; + if (s->v_window_count < OBSERVER_WINDOW_N) return 0; + if (observer_slot_raw_diverging(s) || observer_slot_raw_oscillating(s)) return 0; + int all_zero, all_small; + obs_num_flags(s, &all_zero, &all_small); + if (!all_small) return 0; + for (size_t i = 0; i + 1 < OBSERVER_WINDOW_N; i++) { + double a = observer_slot_v_get(s, i); + double b = observer_slot_v_get(s, i + 1); + if (a * b < 0.0 && fabs(a) > g_obs_dh_zero && fabs(b) > g_obs_dh_zero) return 0; + } + return 1; +} + +/* Numeric `report`: the canonical priority order over the family above, + * `moving` for a full window in no band, and report_value's historical + * partial-window fallback preserved verbatim. */ +static const char *obs_num_report(const ObserverSlot *s) { + if (!s || !s->v_used) return "equilibrium"; /* no numeric trajectory */ + size_t cnt = s->v_window_count; + if (cnt == 0) return "equilibrium"; /* one value seen, no step yet */ + if (obs_num_oscillating(s)) return "oscillating"; + if (obs_num_diverging(s)) return "diverging"; + if (obs_num_improving(s)) return "improving"; /* partial-capable, like + * the two bands above */ + if (cnt >= OBSERVER_WINDOW_N) { + if (obs_num_converged(s)) return "converged"; + if (obs_num_equilibrium(s)) return "equilibrium"; + if (obs_num_stable(s)) return "stable"; + return "moving"; + } + int all_zero, all_small; + obs_num_flags(s, &all_zero, &all_small); + (void)all_zero; + if (all_small) return "stable"; + return "moving"; +} + +/* Route: value channel iff the most recent observed assignment was numeric. */ +static int obs_route_num(const ObserverSlot *s) { + return s && s->v_used && s->v_last; +} + +/* Public predicates: dispatch numeric bindings to the value-channel family + * above (#861); everything else keeps the entropy classifiers below. The + * saturation gates that #889 put on the entropy bodies are gone — a live + * numeric binding always routes to the value channel, where the family + * handles saturation itself, so the entropy route can no longer see one. */ int observer_slot_converged(const ObserverSlot *s) { - if (observer_slot_saturated(s)) return 0; /* #861 */ + if (obs_route_num(s)) return obs_num_converged(s); if (!s || s->dh_window_count < OBSERVER_WINDOW_N) return 0; for (size_t i = 0; i < OBSERVER_WINDOW_N; i++) if (fabs(observer_slot_window_get(s, i)) >= g_obs_dh_zero) return 0; @@ -595,7 +873,7 @@ int observer_slot_converged(const ObserverSlot *s) { } int observer_slot_equilibrium(const ObserverSlot *s) { - if (observer_slot_saturated(s)) return 0; /* #861 */ + if (obs_route_num(s)) return obs_num_equilibrium(s); if (!s || s->dh_window_count < OBSERVER_WINDOW_N) return 0; double sum = 0.0; for (size_t i = 0; i < OBSERVER_WINDOW_N; i++) sum += observer_slot_window_get(s, i); @@ -613,12 +891,7 @@ int observer_slot_equilibrium(const ObserverSlot *s) { /* Slot mirrors of the remaining four windowed predicates — identical logic to * the observer_*(Value*) versions above, reading the slot's window/entropy. */ int observer_slot_improving(const ObserverSlot *s) { - /* #861: H decreases as |x| grows past 1, so a runaway climbing toward the - * ceiling shows a run of negative dH — "improving" — right up until it - * pins and the window flattens to "converged". Both readings describe the - * clamp, not the trajectory. `report` tries improving before converged, - * so leaving this ungated just moves the wrong answer one band over. */ - if (observer_slot_saturated(s)) return 0; + if (obs_route_num(s)) return obs_num_improving(s); size_t cnt = s ? s->dh_window_count : 0; if (cnt < 3) return 0; double sum = 0.0; int down = 0; @@ -631,10 +904,7 @@ int observer_slot_improving(const ObserverSlot *s) { } int observer_slot_diverging(const ObserverSlot *s) { - /* #861: claimed before the window guard — the evidence is the value's - * position at the ceiling, not the shape of the (flattened) window, so - * this must answer on a partial window too. */ - if (observer_slot_saturated(s)) return 1; + if (obs_route_num(s)) return obs_num_diverging(s); size_t cnt = s ? s->dh_window_count : 0; if (cnt < 3) return 0; double sum = 0.0; int up = 0; @@ -647,6 +917,7 @@ int observer_slot_diverging(const ObserverSlot *s) { } int observer_slot_oscillating(const ObserverSlot *s) { + if (obs_route_num(s)) return obs_num_oscillating(s); size_t cnt = s ? s->dh_window_count : 0; if (cnt < 3) return 0; const int FLIPS = (OBSERVER_WINDOW_N + 2) / 3; @@ -660,7 +931,7 @@ int observer_slot_oscillating(const ObserverSlot *s) { } int observer_slot_stable(const ObserverSlot *s) { - if (observer_slot_saturated(s)) return 0; /* #861 */ + if (obs_route_num(s)) return obs_num_stable(s); size_t cnt = s ? s->dh_window_count : 0; if (cnt < OBSERVER_WINDOW_N) return 0; if (s->entropy < g_obs_h_low) return 0; @@ -676,7 +947,10 @@ int observer_slot_stable(const ObserverSlot *s) { /* Slot mirror of builtin_report — same priority order and partial-window * fallback, reading the slot trajectory instead of a Value's. */ -const char *observer_slot_report(const ObserverSlot *s) { +/* The entropy-channel report — the classifier for non-numeric bindings, and + * the explicit `classify of [t, "entropy"]` channel. Routed callers go + * through observer_slot_report below. */ +const char *observer_slot_report_entropy(const ObserverSlot *s) { if (!s) return NULL; if (observer_slot_oscillating(s)) return "oscillating"; if (observer_slot_diverging(s)) return "diverging"; @@ -702,56 +976,27 @@ const char *observer_slot_report(const ObserverSlot *s) { return "stable"; } -/* #294 value-signal report. Classifies the binding's VALUE trajectory using the - * SAME windowed logic and thresholds as the entropy report above — only the - * observed signal differs (relative value-deltas, not entropy-deltas). This is - * the point of the experiment: the windowed approach is sound; the entropy - * SIGNAL is a lossy proxy that goes blind to value-oscillation in flat-entropy - * regions. Oscillation is detected by sign-flips (scale-free); settling by the - * relative-step magnitude (so thresholds mean the same across value scales). */ +/* #861: `report` routes exactly as the predicates do — the value channel for + * a binding whose latest observed assignment is numeric, entropy otherwise. + * At a full window it agrees with the bare predicates by construction on + * BOTH routes (each route's report tests that route's own family in the + * canonical priority order). */ +const char *observer_slot_report(const ObserverSlot *s) { + if (obs_route_num(s)) return obs_num_report(s); + return observer_slot_report_entropy(s); +} + +/* #294 value-signal report — since #861 this is the numeric classifier + * itself, shared with `report`/the predicates on numeric bindings, so the + * explicit `report_value of x` surface and the routed words can never + * disagree about the same trajectory. The old body lives on as + * obs_num_report; the historical head cases (no trajectory / one value → + * "equilibrium") and the partial-window fallback are preserved there. */ const char *observer_slot_report_value(const ObserverSlot *s) { - if (!s || !s->v_used) return "equilibrium"; /* no numeric trajectory */ - size_t cnt = s->v_window_count; - if (cnt == 0) return "equilibrium"; /* one value seen, no step yet */ - if (cnt >= 3) { - const int FLIPS = (OBSERVER_WINDOW_N + 2) / 3; /* same as entropy channel */ - int flips = 0; - for (size_t i = 0; i + 1 < cnt; i++) { - double a = observer_slot_v_get(s, i); - double b = observer_slot_v_get(s, i + 1); - if (a * b < 0.0 && fabs(a) > g_obs_dh_zero && fabs(b) > g_obs_dh_zero) flips++; - } - if (flips >= FLIPS) return "oscillating"; - } - /* #422: the raw-step structure tests run BEFORE the relative verdicts — - * relative normalization is exactly what hides these two classes. A - * sub-deadband perpetual oscillation (x → -x seeded tiny, or a fixed - * absolute swing around a large offset) is 'oscillating'; non-vanishing - * same-sign steps (a linear/polynomial runaway whose Δv/|v| → 0) are - * 'diverging' — the value channel's first use of that label. */ - if (observer_slot_raw_oscillating(s)) return "oscillating"; - /* #861: the value channel goes blind at the ceiling for the same reason - * the entropy channel does — past EIGS_NUM_MAX the relative step is - * exactly 0, so `all_zero` below reads "converged". #422 closed on the - * reasoning that this channel catches fixed-relative-step growth; that - * holds only while the value can still grow. Placed after the oscillation - * tests to keep this channel's own priority order (oscillating before - * diverging), so a value flipping ±EIGS_NUM_MAX still reads - * "oscillating" here — the entropy channel cannot see that flip at all - * (H(x) ≡ H(−x), #862) and answers "diverging". */ - if (observer_slot_saturated(s)) return "diverging"; - if (observer_slot_raw_diverging(s)) return "diverging"; - int all_zero = 1, all_small = 1; - for (size_t i = 0; i < cnt; i++) { - double w = fabs(observer_slot_v_get(s, i)); - if (w >= g_obs_dh_zero) all_zero = 0; - if (w >= g_obs_dh_small) all_small = 0; - } - if (cnt >= OBSERVER_WINDOW_N && all_zero) return "converged"; - if (all_small) return "stable"; - return "moving"; + return obs_num_report(s); } + /* ---- #711: query-time entropy — the current-state channel. ------------- * The stored slot entropy is a snapshot taken at the last ASSIGNMENT, so an * in-place mutation (dict_set, append, an indexed scalar store) left it diff --git a/src/eigenscript.h b/src/eigenscript.h index e0dec54e..3af4da39 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -272,6 +272,13 @@ typedef struct ObserverSlot { * relative normalization erases */ uint8_t v_window_head, v_window_count; uint8_t v_used; /* 1 once a numeric value has been recorded */ + uint8_t v_last; /* #861: 1 iff the MOST RECENT observed + * assignment was numeric. The predicates and + * `report` route to the value channel exactly + * when this is set — a binding rebound from + * number to string falls back to the entropy + * channel instead of answering from a stale + * numeric trajectory. */ } ObserverSlot; struct Env { @@ -376,6 +383,9 @@ int observer_slot_stable(const struct ObserverSlot *s); /* Classify a slot into a report band (mirrors builtin_report's priority). * Returns a static string; NULL if the slot is unusable. */ const char *observer_slot_report(const struct ObserverSlot *s); +/* The entropy-channel classifier without #861 routing — for the explicit + * `classify of [t, "entropy"]` channel and non-numeric bindings. */ +const char *observer_slot_report_entropy(const struct ObserverSlot *s); /* #294 value-signal report: classify the binding's VALUE trajectory (not its * entropy) — "oscillating"/"converged"/"stable"/"moving"/"equilibrium". */ const char *observer_slot_report_value(const struct ObserverSlot *s); diff --git a/src/vm.c b/src/vm.c index a17f2765..a92d0b85 100644 --- a/src/vm.c +++ b/src/vm.c @@ -1728,8 +1728,24 @@ int eigs_loop_stall_step(Env *e) { int should_exit = 0; if (g_unobserved_depth == 0) { double dH, ent; + /* #861: quiet trajectory at ANY entropy. The old `ent >= h_low` + * clause existed because a quiet LOW-entropy trajectory used to be + * `converged`'s territory — the entropy-channel predicate fired + * there (often wrongly: that was the [77, 1e307] permissive region) + * and the stall only needed to cover the high-entropy remainder. + * With the predicates routed to the value channel, `converged` + * fires exactly where the VALUE settles, at any magnitude — and a + * runaway that saturates (or drifts forever below the deadband) + * now correctly never certifies. Those trajectories are entropy- + * quiet at LOW entropy, which the old clause excluded, so a bare + * `loop while not converged` around a runaway would spin forever + * (#772 removed the unconditional cap). The honest contract: + * `converged` ends the loop when the value settles; `stalled` ends + * it when the observer has watched 100 quiet iterations without + * certifying — and __loop_exit__ says which one happened. */ if (obs_stall_trajectory(&dH, &ent) - && fabs(dH) < g_obs_dh_zero && ent >= g_obs_h_low) { + && fabs(dH) < g_obs_dh_zero) { + (void)ent; g_loop_stall_count++; if (g_loop_stall_count >= 100) { g_loop_exit_reason = "stalled"; @@ -5198,8 +5214,14 @@ static Value *vm_run_ex(EigsChunk *chunk, Env *env, Task *resume) { int should_exit = 0; if (g_unobserved_depth == 0) { double dH, ent; + /* #861: quiet at ANY entropy — mirrors eigs_loop_stall_step + * (see the rationale there). The routed `converged` now owns + * every genuine settle; the stall is the catch-all for quiet + * trajectories the predicate refuses (saturated runaways, + * sub-deadband drift), which live at LOW entropy. */ if (obs_stall_trajectory(&dH, &ent) - && fabs(dH) < g_obs_dh_zero && ent >= g_obs_h_low) { + && fabs(dH) < g_obs_dh_zero) { + (void)ent; g_loop_stall_count++; if (g_loop_stall_count >= 100) { g_loop_exit_reason = "stalled"; diff --git a/tests/observer_corpus/golden/EigenScript__idioms.out b/tests/observer_corpus/golden/EigenScript__idioms.out index 32bc7b2e..495a9d5a 100644 --- a/tests/observer_corpus/golden/EigenScript__idioms.out +++ b/tests/observer_corpus/golden/EigenScript__idioms.out @@ -43,4 +43,4 @@ diana: index 3 frank: index -1 --- convergence with safety limit --- -Converged at step 219 x=0.0013227249887463285 +Converged at step 176 x=0.012004788991027214 diff --git a/tests/observer_corpus/golden/EigenScript__numerical.out b/tests/observer_corpus/golden/EigenScript__numerical.out index 5c1751d3..465587cf 100644 --- a/tests/observer_corpus/golden/EigenScript__numerical.out +++ b/tests/observer_corpus/golden/EigenScript__numerical.out @@ -5,33 +5,31 @@ sqrt(144) = 12 sqrt(2.5) = 1.5811388300841895 --- Bisection Method: x^3 - x - 2 = 0 --- -Root: 1.5213797068045678 -f(root) = 8.881784197001252e-16 +Root: 1.5213796943426132 +f(root) = -7.407122248892506e-08 --- Fixed Point: cos(x) = x --- -Fixed point: 0.7390851332151607 +Fixed point: 0.7390713652989449 Observer status: equilibrium --- Exponential Decay --- -Step 5: value=44.37053125 status=diverging -Step 10: value=19.68744043407226 status=diverging -Step 15: value=8.735421910125167 status=diverging -Step 20: value=3.8759531084514336 status=diverging -Step 25: value=1.7197809852207897 status=diverging -Step 30: value=0.7630759594789481 status=diverging +Step 5: value=44.37053125 status=improving +Step 10: value=19.68744043407226 status=improving +Step 15: value=8.735421910125167 status=improving +Step 20: value=3.8759531084514336 status=improving +Step 25: value=1.7197809852207897 status=improving +Step 30: value=0.7630759594789481 status=improving Step 35: value=0.3385808570618439 status=improving Step 40: value=0.15023012498914326 status=improving Step 45: value=0.06665790455522187 status=improving Step 50: value=0.02957646637126989 status=improving Step 55: value=0.013123235253910044 status=improving Step 60: value=0.005822849199347172 status=improving -Step 65: value=0.0025836291236367107 status=moving -Step 70: value=0.0011463699676873278 status=moving -Step 75: value=0.0005086504447533207 status=moving -Step 80: value=0.00022569090454253608 status=equilibrium -Converged at step 84 with value 0.00011781206273935721 +Step 65: value=0.0025836291236367107 status=improving +Step 70: value=0.0011463699676873278 status=converged +Converged at step 70 with value 0.0011463699676873278 --- Gradient Descent: minimize (x-3)^2 --- -Minimum at x = 3.0000000000084173 -f(x) = 7.08503817880336e-23 -Status: equilibrium +Minimum at x = 3.001817303900487 +f(x) = 3.302593466725101e-06 +Status: converged diff --git a/tests/observer_corpus/golden/EigenScript__observer.out b/tests/observer_corpus/golden/EigenScript__observer.out index bbbe973d..79c6e4ef 100644 --- a/tests/observer_corpus/golden/EigenScript__observer.out +++ b/tests/observer_corpus/golden/EigenScript__observer.out @@ -1,3 +1,3 @@ Observer state: ["diverging", 0.9182958340544896, 0.19636773916712724, 0.13025531630503506] Status: diverging -Stable: equilibrium +Stable: stable diff --git a/tests/observer_corpus/golden/EigenScript__observer_predicates.out b/tests/observer_corpus/golden/EigenScript__observer_predicates.out index dbac4910..cabb5fff 100644 --- a/tests/observer_corpus/golden/EigenScript__observer_predicates.out +++ b/tests/observer_corpus/golden/EigenScript__observer_predicates.out @@ -3,45 +3,43 @@ sqrt(50) = 7.0710678118654755 sqrt(0.01) = 0.1 --- report-driven convergence --- - step 10: stable signal=43.43884542236323 - step 20: diverging signal=18.869332916279667 - step 30: diverging signal=8.196620357733826 - step 40: diverging signal=3.5605172470539532 - step 50: diverging signal=1.5466475831843494 - step 60: moving signal=0.6718458528881662 + step 10: improving signal=43.43884542236323 + step 20: improving signal=18.869332916279667 + step 30: improving signal=8.196620357733826 + step 40: improving signal=3.5605172470539532 + step 50: improving signal=1.5466475831843494 + step 60: improving signal=0.6718458528881662 step 70: improving signal=0.2918420815126484 step 80: improving signal=0.12677283066568665 step 90: improving signal=0.05506865395042193 step 100: improving signal=0.023921187465699906 - step 110: moving signal=0.010391087646419113 - step 120: moving signal=0.004513768500430279 - step 130: moving signal=0.0019607289216252324 - step 140: moving signal=0.0008517180054163542 -Converged at step 145 signal=0.0005613516003466767 + step 110: improving signal=0.010391087646419113 +Converged at step 118 signal=0.005332902292568855 --- stable as early detection --- -Entered stable band at step 1 value=45 -Converged at step 113 value=0.00033757756092487343 +Converged at step 91 value=0.003427980662063994 --- improving as progress monitor --- + step 10: improving, value=15.749952347257809 + step 15: improving, value=6.988337528100135 + step 20: improving, value=3.100762486761148 + step 25: improving, value=1.3758247881766321 + step 30: improving, value=0.6104607675831588 step 35: improving, value=0.2708646856494753 step 40: improving, value=0.12018409999131469 step 45: improving, value=0.053326323644177526 step 50: improving, value=0.023661173097015924 step 55: improving, value=0.010498588203128042 step 60: improving, value=0.0046582793594777405 -Done at step 83 value=0.00011088194140174804 status=converged + step 65: improving, value=0.00206690329890937 +Done at step 68 value=0.0012693369884427168 status=converged --- regime detection --- -Step 1: start -> stable -Step 3: stable -> diverging -Step 34: diverging -> moving -Step 38: moving -> improving -Step 70: improving -> moving -Step 93: moving -> equilibrium -Step 97: equilibrium -> converged -Total regime changes: 7 -Final loss: 0.00020596299710139924 +Step 1: start -> moving +Step 6: moving -> improving +Step 78: improving -> converged +Total regime changes: 3 +Final loss: 0.002336783255831447 --- oscillating as instability detector --- step 5: oscillating! lr halved to 0.6 @@ -52,4 +50,6 @@ Final loss: 0.00020596299710139924 step 10: oscillating! lr halved to 0.01875 step 11: oscillating! lr halved to 0.009375 step 12: oscillating! lr halved to 0.0046875 -Done at step 501 x=4.989944084585771 lr=0.0046875 adjustments=8 + step 13: oscillating! lr halved to 0.00234375 + step 14: oscillating! lr halved to 0.001171875 +Done at step 23 x=4.028419306289019 lr=0.001171875 adjustments=10 diff --git a/tests/observer_corpus/golden/EigenScript__structural_observer.out b/tests/observer_corpus/golden/EigenScript__structural_observer.out index 55a20130..f8e3054a 100644 --- a/tests/observer_corpus/golden/EigenScript__structural_observer.out +++ b/tests/observer_corpus/golden/EigenScript__structural_observer.out @@ -1,9 +1,9 @@ equilibrium -stable -["stable", 0.7219280948873623, -0.19636773916712724, -0.08170416594551044] -0.00032292460179985625 +moving +["moving", 0.7219280948873623, -0.19636773916712724, -0.08170416594551044] +0.0032791850478503127 4 1 -0.00011284545227126804 -0.00031893787832084573 -3.0000000000084173 +0.0012918145618183554 +0.0032387012818274703 +3.001817303900487 diff --git a/tests/observer_corpus/golden/dynamics__life.out b/tests/observer_corpus/golden/dynamics__life.out index b7c3dd9d..073d7966 100644 --- a/tests/observer_corpus/golden/dynamics__life.out +++ b/tests/observer_corpus/golden/dynamics__life.out @@ -3,12 +3,12 @@ population is constant for both a blinker and a block, so report-of-population cannot tell them apart. Signature history (temporal) reveals the period. seed population-regime prev-still? period reading - blinker equilibrium N 2 period-2 oscillator - block equilibrium Y 1 still life - glider equilibrium N 0 translating / chaotic + blinker converged N 2 period-2 oscillator + block converged Y 1 still life + glider converged N 0 translating / chaotic single converged Y 1 extinct -blinker and block both read population 'equilibrium' yet are period-2 and +blinker and block both read population 'converged' yet are period-2 and period-1 — only the temporal signature, not the scalar predicate, separates them. DONE diff --git a/tests/observer_corpus/golden/dynamics__physics.out b/tests/observer_corpus/golden/dynamics__physics.out index 912d1a07..df0158b4 100644 --- a/tests/observer_corpus/golden/dynamics__physics.out +++ b/tests/observer_corpus/golden/dynamics__physics.out @@ -2,7 +2,7 @@ observe ENERGY (Lyapunov) vs DISPLACEMENT (oscillation); flags = regime visited zeta | E:osc conv div grew | x:osc conv div | reading - -0.05 | E:Y - Y Y | x:Y - - | DIVERGES (energy grew) + -0.05 | E:Y - - Y | x:Y - - | DIVERGES (energy grew) 0 | E:Y - - - | x:Y - - | x OSCILLATES, energy settles 0.1 | E:- Y - - | x:Y - - | x OSCILLATES, energy settles 0.4 | E:- Y - - | x:Y Y - | x OSCILLATES, energy settles diff --git a/tests/observer_corpus/golden/dynamics__solve.out b/tests/observer_corpus/golden/dynamics__solve.out index 007870c0..217b65d4 100644 --- a/tests/observer_corpus/golden/dynamics__solve.out +++ b/tests/observer_corpus/golden/dynamics__solve.out @@ -2,13 +2,13 @@ every loop runs until `report of change` is settled and HOLDs — the observer, not a magnitude tolerance, decides when to stop. (iters include the hold) -Jacobi Ax=b -> [0.9999999997671694, 0.9999999995343387, 0.9999999997671694] - 21 iters -Gauss-Seidel Ax=b -> [0.9999999999999998, 0.9999999999999999, 1] - 18 iters (fresh values -> fewer than Jacobi) +Jacobi Ax=b -> [0.9999999981373549, 0.9999999962747097, 0.9999999981373549] + 19 iters +Gauss-Seidel Ax=b -> [0.9999999999999978, 0.9999999999999989, 0.9999999999999998] + 17 iters (fresh values -> fewer than Jacobi) -Power iteration dominant eigenvalue -> 2 (6 iters, expect ~2) +Power iteration dominant eigenvalue -> 2 (14 iters, expect ~2) -PageRank stationary distribution -> [0.39999898274739587, 0.20000203450520834, 0.39999898274739587] (32 iters) +PageRank stationary distribution -> [0.39998372395833337, 0.20003255208333334, 0.39998372395833337] (24 iters) DONE diff --git a/tests/observer_corpus/golden/iLambdaAi__test_halting_descent.out b/tests/observer_corpus/golden/iLambdaAi__test_halting_descent.out index 78f7f7db..b3630d57 100644 --- a/tests/observer_corpus/golden/iLambdaAi__test_halting_descent.out +++ b/tests/observer_corpus/golden/iLambdaAi__test_halting_descent.out @@ -1,8 +1,8 @@ === HALTING: BOUNDED DESCENT TEST === --- HD1: Entropy strictly decreases each iteration --- -20 -converged -2.3617382852898424e-06 --2.1710030211783986e-06 +110 +diverging +8.730003786945632e-33 +-8.575929391394651e-33 === BOUNDED DESCENT COMPLETE === diff --git a/tests/observer_corpus/golden/iLambdaAi__test_report_alignment.out b/tests/observer_corpus/golden/iLambdaAi__test_report_alignment.out index c6157b55..d55f1311 100644 --- a/tests/observer_corpus/golden/iLambdaAi__test_report_alignment.out +++ b/tests/observer_corpus/golden/iLambdaAi__test_report_alignment.out @@ -3,22 +3,22 @@ Verifies report of x agrees with predicate vocabulary --- RA1: Diverging (low H -> high H) --- 0 -stable +moving --- RA2: Improving (primed, high H -> low H) --- 0 -stable +moving --- RA3: Converged (low H basin, dH~0) --- 0 -equilibrium +stable --- RA4: Oscillating (sign change in dH) --- 0 -stable +moving --- RA5: Equilibrium (dH~0, moderate H) --- 0 -equilibrium +stable === REPORT-PREDICATE ALIGNMENT COMPLETE === diff --git a/tests/observer_corpus/golden/iLambdaAi__test_stable_band.out b/tests/observer_corpus/golden/iLambdaAi__test_stable_band.out index 2ea61fe4..14ed1493 100644 --- a/tests/observer_corpus/golden/iLambdaAi__test_stable_band.out +++ b/tests/observer_corpus/golden/iLambdaAi__test_stable_band.out @@ -1,12 +1,12 @@ === STABLE BAND TEST === --- SB1: Stable is reachable (small drift, moderate H) --- 0 -stable +moving 0.15374218032876188 -0.002748882576939643 --- SB2: Converged is NOT stable --- 0 -equilibrium +stable === STABLE BAND COMPLETE === diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index 47dd827d..bff46a6e 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -344,7 +344,7 @@ RA_OUTPUT=$(./eigenscript ../tests/test_report_alignment.eigs 2>&1) RA1_D=$(echo "$RA_OUTPUT" | grep -A2 'RA1:' | tail -2 | head -1) RA1_R=$(echo "$RA_OUTPUT" | grep -A2 'RA1:' | tail -1) -check "RA1 diverging predicate" "$RA1_D" "1" +check "RA1 diverging predicate" "$RA1_D" "1" # #861: linear runaway, raw same-sign check "RA1 report=diverging" "$RA1_R" "diverging" RA2_I=$(echo "$RA_OUTPUT" | grep -A2 'RA2:' | tail -2 | head -1) @@ -364,52 +364,48 @@ check "RA4 report=oscillating" "$RA4_R" "oscillating" RA5_E=$(echo "$RA_OUTPUT" | grep -A2 'RA5:' | tail -2 | head -1) RA5_R=$(echo "$RA_OUTPUT" | grep -A2 'RA5:' | tail -1) -check "RA5 equilibrium predicate" "$RA5_E" "1" +check "RA5 equilibrium predicate" "$RA5_E" "1" # #861: balanced jitter, NOT converged check "RA5 report=equilibrium" "$RA5_R" "equilibrium" echo "" -echo "[6/15] Halting: Bounded Descent (4 checks)" +echo "[6/15] Halting: Runaway Loop Contract (4 checks, #861)" HD_OUTPUT=$(./eigenscript ../tests/test_halting_descent.eigs 2>&1) +# #861: the runaway loop's honest contract — the stall backstop ends it +# after ~100 quiet-entropy iterations (was: exit at ~13 via the entropy +# defect certifying a doubling runaway as converged). HD_ITERS=$(echo "$HD_OUTPUT" | grep -A1 'HD1:' | tail -1) TOTAL=$((TOTAL + 1)) -if [ -n "$HD_ITERS" ] && [ "$HD_ITERS" -gt 0 ] 2>/dev/null && [ "$HD_ITERS" -lt 50 ] 2>/dev/null; then - echo " PASS: HD1 loop terminated in $HD_ITERS iterations" +if [ -n "$HD_ITERS" ] && [ "$HD_ITERS" -ge 100 ] 2>/dev/null && [ "$HD_ITERS" -lt 150 ] 2>/dev/null; then + echo " PASS: HD1 runaway loop stalled out in $HD_ITERS iterations" PASS=$((PASS + 1)) else - echo " FAIL: HD1 loop iteration count (got '$HD_ITERS')" + echo " FAIL: HD1 loop iteration count (got '$HD_ITERS', want 100..149)" FAIL=$((FAIL + 1)) fi HD_REPORT=$(echo "$HD_OUTPUT" | grep -A2 'HD1:' | tail -1) -check "HD2 final report=converged" "$HD_REPORT" "converged" +check "HD2 final report=diverging" "$HD_REPORT" "diverging" -HD_H=$(echo "$HD_OUTPUT" | grep -A3 'HD1:' | tail -1) -TOTAL=$((TOTAL + 1)) -if [ -n "$HD_H" ]; then - echo " PASS: HD3 final entropy reported ($HD_H)" - PASS=$((PASS + 1)) -else - echo " FAIL: HD3 final entropy empty" - FAIL=$((FAIL + 1)) -fi +HD_EXIT=$(echo "$HD_OUTPUT" | grep -A3 'HD1:' | tail -1) +check "HD3 __loop_exit__=stalled" "$HD_EXIT" "stalled" HD_DH=$(echo "$HD_OUTPUT" | grep -A4 'HD1:' | tail -1) TOTAL=$((TOTAL + 1)) -if echo "$HD_DH" | grep -q '^-'; then - echo " PASS: HD4 final dH is negative ($HD_DH)" +if [ -n "$HD_DH" ]; then + echo " PASS: HD4 final dH reported ($HD_DH)" PASS=$((PASS + 1)) else - echo " FAIL: HD4 final dH should be negative (got '$HD_DH')" + echo " FAIL: HD4 final dH empty" FAIL=$((FAIL + 1)) fi echo "" -echo "[7/15] Halting: Stall Detection (5 checks)" +echo "[7/15] Halting: Settled Constant (5 checks, #861)" HS_OUTPUT=$(./eigenscript ../tests/test_halting_stall.eigs 2>&1) HS_CONV=$(echo "$HS_OUTPUT" | grep -A1 'HS1:' | tail -1) -check "HS1 converged=0 at moderate H" "$HS_CONV" "0" +check "HS1 converged=1 at moderate H (#861: dead zone gone)" "$HS_CONV" "1" HS_EQ=$(echo "$HS_OUTPUT" | grep -A2 'HS1:' | tail -1) check "HS2 equilibrium=1 at dH~0" "$HS_EQ" "1" @@ -428,20 +424,20 @@ HS_DH=$(echo "$HS_OUTPUT" | grep -A2 'HS2:' | tail -1) check "HS4 dH~0" "$HS_DH" "0" HS_REPORT=$(echo "$HS_OUTPUT" | grep -A3 'HS2:' | tail -1) -check "HS5 report=equilibrium" "$HS_REPORT" "equilibrium" +check "HS5 report=converged (#861)" "$HS_REPORT" "converged" echo "" echo "[8/15] Stable Band (4 checks)" SB_OUTPUT=$(./eigenscript ../tests/test_stable_band.eigs 2>&1) SB1_S=$(echo "$SB_OUTPUT" | grep -A1 'SB1:' | tail -1) -check "SB1 stable=1" "$SB1_S" "1" +check "SB1 stable=0 (#861: linear drift is diverging)" "$SB1_S" "0" SB1_R=$(echo "$SB_OUTPUT" | grep -A2 'SB1:' | tail -1) -check "SB1 report=stable" "$SB1_R" "stable" +check "SB1 report=diverging (#861)" "$SB1_R" "diverging" SB2_S=$(echo "$SB_OUTPUT" | grep -A1 'SB2:' | tail -1) -check "SB2 stable=0 (converged)" "$SB2_S" "0" +check "SB2 stable=1 (#861: converged implies stable)" "$SB2_S" "1" SB2_R=$(echo "$SB_OUTPUT" | grep -A2 'SB2:' | tail -1) check "SB2 report=converged" "$SB2_R" "converged" @@ -456,7 +452,7 @@ check "WC2 full N quiet window converges" "$WC2" "1" WC3=$(echo "$WC_OUTPUT" | grep -A1 'WC3:' | tail -1) check "WC3 single transient breaks convergence" "$WC3" "0" WC4=$(echo "$WC_OUTPUT" | grep -A2 'WC4:' | tail -1) -check "WC4 newton sqrt reaches equilibrium not converged" "$WC4" "converged=0 equilibrium=1" +check "WC4 newton sqrt CERTIFIES converged (#861: dead zone gone)" "$WC4" "converged=1 equilibrium=1" WC5=$(echo "$WC_OUTPUT" | grep -A1 'WC5:' | tail -1) check "WC5 rebind-from-temp loop converges (issue #260)" "$WC5" "converged=1 equilibrium=1" echo "" @@ -540,7 +536,7 @@ echo "[11/15] Loop Exit Reason (3 checks)" LE_OUTPUT=$(./eigenscript ../tests/test_loop_exit.eigs 2>&1) LE1_EXIT=$(echo "$LE_OUTPUT" | grep -A1 'LE1:' | tail -1) -check "LE1 exit=normal" "$LE1_EXIT" "normal" +check "LE1 runaway exit=stalled (#861: false-converged exit gone)" "$LE1_EXIT" "stalled" LE1_ITERS=$(echo "$LE_OUTPUT" | grep -A2 'LE1:' | tail -1) TOTAL=$((TOTAL + 1)) @@ -553,7 +549,7 @@ else fi LE2_EXIT=$(echo "$LE_OUTPUT" | grep -A1 'LE2:' | tail -1) -check "LE2 exit=stalled" "$LE2_EXIT" "stalled" +check "LE2 constant exit=normal (#861: converged fires, no stall needed)" "$LE2_EXIT" "normal" echo "" echo "[Structural Equality] (15 checks)" diff --git a/tests/test_convergence_oracle.eigs b/tests/test_convergence_oracle.eigs index 3107aef3..152d4621 100644 --- a/tests/test_convergence_oracle.eigs +++ b/tests/test_convergence_oracle.eigs @@ -11,17 +11,24 @@ # POSIX, the C compiler). The observer is the one original # component, so it had nothing to be wrong against. # -# THIS TEST PINS A DEFECT BASELINE, NOT A TARGET. -# The scores below are what the predicates currently get right. -# The entropy channel — which `converged` and `report` actually -# use — is wrong on 8 of 27 sequences. That is #861, still open. +# Since #861 the predicate words and `report` route numeric +# bindings to the VALUE channel (the mixed-tolerance stopping +# criterion), so both columns below read the same classifier and +# score 25/27. The two remaining misses — cases 6 and 8 — are the +# IRREDUCIBLE tolerance gap: their steps have fallen below the +# settle deadband while the value is still ~1e-2 from the limit. +# "Settled at the deadband" is the strongest claim a finite +# window can make (case 9, harmonic, is the proof that vanishing +# steps do not imply a limit), so those two are the documented +# floor, not defects to fix. # # Two things are pinned: the aggregate scores AND the per-case # band each channel reports. Either moving in EITHER direction -# fails, so an improvement must land as a deliberate, reviewed -# edit here rather than silently shifting the meaning of a green -# suite. Both are needed — the scores alone are too coarse (see -# the note above EXPECT_E). +# fails, so a semantics change must land as a deliberate, +# reviewed edit here rather than silently shifting the meaning +# of a green suite. Both are needed — the scores alone are too +# coarse: a planted h_low change once moved two bands while both +# aggregates held. # # Run standalone: ./src/eigenscript tests/test_convergence_oracle.eigs # ============================================================ @@ -161,17 +168,18 @@ TRUTH is [0, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1 # The pinned baseline. Change these ONLY with a measurement and a # reason in the commit message. -EXPECT_ENTROPY_OK is 19 +EXPECT_ENTROPY_OK is 25 EXPECT_VALUE_OK is 25 -# Per-case verdicts, pinned. The aggregate scores above are NOT sufficient on -# their own: they collapse each case to converged/not-converged, so a real -# semantics change can move individual bands while the totals hold. Measured — -# raising h_low 0.1 -> 0.5 (a change that makes `hold 10` certify converged) -# flips cases 7 and 13 from `stable` to `moving` and leaves both scores at -# 19/27 and 25/27. Pinning the bands catches what the counts cannot. -EXPECT_E is ["", "converged", "equilibrium", "converged", "equilibrium", "equilibrium", "equilibrium", "stable", "equilibrium", "stable", "stable", "stable", "stable", "stable", "converged", "converged", "equilibrium", "oscillating", "moving", "oscillating", "oscillating", "converged", "oscillating", "equilibrium", "oscillating", "converged", "equilibrium", "converged"] -EXPECT_V is ["", "converged", "converged", "converged", "converged", "converged", "converged", "oscillating", "converged", "stable", "stable", "stable", "stable", "diverging", "diverging", "diverging", "oscillating", "oscillating", "moving", "oscillating", "moving", "oscillating", "oscillating", "converged", "oscillating", "converged", "converged", "converged"] +# Per-case verdicts, pinned. The aggregate scores are NOT sufficient on their +# own: they collapse each case to converged/not-converged, so a semantics +# change can move individual bands while the totals hold (measured against +# the pre-routing pins: an h_low change flipped two bands with both scores +# unmoved). Since #861 routes both surfaces through one numeric classifier, +# EXPECT_E and EXPECT_V are identical BY CONSTRUCTION — a divergence between +# them means the unification broke. +EXPECT_E is ["", "converged", "converged", "converged", "converged", "converged", "converged", "oscillating", "converged", "stable", "stable", "stable", "stable", "diverging", "diverging", "diverging", "oscillating", "oscillating", "oscillating", "oscillating", "oscillating", "oscillating", "oscillating", "converged", "oscillating", "converged", "converged", "converged"] +EXPECT_V is ["", "converged", "converged", "converged", "converged", "converged", "converged", "oscillating", "converged", "stable", "stable", "stable", "stable", "diverging", "diverging", "diverging", "oscillating", "oscillating", "oscillating", "oscillating", "oscillating", "oscillating", "oscillating", "converged", "oscillating", "converged", "converged", "converged"] fp is 0 fn is 0 @@ -220,8 +228,8 @@ for id in range of 27: flag is " <-" print of f"{i} entropy={r[0]} value={r[3]} truth={want}{flag} {NAMES[i]}" print of "" -print of f"ENTROPY CHANNEL (what converged/report use): correct={ok}/27 FP={fp} FN={fn}" -print of f"VALUE CHANNEL (report_value) : correct={vok}/27 FP={vfp} FN={vfn}" +print of f"PREDICATES (converged / report, routed): correct={ok}/27 FP={fp} FN={fn}" +print of f"report_value (same classifier since #861): correct={vok}/27 FP={vfp} FN={vfn}" print of f"precision = {tp}/{firings} of firings were real" print of f"recall = {tp}/{positives} of real convergences detected" print of "" @@ -229,12 +237,12 @@ print of "" pass is 1 if ok != EXPECT_ENTROPY_OK: pass is 0 - print of f"FAIL: entropy channel scored {ok}/27, pinned baseline is {EXPECT_ENTROPY_OK}/27." + print of f"FAIL: predicates scored {ok}/27, pinned baseline is {EXPECT_ENTROPY_OK}/27." print of " A move in EITHER direction is a semantics change — update the" print of " baseline deliberately, with the measurement, or find the regression." if vok != EXPECT_VALUE_OK: pass is 0 - print of f"FAIL: value channel scored {vok}/27, pinned baseline is {EXPECT_VALUE_OK}/27." + print of f"FAIL: report_value scored {vok}/27, pinned baseline is {EXPECT_VALUE_OK}/27." if band_drift != 0: pass is 0 print of f"FAIL: {band_drift} per-case band(s) moved (listed above). The aggregate" diff --git a/tests/test_dispatch.eigs b/tests/test_dispatch.eigs index 3578cd84..6764a759 100644 --- a/tests/test_dispatch.eigs +++ b/tests/test_dispatch.eigs @@ -128,18 +128,25 @@ t743 is [d743] # Warm-up loop: __loop_iterations__ is undefined until a loop has run, so # this one exists to establish the baseline the two measured loops subtract. +# #861: the three loops DECAY (x*0.5) so they exit normally via the routed +# `converged` — the old doubling form only "terminated" through the entropy +# defect, and under honest semantics it stalls, which RESETS the cumulative +# counter and breaks the delta bookkeeping this case measures. The decaying +# variable is each body's LAST assignment: the bare predicate reads the +# last-observed binding, and a counter assigned after it would repoint the +# alias at a +1 climb that never converges. w743 is 1 w743 is 100 loop while not converged: - w743 is w743 * 2 + w743 is w743 * 0.5 plain_before is __loop_iterations__ plain_n is 0 p743 is 1 p743 is 100 loop while not converged: - p743 is p743 * 2 plain_n is plain_n + 1 + p743 is p743 * 0.5 plain_delta is __loop_iterations__ - plain_before disp_before is __loop_iterations__ @@ -147,9 +154,9 @@ disp_n is 0 v743 is 1 v743 is 100 loop while not converged: - v743 is v743 * 2 disp_n is disp_n + 1 junk743 is dispatch of [t743, 0, v743] + v743 is v743 * 0.5 disp_delta is __loop_iterations__ - disp_before check of ["plain loop contributes its own iteration count", plain_delta == plain_n] diff --git a/tests/test_halting_descent.eigs b/tests/test_halting_descent.eigs index 627e94b4..bf1953b0 100644 --- a/tests/test_halting_descent.eigs +++ b/tests/test_halting_descent.eigs @@ -1,5 +1,11 @@ -print of "=== HALTING: BOUNDED DESCENT TEST ===" -print of "--- HD1: Entropy strictly decreases each iteration ---" +print of "=== HALTING: RUNAWAY LOOP CONTRACT (#861) ===" +print of "--- HD1: bare-predicate loop around a runaway exits via STALL ---" +# Pre-#861 this loop "terminated" via the defect: H(x) fell under h_low as x +# doubled, every clause of the entropy `converged` was met, and the loop +# exited after ~13 iterations reporting the canonical instability as +# converged. Now `converged` (value-routed) refuses a runaway, and the +# stall backstop ends the loop after 100 quiet-entropy iterations with the +# honest exit reason. counter is 0 x is 10 @@ -11,8 +17,8 @@ loop while not converged: print of counter print of report of x -print of where is x +print of __loop_exit__ print of why is x print of "" -print of "=== BOUNDED DESCENT COMPLETE ===" +print of "=== RUNAWAY LOOP CONTRACT COMPLETE ===" diff --git a/tests/test_halting_stall.eigs b/tests/test_halting_stall.eigs index 9d3c719a..0ade2e05 100644 --- a/tests/test_halting_stall.eigs +++ b/tests/test_halting_stall.eigs @@ -1,5 +1,9 @@ -print of "=== HALTING: STALL DETECTION TEST ===" -print of "--- HS1: Entropy-preserving at moderate H is NOT converged ---" +print of "=== HALTING: SETTLED CONSTANT TEST (#861) ===" +print of "--- HS1: a constant at moderate H certifies converged ---" +# Pre-#861 this pinned the dead zone: H(42) ~ 0.16 sat above h_low, so the +# entropy `converged` could never fire on a constant at 42 and the strongest +# claim was "equilibrium". The value route certifies a settled value at ANY +# magnitude — that was the point of the fix. x is 42 x is 42 @@ -21,11 +25,11 @@ print of hs1[0] print of hs1[1] print of "" -print of "--- HS2: Stall is observable via interrogation ---" +print of "--- HS2: the entropy MEASUREMENT is unchanged ---" print of where is x print of why is x print of report of x print of "" -print of "=== STALL DETECTION COMPLETE ===" +print of "=== SETTLED CONSTANT COMPLETE ===" diff --git a/tests/test_loop_exit.eigs b/tests/test_loop_exit.eigs index 016ecf9b..e2e6ec22 100644 --- a/tests/test_loop_exit.eigs +++ b/tests/test_loop_exit.eigs @@ -1,6 +1,9 @@ print of "=== LOOP EXIT TEST ===" -print of "--- LE1: Normal exit ---" +# #861: the doubling loop is a genuine runaway — it now exits via the STALL +# backstop (the old "normal" exit was the entropy defect certifying it as +# converged). The constant loop in LE2 is the one that exits normally now. +print of "--- LE1: Runaway exits via stall ---" x is 1 x is 100 loop while not converged: @@ -9,7 +12,7 @@ print of __loop_exit__ print of __loop_iterations__ print of "" -print of "--- LE2: Stall exit ---" +print of "--- LE2: Constant certifies converged, exits normally ---" y is 42 y is 42 loop while not converged: diff --git a/tests/test_observer_coherence.eigs b/tests/test_observer_coherence.eigs index 9a614e06..0634c349 100644 --- a/tests/test_observer_coherence.eigs +++ b/tests/test_observer_coherence.eigs @@ -34,8 +34,14 @@ i is 0 loop while i < 12: y is 1.0 i is i + 1 -check of ["flat run AT 1.0 is equilibrium, not converged", (report of y) == "equilibrium"] -check of ["converged predicate stays false at the horizon", (converged of y) == 0] +# #861: a constant at 1.0 is SETTLED — the value route certifies it like any +# other constant. #412's horizon insight survives where it belongs: in the +# MEASUREMENT — entropy(1.0) is still the formula maximum (checked below) — +# but it no longer vetoes the settle verdict, which is about motion, not +# information content. +check of ["flat run AT 1.0 certifies converged (value route)", (report of y) == "converged"] +check of ["converged predicate fires at the horizon too", (converged of y) == 1] +check of ["the horizon property lives in the measurement: H(1.0) is max", (where is y) == 1.0] # a genuine convergence to the home region still classifies converged c is 100.0 diff --git a/tests/test_observer_slots.eigs b/tests/test_observer_slots.eigs index cf1e8964..3db72aa3 100644 --- a/tests/test_observer_slots.eigs +++ b/tests/test_observer_slots.eigs @@ -38,7 +38,10 @@ loop while i < 14: observer_slots.feed of [3, v] v is v * 0.6 i is i + 1 -check of ["entropy-rising signal diverges", observer_slots.is_diverging of (3), 1] +# #861: v*0.6 shrinking toward ~0.32 is a CONTRACTING approach — the value +# route reads improving, not diverging (the old expectation was the +# entropy-rising artifact: H grows as |x| falls toward 1). +check of ["shrinking-toward-1 signal is not diverging", observer_slots.is_diverging of (3), 0] # independence: slot 0's verdict survived slot 1's activity check of ["slot 0 verdict independent of slot 1", observer_slots.is_stable of (0), 1] @@ -70,12 +73,14 @@ loop while i < 14: i is i + 1 check of ["verdict reads the slot regime (improving)", observer_slots.verdict of (4), "improving"] check of ["verdict agrees with is_diverging (0)", observer_slots.is_diverging of (4), 0] -# slot 5: wedged constant -> equilibrium, which also satisfies stable +# slot 5: wedged constant -> converged (#861: the value route certifies a +# settled value at ANY magnitude — 9.0 was in the entropy dead zone, where +# `converged` could never fire and "equilibrium" was the strongest label) i is 0 loop while i < 14: observer_slots.feed of [5, 9.0] i is i + 1 -check of ["verdict on a wedged constant is equilibrium", observer_slots.verdict of (5), "equilibrium"] +check of ["verdict on a wedged constant is converged", observer_slots.verdict of (5), "converged"] check of ["...and that same slot reads stable", observer_slots.is_stable of (5), 1] vcaught is 0 try: diff --git a/tests/test_observer_value_signal.eigs b/tests/test_observer_value_signal.eigs index 6c02fe28..bc182bf1 100644 --- a/tests/test_observer_value_signal.eigs +++ b/tests/test_observer_value_signal.eigs @@ -53,15 +53,17 @@ define run_climb() as: local pass is 1 -# Case 1: slow oscillation. Entropy is blind here (does not flag it as -# oscillating); the value channel must NOT report it settled. +# Case 1: slow oscillation. #861: `report` now routes numerics to the value +# channel, so BOTH surfaces flag it — the blindness this case originally +# demonstrated lives on only in the explicit entropy channel, pinned below +# via classify (it is the reason the routing exists). local r1 is run_osc of (0.6) local ent1 is r1[0] local val1 is r1[1] print of ("slow-osc: report=" + ent1 + " report_value=" + val1) -if ent1 == "oscillating": +if not (ent1 == val1): pass is 0 - print of "FAIL: entropy channel unexpectedly flagged the slow oscillation" + print of "FAIL: report and report_value disagree on a numeric binding (#861 unification)" if (val1 == "converged") or (val1 == "stable"): pass is 0 print of ("FAIL: value channel falsely settled on a sustained oscillation: " + val1) diff --git a/tests/test_predicate_matrix.eigs b/tests/test_predicate_matrix.eigs index 73fbbcc7..79627909 100644 --- a/tests/test_predicate_matrix.eigs +++ b/tests/test_predicate_matrix.eigs @@ -1,5 +1,10 @@ -# Predicate family regression matrix — pins the windowed-predicate semantics -# specified in docs/PREDICATES.md (issue #200). Covers what +# Predicate family regression matrix — pins the ROUTED predicate semantics +# (#861: numeric bindings answer from the value channel; docs/PREDICATES.md). +# The trajectories below drive numbers, so every row was re-measured against +# the value-channel classifiers when the routing landed; rows whose old +# entropy-band expectations inverted are annotated in place. (#200 first +# pinned the windowed entropy semantics; those still hold for non-numeric +# bindings and are covered via classify-entropy in the windowed_* suites.) Covers what # test_windowed_converged.eigs does not: the mutual-exclusion matrix for # stable/improving/diverging/oscillating/equilibrium, threshold-knob # transitions, the two documented co-fire edge cases, and Newton sqrt @@ -36,7 +41,8 @@ a is 9.0 a is 19.0 a is 99.0 pa is [converged, stable, improving, diverging, oscillating, equilibrium, report of a] -expect of ["improving", pa, 0, 0, 1, 0, 0, 0, "improving"] +# #861: growth 2->99 is a runaway on the value route (raw same-sign non-vanishing); "improving" was the entropy channel reading its own descent +expect of ["improving", pa, 0, 0, 0, 1, 0, 0, "diverging"] # --- diverging only: entropy rising fast (decreasing |x| toward 1) --- b is 99.0 @@ -47,7 +53,8 @@ b is 4.0 b is 3.0 b is 2.0 pb is [converged, stable, improving, diverging, oscillating, equilibrium, report of b] -expect of ["diverging", pb, 0, 0, 0, 1, 0, 0, "diverging"] +# #861: decay 99->2 contracts its steps toward a limit — improving; "diverging" was the entropy-rising artifact +expect of ["diverging", pb, 0, 0, 1, 0, 0, 0, "improving"] # --- oscillating only: small-amplitude sign flips, |dH|~0.0057 in the gray band # (dh_zero < |dH| < dh_small). Needs >=4 flips for windowed `oscillating` @@ -79,7 +86,8 @@ e is 46.0 e is 48.0 e is 50.0 pe is [converged, stable, improving, diverging, oscillating, equilibrium, report of e] -expect of ["stable", pe, 0, 1, 0, 0, 0, 0, "stable"] +# #861: a steady drift is a linear runaway on the value route (the #422 additive class the entropy gray band hid) +expect of ["stable", pe, 0, 0, 0, 1, 0, 0, "diverging"] # --- equilibrium + stable (#209/#205): FULL window at rest, HIGH entropy. # Zero-mean low-variance -> equilibrium; quiet + high entropy + no flips -> @@ -98,7 +106,8 @@ g is 2.0 g is 2.0 g is 2.0 pg is [converged, stable, improving, diverging, oscillating, equilibrium, report of g] -expect of ["equilibrium+stable", pg, 0, 1, 0, 0, 0, 1, "equilibrium"] +# #861: quiescent lattice — a settled value certifies converged, which implies equilibrium AND stable +expect of ["equilibrium+stable", pg, 1, 1, 0, 0, 0, 1, "converged"] # --- converged: low-entropy value, FULL window (>= N=10). converged is a # strict subset of equilibrium, so BOTH fire; report resolves to converged. --- @@ -114,7 +123,8 @@ k is 1000000.0 k is 1000000.0 k is 1000000.0 pk is [converged, stable, improving, diverging, oscillating, equilibrium, report of k] -expect of ["converged", pk, 1, 0, 0, 0, 0, 1, "converged"] +# #861: converged implies stable now (all-under-deadband satisfies both) +expect of ["converged", pk, 1, 1, 0, 0, 0, 1, "converged"] # --- EDGE: large-amplitude oscillation is `oscillating` only. Under the # pointwise rule it ALSO co-fired diverging (last dH > dh_small); windowed @@ -148,43 +158,48 @@ h is 100.0 h is 100.0 h is 100.0 ph is [converged, stable, improving, diverging, oscillating, equilibrium, report of h] -expect of ["converged-at-boundary", ph, 1, 0, 0, 0, 0, 1, "converged"] - -# --- THRESHOLD KNOB: the small-amplitude oscillation (|dH|~0.0057) is -# `oscillating` at default thresholds, but raising dh_zero above |dH| -# reclassifies the SAME trajectory as `equilibrium`. --- -t1 is 70.0 -t1 is 75.0 -t1 is 70.0 -t1 is 75.0 -t1 is 70.0 -t1 is 75.0 -t1 is 70.0 -t1 is 75.0 -t1 is 70.0 -t1 is 75.0 -t1 is 70.0 -td is [oscillating, equilibrium] -assert of [td[0] == 1, f"threshold-default: oscillating expected 1 got {td[0]}"] -assert of [td[1] == 0, f"threshold-default: equilibrium expected 0 got {td[1]}"] -print of " PASS: threshold-default (oscillating)" +# #861: same lattice at the h_low boundary value — the boundary is meaningless on the value route +expect of ["converged-at-boundary", ph, 1, 1, 0, 0, 0, 1, "converged"] +# --- THRESHOLD KNOB (#861 semantics): dh_zero is the settle deadband on +# RELATIVE steps. A decay whose tail steps sit between the default +# (0.001) and a raised (0.01) deadband certifies only under the raised +# one — same trajectory, tolerance chosen by the caller. This is the +# mixed-tolerance stopping criterion with the tolerance exposed. --- +define tail_decay as: + local x is 180.0 + local k is 0 + loop while k < 30: + x is 100.0 + (x - 100.0) * 0.9 + k is k + 1 + return converged of x +kd is tail_decay of 0 +assert of [kd == 0, f"knob-default: tail steps (~0.0034..0.0096 rel) exceed dh_zero=0.001, converged expected 0 got {kd}"] +print of " PASS: knob-default (tail decay not yet converged at 0.001)" set_observer_thresholds of [0.01, 0.05, 0.5] -t2 is 70.0 -t2 is 75.0 -t2 is 70.0 -t2 is 75.0 -t2 is 70.0 -t2 is 75.0 -t2 is 70.0 -t2 is 75.0 -t2 is 70.0 -t2 is 75.0 -t2 is 70.0 -tr is [oscillating, equilibrium] -assert of [tr[0] == 0, f"threshold-raised: oscillating expected 0 got {tr[0]}"] -assert of [tr[1] == 1, f"threshold-raised: equilibrium expected 1 got {tr[1]}"] -print of " PASS: threshold-raised dh_zero -> equilibrium" +kr is tail_decay of 0 +assert of [kr == 1, f"knob-raised: same tail under dh_zero=0.01, converged expected 1 got {kr}"] +print of " PASS: knob-raised dh_zero -> converged (caller-chosen tolerance)" + +# --- Perpetual-motion STRUCTURE is threshold-free (#422): a +/-5 swing is +# an oscillation at ANY deadband — the raw structure tests detect +# non-vanishing alternation, not amplitude. The knob tunes what counts +# as settled, never what counts as perpetual motion. --- +ts is 70.0 +ts is 75.0 +ts is 70.0 +ts is 75.0 +ts is 70.0 +ts is 75.0 +ts is 70.0 +ts is 75.0 +ts is 70.0 +ts is 75.0 +ts is 70.0 +tsr is [oscillating, equilibrium] +assert of [tsr[0] == 1, f"structure-threshold-free: oscillating expected 1 got {tsr[0]}"] +assert of [tsr[1] == 0, f"structure-threshold-free: equilibrium expected 0 got {tsr[1]}"] +print of " PASS: perpetual swing stays oscillating at raised thresholds (#422)" set_observer_thresholds of [0.001, 0.01, 0.1] # --- #735: FULL window, low-entropy gray-band drift -> NO band fires. @@ -201,7 +216,8 @@ loop while mi < 20: m is m * 0.5 mi is mi + 1 pm is [converged of m, stable of m, improving of m, diverging of m, oscillating of m, equilibrium of m, report of m] -expect of ["full-window residual drift (#735)", pm, 0, 0, 0, 0, 0, 0, "moving"] +# #861: the value route CERTIFIES this settle — a decay toward zero is exactly what converged should claim. The #735 no-band gray state remains constructible only on the entropy channel (non-numeric bindings) +expect of ["full-window residual drift (#735)", pm, 1, 1, 0, 0, 0, 1, "converged"] # --- The full-window agreement guarantee (PREDICATES.md "The report builtin"), # asserted directly instead of in prose. At a full window `report of x` must diff --git a/tests/test_report_alignment.eigs b/tests/test_report_alignment.eigs index 83ef44be..d5f74576 100644 --- a/tests/test_report_alignment.eigs +++ b/tests/test_report_alignment.eigs @@ -1,79 +1,76 @@ +# Report-predicate alignment (#861: routed semantics). Each case drives a +# VALUE trajectory that genuinely produces the band, then checks the bare +# predicate and `report` agree — the full-window agreement guarantee, +# exercised state by state. (The pre-#861 version drove ENTROPY shapes: +# "diverging" meant entropy rising — a value shrinking toward 1 — which the +# value route correctly refuses to call divergence.) print of "=== REPORT-PREDICATE ALIGNMENT TEST ===" print of "Verifies report of x agrees with predicate vocabulary" print of "" -print of "--- RA1: Diverging (windowed ascent #208: needs count>=3) ---" -x is 1000000 -x is 1000 -x is 100 -x is 10 -x is 2 -d1 is diverging -r1 is report of x -print of d1 -print of r1 - +# RA1: diverging — a linear runaway (constant +7 steps, raw non-vanishing +# same-sign: the #422 additive class). +print of "--- RA1: Diverging (linear runaway, raw same-sign steps) ---" +a is 10.0 +i is 0 +loop while i < 12: + a is a + 7.0 + i is i + 1 +print of (str of (diverging of a)) +print of (report of a) print of "" -print of "--- RA2: Improving (windowed descent #207: needs count>=3) ---" -y is 0.5 -y is 1.0 -y is 5.0 -y is 100.0 -y is 1000000 -i2 is improving -r2 is report of y -print of i2 -print of r2 +# RA2: improving — steps contracting geometrically toward a limit, not yet +# under the settle deadband. +print of "--- RA2: Improving (contracting approach, not yet settled) ---" +b is 400.0 +i is 0 +loop while i < 8: + b is 100.0 + (b - 100.0) * 0.7 + i is i + 1 +print of (str of (improving of b)) +print of (report of b) print of "" -print of "--- RA3: Converged (low H basin, dH~0, full window) ---" -z is 1000000 -z is 1000000 -z is 1000000 -z is 1000000 -z is 1000000 -z is 1000000 -z is 1000000 -z is 1000000 -z is 1000000 -z is 1000000 -z is 1000000 -c3 is converged -r3 is report of z -print of c3 -print of r3 +# RA3: converged — a settled decay (full window under the deadband). +print of "--- RA3: Converged (settled decay) ---" +c is 100.0 +i is 0 +loop while i < 30: + c is c * 0.5 + i is i + 1 +print of (str of (converged of c)) +print of (report of c) print of "" -print of "--- RA4: Oscillating (windowed #206: needs >=4 dH sign flips) ---" -w is 1000000 -w is 0.5 -w is 1000000 -w is 0.5 -w is 1000000 -w is 0.5 -w is 1000000 -o4 is oscillating -r4 is report of w -print of o4 -print of r4 +# RA4: oscillating — deadband-crossing sign flips. +print of "--- RA4: Oscillating (sign-flipping steps) ---" +d is 5.0 +i is 0 +loop while i < 12: + d is 0.0 - d + i is i + 1 +print of (str of (oscillating of d)) +print of (report of d) print of "" -print of "--- RA5: Equilibrium (windowed #209: full window at rest, moderate H) ---" -v is 42 -v is 42 -v is 42 -v is 42 -v is 42 -v is 42 -v is 42 -v is 42 -v is 42 -v is 42 -v is 42 -e5 is equilibrium -r5 is report of v -print of e5 -print of r5 +# RA5: equilibrium WITHOUT converged — zero-mean jitter whose variance is +# under dh_zero^2 while at least one step exceeds dh_zero. The one rest +# band that is not implied by converged, constructed directly. +print of "--- RA5: Equilibrium (balanced jitter, not converged) ---" +e is 50.0 +e is 50.0765 +e is 50.0867 +e is 50.0969 +e is 50.1071 +e is 50.1173 +e is 50.0408 +e is 50.0306 +e is 50.0204 +e is 50.0102 +e is 50.0 +print of (str of (equilibrium of e)) +print of (report of e) print of "" + print of "=== REPORT-PREDICATE ALIGNMENT COMPLETE ===" diff --git a/tests/test_simulation.eigs b/tests/test_simulation.eigs index ea37f3ae..e85b7728 100644 --- a/tests/test_simulation.eigs +++ b/tests/test_simulation.eigs @@ -36,7 +36,11 @@ print of (" phases: " + (str of analysis.phases)) print of "--- analyze_stability (constant) ---" constant is [5, 5, 5, 5, 5, 5, 5, 5, 5, 5] analysis2 is analyze_stability of constant -assert_eq of [analysis2.dominant, "equilibrium", "constant series is equilibrium"] +# #861: fed one-by-one, a 10-element constant series yields 9 observed steps — +# one short of the certifying window — so the honest dominant is the partial- +# window rest label "stable" (converged needs the full 10). The old +# "equilibrium" came from the entropy channel's instantaneous fallback. +assert_eq of [analysis2.dominant, "stable", "constant series reads stable (partial-window rest)"] print of (" dominant: " + analysis2.dominant) # ---- analyze_stability on converging series ---- diff --git a/tests/test_stable_band.eigs b/tests/test_stable_band.eigs index 01ffb1ef..80a0d15a 100644 --- a/tests/test_stable_band.eigs +++ b/tests/test_stable_band.eigs @@ -1,6 +1,9 @@ print of "=== STABLE BAND TEST ===" -print of "--- SB1: Stable is reachable (full-window small drift, moderate H) ---" +# #861: a +2/step climb is a linear runaway on the value route (raw +# non-vanishing same-sign — the #422 additive class the entropy gray band +# hid). stable=0, report=diverging. +print of "--- SB1: steady linear drift reads diverging (was entropy-stable) ---" x is 30 x is 32 x is 34 @@ -22,7 +25,9 @@ print of where is x print of why is x print of "" -print of "--- SB2: Converged is NOT stable (needs full window) ---" +# #861: the routed quiescent lattice — converged implies stable (all steps +# under the deadband satisfies both). +print of "--- SB2: converged implies stable (routed lattice) ---" y is 1000000 y is 1000000 y is 1000000 diff --git a/tests/test_step.sh b/tests/test_step.sh index f08425fd..32f6f664 100644 --- a/tests/test_step.sh +++ b/tests/test_step.sh @@ -93,11 +93,13 @@ echo "$OUT" | grep -Eq '^msg = "hello" \(1 assign\)' \ # ---- 5. THE ACCEPTANCE CHECK (#418): step back and watch the label flip. # At the end conv is [converged]; 40 steps earlier it is still -# mid-descent -> [moving]. Same binding, same tape, different moment. +# mid-descent -> [improving] (#861: the halving steps are contracting, +# which the routed classifier names; pre-#861 the mid-flight label +# was the residual "moving"). Same binding, same tape, different moment. OUT=$(drive "s 200" "b 40" p q) -echo "$OUT" | grep -Eq "^conv = .*\[moving\]" \ - && ok "stepping back flips conv's label converged -> moving" \ - || fail "stepping back flips conv's label converged -> moving" +echo "$OUT" | grep -Eq "^conv = .*\[improving\]" \ + && ok "stepping back flips conv's label converged -> improving" \ + || fail "stepping back flips conv's label converged -> improving" # ---- 6. breakpoints: br + c stops only on that line; rc goes back OUT=$(drive "br 7" c c rc q) @@ -116,7 +118,7 @@ echo "$OUT" | grep -q "line 8" && echo "$OUT" | grep -q "line 1" \ OUT=$(drive "s 200" "t conv" q) echo "$OUT" | grep -q "^conv: 31 assigns" \ && echo "$OUT" | grep -q "earlier assign(s) elided" \ - && echo "$OUT" | grep -Eq "\[moving\]" \ + && echo "$OUT" | grep -Eq "\[improving\]" \ && echo "$OUT" | grep -Eq "\[converged\]" \ && ok "trajectory view shows running classification" \ || fail "trajectory view shows running classification" diff --git a/tests/test_windowed_diverging.eigs b/tests/test_windowed_diverging.eigs index a22bc585..0d9c779f 100644 --- a/tests/test_windowed_diverging.eigs +++ b/tests/test_windowed_diverging.eigs @@ -1,3 +1,9 @@ +# #861: numeric bindings route the predicate words to the VALUE channel +# (see docs/PREDICATES.md). The sequences below drive numbers, so the +# entropy-window mechanics this file was written for are now reachable +# only via `classify of [t, "entropy"]` — pinned per-case where the +# entropy behaviour was the point. The routed pins were re-measured and +# each change is annotated at the assert. # Windowed `diverging` (#208, docs/PREDICATES.md) — mirror of `improving`: # diverging = count >= 3 # AND sum(window) > 0 (NET entropy ascent, magnitude-aware) @@ -25,7 +31,8 @@ a is 9.0 a is 5.0 a is 3.0 d1 is diverging -want of ["WD1 monotone clean ascent fires", d1, 1] +# #861 re-pin: value route: 499->3 with steps contracting 400->2 is a genuine approach -> improving; the ENTROPY ascent reads "diverging" only on the entropy channel (pinned via classify below) +want of ["WD1 monotone clean ascent fires", d1, 0] # WD2: noisy ascent with down-ticks still fires — net up AND majority (4/6) # genuine ascents. Proportional vote tolerates the bounces. @@ -37,7 +44,8 @@ b is 40.0 b is 5.0 b is 3.0 d2 is diverging -want of ["WD2 noisy ascent, net+majority, fires", d2, 1] +# #861 re-pin: value route: bouncing -> oscillating +want of ["WD2 noisy ascent, net+majority, fires", d2, 0] # WD3: net entropy FELL despite several ascent steps -> does NOT fire. # Magnitude mirror of the anti-lie guard: most steps tick up but the value @@ -60,7 +68,8 @@ e is 54.0 e is 52.0 e is 50.0 d4 is diverging -want of ["WD4 gray-band steady ascent does not fire", d4, 0] +# #861 re-pin: value route AGREES here for the right reason: -2/step is a linear runaway toward -inf (raw non-vanishing same-sign), where the entropy channel called it gray-band "stable" +want of ["WD4 gray-band steady ascent does not fire", d4, 1] # WD5: net UP but only a MINORITY (2/5) of steps are genuine ascents -> does # NOT fire. Proves the proportional vote gates independently of sum>0. @@ -97,4 +106,17 @@ k is 3.0 d8 is diverging want of ["WD8 count < 3 does not fire", d8, 0] + +# #861: entropy-window mechanics — WD1 is the canonical entropy-ascent (value +# decay toward 1) case; its entropy label must stay "diverging". +wd1e is 499.0 +wd1e is 99.0 +wd1e is 19.0 +wd1e is 9.0 +wd1e is 5.0 +wd1e is 3.0 +elabel is classify of [(trajectory of wd1e), "entropy"] +assert of [elabel == "diverging", f"WD1e entropy channel lost its diverging verdict: {elabel}"] +print of " PASS: WD1e entropy channel still reads diverging via classify" + print of "WINDOWED_DIVERGING_ALL_PASS" diff --git a/tests/test_windowed_equilibrium.eigs b/tests/test_windowed_equilibrium.eigs index 0752d613..6988a70b 100644 --- a/tests/test_windowed_equilibrium.eigs +++ b/tests/test_windowed_equilibrium.eigs @@ -1,3 +1,9 @@ +# #861: numeric bindings route the predicate words to the VALUE channel +# (see docs/PREDICATES.md). The sequences below drive numbers, so the +# entropy-window mechanics this file was written for are now reachable +# only via `classify of [t, "entropy"]` — pinned per-case where the +# entropy behaviour was the point. The routed pins were re-measured and +# each change is annotated at the assert. # Windowed `equilibrium` (#209, docs/PREDICATES.md): # equilibrium = count == N (full window) # AND |mean(window)| < dh_zero @@ -93,7 +99,8 @@ f is 801.0 f is 800.0 f is 801.0 f is 800.0 -want of ["WE6 sub-noise cancellation fires", equilibrium, 1] +# #861 re-pin: value route: 800<->801 is a real +/-1 perpetual oscillation (#422) — an active-motion band, exclusive of equilibrium; the entropy channel saw dH~1.5e-5 and called it rest +want of ["WE6 sub-noise cancellation fires", equilibrium, 0] # WE7: gray-band oscillation (|dH|~0.0057) -> zero-mean but variance over # dh_zero^2 -> does NOT fire (variance gate, distinct from large amplitude) diff --git a/tests/test_windowed_improving.eigs b/tests/test_windowed_improving.eigs index 5e4daf79..d072cc5f 100644 --- a/tests/test_windowed_improving.eigs +++ b/tests/test_windowed_improving.eigs @@ -1,3 +1,9 @@ +# #861: numeric bindings route the predicate words to the VALUE channel +# (see docs/PREDICATES.md). The sequences below drive numbers, so the +# entropy-window mechanics this file was written for are now reachable +# only via `classify of [t, "entropy"]` — pinned per-case where the +# entropy behaviour was the point. The routed pins were re-measured and +# each change is annotated at the assert. # Windowed `improving` (#207, docs/PREDICATES.md) — hybrid rule: # improving = count >= 3 # AND sum(window) < 0 (NET entropy descent, magnitude-aware) @@ -23,7 +29,8 @@ a is 19.0 a is 99.0 a is 499.0 i1 is improving -want of ["WI1 monotone clean descent fires", i1, 1] +# #861 re-pin: value route: growth 3->499 is a linear/geometric runaway -> diverging; the ENTROPY descent this case drove reads "improving" only on the entropy channel (pinned via classify below for WI1) +want of ["WI1 monotone clean descent fires", i1, 0] # WI2: noisy descent with bounces still fires — net down AND majority (4/6) # genuine descents. The old absolute bounce cap rejected this; the @@ -36,7 +43,8 @@ b is 200.0 b is 60.0 b is 4000.0 i2 is improving -want of ["WI2 noisy descent, net+majority, fires", i2, 1] +# #861 re-pin: value route: wild bouncing (4 deadband sign-flips) -> oscillating +want of ["WI2 noisy descent, net+majority, fires", i2, 0] # WI3: net entropy ROSE despite several descent steps -> does NOT fire. # Guards against the magnitude-blind failure: most steps tick down but the @@ -96,4 +104,17 @@ h is 9999.0 i8 is improving want of ["WI8 count < 3 does not fire", i8, 0] + +# #861: keep the entropy-window mechanics covered — WI1 is the canonical +# entropy-descent (value growth) case; its entropy label must stay "improving". +wi1e is 3.0 +wi1e is 5.0 +wi1e is 9.0 +wi1e is 19.0 +wi1e is 99.0 +wi1e is 499.0 +elabel is classify of [(trajectory of wi1e), "entropy"] +assert of [elabel == "improving", f"WI1e entropy channel lost its improving verdict: {elabel}"] +print of " PASS: WI1e entropy channel still reads improving via classify" + print of "WINDOWED_IMPROVING_ALL_PASS" diff --git a/tests/test_windowed_oscillating.eigs b/tests/test_windowed_oscillating.eigs index f675a745..6e372a41 100644 --- a/tests/test_windowed_oscillating.eigs +++ b/tests/test_windowed_oscillating.eigs @@ -1,3 +1,9 @@ +# #861: numeric bindings route the predicate words to the VALUE channel +# (see docs/PREDICATES.md). The sequences below drive numbers, so the +# entropy-window mechanics this file was written for are now reachable +# only via `classify of [t, "entropy"]` — pinned per-case where the +# entropy behaviour was the point. The routed pins were re-measured and +# each change is annotated at the assert. # Windowed `oscillating` (#206, docs/PREDICATES.md): # oscillating = count >= 3 # AND sign_flip_count(window) >= FLIPS (FLIPS = ceil(N/3) = 4) @@ -66,7 +72,8 @@ e is 800.0 e is 801.0 e is 800.0 o5 is oscillating -want of ["WO5 sub-noise wobble does not fire", o5, 0] +# #861 re-pin: value route: 800<->801 is a REAL +/-1 perpetual oscillation — "sub-noise" only to the entropy signal (H is flat out there, the #294 blindness). #422 pins this class as oscillating; the entropy label (equilibrium-ish) is preserved via classify below +want of ["WO5 sub-noise wobble does not fire", o5, 1] # WO6: monotone descent — no flips at all f is 3.0 diff --git a/tests/test_windowed_stable.eigs b/tests/test_windowed_stable.eigs index 33b80a8f..cb576672 100644 --- a/tests/test_windowed_stable.eigs +++ b/tests/test_windowed_stable.eigs @@ -1,3 +1,9 @@ +# #861: numeric bindings route the predicate words to the VALUE channel +# (see docs/PREDICATES.md). The sequences below drive numbers, so the +# entropy-window mechanics this file was written for are now reachable +# only via `classify of [t, "entropy"]` — pinned per-case where the +# entropy behaviour was the point. The routed pins were re-measured and +# each change is annotated at the assert. # Windowed `stable` (#205, docs/PREDICATES.md): # stable = count == N (full window) # AND every |dH| < dh_small @@ -28,7 +34,8 @@ a is 44.0 a is 46.0 a is 48.0 a is 50.0 -want of ["WS1 full-window drift fires", stable, 1] +# #861 re-pin: value route: +2/step linear climb -> diverging; entropy small-dH "stable" was the additive-runaway blindness (#422) +want of ["WS1 full-window drift fires", stable, 0] # WS2: full-window quiet at HIGH entropy -> stable (also equilibrium) b is 2.0 @@ -83,7 +90,7 @@ e is 1000000.0 e is 1000000.0 e is 1000000.0 e is 1000000.0 -want of ["WS5 low-entropy quiet does not fire (converged)", stable, 0] +want of ["WS5 low-entropy quiet does not fire (converged)", stable, 1] # WS6: gray-band oscillation (consecutive sign flips) -> NOT stable f is 70.0 @@ -125,6 +132,6 @@ h is 100.0 h is 100.0 h is 100.0 h is 100.0 -want of ["WS8 quiet just below h_low does not fire", stable, 0] +want of ["WS8 quiet just below h_low does not fire", stable, 1] print of "WINDOWED_STABLE_ALL_PASS"