diff --git a/CHANGELOG.md b/CHANGELOG.md index 0565fe9..8038958 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/docs/BUILTINS.md b/docs/BUILTINS.md index 90caf92..3855fb9 100644 --- a/docs/BUILTINS.md +++ b/docs/BUILTINS.md @@ -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 | diff --git a/docs/DIAGNOSTICS.md b/docs/DIAGNOSTICS.md index e52b640..62c8c8f 100644 --- a/docs/DIAGNOSTICS.md +++ b/docs/DIAGNOSTICS.md @@ -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 ` of `. 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. | diff --git a/docs/PREDICATES.md b/docs/PREDICATES.md index 36181ea..ac0000a 100644 --- a/docs/PREDICATES.md +++ b/docs/PREDICATES.md @@ -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 @@ -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 @@ -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` @@ -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": diff --git a/docs/SPEC.md b/docs/SPEC.md index 0b3b593..960f528 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -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`, diff --git a/docs/SYNTAX.md b/docs/SYNTAX.md index 0f67826..31f785f 100644 --- a/docs/SYNTAX.md +++ b/docs/SYNTAX.md @@ -406,7 +406,7 @@ you go — but that maintenance is paid on every assignment outside | `when is x` | Observation age (number of assignments) | `when is loss` → `4` | | `where is x` | Entropy (information content) | `where is loss` → `0.832` | | `why is x` | dH (rate of entropy change; negative while descending into a basin = improving) | `why is loss` → `-0.27` | -| `how is x` | Currently degenerate — returns 0 (1 only at zero entropy); see OBSERVER.md, #412 | `how is loss` → `0` | +| `how is x` | Settledness of the last step, `1 - min(1, \|dH\| / dh_zero)` — a real gradient in `[0, 1]` since #412: `1` when the step is inside the deadband, `0` when it clears it | `how is loss` → `0` | ```eigenscript loss is 0.9 @@ -415,7 +415,7 @@ loss is 0.2 print of (what is loss) # 0.2 print of (why is loss) # negative — descending into a basin (entropy falling) -print of (how is loss) # 0 — how is currently degenerate (see OBSERVER.md) +print of (how is loss) # 0 — the last step is far outside the deadband print of (when is loss) # 3 — three assignments ``` @@ -464,6 +464,23 @@ print of (where is x at 2) # entropy as of the line-2 assign print of (why is x at 2) # dH as of the line-2 assign ``` +**A line inside a loop is asked once, not per iteration.** "The last value +bound at or before that line" is a statement about the *run*, so a line the +loop executed four times answers with the fourth assignment — the final one, +not the first (#736). This is where the rule stops matching intuition: + +```eigenscript +total is 0 +for i in range of 4: + total is total + i # line 3, runs four times +print of (what is total at 3) # 6 — the LAST pass (0+1+2+3), not the first +print of (when is total at 3) # 5 — all five assignments, seed included +``` + +To pin a single iteration, record it yourself (`snapshots[i] is total`); +`at` addresses source lines, and a loop body's line has one history, not one +per pass. + All seven temporal forms support `at`. The observer-derived three (`where`/`why`/`how`) answer from per-assign observer snapshots that are captured only when the compiled program actually contains such a @@ -473,7 +490,11 @@ assignment to the name return `null`. **`state_at of line`** — the whole-program version: returns a dict mapping every tracked binding to the value it held at or before `line` (names not yet assigned by then are omitted). See -[BUILTINS.md](BUILTINS.md). +[BUILTINS.md](BUILTINS.md). Names of the form `__name__` are reserved for +the runtime — the observed-loop machinery binds `__loop_exit__` and +`__loop_iterations__` there — and are filtered out of `state_at` and of +`--step`'s bindings listing (#736). Don't use that form for your own +variables; they would be hidden from both. `prev`, and `at` inside an interrogative, are soft keywords — outside these positions they parse as ordinary identifiers, so existing code diff --git a/lib/experiment.eigs b/lib/experiment.eigs index df64cd2..e662af8 100644 --- a/lib/experiment.eigs +++ b/lib/experiment.eigs @@ -15,10 +15,17 @@ # entropy spikes reveal anomalies, trajectory classification # identifies behavioral regimes, and convergence detection # determines when measurements have stabilized. +# +# Each analyzer declares its `tracker` inside `unobserved:` (#735). Seeding it +# with an OBSERVED sentinel put a synthetic `0 -> values[0]` entropy jump at +# the head of the trajectory, which is a real step to every windowed predicate: +# ten identical readings then carried one large dH and no band fired. The +# sentinel is a declaration, not a measurement, so it must not be observed. # ---- track_measurements: annotate each measurement with observer state ---- define track_measurements(values) as: - tracker is 0 + unobserved: + tracker is 0 # seed unobserved — see the note above results is [] for i in range of (len of values): @@ -36,7 +43,8 @@ define track_measurements(values) as: # ---- is_measurement_stable: check if recent measurements are stable ---- define is_measurement_stable(values, min_stable_count) as: - tracker is 0 + unobserved: + tracker is 0 # seed unobserved — see the note above stable_count is 0 for i in range of (len of values): @@ -51,7 +59,8 @@ define is_measurement_stable(values, min_stable_count) as: # ---- detect_entropy_spikes: find anomalies via entropy derivative ---- define detect_entropy_spikes(values, threshold) as: - tracker is 0 + unobserved: + tracker is 0 # seed unobserved — see the note above spikes is [] for i in range of (len of values): @@ -65,7 +74,8 @@ define detect_entropy_spikes(values, threshold) as: # ---- convergence_rate: estimate how fast a sequence converges ---- define convergence_rate(values) as: - tracker is 0 + unobserved: + tracker is 0 # seed unobserved — see the note above dH_values is [] for i in range of (len of values): @@ -90,7 +100,8 @@ define convergence_rate(values) as: # ---- detect_regimes: identify behavioral phase transitions ---- define detect_regimes(values) as: - tracker is 0 + unobserved: + tracker is 0 # seed unobserved — see the note above regimes is [] current_status is "" regime_start is 0 diff --git a/src/eigenscript.c b/src/eigenscript.c index ac05c29..7698da9 100644 --- a/src/eigenscript.c +++ b/src/eigenscript.c @@ -647,6 +647,18 @@ const char *observer_slot_report(const ObserverSlot *s) { if (observer_slot_converged(s)) return "converged"; if (observer_slot_equilibrium(s)) return "equilibrium"; if (observer_slot_stable(s)) return "stable"; + /* #735: the instantaneous fallback is for a PARTIAL window only. Ungated, + * it let a full window whose six predicates are all false still be labelled + * from the last dH alone — a low-entropy gray-band drift (every step under + * dh_small, so not improving/diverging per #187; mean over dh_zero, so not + * equilibrium/converged; entropy under h_low, so not stable) reported + * "equilibrium" while nothing was settled. PREDICATES.md guarantees report + * agrees with the bare predicates at a full window, so at a full window the + * windowed helpers are the ONLY authority: no band true means the residual + * band, "moving" — the same label the value channel uses for exactly this + * state. Reporting a still-moving value as settled is what broke the + * documented settled-plus-hold recipe. */ + if (s->dh_window_count >= OBSERVER_WINDOW_N) return "moving"; /* Partial-window best-effort label (mirrors builtin_report's tail). */ if (fabs(s->dH) < g_obs_dh_zero) return "equilibrium"; if (fabs(s->dH) < g_obs_dh_small && s->entropy >= g_obs_h_low) return "stable"; diff --git a/src/lint.c b/src/lint.c index e759ecf..9c16122 100644 --- a/src/lint.c +++ b/src/lint.c @@ -950,8 +950,37 @@ static const char *interrog_word(int kind) { return (kind >= 0 && kind <= 5) ? words[kind] : "prev"; } +/* #736: the same silent no-op through the other door. `report of x` / + * `observe of x` / `report_value of x` / `trajectory of x` over an IDENT are + * compiler-resolved special forms (REPORT_SLOT/NAME &c) — pure queries with no + * side effect, so at statement level they evaluate and throw the answer away, + * printing nothing and raising nothing. That is the worst affordance the + * observer has: silence is indistinguishable from "nothing to say", and the + * bare form is what issue bodies and READMEs reach for. Zero false positives by + * construction: over an ident these names never reach a user function even when + * one shadows them (#459, see W013), and a non-ident argument (`report of (x + + * 0.0)`) is an ordinary call this check never sees. */ +static const char *disc_observer_query(ASTNode *node) { + static const char *names[] = {"report", "report_value", "observe", + "trajectory"}; + if (!node || node->type != AST_RELATION) return NULL; + ASTNode *l = node->data.relation.left, *r = node->data.relation.right; + if (!l || l->type != AST_IDENT || !r || r->type != AST_IDENT) return NULL; + for (size_t i = 0; i < sizeof(names) / sizeof(names[0]); i++) + if (strcmp(l->data.ident.name, names[i]) == 0) return names[i]; + return NULL; +} + static void check_disc_interrog(ASTNode *node, LintContext *ctx) { if (!node) return; + const char *q = disc_observer_query(node); + if (q) { + lint_warn(ctx, node->line, "W019", + "'%s of ...' is an observer query; as a statement its result is " + "discarded and nothing is printed — wrap it in " + "'print of (%s of ...)'", q, q); + return; + } if (node->type == AST_INTERROGATE) { int k = node->data.interrogate.kind; if (k >= 0 && k <= 5) diff --git a/src/step.c b/src/step.c index 790efaa..295dd5c 100644 --- a/src/step.c +++ b/src/step.c @@ -449,6 +449,11 @@ static void show_bindings(const Tape *t, int pos, const char *only) { for (int i = 0; i < t->nnames; i++) { const NameHist *h = &t->names[i]; if (h->scope != sc) continue; + /* #736: the loop machinery's own bindings are implementation + * detail — keep them out of the unfiltered listing. `p + * __loop_exit__` still answers: an explicit request is not a + * leak, and the tape's binding count stays honest. */ + if (trace_name_is_internal(h->name)) continue; if (!latest_at(h, pos)) continue; int shadowed = 0; for (int k = 0; k < nseen; k++) diff --git a/src/trace.c b/src/trace.c index be46059..6c52bb0 100644 --- a/src/trace.c +++ b/src/trace.c @@ -368,12 +368,24 @@ int trace_query_at(int kind, const char *interned_name, int line, EigsSlot *out) return 0; } +/* #736: the observed-loop machinery injects bindings of its own + * (`__loop_exit__`, `__loop_iterations__` — vm.c) into the env the assignment + * history folds over. They are implementation detail and must not surface in + * a user-visible binding dump. The dunder form is the runtime's own + * convention for these — lint.c pre-binds the same names for E003. */ +int trace_name_is_internal(const char *n) { + size_t len = n ? strlen(n) : 0; + return (len > 4 && n[0] == '_' && n[1] == '_' && + n[len - 1] == '_' && n[len - 2] == '_') ? 1 : 0; +} + Value *trace_state_at(int line) { Value *out = make_dict(g_prev_count > 0 ? g_prev_count : 8); if (!out || !g_prev_tab) return out; for (int i = 0; i < g_prev_cap; i++) { PrevEntry *e = &g_prev_tab[i]; if (!e->name || e->hist_count == 0) continue; + if (trace_name_is_internal(e->name)) continue; int idx = find_hist_idx_at_or_before(e, line); if (idx < 0) continue; Value *v = slot_to_value(e->history[idx].value); diff --git a/src/trace.h b/src/trace.h index c033add..5002c4c 100644 --- a/src/trace.h +++ b/src/trace.h @@ -151,6 +151,10 @@ int trace_query_at(int kind, const char *interned_name, int line, EigsSlot *out) * plain O(H) backward scan. */ struct Value *trace_state_at(int line); +/* #736: 1 for a runtime-internal binding name (the `__name__` form the + * observed-loop machinery injects) — filtered out of user-visible dumps. */ +int trace_name_is_internal(const char *name); + /* Phase 3 — replay. * * When EIGS_REPLAY= is set at startup, the named tape is opened diff --git a/tests/observer_corpus/golden/EigenScript__numerical.out b/tests/observer_corpus/golden/EigenScript__numerical.out index b28a7cd..5c1751d 100644 --- a/tests/observer_corpus/golden/EigenScript__numerical.out +++ b/tests/observer_corpus/golden/EigenScript__numerical.out @@ -25,9 +25,9 @@ Step 45: value=0.06665790455522187 status=improving Step 50: value=0.02957646637126989 status=improving Step 55: value=0.013123235253910044 status=improving Step 60: value=0.005822849199347172 status=improving -Step 65: value=0.0025836291236367107 status=stable -Step 70: value=0.0011463699676873278 status=stable -Step 75: value=0.0005086504447533207 status=equilibrium +Step 65: value=0.0025836291236367107 status=moving +Step 70: value=0.0011463699676873278 status=moving +Step 75: value=0.0005086504447533207 status=moving Step 80: value=0.00022569090454253608 status=equilibrium Converged at step 84 with value 0.00011781206273935721 diff --git a/tests/observer_corpus/golden/EigenScript__observer_predicates.out b/tests/observer_corpus/golden/EigenScript__observer_predicates.out index 6351bd6..dbac491 100644 --- a/tests/observer_corpus/golden/EigenScript__observer_predicates.out +++ b/tests/observer_corpus/golden/EigenScript__observer_predicates.out @@ -8,15 +8,15 @@ sqrt(0.01) = 0.1 step 30: diverging signal=8.196620357733826 step 40: diverging signal=3.5605172470539532 step 50: diverging signal=1.5466475831843494 - step 60: stable signal=0.6718458528881662 + step 60: moving signal=0.6718458528881662 step 70: improving signal=0.2918420815126484 step 80: improving signal=0.12677283066568665 step 90: improving signal=0.05506865395042193 step 100: improving signal=0.023921187465699906 - step 110: stable signal=0.010391087646419113 - step 120: stable signal=0.004513768500430279 - step 130: stable signal=0.0019607289216252324 - step 140: equilibrium signal=0.0008517180054163542 + step 110: moving signal=0.010391087646419113 + step 120: moving signal=0.004513768500430279 + step 130: moving signal=0.0019607289216252324 + step 140: moving signal=0.0008517180054163542 Converged at step 145 signal=0.0005613516003466767 --- stable as early detection --- @@ -30,16 +30,15 @@ Converged at step 113 value=0.00033757756092487343 step 50: improving, value=0.023661173097015924 step 55: improving, value=0.010498588203128042 step 60: improving, value=0.0046582793594777405 - step 70: stable, value=0.0009170959741498628 Done at step 83 value=0.00011088194140174804 status=converged --- regime detection --- Step 1: start -> stable Step 3: stable -> diverging -Step 34: diverging -> stable -Step 38: stable -> improving -Step 70: improving -> stable -Step 88: stable -> equilibrium +Step 34: diverging -> moving +Step 38: moving -> improving +Step 70: improving -> moving +Step 93: moving -> equilibrium Step 97: equilibrium -> converged Total regime changes: 7 Final loss: 0.00020596299710139924 diff --git a/tests/observer_corpus/golden/dynamics__solve.out b/tests/observer_corpus/golden/dynamics__solve.out index 2312328..007870c 100644 --- a/tests/observer_corpus/golden/dynamics__solve.out +++ b/tests/observer_corpus/golden/dynamics__solve.out @@ -2,13 +2,13 @@ every loop runs until `report of change` is settled and HOLDs — the observer, not a magnitude tolerance, decides when to stop. (iters include the hold) -Jacobi Ax=b -> [0.9999995231628418, 0.9999995231628418, 0.9999995231628418] - 14 iters -Gauss-Seidel Ax=b -> [0.9999999994179234, 0.9999999997089617, 0.9999999999272404] - 11 iters (fresh values -> fewer than Jacobi) +Jacobi Ax=b -> [0.9999999997671694, 0.9999999995343387, 0.9999999997671694] + 21 iters +Gauss-Seidel Ax=b -> [0.9999999999999998, 0.9999999999999999, 1] + 18 iters (fresh values -> fewer than Jacobi) Power iteration dominant eigenvalue -> 2 (6 iters, expect ~2) -PageRank stationary distribution -> [0.39998372395833337, 0.20001220703125, 0.4000040690104167] (27 iters) +PageRank stationary distribution -> [0.39999898274739587, 0.20000203450520834, 0.39999898274739587] (32 iters) DONE diff --git a/tests/test_lint.sh b/tests/test_lint.sh index 89e7781..cd496da 100644 --- a/tests/test_lint.sh +++ b/tests/test_lint.sh @@ -947,6 +947,38 @@ OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true) check_not_contains "#583 interrogatives inside expressions are not flagged" "$OUTPUT" "W019" rm -f "$TMPFILE" +# --- #736 (W019): a bare observer query as a statement prints nothing --- +TMPFILE=$(mktemp /tmp/lint_test_XXXXXX.eigs) +cat > "$TMPFILE" << 'EIGS' +x is 100.0 +x is 50.0 +report of x +observe of x +report_value of x +trajectory of x +print of x +EIGS +OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true) +check_contains "#736 W019 fires on bare 'report of x' (line 3)" "$OUTPUT" ":3: warning\[W019\]" +check_contains "#736 W019 fires on bare 'observe of x' (line 4)" "$OUTPUT" ":4: warning\[W019\]" +check_contains "#736 W019 fires on bare 'report_value of x' (line 5)" "$OUTPUT" ":5: warning\[W019\]" +check_contains "#736 W019 fires on bare 'trajectory of x' (line 6)" "$OUTPUT" ":6: warning\[W019\]" +rm -f "$TMPFILE" + +TMPFILE=$(mktemp /tmp/lint_test_XXXXXX.eigs) +cat > "$TMPFILE" << 'EIGS' +x is 100.0 +x is 50.0 +print of (report of x) +s is report of x +o is observe of x +print of s +print of o[0] +EIGS +OUTPUT=$($EIGS --lint "$TMPFILE" 2>&1 || true) +check_not_contains "#736 observer queries inside expressions are not flagged" "$OUTPUT" "W019" +rm -f "$TMPFILE" + # --- #655 (W020): an unobserved: block that provably does nothing --- # The positive is the shape our own README shipped for two months. The # negatives are what keep the rule honest: each is a block that LOOKS inert diff --git a/tests/test_predicate_matrix.eigs b/tests/test_predicate_matrix.eigs index bdbeaf9..73fbbcc 100644 --- a/tests/test_predicate_matrix.eigs +++ b/tests/test_predicate_matrix.eigs @@ -187,6 +187,52 @@ assert of [tr[1] == 1, f"threshold-raised: equilibrium expected 1 got {tr[1]}"] print of " PASS: threshold-raised dh_zero -> equilibrium" set_observer_thresholds of [0.001, 0.01, 0.1] +# --- #735: FULL window, low-entropy gray-band drift -> NO band fires. +# Every |dH| is under dh_small (not improving/diverging, the #187 gray-band +# rule), the window mean is over dh_zero (not equilibrium/converged), and +# entropy is under h_low (not stable). `report` used to fall through to the +# PARTIAL-window instantaneous label and answer "equilibrium" here — +# declaring a value that is still moving settled, which breaks the +# settled-plus-hold recipe PREDICATES.md recommends. At a full window the +# windowed helpers are the only authority; the residual band is "moving". --- +m is 0.9 +mi is 0 +loop while mi < 20: + m is m * 0.5 + mi is mi + 1 +pm is [converged of m, stable of m, improving of m, diverging of m, oscillating of m, equilibrium of m, report of m] +expect of ["full-window residual drift (#735)", pm, 0, 0, 0, 0, 0, 0, "moving"] + +# --- The full-window agreement guarantee (PREDICATES.md "The report builtin"), +# asserted directly instead of in prose. At a full window `report of x` must +# name a band whose bare predicate is TRUE, or "moving" when none is. #735 +# survived because this invariant was documented and never checked: a report +# that names a band the predicates deny is the silent-wrong-answer class. +# Only full-window shapes qualify — a partial window is explicitly allowed to +# disagree (the instantaneous fallback). --- +define agrees(tag, p) as: + local names is ["oscillating", "diverging", "improving", "converged", "equilibrium", "stable"] + local idx is [4, 3, 2, 0, 5, 1] # each band's index in p + local r is p[6] + local ok is 0 + local n_true is p[0] + p[1] + p[2] + p[3] + p[4] + p[5] + local i is 0 + loop while i < 6: + if names[i] == r: + ok is p[idx[i]] + i is i + 1 + if r == "moving": + if n_true == 0: + ok is 1 + assert of [ok == 1, f"{tag}: report={r} is not backed by a true predicate (p={p})"] + print of f" PASS: full-window agreement {tag} (report={r})" + +agrees of ["stable", pe] +agrees of ["equilibrium+stable", pg] +agrees of ["converged", pk] +agrees of ["converged-at-boundary", ph] +agrees of ["residual drift (#735)", pm] + # --- Newton sqrt correctness (independent of predicates): pin that the math # still converges across magnitudes. |result^2 - n| < 1e-6 (two-sided). --- define check_sqrt(n) as: diff --git a/tests/test_temporal.eigs b/tests/test_temporal.eigs index 0907557..a1862cc 100644 --- a/tests/test_temporal.eigs +++ b/tests/test_temporal.eigs @@ -71,6 +71,17 @@ check of ["where at latest", (where is ox at 66) == (where is ox)] check of ["why at miss", (why is ox at 61) == null] check of ["how at", (how is ox at 63) == (how is ox)] +# ---- #736: state_at must not leak the loop machinery's own bindings. +# Queried at a line past the end, so this block stays line-insensitive. ---- +lv is 1 +li is 0 +loop while li < 3: + lv is lv + 1 + li is li + 1 +st is state_at of 999 +check of ["state_at hides __loop_exit__", (has_key of [st, "__loop_exit__"]) == 0] +check of ["state_at keeps user bindings", st["lv"] == 4] + if fail_count == 0: print of "All tests passed" else: