observer: a predicate under unobserved: raises, and the block stops leaking its depth (#871) - #896
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens unobserved: as a performance annotation by (1) making observer predicates raise when evaluated under an active unobserved: depth (including in callees), and (2) fixing process-wide g_unobserved_depth leaks on non-fallthrough exits (return/break/continue and error unwinding into catch). It also centralizes predicate vocabulary and adds tests/docs/changelog updates for the new behavior.
Changes:
- VM: raise on predicate opcodes when
g_unobserved_depth > 0, and restoreg_unobserved_depthon error unwind intocatch. - Compiler: track lexical
unobserved:depth to emitOP_UNOBSERVED_ENDonreturn/break/continueexits. - Tests + docs: add
tests/test_unobserved.eigscoverage and document the new semantics in README/SPEC/CHANGELOG; lint now uses shared predicate-name table.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/test_unobserved.eigs | Adds regression tests for predicate-raise semantics and unobserved-depth restoration across exit edges. |
| src/vm.h | Extends try handler metadata to include saved unobserved depth for catch unwinds. |
| src/vm.c | Implements predicate-under-unobserved raise and restores unobserved depth when unwinding into a catch handler. |
| src/lint.c | Switches W016 predicate naming to a shared predicate-name table to avoid drift. |
| src/eigenscript.h | Exposes eigs_predicate_name() for shared parser/VM/lint predicate vocabulary. |
| src/eigenscript.c | Adds the shared predicate-name table and accessor. |
| src/compiler.c | Tracks lexical unobserved: nesting to emit OP_UNOBSERVED_END on non-fallthrough exits. |
| README.md | Documents that predicates raise under unobserved: and why. |
| docs/SPEC.md | Updates spec to state predicates raise under unobserved: due to missing trajectory. |
| CHANGELOG.md | Records the semantic change (predicate raises) and the unobserved-depth leak fix. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| frame->try_count--; \ | ||
| uint8_t *_catch_ip = frame->try_handlers[frame->try_count].catch_ip; \ | ||
| int _catch_bp = frame->try_handlers[frame->try_count].catch_bp; \ | ||
| g_unobserved_depth = \ | ||
| frame->try_handlers[frame->try_count].unobs_depth; /* #871 */ \ | ||
| frame->is_try = (frame->try_count > 0); \ |
| updates entirely — use them for hot numeric loops. The depth is | ||
| dynamic, so it covers functions called from inside the block; an | ||
| observer predicate asked anywhere under one **raises**, because there is | ||
| no trajectory for it to classify (a performance annotation must not | ||
| change an answer): |
18a998d to
efe3087
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (3)
README.md:200
- README wording is broader than the behavior: it says an “interrogation” inside
unobserved:has no trajectory to classify, but only predicates raise (e.g.reportinsideunobserved:still returns a classification based on the frozen observer state). Consider tightening this sentence to refer specifically to observer predicates, to avoid implying other interrogatives raise too.
Because the depth is dynamic, an interrogation inside the block — or
inside anything it calls — has no trajectory to classify. Rather than
answer `false` forever, **an observer predicate raises inside an
`unobserved:` block** (#871):
src/vm.c:262
- This comment references
README.md:189, but the benchmark line in README has shifted with the new README changes in this PR (it’s currently around line 187). Updating the reference avoids pointing readers to the wrong place.
* `unobserved:` loop after 100 iterations — including the accumulator loop
* README.md:189 measures at 2.7x. With the predicate raising, the hang is
* unreachable and the backstop's gate is no longer load-bearing here. */
CHANGELOG.md:151
- This CHANGELOG entry references
README.md:189, but the README benchmark location has shifted with the README edits in this PR. Update the line number (or reference) so readers can find the benchmark easily.
every legitimate `unobserved:` loop after 100 iterations — including
the accumulator loop README.md:189 measures at 2.7x. With the
predicate raising, the hang is unreachable. And `unobserved:` was left
leaking its depth (#871) Two defects. The filed one: `unobserved:` suppresses observer updates and its depth is DYNAMIC — it covers every function called from inside the block. So a caller adding a performance annotation silently changed a callee's answer (the issue's settle loop returns -1 instead of 22), and a bare `loop while not converged` inside one never terminated: the predicate could not become true, and the stall backstop that would have ended the loop is gated on the same depth. Both mechanisms that could have saved it failed for the same reason at the same moment. A predicate asked under an unobserved block is being asked a question the runtime structurally cannot answer, so it now says so, naming the predicate, the block, and the transitive scope. Checked in the three predicate opcode handlers rather than inside vm_slot_predicate, because a binding assigned inside the block has no `used` slot at all and the classifier is never reached on exactly the path that hangs. The predicate vocabulary moved into one shared table (eigs_predicate_name), which lint's W016 now reads instead of keeping a second copy. Deliberately NOT fixed by ungating the stall backstop instead: that check reads a frozen trajectory as "quiet", so ungating it would exit every legitimate unobserved loop after 100 iterations — including the accumulator loop README.md:189 measures at 2.7x. With the predicate raising, the hang is unreachable. And the block stays dynamic rather than becoming lexical, because lexical scoping would exclude callees, which is most of what a hot region does. The second defect, found while writing the test and worse: g_unobserved_depth is a runtime counter that only OP_UNOBSERVED_END decrements, and the compiler emitted that opcode on the fallthrough edge ONLY. A return, break, or continue out of the block — or any error caught outside it — left the depth elevated for the rest of the PROCESS. From that point the observer recorded nothing and every `report` answered "equilibrium" about a value that was plainly moving. Four independent silent deaths of the runtime's central mechanism, none of which produced a diagnostic: try: unobserved: boom is [1, 2][99] catch e: pass # observer is now dead for the rest of the program Fixed the way #726 fixed the identical disease in g_try_depth: break/continue/return emit the OP_UNOBSERVED_ENDs for every block they jump out of (with per-loop baselines for the first two, mirroring try_depth_at_entry), and a try handler records the depth at registration and restores it when an error unwinds into the catch. tests/test_unobserved.eigs pins the raise across all three predicate opcodes and all four leak edges plus nesting, using a moving-value probe that reads "equilibrium" when the observer is dead. Suite 3788/3788 release, 3786/3786 ASan+UBSan with detect_leaks=1, leak tally 0. Closes #871 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
efe3087 to
8278a13
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/vm.c:5212
- The PREDICATE_NAME handler comment says an undefined name raises like GET_NAME, but the new unobserved guard runs before name resolution, so under
unobserved:it will raise the unobserved predicate error instead. Update the comment to reflect the new precedence (or move the guard after the undefined-name check).
/* `<predicate> of <name>` — resolve the binding's (env,slot) and classify
* its slot (mirrors REPORT_NAME). Undefined name raises like GET_NAME. */
uint16_t kind = read_u16(ip); ip += 2;
uint16_t name_idx = read_u16(ip); ip += 2;
if (vm_pred_unobserved(kind, current_line)) { /* #871 */
CHANGELOG.md:186
- In the [Unreleased] section, this adds a second "### Fixed" heading even though the section already has one earlier. That creates duplicate headings and splits the same list into two Fixed sections; remove this extra heading so the Unreleased structure stays consistent.
### Fixed
Two defects. The filed one, and a worse one found while writing its test.
1. The filed defect
unobserved:suppresses observer updates, and its depth is dynamic — it covers every function called from inside the block. So:-1instead of22);loop while not convergedinside one never terminated — the predicate could not become true, and the stall backstop that would have ended the loop is gated on the same depth. Both mechanisms that could have saved it failed for the same reason at the same moment.A predicate asked under an
unobserved:block is being asked a question the runtime structurally cannot answer, so it now says so:Checked in the three predicate opcode handlers rather than inside
vm_slot_predicate, because a binding assigned inside the block has nousedslot at all — so the classifier is never reached on exactly the path that hangs. The predicate vocabulary moved into one shared table (eigs_predicate_name), which lint's W016 now reads instead of keeping a second copy in order.Why not the other options
Not ungating the stall backstop (the issue's option 1). That check reads a frozen trajectory as "quiet", so ungating it would exit every legitimate
unobserved:loop after 100 iterations — including the accumulator loopREADME.md:189measures at 2.7×. With the predicate raising, the hang is unreachable and the backstop's gate is no longer load-bearing here.Not making the block lexical (option 2, which the issue calls "the real fix"). Lexical scoping would exclude callees — which is most of what a hot region does — so it would gut the feature to fix a hazard that raising already removes. A perf annotation must not change answers; it is now incapable of doing so.
2. The defect found underneath, which is worse
g_unobserved_depthis a runtime counter that onlyOP_UNOBSERVED_ENDdecrements, and the compiler emitted that opcode on the fallthrough edge only. Areturn,break, orcontinueout of the block — or any error caught outside it — left the depth elevated for the rest of the process. From that point the observer recorded nothing, and everyreportansweredequilibriumabout a value that was plainly moving.Four independent silent deaths of the runtime's central mechanism, none producing a diagnostic. This is pre-existing — the diff that exposed it only adds depth bookkeeping.
Fixed the way #726 fixed the identical disease in
g_try_depth, whose pattern the compiler already had:break/continue/returnemit theOP_UNOBSERVED_ENDs for every block they jump out of (with per-loop baselines mirroringtry_depth_at_entry), and atryhandler records the depth at registration and restores it when an error unwinds into the catch.Verification
tests/test_unobserved.eigspins the raise across all three predicate opcodes (bare,of <local>,of <name>) and all four leak edges plus nesting, using a moving-value probe that readsequilibriumexactly when the observer is dead.detect_leaks=1: 3786/3786, leak tally 0Nothing in the existing suite used a predicate inside
unobserved:, so the raise broke no existing test.Filed separately
#895 — a name assigned inside an
unobserved:block is readable but not interrogable inside it:report of mraises "undefined variable" whilestr of mreads it happily on the next line. Same family, distinct root (compile-time name resolution, not the depth counter), so it gets its own issue rather than more scope here.Closes #871
🤖 Generated with Claude Code