Skip to content

observer: a predicate under unobserved: raises, and the block stops leaking its depth (#871) - #896

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/871-unobserved-dynamic-scope
Aug 5, 2026
Merged

observer: a predicate under unobserved: raises, and the block stops leaking its depth (#871)#896
InauguralPhysicist merged 1 commit into
mainfrom
fix/871-unobserved-dynamic-scope

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

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:

  • a caller adding a performance annotation silently changed a callee's answer (the issue's settle loop returns -1 instead of 22);
  • 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:

Error line 4: converged: the observer is off inside an 'unobserved:' block, so this
predicate has no trajectory to classify — the block's depth is dynamic, so it also
covers functions called from inside it
     4 |     loop while not (converged of x):
       |                               ^
  at settle (line 4)
  at <module> (line 9)

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 — 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 loop README.md:189 measures 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_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.

try:
    unobserved:
        boom is [1, 2][99]
catch e:
    print of "handled"
# the observer is now dead for the rest of the program, silently

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/return emit the OP_UNOBSERVED_ENDs for every block they jump out of (with per-loop baselines mirroring try_depth_at_entry), and a try handler records the depth at registration and restores it when an error unwinds into the catch.

Verification

tests/test_unobserved.eigs pins 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 reads equilibrium exactly when the observer is dead.

  • Release suite: 3788/3788
  • ASan + UBSan, detect_leaks=1: 3786/3786, leak tally 0

Nothing 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 m raises "undefined variable" while str of m reads 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

Copilot AI lite review requested due to automatic review settings August 5, 2026 21:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 restore g_unobserved_depth on error unwind into catch.
  • Compiler: track lexical unobserved: depth to emit OP_UNOBSERVED_END on return/break/continue exits.
  • Tests + docs: add tests/test_unobserved.eigs coverage 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.

Comment thread src/vm.c
Comment on lines 2744 to 2749
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); \
Comment thread docs/SPEC.md
Comment on lines +1280 to +1284
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):
Copilot AI review requested due to automatic review settings August 5, 2026 22:10
@InauguralPhysicist
InauguralPhysicist force-pushed the fix/871-unobserved-dynamic-scope branch from 18a998d to efe3087 Compare August 5, 2026 22:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. report inside unobserved: 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>
Copilot AI review requested due to automatic review settings August 5, 2026 22:34
@InauguralPhysicist
InauguralPhysicist force-pushed the fix/871-unobserved-dynamic-scope branch from efe3087 to 8278a13 Compare August 5, 2026 22:34

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@InauguralPhysicist
InauguralPhysicist merged commit 2ba4a5d into main Aug 5, 2026
19 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix/871-unobserved-dynamic-scope branch August 5, 2026 22:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

unobserved: is dynamically scoped, so a caller silently breaks a callee's convergence loop — and a bare-predicate loop inside one hangs forever

2 participants