fix(observer): report must agree with the predicates at a full window (#735, #736) - #759
Merged
Merged
Conversation
…#735, #736) `observer_slot_report` resolves the six windowed bands and then falls back to an instantaneous label read off the last dH. That fallback is 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 one. 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. Every such window was reported "equilibrium". A decaying residual is 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, while docs/PREDICATES.md:328-335 promised the opposite and named the partial window as the only exception. 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, so both channels answer "not settled, not in any named band" the same way. The agreement invariant is asserted in test_predicate_matrix 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 analyzers seeded `tracker is 0` OBSERVED, putting a synthetic 0 -> values[0] entropy jump at the head of the trajectory, and is_measurement_stable scored ten identical readings as stable only because the fallback overrode the resulting window. The sentinel is a declaration, not a measurement — it is now seeded inside `unobserved:`. Also closes the four observer papercuts in #736: - W019 now covers the query special forms over an ident (report / report_value / observe / trajectory) as bare statements, which printed nothing and raised nothing. Zero false positives by construction: over an ident these 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. - state_at no longer returns __loop_exit__; the same filter applies to --step's bindings listing (an explicit `p __loop_exit__` still answers, and the tape's binding count stays honest). - SYNTAX.md no longer calls `how` degenerate — #412 made it a real gradient, 1 - min(1, |dH| / dh_zero). - PREDICATES.md no longer claims entropy is exactly 0 at |x| = 1 — #412 deleted that special case, so unity is the maximum, the horizon. Stale in the maximally confusing direction: 0 vs 1 is the difference between "fully converged" and "maximally undecided". - Adds the missing worked `at` example inside a loop, where "the last assignment at or before this line" answers with the final pass. Release suite 3273/3273; ASan/UBSan with detect_leaks=1 3277/3277, leak tally 0. COMPARISON.md carries no report-vocabulary claim, so it needed no change; SPEC.md did and got one. Closes #735 Closes #736 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Fixes observer contract violations and papercuts so report/predicates, temporal queries, and lint/docs all agree with the intended observer model.
Changes:
- Gate
observer_slot_report’s instantaneous fallback to partial windows; full windows now return"moving"when no predicate band is true, and add regression coverage asserting full-window agreement. - Hide runtime-internal
__name__bindings fromstate_atand unfiltered--stepbinding dumps via a sharedtrace_name_is_internal()helper. - Extend
W019to warn on statement-position observer query special-forms (report/observe/report_value/trajectoryover an ident) and refresh observer docs (how, entropy at|x|=1, loopatexample, diagnostics/spec/changelog).
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_temporal.eigs | Adds regression asserting state_at does not expose __loop_exit__ while still returning user bindings. |
| tests/test_predicate_matrix.eigs | Adds full-window residual-drift case and asserts the “full-window report agrees with predicates or ‘moving’” invariant. |
| tests/test_lint.sh | Adds W019 positives for bare observer queries-as-statements and negatives for expression usage. |
| src/trace.h | Declares trace_name_is_internal() for shared filtering logic. |
| src/trace.c | Implements trace_name_is_internal() and filters internal bindings from trace_state_at(). |
| src/step.c | Filters internal bindings from unfiltered bindings listing while keeping explicit p __loop_exit__ queries working. |
| src/lint.c | Extends W019 to warn on discarded observer-query special forms at statement level. |
| src/eigenscript.c | Fixes observer_slot_report() so full windows with no true predicate return "moving" (no partial-window fallback). |
| lib/experiment.eigs | Seeds analyzer tracker under unobserved: to avoid synthetic first-step entropy artifacts. |
| docs/SYNTAX.md | Updates how semantics, adds loop at example, and documents __name__ as runtime-reserved/hidden in dumps. |
| docs/SPEC.md | Specifies full-window report agreement contract and "moving" residual for non-exhaustive bands. |
| docs/PREDICATES.md | Documents non-exhaustive full-window “moving” case; clarifies entropy peak at ` |
| docs/DIAGNOSTICS.md | Updates W019 description to include observer query special forms over identifiers. |
| docs/BUILTINS.md | Updates report vocabulary to include "moving" for the residual full-window case. |
| CHANGELOG.md | Records both #735 and #736 fixes, including contract clarification and exposed downstream reliance. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…oves The cross-repo corpus (#262) is the gate that catches an observer change altering real consumer behavior, and it fired on three of fourteen programs. All three are the fix working; the other eleven are byte-identical. - dynamics__solve — the consumer-visible half of the bug, and the best evidence the fix is right. All three solvers drive `status is report of change` and stop on "equilibrium", so all three were terminating early on the false label: Jacobi 14 iters -> 0.9999995231628418 becomes 21 -> 0.9999999997671694, Gauss-Seidel 11 -> 18, PageRank 27 -> 32. A real downstream solver was silently returning a less-accurate answer, exactly as #735 predicted. - EigenScript__numerical, EigenScript__observer_predicates — decaying residuals relabelled from "stable"/"equilibrium" to "moving". Same trajectory, honest name. 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.
#735 —
reportlabelled a still-moving value "equilibrium"observer_slot_reportresolves the six windowed bands in priority order, then falls back to an instantaneous label read off the lastdH. That fallback exists for a partial window — where the full-window predicates are false by rule andreportstill has to say something — but it was never gated oncount < N, so it also ran at a full one.The bands are not exhaustive. A full window of steady gray-band drift at low entropy fires nothing:
improving/divergingdh_small— the #187 gray-band ruleequilibrium/convergeddh_zerostableentropy >= h_lowEvery such window was reported
equilibrium. A decaying residual is exactly that state:So a convergence loop written the way
PREDICATES.mdrecommends — the settled-plus-hold recipe, keyed onreport— exits early at rc=0 with a plausible answer.docs/PREDICATES.md:328-335promised the opposite and named the partial window as the only exception; the observed behaviour is the excluded case. Found by an agent that reimplemented the published formulas as an independent oracle: it confirms the six predicates are right andreportis wrong.Fix. The fallback is gated on a partial window. A full window with no band true reports
moving— the residual label the value channel already uses, so both channels answer "not settled, not in any named band" the same way. Relaxingstable'sh_lowgate was the alternative, and was rejected: the oracle says the predicates are correct, so the wider blast radius would have been spent fixing the wrong thing.The invariant is now asserted, not described.
tests/test_predicate_matrix.eigschecks every full-window shape:reportmust name a band whose bare predicate is true, ormovingwhen none is. That guarantee was prose only, which is why this survived. Verified red-then-green against the pre-fix build — the assert fires onreport=equilibriumwith all six false.Fallout the fix exposed, fixed rather than worked around.
lib/experiment.eigswas relying on the wrong answer. All five analyzers seededtracker is 0observed, putting a synthetic0 -> values[0]entropy jump at the head of the trajectory — a real step to every windowed predicate.is_measurement_stablescored ten identical readings as stable only because the fallback overrode the resulting window; withreporttelling the truth it returned "not stable" for constant data. The sentinel is a declaration, not a measurement, so it is now seeded insideunobserved:and the trajectory is the data.#736 — four observer papercuts
W019already covered the interrogatives (where is x,prev of y); it now covers the query special forms over an ident —report/report_value/observe/trajectory— which print nothing and raise nothing at statement level. That is the worst affordance the observer had: silence is indistinguishable from "the observer had nothing to say", and the bare form is the one issue bodies and READMEs reach for. Zero false positives by construction — over an ident these are compiler-resolved and never reach a user function even when one shadows the name (Name-keyed dispatch superinstruction hijacks a user-rebound 'dispatch' (silent wrong result); also fires on the parenthesized #355 form #459, seeW013), and a non-ident argument (report of (x + 0.0)) is an ordinary call the check never sees.state_atleaked__loop_exit__. Now{"x": 4, "i": 0}. The same filter applies to--step's bindings listing, where it also appeared; an explicitp __loop_exit__still answers, and the tape's binding count stays honest. The filter matches the general__name__form rather than a hardcoded pair, so a new internal cannot drift back into view — the form is now documented as runtime-reserved inSYNTAX.md, since a user variable shaped that way would be hidden too.SYNTAX.mdstill calledhowdegenerate — returns 0, 1 only at zero entropy. Observer surface coherence: implement or remove 'how'; decide |x|=1.0; fold in #383 #412 made it a real gradient:1 - min(1, |dH| / dh_zero), the settledness of the last step.PREDICATES.mdstill claimed entropy is exactly0at|x| ∈ {0, 1}. Observer surface coherence: implement or remove 'how'; decide |x|=1.0; fold in #383 #412 deleted the|x| == 1special case, so unity is the maximum — the horizon — and only0is the home point. Stale in the maximally confusing direction: 0 vs 1 is the difference between "fully converged" and "maximally undecided".Also adds the missing worked
atexample inside a loop, where "the last assignment at or before this line" answers with the loop's final pass — precisely where the documented rule stops matching intuition.Validation
detect_leaks=13277/3277, leak tally 0.test_predicate_matrix.eigs),state_atinternal-binding filtering (test_temporal.eigs, queried past EOF so it stays line-insensitive),W019positives and negatives (test_lint.sh).docs/COMPARISON.mdcarries noreport-vocabulary claim, so it needed no change;docs/SPEC.mddid and got one.Closes #735
Closes #736
🤖 Generated with Claude Code