Overview
Bagl is a small statically-typed functional language with three unusual
properties for its size: tensor shapes are part of the type system, so a
matrix-dimension mismatch is a compile error; the compiler performs
automatic differentiation, so grad turns functions into their
derivatives before type inference ever runs; and the whole compiler runs in
your browser, which is what powers this page.
The pipeline is a real compiler, not an interpreter:
source → lexer → parser → autodiff expansion → Hindley-Milner inference (+ shapes)
→ control-flow-graph IR → optimizer → stack bytecode → VM
The design bias throughout: an error beats a silently wrong answer. Anything the compiler cannot handle correctly is a diagnostic, never a guess.
Basics
Literals and comments
Four scalar types: int, float, bool,
string. Integers and floats are distinct types with no implicit
conversion. Line comments start with //, block comments are
/* ... */.
Bindings
let name = value in body binds a name for the body. Bindings
nest and shadow. A program evaluates to the value of its final
expression.
Functions
Functions take exactly one parameter; multiple arguments are curried
with nested fn. Application is juxtaposition and
left-associative. Functions are first-class values and close over their
environment.
Recursion
letrec makes the name visible inside its own definition. At
runtime this is a self-capturing closure the VM back-patches after
allocation.
Conditionals
if is an expression; both branches must have the same type
and the condition must be bool. Comparison operators are
== != < > <= >=; logic is &&
|| !. Equality is defined for scalars only (see
Limits).
The type system
Inference
Bagl uses Hindley-Milner type inference: you rarely write a type, and
the checker finds the most general one. let-bound values
generalize, so one definition serves many types. Inference is implemented
with union-find unification and level-based generalization, the same
technique OCaml's own checker uses.
The occurs check rejects infinite types: fn x -> x x is a
compile error, not a hang.
Numeric operators
Bagl has no type classes, so + - * / and the comparisons
resolve by inspecting their operands: if either side is a
float, the operation is float. When both sides are still
unresolved, the choice is deferred and only defaults to
int when the enclosing binding is generalized, so operand
order never matters:
With no constraint at all, numeric parameters default to
int: fn x -> x + x is int ->
int. This trades principal types for a simple single-pass checker;
annotate the parameter to get the float version. Division by zero is a
runtime error, integer or float, never inf.
Annotations
Optional annotations appear after a binding name or parameter. A
parameter annotation is a non-arrow type; wrap arrows in parentheses.
Named type variables ('a) and dimension variables
('n) are shared within one annotation, so
tensor<float>['n,'n] really means square:
Tensors
Literals and rank
Tensors are float-backed, rank 1 (vectors) or rank 2 (matrices). The
bracket syntax decides the rank, and single-row matrices are distinct
from vectors: [1.0, 2.0] is a vector with shape
[2], while [[1.0, 2.0]] is a 1×2 matrix.
Integer tensors are rejected at compile time rather than silently
coerced.
dot, transpose, reshape
dot covers four rank combinations, unifying the shared
dimension in each:
| operands | result | meaning |
|---|---|---|
| [m,k] · [k,n] | [m,n] | matrix product |
| [m,k] · [k] | [m] | matrix-vector |
| [k] · [k,n] | [n] | vector-matrix |
| [k] · [k] | float | inner product |
transpose works on matrices; reshape takes a
literal shape and checks the element count when both sizes are known.
Element-wise arithmetic
+ - * / on two same-shape tensors apply element-wise
(* is the Hadamard product, not matrix multiplication; that
is dot). A float on either side broadcasts. Shapes are
checked at compile time and re-validated by the VM.
Shape checking
Shapes live in the type system as a second unification domain: dimensions are union-find variables with their own occurs check, unified in parallel with ordinary types. A mismatch is a compile-time diagnostic pointing at the offending expression, and it composes: passing a bad shape through a function still reports the mismatch.
Math builtins
exp, log, sqrt,
relu, and step apply to a float or element-wise
to a tensor. step is the Heaviside function (1.0 for x > 0,
else 0.0) and is exactly relu's derivative. Domain errors raise at
runtime instead of producing nan: log of a
non-positive number and sqrt of a negative number are
errors.
Differentiation
grad is a source-to-source transform that runs before type
inference: grad (fn x -> body) is rewritten into an
ordinary Bagl function computing the derivative, which then flows through
inference, the optimizer, and the VM like any other code. The derivative
is type- and shape-checked. There are two modes, selected by the
parameter annotation.
Scalar mode (forward)
For an unannotated or float parameter over scalar code,
grad applies the sum, product, quotient, and chain rules
symbolically. if differentiates each branch (the condition is
data); let chains through; the builtins carry their
calculus-textbook rules.
Derivatives are ordinary functions, so they compose with everything else. Newton's method where the compiler supplies f′:
Tensor mode (reverse)
With a tensor parameter annotation, grad switches to
reverse-mode differentiation and returns the gradient of a scalar loss
with the parameter's shape. The pullback rules cover dot
(all four rank cases), transpose, element-wise arithmetic,
the math builtins, scalar broadcast, and let. The annotation
is required because the transform runs before inference and the
dot pullbacks depend on operand ranks.
This is enough to train a model entirely in the language. Feature-mapped
XOR by gradient descent, converging to the exact solution
[1, 1, -2]:
Pathwise derivatives
A float-annotated parameter can be differentiated
through tensor code. The rank-1 sum-reductions this needs are
expressed internally as dot products. This is the pathwise derivative
estimator from computational finance: the sensitivity of a Monte Carlo
average, computed by differentiating through the simulation itself.
0.65 is exactly the estimator E[g · 1ITM]: the two in-the-money paths contribute (1.2 + 1.4)/4.
What is rejected, and why
Every gradient Bagl returns is exact for the program you wrote. Anything it cannot differentiate correctly is a compile error:
- Function calls in the body: the transform is syntactic and cannot see through a call boundary yet.
- Unannotated tensor parameters: the
dotpullbacks need operand ranks, and the transform runs before inference. - Outer-product pullbacks (the matrix side of a
matrix-vector
dot) and double reductions (a scalar broadcast against a matrix): Bagl has no outer product or matrix reduction to express them with. letrec, nestedfn, tensor literals mentioning the parameter, and conditions on the parameter.
Runtime and tooling
Bagl compiles to a dense stack bytecode executed by a virtual machine
with dynamically-sized frames. Programs serialize to .baglc
binaries (baglc -c file.bagl) and reload byte-identically,
negative numbers included. The optimizer runs constant folding, dead-code
elimination, copy propagation, and block-local common-subexpression
elimination over a control-flow-graph IR; -O0 disables it and
--dump-ast/-types/-ir/-bytecode show every intermediate
stage.
- REPL:
baglcwith no file. Bindings persist across lines;letrec, closures, andgradall work interactively;:type eshows an inferred type. - Editor support:
bagl-lspspeaks the Language Server Protocol over stdio, publishing real compiler diagnostics as you type; a VS Code extension and Neovim setup live ineditors/. - Browser: the compiler builds to 150 KB of JavaScript via js_of_ocaml. That bundle runs this page and the playground.
Limits
Stated plainly, because knowing the edges is part of knowing the language:
- No I/O, lists, tuples, records, or string operations; strings support literals and equality only. A program is one expression.
- Tensors are rank 1 or 2 and float-only; no indexing or slicing (components are extracted with basis-vector dot products), no broadcasting beyond scalars, no outer product.
- Numeric operators default unconstrained operands to
intat generalization;fn x -> x + xisint -> intunless annotated. The principled fix is type classes; the trade was made for a single-pass checker. - Equality on tensors or functions is a compile error rather than a structural comparison.
gradhas the restrictions in the section above; every one of them is a diagnostic, not a wrong number.- An unannotated tensor argument to a tensor op defaults to rank 2 in
one corner of inference; annotate 1-D parameters as
tensor<float>['n].
Grammar
program ::= expr
expr ::= "let" NAME [":" type] "=" expr "in" expr
| "letrec" NAME [":" type] "=" expr "in" expr
| "fn" NAME [":" simple-type] "->" expr
| "if" expr "then" expr "else" expr
| binop-expr
binop-expr::= unary { binop unary } // precedence: || < && < ==,!= < <,>,<=,>= < +,- < *,/
unary ::= ["-" | "!"] postfix
postfix ::= primary { primary } // application, left-associative
primary ::= INT | FLOAT | STRING | "true" | "false" | NAME
| "(" expr ")" | tensor
| "dot" "(" expr "," expr ")" | "transpose" "(" expr ")"
| "reshape" "(" expr "," shape ")"
| ("exp"|"log"|"sqrt"|"relu"|"step") "(" expr ")"
| "grad" ... // ordinary application; special-cased on fn literals
tensor ::= "[" expr {"," expr} "]" // vector
| "[" row {"," row} "]" // matrix, even with one row
type ::= simple-type ["->" type]
simple-type ::= "int" | "float" | "bool" | "string" | "'" NAME
| "tensor" "<" type ">" shape | "(" type ")"
shape ::= "[" dim {"," dim} "]" // dim ::= INT | "'" NAME
Every example on this page is compiled and executed by the Bagl compiler running in this tab, and each one's expected output is verified in CI.