Skip to content

observer: when is x counts assignments made inside unobserved: (#908) - #910

Merged
InauguralPhysicist merged 1 commit into
mainfrom
fix/908-unobserved-assign-count
Aug 9, 2026
Merged

observer: when is x counts assignments made inside unobserved: (#908)#910
InauguralPhysicist merged 1 commit into
mainfrom
fix/908-unobserved-assign-count

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Closes #908.

The divergence

when is x and when is x at L answer the same question — "how many times has
x been assigned" — from two different counters:

They disagreed by exactly the number of assignments made inside unobserved:
blocks — while the unobserved assignment's value was recorded and readable
the whole time (what is x at L answers it). Only the count omitted it.

x is 0
for i in range of 5:
    x is i * 10
unobserved:
    x is 777
print of (when is x)          # was 6, now 7
print of (when is x at 999)   # 7
print of (what is x at 999)   # 777 — the history held it all along

Why up and not down

#868 made the divergence load-bearing: <kw> is x when <n> indexes the
history's counter, because that is the only counter that can address a stored
entry. So when is x — the natural way to discover how many assignments there
are to ask about — was short by one per unobserved assignment, and every
ordinal computed from it was off.

Matching the history downward would have been worse than the inconsistency: the
write happens and its value is retained, so dropping it from the ordinal space
leaves an addressable-looking hole. The count comes up to the history instead —
the direction recorded on #908 when #909 landed.

unobserved: suppresses observation (entropy/dH), which is all it ever
documented; it never meant the assignment did not happen. Same principle as
#871's raising predicate: a performance annotation must not change an answer.

Sites

Nine gates in vm.c, four plus two first-binding sites in eigenscript.c, and
the emitted sequence in the JIT's inline SET_NAME fast path — which re-read
EigsThread.unobserved_depth through the VM.owner back-pointer and skipped
the bump, mirroring the interpreter. The JIT bump is now unconditional behind
its NULL check; emit_cmpl_0_disp32_rax goes with it (last user).

Tests

test_unobserved.eigs gains the count/ordinal agreement, an in-block
interrogation, and a fn-local case (whose interrogation forces the slow env
path). test_jit_paths.eigs section 9 covers the emitter with a 20k-iteration
unobserved loop: the interpreter counts it correctly for the first 5000
iterations and only the OSR thunk can get it wrong, so a stale gate in the
emitter is invisible to any interpreted test.

Planted-fault validation of that check — restoring the old emitted sequence
in jit.c alone:

-- jit on  : 5001
-- jit off : 20001

so the new check fails on exactly the fault it exists for.

Gates

  • Release suite 3860/3860
  • ASan+UBSan detect_leaks=1 3852/3852, leak tally 0 (floor)
  • SPEC.md + COMPARISON.md updated in this PR (executable-doc gate [89]/[90]);
    TRACE.md's recorded divergence note replaced with the agreement requirement.

Consumer preflight

EigenGauntlet/src/observer_lab.eigs asserts the old behavior
("unobserved age unchanged") — filed as InauguralSystems/EigenGauntlet#26.
Its CI is pinned to v0.38.0 and stays green until the pin bump past this
change; the issue carries the replacement assertion. No other consumer combines
unobserved: with when is. The AOT calls the same runtime env write paths and
needs no change.

Closes #908.

)

Two counters answered "how many times has x been assigned" and disagreed.
`when is x` read env->assign_counts, bumped only under
g_unobserved_depth == 0; `when is x at L` and the #868 occurrence ring read
the history's own recorded-assignment counter (trace.c), which has no such
gate. They diverged by exactly the number of assignments made inside
`unobserved:` blocks — while the unobserved assignment's VALUE was recorded
and readable the whole time. Only the count omitted it.

#868 made that load-bearing: `<kw> is x when <n>` indexes the history's
counter, because it is the only counter that can address a stored entry. So
`when is x` — the natural way to discover how many assignments there are to
ask about — was short by one per unobserved assignment, and every ordinal
computed from it was off. Matching downward was worse: the write happens and
its value is retained, so dropping it from the ordinal space would leave an
addressable-looking hole. The count comes up to the history instead.

`unobserved:` suppresses OBSERVATION (entropy/dH), which is all it ever
documented; it never meant the assignment did not happen. Same principle as
#871's raising predicate — a performance annotation must not change an
answer.

Sites: 9 gates in vm.c, 4 + 2 first-binding sites in eigenscript.c, and the
emitted sequence in the JIT's inline SET_NAME fast path, which re-read
EigsThread.unobserved_depth through the VM.owner back-pointer and skipped the
bump. The JIT bump is now unconditional behind its NULL check, and
emit_cmpl_0_disp32_rax goes with it (last user).

Tests: test_unobserved.eigs gains the count/ordinal agreement, an
in-block interrogation, and a fn-local case. test_jit_paths.eigs section 9
covers the emitter — a 20k-iteration unobserved loop, which the interpreter
counts correctly for its first 5000 iterations before OSR takes over, so a
stale gate in the emitter is invisible to any interpreted test. Validated by
planting the old emitted sequence back: the check reports 5001 instead of
20001 while EIGS_JIT_OFF=1 still reports 20001.

Release suite 3860/3860; ASan+UBSan detect_leaks=1 3852/3852, leak tally 0.

Consumer preflight for the next pin bump: EigenGauntlet's observer_lab
asserts the old behavior ("unobserved age unchanged") — filed as
InauguralSystems/EigenGauntlet#26. Its CI is pinned to v0.38.0 and stays
green until the bump.
@InauguralPhysicist
InauguralPhysicist merged commit 8b6fd2b into main Aug 9, 2026
18 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the fix/908-unobserved-assign-count branch August 9, 2026 11:10
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.

when is x and when is x at L count different things — an unobserved: assignment is in the history but not in the count

1 participant