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
66 changes: 66 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,72 @@ All notable changes to EigenScript are documented here.

### Fixed

- **`report` labelled a still-moving value "equilibrium" at a full window,
violating the agreement guarantee it documents (#735).** `report of x`
resolves the six windowed bands in priority order and then, if none is true,
falls back to an instantaneous label read off the last `dH` alone. That
fallback exists for a *partial* window — where the full-window predicates are
false by rule and `report` still has to say something — but it was never
gated on `count < N`, so it also ran at a full window. The bands are not
exhaustive: a full window of steady gray-band drift at *low* entropy fires
nothing (every step under `dh_small` excludes improving/diverging by the #187
rule, a mean over `dh_zero` excludes equilibrium/converged, and `stable`
requires `entropy >= h_low`), and every such window was reported
`equilibrium`. A decaying residual — `d is d * 0.5`, twenty times — is in
exactly that state, so a convergence loop written the way `PREDICATES.md`
recommends, keyed on `report`, exited early at rc=0 with a plausible answer.
`docs/PREDICATES.md:328-335` states the opposite guarantee explicitly and
names the partial window as the only exception; found by an agent that
reimplemented the published formulas as an independent oracle, which
confirmed the six predicates were right and `report` was wrong. The fallback
is now gated on a partial window, and a full window with no band true reports
**`moving`** — the residual label the value channel already uses for this
state, so both channels answer "not settled, not in any named band" the same
way. The invariant (at a full window, `report` names a band whose predicate
is true, or `moving`) is now asserted in `tests/test_predicate_matrix.eigs`
across every full-window shape; it had been prose only, which is why this
survived. `lib/experiment.eigs` was relying on the wrong answer: all five of
its analyzers seeded `tracker is 0` *observed*, putting a synthetic
`0 -> values[0]` entropy jump in the first window, and
`is_measurement_stable` scored ten identical readings as stable only because
the fallback overrode the resulting window. The sentinel is now seeded inside
`unobserved:`, so the trajectory is the data. The cross-repo observer corpus
(#262) caught the consumer-visible half and is the best evidence the fix is
right: `dynamics`' Jacobi, Gauss-Seidel and PageRank solvers all drive
`status is report of change` and stop on `"equilibrium"`, so all three were
terminating early on the false label — Jacobi at 14 iterations and
`0.9999995231628418`, now 21 and `0.9999999997671694`; Gauss-Seidel 11 → 18;
PageRank 27 → 32. A real downstream solver was silently returning a
less-accurate answer. Three goldens were recaptured for that reason (the
other eleven programs are byte-identical).

- **Observer papercuts: a discarded query was silent, `state_at` leaked a
runtime-internal binding, and two doc rows had gone stale (#736).** Four
small independent defects on the observer surface, from the same review as
#735. (1) A bare `report of x` as a *statement* evaluates the query and
throws the answer away — nothing printed, nothing raised, exit 0. `W019`
already covered the interrogatives (`where is x`, `prev of y`); it now covers
the query special forms over an ident (`report`/`report_value`/`observe`/
`trajectory`) too. It stays zero-false-positive: over an ident those are
compiler-resolved and never reach a user function even when one shadows the
name (#459), and a non-ident argument is an ordinary call the check never
sees. This was the worst affordance the observer had — silence reads as "the
observer had nothing to say", and the bare form is the one issue bodies and
READMEs reach for. (2) `state_at of <line>` returned `__loop_exit__` in its
user-visible dict; the observed-loop machinery's own bindings are now
filtered from it and from `--step`'s bindings pane (an explicit
`p __loop_exit__` still answers, and the tape's binding count stays honest).
(3) `docs/SYNTAX.md` still described `how` as degenerate, returning 0 with 1
only at zero *entropy*; #412 made it a real gradient
(`1 - min(1, |dH| / dh_zero)`, settledness of the last step). (4)
`docs/PREDICATES.md` still claimed entropy is exactly `0` at `|x| ∈ {0, 1}`;
#412 deleted the `|x| == 1` special case, so unity is the *maximum* — the
horizon — and only `0` is the home point. That one was stale in the maximally
confusing direction: 0 vs 1 is the difference between "fully converged" and
"maximally undecided". A worked `at` example inside a loop was also added,
since "the last assignment at or before this line" answers with the loop's
final pass, which is exactly where the rule stops matching intuition.

- **`ext_http.c` leaked a `Value` on three request paths (#731).** Every
`eigs_json_parse_value` call site in the file was audited after #731 reported
the first; three of five leaked. `shared_incr` dropped two per call — the
Expand Down
2 changes: 1 addition & 1 deletion docs/BUILTINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ Query a binding's assignment history. Always on for top-level bindings;

| Name | Signature | Description |
|------|-----------|-------------|
| `report` | `report of value` | Classify change trajectory: "improving", "diverging", "stable", "equilibrium", "oscillating", "converged" |
| `report` | `report of value` | Classify change trajectory: "improving", "diverging", "stable", "equilibrium", "oscillating", "converged" — or "moving" when a full window matches none of them (#735) |
| `observe` | `observe of value` | Return [status, entropy, dH, prev_dH] snapshot |
| `classify` | `classify of t` or `classify of [t, "entropy"]` | Classify a trajectory snapshot (from `trajectory of x`, #421): value-channel label by default, entropy-channel with `"entropy"`. Raises `type_mismatch` on a non-snapshot — a bare value never silently classifies |

Expand Down
2 changes: 1 addition & 1 deletion docs/DIAGNOSTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ a code's meaning never changes, and retired codes are not reused.
| `W016` | warning | Bare trajectory predicate **outside a loop condition** (`if stable:`, `ok is converged`, `return diverging`) reads the last-observed binding — an invisible alias (#247/#262) — write `<predicate> of <var>`. Loop conditions are exempt: the single-assign `loop while not converged` form is the documented idiom, and the ambiguous multi-assign case is `W014`. Any explicit subject counts as named, including `stable of (x + 0.0)`; deliberate bare reads carry `# lint: allow W016`. |
| `W017` | warning | Bare 1-element literal arg list: `f of [x]` passes **one argument** — the element, not the list (#405; the pre-#405 rule meant the opposite, so the form reads ambiguously). Write `f of x` for one argument, or `f of ([x])` (#355) to pass a 1-element list. Doubles as the #405 migration audit: `--lint` over a consumer repo surfaces every behavior-changed call site. |
| `W018` | warning | A `catch`-bound error's `.kind` is compared (`==`/`!=`) against a string that is a **near-miss** of a real kind — a case variant (`"IO"`) or a single-character typo (`"index_rage"`), or a kind renamed out from under the handler — so the branch is dead code that silently never fires (#469). Kinds are a closed set (below). Zero-false-positive by construction: only near-misses of a closed kind fire, and only off a catch-bound variable — an exactly-valid kind, and a genuinely custom `throw {kind: "..."}` value many edits from every builtin, both stay silent. |
| `W019` | warning | An interrogative used as a **bare statement** — `why is "..."`, `what is x`, `prev of y` at statement level — evaluates and **discards** its result: a silent no-op (#583). Question words (`what/who/when/where/why/how`) cannot be assigned with `is` — the "assignment" is the interrogative expression form — so when a same-named binding exists in scope the statement is almost certainly a mistaken assignment (the real hit: a catch handler "reassigning" a `local why` that silently kept its stale value). An interrogative inside an expression (`print of (why is x)`, `r is prev of y`) is never flagged. |
| `W019` | warning | An interrogative used as a **bare statement** — `why is "..."`, `what is x`, `prev of y` at statement level — evaluates and **discards** its result: a silent no-op (#583). Question words (`what/who/when/where/why/how`) cannot be assigned with `is` — the "assignment" is the interrogative expression form — so when a same-named binding exists in scope the statement is almost certainly a mistaken assignment (the real hit: a catch handler "reassigning" a `local why` that silently kept its stale value). An interrogative inside an expression (`print of (why is x)`, `r is prev of y`) is never flagged. Extended by #736 to the observer **query** forms over an ident — `report of x`, `report_value of x`, `observe of x`, `trajectory of x` at statement level — which are the same silent no-op through the other door: they print nothing and raise nothing, so silence is indistinguishable from "the observer had nothing to say". Zero false positives by construction: over an ident these are compiler-resolved special forms that never reach a user function even when one shadows the name (#459, see `W013`), and a non-ident argument (`report of (x + 0.0)`) is an ordinary call the check never sees. |
| `W020` | warning | An `unobserved:` block in which **every** assignment targets a dict field or list element (`d.k is ...`, `xs[i] is ...`) — a provable no-op (#655). Observer bookkeeping is gated on the named env path, so a dict field is never observed and there is nothing to skip; the in-place numeric mutation the block used to enable became unconditional with NaN-boxing B-3a (`dict_set_cached_immediate`), so it costs nothing to drop the block. This shape was true when written and expired silently, which is why it needs a lint — our own README shipped the dead form as its headline example for two months. Conservative by construction: `g_unobserved_depth` is a global, so a **call** inside the block runs the callee unobserved too and suppresses the warning; any plain-variable assignment, or a name-binding form (`for` / listcomp / `catch` / `match`), also suppresses it. Only a provably inert block fires. |
| `W021` | hint | Function definition shadows a **public stdlib function** from a module the file never imported (`define 'median' shadows lib/stats.eigs 'median' (import stats to use it)`) — a discoverability nudge toward `lib/*.eigs` (#591), sibling of `W013` (which covers compiled-in builtins; a name that is both stays `W013`-only). The name table is scraped from the public top-level defines of the same `lib/` directories the import resolver searches; the hint stays silent when the module is imported, and when the linted file *is* the module that ships the name. Name-only matching has false positives (a deliberately-different local `mean`), so this is hint-severity: advisory, **never fails `--lint`** under either `--lint-level`, and suppressible like any other code. |

Expand Down
61 changes: 49 additions & 12 deletions docs/PREDICATES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

The bare predicate words — `converged`, `equilibrium`, `stable`,
`improving`, `diverging`, `oscillating` — and the `report of x` builtin
classify a value's recent trajectory into one of those bands. Each
classify a value's recent trajectory into one of those bands (`report` adds
one label the predicates don't have: `moving`, for a full window in which
none of the six is true — see [The `report` builtin](#the-report-builtin)).
Each
predicate also has a **named form**, `converged of x` (and so on), that
binds to a specific value rather than the last-observed one — the
preferred form, especially in a loop condition (see
Expand Down Expand Up @@ -304,8 +307,15 @@ the most specific via its priority order.
`equilibrium`).
So `equilibrium` never fires alone — it is always accompanied by
`converged` (low H) or `stable` (high H). A `stable` window that is *not*
equilibrium is one with steady directional drift (mean `|dH| > dh_zero`):
moving a little, but settled.
equilibrium is one with steady directional drift (mean `|dH| > dh_zero`)
**at high entropy**: moving a little, but settled.
- **The bands are not exhaustive.** A full window with steady gray-band
drift at *low* entropy fires nothing: the steps are under `dh_small` so
`improving`/`diverging` are excluded by the #187 rule, the mean is over
`dh_zero` so `equilibrium`/`converged` are excluded, and `stable`'s
`entropy >= h_low` clause excludes it too. That state is real (a value
decaying toward zero is in it) and it has no predicate — `report` names
it `moving` (#735). Do not read "no band fired" as "at rest".

This makes `report`'s priority order load-bearing: `oscillating` →
`diverging` → `improving` → `converged` → `equilibrium` → `stable` returns
Expand All @@ -324,17 +334,38 @@ band, tested in priority order:
5. `equilibrium`
6. `stable`

…and, when none of the six is true at a full window, the residual band:

7. `moving`

These all use the same windowed helpers as the predicates, so
`report of x == "converged"` agrees with `if converged:` on the same value
— **at a full window**. For a *partial* window (`count < N`), the
full-window predicates (`converged`/`equilibrium`/`stable`) are all false
by the partial-window rule, but `report` still needs to say something, so
it falls back to an instantaneous best-effort label: `equilibrium` if the
last `|dH| < dh_zero`, else `stable` if `|dH| < dh_small` at high entropy,
else `stable`. This is the one place `report` can disagree with the bare
— **at a full window**. That agreement is the contract: at a full window
the windowed helpers are the *only* authority, so `report` either names a
band whose bare predicate is true, or says `moving`. It never names a band
the predicates deny. (`moving` is also the value channel's residual label,
so both channels answer "not settled, not in any named band" the same way.)

For a *partial* window (`count < N`), the full-window predicates
(`converged`/`equilibrium`/`stable`) are all false by the partial-window
rule, but `report` still needs to say something, so it falls back to an
instantaneous best-effort label: `equilibrium` if the last
`|dH| < dh_zero`, else `stable` if `|dH| < dh_small` at high entropy, else
`stable`. This is the one place `report` can disagree with the bare
predicates, and only while observations are still accumulating — by the
time the window fills, the windowed helpers decide and the two agree.

**#735**: that fallback used to run at *any* window fill, so a full window
in which no band fired was still labelled from the last `dH` alone — a
low-entropy gray-band drift (every step under `dh_small`, so not
improving/diverging; mean over `dh_zero`, so not equilibrium/converged;
entropy under `h_low`, so not stable) reported `equilibrium` while nothing
had settled. A convergence loop written exactly as the settled-plus-hold
recipe below recommends therefore exited early, at rc=0, with a plausible
answer. The fallback is now gated on `count < N`, and the agreement
invariant is asserted directly in `tests/test_predicate_matrix.eigs`
rather than stated only here — which is how it survived.

## Canonical examples

### Iterating at a low-entropy value → `converged`
Expand Down Expand Up @@ -431,9 +462,15 @@ 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 near `|x| = 1`**
and falls toward 0 as `|x| → 0` or `|x| → ∞` (and is defined as exactly `0`
at `|x| ∈ {0, 1}`). So a value decaying from a large magnitude *toward* 1
(`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":

Expand Down
10 changes: 10 additions & 0 deletions docs/SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,16 @@ its own condition is false. Both kinds keep an absolute iteration cap
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` →
`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
bands are not exhaustive, and `moving` is the honest answer for the gap
(#735); only while the window is still filling may `report` fall back to an
instantaneous label the predicates don't yet confirm. See
[PREDICATES.md](PREDICATES.md).

**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
Loading
Loading