tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

README.md (19334B)


Haskell WASM probes and browser embedding

These opt-in targets compile the production Runtime.Tree and Runtime.Value modules unchanged for native GHC and upstream GHC WASM. They are not a port of the native CLI. The command-line probes measure reduction and the site's pinned portable compiler; the browser target adds an isolated package/compiler session with the site's codecs, inspection/export and five-action IO protocol.

nix build .#haskellWasmProbe --out-link /tmp/tricu-haskell-wasm-probe
nix shell --inputs-from . nixpkgs#python3 -c python3 \
  ext/haskell-wasm/measure.py /tmp/tricu-haskell-wasm-probe /tmp/haskell-wasm.json

The root flake pins ghc-wasm-meta independently of native nixpkgs. The output contains versions.txt, native and WASM probes, Hello World, and the matching Wasmtime executable. Building checks Hello World, reduction rules, lazy discard, non-demanding application construction, rejection of an out-of-range byte, and small completely observed workloads on both execution targets. All builds and runs go through Nix; no Cabal invocation is needed for these boot-library-only modules (the build records the Cabal-install version without building with it).

The measurement script runs five fresh processes per host/workload, alternating host order, with a 60-second timeout, 512 MiB Haskell heap limit and 8 MiB Haskell stack limit. These are not limits on total engine memory or native machine stack. It records every sample, medians, binary SHA256s, raw and gzip sizes, versions, and platform. Wasmtime compilation caching is disabled. External process time therefore includes engine compilation; it is not warm browser-call latency.

Workloads are deliberately narrow:

Construction, WHNF reduction and complete observation/validation are timed separately. GC during each phase is included in that phase. Harness-only anti-sharing options and an opaque application wrapper prevent accidental hoisting of repeated applications; the production reducer compiles normally.

Native GHC and WASM GHC currently differ in version and word size. Ratios describe these pinned configurations, not isolated backend overhead. Small times and concurrently loaded machines require caution; use repeated independent runs before making deployment decisions.

Site compiler workloads

nix shell --inputs-from . nixpkgs#python3 -c python3 \
  ext/haskell-wasm/measure-compiler.py /tmp/tricu-haskell-wasm-probe \
  /tmp/haskell-wasm-compiler.json --samples 5

This runs the site's existing compiler-conformance.json cases on native Haskell, Wasmtime, Node/V8 WASM, and the current TS evaluator under the same Node version. It covers identity, curried pair construction, dependent definitions, six sequential definitions, local recursion/pattern matching, and two diagnostic cases. The IO case is excluded: no interaction-tree driver is included here.

Every successful job compares all compiled definitions, including their full tree encodings, with existing goldens, then applies the selected program and checks its complete result. Failures must produce the expected diagnostic. Both export-name availability (without forcing programs) and complete-result observation are timed. Timing runs are outside Nix builds, never threshold gates. Each sample starts a fresh process. Wasmtime caching is disabled; Node/V8 may perform tiered compilation during execution. This is not a warm-session benchmark.

prepare-compiler.mjs authenticates the original site compiler bundle against compiler.json and the goldens, validates it with the site's bundle decoder, then emits a compact indexed DAG and the site's contract environment. Haskell loads this trusted benchmark transport, preserving sharing. This avoids bringing native crypto/vector/package dependencies into the initial evaluator experiment. It does not establish that the Haskell CLI's bundle loader has been ported. The transport is not an interchange format or a supported public API.

Loading and input construction are outside compiler timings. TS authenticates and decodes the original bundle during loading; Haskell loads the prepared DAG. Consequently loading and total process times are not like-for-like loader benchmarks. Reports retain the original compiler pin, golden-file and prepared-DAG hashes, binary hashes/sizes, tool versions, all samples and medians.

The build checks all seven cases on native Haskell, Wasmtime and Node/V8, plus a TS oracle smoke. Negative controls reject a wrong expected result, a self-referencing DAG, a truncated DAG, corrupt compiler bytes during preparation, and missing WASI filesystem permissions. The Node wrapper propagates WASI's exit status; otherwise a failed Haskell process can appear successful to its supervisor.

The runner gives each process 120 seconds and each Haskell runtime a 512 MiB heap/8 MiB Haskell-stack budget. TS uses the site's existing graph limits. RTS allocated_bytes, max_live_bytes, and max_mem_in_use_bytes include loading and validation, not just compilation. They are not total process RSS or WASM linear-memory size. Node's TS maximum RSS is separately labelled in raw samples. Browser loading, persistent-instance ownership, JS tree transfer, cancellation, and process-wide memory limits are not measured by this command-line probe. See the separate browser reactor below.

Persistent browser reactor

The separate opt-in target provides an isolated static test page and Worker, not a selectable backend in the published demo UI:

nix build .#haskellWasmBrowser --out-link /tmp/tricu-haskell-wasm-browser
nix shell --inputs-from . nixpkgs#nodejs nixpkgs#chromium -c node \
  ext/haskell-wasm/check-browser.mjs /tmp/tricu-haskell-wasm-browser \
  /tmp/haskell-wasm-browser.json

nix shell --inputs-from . nixpkgs#nodejs nixpkgs#firefox -c node \
  ext/haskell-wasm/check-firefox.mjs /tmp/tricu-haskell-wasm-browser \
  /tmp/haskell-wasm-firefox.json

Both runners use the same page/Worker assertions and unchanged reactor. Chromium uses CDP; stock Firefox reports completion to a tokenized localhost endpoint, without Playwright patches or WebDriver. Each runner uses a temporary localhost server and fresh browser profile, removes both on exit, and writes the browser version, compiler provenance, reactor hash and size, every timing sample, initialization phases, memory observations, and assertion results. It runs the same five successful compiler cases five times in one persistent Worker, comparing export names, all compiled trees, and application results. Both diagnostic cases must match exactly. Timing includes bulk Haskell response encoding; round-trip timings also include Worker messaging and main-thread UTF-8 decoding. Do not compare these measurements directly with fresh-process Node timings without accounting for the different boundaries.

Additional controls cover prepared-input hashes, valid Unicode source against an independent scalar-tree golden, lone-surrogate/oversized source, malformed ternary input, invalidation of prior definitions after a failed compile, single-flight request rejection, 30 compile/run/reset cycles, inaccessible exports after reset, a main-thread heartbeat during divergent reduction, deadline-triggered termination, rejection of further requests to the disposed Worker, and successful execution in a replacement Worker.

browser-parity.mjs additionally checks all eight curated authenticated packages against 117 existing Haskell result/terminal goldens, including four Conversation IO cases, then compiles all eight sources using the empty/practical environments. It checks all seven output codecs, invalid bytes/scalars/records, authentication and profile rejection (failed loads preserve exports; failed compile replacements invalidate them), a 40-level exponentially shared tree, bounded preview, immutable and runtime DAG export, and refusal to export truncated snapshots or oversized expanded trees. IO controls cover sentinel version, unsupported actions, action/output limits, stale replies, GC while awaiting input, invalid-line retry, explicit cancellation and subsequent recovery. Separate divergent-payload tests ensure bounded preview and abort traces do not force excluded program subtrees. Both browser reports include these assertions.

Boundary and ownership

Reactor.hs links the unchanged runtime into a WASI Preview 1 reactor. The pinned upstream browser_wasi_shim supplies the required imports. The loader calls WASI _initialize once and then the C initializer, which calls hs_init_with_rtsopts. This synchronous C-export path needs no generated JSFFI loader and exercises neither asynchronous JSFFI nor Haskell parallelism.

Each Worker owns one runtime, an optional retained compiler/environment and at most one current definition set. Source is UTF-8 copied into Haskell-owned bytes before the JS allocation is freed. A successful compilation atomically replaces the definitions after its names response is materialized. Starting a replacement clears the preceding public interface and expression scope, including on failure. Native modules retain a separate local/import expression scope. Imports supply lexical bindings; reexports select dependency exports for the public interface. Header parsing and name qualification invoke authenticated source_module trees, with bindings hydrated from bundles verified against both transport and manifest pins. The compiler-exports.tsv root table is authenticated with the prepared compiler kit. Program trees stay lazy until inspected or applied. dump and observe produce a bounded bulk UTF-8 response. Haskell copies that response into a JS-requested WASM buffer and clears its retained reference. JS clones the buffer, frees it, and transfers the resulting ArrayBuffer to the parent. The UI receives serialized values and runtime statistics. Legacy observe/dump ternary output expands sharing and is retained for benchmark comparisons only. preview/snapshot use local IDs, stable-name identity with collision checks, and explicit aliases. Labels are scoped to each snapshot. Preview registers child references and observes nodes within its budget. A complete snapshot is converted to the site's immutable DAG export input, rejecting partial graphs and cycles. Stored package export uses the original validated bundle records. The framed binary wire serves the private embedding protocol.

The JS wrapper refreshes linear-memory views after calls that might grow memory. reset discards current definitions and collects while retaining the compiler; dispose terminates the Worker and invalidates the entire runtime. Both client and Worker enforce single-flight requests. There is no default compile/run timer: request(message, null) (the default) and Infinity disable the timer; a positive finite millisecond value supplies an explicit deadline. Tests use finite deadlines to verify termination. A deadline, fatal WASM trap or RTS asynchronous/resource exception discards the instance instead of resuming it. Expected input/compiler errors are recoverable. Termination loses unsaved definitions; callers must initialize a new Worker to resume work.

Wire/observation limits are separate from user execution budgets. They include 16,384 source scalars, 16 compiled exports (package tables permit 4,096), 16 arguments, and 16 MiB framed input/response bytes. Typed inputs use the site's literal validator, including its nesting/item/text bounds. Legacy ternary input is capped at 50,000 digits per argument. Preview permits 1–256 emitted nodes; complete snapshots permit up to 100,000, and refuse partial export. Those limits do not bound the reduction needed to expose any one node: a divergent reduction still needs explicit Worker termination. Shared export retains the site's 32 MiB expanded / 128 MiB DAG file limits. The RTS receives a 256 MiB heap and 8 MiB Haskell-stack budget; the linker caps linear memory at 512 MiB. These are not a browser-process memory cap. Reported linearBytes includes WASM linear memory only, excluding JS buffers, compiled machine code and browser overhead. Reset/GC can reuse memory but WebAssembly memory does not shrink. The plateau assertion covers this finite workload, not arbitrary programs.

The tracked checks have passed on Chromium 148 and Firefox 150.0.2 on Linux. This does not establish support for earlier releases, other operating systems, or Safari. Fresh-Worker startup samples use localhost and may benefit from browser caches; they are not Internet cold-download measurements. Browser timer precision differs: a reported zero for a short transfer/observation is below the clock's resolution, not proof of zero cost. The site adapter has separate real-page integration tests; this isolated suite does not exercise that UI. Restoration of definitions after termination is not supported. Prepared compiler DAG/environment bytes are authenticated against hashes embedded in the built Worker assets; their original compiler provenance is verified during Nix preparation. Ordinary package/environment bytes instead pass the site's SHA-256 and bundle decoder inside the Worker before conversion to the checked internal DAG. The Haskell core does not implement a second bundle parser.

Worker operations

browser-client.mjs builds to client.mjs; its CompilerWorker loads the adjacent worker.mjs. Both paths must come from the Nix browser output. Successful replies carry UTF-8 text, milliseconds, roundtripMs and actual linearBytes. Except for the legacy text operations and compilation names, structured results are JSON in text. These are private adapter operations, not yet the published site's UI protocol:

Operation Inputs / result
init Default loads the build-pinned compiler kit; compiler:false starts a package-only session without downloading the kit. Ready identifies compilerHash (or null).
load bytes: ArrayBuffer, lowercase whole-bundle hash; returns export names, atomically replaces definitions.
compiler-init Lazily authenticate/load the prepared compiler kit into an existing package session; returns compilerHash without replacing definitions.
expression Private adapter request: one wrapped expression (name, source), mode, output. Compiles against the retained expression scope (package exports, or draft locals and explicit imports) without changing it; returns a value view or starts the ordinary IO event protocol. Requires an initialized compiler and idle IO.
compile source, optional standalone environment or site profileName/same-origin base; returns newline-separated public names and scopeNames. Native headers resolve manifest imports. A failed replacement leaves no previous definitions.
evaluate name, typed arguments, output; returns the site's ValueView.
preview / snapshot name, typed arguments, optional limit; returns {root,nodes,aliases,truncated}.
export name, format (ternary, tricu, triangle, dag); returns the shared exporter's TreeFile. Stored bundles are exported without evaluation; compiled definitions require a complete snapshot.
io-start name, typed arguments; starts the resumable action machine.
io-resume token, optional line value; consumes exactly the current event token.
io-view / io-snapshot Decode/inspect the completed IO result without moving a continuation into JS.
io-cancel Drop the pending/completed interaction, retaining definitions and compiler.
reset Drop definitions and IO state, collect, retain compiler.
collect GC while retaining definitions, compiler and any IO continuation/result.
stats Actual WASM linearMemoryBytes.

Compilation environment semantics are explicit: omitted means the retained contract environment; null means empty; {bytes,hash,names} means exactly those named exports from an authenticated environment bundle. Missing names, duplicate names and wrong hashes are errors. The shared site host owns profile selection and checks its compiler pin against ready's compilerHash.

Resumable browser IO

BrowserRuntime.hs handles only pure, bind, abortIO, putStr, and getLine. The native IODriver (filesystem, networking, threads, etc.) is not pulled in. The machine returns before, read, write, after, or done events with monotonically increasing session-local tokens. JS awaits the before/step hook before acknowledging it; a later event requests the actual read/write effect. after includes step/name/value trace fields without forcing arbitrary values. On done, the retained result is available through io-view/io-snapshot. No Haskell FFI call remains blocked while the user is entering a line.

An adapter maps these events to the shared IO host's before/after/write/read/ running callbacks. Page request IDs are independent of internal resume tokens. It must cancel the interaction on terminal driver errors; an invalid line or stale token leaves the pending state intact so a caller may retry. io-cancel also permits cancelling a paused interaction without destroying compiled code. Loading/compiling while an interaction is pending is rejected; reset or Worker disposal also cancels it. There are no automatic effects or default terminal fallbacks. Bounds match the site: 128 actions, 256-scalar single-line input with no CR/LF, 4,096-scalar decoded strings and 16,384 total output scalars.

Upstream references:

The upstream README at this revision recommends Cabal-install 3.14 or earlier because of a reported 3.16 regression. The selected flavour targets WASI Preview 1; browser validation does not imply native WASI Preview 2 or component-model support.

The editor's tricu, triangle, dag and ternary result interpretations use view to return a bounded complete snapshot (up to 100,000 nodes) through the private protocol. The site adapter then resolves snapshot aliases and formats the actual pure/IO result with the shared tree serializer. Serialization requires a complete, acyclic snapshot.

The site adapter resolves local modules from page-owned bytes and verifies their manifest pins. module-snapshot observes the declared public trees as one shared list for portable export. Its 100,000-node budget includes the list spine. Observation can diverge; Worker termination cancels it. See the demo site's local module workflow.