This session cleared 15 of the 18 documented skips:
- Toggle parser ambiguity (1) — 2-token lookahead in parse-toggle
- Throttled-at modifier (1) — parser + emit-on wrap + runtime hs-throttle!/hs-debounce!
- Tokenizer-stream API (13) — hs-stream wrapper + 15 stream primitives
Plus a perf fix in compiler.sx (hoisted throttle/debounce helpers to
module level so they don't get JIT-recompiled per emit-on call). Wall
time for full batched suite: 28m45s, was 26m17s before sync (so net
+18 tests cost only +2m even though 3x more work).
Remaining skips (3):
- Template-component scope tests (2) — needs <script type="text/
hyperscript-template"> custom-element bootstrap registrar.
- Async event dispatch (1) — repeat until event needs the OCaml
kernel to release the JS event loop between iterations.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
vwait used frame-lookup which doesn't honor `::` global routing. So
`vwait ::done` after `set ::done fired` (where set routes to root frame)
never saw the var change in the local frame, looping forever.
Added tcl-vwait-lookup helper that mirrors tcl-var-get's `::` routing
but returns nil instead of erroring on missing vars.
Was the deadlock that hung the full test suite past test 32.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parser: { f = e; f = e; ... } -> (:record (F E)...). { base with f = e;
... } -> (:record-update BASE (F E)...). Eval builds a dict from field
bindings; record-update merges the new fields over the base dict — the
same dict representation already used for modules.
{ also added to at-app-start? so records are valid arg atoms. Field
access via the existing :field postfix unifies record/module access.
Record patterns deferred to a later iteration.
lib/ocaml/conformance.sh runs the full test suite, classifies each
result by description prefix into one of 14 suites (tokenize, parser,
eval-core, phase2-refs/loops/function/exn, phase3-adt, phase4-modules,
phase5-hm, phase6-stdlib, let-and, phase1-params, misc), and emits
scoreboard.json + scoreboard.md.
Per the briefing: "Once the scoreboard exists (Phase 5.1), it is your
north star." Real OCaml testsuite vendoring deferred — needs more
stdlib + ADT decls to make .ml files runnable.
Parser: try-consume-param! handles ident, wildcard _ (fresh __wild_N
name), unit () (fresh __unit_N), typed (x : T) (skips signature).
parse-fun and parse-let (inline) reuse the helper; top-level
parse-decl-let inlines a similar test.
test.sh timeout bumped from 60s to 180s — the growing suite was hitting
the cap and reporting spurious failures.
OCaml-on-SX is the deferred second consumer for lib/guest/hm.sx step 8.
lib/ocaml/infer.sx assembles Algorithm W on top of the shipped algebra:
- Var: lookup + hm-instantiate.
- Fun: fresh-tv per param, auto-curried via recursion.
- App: unify against hm-arrow, fresh-tv for result.
- Let: generalize rhs over (ftv(t) - ftv(env)) — let-polymorphism.
- If: unify cond with Bool, both branches with each other.
- Op (+, =, <, etc.): builtin signatures (int*int->int monomorphic,
=/<> polymorphic 'a->'a->bool).
Tests pass for: literals, fun x -> x : 'a -> 'a, let id ... id 5/id true,
fun f x -> f (f x) : ('a -> 'a) -> 'a -> 'a (twice).
Pending: tuples, lists, pattern matching, let-rec, modules in HM.
Parser collects multiple bindings via 'and', emitting (:def-rec-mut
BINDINGS) for let-rec chains and (:def-mut BINDINGS) for non-rec.
Single bindings keep the existing (:def …) / (:def-rec …) shapes.
Eval (def-rec-mut): allocate placeholder cell per binding, build joint
env where each name forwards through its cell, then evaluate each rhs
against the joint env and fill the cells. Even/odd mutual-rec works.
Previous version put (define _throttle-ms ...) (define _debounce-ms ...)
(define _strip-throttle-debounce ...) inside emit-on's body, redefining
them on every call to emit-on. The kernel JIT-compiled the helper fn
fresh each invocation, doubling compile time across the suite and
pushing many tests over their wall-clock deadline (35 cumulative-only
timeouts in the latest batched run, up from 0).
Move the three definitions to module-level. Use (set! _throttle-ms nil)
(set! _debounce-ms nil) at the top of emit-on to reset state for each
call. JIT compilation of _strip-throttle-debounce now happens once.
Verified: hs-upstream-expressions/dom-scope went from 18/20 (with two
state-related timeouts) back to 20/20, suite wall-time 232s → 75s.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lib/ocaml/runtime.sx defines the stdlib in OCaml syntax (not SX): every
function exercises the parser, evaluator, match engine, and module
machinery built in earlier phases. Loaded once via ocaml-load-stdlib!,
cached in ocaml-stdlib-env, layered under user code via ocaml-base-env.
List: length, rev, rev_append, map, filter, fold_left/right, append,
iter, mem, for_all, exists, hd, tl, nth.
Option: map, bind, value, get, is_none, is_some.
Result: map, bind, is_ok, is_error.
Substrate validation: this stdlib is a nontrivial OCaml program — its
mere existence proves the substrate works.
Parser: module F (M) (N) ... = struct DECLS end -> (:functor-def NAME
PARAMS DECLS). module N = expr (non-struct) -> (:module-alias NAME
BODY-SRC). Functor params accept (P) or (P : Sig) — signatures
parsed-and-skipped via skip-optional-sig.
Eval: ocaml-make-functor builds curried host-SX closures from module
dicts to a module dict. ocaml-resolve-module-path extended for :app so
F(A), F(A)(B), and Outer.Inner all resolve to dicts.
Phase 4 LOC ~290 cumulative (still well under 2000).
Parser: open Path and include Path top-level decls; Path is Ctor (.Ctor)*.
Eval resolves via ocaml-resolve-module-path (same :con-as-module-lookup
escape hatch used by :field). open extends the env with the module's
bindings; include also merges into the surrounding module's exports
(when inside a struct...end).
Path resolver lets M.Sub.x work for nested modules. Phase 4 LOC ~165.
module M = struct DECLS end parsed by sub-tokenising the body source
between struct and the matching end (nesting tracked via struct/begin/
sig/end). Field access is a postfix layer above parse-atom, binding
tighter than application: f r.x -> (:app f (:field r "x")).
Eval (:module-def NAME DECLS) builds a dict via ocaml-eval-module
running decls in a sub-env. (:field EXPR NAME) looks up dict fields,
treating (:con NAME) heads as module-name lookups instead of nullary
ctors so M.x works with M as a module.
Phase 4 LOC so far: ~110 lines (well under 2000 budget).
Parser: try EXPR with | pat -> handler | ... -> (:try EXPR CLAUSES).
Eval delegates to SX guard with else matching the raised value against
clause patterns; re-raises on no-match. raise/failwith/invalid_arg
shipped as builtins. failwith "msg" raises ("Failure" msg) so
| Failure msg -> ... patterns match.
Sugar for fun + match. AST (:function CLAUSES) -> unary closure that
runs ocaml-match-clauses on its arg. let rec recognises :function as a
recursive rhs and ties the knot via cell, so
let rec map f = function | [] -> [] | h::t -> f h :: map f t
works. ocaml-match-eval refactored to share clause-walk with function.
Parser: for i = lo to|downto hi do body done, while cond do body done.
AST: (:for NAME LO HI :ascend|:descend BODY) and (:while COND BODY).
Eval re-binds the loop var per iteration; both forms evaluate to unit.
ref is a builtin boxing its arg in a one-element list. Prefix ! parses
to (:deref ...) and reads via (nth cell 0). := joins the binop
precedence table at level 1 right-assoc and mutates via set-nth!.
Closures share the underlying cell.
Two-phase grammar: parse-expr-no-seq (prior entry) + parse-expr wraps
it with ;-chaining. List bodies keep parse-expr-no-seq so ; remains a
separator inside [...]. Match clause bodies use the seq variant and stop
at | — real OCaml semantics. Trailing ; before end/)/|/in/then/else/eof
permitted.
Patterns: wildcard, literal, var, ctor (nullary + arg, flattens tuple
args so Pair(a,b) -> (:pcon "Pair" PA PB)), tuple, list literal, cons
:: (right-assoc), unit. Match: leading | optional, (:match SCRUT
CLAUSES) with each clause (:case PAT BODY). Body parsed via parse-expr
because | is below level-1 binop precedence.
ocaml-parse-program: program = decls + bare exprs, ;;-separated.
Each decl is (:def …), (:def-rec …), or (:expr …). Body parsing
re-feeds the source slice through ocaml-parse — shared-state refactor
deferred.
lib/hyperscript/tokenizer.sx — added cursor + follow-set wrapper over
the existing flat-list tokenize output:
hs-stream src → {:tokens :pos :follows :last-match :last-ws}
hs-stream-current s → next non-WS token (skips WS, captures :last-ws)
hs-stream-match s value → consume if value matches & not in follow set
hs-stream-match-type s ...types → consume if upstream type name matches
hs-stream-match-any s ...names → consume if value matches any name
hs-stream-match-any-op s ...ops → consume if op token & value matches
hs-stream-peek s value n → look n non-WS tokens ahead, no consume
hs-stream-consume-until s marker → collect tokens until marker
hs-stream-consume-until-ws s → collect until next whitespace
hs-stream-push-follow! / pop-follow!
hs-stream-push-follows! / pop-follows! n
hs-stream-clear-follows! → saved / restore-follows! saved
hs-stream-last-match / last-ws
hs-stream-type-map maps our lowercase type names to upstream's
("ident" → "IDENTIFIER", "number" → "NUMBER", etc.) so type-based
matching works against upstream test expectations.
13 tokenizer-stream tests now pass; 30/30 in hs-upstream-core/tokenizer.
Skips remaining: 5 (down from 18).
- 2 template-component scope tests
- 1 async event dispatch (until event keyword works)
- left for later: needs more architectural work
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
parser.sx parse-toggle-cmd: when seeing 'toggle .foo for', peek the
following two tokens. If they are '<ident> in', it is a for-in loop
and toggle does NOT consume 'for' as a duration clause. Restores the
trailing for-in to the command list.
parser.sx parse-on (handler modifiers): recognize 'throttled at <ms>'
and 'debounced at <ms>' as handler modifiers. Captured as :throttle /
:debounce kwargs in the on-form parts list.
compiler.sx emit-on: pre-extract :throttle / :debounce from parts via
new _strip-throttle-debounce helper before scan-on, then wrap the built
handler with (hs-throttle! handler ms) or (hs-debounce! handler ms).
runtime.sx: hs-throttle! — closure with __hs-last-fire timestamp,
fires immediately and drops events arriving within ms of the last fire.
hs-debounce! — closure with __hs-timer, clears any pending timer and
schedules a new setTimeout(handler, ms) so only the last burst event
fires.
Both formerly-architectural skips now pass:
- "toggle does not consume a following for-in loop"
- "throttled at <time> drops events within the window"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Brings the architecture branch (559 commits ahead — R7RS step 4-6, JIT
expansion, host_error wrapping, bytecode compiler, etc.) into the
loops/haskell line of work. Conflict in lib/haskell/conformance.sh:
architecture replaced the inline driver with a thin wrapper delegating
to lib/guest/conformance.sh + a config file. Resolved by taking the
wrapper and extending lib/haskell/conformance.conf with all programs
added under loops/haskell (caesar, runlength-str, showadt, showio,
partial, statistics, newton, wordfreq, mapgraph, uniquewords, setops,
shapes, person, config, counter, accumulate, safediv, trycatch) plus
adding map.sx and set.sx to PRELOADS.
plans/haskell-completeness.md gains three new follow-up phases:
- Phase 17 — parser polish (`(x :: Int)` annotations, mid-file imports)
- Phase 18 — one ambitious conformance program (lambda-calc / Dijkstra /
JSON parser candidate list)
- Phase 19 — conformance speed (batch all suites in one sx_server
process to compress the 25-min run to single-digit minutes)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
hk-bind-exceptions! in eval.sx registers throwIO, throw, evaluate, catch,
try, handle, displayException. SomeException constructor pre-registered
in runtime.sx (arity 1, type SomeException).
throwIO and the existing error primitive both raise via SX `raise` with a
uniform "hk-error: msg" string. catch/try/handle parse it back into a
SomeException via hk-exception-of, which strips nested
'Unhandled exception: "..."' host wraps (CEK's host_error formatter) and
the "hk-error: " prefix.
catch and handle evaluate the handler outside the guard scope (build an
"ok"/"exn" outcome tag inside guard, then dispatch outside) so that a
re-throw from the handler propagates past this catch — matching Haskell
semantics rather than infinite-looping in the same guard.
14 unit tests in tests/exceptions.sx (catch success, catch error, try
Right/Left, handle, throwIO + catch/try, evaluate, nested catch, do-bind
through catch, branch on try result, IORef-mutating handler).
Conformance: safediv.hs (8/8) and trycatch.hs (8/8). Scoreboard now
285/285 tests, 36/36 programs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/extract-upstream-tests.py — new walker that scrapes
/tmp/hs-upstream/test/**/*.js for test('name', ...) patterns. Uses
brace-counting that handles strings, regex, comments, and template
literals. Two modes:
- merge (default): preserves existing test bodies, only adds new tests
- --replace: discards old bodies, fully re-extracts (use when bodies
drift due to upstream cleanup)
Merge mode is what we want for an incremental sync — the old snapshot
had bodies that had been hand-tuned for our auto-translator; raw
re-extraction loses those tweaks and regresses ~250 working tests
back to SKIP (untranslated).
Snapshot updated: spec/tests/hyperscript-upstream-tests.json grows
from 1496 → 1514 tests. All 18 new tests are documented as either
manual bodies (3) or skips (15):
Manual bodies (3):
- on resize from window — dispatches via host-global "window"
- toggle between followed by for-in loop works — direct test
Skips for architectural reasons (15):
- 13× core/tokenizer — upstream exposes a streaming token API
(matchToken, peekToken, consumeUntil, pushFollow…) that our
tokenizer doesn't surface. Implementing it = a token-stream
wrapper primitive over hs-tokenize output.
- 2× ext/component — template-based components via
<script type="text/hyperscript-template">. We use defcomp directly;
no template-bootstrap path.
- 1× toggle does not consume a following for-in loop — parser
ambiguity in 'toggle .foo for <X>'. Parser must distinguish
'for <duration>ms' from 'for <ident> in <expr>'. The 'toggle
between' variant works (different parse path).
Net per-suite status: every individual suite passes 100% on counted
tests (skips excluded). 1496 runnable / 1514 total = 100% on what runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Trivial wrapper: apl-run-file = apl-run ∘ file-read, where
file-read is built-in to OCaml SX.
Tests verify primes.apl, life.apl, quicksort.apl all parse
end-to-end (their last form is a :dfn AST). Source-then-call
test confirms the loaded file's defined fn is callable, even
when the algorithm itself can't fully execute (primes' inline
⍵ rebinding still missing — :glyph-token, not :name-token).
Parser: :name clause now detects 'name ← rhs' patterns inside
expressions. When seen, consumes the remaining tokens as RHS,
parses recursively, and emits a (:assign-expr name parsed-rhs)
value segment.
Eval-ast :dyad and :monad: when the right operand is an
:assign-expr node, capture the binding into env before
evaluating the left operand. This realises the primes idiom:
apl-run "(2 = +⌿ 0 = a ∘.| a) / a ← ⍳ 30"
→ 2 3 5 7 11 13 17 19 23 29
Also: top-level x←5 now evaluates to scalar 5 (apl-eval-ast
:assign just unwraps to its RHS value).
Caveat: ⍵-rebinding (the original primes.apl uses
'⍵←⍳⍵') is a :glyph-token; only :name-tokens are handled.
A regular variable name (like 'a') works.
Haskell strings are [Char]. Calling reverse / head / length on a SX raw
string transparently produces a cons-list of char codes (via hk-str-head /
hk-str-tail in runtime.sx), but (==) then compared the original raw string
against the char-code cons-list and always returned False — so
"racecar" == reverse "racecar" was False.
Added hk-try-charlist-to-string and hk-normalize-for-eq in eval.sx; routed
== and /= through hk-normalize-for-eq so a string compares equal to any
cons-list whose elements are valid Unicode code points spelling the same
characters, and "[]" ↔ "".
palindrome.hs lifts from 9/12 → 12/12; conformance 33/34 → 34/34 programs,
266/269 → 269/269 tests.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Investigation of the long-standing 'why does the runner say 1494/1494 not
1496/1496?' question. The answer is in tests/hs-run-filtered.js:969 — two
tests are skipped via _SKIP_TESTS for documented architectural reasons:
1. 'until event keyword works' — uses 'repeat until event click from #x',
which suspends the OCaml kernel waiting for a click that is never
dispatched from outside K.eval. The sync test runner has no way to
fire the click while the kernel is suspended.
2. 'throttled at <time> drops events within the window' — the HS parser
does not implement the 'throttled at <ms>' modifier. The compiled SX
for the handler is malformed: handler body is the literal symbol
'throttled', the time expression dangles outside the closure as
stray (do 200 ...). Genuinely needs parser+compiler+runtime work,
not just a deadline bump.
Both are documented at the skip site with a comment explaining why they
can't run synchronously. The conformance number is 1494/1494 = 100% on
counted tests, with 2 explicit, justified skips out of 1496 total.
This was the source of the cumulative-vs-isolated test-count discrepancy.
Suite filter runs see them as 'not in this suite,' batched runs see them
as 'continued past'. Either way: not failures.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ships the algebra for HM-style type inference, riding on
lib/guest/match.sx (terms + unify) and ast.sx (canonical AST):
• Type constructors: hm-tv, hm-arrow, hm-con, hm-int, hm-bool, hm-string
• Schemes: hm-scheme / hm-monotype + accessors
• Free type-vars: hm-ftv, hm-ftv-scheme, hm-ftv-env
• Substitution: hm-apply, hm-apply-scheme, hm-apply-env, hm-compose
• Generalize / Instantiate (with shared fresh-tv counter)
• hm-fresh-tv (counter is a (list N) the caller threads)
• hm-infer-literal (the only fully-closed inference rule)
24 self-tests in lib/guest/tests/hm.sx covering every function above.
The lambda / app / let inference rules — the substitution-threading
core of Algorithm W — intentionally live in HOST CODE rather than the
kit, because each host's AST shape and substitution-threading idiom
differ subtly enough that forcing one shared assembly here proved
brittle in practice (an earlier inline-assembled hm-infer faulted with
"Not callable: nil" only when defined in the kit, despite working when
inline-eval'd or in a separate file — a load/closure interaction not
worth chasing inside this step's budget). The host gets the algebra
plus a spec; assembly stays close to the AST it reasons over.
PARTIAL — algebra + literal rule shipped; full Algorithm W deferred
to host consumers (haskell/infer.sx, lib/ocaml/types.sx when
OCaml-on-SX Phase 5 lands per the brief's sequencing note). Haskell
infer.sx untouched; haskell scoreboard still 156/156 baseline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apl-permutations was doing (append acc <new-perms>) which is
O(|acc|) and acc grows ~N! big — total cost O(N!²).
Swapped to (append <new-perms> acc) — append is O(|first|)
so cost is O((n+1)·N!_prev) per layer, total O(N!). q(7)
went from 32s to 12s; q(8)=92 now finishes well within the
300s timeout, so the queens(8) test is restored.
497/497. Phase 8 complete.