observer: the saturation ceiling is not a rest state (#861) - #889
Merged
Conversation
A geometric runaway saturates at ±1e308 ("finite by construction"), which
turns an unbounded trajectory into a fixed point. The dH window then fills
with zeros and H(1e308) falls under h_low, so every clause of `converged`
is legitimately satisfied — in BOTH channels. The canonical instability
reported maximal stability with rc=0, and #422 had recorded that class as
caught. The window really is quiet; the quiet is an artifact of the clamp,
formed after the evidence of divergence was already destroyed.
Fix at the predicate layer: a slot whose last observed number sits at
|value| >= EIGS_NUM_MAX is refused by the rest bands (converged,
equilibrium, stable) and claimed by `diverging`, in both the entropy and
the value channel.
- `improving` is gated too. H decreases as |x| grows past 1, so a runaway
climbing toward the ceiling shows a run of negative dH; since `report`
tries improving before converged, leaving it open just moves the wrong
answer one band over.
- `diverging` is claimed BEFORE the partial-window guard — the evidence is
the value's position, not the shape of the flattened window. This is the
one predicate that can fire on fewer than 3 samples.
- `oscillating` is untouched, so a trajectory moving in and out of the
ceiling still resolves to the more specific band.
Arithmetic is unchanged (the Numbers contract is intact) and the entropy
constants are untouched — this is a classification gate, the same
discipline as #708's opaque band. It lives with the predicates rather than
in vm.c, so the tape, DAP and step surfaces inherit it: `--step` on a
runaway tape now shows `z = 1e+308 [diverging]`.
A literal ±1e308 that never overflowed reads `diverging` too. The runtime
cannot distinguish the two (that indistinguishability is #865's second
item), and this is the direction that fails loudly: a diverging solver
reporting `converged` returns 1e308 *as an answer*.
EIGS_NUM_MAX replaces the bare 1e308 literal in num_guard, the JIT bail
comparison, and the new observer gate — three sites that must agree or the
observer goes blind to the boundary the arithmetic clamps to.
Docs: SPEC.md and COMPARISON.md gain executable examples (both pass the
byte-for-byte doc gate); PREDICATES.md gains a saturation-rule section,
amends the partial-window rule and the mutual-exclusion lattice for the
two exceptions above, and completes the entropy-peak section with the
growing-value mirror (a runaway below the ceiling reads `improving`) that
was previously documented in the shrinking direction only.
Suite [Observer] test_observer_saturation.eigs pins the refusal, the
negative ceiling, and — the half that matters for regressions — that
genuine convergence and a constant one decade below the ceiling still
certify exactly as before. Validated with a planted fault: with the gate
stubbed to 0, the new section reproduces the original bug and fails.
Gates: release 3786/3786, ASan+UBSan detect_leaks=1 3784/3784 with the
LeakSanitizer tally at its 0 floor, jit-smoke, freestanding-check, and the
doc-example gate (76 pass) all green.
Closes #861
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes a correctness bug in the observer predicates where numeric overflow saturation at ±1e308 could make an unbounded trajectory appear as a rest state (converged/stable/equilibrium) in both entropy and value channels. The change adds an explicit “saturation ceiling” classification gate (treat ceiling as diverging) and unifies the ceiling constant across the runtime.
Changes:
- Add a saturation-ceiling gate so bindings at
|value| >= EIGS_NUM_MAXare never certified as rest/improving and are claimed asdiverging(including on partial windows). - Introduce
EIGS_NUM_MAXand use it innum_guardand the JIT’s bailout comparison to keep the saturation boundary consistent. - Add a new observer regression test section and update SPEC/PREDICATES/COMPARISON docs to describe the saturation rule.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_observer_saturation.eigs | New regression coverage for saturation-ceiling classification in both channels, including negative ceiling and non-regressions below the ceiling. |
| tests/run_all_tests.sh | Registers the new observer saturation ceiling test section in the suite. |
| src/jit.c | Switches JIT bailout threshold from a 1e308 literal to EIGS_NUM_MAX. |
| src/eigenscript.h | Adds EIGS_NUM_MAX and applies it in num_guard for the saturation clamp. |
| src/eigenscript.c | Implements observer_slot_saturated() and gates/restores predicate/report behavior at the ceiling. |
| docs/SPEC.md | Documents that the saturation ceiling is not a rest state and shows an example. |
| docs/PREDICATES.md | Specifies the saturation rule, including partial-window diverging behavior and channel-disagreement notes. |
| docs/COMPARISON.md | Notes overflow saturation vs inf and the observer’s ceiling handling when porting numeric code. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+124
to
+128
| 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 |
| 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. |
InauguralPhysicist
added a commit
that referenced
this pull request
Aug 5, 2026
…) (#891) #889 closed #861's reproducer, not its defect. I wrote that fix; this is the correction. The saturation gate lands at |v| >= 1e308. The false `converged` starts ~305 decades earlier: x *= 1.5 first certifies `converged` at x = 287,626 (~1e5) x *= 2.0 first certifies `converged` at x = 8,388,608 h_low = 0.1 against H(x) = H_b(1/(1+|x|)) puts every |x| > ~76 under the entropy floor, so `converged` fires across [77, 1e307]. A linear runaway `y is y + 1.0` exits `loop while not (converged of y)` at y = 88. The boundary is a region; #889 closed one point at the top of it. ## The oracle tests/test_convergence_oracle.eigs — 27 sequences whose behaviour is known analytically, no implementation consulted. This is the one component in the project that never had an external reference to be wrong against; every other subsystem has one (Blargg ROMs, the Go liferaft, drat-trim, POSIX, the C compiler), and every other subsystem is correct. ENTROPY CHANNEL (what converged/report use): 19/27 FP=3 FN=5 VALUE CHANNEL (report_value) : 25/27 FP=2 FN=0 Cases 25-27 are the cleanest single result: the same computation (halve the distance each step) targeting 5000, 5 and 0.005 returns converged / equilibrium / converged. Identical mathematics, verdict decided by units. ## Two corrections made while landing it Ground truth was wrong on three labels. Cases 6, 7 and 8 (1 + 1/n, (-1)^n/n, Basel partial sums) were labelled "settles", but their tails decay like 1/n, so at step 60 they sit 1.6e-2, 1.6e-2 and 9.9e-3 from their limits — outside the file's own 1e-9 criterion. They converge; they have not arrived. Verified against the closed forms. This moves entropy 16->19 and value 26->25: two of the value channel's wins were against mislabelled truth, and are really false positives. Aggregate scores alone are too coarse a gate. Planting a real semantics change (h_low 0.1 -> 0.5, which makes a held 10.0 certify converged) left both scores unmoved while flipping cases 7 and 13 from `stable` to `moving` — offsetting changes cancel in a count. The per-case bands are pinned too, and that version names the drifting cases. ## Green, not red The baseline is pinned rather than failing: a permanently red suite erodes every other gate, and [99c] is the existing precedent for inverting an expected failure into a pass. Movement in EITHER direction fails, so a fix must land as a deliberate edit here. ## test_observer_saturation.eigs Case 4 asserted "1e307 constant should still converge". A constant does converge, so the assertion was not false — but the entropy channel cannot distinguish it from a runaway that flattened there, so pinning the verdict pinned the region. It now asserts only that the saturation gate is tight to the ceiling, which is the property that is actually defensible. Gates: release 3787/3787, ASan+UBSan detect_leaks=1 3785/3785 with the LeakSanitizer tally at its 0 floor. Re-opens #861. Related: #862, #865. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
A textbook geometric runaway (
z is z * zfrom 2.0) saturates at±1e308under the "finite by construction" rule. That turns an unbounded trajectory into a fixed point: the dH window fills with zeros, the relative step goes to exactly 0, andH(1e308)falls underh_low— so every clause ofconvergedis legitimately satisfied, in both channels.README.md:158sells the trajectory words for instability alerts. This is the canonical instability, and the runtime reported maximal stability with rc=0. #422 had recorded this class as caught; that reasoning held only while the value could still grow.The fix
A slot whose last observed number sits at
|value| >= EIGS_NUM_MAXis refused by the rest bands and claimed bydiverging, in both channels.converged,equilibrium,stableimprovingdivergingoscillatingThree judgement calls worth reviewing:
improvingis gated too.Hdecreases as|x|grows past 1, so a runaway climbing toward the ceiling shows a run of negative dH — andreporttriesimprovingbeforeconverged. Ungated, the fix would just move the wrong answer one band over. (This mirror of the documented shrinking→divergingcase was only half-documented; PREDICATES.md now covers both directions.)divergingfires before the partial-window guard — the evidence is the value's position, not the shape of the flattened window. This is the only predicate that can fire on fewer than 3 samples, and the partial-window rule is amended to say so.±1e308that never overflowed readsdivergingtoo. The runtime cannot distinguish it from a saturated value (numbers: saturation breaks associativity by 292 orders of magnitude silently, and makes overflow undetectable in-language #865 item 2). Of the two errors this is the loud one: a diverging solver reportingconvergedreturns1e308as an answer.Arithmetic is unchanged — the Numbers contract is intact — and the entropy constants are untouched. This is a classification gate, the same discipline as #708's opaque band.
Layering: the gate sits with the predicates rather than in
vm.c(where #708 must live, since value-is-a-function is only answerable from the binding). So the tape, DAP and step surfaces inherit it for free — verified, not assumed:EIGS_NUM_MAXreplaces the bare1e308literal innum_guard, the JIT bail comparison, and the new gate — three sites that must agree or the observer goes blind to the boundary the arithmetic clamps to.Validation
New suite section
[Observer] observer saturation ceiling (#861)pins the refusal, the negative ceiling, and — the half that matters for regressions — that genuine convergence and a constant one decade below the ceiling (1e307) still certify exactly as before.Planted fault: with
observer_slot_saturatedstubbed toreturn 0and the binary rebuilt, the new section reproduces the original bug and fails on 9 assertions. Restored, it passes.detect_leaks=1make jit-smokemake freestanding-checkNot in scope
log of 0returning a clamped fudge value) are contract-level changes to the Numbers promise and are left open; only the observer-facing consequence is fixed here.H(x) ≡ H(1/x) ≡ H(−x)) is a separate root on the entropy-constant fence. It shows up here as the two channels disagreeing on a pure sign-flip at the ceiling —reportsaysdiverging,report_valuesaysoscillating— which PREDICATES.md now notes explicitly.Closes #861
🤖 Generated with Claude Code