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 intoSep 8, 2026
Conversation
…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
| 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] |
| * 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) { |
|
|
||
| /* 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) { |
| * 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]) { |
| /* #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"); |
| * (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"); |
| 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 && |
| 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 && |
| 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 && |
| 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 && |
…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
deleted the
claude/skills-location-drive-issues-ur41ll
branch
September 8, 2026 20:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
unobserved:is not verdict-neutral: an elided assignment is missing from the window, so verdicts differ for up to WINDOW_N reads afterwards #1049 — configurable observer window depth + scale-free relative step; the tape carries the observer config;unobserved:keeps the value window complete.unobserved:) #972/#915's write-path gate arms per EigsState and monotonically: one predicate read — or a bare "report" string — anywhere in an interpreter state arms entropy bookkeeping for every assignment in every module #1046/Observer entropy is computed on EVERY assignment regardless of use: 88% of runtime / 8.50x ceiling on a consumer that uses no observer features #915 — observer gate hoisted ahead of the observe helpers; gate no longer arms on mere presence of an import or an observer name in the constant pool; literal imports resolved at compile time.zeros of nreturns a buffer;gatherraises on out-of-range (breaking).forbinder is loop-scoped at module level but persists inside a function (function-slot exception left by #1056) #1105 (fresh for-binder loop-scoped in a function), REPL swallows the line after a failed multi-line unit #1109 (REPL line-swallow), lib/eigen.eigs meta-interpreter does not honor the report/report_value reservation (#1102) #1111 (meta-interpreter report reservation), replay_diff reports OK over a SIGSEGV after the unsupported-concurrency diagnostic #1112 (replay boundary SIGSEGV), Embed opt-in: obs_history_gap lags one boundary for DIRECT host predicate reads (#1038 follow-up) #1114 (obs_history_gap at end of closed isolated eval), observer_gate_diff.sh: normalize the exe-dir in compared error text (out-of-tree-binary false mismatches) #1115 (observer_gate_diff exe-dir normalization).--api; [99s] strict_differential flakes under load: a probe is scored "raised by the wrong guard" while the diagnostic prints the right guard's message #1120/--lintand--apiskip the exit collector, so every container the linter parses out of eigs.json leaks #1121/The #1120 pipefail race is latent in six more scripts that decide verdicts with| grep -q#1122 pipefail/collector-drain fixes; the pattern-kill ban graduated tobash_guard.Landing gated on the full CI matrix (suites, ASan, TSan, macOS, CodeQL, jit/replay differentials) + review of the breaking changes.
🤖 Generated with Claude Code