A toy Julia-like language, hosted in OCaml, compiled to WebAssembly via
wasm_of_ocaml (real WasmGC,
not a linear-memory emulation of one). Its numeric kernel calls out across a
wasm module boundary into faer, a
pure-Rust linear algebra library (no BLAS/LAPACK, no C, no Fortran), compiled
separately to wasm32-unknown-unknown (plain linear memory, no GC needed).
It exists to answer one question honestly: how much of Julia's actual
semantics — multiple dispatch, a real abstract-type hierarchy, structs,
closures, keyword arguments — can a small, hand-written interpreter cover, and
how does building that in OCaml compare to building the same thing in
JavaScript. (Short version: the type system's exhaustiveness checking catches
every place a new value variant needs handling, at compile time, by name.
JavaScript's switch has no idea the space of tags is closed, so the same
mistake surfaces later, at runtime, in an unrelated-looking stack trace.)
This is not a serious Julia implementation. Treat it as a demo of what's reachable in an afternoon-scale project, not as a foundation to build on without expecting to rewrite large parts of it. Everything documented here was verified by actually running it — the embedded demo, a real benchmark, or a real headless-browser harness — not by reading the code.
examples/ holds four benchmarks with their algorithm bodies taken verbatim
from JuliaLang/Microbenchmarks
(only the @test/@timeit macro harness is swapped for plain Tsubaki code) —
real code Julia's own team uses, not code written to flatter this interpreter.
All four produce the same answer as real Julia; none are fast.
| Benchmark | no cache | fully optimized | real Julia (1.12.6) | slowdown |
|---|---|---|---|---|
fib(20) (recursive) |
0.128 s | 0.006 s | 0.00023 s | ~25× |
qsort! 5,000 floats |
0.489 s | 0.027 s | 0.00049 s | ~56× |
pisum (5,000,000 float divisions) |
29.8 s | 0.80 s | 0.0032 s | ~250× |
mandelperf (complex-plane sweep, maxiter=80) |
— | 0.013 s | 0.00026 s | ~50× |
The honesty practice behind those numbers, in short:
- Bugs found by running real code, not synthetic demos. Every feature had
its own passing hand-written demo; the benchmarks (and later, unmodified
real package source) broke in ways those demos never did — a missing
>>>operator, destructuring that only accepted bare names, and a real scoping bug where every "local" was secretly one shared global (found because quicksort is recursive and actually exercises it). - Eleven optimizations, applied in order, each measured — inline caches
for
EBinOp/ECall, assoc-list scopes, allocation-free cache hits, variable-depth caches, identifier interning, physical-equality tag comparison — plus a real static-analysis phase (Resolve) that computes variable depths once before execution. Several were found bynode --prof, not by guessing: the dominant cost turned out to be variable-name lookup, not dispatch, and allocation was never the bottleneck the earlier reasoning assumed. None change any dynamic behavior (bit-for-bit identical output before and after each). - Tested against real, unmodified package source.
JuliaLang/Example.jl(the official minimal template) runs correctly, verbatim.DataStructures.jl'sdeque.jland the 1057-lineJuliaMath/Primes.jldrove many rounds of fixes, each found by nothing more than re-running the exact file (triple- quoted docstrings,export, inner constructors,whereclauses, bitwise ops, first-class type values for::Type{X}dispatch, …). Where the line falls is an honest snapshot, found by testing, not guessed at. - A second execution path. A zero-parameter function whose entire body is
restricted-numeric gets JIT-compiled (the first time it's evaluated, cached
per declaration site) into a small bytecode ISA run by a second interpreter
in the Rust kernel, with superinstruction fusion. ~6.6× faster than the
tree-walker on the unmodified
pisum(); anything outside the subset falls back to tree-walking, unchanged. Full story:AST_IN_RUST_EXPERIMENT.md.
Requires: OCaml + dune + wasm_of_ocaml-compiler (opam), Rust + the
wasm32-unknown-unknown target (rustup), Node.js 22+ (needs WasmGC, default
from Node 22 on).
make run # runs bin/main.ml's embedded demo program
make run FILE=path/to/prog.jl # runs that file insteadThere's no REPL, but a real .jl-style file works:
node -r ./preload.js _build/default/bin/main.bc.wasm.js path/to/prog.jl
(paths resolve relative to wherever node was launched from). With no path,
it falls back to the fixed demo at the bottom of bin/main.ml.
--frames N runs a program's on_frame callback N times headlessly (fixed
1/60s dt, no browser) — enough to exercise a frame's logic with no pixels.
The draw/input/audio host functions are stubbed by preload.js; a frame that
raises stops the run with a non-zero exit code. Flags may come before or
after the path, and an unrecognized argument is an error (it used to match no
shape at all and quietly run the built-in demo instead).
make build alone produces _build/default/bin/main.bc.wasm.js (OCaml side,
real WasmGC) and kernel/target/.../tsubaki_kernel.wasm (Rust side, plain
linear memory). preload.js wires them together — see the comment at its top
for why it's a separate --require preload (short version: wasm_of_ocaml's
loader resolves its .assets/ dir from require.main.filename).
gpu/ is a second, independent Rust crate (tsubaki-gpu) exposing WebGPU to the
browser, aimed at GPU-accelerated visualizations (N-body, wave equations,
Julia/Mandelbrot sets — anything embarrassingly parallel over a buffer) from a
page that also hosts Tsubaki.
It is deliberately not part of kernel/. That module is raw linear memory,
loaded synchronously with zero imports. wgpu's web backend can't be that:
adapter/device requests and buffer readback are all async Promise calls, which
only work through wasm-bindgen (JS glue, Promise-returning exports, an
externref table) — a different-shaped wasm module. So gpu/ is its own crate
with its own build step.
The API is a small resource-handle model (buffers/pipelines are opaque u32
handles the caller creates and destroys explicitly), so a toy gets real control
over shape and lifetime. See gpu/src/lib.rs for full per-function docs;
summary:
await gpu_init(powerPreference?, canvas?)— requests a GPU adapter+device once ("low-power"/"high-performance"/ omitted). Returns what the browser actually granted ({name, backend, deviceType, driver}). Prefers real WebGPU but transparently falls back to WebGL2 where WebGPU isn't available (Safari without the flag, older browsers, some headless/CI), via wgpu'snew_instance_with_webgpu_detection(which actually probesrequestAdapter(), not justnavigator.gpu's presence).canvasis optional and only needed for that fallback — a browser WebGL context is intrinsically tied to a canvas, so a canvas-lessgpu_initcan only ever land on WebGPU. Disclosed: WebGL2 has no compute stage at all (so compute fails cleanly at device-request time on a GL adapter), and the two backends can pick different default surface formats, so the same draw can render at a visibly different brightness across WebGPU vs. WebGL2 — shape/ interpolation correct, just not pixel-identical.create_buffer(sizeBytes, kind)→ handle —kindis"storage-read"/"storage-read-write"/"uniform".write_buffer(handle, f32Array)(sync) andawait read_buffer(handle)(async — WebGPU readback always is) round-trip data.destroy_buffer(handle)releases it.await create_pipeline(wgsl, entryPoint, bindingKinds)→ handle — compiles a@compute fn <entryPoint>shader, one binding perbindingKinds[i]("storage-read"/"storage-read-write"/"uniform"/"texture"/"sampler", in@bindingorder). Content-hash cached — calling it every frame with the same shader returns the cached handle.destroy_pipeline.dispatch(pipelineHandle, resourceHandles, wgX, wgY, wgZ)— sync, bindsresourceHandles[i]at@binding(i)(each resolved to buffer/texture/ sampler automatically) and submits one compute pass. Cheap per frame.await gpu_on_device_lost()— resolves once on device loss;gpu_initagain to recover.gpu_shutdown()— drops everything deterministically.
Rendering to a canvas is the same handle model, one resource deeper:
configure_canvas(canvasElement, width, height, alphaMode)— creates and configures awgpu::Surfaceusing whatever format/present-mode the adapter reports (viaget_capabilities, never assumed).alphaModeis"opaque"or"premultiplied"(genuinely composites with the page behind), checked against what the browser actually supports — asking for one it doesn't is a real error. For"premultiplied", pair with"premultiplied-alpha"blend and premultiply your own colors.await create_render_pipeline(wgsl, vertexEntry, fragmentEntry, bindingKinds, topology, blend)→ handle — same shape ascreate_pipeline, for a@vertex+@fragmentpair. No vertex-buffer layout at all (geometry off@builtin(vertex_index)).topologyis"triangle-list"/"triangle-strip"/"line-list"/"line-strip"/"point-list";blendis"replace"/"alpha"/"premultiplied-alpha".destroy_render_pipeline.begin_frame(r,g,b,a)/draw_frame(pipeline, resources, vertexCount, instanceCount)/end_frame()compose one frame from several draws: eachdraw_frameLOADS (never re-clears) the same texture, so a curve, points, and labels layer onto one frame instead of each wiping the last.create_texture(width, height, rgbaBytes)→ handle (uploads raw RGBA8, e.g. straight from a 2-D canvas'getImageData().data) +destroy_texture;create_sampler("nearest"|"linear")→ handle (clamp-to-edge) +destroy_sampler. Bind either with kind"texture"/"sampler".
Build with make build-gpu (needs wasm-bindgen-cli, version-matched to the
wasm-bindgen crate — a mismatch fails loudly). Produces gpu/pkg/tsubaki_gpu.js
.wasm, loadable from any page. Three test pages (gpu/{test,render-test,text-test}.html) each verify real output (read-back pixels + screenshots), run against real Chrome headless viapuppeteer-core— none is "it compiled." The caller always supplies the shader text; the crate ships no WGSL of its own.
Disclosed limits. Only f32 (WGSL/WebGPU has no f64; a Tsubaki-side caller
narrows/widens at the boundary). WebGPU's own error-reporting mechanisms
(error scopes, on_uncaptured_error) both route through a wgpu-30 conversion
that panics on any error class beyond Validation/OOM (headless Chrome
reports GPUInternalError), so neither is used — create_pipeline catches
shader-source errors via ShaderModule::get_compilation_info() instead;
later-stage errors (a bind-group layout not matching the shader) still have no
safe catchable path.
The API above is genuinely async, and Eval.eval is a plain synchronous
recursive function — calling one of these from a Tsubaki builtin needs the whole
interpreter call stack to suspend and resume. bin/async.ml performs an OCaml
5 effect (AwaitJs) at the await point inside a builtin (see
bin/gpuBridge.ml), with an Effect.Deep handler around both entry points
(Eval.run, GpuBridge.run_frame). From a Tsubaki script's own point of view,
gpu_init() reads and behaves like any other synchronous call:
info = gpu_init("high-performance")
buf_in = create_buffer(32, "storage-read")
buf_out = create_buffer(16, "storage-read-write")
write_buffer(buf_in, [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0])
pipeline = create_pipeline(wgsl_source, "main", ["storage-read", "storage-read-write"])
dispatch(pipeline, [buf_in, buf_out], 1, 1, 1)
result = read_buffer(buf_out) # suspends & resumes transparentlybin/dune builds with --effects=cps (whole-program CPS), not the default
--effects=jspi (which needs the runtime's JS Promise Integration, absent
under plain node). Cost: a mostly-fixed per-run overhead (~2.8–3× on the
three short benchmarks, ~1.08× on pisum, which amortizes it over 5,000,000
iterations). A rejected Promise resumes with discontinue k (Failure msg) —
the exact exception a script's try/catch already catches. Verified end to
end in headless Chrome via examples/gpu_reduce.jl +
web/gpu-compute-demo.html.
ECS SoA storage feeds this directly. A component struct with all-::Float
fields and no mutable gets stored columnar (Runtime.soa_eligible);
soa_flatten(kind, fields) reads its whole live column set into a flat Vector
in one OCaml pass (~53× faster than a per-entity interpreter loop), ready for
write_buffer. draw_frame's instance_count draws N entities from one call
(a WGSL vertex shader indexing a storage buffer by @builtin(instance_index));
a camera transform is just a small uniform the vertex shader reads. Examples:
ecs_gpu_soa_reduce.jl / ecs_gpu_instanced.jl / ecs_gpu_camera.jl, each
verified in headless Chrome. offset_xy(vec, dx, dy) rebases large-magnitude
world coordinates onto a local origin before the f32 narrowing loses bits.
to_wgsl(kernel, buffers) (bin/compile.ml's Compile.Wgsl) compiles a
restricted subset of Tsubaki syntax into real WGSL — the same Julia-to-shader
idea WGPUCompute.jl established for real Julia, mirrored here. kernel is a
quote ... end block; buffers is a Dict mapping each buffer name to its
BindingKind string, in insertion order (which becomes the binding index), so
the result feeds straight into create_pipeline:
kernel = quote
output[gid] = input[gid] * 2.0
end
buffers = Dict()
buffers["input"] = "storage-read"
buffers["output"] = "storage-read-write"
wgsl = to_wgsl(kernel, buffers) # real WGSL, ready for create_pipelinegid is a magic free name — the invocation's element index
(@builtin(global_invocation_id)). The accepted subset is arithmetic/
comparison, if/for/while, buffer read/write, and vec2/vec3/vec4/
mat4 (below); a comparison's result may only be used directly in an
if/while condition (WGSL's bool is a distinct type this compiler never
casts to). Workgroup size is a fixed 64, entry point always "main" — both a
disclosed v1 cut. Verified as a full round trip (examples/wgsl_double.jl:
Tsubaki → WGSL → real GPU compute → [2,4,…,16]), not just "the string looks
right."
to_wgsl's compute shape has nowhere to run as GLSL — real WebGL2 has no
compute stage (that needs OpenGL ES 3.1, one major version past WebGL2's ES
3.0 base). to_glsl(vertex_kernel, fragment_kernel, uniforms)
(bin/compile.ml's Compile.Glsl) targets what WebGL2 actually runs: a
vertex+fragment pair. Structural near-twin of Compile.Wgsl (same restricted
subset, same first-assignment-wins Int/Float inference), different magic
names:
vertex_kernel = quote
if vertex_index == 0
pos_x = 0.0; pos_y = 0.6
elseif vertex_index == 1
pos_x = -0.6; pos_y = -0.6
else
pos_x = 0.6; pos_y = -0.6
end
end
fragment_kernel = quote
c = color
frag_r = c.x; frag_g = c.y; frag_b = c.z; frag_a = 1.0
end
uniforms = Dict()
uniforms["color"] = "Vec3"
shaders = to_glsl(vertex_kernel, fragment_kernel, uniforms) # [vertexGlsl, fragmentGlsl]vertex_index (vertex only — GLSL's gl_VertexID) is the magic input;
pos_x/pos_y and frag_r/frag_g/frag_b/frag_a are the magic outputs,
assembled into gl_Position/fragColor. uniforms maps each name to its
type ("Float", a vecN struct name, or "mat4") — WebGL2 sets uniforms by
name, so there's no positional binding index like to_wgsl's buffers. No
varyings yet (the vertex stage can't hand the fragment stage anything beyond
gl_Position) — a disclosed v1 cut. Verified against a real WebGL2 context
(gl.compileShader/linkProgram/drawArrays, read-back pixels, screenshot).
to_glsl emits GLSL; a small synchronous runtime actually runs it, driven
from Tsubaki source, through the plain browser WebGL2 API — no wgpu/Rust in the
path (bin/webglBridge.ml, the JS side in web/webgl-demo.html's
host_webgl_* functions). WebGL2 is entirely synchronous (context, compile,
link, uniforms, draw), so unlike the WebGPU path this needs none of
async.ml's effect machinery — every call is an ordinary host_call, same
shape as gpuBridge.ml's draw_rect.
webgl_program(vertexGlsl, fragmentGlsl)→ Int handle (compiles+links; throws the real info log on failure).webgl_clear(r, g, b, a).webgl_uniform(prog, name, values)— the value's LENGTH picks the call, covering exactlyto_glsl's uniform types: 1 → float, 2/3/4 → vecN, 16 → mat4. A numericVectorcarries scalars/vecN; aMatrixliteral (row-major, real Julia's[.. ; ..]) is the natural way to write a mat4 — uploaded withtranspose=true(WebGL2 honors it) so a row-major Tsubaki matrix lands correctly in GLSL's column-majormat4.webgl_draw(prog, mode, vertexCount)—useProgram+drawArrays;modeis"triangles"/"triangle_strip"/"lines"/"line_strip"/"points". No vertex buffers (geometry offgl_VertexID).
shaders = to_glsl(vertex_kernel, fragment_kernel, uniforms)
prog = webgl_program(shaders[1], shaders[2])
transform = [1.0 0.0 0.0 0.3
0.0 1.0 0.0 0.0
0.0 0.0 1.0 0.0
0.0 0.0 0.0 1.0]
webgl_uniform(prog, "transform", transform)
webgl_uniform(prog, "color", [0.25, 0.85, 0.45])
webgl_clear(0.06, 0.07, 0.12, 1.0)
webgl_draw(prog, "triangles", 3)Verified as a real rendered image (examples/webgl_triangle.jl +
web/webgl-demo.html, headless Chrome + screenshot): a green triangle,
visibly shifted right by the mat4 translation — which is what proves the
row-major→transpose→column-major path is correct, not just plausible. Serve
the repo root (python3 -m http.server) and open
/web/webgl-demo.html?src=../examples/webgl_triangle.jl.
A Tsubaki struct is "vecN-eligible" when it's flat, immutable, non-parametric,
has 2–4 fields, every field is exactly ::Float, and the field names are
exactly x/y[/z[/w]] in order (WGSL's/GLSL's own swizzle names):
struct Vec2
x::Float
y::Float
endConstruction, swizzle reads (.x/.y/.z/.w), and arithmetic (vecN ± vecN, vecN * scalar either order, vecN / scalar) all compile to the real
operators (* on two vecNs is componentwise, as in WGSL/GLSL). to_wgsl
buffers can hold vec2/vec4 elements
(buffers["p"] = "storage-read-write:Vec2") but not vec3 (WGSL pads
array<vec3> to 16 bytes, which a tightly-packed write_buffer can't match —
a vec3 uniform is fine). A mat4 comes from a Tsubaki matrix literal (4×4 of
Floats), as a uniform only, and is transposed on the way out (Tsubaki's rows
are row-major, WGSL/GLSL's constructors fill column-major). Verified against
real GPU/WebGL2 execution (examples/wgsl_vec2.jl, examples/glsl_triangle.jl
— the latter's triangle visibly shifts under a mat4 translation).
cos/sin/tan/asin/acos/atan/atan2/hypot/exp/log/floor/
ceil/round (bin/runtime.ml) — thin wrappers over OCaml's Stdlib,
accepting Int or Float uniformly (pi is a plain global). Without them,
anything needing an angle (circular motion, rotation, bearing) had no way to
be written in Tsubaki at all.
- Multiple dispatch, close to Julia's real algorithm: a single-inheritance
abstract-type hierarchy up to
Any, most-specific-applicable resolution, and a genuine ambiguity error when two candidates tie. abstract type,struct/mutable struct(<: Parent) — each registers a real runtime type, not a hardcoded enum case. Field access; for mutable structs, field assignment.- Parametric structs, any number of parameters:
struct Box{T}/Pair{K,V}infers each concrete type from whichever field is declared exactly::T, and registersBox{Int} <: Boxon the fly. - Inner constructors and
new/new{T}— astructbody can definefunction StructName(...) ... end, replacing the default constructor.new(...)builds the raw struct directly (and can take FEWER args than fields, leaving the rest assigned afterward — what makes a self-referential/ circular struct constructible at all). Short-form one-liner constructors and the generalName{T}(args)call shape work too. - Modules (
module Name ... end/using Name): namespacesfunction,struct/abstract type, andmacrodeclarations; nested modules work (using Outer.Inner).Name.member(...)is a strict qualified call, nousingneeded, any chain length.import Name: a, bbinds only the named members bare. Constructing a type qualified vs. bare produces the identical runtime tag. - Macros, with real hygiene,
gensym,esc—:( expr )/quote ... endquote code asSymbol/Exprvalues;$(expr)/$namesplice;macro name(args...) ... end(args arrive unevaluated, matched by count) +@name(args). Hygiene renames a macro's own template variables to fresh names so they can't collide with the call site (verified with the classic@swap!);esc(x)strips that off to reach the caller's scope;eval(quoted)runs it.@namecan also wrap a whole STATEMENT (@inline function f(x) ... end); a fixed set of compiler-hint macros (@inline/@inbounds/…) are pure identity. Quoting covers most of the grammar (see "does not do" for the scoped-out edges). Union{A,B,C}annotations in signatures.- Complex numbers:
complex(re, im),real,imag, and+/-/*/^onComplex(aNumbersubtype) — added to runexamples/mandel.jl. - Closures:
x -> expr,(a, b) -> expr, with real lexical capture. Namedfunctions declared inside another function close over its locals (read and mutate). One caveat: re-declaring the same named function adds a candidate method rather than replacing it. - Keyword arguments:
f(a; k = default)— a side channel, never part of the dispatch signature, matching real Julia. - Control flow:
if/elseif/else,for x in/= <range or vector>,while, explicitreturnplus "last expression is the value." try/catch/error(msg): user errors and the interpreter's own (MethodError,UndefVarError, …) are both ordinary catchable values.&&/||with real short-circuit evaluation.- Vectors: literals, comprehensions,
push!(real heap growth), 1-indexedv[i]read/write,v[end]/v[end-1],length, slicing (v[2:4],v[2:end]). - A
Basecollection vocabulary:max/min/clamp, andmap/filter/sort(by=,rev=)/any/all/count/sum/maximum/minimum/pop!overRange/Vector/Array/Tuple/Dict.filter/mapgive back the shape they were handed; ordering goes through Tsubaki's own<dispatch. - A named function is a value.
f = double,filter(fell, balls)— the value is the whole generic function, dispatched on the arguments it receives. Dict:Dict()thend[k] = v/d[k](missing key raisesKeyError;get(d, k, default)doesn't), plushaskey/delete!/keys/values/lengthandfor (k, v) in d. Keys are Int/Float/Bool/String/ Symbol/nothing (d[1]andd[1.0]are the same entry); insertion-ordered. No=>literal (see "does not do").Array: a Vector that can hold anything. A literal is a numericVectoronly if non-empty and every element is a number; otherwise (or if empty, Julia'sVector{Any}) it's anArray. Samepush!/length/v[i]/ slicing/for, holding real values.Array{T}dispatch, inferred or declared: a homogeneousArray's tag reflects its contents (Array{Named});Array{Player}()is a real constructor enforcingPlayeron every laterpush!.- 2D comprehensions: a second
forclause builds a genuine 2D result (all-numeric → the FFI-friendlyMatrix; otherwise a row-major Array-of-Array). Three+ clauses raise a clear error. - A generic boxed
Matrix{T}container:Matrix{Named}(undef, m, n),A[i,j]-indexed over any element type, elementwise+/-and scalar*dispatching each cell through Tsubaki's ownTmethods. Rational:n // dbuilds a GCD-reduced fraction (Int-backed, not BigInt — a disclosed cut). Stays exact between Rationals or Rational/Int; mixing a Float promotes to Float.numerator/denominator/abs.- Ranges with a step:
a:step:b(Int or Float, computed asstart + i*stepso error can't accumulate). - Multiple return values and destructuring:
return a, bbuilds aTuple;x, y = f()destructures it; targets can be full lvalues (a[i], a[j] = a[j], a[i]). - Ternary, compound assignment (
+=/-=/*=//=/>>=),%,>>>, bitwise&/|/⊻and<</>>(with real Julia's arithmetic-like precedence, not&&/||'s), unary!,===/!==(reference identity). - Numeric literal coefficients (
2x,2I,2(x+1),2^3x→2^(3*x)). - Strings:
\"/\\/\n/\t/\$escapes,"hi $name"and"$(a + b)"interpolation, triple-quoted"""..."""docstrings. export a, b, candconst NAME = exprparse and are discarded — real Julia hints meaningless here (found necessary running real packages).- Matrix literals, real Julia's own syntax:
[1.0 2.0; 3.0 4.0](whitespace within a row,;between rows; comma rows work too; a whitespace-only single row builds a genuine 1×NMatrix).A * vgoes straight through the Rust/faer FFI. - Struct field type annotations are enforced, at construction and on later
assignment (
p.y = "nope"raises a catchableTypeError). - Fixed-width integers
Int8/Int16/Int32/UInt8/UInt16/UInt32(anInteger/Signed/Unsignedlayer underNumber);T(x)is a checked conversion (InexactErroron out-of-range), same-type arithmetic wraps on overflow.f.(container)broadcast (single-arg).Int[]/Int[1,2,3]typed literals.Vector{T}(undef, n)/Matrix{T}(undef, m, n). - First-class type values and
::Type{X}dispatch — a bare type name evaluates to aVType;f(::Type{Deque{T}}) where Tandfactor(::Type{A}, n) where {A<:AbstractArray}dispatch on the type itself, via the existing covariant-parametric matching. - A typed exception hierarchy —
catch e; isa(e, DimensionMismatch)ande.msgboth work. One parser (exn_of_failure_message) turns the existing"Kind: message"convention into realisa-checkableVStructs at the one place aFailureis caught;showprints them exactly as before. - A real cross-module FFI boundary:
*on(Matrix, Vector)androtate(vec2, angle)call the separate Rust/faer wasm module, copying bytes across the GC↔linear-memory boundary by hand. LinearAlgebracompatibility (faer does the numerical heavy lifting; seeROADMAP.mdfor the staged plan and where full parity isn't reachable in principle):- Core ops:
A[i,j]get/set,A * B(rectangular, checkedDimensionMismatch),transpose(A)/A',dot(a,b)/a ⋅ b,norm(v[, p]),zeros/ones,size, andLinearAlgebra.Ias a real lazyUniformScaling(A + I,2I,I * v, …). Scalar*andVector - Vectoralongside. - Solves/decompositions:
A \ b,det,inv,tr,rank, plus real factorization objectslu/qr/cholesky/svd(field names matching real Julia —.L/.U/.p,.Q/.R,.U/.S/.V). Eigen (eigvals/eigvecs/eigen) branches on symmetry: a symmetric input stays real; a non-symmetric one returns genuineComplexVector/ComplexMatrixresults (a separate FFI export wrapping faer's general eigendecomposition). Disclosed gap: an exactly singularAisn't reliably detected by\/inv(partial-pivot LU can returnNaN/Infinstead of raising). - Wrapper types
Symmetric/Diagonal/UpperTriangular/LowerTriangular/Tridiagonal(*/\/det/inv/tr, with real O(n)/O(n²) algorithms forDiagonalandTridiagonal's Thomas solve, densify-and-redispatch for the rest),± Iabsorbing into each, and the long tail (issymmetric/ishermitian,isposdef,logdet(overflow-safe),cond,pinv,nullspace(any shape, via full-V SVD),kron). - Sparse (
SparseMatrixCSC):sparse/spzeros/nnz/*/\(square, via faer'ssp_lu); COO/triplet input like real Julia'ssparse(I,J,V).
- Core ops:
- Compiles to an actual WasmGC module — verified by disassembling and finding
real
(type (struct …))/(type (array …)), not a linear-memory emulation.
Dicthas no=>literal.Dict()thend[k] = vis the only way;Dict("a" => 1)doesn't parse (it needs aPairtype that collides with the demo's ownstruct Pair{K,V}, plus varargs dispatch doesn't have — three decisions, not one). Keys are limited to immutable scalar types.- A runtime error names the function, not the line.
MethodError/HostErrorname what failed but carry no source position (the AST doesn't carry one). Parse errors do have line/column, andparse_stmt_listrecovers across statements — one pass reports every independent mistake, each with its own line/col (it still won't run with any errors, only diagnoses better). module/using/importare a real but narrow subset. No export lists; two modules declaring an unrelated same-named type bothusing'd end up treated as related (the same "same name → merged" simplification same-named functions already accept); no bareName.memberfor a non-call member (no first-class module value, plain variables aren't namespaced).- Macros/quoting cover a scoped subset of the grammar. Not quotable
(raises a clear error):
Vector{T}(undef, n)and a bare evaluated block. Macros are namespaced by module and are NOT merged byusing(unlike functions/types). Hygiene renames variable-binding/reference positions only, never a call's function/operator name or a.field(renaming+itself was a real bug caught while building this); a macro-local closure recursively calling itself by its own name is a disclosed residual gap. No@generated, no built-in macro library. - Matrix literal whitespace-sensitivity is faithful across a whole row now
(
[1.0 -2.0]splits into two elements), reached via a two-attempt re-parse since this parser isn't whitespace-sensitive everywhere real Julia's lexer is. Rationalis Int-backed, not BigInt-backed, so it silently overflows like any otherVInthere. Always GCD-reduced with a positive denominator;1 // 0raises.- No
BigFloat, permanently. Spiked, not deferred:zarith+zarith_stubs_jsruns underbyte/native/jsbut fails at runtime underwasm(the target this actually ships), andzarith_stubs_jsonly shims classicjs_of_ocaml. A self-contained pure-OCaml bignum was judged bigger than any other single piece of this project's history, and not taken on. SeeROADMAP.md's "Numeric type genericity." - No REPL, no package system. One
.jl-style file per run (or the embedded demo), no multi-file programs beyondmodule/using. - Eleven targeted optimizations plus one static-analysis pass, no more.
Variable lookup's depth is resolved statically, but the lookup at that depth
is still a linear assoc-list scan (interning just makes its comparisons
cheap). No JIT in the real sense — "pseudo-JIT" means caching a hot
interpretive decision, not compiling one away (except the restricted
bytecode path, which is a genuinely separate compiler — see
AST_IN_RUST_EXPERIMENT.md). Fine for a demo, nowhere near fine for anything real.
bin/— the whole interpreter, one file per module (dune wires multi-file executables automatically):runtime.ml— value representation, type hierarchy, multiple dispatch, every built-in (+,LinearAlgebra, the exception hierarchy, …) — the biggest file by far.ast.ml,lexer.ml,parser.ml— AST, tokenizer, recursive-descent parser (rust_parser/ports this last one's expression grammar to Rust).resolve.ml— the static scope-resolution pass (runs once before execution).compile.ml— the restricted bytecode compiler/VM bridge plus theWgsl/Glslshader emitters (to_wgsl/to_glsl).eval.ml— the tree-walking evaluator.async.ml— the OCaml-5-effectawaitbridge for the async GPU builtins.hints.ml— one shared function (is_inert_hint_macro).gpuBridge.ml/webglBridge.ml— the browser bridges: WebGPU (wgpu, viagpu/) and raw WebGL2 (plain JS, runningto_glsloutput). Their top-level registrations are forced to link by an explicitinit ()call inmain.ml(an unreferenced module's side effects are otherwise dropped by the wasm/js build — seeCurveBridge.init's own comment).curveBridge.ml,physicsBridge.ml,audioBridge.ml,parallelBridge.ml,ecs.ml— the other host bridges (museum curve, physics, audio, worker pool, ECS).main.ml— the CLI entry point (run a file, or the built-in demo) and, at the bottom, that demo program.
examples/— real JuliaLang/Microbenchmarks programs plus the GPU/WebGL demos (wgsl_double.jl,glsl_triangle.jl,webgl_triangle.jl, theecs_gpu_*set). Run browser ones viaweb/*.html(serve the repo root).web/— browser host pages:demo.html(fixed-function 2D),gpu-compute-demo.html(wgpu compute from Tsubaki),webgl-demo.html(raw WebGL2 from Tsubaki).kernel/— the Rust/faer numeric kernel (wasm32-unknown-unknown, plain linear memory).gpu/— the browser WebGPU (wgpu) crate, independent ofkernel/.preload.js— wires the two wasm modules together.ROADMAP.md— the stagedLinearAlgebraplan, including where full parity isn't reachable even in principle.AST_IN_RUST_EXPERIMENT.md— the bytecode compiler + VM for "pisum-shaped" numeric functions (~6.6× faster, automatically), with a superinstruction fusion pass; also where emitting real wasm would go.rust_parser/— a deliberately frozen Rust src→AST port of Tsubaki's expression grammar, valued for differential testing and as one more data point on OCaml's exhaustiveness vs. JavaScript'sswitchvs. Rust'smatch.