Two additions from loops/hs needed for the new WebSocket socket tests:
- unhandledRejection suppressor — synchronous test harness doesn't await RPC promises
- Fake setTimeout/clearTimeout + __hsFlushTimers — drain RPC timeout tests synchronously
Plan update: mark E36 WebSocket as DONE (previously "design-done, pending review").
Skipped: loops/hs's tests/playwright/generate-sx-tests.py — architecture's version
is 1468 lines vs loops/hs's 290; arch's is the further-evolved version.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
plans/kernel-on-sx.md — Phase 7 header updated from "partial" to
"env.sx EXTRACTED 2026-05-12"; second-consumer-found checkbox ticked
for env.sx specifically. Other five files (combiner, evaluator,
hygiene, quoting, short-circuit) stay blocked pending their own
second consumers.
plans/lib-guest-reflective.md — Phases 1-3 ticked off with date
stamps; Outcome section added summarising the three commits, file
stats (124 LoC, within 80-200 bound), and the third-consumer
adoption protocol (cfg with five keys, no changes to env.sx).
The kernel-on-sx loop documented six candidate reflective API files
gated on the two-consumer rule. This plan opens that block by
selecting Tcl's existing uplevel/upvar machinery as the second
consumer for env.sx specifically (the highest-fit candidate).
Discovery: Kernel and Tcl have identical scope-chain semantics but
diverge on mutable-vs-functional update. Solution: adapter-cfg
pattern, same as lib/guest/match.sx. Canonical wire shape with
mutable defaults for Kernel; Tcl provides its own cfg keeping
the functional model.
Roadmap: env.sx extracted, both consumers migrated, all tests green.
The other five candidate files (combiner, evaluator, hygiene,
quoting, short-circuit) stay deferred — Tcl has no operatives.
Loop closer documenting what 18 feature commits produced. Kernel-on-SX
is 1,398 LoC substrate + 1,747 LoC tests = 3,145 LoC total. Zero
substrate fixes required across the loop. R-1RK core + extras
implemented. Six proposed lib/guest/reflective/ files awaiting second
consumer. Substrate verdict: env-as-value generalises to
evaluator-as-value; the m-eval demo proves it.
Five type predicates (number?, string?, list?, boolean?, symbol?).
New tests/metacircular.sx: m-eval defined in Kernel walks expressions
itself, recursing on applicative-call args and delegating to host
eval only for operatives and symbol lookup. 14 demo tests.
The demo surfaced a real bug: map/filter/reduce called kernel-combine
on applicative head-vals directly, which re-evaluates already-
evaluated element values; nested-list elements crashed. Fix: extracted
knl-apply-op (unwrap-applicative-or-pass-through) and use it in all
three combinators before kernel-combine. Mirrors apply's approach.
Added knl-apply-op as a proposed entry in the reflective combiner.sx
API. 322 tests total.
apl-inner now wraps its result in (enclose result) when A's ravel
contains any dict element (a boxed array). This matches Hui's
semantics where `1 ⍵ ∨.∧ X` produces a rank-0 wrapping the
(5 5) board, then ⊃ unwraps to bare matrix.
Homogeneous inner product unaffected (+.× over numbers and
matrices still produces bare arrays — none of those ravels
contain dicts).
life.apl restored to true as-written form:
life ← {⊃1 ⍵ ∨.∧ 3 4 = +/ +/ ¯1 0 1 ∘.⊖ ¯1 0 1 ⌽¨ ⊂⍵}
4 pipeline tests + 5 e2e tests verify heterogeneous case and
that ⊃ unwraps to the underlying (5 5) board.
Full suite 589/589. Phase 11 complete.
(apply F (list V1 V2 V3)) ≡ (F V1 V2 V3). Unwrap applicative first to
skip auto-eval (args are values), then kernel-combine with the
underlying operative. Universal pattern in reflective Lisps —
sketched into the combiner.sx API. 296 tests total.
Added kernel-make-primitive-applicative-with-env in eval.sx — IMPL
receives (args dyn-env), needed by combinators that re-enter the
evaluator. map/filter/reduce in runtime.sx use it to call user-supplied
combiners on each element with the caller's dynamic env preserved.
Sketched the env-blind vs env-aware applicative split as a new entry
in the proposed combiner.sx reflective API. 289 tests total.
Standard Kernel control flow. $cond walks clauses in order with `else`
catch-all; clauses past the first match are NOT evaluated. $when/$unless
are simple guards. 12 tests, 242 total.
kernel-quasiquote-operative walks the template via mutually-recursive
knl-quasi-walk ↔ knl-quasi-walk-list. $unquote forms eval in dyn-env;
$unquote-splicing splices list-valued results. No depth tracking
(nested quasiquotes flatten). 8 new tests, 230 total. Sketched the
universal reflective quoting kit API for the eventual Phase 7 extraction.
:body slot holds a LIST of forms now (was single expression). New
knl-eval-body in eval.sx evaluates each form in sequence, returning
the last. $vau and $lambda accept (formals env-param body...) /
(formals body...). No $sequence dependency. 223 tests total.
Parser now reads 'expr, \`expr, ,expr, ,@expr as the four standard
shorthands. Quote uses existing $quote operative; quasiquote /
unquote / unquote-splicing recognised but not yet expanded at runtime
(left for first consumer to drive). 218 tests total across six suites.
Hygiene-by-default was already present: user operatives close over
static-env and bind formals + body $define!s in (extend STATIC-ENV),
caller's env untouched. $let evaluates values in caller env, binds
in fresh child env, runs body there. $define-in! explicitly targets
an env. Full scope-set / frame-stamp hygiene is research-grade
and documented as deferred future work in the reflective API notes.
`dl-set-strategy!` accepted any keyword silently — typos like
`:semi_naive` or `:semiNaive` were stored uninspected and the
saturator then used the default. The user never learned their
setting was wrong.
Validator added: strategy must be one of `:semi-naive`, `:naive`,
`:magic` (the values currently recognised by the saturator and
magic-sets driver). Unknown values raise with a clear message that
lists the accepted set.
1 regression test; conformance 276/276.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The renamer for anonymous `_` variables started at counter 0 and
produced `_anon1, _anon2, ...` unconditionally. A user writing the
same naming convention would see their variables shadowed:
(dl-eval "p(a, b). p(c, d). q(_anon1) :- p(_anon1, _)."
"?- q(X).")
=> () ; should be ({:X a} {:X c})
The `_` got renamed to `_anon1` too, collapsing the two positions
of `p` to a single var (forcing args to be equal — which neither
tuple satisfies).
Fix: scan each rule (and query goal) for the highest `_anon<N>`
already present and start the renamer past it. New helpers
`dl-max-anon-num` / `dl-max-anon-num-list` / `dl-try-parse-int`
walk the rule tree; `dl-make-anon-renamer` now takes a `start`
argument; `dl-rename-anon-rule` and the query-time renamer in
`dl-query` both compute the start from the input.
1 regression test; conformance 275/275.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dl-magic-query could silently diverge from dl-query when an
aggregate's inner-goal relation was IDB. The rewriter passes
aggregate body lits through unchanged (no magic propagation
generated for them), so the inner relation was empty in the magic
db and the aggregate returned 0. Repro:
(dl-eval-magic
"u(a). u(b). u(c). u(d). banned(b). banned(d).
active(X) :- u(X), not(banned(X)).
n(N) :- count(N, X, active(X))."
"?- n(N).")
=> ({:N 0}) ; should be ({:N 2})
dl-magic-query now pre-saturates the source db before copying facts
into the magic db. This guarantees equivalence with dl-query for
every stratified program; the magic benefit still comes from
goal-directed re-derivation of the query relation under the seed
(which matters for large recursive joins). The existing test cases
happened to dodge this because their aggregate inner-goals were all
EDB.
1 new regression test; conformance 274/274.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The canonical Datalog idiom for "no X has any Y":
orphan(X) :- person(X), not(parent(X, _)).
was rejected by the safety check with "negation refers to unbound
variable(s) (\"_anon1\")". The parser renames each anonymous `_`
to a fresh `_anon*` symbol so multiple `_` occurrences don't unify
with each other, and the negation safety walk then demanded all
free vars in the negated lit be bound by an earlier positive body
lit — including the renamed anonymous vars.
Anonymous vars in a negation are existentially quantified within
the negation, not requirements from outside. Added dl-non-anon-vars
to strip `_anon*` names from the `needed` set before the binding
check in dl-process-neg!. Real vars (like `X` in the orphan idiom)
still must be bound by an earlier positive body lit, just as before.
2 new regression tests (orphan idiom + multi-anon "solo" pattern);
conformance 273/273.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Datalog has no function symbols in argument positions, but the
existing dl-add-fact! / dl-add-rule! validators only checked that
literals were ground (no free variables). A compound like `+(1, 2)`
contains no variables, so:
p(+(1, 2)).
=> stored as the unreduced tuple `(p (+ 1 2))`
double(*(X, 2)) :- n(X). n(3).
=> saturates `double((* 3 2))` instead of `double(6)`
Added dl-simple-term? (number / string / symbol) and an
args-simple? walker, used by:
- dl-add-fact!: all args must be simple terms
- dl-add-rule!: rule head args must be simple terms (variables
are symbols, so they pass)
Compounds remain legal in body literals where they encode `is` /
arithmetic / aggregate sub-goals. Error messages name the offending
literal and point the user at the body-only mechanism.
2 new regression tests; conformance 271/271.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quoted atoms with uppercase- or underscore-leading names were
misclassified as variables. `p('Hello World').` flowed through the
tokenizer's "atom" branch and through the parser's string->symbol,
producing a symbol named "Hello World". dl-var? inspects the first
character — "H" is uppercase, so the fact was rejected as non-ground
("expected ground literal").
Tokenizer now emits "string" for any '...' quoted form. Quoted atoms
become opaque string constants — matching how Datalog idiomatically
treats them, and avoiding a per-symbol "quoted" marker that would
have rippled through unification and dl-var?. The trade-off is that
'a' and a are no longer the same value (string vs symbol); for
Datalog this is the safer default.
Updated the existing "quoted atom" tokenize test, added a regression
case for an uppercase-named quoted atom, and a parse-level test that
verifies the AST. Conformance 269/269.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Type-mixed comparisons were silently inconsistent:
<("hello", 5) => no result, no error (silent false)
<(a, 5) => raises "Expected number, got symbol"
Both should fail loudly with a comprehensible message. Added
dl-compare-typeok?: <, <=, >, >= now require both operands to share
a primitive type (both numbers or both strings) and raise a clear
"comparison <op> requires same-type operands" error otherwise.
`!=` is exempted because it's the polymorphic inequality test
built on dl-tuple-equal? — cross-type pairs are legitimately unequal
and the existing semantics for that case match user intuition.
2 new regression tests; conformance 267/267.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A dict in a rule body that isn't `{:neg <positive-lit>}` (the only
recognised dict shape) used to silently fall through every dispatch
clause in dl-rule-check-safety, contributing zero bound variables.
The user would then see a confusing "head variable(s) X do not
appear in any positive body literal" pointing at the head — not at
the actual bug in the body. Typos like `{:negs ...}` are the typical
trigger.
dl-process-lit! now flags both:
- a dict that lacks :neg
- a bare number / string / symbol used as a body lit
with a clear error naming the offending literal.
1 new regression test; conformance 265/265.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`is(R, /(X, 0))` was silently producing IEEE infinity:
(dl-eval "p(10). q(R) :- p(X), is(R, /(X, 0))." "?- q(R).")
=> ({:R inf})
That value then flowed through comparisons (anything < inf, anything
> inf) and aggregations (sum of inf, max of inf) producing nonsense
results downstream. `dl-eval-arith` now checks the divisor before
the host `/` and raises "division by zero in <expr>" — surfacing
the bug at its source rather than letting infinity propagate.
1 new test; conformance 264/264.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`count(N, Y, p(X))` silently returned `N = 1` because `Y` was never
bound by the goal — every match contributed the same unbound symbol
which dl-val-member? deduped to a single entry. Similarly:
sum(S, Y, p(X)) => raises "expected number, got symbol"
findall(L, Y, p(X)) => L = (Y) (a list containing the unbound symbol)
count(N, Y, p(X)) => N = 1 (silent garbage)
Added a third validator in dl-eval-aggregate: the agg-var must
syntactically appear among the goal's variables. Error names the
variable and the goal and explains why the result would be
meaningless.
1 new test; conformance 263/263.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A "mixed" relation has both user-asserted facts AND rules with the
same head. Previously dl-retract! wiped every rule-head relation
wholesale before re-saturating — the saturator only re-derives the
IDB portion, so explicit EDB facts vanished even for a no-op retract
of a non-existent tuple. Repro:
(let ((db (dl-program "p(a). p(b). p(X) :- q(X). q(c).")))
(dl-retract! db (quote (p z)))
(dl-query db (quote (p X))))
went from {a, b, c} to just {c}.
Fix: track :edb-keys provenance in the db.
- dl-make-db now allocates an :edb-keys dict.
- dl-add-fact! (public) marks (rel-key, tuple-key) in :edb-keys.
- New internal dl-add-derived! does the append without marking.
- Saturator (semi-naive + naive driver) now calls dl-add-derived!.
- dl-retract! strips only the IDB-derived portion of rule-head
relations (anything not in :edb-keys) and preserves the EDB
portion through the re-saturate pass.
2 new regression tests; conformance 262/262.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nested `not(not(P))` silently misparsed: outer `not(...)` is
recognised as negation, but the inner `not(banned(X))` was parsed
as a positive call to a relation called `not`. With no `not`
relation present, the inner match was empty, the outer negation
succeeded vacuously, and `vip(X) :- u(X), not(not(banned(X))).`
collapsed to `vip(X) :- u(X).` — a silent double-negation = identity
fallacy.
Fix in `dl-rule-check-safety`: the positive-literal branch and
`dl-process-neg!` both reject any body literal whose relation
name is in `dl-reserved-rel-names`. Error message names the
relation and points the user at stratified negation through an
intermediate relation.
1 regression test; conformance 260/260.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Classic O(n) greedy gas-station algorithm:
walk once, tracking
total = sum of (gas[i] - cost[i]) -- if negative, no answer
curr = running tank since start -- on negative, advance
start past i+1 and reset
if total < 0 then -1 else start
For gas = [1;2;3;4;5], cost = [3;4;5;1;2], unique start = 3.
Tests `total` + `curr` parallel accumulators, reset-on-failure
pattern.
202 baseline programs total.
Greedy BFS-frontier style — track the farthest reach within the
current jump's reachable range, and bump the jump counter when i
runs into the current frontier:
while !i < n - 1 do
farthest := max(farthest, i + arr.(i));
if !i = !cur_end then begin
jumps := !jumps + 1;
cur_end := !farthest
end;
i := !i + 1
done
For [2; 3; 1; 1; 2; 4; 2; 0; 1; 1] (n = 10), the optimal jump
sequence 0 -> 1 -> 4 -> 5 -> 9 uses 4 jumps.
Tests greedy-with-frontier pattern, three parallel refs
(jumps, cur_end, farthest), mixed for-style index loop using ref.
201 baseline programs total.
Pascal-recursion combination enumerator:
let rec choose k xs =
if k = 0 then [[]]
else match xs with
| [] -> []
| h :: rest ->
List.map (fun c -> h :: c) (choose (k - 1) rest)
@ choose k rest
C(9, 4) = |choose 4 [1; ...; 9]| = 126
Tests pure-functional enumeration with List.map + closure over h,
@ append, [] | h :: rest pattern match on shrinking input.
200 baseline programs total -- milestone.
Monotonic decreasing stack — for each day i, pop entries from
the stack whose temperature is strictly less than today's; their
answer is (i - popped_index).
temps = [73; 74; 75; 71; 69; 72; 76; 73]
answer = [ 1; 1; 4; 2; 1; 1; 0; 0]
sum = 10
Complementary to next_greater.ml (iter 256) — same monotonic-stack
skeleton but stores the distance to the next greater element
rather than its value.
Tests `match !stack with | top :: rest when …` pattern with
guard inside a while-cont-flag loop.
198 baseline programs total.
DP recurrence for popcount that avoids host bitwise operations:
result[i] = result[i / 2] + (i mod 2)
Drops the low bit (i / 2 stands in for i lsr 1) and adds it back
if it was 1 (i mod 2 stands in for i land 1).
sum over 0..100 of popcount(i) = 319
Tests pure-arithmetic popcount, accumulating ref + DP array,
classic look-back to half-index pattern.
197 baseline programs total.