Skip to content

Backlog burn-down: ~20 issues since v0.43.0 (namespace live-view #1057, observer config, strict ladder, follow-ups) - #1124

Merged
InauguralPhysicist merged 39 commits into
mainfrom
claude/skills-location-drive-issues-ur41ll
Sep 8, 2026
Merged

Backlog burn-down: ~20 issues since v0.43.0 (namespace live-view #1057, observer config, strict ladder, follow-ups)#1124
InauguralPhysicist merged 39 commits into
mainfrom
claude/skills-location-drive-issues-ur41ll

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Collaborator

Parallel-session branch clearing a large swath of the post-v0.43.0 backlog. Rebased on current main. Full detail in CHANGELOG.md [Unreleased].

Headline changes:

Landing gated on the full CI matrix (suites, ASan, TSan, macOS, CodeQL, jit/replay differentials) + review of the breaking changes.

🤖 Generated with Claude Code

…ee import-shadow warning before diffing (#1115)

Repro (byte-identical trees, binaries at two paths; captures via
EIGS_GATE_DIFF_DIR=$S/caps):
  EIGS_GATE_DIFF_BIN=/home/user/EigenScript/src/eigenscript capture base ; capture base2
  EIGS_GATE_DIFF_BIN=$WT/src/eigenscript capture wt
  EIGS_OBS_FORCE=1 EIGS_GATE_DIFF_BIN=$WT/src/eigenscript capture wtf

Before (tool at a6c50fb):
  compare base wt  -> "FAIL: SAME binary AND the same EIGS_OBS_FORCE" rc=2
                      (same sha at two paths was refused outright)
  compare base wtf -> mismatches: 9, RESULT: FAIL rc=1 — 7 of them are
                      location: 5 x "stdlib roots '<exe-dir>/../<path>'" in
                      cannot-read errors (lib/test_runner, tests/test_import,
                      test_import_errors, test_import_toplevel_scope,
                      test_module_scope) and 2 x the project-vs-stdlib
                      import-shadow warning that fires only out of tree
                      (lib/engineering, lib/linalg importing complex).
After:
  compare base wt  -> location-only differences: 7 (named), residual
                      mismatches: 0, RESULT: LOCATION-CLEAN rc=0
  compare base wtf -> location-only: 7, residual mismatches: 2
                      (test_chunk_verify_stack, test_desc_unrecorded_read —
                      genuine force-arm divergences, untouched), rc=1

What changed in compare:
- each arm's capture is normalised with that arm's own exe-dir (dirname of
  the manifest's realpath'd bin): the exe-dir string -> <EXE_DIR>, and the
  import-shadow warning is dropped ONLY in the exact shape "using
  '<corpus>/lib/N.eigs', shadowing '<arm-tree>/lib/N.eigs'" (same N).
  Nothing else is touched; an arm whose exe-dir is '/', '.', '' or relative
  compares raw, announced by NOTE.
- arm identity is (sha, force, path): same build at the SAME path is still
  refused; same build at two paths is admitted with the verdict word
  LOCATION-CLEAN (never PASS) because it measures location-independence,
  not the gate. The determinism reference must match base on all three.
- `location-only differences ... : K` lists every absorbed program by name
  with its first raw diff line; `residual mismatches: M` is what RESULT
  gates on.
- `selftest` subcommand (9 cases) drives the real `compare` entry point over
  synthetic captures: both location shapes absorbed; a different error
  message, a differently-named shadow, a project-file shadow, a corpus-path
  difference and the root-exe-dir guard each still FAIL; the two provenance
  refusals still fire; different builds still PASS.
- slug() is pure bash (same output): the selftest went 12.6s -> 4.6s.

Regression test: suite section [99q] runs the selftest and pins the tally
"9 ok, 0 failed (of 9)". Planted fault (normaliser made identity): case 1
fails, [99q] goes red, and `compare base wt` reports residual mismatches: 7.

Residuals (documented in the tool header): $HOME in the same error line, the
corpus tree's own path, and the argv[0] fallback on platforms without
/proc/self/exe are deliberately NOT normalised.

Closes #1115

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
#1114)

Under eigs_set_eval_observer_isolated(1), after an explicitly armed unit,
an un-armed read-free unit that reassigns a binding runs with the gate
closed. The missing-history flag was stored only at the NEXT eval
boundary, so a host reading observer_predicate_at DIRECTLY between units
was told "history complete" (gap=0) for exactly one boundary while the
answer came from the stale window.

Repro (C host linked against the v0.43.0 release objects, run from src/):

    arm; eval(series); eval("x is 1000\nx is 2000\nx")
    before:  un-armed reassigning unit: obs_needed=0 gap=0
             DIRECT improving(x)=1 obs_needed=0 gap=0
             third boundary: gap=1            <- one boundary late
    after:   un-armed reassigning unit: obs_needed=0 gap=1
             DIRECT improving(x)=1 obs_needed=0 gap=1

Fix: eval_source records the closed execution immediately after
vm_execute (same `!obs_needed && obs_exec_started` condition, same sticky
release store) via a small helper shared with the pre-existing boundary
site, which is kept for code that executes closed outside eval_source. No
verdict, scan, guard or store order changes; the armed recipe's
`isolated host: DIRECT improving=1 obs_needed=1 gap=0` line is unchanged
and the eval-unit read after the direct read still raises naming
EIGS_OBS_FORCE=1 (#1028). The predicate answer itself is still the
stale-window answer by contract (direct reads bypass the eval guard); the
flag is the host's signal.

Regression: new `isolated gap` arm in tests/test_embed_observer.c (full
run and --isolated-host): armed unit -> gap=0; un-armed reassigning unit
-> closed; direct improving read -> assert gap=1; eval-unit read still
raises. The driver pins the printed line and 41 passed. Planted fault
(end-of-unit store removed): FAIL "isolated gap: flag is set before the
next eval boundary", 40 passed, 1 failed.

Docs: EMBEDDING.md states the immediate-truth guarantee;
EMBED_OBSERVER_VALIDATION.md round 4 records the reproducer, fix and
planted fault.

Residual: the direct predicate ANSWER is not corrected (documented as
stale; the host must arm each interrogated unit).

Closes #1114

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…eter (#1111)

lib/eigen.eigs (the EigenScript-in-EigenScript meta-interpreter) still let
`report` / `report_value` be bound and accepted non-identifier operands,
reproducing the shadowing #1102 removed from the runtime. It now mirrors the
C parser stage-for-stage: the tokenizer lexes both words as a reserved
"report" token; `_p_expect` rejects that token in every binding position
(assignment, define name, parameter, lambda/for/comprehension/catch variable,
import, bare value use); the primary parser accepts only `report of <ident>`
(operand parsed at unary precedence like the C `of` RHS, so parentheses are
transparent and `report of x + "!"` is `(report of x) + "!"`); dot keys
still admit the word (`d.report` stays a data key). The message carries the
runtime's E005 text. Eval routes the new ["report", kind, name] node through
fresh-parameter bridges (`report of v` / `report_value of v`), keeping the
documented value-only classification (equilibrium / opaque); the `report`
value entry is gone from the default env, and `report_value of x` now works
in eigen (it was an undefined variable before).

Repro (baseline):
  src/eigenscript -e 'load_file of "lib/eigen.eigs"
  print of (eigen_run of "report is 4\nprint of report")
  print of (eigen_run of "define report(v) as:\n    return \"mine\"\nx is 1\nx is 2\nprint of (report of x)")'
  -> 4 / null / mine / null            (native: E005 on both, rc=1)
After:
  -> parse error line 1: 'report' is a reserved observer form; use it with
     'of variable', never as a binding [E005]   (raised by eigen_run, rc=1)

tests/test_meta_parity.eigs ([107]) now asserts, per probe, that native
`eval` raises kind "parse" AND `eigen_run` raises an E005-shaped message:
15 binding/operand forms for both words, the unbound-operand runtime raise
on both sides, and positive controls (bound identifier, parenthesised
identifier, `of` precedence, host-function operand, dict fields). With the
old lib/eigen.eigs planted back, 30 checks print FAIL and the file then
aborts on `undefined variable 'report_value'` (rc=1).
tests/test_report_reserved.sh's old `eigen_run of "report of 5"` ==
"equilibrium" pin is replaced by the bridge fallback on a bound name
(`x is 5 / report of x`).

Residual (unchanged, documented): the meta bridge classifies the VALUE
without the host's binding trajectory, so a moved binding reports
`equilibrium` where native reports `moving`, and a meta-defined function
(a list value) reports `equilibrium` where native reports `opaque`.

Closes #1111

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
)

A `for` binder with NO pre-existing binding was loop-scoped at module
scope but persisted inside a function: the env-skip fast path gave it a
fresh frame slot and nothing stopped a post-loop read from resolving to
that slot, so it silently answered the last element.

    define probe() as:
        for z in [7, 8]:
            0
        return z
    print of (probe of [])     # before: 8   after: Error ... undefined variable 'z'
    for m in [7, 8]:
        0
    print of m                 # Error: undefined variable 'm' (unchanged)

Before, on all three roads (main / load_file / import): `8`, exit 0.
After: `Error line 4: undefined variable 'z'` on all three roads, exit 1,
identical with EIGS_JIT_OFF=1 and EIGS_JIT_OSR_THRESHOLD=1.

Root: compiler.c AST_FOR, the can_skip_env path. `add_local(loop_var)`
allocated the binder's slot and left it resolvable for the rest of the
function. The fix retires that slot at the loop exit (`Local.retired`,
skipped by resolve_local and name_in_enclosing): every GET_LOCAL/SET_LOCAL
already emitted for the body stays valid, a post-loop read compiles to
OP_GET_NAME and raises `undefined variable` unless an outer binding exists
(the module answer), a post-loop write or a later `for` over the same name
allocates a new slot. The #1064 restore of a binder over an existing
parameter/local/plain-assigned slot is untouched (and guarded: a binder
whose #1064 save slot could not be allocated at MAX_LOCALS keeps its
existing slot, which is the parameter itself). No JIT change: the loop
body's bytecode is identical; only the post-loop read changes shape.
tools/jit_diff.sh: OK (231 programs x {jit, osr} vs the interpreter).

Uniform rule now (docs/LANGUAGE_CONTRACT.md, SPEC.md, COMPARISON.md,
llms.txt): a `for` binder is loop-scoped everywhere; a pre-existing
binding is restored; a fresh one is gone. The "function-slot exception"
prose is deleted from all four docs and tests/roads/README.md.

lib/eigen.eigs (meta-circular interpreter) mirrored: `for` saves the
current env's own binding and restores or removes it on every exit;
tests/test_meta_parity.eigs asserts meta and VM both raise for the fresh
function and module binders and both restore a parameter.

Lint aligned (src/lint_host.c E003, docs/DIAGNOSTICS.md): the resolver
encoded the old exception ("a function-level for var survives"). It now
loop-scopes every `for` binder, with a separate bind_scope so a body's
plain `is` binds in the enclosing function/module scope (#1056). That also
removes a pre-existing E003 false positive: a module-level
`for k ...: from_for is 4` followed by `print of from_for` was flagged
although the runtime binds it (the `blocks` road fixture). The [stdlib]
lint gate over lib/*.eigs stays clean, so a stdlib post-loop read of a
fresh binder is now caught statically as well as at runtime.

Inventory (a temporary compile-time diagnostic over every lib/, tests/,
examples/, tools/ .eigs): the only code reading a fresh function binder
after its loop was the two sites that asserted the exception --
tests/test_for_binder_scoped_in_function.eigs (f_fresh) and
tests/roads/binders.eigs (fresh_result). No stdlib function relied on it.

Regression tests:
- tests/test_for_binder_fresh_loop_scoped.eigs (new; run on the JIT and
  interpreter tiers in run_all_tests.sh): fresh binder raises after the
  loop and after break; body reads intact; post-loop write is a fresh
  binding; compound assign is loud; sequential and nested same-name
  loops; captured and interrogated (loop-env tier) binders also loud;
  parameter/local/plain-assigned restore (#1064) and body-fresh function
  scoping (#1056) unchanged; hot for-range value; module control.
  On the baseline binary 4 of its 16 checks fail (the slot-path cases).
- tests/roads/binders.eigs: fresh_result snapshots the caught message on
  all three roads; new shadow_result (fresh binder over a module name
  reads the module value after) and rebind_result controls. The baseline
  binary fails the fixture on all 6 runs (`+["fresh_result", 1, 8]`).
- tests/test_lint.sh: E003 fires on a function post-loop binder read;
  silent on post-loop reads of body-assigned names at both levels.

Residuals: `chunk->local_names[]` still carries the retired slot's name
(debugger/DAP variable listings show it as before); .claude/skills/
write-eigenscript/SKILL.md line 36 still describes for-body assignments
as loop-local (a #1056-era staleness, not this rule).

Closes #1105

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
An elided assignment was ABSENT from the observer's 10-deep value window,
so every value-channel verdict (`report`, the six predicates on a numeric
binding, `report_value`, a trajectory's rel/raw lists) differed from the
unelided program until the missing sample would have aged out. The
issue's two measurements, reconstructed as tests/test_unobserved_neutral.eigs:

  (1) an elided `b is 0.0` initialiser moved the window-fill boundary:
      observed-init   hits=31 at=[9, 10, 11, ...]
      unobserved-init hits=30 at=[10, 11, ...]     differing reads = [0, 5, 6, 9]
  (2) one elided assignment at read 50 of 80 (equal hit COUNTS):
      differing reads = [53, 60]  (the merged step, then its aging-out)

After: hits identical ([9, 10, 11, ...] both), differing reads = [] in both.

Root: OP_OBSERVE_ASSIGN_LOCAL / OP_OBSERVE_NAME_POST (and their JIT
helpers) skipped the WHOLE update under g_unobserved_depth. The block
exists to skip the expensive half — the entropy walk (entropy_of_num's
log2s, compute_entropy's O(children) pass) — not the O(1) sample. The
four sites now call observer_slot_sample[_num] when the depth is nonzero:
a scalar's rel/raw step enters the value ring and the slot counts as
`used`; a non-numeric value only flips the #861 route bit (no walk). Not
recorded for an elided assignment: entropy, dH and its window, obs_age,
the tape's observer snapshot, and the bare-predicate alias (kept on the
last OBSERVED binding so scratch work inside the block cannot hijack a
`loop while not converged`). Still elision-sensitive, documented in
PREDICATES.md "Inputs": `why`/`how`, `observe`'s dH pair, a snapshot's
dh/dH/last_entropy, `classify of [t, "entropy"]`, `report`/predicates on a
NON-numeric binding, the stall backstop, a bare predicate. Gated by the
#915 observer gate (eigs_obs_gate_open, now a static inline in
eigenscript.h so vm.c can ask it before resolving a name it will only
sample) — a program that never reads the observer still pays nothing.

One library relied on the old drop-the-sample behaviour: lib/experiment.eigs
(#735) seeded `tracker is 0` inside `unobserved:` so the seed-to-first-
reading jump would stay out of the window. Under #1049 that seed IS a
sample, and test_lab/test_experiment went red (ten identical readings no
longer read stable within 3). The block was never the right tool for
"declare without sampling"; the seed is now `tracker is null` — null and
boolean assignments are skipped by the observe ops on both paths — and
PREDICATES.md/SPEC.md document that idiom as the replacement.

Regression: tests/test_unobserved_neutral.eigs (section [51a], 26
checks) pins both probes as whole verdict STREAMS on the fn-local slot
path, the name path and a 20000-iteration JIT-hot loop; that the entropy
channel is still elided (empty dh window, dH untouched); the route bit;
the alias. Two existing checks pinned the old drop-the-sample behaviour
and were rewritten to the property: test_unobserved.eigs UO-871L (now
asserts the samples land AND the dH window is empty) and
test_observer_interactions.eigs OI-895 literals ("equilibrium" -> "moving",
observed 0 -> 1; the promoted == env-bound invariant is unchanged).

Cost (n=5 medians, this shared 4-CPU box, gate open): 400k two-assignment
loop inside `unobserved:` 21 ms -> 26 ms (4M iterations: 158 -> 227 ms,
~1.45x, ~8 ns/sample; variants that return early attribute ~2.5 ns to the
call + gate check, ~2.5 ns to the slot lookup, ~3-4 ns to the ring write,
and a deferred pending-value ring was prototyped at 216 ms — no better,
so the eager record stays); the README accumulator shape 253 -> 246 ms
(unchanged); a 20000-iteration container-assignment loop inside the block
5 -> 5 ms (the O(children) walk, 734 ms observed, is still elided). The
block still buys 2x on the pathological scalar loop and >100x on
container assignment; with the gate closed it costs zero.

Residual: ouroboros aot/aot_rt.h still returns early under
g_unobserved_depth (aot_observe / aot_observe_num), so the AOT diverges
from the VM on this until it calls observer_slot_sample[_num] — a
follow-up there at the next pin bump. lib/eigen.eigs models `unobserved`
as transparent with no window at all, so there is nothing to mirror.

Closes #1049

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…#1045)

Both were found by phugoid grading observer verdicts against a physical
oracle. Baseline (v0.43.0) reproduction, in the phugoid repo:

  eigenscript tests/observer_check.eigs
    O.ph1s.t120/t200/t280   stable / diverging / improving  (truth: oscillating)
  eigenscript tests/observer_lat_check.eigs
    O2.units.rad/deg/mrad   converged / moving / moving     (one trajectory)

#1045 — rel = dv / max(|v|, |v_prev|, scale). The old dv/(1+|v|) was the
entropy normalisation borrowed as a step: below |v| ~ 1 the denominator is
~1 and rel is just dv, an absolute deadband, so the unit a value was stored
in decided the verdict. The step's own local scale is the textbook relative
step, symmetrised (|rel| <= 2, defined across a zero crossing); `scale`
(set_observer_scale / get_observer_scale, default 1e-3) is the magnitude
below which a value counts as "at zero" and the deadband turns absolute
(|dv| < dh_zero*scale = 1e-6). Read as the textbook criterion, `converged`
is |dx| <= rtol*|x| with rtol = dh_zero and atol = dh_zero*scale.

The default 1e-3 against the bar's three properties: (a) the rad/deg/mrad
triplet at 0.0124 rad sits 12x above the scale, so all three read `moving`
(fixture U1-U5); (b) rounding noise around an exact zero is 1e-16/1e-3 =
1e-13 relative and certifies (Z1-Z2); (c) a geometric decay keeps
rel = 1 - r until inside the scale — 1e6*0.3^k reads `improving` at
x = 14.3 and first certifies at x = 5.6e-12, 8*0.5^k at 1.9e-9 (D1-D5).
A larger scale (1e-2) leaves only 1.24x margin on (a); a smaller one only
lengthens decays' certification. NOT the window's running max |v| that the
design first proposed: for a monotone decay that maximum is the OLDEST
sample, so rel = (1-r)*r^(N-1) and any r < ~0.5 certifies at the first full
window regardless of how far the value still is from its limit (1e6*0.3^k
at x ~= 1.8) — and a wider #1044 window makes it worse (r^49 at N = 50).
The local scale keeps the two knobs independent.

#1044 — set_observer_window of n (state default, live, like the
thresholds) and set_observer_window of ["x", n] (one binding, resolved by
name from the call site; a string-literal operand marks a fn-local
interrogated so plain locals are reachable; ["x", 0] clears), 4..64, 10 at
start; get_observer_window of null | "x". Rings carry a capacity (v_cap /
dh_cap) and grow on the next push when the depth in force exceeds it
(samples preserved, oldest first); a smaller depth reads the newest N.
The default depth allocates exactly what it did before. A trajectory
snapshot carries `window`, so classify of (trajectory of x) agrees with
report of x. The tape/step/DAP slot rebuilds read the same state default.

After (this build): the 1 Hz phugoid (T = 46.9 s) with w1 widened to 50
reads oscillating at all three probes, and a full-window census over 186
samples is {"oscillating": 186} — `diverging` never appears (it is 71 of
269 at N = 10, measured in the same run on a sibling binding). Unit
triplet with default knobs: moving / moving / moving.

Regression test: tests/test_observer_window_scale.eigs, section [51b]
(34 checks): the unit triplet, noise-around-zero, decay-to-zero at two
ratios, the scale knob restoring the old shape, the synthetic phugoid at
N = 10 (control: diverging present) and N = 50 (never), the three probes,
the sibling-binding control, snapshot depth, clear/default/narrow forms,
fn-local reach, and the loud errors. Planted faults, rebuilt and run:
F1 (old dv/(1+|v|) step) -> 6 FAIL (U1-U5, D5; rad=converged deg=moving);
F2 (obs_win pinned to 10, knob ignored) -> 12 FAIL (P1-P16; probes
stable/diverging/stable).

Flipped fixtures, each decided (no blind re-baselining):
- test_predicate_matrix "#735 residual drift": 0.9*0.5^20 = 8.6e-7 with
  steps still 50% of |m| -> `improving` is right (property (c)); pinned at
  20 as improving, and converged at 30 once ten steps sit under 1e-6.
- test_observer_saturation run_converge, test_report_alignment RA3,
  test_step.sh fixture: 30 halvings of 100/1024 end at 1e-7..1e-6 with only
  the last step under the absolute floor -> `improving`; 40 halvings settle
  (converged). The absolute settle floor moved from dh_zero to dh_zero*scale.
- test_unobserved_neutral: a 4e-4 wobble around zero is 40% of the scale —
  real motion; the "sub-deadband" amplitude is 4e-7 now. Elision-neutrality,
  the property under test, holds identically.
- Doc examples [89]/[90]: none flipped.
- phugoid oracle rows (external, not pinned here), default knobs:
  O.sp.t29 converged->moving (measured on this build: w goes -0.014923 ->
  -0.015066 -> -0.015212 m/s, a 1.46e-4 step = 0.96% of |w| per sample and
  still growing in magnitude — the old `converged` was the absolute
  deadband reading 1.46e-4 < 1e-3, i.e. it depended on w being stored in
  m/s; in mm/s the same physics read `moving`), O2.units.rad
  converged->moving (the fix), O2.roll.fast stable->moving (draining 2.5%
  per step), O2.phi.t35 converged->oscillating (a decaying 0.6 mrad DR
  residual is a damped oscillation). All four are pinned by phugoid as
  divergence-class rows that flip loudly on exactly this change.

Validation: release suite 4308/4308, 0 failed; ASan+UBSan suite with
ASAN_OPTIONS=detect_leaks=1 4309/4309, 0 failed, no "NOTE: N test
program(s)" line — leak tally 0, unchanged (the lazy rings are freed on the
same slot teardown as before, and a grow/shrink/re-grow stress across every
depth 4..64 is ASan-clean). tools/replay_diff.sh OK (232 programs, 0
ledgered) and tools/jit_diff.sh OK (232 x {jit, osr}, 0 ledgered);
test_spawn_channel_exit.eigs segfaults on its replay arm on the BASE commit
too — pre-existing, not from this change.

Tape/step parity: a recorded tape stepped with --step prints the same
verdicts as the live run (spiral=moving, osc=oscillating, conv=converged);
tests/test_step.sh (22 checks) passes with the new formula.

Cost (n=5, user CPU, under load ~8 on 4 cores, provisional): 400k observed
scalar loop 0.036 -> 0.038 s median; 4M loop 0.338 -> 0.346 s median
(+2.4%). The hot path reads the state default with one branch and two loads
— the depth is validated at the seam, so nothing is re-clamped per
assignment.

The sandbox allowlist deliberately does NOT gain these four builtins: the
setters mutate process-global state (the rule set_observer_thresholds is
already out for), and the getters stay out under the fail-closed default.

Residuals: the tape does not record set_observer_window/set_observer_scale
calls (nor set_observer_thresholds — the pre-existing class), so `--step`
verdicts on a program that changes the knobs at runtime use the defaults;
a computed (non-literal) name in the per-binding form only reaches
name-resolvable bindings; the rung-2 fast-mode sibling (O2.roll.fast) wants
a narrower window or a coarser cadence, documented, not a knob default.
lib/eigen.eigs models only the pointwise entropy predicates — no mirror.

Closes #1044
Closes #1045

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…d JIT (#972)

#972's last measured residual. With the #915 gate CLOSED — nothing compiled
into the state can interrogate the observer — `OBSERVE_ASSIGN_LOCAL` and
`OBSERVE_NAME_POST` still dispatched into their helpers on every assignment:
a call, the TOS decode, the slot or (module level) hashed name resolution,
only to return at `observer_slot_update_num`'s own gate test one frame later.
`unobserved:` short-circuited earlier, which is where the measured gap came
from.

The gate test is now the FIRST thing both opcodes do — in the interpreter
`CASE` bodies, in the two JIT helpers, and inlined into the thunk ahead of the
call by a new `emit_obs_gate_test()` (the same two loads `eigs_obs_gate_open()`
makes: `VM.owner -> EigsThread.state -> obs_needed`, then the trace-history
flag through its baked storage address, so a mid-run arming, a SIGUSR1 dump or
a descriptor still opens it live — nothing is baked but the flag's address).
%rax is scratch between ops and the stack is untouched, so `last_imm` is the
same on both arms of the merge and the post-op advance writeback is emitted
after it. The JIT also stops emitting a call for `OP_OBSERVE_ASSIGN`, whose
helper has been a no-op since #262 Phase-3/E (a name binding is observed by
the `OBSERVE_NAME_POST` after its SET); the op still advances 3 bytes and
still falls through the `last_imm` switch default.

Repro (20M-iteration read-free loop, user+sys CPU, n=5 medians, shared box —
provisional, see docs/OBSERVER.md for the table):

  before  eigenscript verify972/big_a.eigs   4.12 s   (module level, `x` a NAME)
  after   eigenscript verify972/big_a.eigs   3.88 s
  before  eigenscript verify972/fn_a.eigs    2.94 s   (function level, JIT'd)
  after   eigenscript verify972/fn_a.eigs    2.85 s

and against the like-for-like `unobserved:` control (`big_c2`: the same block,
but `print of x` outside it, so #871 Part B refuses the module-slot promotion
and the binding stays a NAME) the read-free arm went +5.6% -> -1.8%. The ~15%
that remains against the *promoted* `unobserved:` arm is that slot promotion,
not the observer: `big_c` 3.37 s vs `big_c2` 3.95 s on the same binary. Reader
arm (`report of x`) and `EIGS_OBS_FORCE=1` arms are unchanged within 0.3%.

The instrument. `obs-gate: unobserved <unit>` cannot tell "skipped" from
"called and returned at the gate", and neither can the observer slot's `used`
flag — the helper never touched the slot in either case. So
`EIGS_OBS_GATE_STATS=1` now also prints, at exit, `obs-gate: observe-calls N`:
how many times an observer update/sample entry point was ENTERED, counted
before each one's own gate test. Off, it is one predictable branch on a cold
global; on, a relaxed atomic add (workers observe too). Host-only report; the
freestanding profile never sets the flag.

Regression: suite section [99u] checks 45-50 (pin 44 -> 50). A read-free
module-level loop and a read-free fn-local loop must report `calls=0` after
1000 assignments, on the interpreter AND on a WITNESSED JIT thunk
(`EIGS_JIT_STATS` `compiled=` is part of the verdict — a loop that never got a
thunk would score 0 while running interpreted), plus two controls: a reader
and `EIGS_OBS_FORCE=1` must report `populated`. Planted faults, all executed:
(A) interpreter hoists reverted -> checks 45-48 red (`calls=populated`, and
`calls=2` on the JIT arms from the pre-OSR iterations); (B) `emit_obs_gate_test`
removed -> 47/48 red (`calls=999`); (C) the tally made vacuous -> the two
controls red. Section green at 51/51 on release and under ASan.

Verifier's four #972 items, re-executed on this build: (1) the elision — this
commit; (2) assembled chunks arm via `eigs_obs_enable_runtime()` in
`vm_run_bytecode`/`sandbox_run` — [99u] "a descriptor ARMS the observer for its
OWN writes" green; (3) the JIT reader needs no own scan, the read opcode is in
the chunk the compiler scanned — `tools/obs_reader_sync_check.sh` PASS (15
marker readers, 17 switch entries, 38 assertions); (4) the SIGUSR1 dump says
"observer gate CLOSED … absence of data, NOT equilibrium" — [99e] green.

Correctness: `tools/observer_gate_diff.sh` base-vs-hoist over the full corpus,
both binaries run out of the same worktree `src/` so stdlib and diagnostic
paths are identical — 498 programs compared, 0 mismatches, 24 nondeterministic
excluded by the two-run self-diff. `tools/jit_diff.sh` OK (231 programs x
{jit, osr}, 0 ledgered). `tools/obs_marker_check.sh` PASS. `make jit-smoke`,
`make freestanding-check` green. Full release suite 4280/4280.

Closes #972

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…ount

Integration finding: the merged observer tree (#1049 + #1044/#1045 + #972) ran
4345/4350 with 5 failures in [99e], while every branch was green on its own and
the section still passed 5/5 standalone at low load. It is not a flake.

#972 hoists the observer gate ahead of the observe helpers, which makes this
fixture's read-free loop much faster. Measured on the same box, whole-fixture
wall time, n=5:

    baseline v0.43.0        ~244 ms
    #1049 alone             ~264 ms
    merged (with #972)      ~131 ms      1.9x faster

The harness prints READY, polls for it at 0.1 s granularity, then signals — and
needs the child alive across TWO signal round-trips (the #915 gated first dump
declares absence and arms observation; the second carries data). At 131 ms of
total child life that race is lost, and under suite load it is lost every time:
"child exited before the signal (no live process to dump)".

The fixture's own header says synchronisation is on observable state only. Its
loop bound was the exception: an iteration count is a time assumption wearing a
counter's clothes. Both fixtures now run until the HARNESS creates a release
file, which it does once it has collected the dumps it asserts on, with a large
finite cap (2e9) so a dead harness cannot hang the suite. The when=1 row
assertion follows the parameter's new value.

No runtime change: the speedup is #972's deliverable, and the fixture was
measuring it as a failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…diff fails on any signal (#1112)

Two defects, one issue.

A. Runtime. Replaying tests/test_spawn_channel_exit.eigs printed the #148
boundary diagnostic and then died by SIGSEGV:

    EIGS_JIT_OFF=1 EIGS_TRACE=/tmp/x.tape  src/eigenscript tests/test_spawn_channel_exit.eigs   # rc 0
    EIGS_JIT_OFF=1 EIGS_REPLAY=/tmp/x.tape src/eigenscript tests/test_spawn_channel_exit.eigs
    before: "Error line 8: recv: not replayable under EIGS_REPLAY (...)" then rc 139
    after:  same diagnostic, rc 1, no signal (both tiers; ASan+UBSan clean, detect_leaks=1)

Root cause: the worker is `spawn of [recv, ch]` -- a builtin run directly
by thread_entry, so that thread never enters vm_execute and
eigs_current->vm is NULL. rt_error has no dispatch loop to defer the
uncaught print to, prints immediately, and calls vm_print_stack_trace,
which read g_vm (= *eigs_current->vm) -> NULL deref at vm.c:2807. The
same shape killed ANY uncaught raise on a direct-builtin worker with no
replay at all (`spawn of [recv, 5]` -> "invalid channel" -> SIGSEGV), and
all 11 replay_blocks() call sites reproduce it. Fix: vm_print_stack_trace
returns when there is no VM (no frames, no trace); all three print sites
already guarded the deferral on that same condition.

That alone makes the refusal exit 0, not 1: main never observed the
worker's death. A spawn()ed worker that dies of an uncaught error now
fails the run (rc 1) whether or not it is joined -- the #493 rule for
cooperative tasks applied to OS threads. thread_entry counts the death
on the state (spawn_err_count, atomic); main reads it after
handle_table_drain has joined every worker. A worker's `exit of N` is a
request, not a death (still decides the status via the #739 latch); an
error caught inside the worker recovers (rc 0). Semantics change ->
docs/SPEC.md + docs/COMPARISON.md + docs/CONCURRENCY.md; docs/TRACE.md
states the boundary contract: a refusal is a clean exit, never a signal.

B. Oracle. tools/replay_diff.sh printed "CRASH ... rep arm rc=139" and
then classified the replay arm as "at the documented boundary" because
its stderr contained the diagnostic; the run said OK (rc 0). A signal
exit (rc >= 128) in EITHER arm is now the first verdict: named, counted,
never a boundary or a row, hard FAIL; --record refuses to write a ledger
over one. `--selftest` plants, through a wrapper binary, a
boundary-plus-crash witness (must FAIL, attributed by the CRASH line), an
identical crash in both arms (invisible to the diff, must FAIL), a
--record-over-crash (must refuse), a clean boundary control from the real
runtime (must stay OK and be counted), a non-signal nonzero rc that must
still diff into a row, and the vacuity floor.

The signal test is numeric (rc >= 128) at both the counting site and the
per-program skip. The first version skipped on a glob over the rc text
(`rc=1[2-9][0-9]`), which also matches 120-127 -- rc 124 (timeout, a
condition this tool has actually hit) and 127 (no such command) would have
been silently skipped instead of diffed. Selftest case 5 pins that.

Regression tests:
  [103a] tests/test_replay_boundary_exit.sh (21 checks): the issue's
    program record+replay on both tiers (rc 1, diagnostic, no signal),
    every #148 boundary builtin as a direct worker, the no-replay direct
    raise, VAL_FN worker death joined/unjoined, and the positive
    controls (caught error rc 0, exit of 4 -> 4, exit of 0 -> 0, clean
    worker rc 0, main-thread refusal). Baseline: 16/21 FAIL (rc 139 / rc 0).
  [99q] tools/replay_diff.sh --selftest (6 cases, count pinned).
Planted faults (each restored, green again): drop the vm.c guard ->
14 checks FAIL "rc=139"; drop the spawn_err_count increment -> 16 FAIL
"rc=0 (want 1)"; neuter crash_check -> the selftest reports the three
planted crash cases "exited 0, want 1" and --record writes a ledger over
a crash; put the rc glob back in place of the flag -> the rc-124 case
reports OK where it must report a row.

Full corpus (CI job replay-differential): replay_diff: OK (230 programs
record+replay; 12 at the documented boundary; 0 nondeterministic;
0 ledgered) -- same 12 boundaries as before, 0 CRASH lines.

Closes #1112

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…ory (#846)

A schedule visualizer or a DST wants "who ran when" without instrumenting
every yield site. Nothing in the task layer exposed the scheduler's
decisions, so consumers derived lanes from app-level marks (eddy) or could
not draw run segments at all (third-party task code, liferaft's cluster
sim).

Repro (baseline v0.43.0):
    $ eigenscript repro.eigs            # 2 seeded tasks yield/sleep, then read
    Error line 10: undefined variable 'task_sched_trace'   (rc 1)
    $ EIGS_TASK_TRACE=1 eigenscript repro.eigs             # env var inert, same
After:
    $ eigenscript repro.eigs  -> []                        # off by default
    $ EIGS_TASK_TRACE=1 eigenscript repro.eigs
    [{"seq": 0, "tick": 0, "task": 2, "cause": "spawn"}, {"seq": 1, "tick": 0,
     "task": 1, "cause": "spawn"}, ... {"seq": 6, "tick": 2, "task": 0,
     "cause": "join-release"}]

Surface: `task_sched_trace of 1` arms (so does EIGS_TASK_TRACE=1, read at
thread attach), `of null` reads a list of {seq, tick, task, cause} dicts —
one per task RESUME in schedule order — `of 0` disarms and discards. The
cause vocabulary is enumerated from the scheduler's enqueue sites, every one
of which now names its cause: spawn, yield, sleep-wake, join-release,
kill-release, recv-wake, deadlock (the #509 re-enqueue of main).

Design, against the two DST constraints:
- Pure reader. The cause rides with the ready-queue entry (a parallel byte
  array moved in lockstep by the same compaction, always maintained); the
  trampoline records AFTER sched_ready_pop, so the seeded PRNG draws, the
  clock and the queue order are untouched whether or not the trace is armed.
- Derived, not taped. The schedule is a pure function of program order and
  the seed, so the history is re-derived under EIGS_REPLAY rather than
  recorded; it adds no N records and no N record names it (docs/TRACE.md).
- The arm flag lives on EigsThread, NOT on the TaskScheduler, so arming
  never creates a scheduler: a scheduler that exists unarmed by any spawn is
  a pre-existing hazard (task_sched_seed then task_yield with no spawn
  suspends main against it and vm_execute_common returns the suspend's
  NULL — the program prints nothing, rc 0). Unchanged here; see residuals.
- The history lives on the scheduler and is freed in task_sched_thread_free.
  Unbounded while armed by design — a silent cap would make a long run's
  trace lie about its tail.
- Not added to SANDBOX_ALLOW: the allowlist is fail-closed, so the builtin
  is shadowed inside sandbox_run, which is the right posture for a call
  that mutates per-thread scheduler-observation state.

Regression tests:
- tests/test_task_sched_trace.eigs (suite [104b], 39 checks): every
  expectation written from reading src/vm.c before running — the FIFO
  3-task yield/sleep(30/10/20)/join history by hand, the seed-42 history
  through an independent splitmix64 model of sched_ready_pop (picks b, c, a
  — the same permutation docs/SPEC.md's seeded example prints), plus
  recv-wake, kill-release, deadlock, off-by-default, disarm/re-arm and the
  argument contract, and the sandbox fail-closed posture (a sandboxed chunk
  calling it is denied and the host's trace stays disarmed).
- tests/test_task_sched_trace.sh (20 checks): purity — stdout+stderr+rc
  byte-identical with EIGS_TASK_TRACE=1 across all 12 task programs in the
  tree (3 examples/task_*.eigs, test_tasks, test_task_sleep_order,
  test_task_osr, and the six error-path tests/task_*.eigs, which are the
  only programs reaching kill-release and the deadlock re-enqueue); replay
  — a tape recorded armed replays to the identical printed trace, plain and
  under EIGS_REPLAY_STRICT=1, JIT on and EIGS_JIT_OFF=1; tape — the
  N-record count (1, the probe consumes `random`) is unchanged by arming and
  no N record names the trace or a cause.

Planted faults (each rebuilt, run, restored, green again):
  P1 recording removed          -> fixture "got []", replay checks entries=0
  P2 arming consumes a PRNG draw-> purity red on task_seeded_schedule and
                                   test_tasks (the seeded order changes)
  P3 cause/queue lockstep broken-> seeded fixture reads "spawn" where
                                   "yield" is expected
  P4 tick recorded from the entry address (replay-unstable)
                                -> fixture tick rows red, both replay tiers
                                   diverge
  P5 each entry also written as an N record
                                -> tape count 11 vs 1 and the N-name check
                                   names `sched_trace`

Disabled cost (UNDER LOAD, PROVISIONAL — this box ran ~8 agents' builds):
1.2M-resume yield loop (2 tasks x 600k task_yield), n=5 A/B interleaved,
baseline v0.43.0 binary vs this one with the trace off, load average ~6.2:
baseline 0.464/0.339/0.298/0.348/0.373 (mean 0.364s), this 0.342/0.267/
0.241/0.368/0.349 (mean 0.313s) — the two spreads overlap and this side is
nominally FASTER, which is the shape of noise, not of a win. Armed, for
scale (n=3, 1.2M entries retained): 0.324/0.357/0.377s.

Docs: BUILTINS.md row, SPEC.md "Scheduler trace" subsection (doc-gated
example), CONCURRENCY.md section, TRACE.md derived-not-recorded note;
src/lsp_builtin_index.h regenerated by the Makefile rule.

Residuals: the pre-existing unarmed-scheduler truncation above
(task_sched_seed + task_yield with no spawn prints nothing at rc 0) is not
touched by this change and deserves its own issue; the main task's initial
run precedes the first entry and is implicit (documented).

Closes #846

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
#1115 and #1112 were developed in parallel and both landed a suite section
labelled [99q] — #1112 had renamed its own [99y] to [99q] to avoid a collision
it could see, without seeing #1115's. tools/suite_label_check.sh caught the
result on the merged tree ("label [99q] echoed at lines 5898 and 6476"), which
is the case it exists for: neither branch could have found it alone.

#1115's [99q] is already pushed, so #1112's replay_diff crash gate moves to
[136] (the [99a]-[99z] range is full).

Also corrects a comment in [99r] that cited "[99q]'s pins" for the strict
differential's assertions. That reference was already stale at a6c50fb, where
no [99q] section existed at all; it dangled harmlessly then and would now point
at a real but unrelated section. It means [99s].

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
#1118, #1119)

#1118 — README.md and docs/BUILTINS.md both claimed "250+ builtin functions
(199 core + ~60 extensions)". Measured with `eigenscript --api` on this tree:
258 core + 87 extensions = 345. The headline was technically true and the
parenthetical was wrong on both terms.

Updating the numbers alone would not have closed it: the issue measured 253
core on v0.43.0 and this tree already reads 258, because the branch adds
builtins. A hand-maintained count in prose re-drifts on the next addition, and
nothing derived it from anything. So check 8 of tools/doc_drift_check.sh now
reads the counts out of the binary's own index and compares them to the two
prose lines — mechanical-gates §1, ask the tool rather than re-count the source.

Exact rather than a floor, deliberately: the failure this closes is
UNDERSTATEMENT, which a floor tolerates by construction. The cost is one number
in two files per builtin added, and the gate prints the numbers to use.

Validated by planting each fault it claims to catch:
  doc understates (the #1118 shape)  -> DRIFT: README.md says 345 (199 core...)
  count line deleted                 -> DRIFT: BUILTINS.md has no ... count line
  binary absent                      -> DRIFT: ... cannot run — no eigenscript binary
The third matters most: an instrument that cannot run must not report success,
which is the lesson check 2 already carries about an empty `git tag`.

#1119 — ROADMAP.md still showed raw TCP/UDP sockets unchecked in two places.
#414 shipped as ext_net in PR #788 and is closed; `--api` lists the net group.
Both boxes ticked.

Closes #1118
Closes #1119

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…ad direction here

It recurred on 2026-09-07 (fifth bite): `pkill -f test_sigusr1_dump.sh` in a
compound command matched its own command line and killed the invoking shell,
so the cleanup after it never ran. This note already pre-authorised the
graduation — "Four bites in one day; if it recurs, this graduates to
bash_guard" — and the agent that lost its shell had written this very rule
into its own brief hours earlier. Prose that has been read does not prevent
the next time.

hq/hooks/bash_guard.sh now denies `pkill`/`killall` at command position and
names the PID recipe in the refusal; `pkill -P <pid>` passes. This file keeps
the READ direction (polling with a process-table match), which no hook covers,
and points at the hook for the kill direction so the two cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
A verdict — `report of x`, the six predicates, the trajectory labels
`--step` and the DAP server print — is a function of the ASSIGNMENTS and of
the observer configuration: the three thresholds, the window depth (state
default and per-binding), and the characteristic scale. The tape carried
only the assignments, so a reader rebuilt every slot at the compiled-in
defaults and confidently printed a verdict the live run never gave.

    u is 0.0
    set_observer_window of ["u", 50]
    t is 0
    loop while t < 200:
        u is 272.4 + 10.0 * (cos of (6.283185307179586 * t / 46.9))
        t is t + 1
    print of ("live u=" + (report of u))

    before: live u=oscillating   /  --step `t u`: #201 line 5 ... [diverging]
    after:  live u=oscillating   /  --step `t u`: #201 line 5 ... [oscillating]

The class is older than #1044: the same program with
`set_observer_thresholds of [0.01, 0.02, 0.1]` diverges on the v0.43.0
release binary (live `converged`, stepped `stable`) and agrees here.

Design (a) of the two the brief weighed: each knob is recorded AS AN EVENT
at the point it takes effect, so a mid-run change replays in the right
order — a binding that reads `moving` before a `set_observer_scale` and
`converged` after reads exactly that at both stops. A header/snapshot stamp
would have had to refuse that program.

  * `O cfg <dh_zero> <dh_small> <h_low> <window> <scale>` — the five
    state-level scalars, emitted BY DIFF rather than from the knob builtins:
    the writer compares the state's live configuration against what the tape
    last said, immediately before the next `L`/`A` record. The tape therefore
    carries the configuration in force by construction — one set by an
    embedder, by a second `EigsState`, or by a knob nobody remembered to
    instrument still lands on it. A knobless program writes no `O` records.
  * `O win <name> <n>` — the per-binding window override, which lives on an
    `Env` slot and so has no cheap diff; written from `set_observer_window`,
    preceded by its own frame's `S` record.
  * `tape_read.c` is the one reader (`--step` and `eigsdap` share it): it
    installs the defaults, applies every `O` record preceding the assign it
    is folding, and restores the caller's configuration at the end, so a
    reader never leaks a tape's knobs into its own state.

THE CONFIGURATION IS FOLDED TO THE STOP, NOT TO THE LAST ASSIGN. The knobs
split by when the runtime consumes them: the window and the scale are read
while a value is being RECORDED, the three thresholds (and the window again,
for the full-window certifications) while a verdict is being REPORTED. So a
knob moved after a binding's last assign and before the stop still changes
what `report of x` says there, and a fold that stopped at the last assign
dropped exactly those — the first cut of this change printed

    x is 1000.0 / d is 5.0 / loop 30x: x is x + d ; d is d * 0.99
    set_observer_thresholds of [0.01, 0.02, 0.1]
    print of ("x=" + (report of x))          # live: converged

    --step `p x`: 1130.1498133058592  [stable]     <- record on the tape,
                                                      in force, and skipped

for a nine-line program, on both the stepper and the DAP. `tape_traj_settle`
carries the cursor on to the stop position and re-reads the label, so `p`
and the DAP binding cell answer "what would `report of x` say HERE". The
`t` trajectory rows stay per-moment — a row is the label after THAT assign —
so when the settled label differs it gets a line of its own ("observer
configuration changed after the last assign — at this stop: [converged]"),
and the DAP the same as a `#now` row: the reader never lets a stale row
stand in for the present, and never relabels history either.

A CORRUPT `O` RECORD IS REFUSED, NOT INSTALLED. The reader puts these values
into its own observer state, so `O cfg ... 0 ...` divided by zero sizing the
value ring (SIGFPE) and a negative or 4e9 window asked calloc for 2^64-1
bytes (abort) — a new crash class on a data file that #413 attached-tape
bundles ship. Every field is now checked at parse time against exactly the
invariants the live builtins enforce (obs_window_arg,
builtin_set_observer_scale, builtin_set_observer_thresholds) and a record
outside them refuses the tape with exit 3, like a torn bundle archive.
Clamping was considered and rejected: a clamped window is a configuration
the recording run never had, so the label would still be a confident lie,
just a different one.

An `O win` record names a BINDING, not a name. `tape_traj_begin` takes the
`NameHist` being folded and the record is applied only when it RESOLVES to
that history (identity, not `strcmp` on the name), because one name is
routinely several bindings on one tape: two invocations of a function are
two frame instances, and a function-local shares a name with a module-level
global. Matching by name made `--step` print `oscillating` for the binding
whose live run said `diverging` — the same fail-soft shape the records exist
to remove, in the opposite direction. The writer half matters equally: the
scope transition is stamped before the `O win` record, so an override set on
a parameter before the frame has assigned anything carries its own frame's
scope and not its caller's.

Census (writes to the observer configuration on `EigsState`, grep for
`obs_dh_zero|dh_small|h_low|window|scale|win_override`): the three
`set_observer_thresholds` scalars, `set_observer_window`'s two forms,
`set_observer_scale`, the `state.c` defaults, and `observer_slot_set_window`
(one caller, `builtins.c`). All are carried. No embed-API setter exists;
if one is added, the `O cfg` diff carries it with no further work — that is
why the state scalars are emitted by diff and not from the builtins.

TRACE_FORMAT_VERSION 2 -> 3. The #411 rule applies unchanged: a v2 tape is
refused loudly by both `--step` and `EIGS_REPLAY` with exit 3 rather than
silently classified at the defaults, which is the deliberate answer to
"an old tape should still step" — a v2 tape cannot carry the knobs, so
stepping it would print exactly the wrong verdict this change removes.
`tests/fixtures/tape_v2_baseline.tape` is a REAL tape recorded by the
v0.43.0 release binary and the fixture requires the exit-3 refusal from both
surfaces.

Regression: `tests/test_tape_observer_config.sh`, suite section [42f2], 66
checks. Every case asserts that the label `--step` prints equals the label
the live run printed AND names the label it expects, so a change making both
sides equally wrong is caught; `O`-stripped copies of the same tapes are the
built-in discrimination control. Section 8 is the cross-scope set; section 9
is the post-assign set (thresholds, per-binding window, state window), each
asserted BOTH at the last stop and at a stop before the knob call so a
reader that applies every record regardless of position fails too; section
10 is the corrupt-record set. `tests/test_dap.py` gains the same assertion
on the DAP surface (binding cell + `#now` row), 35 checks.

Planted faults, each rebuilt and run:
  * settle removed (fold stops at the last assign) ->
      FAIL postthr/postwin/postdef: --step at the last stop agrees with the
        live run (live=converged step=stable, and both inversions)
      FAIL the t view names the settled label
      DAP: 2 red (binding cell, `#now` row)
  * invalid `O` record installed instead of refused ->
      FAIL corrupt O record refused with exit 3: window 0 (rc=136)
      FAIL ... window -5 / 4000000000 (rc=134, 18446744073709551615 bytes)
      + 4 more (scale 0, dh_zero >= dh_small, per-binding 0 and 999)
  * reader stops applying `O` records -> 8 red, every one the class itself
  * reader matches by NAME instead of binding identity -> exactly the two
    cross-scope cases red (xsleak, xsouter)
  * writer drops the per-binding record / the scope stamp -> 5 and 2 red

Residual: the reader walks the recorded CALL chain, so an override set on a
name a closure captured (its env parent is its definition site) may resolve
to nothing. It is then applied only if that name is unique on the whole
tape, and otherwise dropped — the stepped verdict is the default-window one,
never another binding's. Losing a knob shows a different label; applying it
to the wrong binding shows a confident wrong one, and only the second is the
shape this design refuses. Written down in docs/TRACE.md and docs/PREDICATES.md.

Follow-up to #1044/#1045: the tape carries the observer configuration

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…d it (#1109)

The REPL's block rules make an unindented non-blank line BOTH the terminator
of an open block and part of the same compiled unit. That is right when the
unit runs, and wrong when it does not: a tokenize/parse/compile failure threw
the whole buffer away, and the closing line — a statement the user typed and
that never ran — went with it.

Minimal repro (piped REPL, baseline v0.43.0 / a6c50fb):

    $ printf 'define f(@) as:\nx is 1\nprint of x\n' | src/eigenscript
    eigs> ...   Syntax error line 1: unexpected character '@'
    eigs> Error line 1: undefined variable 'x'
         1 | print of x
           |          ^
      at <module> (line 1)

`x is 1` was eaten by the failed define, so `print of x` had nothing to read.
After:

    eigs> ...   Syntax error line 1: unexpected character '@'
    => 1
    eigs> 1

The issue's #1102 consequence goes with it: the reservation program pasted
into the REPL lost its `x is 1`, so `report of x` saw one assignment and
answered `equilibrium`; it now answers `moving`.

Fix: `repl_eval_buffer` reports whether the unit NEVER RAN (tokenize, parse or
compile error) through a new `failed` out-param — a runtime error does not
qualify, because then the closing line executed with the unit. The per-line
block rules, duplicated in the piped loop and the interactive editor loop,
move into one `ReplAccum` + `repl_feed_line`; when a never-ran unit was closed
by an unindented line, that line is re-fed through the same rules as the start
of the next unit. It may open a block of its own, and `exit`/`quit` matching
moved into the shared feed so a re-fed `exit` is honoured rather than eaten.

Behaviour on every other path is unchanged. A differential over 21 piped REPL
transcripts against the baseline binary is byte-identical on 17 (simple lines,
valid blocks closed by a blank line AND by an unindented line, top-level parse
errors, nested blocks, exit/quit forms, EOF mid-block, runtime errors) and
differs only on the 4 failed-multi-line-unit cases this fixes.

Regression tests (tests/test_repl.sh, 6 piped checks; tests/test_repl.py, 2 pty
checks) pin: the issue repro; a VALID block closed by an unindented line still
compiling as ONE unit, byte-exact; a failed unit closed by a BLANK line
reporting once with no spurious empty unit; two consecutive failed units each
reporting once with both closing lines surviving; the #1102 pasted program; and
a re-fed `exit` still ending the session. Planted fault (`failed && 0 &&
terminator`, i.e. the re-feed removed): 4 of the 8 go red, the two controls
that must not depend on the re-feed stay green.

Closes #1109

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…s of n returns a buffer (#1093)

Part 1 — every tensor builtin in src/builtins_tensor.c that takes a flat
numeric list now takes a VAL_BUFFER in the same position, and returns a
buffer when EVERY tensor operand was a buffer (a list otherwise). Nested
2-D list inputs keep list semantics; a shaped buffer is the buffer form of a
2-D tensor. Converted through the shared helpers (tensor_dims,
tensor_to_flat, tensor_total, tensor_flatten_recursive, tensor_unary,
tensor_elementwise) plus a new flat_to_like that rebuilds a shape-preserving
result in the input's container — so each operation has ONE implementation
rather than a buffer copy beside a list copy (add's and relu's duplicated
buffer fast paths were deleted, not extended).

Part 2 — `zeros of n` (scalar n) returns a buffer: `type of` is `buffer`,
`print of` shows `<buffer:n>`, 8 bytes per element instead of a boxed Value
plus a slot pointer. `zeros of [rows, cols]` still returns the nested list.
`zeros_like` mirrors its argument's container. `set_at`/`get_at` take a
buffer too (1-D, or `[row, col]` on a shaped one), because that is the first
thing a `zeros of n` consumer reaches for; a bad index there reports the same
diagnostic the `[]` operator already gives for a buffer.

Guard census (the count is an instrument reading, not a target): 38
`type != VAL_LIST` guards before AND after. 15 are the ARGUMENT ENVELOPE —
`arg` is the argument list `[a, b]`, never a tensor operand, so a buffer can
never appear there. 13 converted. 1 (`shape`) already accepted buffers. 9 are
nested-2-D-LIST structure guards asserting that an element is a row; they keep
their list meaning because the shaped-buffer case is handled in the VAL_BUFFER
branch added above each function. 15 + 13 + 1 + 9 = 38.

Minimal repro, baseline v0.43.0 binary -> after:
  b is buffer of 4  (1,2,3,4)
  mean of b                     0     -> 2.5
  sqrt of b                     0     -> <buffer:4>
  negative of b                 0     -> <buffer:4>
  add of [b, 1]                 0     -> <buffer:4>
  multiply of [b, 2]            0     -> <buffer:4>
  subtract of [b, b]            0     -> <buffer:4>
  softmax of b                  null  -> <buffer:4>
  leaky_relu of b               null  -> <buffer:4>
  gather of [b, 2]              0     -> 3
  zeros_like of b               0     -> <buffer:4>
  type of (zeros of 4)          list  -> buffer

Regression test: tests/test_tensor_buffer_inputs.eigs, wired as suite section
[93b], 99 checks. Every check is a list/buffer PAIR whose flattened numeric
output must be byte-identical, plus the container assertion where a buffer
input must yield a buffer output. Planted-fault proof: removing the
VAL_BUFFER branch in tensor_unary turns 8 checks red (sqrt/exp/log/negative,
numbers and container); reverting `zeros of n` to the list turns 5 red
including a hard error on `reshape of (zeros of 6)`.

List inputs are unchanged: 55 list-only tensor calls — including the empty
tensor and the wrong-type fail-soft answers — are byte-identical against the
pre-change binary in default mode; under EIGS_STRICT=1 the only difference is
one guard message now naming buffers. Wrong types stay loud in both modes.

Sweep. tests/test_sandbox_budget.eigs case 4 rewritten: at 8 bytes/element and
a 10M cap, one `zeros` call can charge at most 80 MB and can no longer exceed
the 256 MiB default budget, so the old assertion was passing through the
boundary result-scan refusal instead of the byte budget. The bomb is now four
maximal allocations and the assertion names the memory diagnostic; the old
zeros(9M) case is kept, pinning the new mechanism. tests/observer_corpus
golden for dynamics__solve recaptured: only the printed rendering of three
`zeros`-built vectors moved — verified element-wise identical against the
pre-change binary, every iteration count unchanged — and the cost is recorded
in the corpus README.

Docs: SPEC.md (a `zeros of n` subsection and the container rule under shaped
buffers, both with executed examples), COMPARISON.md (a numeric-array section
against NumPy), BUILTINS.md (a tensor-section preamble stating the container
rule plus the zeros / zeros_like / matmul / gather / set_at / get_at rows),
llms.txt, README.md.

Residual: ouroboros' AOT mirror hardcodes the old answer.
aot/compile.eigs:882-892 (`is_buf_create`) excludes `zeros` from the buffer
class with the comment "`zeros` does NOT create a buffer -- in this VM it is a
tensor builtin returning a LIST, every time"; aot/aot_rt.h:897 repeats it.
Both remain SAFE after this change (the boxed path and aot_any_len /
aot_any_at already dispatch on VAL_BUFFER), but the AOT will not deliver
ouroboros#170's 12.4x until `is_buf_create` re-admits the scalar `zeros of n`
form at the next pin bump. Not edited here.

Closes #1093

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…n OP_GET_NAME, not string data (#1046, #915)

The write-path gate (#915) armed on the PRESENCE of any `import` (OP_IMPORT
sat in opcode_is_observer_reader) and on any string constant spelling an
observer builtin's name (const_pool_names_observer matched the whole pool).
Both were conservative stand-ins for scans nobody had written; both cost
read-free programs their gate.

Repro (issue rows, baseline v0.43.0 binary, EIGS_OBS_GATE_STATS=1):
    D:     msg is "report" + 200k-frame write loop        -> obs-gate: observed
    imp_b: import linalg (never used) + the same loop     -> obs-gate: observed
    ctl:   host with pre-import history; import probe     -> observed / diverging
After (this build), n=5 medians, wall seconds, under load (~1.4 avg), provisional:
    A  loads wlib only        0.035 -> 0.035  unobserved -> unobserved
    B  + load_file dead read  0.049 -> 0.051  observed   -> observed   (per-function liveness, out of scope)
    C  + load_file control    0.036 -> 0.037  unobserved -> unobserved
    D  msg is "report"        0.051 -> 0.030  observed   -> unobserved (row A is 0.035)
    imp_a no import           0.023 -> 0.019  unobserved -> unobserved
    imp_b + import linalg     0.032 -> 0.021  observed   -> unobserved (row imp_a is 0.019)
    ctl   import reader       diverging -> diverging, observed both sides (rc=0)

What changed:
  * src/builtins_host.c: eigs_import_resolve() -- the ONE `import NAME`
    resolver (project-first, then stdlib, #821/#904/#1056), extracted from
    the inline logic in the OP_IMPORT handler. #915 deferred the import half
    because a second resolver would drift (#737); now there is one, and both
    the handler and the gate's eager pass call it. Its path scratch lives on
    the heap: it runs inside vm_execute, which recurses on nested imports,
    and -fstack-usage measured the stack version at 28,800 bytes per frame
    against 112 now (the C-stack rule; tools/embed_stack_soak.sh green).
    The freestanding profile gets a stub that resolves nothing.
  * src/vm.c OP_IMPORT: calls the shared resolver (the collision warning is
    unchanged, driven by the `shadowed` out-param), and gains the same
    outcome guard builtin_load_file has: a module that reads observer state
    while the gate was closed for this program's earlier assignments RAISES
    ("import: '<name>' reads observer state ...") instead of answering a
    rest value. This is what makes the compile-time scan sound against a
    module rewritten or shadowed between scan and import.
  * src/chunk.c: OP_IMPORT leaves the reader set (vm.h marks it obs:NONE;
    the sync gate's exemption is spent and removed). chunk_scan_static_loads
    hands OP_IMPORT targets to the visitor (is_import=1) next to literal
    load_file targets. The constant-pool string match is replaced by
    chunk_name_loads_observer_builtin: OP_GET_NAME operands only -- the one
    binding-load opcode. Verified with EIGS_DUMP_BC: a string literal, dict
    key or printed literal is CONST; `local r is observe` is GET_NAME; a
    field access `tbl.eval` is DOT_GET (a field name on a user value -- it
    armed the keyword-table fixture until the population was narrowed to
    GET_NAME); `define observe` is a binder. OBS_BUILTINS is unchanged and
    mirrored name-for-name by OBS_NAMES in compiler.c, where the AST scan
    needs `report`/`report_value` (the parser spells `report of x` as a
    relation headed by that IDENT).
  * src/compiler.c: the eager pass's load list carries a kind; AST_IMPORT
    is noted instead of arming; import entries resolve through
    eigs_import_resolve and are parsed and scanned transitively like
    literal loads (nested imports anchored at the module's own directory,
    mixed import/load_file chains included); a provider-served module
    (embed source provider) stays conservative. for_loop_reads_observer
    keeps its pre-change answer for an import in a loop body (#1062 tier
    decision). Circular and self imports terminate in the pass (memo +
    depth cap) and the runtime's existing circular-dependency error is
    unchanged.
  * tools/obs_reader_sync_check.sh: EXEMPT="OP_INTERROGATE", SWITCH_FLOOR 16,
    selftest rows updated (11/11).
  * docs/OBSERVER.md: "What arms the gate -- the rule, precisely" (five
    numbered conditions, each executed), import named in the
    refuse-instead-of-answer section, the stale "compiled twice" residual
    corrected (parse-only since #1031).

Regression test: tests/test_obs_gate_import.sh, suite section [99u+], 19
checks pinned by count (12/19 on the baseline binary): unused stdlib
import, string literal, keyword table, alias of observe/eval/classify
(still arm), user-defined observe + field named eval (do not), --lint
verdict, EIGS_OBS_FORCE, the check-40 invariant on the VALUE (host history
visible to an imported reader: diverging), a reader two imports down,
project-first shadowing and eigs.json project-root resolution through the
shared resolver, an import inside an uncalled function, an unresolvable
import (open), a module rewritten between scan and import (raises), a
clean project import (runs, closed).
Planted faults, each rebuilt and run: OP_IMPORT back in the reader set ->
3 red + sync gate red; CONST matched again -> 4 red; import guard removed
-> "silent:equilibrium"; no eager import scan and no guard -> 8 red
including `equilibrium` where the truth is `diverging` (the #861 inversion
suite check 40 guards).

Corpus oracle: tools/observer_gate_diff.sh capture base1/base12 (baseline
binary, the determinism reference) and mine2 (this build); compare base1
mine2 -> 497 deterministic programs, 0 mismatches after normalising by
hand the two #1115 artifacts of an out-of-tree baseline binary (the
exe-dir substring in resolver error text, 5 programs; the project-vs-
stdlib import-shadow warning that fires only because the baseline's
stdlib is not the worktree's lib/, 2 programs). Raw compare: 7
mismatches, all of that shape; nothing else moved.

Suites: release 4249/4249, 0 failed; ASan (detect_leaks=1) full suite,
leak tally unchanged (0); freestanding-check OK; embed_stack_soak OK;
obs_reader_sync_check + selftest, obs_marker_check + selftest green.

Residuals: row B (a load_file'd module with a DEAD read) stays observed --
per-function liveness, out of scope. ouroboros src/frontend.eigs does not
lint on v0.43.0 at all (E005 at line 2300, a bare `report`, reserved by
#1110); its line-56 keyword table holds the six PREDICATE keywords, which
are opcode forms and were never in the name list, so the table never
armed anything on either binary -- the arming #1046's comment 5 measured
was the two first-class builtin loads at 2300/2308 (`_env_set_local of
[env, "observe", observe]`), genuine aliases that must arm. With both
patched out the file lints `unobserved` on the baseline AND this build;
with only 2300 patched it lints `observed` on both, correctly.
Provider-served imports (embed) stay conservative. No chunk is reused
between the eager pass and the import: since #1031 the pass parses and
scans an AST it then frees, so a literal module is parsed twice and
compiled once (#1031 stays open for chunk reuse).

Closes #1046
Closes #915

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…'s row-label gutter folded into its rect (#859)

The #823 containment clip had four registry opt-outs. Two were real debt:
dropdown and combobox drew their open lists in-tree (so the list could not
be clipped without erasing it, and any later sibling painted over it), and
grid drew its row-label gutter to the LEFT of its own x. Both are gone.

Open lists now ride the `_render_popups` overlay pass that #565 built for
menu_bar pull-downs: `_render_dropdown` / `_render_combobox` draw only the
closed box (and stamp the absolute origin the overlay reads), and
`_render_dropdown_popup` / `_render_combobox_popup` paint the list after
the whole tree walk with no clip active. Hit-testing follows the paint:
`_find_open_popup_at` — already consulted before the tree walk for
mousedown and mousemove — gained dropdown/combobox branches, and the two
`_hit_test_*` functions no longer extend the widget's in-tree bounds,
which would have handed a click that visually landed on the list to
whatever sibling the list covers. `app_loop` runs the overlay pass for
each visible modal too, so a dropdown inside a dialog keeps its list; the
pass and the popup hit-test follow only the ACTIVE tab panel, matching
render and _hit_test.

Two adjacent bugs fell out and are fixed here: `_mousedown_dropdown`
selected by `hover_index`, so a mouse-only click with no preceding
mousemove selected nothing (the #577 defect, fixed for combobox and
menu_bar but never for dropdown) — it now selects by the click's own y
through the shared `_dropdown_item_at`, which also stops forgetting the
2px gap below the box.

grid: the gutter is inside the rect. `_grid_gutter` is `row_label_w` when
`row_labels` is set and 0 otherwise; `_grid_measure` restamps `w` from it
and is registered as the grid's `measure` hook, which `render` calls
BEFORE pushing the containment clip (and `_layout` calls on its pass), so
a box derived from content is never clipped to a rect it has outgrown.
Cells start at `x + row_label_w`, labels draw at `x`, the mouse paths
offset by the gutter and a click in the gutter is not a cell click.
`grid_cell_origin of widget` is the public accessor for cell (0, 0).

Registry opt-outs are now exactly `menu` (a floating popup placed in
window coordinates) and `dialog` (a full-screen dim), and a fixture
enumerates them.

Minimal repro (stubbed gfx, recording the draw stream — panel at (10,10)
200x60 holding an open dropdown, later sibling panel at (10,60)):

  before: list_first=11 sib_first=17 -> the list is drawn BEFORE the later
          sibling, under an active clip of [10, 10, 200, 60] (the panel
          crops it at y=70); clip_optouts = combobox, dialog, dropdown,
          grid, menu
  after:  list_first=19 sib_first=15 -> the list is drawn AFTER the
          sibling with clip = null; clip_optouts = dialog, menu

What the regression tests pin:
- [63] tests/test_ui.eigs, new "#859 overlay pass" section (565 -> 613
  checks): the opt-out enumeration is exactly menu + dialog; the later
  sibling's draws precede the list's; the list records with no active
  clip and its rect runs past the panel edge; the anchor box now records
  under a clip of its own rect; the same for combobox; a vacuity control
  (no `_render_popups` -> no list drawn at all); the overlay works with no
  `_layout` pass; a hidden tab's list is neither painted nor hit-tested,
  with the active-tab control beside it; a click on the overlaid list
  selects on the DROPDOWN while the button underneath sees nothing (with
  the list-closed control proving the button is reachable); mouse-only
  selection, outside-click close, escape close; grid cell (0,0) origin,
  clip, nothing drawn left of x, gutter clicks, `grid_cell_origin`, and
  the measure hook widening the box before the first frame's clip.
- [132] tests/test_ui_containment_gfx.eigs (9 -> 25 checks, real pixels
  through the SDL software renderer): the list is painted over a canvas
  sibling and past the panel's bottom edge; a driven mousedown selects on
  the dropdown while the canvas under it never fires; the grid's cell
  (0,0) is painted at x + row_label_w with nothing painted left of x; and
  a planted fault that re-registers the pre-#859 in-tree render turns both
  the z-order and the escape probe red.

Planted faults executed against the real gates: in-tree list -> [63] 7
red, [132] 3 red; `_find_open_popup_at`'s dropdown branch removed -> [63]
4 red, [132] 2 red; grid gutter reverted -> [63] 6 red, [132] 3 red.

Residual: DeslanStudio's drum view maps its own pixels with
`g._ax + step * cell_w`, which is no longer the cell origin. Its
`test_drum_view.eigs` is the one consumer test this moves; a two-line
migration (`DV_GRID_X 76 -> 16`, and the test's click helper reading
`grid_cell_origin`) was applied to a scratch copy and took all 24 of its
test files green. eigen-sheet does not use lib/ui and its suite is green
against this build.

Closes #859

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…catter_add (#973)

Two ML consumers had each hand-rolled reverse-mode backprop (Tidepool's DQN
in train.eigs:210-408, iLambdaAi's transformer rules in src/model_train.c)
because the substrate shipped only finite-difference `numerical_grad`.
This promotes the shared primitive into the stdlib.

lib/autograd.eigs — a Wengert-list (tape) autograd over the f64 tensor
builtins on shaped buffers: ag_tape / ag_leaf / ag_const, ops ag_matmul,
ag_add, ag_sub, ag_mul, ag_scale, ag_relu, ag_leaky_relu, ag_softmax,
ag_log_softmax, ag_gather, ag_norm, ag_sum, ag_mean, ag_softmax_ce, and
ag_backward / ag_grad / ag_value / ag_sgd_step / ag_sgd_step_clipped.
vjp rules: matmul (dA = dY·Bᵀ, dB = Aᵀ·dY), add/bias (sum over the batch
axis), relu/leaky_relu (mask), softmax / log_softmax / CE (p − onehot),
gather (scatter_add), norm, sum/mean. Leaf values are the caller's buffers
(ag_sgd_step updates in place); a node used twice accumulates; constants
get no gradient. `ag_` prefix so load_file never shadows the builtins.

C prerequisites (src/builtins_tensor.c, always-compiled f64 buffer path;
the f32 twins in model_train.c only exist under EIGENSCRIPT_EXT_MODEL):
  - matmul_at (aᵀ·b) and matmul_bt (a·bᵀ): same i-k-j tiling as
    ne_matmul_buf, so byte-identical to `matmul` of the explicitly
    transposed operand; buffers and nested lists; #512 raise discipline.
  - scatter_add of [dst, indices, values]: gather's in-place dual (per-row
    on a shaped dst, flat on a 1-D dst); every index validated before
    anything is written, so a raise leaves dst untouched. Length mismatches
    RAISE (value) rather than truncating to the shorter side — dropping a
    gradient entry silently is the same wrong-number class that makes an
    out-of-range index raise.
  - buffer paths the tape needed and consumers had hand-rolled around:
    subtract/multiply/divide on buffers, [rows×cols]±[cols] bias broadcast
    and scalar broadcast for all four arithmetic ops (mismatches RAISE,
    they answered a silent 0 before), softmax/log_softmax/leaky_relu/mean
    on buffers, gather on a shaped buffer (out-of-range raises), and
    numerical_grad on a buffer parameter (the gradient-check oracle).
  - log_softmax of a 2-D LIST used to answer for row 0 only: its
    `[tensor, dim]` form fired on "first element is a list", which every
    2-D tensor satisfies. Found by the buffer/list differential; now only
    exactly [list, number] is the dim form.
Registered, SANDBOX_ALLOW'd (pure compute), lsp_builtin_index regenerated.

Repro (baseline v0.43.0, probe over a 2x3 shaped buffer w):
  matmul_at/matmul_bt/scatter_add -> undefined variable
  softmax/log_softmax/leaky_relu/numerical_grad of w -> null
  subtract/multiply/gather of w, add of [w, bias] -> 0
  import autograd -> module not found
After: every one returns a buffer; `import autograd` trains.

Regression tests:
  tests/test_autograd.eigs (185 checks): every vjp rule vs numerical_grad
    within 1e-4 relative + 1e-6 absolute (worst measured 5e-6, most ~1e-10;
    tolerance rationale in the file header), batch axis on every reduction,
    accumulation, const/untouched leaves; a 2-layer softmax-CE MLP trained
    by the tape (loss 1.22 -> 0.0028 in 150 steps, 14/14 windows decreasing,
    100% train accuracy); the Tidepool DQN shape 433->64->32->6 batch 32
    through one backward with gradient shapes asserted and db3 checked
    against the hand-rolled per-action reduction.
  tests/test_tensor_buffer_ops.eigs (78 checks): matmul_at/matmul_bt
    byte-identical to matmul of the transposed list operand (35x33 crosses
    the 32-wide tile), scatter_add vs the list loop and gather's dual,
    buffer elementwise/softmax/log_softmax/leaky_relu/mean vs the list
    path, numerical_grad buffer vs list, the log_softmax 2-D pin, raises
    (out-of-range, wrong types, and both length-mismatch forms).
  tests/test_sandbox_allow.eigs: the three new names reachable in the sandbox.
Planted faults (executed on this tree, then restored):
  A. dB sign flipped in ag_matmul's vjp -> test_autograd: Tests: 185 |
     Pass: 175 | Fail: 10 — matmul dB, matmul(1-D x) dW, relu∘add dW,
     softmax_ce dW, gather∘matmul dW, norm dW (every element off), and the
     MLP stops training (final loss, window descent, accuracy) plus the DQN
     step no longer lowers the TD loss.
  B. scatter_add overwrites instead of accumulating -> test_tensor_buffer_ops:
     Tests: 76 | Pass: 74 | Fail: 2 (the repeated-index accumulate checks).
  C. scatter_add back to min(ni, nv) truncation -> test_tensor_buffer_ops:
     Tests: 78 | Pass: 76 | Fail: 2 (both length-mismatch raises).
  D. docs/ARCHITECTURE.md lib-module count back to 77 -> tools/
     doc_drift_check.sh exits 1 and suite sections [99v]/[99p] FAIL.

Docs: STDLIB.md (index row + lib/autograd section with a gated example),
BUILTINS.md (three new rows, buffer notes on the touched builtins,
numerical_grad buffers), SPEC.md shaped-buffers paragraph, llms.txt, and
ARCHITECTURE.md's raw lib/ module count 77 -> 78 (the doc-drift gate,
check 6, fails the suite otherwise — this module is the 78th).

Round 2 — the broadcast class inside the tape's own vjp rules.

`ag_add`/`ag_sub` reduced a broadcast operand's gradient over the batch
axis; `ag_mul` did not. `ag_mul of [t, x, bias]` answered an unreduced
[rows x cols] gradient for a [cols] parameter, and `ag_sgd_step` then
stepped that parameter with the first `cols` entries of it — a silent wrong
number produced by the module whose reason to exist is removing that class.

Repro (x = [2x3] leaf, v = [3] leaf, loss = sum(x * v)):
  tape  g(v) = 0.5 1 1.5 2 2.5 3   shape [2,3], 6 elements
  numerical  = 2.5 3.5 4.5         shape [3]
  ag_sgd_step of [vn, 0.1] -> v = 0.95 1.15 1.35, not 0.75 0.9 1.05
After: tape g(v) = 2.5 3.5 4.5, shape [3]; the step lands on 0.75 0.9 1.05.

Root: the reduction was inline in the add/sub branch rather than a shared
rule, so a rule added later could not inherit it. `_ag_reduce_to of
[who, pv, g]` now owns it and mirrors buffer_elementwise's shape rules
exactly — scalar operand -> `sum of g`; equal element count -> g (copied
into the operand's shape when the ranks differ, since elementwise is flat
and the values already are the gradient); one-row operand whose column
count matches -> column sums; anything else -> THROW. ag_add, ag_sub and
ag_mul route both deltas through it in both operand orders. A
scalar-valued node (from ag_sum / ag_mean / ag_norm / ag_softmax_ce) as an
elementwise operand now works instead of dying downstream with "cannot
apply '/' to buffer and num".

The class swept, rule by rule:
  ag_add / ag_sub / ag_mul   broadcastable -> reduced (this fix)
  ag_scale                   k is documented as a NUMBER; a buffer k
                             broadcasts and can make the result a different
                             shape from `a` (measured: a=[4], k=[5x4] ->
                             value [5,4]), so it now THROWS
  ag_matmul                  no broadcast form: matmul of [[5x4],[4]] raises
                             "incompatible shapes (5x4 · 1x4)" in the forward
                             pass, and both vjp products are shape-determined
                             by the matmul contract
  relu / leaky_relu / softmax / log_softmax / gather / norm / sum / mean /
  softmax_ce                 one tensor operand; each vjp allocates its delta
                             from that operand's own shape, so no broadcast
                             reduction is reachable
  (the module has no ag_div / ag_pow to check)

ag_sgd_step and ag_sgd_step_clipped are the backstop: both now THROW on a
non-buffer leaf and on a gradient whose element count is not the
parameter's, instead of stepping the first `len of grad` entries.

tests/test_autograd.eigs section 4 (185 checks, was 101; placed last so
sections 1-3 draw the same seeded stream as before): add / sub / mul each
checked against numerical_grad in BOTH operand orders on BOTH operands with
a [5x4] and a [4], plus a scalar-node operand for mul and add; 20
ag_sgd_step steps on a [4] broadcast parameter, each lowering the loss and
converging to the analytic optimum (v[j] = mean_i (T-D)[i][j]) within 0.1%;
and the refusals — matmul's, the forward elementwise mismatch, ag_scale's
tensor k, and both step functions' shape guard.
Planted faults (executed on this tree, then restored, green after each):
  E. ag_mul's reduction dropped -> 185 checks, 6 FAIL: both mul broadcast
     operands wrong element count (20 vs 4), wrong rank (2 vs 1) and every
     element off numerical_grad; then the scalar-node case dies in ag_mean.
  F. ag_add/ag_sub's reduction dropped -> 10 FAIL (bias(add) db,
     sub(bias broadcast) dv, softmax_ce db — count, rank and values) and the
     new ag_sgd_step guard stops the MLP instead of mistraining it.
  G. the ag_sgd_step / ag_sgd_step_clipped shape guard removed ->
     185 | Pass: 183 | Fail: 2 (both refusal checks).
  H. ag_scale's tensor-k guard removed -> 185 | Pass: 184 | Fail: 1
     ("no raise, answered shape [5, 4]").
docs/STDLIB.md gains a Broadcasting paragraph, updated ag_add/ag_mul/
ag_scale/ag_sgd_step rows, and a second gated example (the column-sum
gradient [5, 7, 9] of sum(x * s)) — run by suite [89]/[90].

Residuals: consumers are not edited. Tidepool would drop its hand-rolled
`transpose` (train.eigs:97) and `add_bias` (:123) and replace the manual
dw3/db3/dw2/db2/dw1/db1 chain (:292-364) with ag_matmul/ag_add/ag_relu +
ag_backward + ag_sgd_step_clipped — but its Huber-clipped TD gradient seeds
the q node directly, and `ag_backward` only seeds ones, so full adoption
also wants either an `ag_huber` node or a backward entry point that takes a
caller-supplied seed. iLambdaAi's transformer backward lives in
src/model_train.c on f32 and stays hand-written; a tape for it needs either
an f32 tape or moving that model to f64 buffers.

Closes #973

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
(cherry picked from commit 4a0af5a27f80357a3003e06e92ce883ddf23c2d3)
…aN sources, the -1/falsy families; record the deferrals (#971)

Under EIGS_STRICT=1 three more classes of stand-in now raise a catchable
error; with the flag off every one is byte-identical to v0.43.0, proven
by tools/strict_differential.sh against a build of the parent commit:

  identical-when-off: 98   differing: 0   waived: 0
  raises-under-strict: 98  silent: 0      misattributed: 0
  answer-pins held: 30     valid-input rows unchanged in BOTH modes: 122/122

Phase C — json_path (the 13 JSON-parser sites). A document that failed to
parse was walked leniently and answered the same "" an absent key does,
so malformed JSON was indistinguishable from a missing field:

  $ EIGS_STRICT=1 eigenscript -e 'print of (json_path of ["{\"a\": 1e", "a"])'
  before: 0                      (rc 0 — the partial parse's placeholder)
  after:  Error line 1: json_path: invalid JSON at position 8   (rc 1, kind value)

Strict applies json_decode's own acceptance test (structural error, a
repaired \u scalar, trailing garbage); JSON false/null/absent keys are
answers and stay quiet in both modes. The three parse_number fs:CHANNEL
sites and the `false` fs:LITERAL are untouched; the `!root` fs:TODO is
now fs:STRICT. ext_http's shared store / header parsing and the store's
catalog read the flag channel directly and never pass through json_path,
so they are unaffected in either mode (grep'd).

NaN collapse — enumerated on the tree rather than assumed. The operators
cannot reach NaN from finite operands (0/0 and x%0 raise first; no operand
can hold an inf; sum/mean/norm/dot/buf_* accumulate through num_guard per
step), so the reachable sources are builtins: pow(negative, fractional),
num of "nan", f64_from_bytes of a NaN pattern, matmul reaching inf-inf,
tensor_load of NaN bytes, and the elementwise divide by zero (pre-collapsed
to 0 where `/` raises). Each collapses to 0 + math_flags.invalid exactly as
before and raises under strict naming the builtin (num_guard_named); the
NaN branch of num_guard itself is the backstop ("arithmetic: ...") for any
source the list misses. The JIT bails to the interpreter on a non-finite
result, so both tiers raise from the same guard (probes run with
EIGS_JIT_OFF=1 as well; tools/jit_diff.sh green).

NO default-path change, and that is the second draft. matmul's BUFFER
path stores the kernel's raw inf-inf NaN, and a raw NaN in a buffer is a
NaN-boxed slot tag, so `r[0]` reads back as `null` (0xFFF8... is
SLOT_NULL_BITS) with math_flags.invalid still 0:

  $ eigenscript mm.eigs          # 1e200*1e200 + 1e200*(-1e200), buffers
  v0.43.0:  null / {"invalid": 0}
  draft 1:  0    / {"invalid": 1}      <- collapsed like the list path
  now:      null / {"invalid": 0}      <- byte-identical again

Collapsing it looked free and cost the one claim this reform makes. It
made the probe diverge from the baseline, which had to be carried as
EXPECTED_DIVERGE_FIXED="matmul" in tools/strict_differential.sh — a
waiver inside the instrument that proves "with the flag off, nothing
changed", for a fix nobody asked #971 for. The strict half now raises
through STRICT_DOMAIN, which is a no-op when the flag is off, so the soft
path cannot drift; the waiver is deleted and both waiver lists are empty.
The `null` read is a real defect and is recorded in ROADMAP.md as its own
change (it must decide the buffer contract for inf and NaN together and
mirror it in ouroboros aot_rt.h, whose round-187 fixture pins the raw inf
read), with SM49a/SM49b pinning today's two answers so neither moves by
accident.

Phase D — the -1 and falsy families (#1008) were already loud on the
parent (index_of/ord/list_index_of, file_exists/is_dir/read_text/...);
this pins their documented sentinels for a valid-but-absent input in
both modes (index_of miss -1, file_exists of a missing path 0, ...) and
converts the wrong-type launderers `--sweep` still listed: split, the
three scan_* builtins, tokenize_ids/tokenize_with_names, token_name,
channel_closed, f64_to_bytes, buffer, json_build, sort, random_int,
random_hex (the taped ones via ARG_GUARD_TAPED). Sweep QUIET rows 89 -> 75;
the remainder are argument-ignoring builtins, dict-valid ones, and the
`return make_null()` population the classifier header keeps out of scope.

[99s] under load. The section ran the differential, threw the failing
run's output away and re-ran the tool for a diagnostic, so a
non-deterministic red printed someone else's green report: a full-suite
log from 2026-09-06 has "FAIL: a guard went silent..." followed by a
clean report ending in "OK". It now captures ONE run and prints that
run's own output. The tool grew the two things that can make a loaded box
produce a finding about code that is fine: a probe whose process died on
a signal or could not be exec'd is its own DID NOT RUN bucket naming the
exit status (it was scored "raised by the wrong guard"), and the EXIT
trap prints ABORTED when the script exits before its verdict line
instead of exiting nonzero in silence. Nothing is retried and a crash
stays red. 180 standalone runs (idle, alongside a full suite, and under
added fork pressure) did not reproduce the red, so the cause is not
named here — the next occurrence names itself.

Regression tests: tests/test_strict_math.sh SM36-SM84 (85 rows; SM49 is
now SM49a/SM49b — strict on: raises
naming the builtin and the position; strict off: byte-identical; the
answers pinned in both modes; catchability as kind `value`; the JIT-off
arm), plus a probe/pin/valid row per site in tools/strict_differential.sh,
whose cross-check derives guarded names from STRICT_REQUIRE,
STRICT_DOMAIN and num_guard_named as well as ARG_GUARD (a new site of any
spelling without a probe goes red in [99s]). Planted faults: dropping the
matmul STRICT_DOMAIN turns SM55 red and [99s] SILENT; restoring the
draft-1 collapse turns SM49b red and the two-binary differential
"differing: 1"; dropping the json_path strict block, op_pow's
num_guard_named or split's STRICT_REQUIRE turns SM38/SM50/SM69 red. The
gate hardening is proven the same way: a killed run prints ABORTED, and a
probe binary that exits 139 reports "abs — killed by signal 11 — a crash,
not a guard" instead of "misattributed".

Docs: SPEC.md strict section lists the three classes and the matmul
buffer asymmetry; BUILTINS.md rows for every touched builtin;
LANGUAGE_CONTRACT.md numbers; llms.txt; the write-eigenscript skill.
ROADMAP.md records three deferrals as decisions: value-level invalidity
taint (positional attribution executed and refuted; the bit must travel
with the value and on the tape), the raw non-finite in a matmul buffer
result, and flipping strict to default (94 consumer entry points: 0
change status under strict; the runtime's own suite pins the soft
answers; the AOT mirror must flip first).

Residuals: ouroboros aot_rt.h carries its own inlined num_guard
(aot_num_guard_inl) and aot_tensor_matmul (raw kernel, deliberately
byte-identical to ne_matmul_buf) — the strict NaN raise needs mirroring
there; every other flipped arm is a runtime builtin the AOT links. No
waiver is in force in tools/strict_differential.sh.

Closes #971

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
(cherry picked from commit 95779d9ee1fdbd50816286101ae2d669950bc00c)
…er element; document what carries a trajectory; make every lint message UTF-8-safe (#1048)

Observer trajectory is keyed to an environment slot (`env_obs_slot`), never
to a Value, so a dict field or list element carries no history and the
obvious per-entity read

    loop while i < n:
        local q is fleet[i][2]        # ONE binding, rebound n times
        if diverging of q: ...

manufactures verdicts: the slot's window is the round-robin interleave of
every entity it visits. Repro on v0.43.0 (two entities, one decaying by
x0.9 per step, one sign-flipping, 40 steps):

    shared binding entity 0: oscillating      # the DECAYING entity
    named bindings a: improving   b: oscillating
    for-body local entity 0/1: equilibrium
    closure entity 0: improving   1: oscillating
    $ eigenscript --lint p1.eigs -> no issues found

After, on the same file:

    p1.eigs:8: warning[W024]: 'q' is rebound from 'fleet[..][..]' each
    iteration: 'report_value of q' judges the round-robin of every element
    it visits, not one entity (#1048) — use one named binding or one
    closure per entity

The rule (src/lint.c, `check_container_rebind`), per loop with innermost-
owner attribution: a name (re)bound from a PROJECTION of an element that
walks the loop — `fleet[i][2]`, `chans[i].a`, `for ent in fleet: ent.v`, a
destructure `[n, k, v] is fleet[i]`, or a field of a base rebound from such
an element (`ch is chans[i]` then `ch.a`), transitively — plus an observer
read of it (`<predicate> of q`, `report`/`report_value`/`observe`/
`trajectory of q`) anywhere in that loop (nested function bodies excluded).
The projection may sit under arithmetic whose other operands are inert (a
literal, a name the loop never assigns, another projection), because that is
the spelling the reporting consumer ships: phugoid rung 4 writes `local qobs
is fleet[i][2] + 0.0`. An accumulator (`total is total + fleet[i].v`) is not
that shape and stays silent, and neither is a base rebound from a call
(`s is step of s`, then `s[0] + 0.0` — one entity over time). A flat `xs[i]`
deliberately does NOT fire: subscripting a scalar list by the counter is how
lib/experiment.eigs and lib/simulation.eigs replay a recorded series through
one binding, and that IS one trajectory (a per-entity scalar list read the
same way is the named residual).

The asymmetry from the issue's last comment, measured with `when is q`
rather than taken from the report: a module-level `for`-body `local` is
fresh each iteration on every tier (closure in body, interrogated binder,
nested in if/try/match, loaded module: when=1) so every read answers
`equilibrium`; inside ANY function it is a frame slot that persists
(when=30) and interleaves like a `loop while` binding; a plain `for`-body
assignment creates in the enclosing scope and persists. W024 names each case
with its own text and both runtime facts are pinned by the test.

The first cut of W024 introduced a defect of its own. `LintWarning.message`
is 256 bytes and W024 is the first rule to interpolate an unbounded
identifier twice, so a ~37-character name (`_cumulative_mean_normalized_
difference` = 38; `diagnostic_header_unterminated_text_concat` = 42) pushed
the message over and `vsnprintf` cut it INSIDE the em dash of the remedy
clause:

    $ eigenscript --lint --json long37.eigs | python3 -c "import sys,json; json.loads(sys.stdin.buffer.read())"
    UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe2 in position 332

`jq` hides exactly this (it substitutes U+FFFD), and `eigenlsp` publishes the
same strings over JSON-RPC. Separately `w024_render`'s `char[32]` buffer cut
the container spelling and dropped its `[..][..]`, so a 41-character list
name read `rebound from 'diagnostic_header_unterminated_'` — a name that
appears nowhere in the program, describing the whole list rather than a
projection of one element.

The message is not what gets cut; the IDENTIFIERS are. `w024_emit` renders
with an identifier budget that shrinks (128 -> 8) until the whole message
fits, and each identifier is clipped with a MIDDLE ellipsis on UTF-8
boundaries. The direction is deliberate: a clipped identifier is recoverable
(the diagnostic cites the line and the name is in the source), a clipped
remedy is not, and a middle ellipsis is what keeps `fleet[..][..]` from
degrading to `fleet`. `w024_render` now measures the suffix chain first and
gives the base name what is left, so the projection marker survives any base
length.

Underneath that, two chokepoints make the guarantee hold for rules that do
not exist yet: `lint_vdiag` renders into a scratch and copies through
`lint_copy_utf8`, which truncates on a character boundary and marks the cut
with `...`; `lint_json_escape` clips whole characters only. So a message can
be shortened but never halved mid-character, whatever it interpolates.

**This was never W024-only.** Sweeping identifier length 1..250 against
v0.43.0 — the release before this change — three shipped rules already emit
invalid UTF-8 on BOTH channels: W015 at a 74-character name, W023 at 161-162,
W018 at 198-199 (W024 at 56). Each band is one or two lengths wide out of
250, which is why "no pre-existing rule is long enough" was the wrong
argument and why the gate sweeps instead of sampling.

Gate: `tools/lint_message_utf8_check.sh` (suite section `[81u]`, ~20s) drives
every documented code with a 200-character identifier from
`tests/lint_utf8/`, decodes `--lint --json` strictly with python3 (never jq),
requires each fixture to actually PRODUCE its code, checks the registry in
three directions (documented -> fixture-or-pinned-exemption, fixture -> doc
row, and code emitted by the lint TUs -> doc row, which is what makes a
future rule enrol), re-verifies each of the 8 pinned exemptions (the empty-
block codes are unreachable because the parser demands a statement — the
probe re-checks that), sweeps the four longest messages over 250 identifier
lengths on both channels, and asserts the two chokepoints are still the only
writers. `--selftest` plants six faults and requires each to be caught.

Regression tests (tests/test_lint.sh, section [81], 232 checks): the issue's
shape, the four other spellings, the three arithmetic spellings, the runtime
proof (shared -> `oscillating`, named -> `improving` for the same entity),
the named-binding and closure forms silent, the accumulator and call-rebound
negatives silent, six correct negatives silent with exit 0, the module/
function `for` variants, `when is` = 1 vs 30, `# lint: allow W024`, the 38-
and 200-character cases decoded strictly on both channels with the remedy
clause and the `[..][..]` suffix asserted present, the lexer's rejection of a
non-ASCII identifier (which is why every fixture is ASCII), and the
PREDICATES.md closure recipe EXTRACTED from the doc — not copied — with a
vacuity guard, executed (prints `improving oscillating`), linted clean, and
its printed-output comment pinned. Planted faults: reverting both chokepoints
and the shrink-to-fit reddens 5 test checks and 5 gate checks including the
sweep at len=56 on both channels; restoring the pre-fix `w024_render` reddens
exactly one check and reproduces `rebound from 'diagnostic_header_
unterminated_'`; unregistering the check reddens 13+3 positives with every
negative still green; dropping only the arithmetic descent reddens exactly
the 3 arithmetic checks.

Sweeps with `--lint`: 507 in-tree `.eigs` (lib/ tests/ examples/, excluding
the deliberate fixtures in tests/lint_utf8/) — output byte-identical to the
v0.43.0 baseline binary, 0 W024; 721 `.eigs` across the 15 consumer repos on
this box — 2 hits, both the originating code (phugoid `swarm.eigs:98,130`,
`tests/swarm_profile.eigs:65`).

Docs: DIAGNOSTICS.md W024 row and the `--json` contract paragraph (every
message is valid UTF-8; a clip lands on a character boundary and is marked
`...`; rules that interpolate unbounded text budget it themselves);
PREDICATES.md "What carries a trajectory" (the table, the closure-per-entity
recipe); OBSERVER.md pointer table; llms.txt one line; ROADMAP.md
"Container-keyed observer trajectory" with the mechanism, the ask, the layers
it touches (compiler operand forms, VM observer updates and the #915 reader
scan, JIT inline caches, tape/step/DAP/SIGUSR1, the AOT mirror) and the open
design questions. Container-keyed trajectory itself is not built in this
round.

Closes #1048

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
(cherry picked from commit ad79dcb73cf1e1c1dc130e19a06ab8896ea39582)
…#1057)

`import M` bound a SHALLOW COPY of M's top-level bindings, so whether an
importer saw live state depended on the VALUE'S TYPE: a dict or list was
shared by reference and tracked, a number or string was frozen at import
time and went silently stale, and `M.x is v` reached only the copy. The
rule users had to learn — "module state must be boxed in a container or it
goes stale in your importer" — had no principle behind it, and its failure
mode was a wrong number rather than an error. Nine stdlib modules exist in
that shape.

Minimal repro (baseline v0.43.0 / a6c50fb):

    # q4.eigs: ctr is 0 / define bump() as: ctr is ctr + 1
    #                   / define peek() as: return ctr
    import q4
    q4.bump of null
    q4.bump of null
    print of f"module internal view: {q4.peek of null}"   -> 2
    print of f"dict view:            {q4.ctr}"            -> 0    <-- stale
    import q3            # v is 1 / define peek() as: return v
    q3.v is 42
    print of f"namespace read: {q3.v}"                    -> 42
    print of f"module peek:    {q3.peek of null}"         -> 1    <-- never saw it

After: 2 / 2 and 42 / 42. The container probe (`_q5`) is unchanged at 1 / 1.

Design (shape (i) of the two the issue sketched; recorded here because the
alternative was priced): the namespace stays a VAL_DICT and gains a flag,
`Value::module_ns`, plus an OWNING backref to the module `Env`.

  - The flag byte lands in struct Value's existing TAIL PADDING, so
    sizeof(Value) is unchanged at 72 bytes (measured both sides). The Env*
    itself lives in a small side table keyed by the dict pointer
    (eigs_module_ns_env) rather than in the union, which would have grown
    every Value in the system by 8 bytes for a feature a handful of dicts
    use.
  - A field READ projects the module's current binding into the dict slot
    and returns it (`dict_get_hashed`); an exclusive untracked numeric
    mirror is refreshed in place, so a hot `M.CONST` read allocates
    nothing. A field WRITE goes through to the module binding
    (`dict_set_hashed` -> `env_set_local_hashed`) and mirrors into the dict.
  - The dict's own storage is kept as that mirror, so a namespace is still
    an ordinary dict for every whole-dict reader — `keys`, `values`, `len`,
    `str`, `json_encode`, equality, truthiness, channel send — each of
    which now calls `eigs_module_ns_sync` first.
  - `_`-prefixed module bindings stay private: never projected, never
    written.
  - VAL_MODULE (shape (ii)) was rejected: it puts a new arm on every
    closed-enum switch AND on every `type != VAL_DICT` comparison, and
    -Werror=switch only enumerates the first kind.

Ownership and the collector: attach takes `env_incref`, so dict -> env is a
new OWNED edge and gets ONE row in GC_EDGE_TABLE (walked by
GC_FOR_EACH_CHILD, cleared by gc_clear_node); `free_value` is the non-cycle
mirror of that row. `docs/CLOSURE_CYCLE_GC.md` lists the edge.

Caches: the interpreter's `dict_get_cached` / `dict_set_cached` /
`dict_set_cached_immediate` bail on the flag (their slots are the mirror),
and the JIT's inline dict probe carries the same guard — one
`testb $1, module_ns(%rdi)` + `jne` right after its `type == VAL_DICT`
check, per the "inline fast paths mirror the interpreter" rule.
`module_ns_project` / `_sync` take `env_shared_lock` around the env load
exactly as `env_get_hashed_slot` does (#607).

Regression test: `tests/test_module_live_view.eigs` (30 checks, wired into
run_all_tests.sh) with companion modules `tests/modlive_{counter,box,plain,
outer}.eigs`. It pins the issue's three probes plus the controls that must
NOT move: containers still shared by reference, a value read OUT of a
namespace is a value and not an alias, functions callable as `M.f of x` and
extractable as values, `type of M` still "dict", `keys`/`len`/`has_key`,
`_`-privacy, `str`/`json_encode` current, the module cache returning the
same live state on re-import, a new key written through the namespace
binding in the module, nested imports live through the outer namespace, and
the `load_file` and `eval` roads unchanged.

Planted fault: removing the `eigs_module_ns_attach` call in OP_IMPORT
restores snapshot semantics and takes the section red 9/30 — the same nine
checks the baseline binary fails.

`lib/eigen.eigs` mirrors the rule (a name-keyed module-env registry
consulted by `dot`/`dot_assign`). It is not reachable today: that
interpreter's `import` reads its module with
`read_text of ("lib/" + name + ".eigs")`, which resolves to "" instead of
raising, so every meta-interpreted import has produced an EMPTY namespace
since long before this change (`eigen_run of "import math\nkeys of math"`
-> `[]` on the baseline too). Separate gap, noted in the file.

Validation: release suite 4279/4279 (0 FAIL); ASan+UBSan suite with
detect_leaks=1 4280/4280 (0 FAIL, no leak-tally NOTE — tally still 0,
section [87] closure-cycles leak-clean); `tools/road_diff.sh`
fixtures=21 runs=198 failures=0; `tools/jit_diff.sh` OK (231 programs x
{jit, osr}, 0 ledgered); `tools/replay_diff.sh` OK (231 programs, 0
ledgered); `make jit-smoke` green; the three probes byte-identical under
`EIGS_JIT_OFF=1` and `EIGS_JIT_OSR_THRESHOLD=1`.

Closes #1057

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
(cherry picked from commit 6278a480ca957f1af03e14faa0dd7bfd33aa74d9)
…ge (#1048)

The integration cherry-picked this issue's round-2 commit. Round 3 replaced
the per-message repair with a chokepoint one after a blind critic measured the
real blast radius: on v0.43.0, 512 of 1524 shape x length combinations came
back malformed. Any non-ASCII byte in a source file, or in a filename, was
enough to make `--lint --json` and the language server's JSON-RPC frames
undecodable — not just the new W024 message that the issue was filed about.

The truncation happens where a diagnostic is copied into a fixed buffer, so
that is where it is fixed:

- `eigs_utf8_step` / `eigs_utf8_sanitize` (src/strbuf.c) — a scanner that
  knows where a sequence starts and a sanitiser that will not leave a partial
  one behind. Everything that truncates a message now goes through them.
- The lexer spells an unexpected byte as `\xNN` rather than emitting the raw
  byte, so an invalid byte in the SOURCE cannot become an invalid byte in the
  OUTPUT.
- The parser's caret line counts columns in characters, and renders `?` where
  it cannot place a caret inside a multi-byte character.
- `lint_json_escape` is the last gate on the JSON channel.

Gated by `tools/lint_source_byte_sweep.py`, which is in run_all_tests.sh:
138 source-byte cases x 4 shapes plus 3 path shapes across both channels
(1125 runs), each decoded with a STRICT decoder. `jq` is not usable as the
oracle here — it substitutes U+FFFD for malformed input and reports success,
which is how this survived to a release.

Six planted faults confirm the sweep is gating: reverting each chokepoint
individually turns it red, and for the reason named.

  tests/test_lint.sh                 246 passed, 0 failed
  tools/lint_message_utf8_check.sh   39 checks, 30 codes, 8 pinned exemptions
                                     sweep 2000 runs + bytes 1125 runs, all decode

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
… counts, pin the #1057 meta gap

Fixups for landing #973, #971, #1048 and #1057 on top of the eighteen already
integrated. The substantive one is a semantic conflict git could not see.

**gather (#973 vs #1093).** Both branches independently added a buffer path to
the same tensor builtins and disagreed about an out-of-range index: #1093
folded it to 0.0 to mirror the existing list path, #973 raised `index_range`.
Settled on the raise, in EVERY form, and the list path moved with it — the
rationale is at the definition in src/builtins_tensor.c so it is read where it
is relied on. In short: #1093's contract is that a buffer is accepted wherever
a flat numeric list is, so the answer must not depend on the container;
`scatter_add` already raises on exactly this index, and folding here would make
the forward pass quiet while the backward pass is loud for the same bad input.
Both branches' fixtures now assert the raise; neither was deleted.

Measured before and after on the same four shapes (probe in the integration
notes): v0.43.0 answered `[1, 0]` / `0` / `0` / `0`, the merged tree answers
`RAISE:index_range` on all four.

**Builtin counts 345 -> 348** in README.md and docs/BUILTINS.md. These are
gated against `eigenscript --api` by tools/doc_drift_check.sh check 8 (#1118),
which is why they had to move: #973 adds three core builtins. The gate prints
the numbers to paste. 261 core + 87 extensions.

**#1057 meta-parity canary.** The C evaluator now makes a namespace a live
view of the module env. The meta-interpreter in lib/eigen.eigs cannot be given
a parity row for it, because its `import` does not resolve a module at all —
it reads a path with `read_text`, which answers "" for a path it cannot open,
so every meta-interpreted import yields an empty namespace. Pinned as a
matched-bug canary instead: the row goes red the day `import` starts working
there, and whoever fixes it adds the real live-view rows next to it.

**Docstring** for gather in src/lsp_builtin_index.h follows the new contract.

Gates on the integrated tree:
  tools/suite_label_check.sh   254 labelled echo lines, no two sections share a label
  tools/doc_drift_check.sh     clean

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
Two breaking changes (gather raises index_range in every form; a module
namespace is a live view rather than a snapshot), nine additions, seven
fixes and three changes. Each entry states the behaviour that moved, not
the patch that moved it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…ite stops discarding its subject's exit status (#1121)

`eigenscript --lint` leaked on every run inside a project whose `eigs.json`
contains any nested value. `"deps": {}` is enough, and that is in the manifest
every repo in the fleet ships. Not a regression: the v0.43.0 release binary
leaks the identical 584 bytes in 6 allocations.

    mkdir -p /tmp/ej/sub && printf 'x is 1\n' > /tmp/ej/sub/p.eigs && cd /tmp/ej
    echo '{"name":"t"}'                > eigs.json ; eigenscript --lint sub/p.eigs   # clean
    echo '{"name":"t","deps":{}}'      > eigs.json ; eigenscript --lint sub/p.eigs   # 584 B / 6
    echo '{"name":"t","lint":{"allow":{"sub/p.eigs":["W001"]}}}' > eigs.json
                                                    eigenscript --lint sub/p.eigs   # 1440 B / 16

ROOT. Not the JSON parser and not `eigs_json_lint_allow_for` — both are
correct. `val_decref` on a LIST or DICT does not free it; it registers a
cycle-collector candidate that only `gc_collect_at_exit` reclaims. The run path
calls that before releasing the global. The `--lint` early return did not: it
went straight from `eigenscript_lint` to `eigs_thread_detach` and
`eigs_state_destroy`. That is exactly why a flat document was clean and a
nested one was not — a scalar child dies by refcount, a container child needs
the sweep that never ran.

FIX, at the class rather than the instance. All eight pre-global `--<mode>`
returns (`--lint` x3, `--api`, `--pkg`, `--test`, the `-e` usage error, the
unreadable-file error) go through one `MODE_EXIT` that drains first.
`gc_collect_at_exit` tolerates a NULL global — it guards every deref, and the
module-cache clear plus `gc_collect_cycles` run unconditionally. The macro is
`#undef`'d where the run path's global begins, so using the weaker NULL form
past that point is a compile error rather than a quieter drain. The two
post-global parse/compile-error returns now collect against `global` like the
success returns do; measured a no-op today, and the class stays closed if that
changes.

Only `--lint` leaks today. The other seven were measured clean because nothing
on them currently allocates a container. They are routed through the same exit
so the next mode that does cannot reintroduce this by forgetting a line.

THE GATE IS THE OTHER HALF, AND IT IS THE PART THAT LASTS.
`tests/test_lint.sh:1399` — the #455 per-file allow-list block — has created a
temp project with a three-deep `eigs.json` and linted inside it, five times per
run, since #455. It ran the leaking shape every time. Measured on that exact
block with the ASan binary:

    rc of the linter under ASan: 1
    SUMMARY: AddressSanitizer: 1440 byte(s) leaked in 16 allocations
    W017 occurrences in the captured output: 0

The linter exited 1. `OUT=$($EIGS --lint ... 2>&1 || true)` threw that away, the
leak report landed in `$OUT` via `2>&1`, and `check_not_contains ... "W017"` was
satisfied because a LeakSanitizer report does not contain the string `W017`. The
check passed, for the right reason about the wrong question — the same class as
gating a proof check on grepping for a success line instead of on the checker's
exit code.

Rewriting 151 call sites to capture rc would churn every assertion in the file
and still cover only the calls that exist today. Instead the file points the
sanitizer runtime at a log DIRECTORY (`log_path`), so a diagnostic from ANY
child — present or future, `--lint` or not — leaves a file behind instead of
being folded into a captured string, and one ledger check at the bottom turns
any such file into a FAIL that prints its `SUMMARY:` line. Appended to
`ASAN_OPTIONS`, never assigned, so the harness's `detect_leaks=1` survives. In a
release build nothing writes those files and the check passes trivially, which
is correct: the ASan leg is the one holding the instrument.

PLANTED FAULT. Removing only the `gc_collect_at_exit(NULL)` line from
`MODE_EXIT` and rebuilding:

    ASan     Results: 243 passed, 1 failed, 244 total
             FAIL: #1121 3 sanitizer diagnostic(s) from linter invocations in this file
                 --- asan.3124 --- SUMMARY: AddressSanitizer: 1440 byte(s) leaked in 16 allocation(s).
                 --- asan.5527 --- SUMMARY: AddressSanitizer: 1440 byte(s) leaked in 16 allocation(s).
                 --- asan.5829 --- SUMMARY: AddressSanitizer: 1440 byte(s) leaked in 16 allocation(s).

Restored: ASan 244 passed, 0 failed; release 247 passed, 0 failed. The 247/244
split between the two builds predates this change (the planted run also totalled
244).

The minimal repro is clean on all three shapes after the fix, including a
four-deep `{"a":{"b":{"c":{"d":[1,[2,{"e":3}]]}}},"deps":{}}`, and the linter's
exit status is 0 where it was 1.

Closes #1121

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…1057 on both evaluators

Round-2 fixup on top of 3ec5e1b, which landed a "#1057 canary" in
tests/test_meta_parity.eigs whose stated premise was false and which made that
file fail when it is run from the repo root.

**What was wrong.** 3ec5e1b's commit body, the test comment and the comment in
lib/eigen.eigs all said the meta-interpreter's `import` "does not resolve a
module at all", so "every meta-interpreted import produces an EMPTY namespace"
and the #1057 mirror "is not currently reachable". It does resolve — whenever
`lib/<name>.eigs` happens to be reachable from the process's WORKING DIRECTORY.
The canary passed only because tests/run_all_tests.sh runs this file with cwd
`src/`, where `lib/math.eigs` does not exist:

    $ ./src/eigenscript tests/test_meta_parity.eigs        # from the repo root
    FAIL: #1057 canary: meta-interpreted import is still an empty namespace:
          got ["sign","max_val", ... ,"log10"], want []
    92/93 passed
    $ (cd src && ./eigenscript ../tests/test_meta_parity.eigs)
    All tests passed

**Root cause, and the fix.** `eigen.eigs`'s import read
`read_text of ("lib/" + name + ".eigs")`. That is working-directory-relative,
and `read_text` answers `""` for an absent path (its documented answer), so a
module that did not resolve was parsed as an empty program and bound as an
EMPTY namespace instead of raising. `_eigen_module_path` now mirrors the C
resolver's chain with the two bases script code has — the working directory,
then the stdlib root beside `exe_path` — decides with `file_exists`, and the
import raises when nothing resolves. Measured (cwd `src/`, the suite's cwd):

    eigen_run of "import log\nhas_key of [log, \"log_info\"]"
      before 0            after 1     (C evaluator: 1)
    eigen_run of "import no_such_module_1057\n1"
      before 1 (no raise) after raise (C evaluator: raises)

**A real divergence the parity rows then found.** The #1057 mirror registers
the module env under the imported NAME, so it kept answering from the module
after the name was rebound to an ordinary dict:

    import log
    log is {}
    log.log_info     meta -> the module's log_info function;  C -> null

The C evaluator carries the module env on the namespace VALUE, so rebinding
drops the view. `_ns_env_for` now takes the evaluated target and checks it
against the namespace dict that import produced.

**What the meta-parity test asserts now** (11 rules x both evaluators, plus the
cwd gate and one gap canary; 93 -> 114 checks): a public binding is in the
namespace; a `_` binding reads null and is not a key; a key written through the
namespace reads back and becomes a key; a `_` key written through the namespace
stays in the namespace; an unresolvable module raises; rebinding the name drops
both the view and the keys; and the same import answers the stdlib module from
a cwd with no `lib/` (the file now passes from `.`, `tests/`, `src/` and
`/tmp`).

**What still has no row, named honestly.** The LIVE half of the rule — a module
FUNCTION mutating a module global and the importer seeing it — cannot be
exercised in the meta-interpreter, because its functions cannot read module
globals at all: `eigen_eval`'s "call" arm parents the call env on the CALLER's
env, not on the definition env ("func" stores no env, deliberately, to keep
function values acyclic). `eigen_run of "import log\nlog.log_level of \"warn\""`
throws "undefined variable '_log_level_num'"; the C evaluator runs it. That is
older than #1057 and separate from it, and it is pinned as a labelled gap
canary that goes red when either side moves. Closing it means closing over the
definition env for every meta function — its own change, with its own rows.

Planted faults (each undone one at a time, suite cwd `src/`):
  resolver reverted to cwd-relative read_text  -> 111/114, 3 rows red
  resolver kept, raise removed                 -> 113/114, the raise row red
  `_`-privacy guard removed from _ns_env_for   -> 113/114, the `_` read row red
  identity guard removed from _ns_env_for      -> 113/114, the rebind row red
Restored: 114/114 from every cwd above.

Files: lib/eigen.eigs (the resolver, the identity guard, the corrected
comments), tests/test_meta_parity.eigs (93 -> 114 checks),
tests/run_all_tests.sh ([107]'s label names #1057 and the cwd requirement),
docs/STDLIB.md (the lib/eigen.eigs section), CHANGELOG.md (the #1057 bullet).
No C file changes, so no rebuild is required to reproduce any of the above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…pefail (#1120)

[99s] flaked red on a green tree under load, ~10% of runs, and the verdict it
printed was refuted by the evidence printed beside it: a probe scored "raised
by the wrong guard" with a diagnostic containing that guard's own message.

CAUSE, reproduced. The probe matcher was `printf '%s' "$s" | grep -qF "$pat"`
under `set -o pipefail`. `grep -q` exits the instant it matches and closes the
read end; the writer is then still writing, takes SIGPIPE and exits 141; and
pipefail reports the PIPELINE as 141 — a failed match — while grep's own status
was 0, MATCHED. The score and the diagnostic read the SAME shell variable
microseconds apart, so nothing acting on the child process can make them
disagree; only the matcher can.

  isolated, load 12-16:  21 false no-matches in 20,000 evaluations of
                         `printf | grep -qF` on a 157-byte capture, all rc=141
  this tool, pipe form restored at the matcher:  18 red in 186 runs, every one
                         a misattribution, spread over 18 DIFFERENT probes
  this tool, shell form:  0 red in 300 runs, and 0 in 250 on the final tree
  deterministic:         make the capture exceed the pipe buffer with the
                         needle on a complete first line — 40/40 no-match with
                         the pipe form, 0/40 with the shell form

FIX. No pipeline decides anything in this script any more. Three fork-free
matchers (str_has / str_has_line / str_has_word) replace every `grep -q`
verdict site: the probe attribution, the release-source membership scan, the
EXPECTED_DIVERGE and spent-waiver lookups, the stale-UNPROBEABLE check and the
sweep's SKIP regex. The two probe-table lookups that read through `grep -F |
head -1` become probe_prog_for. The two surviving greps (`-vxF -f` set
difference, `-c` count) consume their input to EOF and cannot SIGPIPE a writer;
that is now stated where they live.

PROOF. `--selftest` (new, ~0.1s, needs no binary) pins the matchers on ordinary
input AND on a capture larger than the pipe buffer, shows a deliberately broken
matcher failing the same battery, and reproduces the race deterministically by
running the construct it replaced on the identical input — growing the pad
until the writer must block, so it depends on no platform constant. [99s] runs
it before the differential and says which half failed. Planted faults verified:
a probe with an unmatchable expect goes red with `recheck: ABSENT`; the pipe
form restored goes red 6/60 under load with `recheck: PRESENT`.

TWO NEIGHBOURING WAYS TO GET A CONFUSING RED, closed while here:
- the variant-only presence check scored a run that DID NOT HAPPEN (exec
  failure, signal) as "builtin present", after which the probe ran and was
  scored "raised by the wrong guard: undefined variable" — an environment fact
  charged to a guard. It now goes to the DID NOT RUN bucket. Verified with a
  wrapper that SIGKILLs that one presence probe.
- $NEW is a path reopened for each of ~130 probes, and src/eigenscript is a
  hard link to build/<variant>/ (#740): a `make` in the tree swaps the binary
  mid-run. The tool now fingerprints its subject at the start and re-checks at
  the end (the #681 shape), and says so loudly instead of blaming a guard.
  Caught a real one on its first soak — section [99d]'s own deliberate swap,
  in a concurrent suite in the same worktree.

EVIDENCE FOR NEXT TIME. A run that finds something writes each finding's WHOLE
capture, its exit status, the pattern matched against, and whether that pattern
is in the bytes after all, to $EIGS_DIFF_EVIDENCE (default a PID-stamped dir),
and names the directory in its output. A file saying `recheck: PRESENT` on a
misattribution IS the #1120 signature, said in those words. A green run writes
nothing. Signal deaths are named (SIGTERM, not 143), with the one thing $? can
never tell you — exit(128+N) vs a signal — stated rather than assumed.

RULED OUT, with the command in each case: stdout/stderr interleaving in the
capture (30/30 intact with 183 KB of stdout ahead of the stderr error; one
process, unbuffered stderr, one write per fprintf); a shared or fixed temp path
across worktrees (mktemp -d only, every probe resource under it); locale
(grep -F and case both match with invalid UTF-8, LC_ALL=C and C.UTF-8); message
truncation (longest expect is 42 chars, emitted whole on one line); a signalled
probe (before the message the capture is empty, so the diagnostic would be too;
after it, the old matcher MATCHED and over-credited a raise — the opposite
symptom); a mid-run binary swap (changes what the capture contains, which both
readers then see alike).

Closes #1120

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
…s back to `find` under load; the waiver mechanism goes (#971 round 2)

Three findings on the round-1 commit, landed together because the third is what
makes the first two visible. Reconciled onto the integration, which already
carries #1120's rewrite of the same tool.

**1. A leak on every new strict-raise path.** `STRICT_REQUIRE` expands to
`rt_error(...); return make_null();`, so it must sit BEFORE anything the
function has allocated and still owns. Three Phase D guards sat one line BELOW
`Value *out = make_list(128);`:

    $ EIGS_STRICT=1 ./src/eigenscript -e 'print of (scan_ints of 42)'   # ASan
    Error line 1: scan_ints: expected a string or [string, comment_marker]
    SUMMARY: AddressSanitizer: 1096 byte(s) leaked in 2 allocation(s)

identically for `scan_tokens` and `scan_int_tokens`. `str` is computed above
both lines, so the fix is a pure reorder and the soft path is untouched — the
flag-off answer is still an empty list. The placement rule is now stated at
`STRICT_REQUIRE`'s definition, with the alternative for functions whose inputs
do not allow the reorder: free explicitly first, as `builtin_write_bytes` does
with its raw buffer.

**2. Nothing caught it, and that is the more important half.** A strict raise
ALREADY exits non-zero, so LeakSanitizer's exit does not change the process
status and a leaking guard is indistinguishable from a working one.
`tests/test_strict_math.sh` drives this exact path and reported 85/85 PASS
under ASan while three of its rows leaked. The rows are now leak-visible:
`leak_clean` reads the LeakSanitizer text out of the output the assertion
already captures, so every strict raise in the file is leak-gated whenever an
ASan build runs it, and it is a no-op under release. `scan_ints` had a row; its
two siblings had none and leaked with no coverage at all, so SM85-SM88 add
them. 85 -> 89 checks.

**3. The `[99i]` / `[99p]` flake, root-caused.** `werror_switch_check.sh` chose
its script-enrollment population by whether `git ls-files` produced output, and
fell back to `find` when it did not — including when the fork lost a race for
memory on a loaded box. `find` then enumerates UNTRACKED files: build output,
scratch dirs, another run's extracted fault tree. Any of those carrying a
compile line is reported as an unenrolled script, so the gate goes red naming a
file that is not in the repository. That is the shape observed twice tonight:
the audit half printed its own `gate OK` and the section still failed, because
the self-test half tripped this. The fallback is now chosen by WHERE WE ARE —
inside a work tree `git ls-files` is authoritative and an empty result is an
ERROR; outside one (the `git archive | tar -x` fault trees, which are
extractions and not repositories) `find` is correct and is the only option.

**4. The divergence-waiver mechanism is deleted.** `differing-when-off: 0` is
the single claim this tool exists to make, and an exemption path is that claim
with a hole in it. Both of the mechanism's failure modes had already been paid
for: waivers are PR-scoped with nothing enforcing it (#1016 — `sign_extend`'s
waiver outlived its PR and the documented pre-land command returned FAIL on a
clean tree for every run between #1015 and #1016, while the half it measures
was green), and a waiver hides exactly what the tool is for (#971 almost kept
one for `matmul`'s buffer path). The identical-when-off half is exercised the
other way instead: put a default-path change back into a converted site and it
reports `differing: 1` and FAILs, with no list that could absorb it.

RECONCILIATION with #1120. That rewrite converted every `grep -q` verdict site
to a fork-free matcher, including the expected-divergence and spent-waiver
lookups. Two of those sites are inside the code this commit deletes, so the
deletion wins there and #1120's matchers stand everywhere else. Both halves
were then exercised together.

MEASURED, after:

    scan_ints / scan_tokens / scan_int_tokens under ASan+EIGS_STRICT   all CLEAN
    tests/test_strict_math.sh under ASan                               89 passed, 0 failed
    tools/strict_differential.sh --selftest                            checks=11 failed=0, OK
    tools/strict_differential.sh --no-baseline                         raises-under-strict: 99
                                                                       silent: 0  misattributed: 0
                                                                       answer-pins held: 31  broken: 0

PLANTED FAULT, executed on this tree: moving the `scan_tokens` guard back below
its `make_list` and rebuilding gives

    FAIL: SM85 strict scan_tokens(num) raises (LEAKED on this path:
          SUMMARY: AddressSanitizer: 1096 byte(s) leaked in 2 allocation(s).)
    STRICT: 88 passed, 1 failed

— one row, naming the byte count. Restored: 89 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
#744 item 3 (per-layer headers, breaking up the 1253-line eigenscript.h
umbrella) is deliberately NOT here — it is 29 TUs plus tools/amalgamate.sh
plus the freestanding profile, and is recorded in ROADMAP.md with the
issue's measured facts. Items 1, 2, 4 and 5 land here. Everything moved is
byte-verbatim; the point is where the code lives, not what it does.

1. THE CORE NO LONGER INCLUDES AN EXTENSION'S PRIVATE HEADER.
   The two includes the issue names had migrated from eigenscript.c into
   builtins.c and were no longer dead — each supplied exactly one
   registration declaration. Repro, on a box without libpq-dev:

     $ gcc -DEIGENSCRIPT_EXT_DB=1 ... -c src/builtins.c
     src/ext_db_internal.h:10:10: fatal error: libpq-fe.h: No such file
     rc=1

   after:  rc=0. In the `full` variant 29 of 30 objects now build here and
   only ext_db.o — which genuinely needs libpq — does not.
   src/ext_register.h carries the five registrars and the two per-state
   teardowns: declarations, no extension types, no libpq, no Server struct,
   no transformer types. state.c's two hand-written externs and
   eigenscript.h's lone register_gfx_builtins declaration fold into it.
   ext_net_internal.h stays included by builtins.c and is pinned as an
   exemption: handle_table_drain reads EigsNetSock.fd and that header is
   deliberately socket-header-free for exactly that.

2. vm.c NO LONGER RE-DECLARES ANY CROSS-TU SYMBOL.
   Deleted: the block-scope externs for tokenize / parse / free_tokenlist /
   free_ast and for read_file_util (all already in eigenscript.h), and
   `extern Value g_null_singleton_external_decl` — a declaration of a symbol
   that exists nowhere in the tree. The last two file-scope externs were
   given a home instead of a comment: builtin_free_val -> vm.h (beside the
   borrow-protocol comment that already names it; builtins_tensor.c carried
   a second hand-written copy) and env_get_assign_count -> eigenscript.h.

4. TWO LEAF TUs AND TWO SPLITS.
   src/fsutil.c + src/fsutil.h: read_file_util and the resolver chain
   (resolve_eigenscript_file{,_from,_from_ex}, eigs_import_resolve,
   eigs_file_directory, eigs_file_resolve_error). The VM, the compiler's
   observer-gate pre-pass, main.c, fmt.c, lint_host.c and the embedding API
   all consume them, so all of them reached downward into a builtins TU to
   read a file. Whole-TU freestanding gate, unchanged semantics. Their
   declarations leave the eigenscript.h umbrella; seven TUs now say they
   read files by including fsutil.h.
   src/task.c + src/task.h: the #408 cooperative scheduler (TaskScheduler,
   the ready queue, virtual time, the #846 trace, the trampoline). 795 lines
   move with exactly ten intended edits — five hooks lose `static` because
   task.h declares them, three call sites go through wrappers, two comments
   are renamed. vm_execute_common's scheduler tail becomes
   task_sched_after_outermost so TaskScheduler stays private to task.c.
   The dispatch loop stays static: vm_run_ex (3131 lines) and
   vm_take_error_value are NOT promoted — vm.c exposes three one-line
   wrappers instead, so the compiler keeps its interprocedural view of the
   hot loop. The jit_helper_* ABI stays in vm.c: it consumes the static
   inline vm_push / vm_pop / vm_slot_lift, and moving it needs those
   promoted to a private header first — a different change.
   src/builtins_buf.c: the numeric-buffer, vectorized buf_*, PCM16LE and
   DEFLATE builtins out of builtins.c. Measured before moving: zero static
   symbols cross the seam in either direction.

5. ROADMAP.md carries item 3 as a design entry with the measured facts.

GATE: tools/core_ext_boundary_check.sh, wired as suite section [99i2]. Two
legs, because either alone passes while the invariant is broken. Leg A scans
every core TU (enumerated from the Makefile's SOURCES, not a copy of it) for
an include of any extension private header (enumerated from the tree), with
exemptions checked in BOTH directions so a waiver cannot outlive its
subject. Leg B compiles every core TU with all four extensions ON and a
POISONED <libpq-fe.h> first on the include path — without the poison the
probe would pass on any dev box that has libpq and only fail on the machines
the gate exists to protect. On the pre-fix tree: 3 leg-A violations and
leg B red on builtins.c. --selftest plants each fault and a stale exemption
and confirms each leg goes red on its own.

FIXED WHILE HERE, because the splits exposed them: five source lists that
nothing tied to the tree. tools/freestanding_check.sh, tools/embed_stack_soak.sh
and tools/freestanding_smoke.sh now derive their list from the Makefile (the
tools/amalgamate.sh pattern) and hard-fail on an empty derivation; web/build.sh
and tools/gen_lsp_builtin_index.sh's DOC_SRCS are updated in place. That last
one was a silent narrowing: without it the regenerated LSP builtin index drops
from 214 signature comments to 195. And tools/failsoft_classify_check.sh's
selftest planted its split-return fault by matching one hardcoded indentation;
when every such site moved to builtins_buf.c the plant became a silent no-op
and the row read as "the detector missed it" — the planter now matches any
indentation and treats planting nothing as fatal.

Behaviour-preserving, proven: tools/observer_gate_diff.sh over the whole
corpus, pre-change binary vs post-change binary, "RESULT: PASS — 532 programs
byte-identical", residual mismatches 0. bash tools/jit_diff.sh: OK (238
programs x {jit, osr}; 0 ledgered). Release suite 5052/5052, ASan+UBSan with
detect_leaks=1 5053/5053, leak tally 0. make {release,http,zlib,net,gfx,asan,
asan-gfx,asan-http,poison,lsp,dap,jit-smoke,amalgamation,embed-smoke,
embed-concurrent} all build; freestanding-check, freestanding-libc-diff,
embed_stack_soak, freestanding_smoke and the [99i] werror gate green.
`make full` cannot be built on this box: no libpq headers.

Closes #744
Closes #746

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
#1120 named the mechanism and fixed it in tools/strict_differential.sh.
The same construct decided verdicts in six more scripts that set
`pipefail`, and — found by the gate written here — in thirteen sites of
tools/werror_switch_check.sh that #1122's table missed.

The race, restated: `printf '%s' "$s" | grep -q "$pat"` exits grep the
instant it matches and closes the read end; the still-writing printf
takes SIGPIPE and exits 141; under pipefail the PIPELINE reports 141 —
a failed match — while grep's own status was 0, MATCHED. The script
then goes red printing the very bytes it says are missing.

Minimal repro (this box, bash 5.2.21), 4 MB subject, 200 evaluations:

    printf '%s' "$big" | grep -qF "abs: expected"   ->  200/200 rc=141
    grep -qF "abs: expected" <<<"$big"              ->    0/200
    str_has "$big" "abs: expected"                  ->    0/200

Two safe forms, chosen per site rather than uniformly:

  * `str_has` (bash `case`, no fork, no pipe) where the needle was a
    LITERAL under a BRE grep — 16 sites across the six scripts.
  * `grep -qX PAT <<<"$var"` where the pattern is a real regex and
    grep's line-oriented semantics are load-bearing — the 13 sites in
    werror_switch_check.sh. A here-string is one command: bash never
    blocks writing it, there is no second process to SIGPIPE, and
    pipefail is not consulted, so flags and semantics are untouched.

tools/freestanding_smoke.sh needed a split, not a substitution: its one
`grep -q "$want_sub"` was called two ways — four callers passed a plain
substring, two passed the line-anchored `^42$` / `^1$`. One substring
matcher would have served both and been silently WIDER on the anchored
pair (`check "..." 0 "42"` would then pass on output containing "426").
It is now `check` (str_has) and `check_line` (str_has_line), and each
call site says which it means.

Left alone, deliberately, with the reason recorded next to each:
grep reading a FILE (no writer to kill: test_pkg_fetch.sh's two eigs.json
checks, one of which is a real BRE), `grep -v`/`tail`/`sort` (read to
EOF), and `| head -N` diagnostics inside an already-decided FAIL branch.

New gate tools/pipefail_verdict_check.sh, enrolled as suite section
[99aa]: it scans every `*.sh` that sets pipefail and fails on an
early-exiting reader (grep -q/-l/-L/-m, head, sed q, awk exit, read) at
the end of a real pipe whose STATUS picks a branch. `--selftest` proves
both halves — it FIRES on each banned spelling and stays QUIET on each
legitimate one — and three vacuity floors make a collapsed enumeration a
failure rather than a pass. It also pins every `str_has*` copy in the
tree byte-identical to the canonical one-liner, which is what makes
strict_differential's `--selftest` (which tests those matchers' positive
AND negative behaviour) load-bearing for all of them.

Planted faults, executed per script: one converted site restored to the
`| grep -q` form, with the capture padded until the writer must block
(grown, no pipe-buffer constant assumed). Every one produced the #1120
symptom — a FAIL verdict whose own diagnostic contains the needle — and
the control (same padded capture, shell matcher) passed. Gate faults:
run in a tree with no scripts, all three vacuity floors fire; a
prefix-only str_has is caught as drift; one converted site restored in
place is named with its file, line and text.

Residuals are in the gate's header: it is a text scanner, not a bash
parser; a BARE pipeline whose status is a function's return value is not
flagged (deliberately — strict_differential's --selftest must run the
banned construct to demonstrate it); pipes inside `$( )` are skipped.

Closes #1122

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
#1007)

`src/ext_gfx.c` had no raise path at all: `grep -c rt_error src/ext_gfx.c`
was 0. ~89 `items[N]->data.num` reads happened with no type check — and
`Value`'s union overlaps `double num` with `char *str`, so a string where a
number belonged reinterpreted a pointer as a double — and ~52 `make_null()`
returns stood in for a rejected argument, indistinguishable from the same
builtin's ordinary "nothing to do" answer.

    $ cat repro.eigs
    o is gfx_open of [64, 64, "r"]
    ignore is gfx_clear of [0, 0, 0]
    ignore is gfx_rect of [0, 0, 32, 32, "255", 0, 0]
    print of f"wrong-typed red: {gfx_read of [4, 4]}"

    before (both modes):  wrong-typed red: [0, 0, 0]     rc=0
    after,  EIGS_STRICT=1: Error line 3: gfx_rect: expected [number x,
                           number y, number w, number h, number r, number g,
                           number b] and an optional number alpha   rc=1
    after,  flag off:      wrong-typed red: [0, 0, 0]     rc=0  (byte-identical)

WHAT CHANGED

- 60 ARG_GUARD / STRICT_REQUIRE sites across the whole extension: the
  drawing calls, the text calls, `gfx_fb`, `ppu_render_frame`, the audio
  generators, the mixer and the three device openers. Every guard sits above
  the `load_sdl2()` call and above any device check, so it is reachable on a
  machine with no libSDL2 and with no device — which is where CI runs; a
  guard behind an environment check is a guard that does not exist in the
  environment that lacks it. `gfx_read` and `audio_capture_open` are
  trace-recorded, so their guards also sit above the tape seam: a rejected
  argument is deterministic and must neither consume nor write a record.
- The union pun is gone in BOTH modes. An unchecked read is not behaviour
  anything can depend on; what a rejected call *draws* therefore changes
  (the parent painted at coordinate 0, in colour 0, at scale 1 — the punned
  subnormal), while every returned value stays byte-identical.
- Three rounds of blind review found the same axis three times, always on a
  different builtin: the argument CONTAINER (its arity and type) rather than
  its elements. The generators answered a short list with an empty sample
  list; the three audio *_open builtins answered a short or non-list argument
  by opening at the 44100/1 defaults and handing back a REAL DEVICE ID
  (`audio_stream_open of [48000]` -> 2, so the caller that asked for 48000
  was told it got 48000); `audio_play` / `audio_stream_push` /
  `audio_play_loop` answered a non-list `samples` with the documented
  "nothing to play" 0 while a wrong-typed ELEMENT of a list already raised.
  All three are closed, and the last of them is why the fourth item below
  exists: a hand-written probe row per bug fixes the instance, not the
  pattern.

GATES

- `tools/gfx_strict_sweep.sh` (new, suite section [139]) derives the guarded
  names AND their required arity from `ext_gfx.c`'s own `want` strings and
  crosses each with the wrong-container shapes, requiring every pair to raise
  from its own guard or to carry a reason in a staleness-checked allowlist.
  35 names, 140 rows, 129 raises, 11 allowlisted. Validated by execution:
  against a build of the parent it reports raised=0 and trips its own floor;
  against the previous round of this same change it names 18 silent rows —
  the three openers a blind review found by hand, plus two builtins it did
  not reach.

  Four things this gate had to learn before it was worth having, each bought
  by a red run rather than by reasoning, and each the same mistake: treating
  "the check did not run" as "the check says no".
  * A probe that exits nonzero having printed no runtime error is UNRUN, not
    a guard verdict. Its first suite run reported `MISATTRIBUTED audio_open`
    for a child the loaded box had killed. Retried three times, then failed
    under its own name (#988's rule).
  * The verdict is decided by shell case-globs, not `grep -q`. Two greps per
    row is 280 forks; a grep that cannot fork exits nonzero, which read as
    "that line is not there" and turned RAISED-OWN into MISATTRIBUTED about
    one run in eight (measured). Same for the pinned-name and allowlist
    lookups.
  * The guarded-name set is PINNED. Deleting audio_play's guard took the
    population from 35 names to 34 and the sweep still printed OK — a
    derived population cannot notice its own subject leaving.
  * An allowlist entry no probe ever asks is dead, and the raises-now
    staleness check cannot see it. Six such entries shipped in the first
    draft; all six are gone and the check now fails on a seventh.
- `tools/strict_differential.sh`: 117 probes / 18 pins / 122 valid-input rows
  against a `make gfx` build of the parent — identical-when-off 117,
  differing 0, raises-under-strict 117, silent 0, misattributed 0.
- `tools/gfx_pixel_differential.sh` (new in the previous round, section
  [138]): the readback oracle, because every drawing builtin returns null on
  every path, so a returned-value differential measured that surface in the
  one state where it could not fail. 37 rows, 23 raises, 0 silent.
- `tools/failsoft_classify_check.sh`: `NULL_SCOPE='src/ext_gfx.c'`, floor
  136 -> 174, every `make_null()` in the file classified, four new selftest
  rows proving the widened matcher fires in scope, discriminates in scope,
  stays out of scope, and rejects a misspelled tag.
- `make asan-gfx` becomes a gate: `tests/test_asan_gfx.sh` + section [137],
  which builds its own instrumented binary rather than running `make` under
  the suite. Its triage found one leak and it was ours (`gfx_poll`'s event
  dict, 584 bytes / 6 allocations on the two decode-nothing paths), so no
  LSan suppression file ships — a suppression with no leak behind it is a
  waiver for a claim nobody checked.
- `tests/test_gfx_argtypes.eigs` (section [133]) runs twice, plain and
  strict, with pinned counts; the load-bearing non-strict row is the pixel
  proof, the only row that can see the defect on real pixels.

RESIDUALS

- A builtin that takes NO argument (`gfx_poll`, `gfx_present`, `gfx_ticks`,
  `gfx_close`, `audio_close`, `audio_clear`, `audio_capture_close`,
  `audio_capture_read`, `audio_stream_close`, `audio_stream_clear`,
  `audio_stream_queued`, `audio_queue_size`, `audio_music_stop`) still
  ignores one, and a surplus TRAILING argument to a fixed-arity builtin is
  still dropped. Both are the general over-arity question (#989), not this
  extension's, and both are stated in docs/BUILTINS.md rather than left for
  a reader to discover.
- `gfx_clear`'s wrong-SHAPE branch stays a coercion (it cleared to black
  before and still does with the flag off), pinned as such in [133].
- The sweep probes the TOP-LEVEL container, not a nested slot;
  `audio_play_loop of [42, 2]` is pinned by hand for that reason.
- NOT this change, but seen while validating it and worth a look:
  `tools/werror_switch_check.sh` intermittently fails under load with a
  GATE ERROR naming a random tracked `.sh` that contains no compiler token
  at all (`tools/obs_reader_sync_check.sh`, `tests/test_state_at_order.sh`,
  `tools/obs_marker_check.sh` on three different runs; measured 1 failure
  in 5 consecutive standalone runs on a 4-core box shared with eight other
  agents, and 2 of 5 suite runs). Its `recognizer_waived` decides with
  `printf | grep -q ... && return 0` and `$(... | sed ...)`, so a helper
  that cannot fork becomes a verdict — the same class this change had to
  fix twice inside its own new gate. Reproducible with
  `for i in $(seq 1 6); do bash tools/werror_switch_check.sh >/dev/null ||
  echo FLAKE; done` under load.

RECONCILIATION ONTO THE INTEGRATION BRANCH

This lands on an integration branch that is 10 commits past the v0.43.0 base
this change was written on, and three of those commits rewrote files it also
rewrites. What was done with each, so a later reader does not have to diff
four trees to find out:

- `tools/strict_differential.sh` — three-way. #1120 replaced every `grep -q`
  verdict with fork-free matchers (`str_has` / `str_has_line` /
  `str_has_word`) because `printf | grep -q` under `pipefail` reports a failed
  match when grep matched and the writer took SIGPIPE, and added `--selftest`,
  an evidence recorder, a binary fingerprint and a DID NOT RUN bucket. #971
  round 2 deleted the divergence-waiver mechanism. This change added probe
  rows, VALID rows, the SDL dummy-driver exports, and a per-file presence
  probe with a sentinel, and it also carried its own (weaker) fix for the
  same pipefail race, spelled as herestrings.
  Resolution: #1120's file is the base. Every row this change adds is kept
  (139 probes now, up from 99). No verdict here goes through a pipeline: the
  herestring conversions are dropped in favour of the matcher of the right
  shape at each site, and the presence probe's `case` globs became `str_has`.
  The waiver mechanism stays deleted — this change added no waiver entry
  (`EXPECTED_DIVERGE_UNSTABLE` and `_FIXED` were both empty on its branch), so
  there was nothing to re-argue. Its awk that derived guarded names from
  ARG_GUARD+STRICT_REQUIRE is dropped for #1120's `extract_guard_names`, which
  is a superset (it also reads STRICT_DOMAIN and num_guard_named), so the
  "STRICT_REQUIRE guards were structurally unprobed" fix this change made
  survives by being subsumed rather than by being applied twice.
  The presence probe is the one place both sides did real work on the same
  lines, so it is spelled out: it is now per-FILE (this change's reduction of
  34 interpreter launches to 1, since a builtin's presence is a property of
  its translation unit), and a file's classification is believed only if BOTH
  tests pass — #1120's exit-status test (`run_did_not_measure`, which sees
  126/127 and signals) and this change's sentinel (the probe's own first
  `print` must appear in the capture, which sees a child that produced no
  output at all). Either one failing puts the file in #1120's DID NOT RUN
  bucket, which is red, names the environment rather than a guard, and
  records evidence — instead of this change's separate `probe_failed` flag,
  which said the same thing in a second vocabulary.

- `tools/werror_switch_check.sh` — union. #971 round 2 fixed the enrollment
  fallback (it chose `git ls-files` vs `find` by whether git produced output,
  so a fork that lost a race under load swapped in untracked files and
  reported them as unenrolled — the root cause of the `[99i]`/`[99p]` flake).
  #744 enrolled `tools/core_ext_boundary_check.sh`. Both are kept, and this
  change's `tests/test_asan_gfx.sh` (floor 4) sits alongside them in
  SCRIPT_AUDITS and in the per-script floors.
  That makes the last RESIDUAL above STALE: the werror flake it reports is
  #971 round 2's, and it is fixed here. Re-measured on the merged tree, 3
  consecutive standalone runs, all green (540 compile invocations across 29
  dry-run targets + 9 scripts).

- `tools/failsoft_classify_check.sh` — no conflict; both sides' changes are
  in. #744 made the split-return planter indentation-agnostic (its seven
  8-space sites had moved to `src/builtins_buf.c`, so the plant became a
  silent no-op and the control failed for the wrong reason); this change added
  NULL_SCOPE, fs:VOID and the floor move to 174. One comment is corrected:
  the header's make_null() census was measured before #744 split
  builtins_buf.c out, so it no longer matches `--residuals`; the census is
  now labelled as the pre-#744 measurement it is, and `--residuals` named as
  the live number. Selftest 17/17.

- `tests/run_all_tests.sh` — the conflict was positional. This change inserts
  three sections immediately above `[132]`, whose comment #859 had meanwhile
  rewritten (dropdown/combobox overlay rows). #859's comment is kept.

SECTION LABELS. The integration branch already uses `[100]`-`[136]`, and its
`[136]` is #1112's replay-diff crash gate. This change's `[136]`/`[137]`/`[138]`
are therefore renumbered to `[137]`/`[138]`/`[139]`, in the headers, the
`echo` lines and the one cross-reference in `[133]`'s comment ("leaks on those
paths"). No other file cites them. `tools/suite_label_check.sh`: 258 labelled
echo lines, no two sections share a label.

REBASED ONTO #1122, AND ITS GATE FOUND A REAL DEFECT HERE. The integration
branch advanced under this work to carry #1122 ("no pipeline decides a verdict
under pipefail"). Neither of that change's regions collided with this one, but
its new gate, `tools/pipefail_verdict_check.sh`, immediately flagged a site in
THIS change's own new tool: `tools/gfx_strict_sweep.sh` decided a selftest
branch with `printf | grep -qx`, the exact construct #1120 established is a
race rather than a test. Worse than the race, the selftest was proving a
matcher the tool does not use in production. Fixed by factoring the real
case-glob into `name_in_population()` and calling it from BOTH the pin loop and
the selftest row, so the selftest now exercises the matcher that ships. The
gate reports 21 scripts and 6400 logical lines clean with 11 matcher copies
pinned, and its own selftest passes 34 checks.

VALIDATION, all post-rebase on the integrated tree:

  release suite                     5059/5059 passed, 0 failed
  gfx-binary full suite             5142/5142 passed, 0 failed
  ASan+UBSan, detect_leaks=1        5060/5060 passed, 0 failed, no leak-tally NOTE
  tools/jit_diff.sh                 OK, 238 programs x {jit, osr}, 0 ledgered
  tools/replay_diff.sh              OK, 238 programs, 0 nondeterministic, 0 ledgered
  tools/suite_label_check.sh        258 labelled sections, no collisions
  tools/doc_drift_check.sh          clean

Zero FAIL lines in any suite and no binary-fingerprint error in either full run.

RE-ASSERTED AFTER THE MERGE, by running each rather than grepping for it, since
a clean auto-merge is where a one-line fix gets silently undone: the three
strict scan guards are leak-clean under the sanitizer (#971 round 2);
`strict_differential.sh --selftest` passes 11 checks (#1120); and
`tests/test_lint.sh` reports its own ledger check green (#1121).

Closes #1007

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8rPrmr564H7yB2tQ6DwV9
Comment thread tests/test_dap.py
lines[0] = "V 2 0.0.1-not-this-binary"
# keep the tape's OWN format integer so this stays a RUNTIME-version test
# after a format bump (it went 2 -> 3 with the observer-config O records)
fmt = open(tape_path).readline().split()[1]
Comment thread src/task.c Dismissed
Comment thread src/lint.c
* counters and re-assignments for this loop's purposes) but belong to that
* loop for reporting. Function and lambda bodies are a different env and are
* not entered. */
static void w024_collect(ASTNode *n, W024Loop *lp, int nested) {
Comment thread src/lint.c

/* Driver: every loop, innermost-owner attribution; fn_depth > 0 inside any
* define/lambda (where a `for`-body `local` is a persisting frame slot). */
static void w024_walk(ASTNode *n, int fn_depth, LintContext *ctx) {
Comment thread src/builtins_tensor.c
* path cannot drift. */
if (g_strict) {
for (int i = 0; i < res->data.buffer.count; i++)
if (res->data.buffer.data[i] != res->data.buffer.data[i]) {
Comment thread src/builtins_tensor.c
/* #971: same NaN collapse as the buffer path, so the strict raise names
* matmul instead of the bare num_guard backstop inside make_num. */
for (int64_t i = 0; i < (int64_t)ar * bc; i++)
if (out[i] != out[i]) out[i] = num_guard_named(out[i], "matmul");
Comment thread src/builtins_tensor.c
* (same 0 + EIGS_MATH_INVALID); guarding first lets the strict raise
* name tensor_load. */
for (int i = 0; i < total; i++)
if (data[i] != data[i]) data[i] = num_guard_named(data[i], "tensor_load");
Comment thread src/trace.c
static void obs_cfg_sync(void) {
if (!eigs_current || !eigs_current->state) return;
const EigsState *st = eigs_current->state;
if (st->obs_dh_zero == g_cfg_dh_zero &&
Comment thread src/trace.c
if (!eigs_current || !eigs_current->state) return;
const EigsState *st = eigs_current->state;
if (st->obs_dh_zero == g_cfg_dh_zero &&
st->obs_dh_small == g_cfg_dh_small &&
Comment thread src/trace.c
const EigsState *st = eigs_current->state;
if (st->obs_dh_zero == g_cfg_dh_zero &&
st->obs_dh_small == g_cfg_dh_small &&
st->obs_h_low == g_cfg_h_low &&
Comment thread src/trace.c
if (st->obs_dh_zero == g_cfg_dh_zero &&
st->obs_dh_small == g_cfg_dh_small &&
st->obs_h_low == g_cfg_h_low &&
st->obs_scale == g_cfg_scale &&
InauguralPhysicist and others added 2 commits September 8, 2026 14:07
…empty (#1124 CI)

The #1115 self-test derives 3 corpus names from corpus() = git ls-files '*.eigs'.
In the CI devcontainer git returns nothing (dubious-ownership / detached checkout),
so the self-test found 0 corpus names and failed (rc=2) on both linux lanes,
cascading to the [99p] child-exit ledger. Locally git worked, so it passed —
the observer corpus is a CI-only surface. Fall back to a find walk of the same
tree (cd'd to $REPO) when git ls-files is empty, excluding .git/build/captures/
eigs_modules. Verified: selftest 9/9 with git, and 9/9 in a .git-less copy where
git ls-files returns 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kpzyjv1SaLaqBf45FSFDhB
The configurable window depth and scale-free relative step (#1044/#1045) change
when the value-channel predicates fire — deliberately. #1045 fixed the
unit-dependent early convergence (rel=dv/(1+|v|) degenerating to an absolute
deadband below |v|~1), so a decaying-toward-zero signal now stays 'improving'
until it is RELATIVELY flat instead of converging at a fixed absolute
threshold: observer_predicates converges at step 201 (was 118), numerical/idioms
run more steps, physics/solve drop a converged flag on two trajectories. All six
programs run clean (0 errors, sensible output); only the observer VERDICTS
shifted. The observer corpus run (tests/observer_corpus/run.sh) is not wired into
the local suite (CI-only), so the goldens were not recaptured with the change.
Recaptured on the branch binary: 14 match, 0 diverge. The 8 unaffected goldens
are byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kpzyjv1SaLaqBf45FSFDhB
@InauguralPhysicist
InauguralPhysicist merged commit 6fa1cb0 into main Sep 8, 2026
17 of 20 checks passed
@InauguralPhysicist
InauguralPhysicist deleted the claude/skills-location-drive-issues-ur41ll branch September 8, 2026 20:04
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.

3 participants