Skip to content

observer: the saturation ceiling is not a rest state (#861) - #889

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/861-saturation-not-rest
Aug 5, 2026
Merged

observer: the saturation ceiling is not a rest state (#861)#889
InauguralPhysicist merged 1 commit into
mainfrom
fix/861-saturation-not-rest

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

The bug

A textbook geometric runaway (z is z * z from 2.0) saturates at ±1e308 under 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, and H(1e308) falls under h_low — so every clause of converged is legitimately satisfied, in both channels.

report=converged report_value=converged value=1e+308
  diverging? 0 converged? 1

README.md:158 sells 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_MAX is refused by the rest bands and claimed by diverging, in both channels.

predicate at the ceiling
converged, equilibrium, stable forced false
improving forced false
diverging forced true, including on a partial window
oscillating unchanged

Three judgement calls worth reviewing:

  1. improving is gated too. H decreases as |x| grows past 1, so a runaway climbing toward the ceiling shows a run of negative dH — and report tries improving before converged. Ungated, the fix would just move the wrong answer one band over. (This mirror of the documented shrinking→diverging case was only half-documented; PREDICATES.md now covers both directions.)
  2. diverging fires 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.
  3. A literal ±1e308 that never overflowed reads diverging too. 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 reporting converged returns 1e308 as 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:

$ eigenscript --step sat.tape
z = 1e+308  [diverging]  (21 assigns)

EIGS_NUM_MAX replaces the bare 1e308 literal in num_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_saturated stubbed to return 0 and the binary rebuilt, the new section reproduces the original bug and fails on 9 assertions. Restored, it passes.

gate result
release suite 3786/3786, 0 failed
ASan+UBSan detect_leaks=1 3784/3784, 0 failed — LeakSanitizer tally at its 0 floor
make jit-smoke all cases passed
make freestanding-check both stages OK
doc-example gate 76 passed, 0 failed

Not in scope

Closes #861

🤖 Generated with Claude Code

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>
Copilot AI lite review requested due to automatic review settings August 5, 2026 14:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_MAX are never certified as rest/improving and are claimed as diverging (including on partial windows).
  • Introduce EIGS_NUM_MAX and use it in num_guard and 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 thread docs/PREDICATES.md
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
InauguralPhysicist merged commit 27a22e7 into main Aug 5, 2026
19 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix/861-saturation-not-rest branch August 5, 2026 15:09
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

observer: genuine exponential divergence reports converged in BOTH channels once saturation clamps — contradicts #422's stated guarantee

2 participants