tricu

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

README.md (15847B)


Experimental Zig/WASM evaluator

An isolated experiment, not the demo site's deployed runtime. It compiles the existing Zig owned-handle ABI and reducer to freestanding wasm32 ReleaseSafe, without WASI, libc, libuv, or JavaScript imports. Native IO returns ARB_IO_ERROR. The Node/browser-compatible adapter implements bundle loading, application, reduction, constructor observation, byte codecs and Unicode scalar text codecs. Native ABI behavior is unchanged.

Reproduce

From the repository root:

nix build .#tricu-wasm .#tricu-wasm-tests .#tricu-wasm-comparison --no-link
REPORT=$(nix path-info .#tricu-wasm-comparison)
cat "$REPORT/report.json"
cat "$REPORT/preview-report.json"

# Opt-in headless Chromium Worker validation and measurements
nix build .#tricu-wasm-browser --no-link
BROWSER=$(nix path-info .#tricu-wasm-browser)
cat "$BROWSER/report.json"

The WASM artifact is $(nix path-info .#tricu-wasm)/lib/arboricx.wasm. Nix caches measurement outputs. These are correctness smokes with recorded timings, not performance thresholds. Run without concurrent heavy workloads for useful comparisons; the harnesses are not CPU-pinned.

Node checks and measurements

Tests cover 300 independent Haskell conformance vectors, Haskell text codec vectors, stale and foreign-context handles within an instance, GC roots, malformed bundles, exhausted fuel and resume, lazy discard, byte roundtrips, unsupported IO and detached memory views after growth. They also check malformed UTF-8, invalid scalar trees, unpaired surrogates, exact scalar limits, allocation ceiling failure and recovery, nested scope escape/failure cleanup, and 200 retained-context GC cycles with stable owned-root and node counts. Bulk previews also run all 300 conformance cases, shared/deep graph bounds, undemanded divergent frontiers, failure output atomicity, repeated ownership checks and aggressive GC. Native Debug/ReleaseSafe tests inject every allocation failure into the snapshot builder.

The comparison alternates JS/WASM order for three fresh-process samples per case. It rebuilds the site's TypeScript and uses the site's pinned compiler bundle, not a newly generated compiler. It verifies complete string results and four existing compiler golden cases (all definition trees plus applied results). Each child has a 120-second timeout and Node's 512 MiB old-space limit. Reports include raw load, construction, evaluation/full-observation and total timings, peak process RSS, linear memory size, fixture and WASM hashes, Node version and CPU.

The identity/append cases use the same scalar-tree JS codecs on both backends. Their -bulk variants use the WASM byte ABI for ASCII inputs and complete output decoding; JS still uses the site codecs. Their -text variants use multibyte Unicode text and the WASM scalar codecs. Generic per-node operations still expose the cost of host crossings and retained root-table scans. The compiler cases use the bulk scalar input codec on WASM. Node/V8 timings are not browser results.

preview-report.json separately compares JS per-node observation, WASM per-node observation, and WASM bulk snapshots of the same 128-node unary tree. Each fresh process performs 100 inspections, with either zero or 2000 additional retained host roots. Three samples alternate order. All iterations check the complete shape; those checks are included in timing (string comparison for per-node output, record comparisons for snapshots). This measures observation/transport plus its check, not just reducer time. The fixture deliberately has no sharing, so the batch path does not gain an unfair logical-expansion advantage. Root-table pressure is synthetic, not a claim about typical browser sessions.

Browser Worker checks and measurements

browser.mjs serves a loopback-only test origin and drives a fresh Chromium profile through CDP. worker.mjs is an experimental test protocol, not the site's IO/Worker protocol. Both JS and WASM paths check the supplied bundle digest and use the site's strict bundle/DAG profile before instantiation/loading. The pins and bytes come from the local harness, not an untrusted remote descriptor.

Each backend runs in a module Worker. Three alternating-order samples use fresh Workers, with four compiler cases per retained compiler session. compileMs measures source encoding, compilation, decoding lazy definition handles and replacement of previous exports. observeRoundTripMs separately measures full independent definition/result checks, including message round trips. These phases are different from the Node combined evaluation/observation timing. Reports record Chromium's user agent, artifact hashes and graph/linear-memory diagnostics, not browser RSS or total memory. Compilation benchmarks omit Worker initialization and bundle loading, so they are not end-to-end page latency measurements.

Controls check:

Each request has a main-thread 30-second deadline; the test process has a 240-second guard. Chromium is headless with --no-sandbox for Nix build-sandbox compatibility, using only local fixtures. This is not a security test of a public browser deployment. Firefox, WebKit, mobile devices, background-tab throttling, large compiler jobs and the production IO protocol are not covered.

Backend session boundary

The experimental Worker selects a backend explicitly on initialization:

worker.postMessage({
  type: 'init', backend: 'zig-wasm', bundle: bundleBytes, hash: bundleSha256,
  name: exportName, artifacts: { wasm: wasmBytes }
});

backends.mjs owns the trusted factory registry (js, zig-wasm) and common bundle authentication/profile checks. Backend code is selected from that registry, never from a caller-supplied script URL. backends/js.mjs and backends/zig-wasm.mjs own runtime loading, text codecs, roots, collection and cleanup. worker.mjs only dispatches session operations and returns cloneable values. Unknown backends, missing artifacts and initialization failures are errors; there is no automatic fallback. The ready reply identifies the selected backend. Selection is immutable for that Worker; a second initialization is rejected.

The current session operations are runText, compile, inspect, preview, collect, stats, dispose, and a harness-only runTree probe. Operations may return values or Promises. Compile retains definition handles privately and only returns names; inspect/preview return copied values. Failed compilation preserves the previous definitions within this experimental session. Dispose is idempotent; other calls after disposal fail. Hard Stop still terminates the entire Worker, without relying on graceful disposal running during a blocked evaluator.

No handles, WASM pointers, runtime heap checkpoints or allocator conventions cross this boundary. stats contains backend-specific diagnostics, not a portable resource budget. The shared tree-session.mjs helper is an implementation detail of the current adapters, not an interface another backend must implement. A new backend may supply its own session implementation and artifact loader without using the Zig C ABI or the JavaScript Graph API. Async-adapter, disposal, authentication-before-initialization and no-fallback controls run in backend-check.mjs; Chromium exercises both registered implementations.

This boundary currently covers the experiment, not the deployed app's full IO, package environment or retained-draft protocol. The site has a Haskell/Zig selector and coarse session adapter (see ext/demo-site/src/runtime-session.ts). site-session.mjs is Zig's private production adapter; the older JS/Zig experiment remains an isolated harness. No backend shares another runtime's memory, handles or checkpoints.

Ownership and retained sessions

import {WasmRuntime} from './runtime.mjs';
const runtime = await WasmRuntime.create(wasmBytes);
try {
  const identity = runtime.load(bundleBytes); // explicitly owned session root
  const result = runtime.scope(() => {
    const input = runtime.ofText('λ雪🙂');
    return runtime.toText(runtime.app(identity, input));
  }); // all temporary handles released, including on failure
  console.log(result);
  runtime.collect(); // identity remains rooted
  runtime.release(identity);
} finally {
  runtime.dispose();
}

Handles are BigInts local to their WASM instance; never transfer them between instances. Constructors borrow inputs and return owned handles. observe and pair return owned child handles. retain produces an independent owned alias. apply returns an owned handle even with zero arguments. fromTernary and toTernary release internal temporaries as they traverse.

scope(callback) tracks newly returned handles, including observed children. It releases them on normal return or failure. keep(handle) marks a newly owned handle for transfer to the parent scope (or session) only on successful return:

const saved = runtime.scope(({keep}) => keep(runtime.ofText('retained')));
// ... later ...
runtime.release(saved);

Scopes are synchronous, not async lifetime managers. Never schedule asynchronous work inside a scope. Borrowed outer handles are not released by a scope. Handles returned without keep are stale after scope exit. Outside scopes, callers must explicitly release handles or destroy the context. Direct raw ABI calls bypass adapter ownership accounting and are for trusted tests only.

Collection reclaims unreachable graph nodes but does not promise to shrink the handle table, arena backing allocations, or WASM linear memory. Generic graph codecs can create large root-table high-water marks even when eventually scoped; use bulk codecs for text/bytes. stats() reports handle slots, owned roots, arena node-array length, and liveAfterCollect (the most recent collector live count, not a current count between collections). These are not byte limits.

Bounded bulk tree previews

runtime.preview(handle, limit = 64, fuel = 1_000_000n) returns a copied snapshot:

{ root: 0, nodes: [{ id: 0, kind: 'fork', children: [1, 1] }, /* ... */],
  truncated: false }

The node limit must be 1–256. One WASM call traverses constructors, preserving physical sharing without logically expanding repeated subtrees. Records are visited depth-first, left before right. Children outside the captured frontier have labels but no records; truncated is true if any unvisited label remains. Pending duplicates alone do not cause truncation. The frontier is not forced merely to decide whether the snapshot is complete.

Labels are snapshot-local numbers, not owned handles, arena node IDs, content identities, or cross-snapshot keys. Snapshots own no runtime roots and remain usable after collection or context destruction. This is a bounded view, not a paginated cursor or full tree-export format; passing labels back to the evaluator is invalid. Compiler logical-result checks still use the separate bounded toTernary path. No deployed tree-view UI has been switched to these snapshots.

Fuel is a u64 ceiling per constructor demand, not a total preview budget. A node cap limits transport and visited graph size, not the cost of evaluating each node. A divergent node within the captured region can exhaust fuel; a frontier node outside the region is left undemanded. As with reduction, failure may leave partial evaluation progress in the shared graph, but publishes no partial output buffer or handles. Use a Worker deadline for whole-job limits.

Unicode and resource boundaries

ofText/toText encode lists of Unicode scalars, not UTF-8 byte lists or UTF-16 code units. They reuse Zig's strict source-text scalar codec. NUL, CRLF, initial U+FEFF and noncharacters are preserved; unpaired surrogates, malformed UTF-8, noncanonical numeral trees, surrogate values and values above U+10FFFF are rejected. In particular, this strict decoder does not inherit the generic site's readNatural tolerance for redundant high zero bits. No normalization is applied. The default ceiling is 4096 scalars; callers can select a different nonnegative u32 ceiling (the Worker compiler uses 16384). A ceiling is not a deadline or fuel budget. ofBytes/toBytes remain byte-oriented and are not Unicode text codecs.

Views are recreated after calls that may grow memory, and decoded buffers are copied before being freed. WASM linear memory is capped at 512 MiB with an 8 MiB stack. Neither the WASM cap nor Node's old-space limit bounds total process RSS.

The raw adapter accepts trusted host buffers. The experimental Worker and the site session separately authenticate bundles and validate their profiles. The site session uses shared package/codecs/compiler helpers, keeps original stored export in shared JS, and implements bounded browser IO with opaque trace labels. It selects WasmRuntime.create(bytes, {unbounded:true}): ordinary demands, bulk text observation and previews have no fuel ceiling. Explicit finite demands remain supported by the raw harness. All execution remains cancellable by hard Worker termination; input/output/preview bounds and real allocation failures remain enforced. See the site guide for packaged release use and both-browser integration checks.

scopeAsync is an exclusive outer operation for the serialized site driver, not concurrent access to one WASM instance. Nested synchronous scopes transfer kept temporary handles into that outer scope; it releases everything on success or host/driver failure. Persistent definitions are owned outside run scopes. Resource/trap failures poison the session and require replacement, not reuse. The site reports actual linear memory only; raw diagnostic counts are used in ownership/collection tests, not presented as JS work or total-process memory.

The production adapter also supports runExpression(source, output, mode, host). It lazily authenticates/loads the pinned compiler when needed and binds the existing definition handles into a temporary compilation environment. The single expression result is scoped through observation/IO; its temporary name is not added to definitions. No stored tree is flattened to reconstruct source. The same exclusive async scope releases transient handles on success or error.

WasmRuntime.snapshot uses the WASM-only arb_host_snapshot_unbounded transport entrypoint for result files: 1–100,000 observed nodes, with unbounded reduction work per observation. The existing preview entrypoints retain their 256-node ceiling. Snapshot labels preserve sharing, use bounded dynamic storage, and never become session handles. site-session.mjs formats complete snapshots with the shared serializer; incomplete snapshots and oversized expanded files fail rather than producing partial downloads. Stored definition export still reads immutable bundle records without evaluation.