diff --git a/docs/COMPARISON.md b/docs/COMPARISON.md index 6215790e..04ce57d3 100644 --- a/docs/COMPARISON.md +++ b/docs/COMPARISON.md @@ -400,6 +400,27 @@ print of (diagnose of (trajectory of r)) diverging ``` +One difference to carry across when you port numeric code: EigenScript has +no `Infinity`, so an overflow that Python would show you as `inf` saturates +at `±1e308` instead (see [SPEC.md](SPEC.md), *Numbers*). A clamped value +stops moving, which would make a runaway look like the calmest possible +trajectory — so the observer treats the ceiling as evidence of divergence, +not of rest (#861). Keep the runaway above going and the verdict holds: + +```eigenscript +r is 1.0 +i is 0 +loop while i < 2000: + r is r * 2.0 + i is i + 1 +print of (report of r) +print of (converged of r) +``` +```output +diverging +0 +``` + ## Before / after: porting checklist Transformations you will apply constantly when porting Python code: diff --git a/docs/PREDICATES.md b/docs/PREDICATES.md index db77b4d8..1807ebe9 100644 --- a/docs/PREDICATES.md +++ b/docs/PREDICATES.md @@ -77,7 +77,8 @@ If the window does not yet hold enough samples, **every predicate returns 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. +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 @@ -94,6 +95,57 @@ differently. The check reads the binding's current value at ask time — rebind `f` to a number and its numeric trajectory (which was recorded all along) answers normally again. +## Saturation rule (#861): bindings at the numeric ceiling + +Overflow saturates at `±1e308` (`num_guard`, the "finite by construction" +rule), which turns an unbounded trajectory into a **fixed point**. Once a +runaway pins there, `dH` is exactly 0 in the entropy channel and the +relative step is exactly 0 in the value channel, and `H(1e308)` sits far +under `h_low` — so every clause of `converged` is *legitimately* satisfied. +The window really is quiet. The quiet is an artifact of the clamp, formed +after the evidence of divergence was already destroyed. + +So a binding whose last observed number has `|value| >= 1e308` is treated +as **diverging, and in no rest band**: + +| predicate | at the ceiling | +|---|---| +| `converged`, `equilibrium`, `stable` | forced `false` — the rest bands | +| `improving` | forced `false` (see below) | +| `diverging` | forced `true`, **including on a partial window** | +| `oscillating` | unchanged — evaluated normally | + +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. + +`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. + +**A literal `±1e308` that never overflowed reads `diverging` too.** The +runtime cannot distinguish a saturated value from one deliberately +assigned the ceiling (#865: the saturated value compares equal to itself +under further growth, so no in-language predicate separates them). Of the +two possible errors this is the loud one: a diverging solver that reports +`converged` returns `1e308` *as an answer*, while a deliberate ceiling +constant reported as `diverging` is a visible false alarm. Below the +ceiling nothing changes — `1e307` held constant still converges. + +As with the opaque rule, the entropy *constants* are untouched: this is a +classification gate, not a change to what entropy measures. It lives with +the predicates rather than in the VM (contrast #708, which must read the +binding), so the tape, DAP, and step surfaces inherit it. + ## Implementation status All six predicates are now windowed (the #202 series is complete): @@ -306,7 +358,12 @@ the most specific via its priority order. - `improving` and `diverging` require opposite net trends, so at most one fires. - `oscillating` requires ≥ `FLIPS` sign changes, which a window with a - monotone net trend (improving/diverging) cannot have. + monotone net trend (improving/diverging) cannot have. (At the numeric + ceiling `diverging` is forced true from the value's *position* rather + than the window's trend, so it is the one case where both can hold at + once: a trajectory that moves in and out of the ceiling and lands on it + has real `dH` sign changes AND a saturated last value. `report`'s + priority order resolves that to `oscillating`, the more specific claim.) - `stable` requires every `|dH| < dh_small`; `improving`/`diverging` require ≥ 60% of steps to *clear* `dh_small` in one direction, so a uniformly small-motion (gray-band) window is `stable` with a @@ -496,6 +553,23 @@ for i in range of 13: report of x # "diverging" (rising information content) ``` +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) +``` + +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. diff --git a/docs/SPEC.md b/docs/SPEC.md index 3c5b2718..dbbe4241 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -1196,6 +1196,32 @@ opaque 0 ``` +**The saturation ceiling is not a rest state** (#861). Overflow saturates +at `±1e308` (*Numbers*, above), which turns an unbounded trajectory into a +fixed point: the dH window fills with zeros and the entropy of `1e308` +falls under the low-entropy threshold, so a runaway satisfies every clause +of `converged`. The window really is quiet — the quiet is an artifact of +the clamp, produced *after* the evidence of divergence was destroyed. So a +binding sitting at `±1e308` is `diverging` in both channels and in no rest +band: `converged`, `equilibrium`, `stable`, and `improving` are all false. +A binding assigned a literal `±1e308` that never overflowed reads +`diverging` too — the runtime cannot distinguish the two, and this is the +direction that fails loudly. Below the ceiling nothing changes: + +```eigenscript +z is 2.0 +i is 0 +loop while i < 20: + z is z * z + i is i + 1 +print of report of z +print of (converged of z) +``` +```output +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`, diff --git a/src/eigenscript.c b/src/eigenscript.c index 419bd44e..609e1b6d 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -558,7 +558,36 @@ void observer_slot_reset(Env *e) { e->obs_cap = 0; } +/* #861: a numeric binding pinned at the saturation ceiling (±EIGS_NUM_MAX, + * where num_guard clamps everything that escapes the finite number line) is + * not evidence of rest — it is the ABSENCE of evidence. A geometric runaway + * reaches the ceiling and stays there, so the dH window fills with zeros and + * H(EIGS_NUM_MAX) falls under h_low: every clause of `converged` is + * legitimately satisfied while the trajectory it describes was destroyed + * before the observer sampled it. The window really is quiet; the quiet is an + * artifact of the clamp. + * + * So the rest bands (converged/equilibrium/stable) and `improving` refuse a + * saturated binding, and `diverging` claims it: under the "finite by + * construction" rule this value has, by the contract's own definition, + * escaped the finite number line. A binding assigned a literal ±EIGS_NUM_MAX + * that never overflowed reads `diverging` too — the runtime genuinely cannot + * tell the two apart (that indistinguishability is #865), and this is the + * direction that fails loudly. A diverging solver reporting `converged` hands + * back EIGS_NUM_MAX as an answer; the reverse is a visible false alarm. + * + * Keyed on the slot's own last observed number, not the env's current value: + * that keeps the check at the same layer as the windows it guards, so the + * tape/DAP/step surfaces (which build an ObserverSlot without an Env) get it + * for free. Contrast #708's opaque band, which must sit in vm.c because + * value-is-a-function is only answerable from the binding. The entropy + * constants are deliberately untouched — same discipline as #708. */ +static int observer_slot_saturated(const ObserverSlot *s) { + return (s && s->v_used && fabs(s->last_value) >= EIGS_NUM_MAX) ? 1 : 0; +} + int observer_slot_converged(const ObserverSlot *s) { + if (observer_slot_saturated(s)) return 0; /* #861 */ 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; @@ -566,6 +595,7 @@ int observer_slot_converged(const ObserverSlot *s) { } int observer_slot_equilibrium(const ObserverSlot *s) { + if (observer_slot_saturated(s)) return 0; /* #861 */ 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); @@ -583,6 +613,12 @@ 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; size_t cnt = s ? s->dh_window_count : 0; if (cnt < 3) return 0; double sum = 0.0; int down = 0; @@ -595,6 +631,10 @@ 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; size_t cnt = s ? s->dh_window_count : 0; if (cnt < 3) return 0; double sum = 0.0; int up = 0; @@ -620,6 +660,7 @@ int observer_slot_oscillating(const ObserverSlot *s) { } int observer_slot_stable(const ObserverSlot *s) { + if (observer_slot_saturated(s)) return 0; /* #861 */ 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; @@ -689,6 +730,16 @@ const char *observer_slot_report_value(const ObserverSlot *s) { * 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++) { diff --git a/src/eigenscript.h b/src/eigenscript.h index ac04b5e2..e0dec54e 100644 --- a/src/eigenscript.h +++ b/src/eigenscript.h @@ -949,14 +949,21 @@ void free_value(Value *v); * Acquire-release decrement: release ensures writes are visible before the * refcount store; acquire ensures the thread that sees 0 observes all prior * writes before calling free_value. */ +/* The saturation ceiling: the largest magnitude a user number can hold. + * #861: three sites must agree on this or the observer goes blind to the + * boundary the arithmetic clamps to — num_guard below, the JIT's bail + * comparison (jit.c), and observer_slot_saturated (eigenscript.c). It was + * a bare literal in all three; one macro so they cannot drift apart. */ +#define EIGS_NUM_MAX 1e308 + /* Numeric invariant: EigenScript has no NaN or Infinity. * All numeric operations route through this guard. * NaN -> 0; values escaping the finite number line saturate at - * +/-1e308 instead of becoming Infinity. */ + * +/-EIGS_NUM_MAX instead of becoming Infinity. */ static inline double num_guard(double x) { - if (x != x) return 0.0; /* NaN */ - if (x > 1e308) return 1e308; /* +Inf or overflow */ - if (x < -1e308) return -1e308; /* -Inf or underflow */ + if (x != x) return 0.0; /* NaN */ + if (x > EIGS_NUM_MAX) return EIGS_NUM_MAX; /* +Inf or overflow */ + if (x < -EIGS_NUM_MAX) return -EIGS_NUM_MAX; /* -Inf or underflow */ return x; } diff --git a/src/jit.c b/src/jit.c index 7d2af676..3a471053 100644 --- a/src/jit.c +++ b/src/jit.c @@ -2379,11 +2379,11 @@ static void jit_compile_to_thunk(struct EigsChunk *chunk, uint8_t *w = code; - /* Bit pattern of 1e308 — the maximum finite value num_guard allows. - * Any addsd/subsd result whose magnitude bits exceed this (incl. - * ±Inf / NaN) bails to the slow interpreter path. */ + /* Bit pattern of EIGS_NUM_MAX — the maximum finite value num_guard + * allows. Any addsd/subsd result whose magnitude bits exceed this + * (incl. ±Inf / NaN) bails to the slow interpreter path. */ uint64_t max_normal_bits; - { double d = 1e308; memcpy(&max_normal_bits, &d, 8); } + { double d = EIGS_NUM_MAX; memcpy(&max_normal_bits, &d, 8); } /* Bail-jump rel32 patch sites collected during body emission and * resolved against the epilogue once we know its address. */ diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index e67a4e15..8f30295b 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -3406,6 +3406,12 @@ check_eigs_suite "observer park-env reset" test_observer_park.eigs "OBS_PARK_OK" # deadband-normalized settledness gradient. check_eigs_suite "observer coherence (#412)" test_observer_coherence.eigs "All tests passed" 10 +# #861: the saturation ceiling is not a rest state. A runaway clamped to +# +/-1e308 used to satisfy every clause of `converged` in BOTH channels — +# the canonical instability reported as maximal stability. Pins the refusal +# AND that everything below the ceiling classifies exactly as before. +check_eigs_suite "observer saturation ceiling (#861)" test_observer_saturation.eigs "OBSERVER_SATURATION_ALL_PASS" 1 + # #571: the entropy walk is visited-once — cyclic/shared container graphs # complete (two back-edges used to be ~2^32 subtree walks). Since #685 the # walk stops at a reference, so cycles and DAG sharing cannot be traversed diff --git a/tests/test_observer_saturation.eigs b/tests/test_observer_saturation.eigs new file mode 100644 index 00000000..761b3c2d --- /dev/null +++ b/tests/test_observer_saturation.eigs @@ -0,0 +1,129 @@ +# #861 — the observer refuses to certify rest at the saturation ceiling. +# +# num_guard clamps everything escaping the finite number line to +/-1e308, so a +# geometric runaway becomes a fixed point: the dH window fills with zeros and +# H(1e308) falls under h_low. Every clause of `converged` is then legitimately +# satisfied — while the evidence of divergence was destroyed before the +# observer sampled it. Both channels used to answer with the STRONGEST rest +# band on the canonical instability; #422 had recorded that case as caught. +# +# The contract pinned here: a binding sitting at +/-1e308 is `diverging` in +# both channels and in NO rest band, and everything below the ceiling is +# classified exactly as before. + +pass is 1 + +define fail(msg) as: + pass is 0 + print of ("FAIL: " + msg) + return 0 + +# The issue's repro: z = z*z from 2.0 reaches the ceiling in ~6 steps and is +# pinned for the remaining 14. +define run_runaway() as: + local z is 2.0 + local k is 0 + loop while k < 20: + z is z * z + k is k + 1 + return [report of z, report_value of z, converged of z, diverging of z, + stable of z, equilibrium of z, improving of z, z] + +# Same runaway with the sign flipped every step — pins at -1e308. +define run_negative() as: + local z is 0 - 2.0 + local k is 0 + loop while k < 20: + z is z * z * (0 - 1.0) + k is k + 1 + return [report of z, diverging of z, z] + +# Geometric decay — genuinely settles, and must still be certified. +define run_converge() as: + local x is 100.0 + local k is 0 + loop while k < 30: + x is x * 0.5 + k is k + 1 + return [report of x, converged of x] + +# One decade below the ceiling, held constant: unsaturated, so the rest bands +# stay available. This is what keeps the gate tight to the boundary rather +# than to "large". +define run_below_ceiling() as: + local b is 1e307 + local k is 0 + loop while k < 12: + b is 1e307 + k is k + 1 + return [report of b, converged of b] + +# A literal at the ceiling that never overflowed. The runtime cannot tell this +# apart from a saturated value (that indistinguishability is #865), so it reads +# `diverging` too — deliberately the loud direction. +define run_literal_ceiling() as: + local c is 1e308 + local k is 0 + loop while k < 12: + c is 1e308 + k is k + 1 + return [report of c, converged of c] + +# --- Case 1: the runaway is diverging in both channels, and in no rest band. +r is run_runaway of null +print of ("runaway: report=" + r[0] + " report_value=" + r[1] + " value=" + (str of r[7])) +if not (r[7] == 1e308): + fail of "runaway did not reach the saturation ceiling — repro no longer valid" +if not (r[0] == "diverging"): + fail of ("entropy channel: expected 'diverging', got " + r[0]) +if not (r[1] == "diverging"): + fail of ("value channel: expected 'diverging', got " + r[1]) +if not (r[2] == 0): + fail of "converged certified a saturated value" +if not (r[3] == 1): + fail of "diverging did not claim a saturated value" +if not (r[4] == 0): + fail of "stable certified a saturated value" +if not (r[5] == 0): + fail of "equilibrium certified a saturated value" +if not (r[6] == 0): + fail of "improving claimed a saturated value" + +# --- Case 2: the negative ceiling behaves identically. +n is run_negative of null +print of ("negative: report=" + n[0] + " value=" + (str of n[2])) +if not (n[2] == (0 - 1e308)): + fail of "negative runaway did not reach -1e308" +if not (n[0] == "diverging"): + fail of ("negative ceiling: expected 'diverging', got " + n[0]) +if not (n[1] == 1): + fail of "diverging did not claim the negative ceiling" + +# --- Case 3: genuine convergence is untouched (the non-regression half). +c is run_converge of null +print of ("decay: report=" + c[0]) +if not (c[0] == "converged"): + fail of ("geometric decay lost its 'converged' verdict, got " + c[0]) +if not (c[1] == 1): + fail of "converged predicate lost a genuine convergence" + +# --- Case 4: below the ceiling the rest bands remain available. +b is run_below_ceiling of null +print of ("below-ceiling: report=" + b[0]) +if not (b[0] == "converged"): + fail of ("1e307 constant should still converge, got " + b[0]) +if not (b[1] == 1): + fail of "converged predicate rejected an unsaturated constant" + +# --- Case 5: a literal at the ceiling reads diverging, by design. +l is run_literal_ceiling of null +print of ("literal-ceiling: report=" + l[0]) +if not (l[0] == "diverging"): + fail of ("literal at the ceiling: expected 'diverging', got " + l[0]) +if not (l[1] == 0): + fail of "converged certified a literal at the ceiling" + +if pass == 1: + print of "OBSERVER_SATURATION_ALL_PASS" +else: + print of "SOME TESTS FAILED"