Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/COMPARISON.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
78 changes: 76 additions & 2 deletions docs/PREDICATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Comment on lines +124 to +128
`±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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
51 changes: 51 additions & 0 deletions src/eigenscript.c
Original file line number Diff line number Diff line change
Expand Up @@ -558,14 +558,44 @@ 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;
return (s->entropy < g_obs_h_low) ? 1 : 0;
}

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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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++) {
Expand Down
15 changes: 11 additions & 4 deletions src/eigenscript.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
8 changes: 4 additions & 4 deletions src/jit.c
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 6 additions & 0 deletions tests/run_all_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading