title: "The Ohm language" description: "Lua 5.4 interleaved with LaTeX math, compiled line-for-line to Lua and run in a sandboxed VM with an async host bridge." nodes: 40 progress: 80% generated: ohm spec editor#
The Ohm language#
Lua 5.4 interleaved with LaTeX math, compiled line-for-line to Lua and run in a sandboxed VM with an async host bridge.
Contents#
☑ Overview#
☑ Scope#
note
packages/ohm-lang (splitter, lexer, parser, AST, code generator, operator
registry) and packages/ohm-runtime (wasmoon session, prelude, scheduler, worker
runner).
☑ Math is an expression language, not a document format#
decision
$…$ is exactly one expression; $$…$$ is a sequence of statements.
Everything outside them is literal Lua, copied through untouched.
The point is that notation people already read — \sum, \forall, \circ,
\frac — becomes executable, and that AI operators get a compact spelling
(\CoT_{\Psi}(q; C)) instead of a nest of function calls.
☑ Line-for-line compilation#
requirement #regression
Source line N compiles to Lua line N. The writer counts newlines it emits and pads forward to the source line before each statement, each math segment and each block body; padding never moves backwards, so no construct may emit more newlines than its source span occupies.
This is the only source map: the chunk is loaded as "=" .. scriptName, so a
Lua runtime error reports script.ohm:12: and the editor can jump to it directly.
☑ Splitting Lua from math#
☑ The scanner#
flow
A source is split into a flat list of segments — lua, inline, display — each
recording both the delimiter span and the content span.
In Lua regions the scanner must skip, so that a $ inside them stays literal:
-- line comments, --[[ ]] / --[==[ ]==] long comments, "…" and '…'
strings (with backslash escapes, and an unescaped newline reported as
Unfinished string), and [[ ]] / [==[ ]==] long strings.
☑ Math regions have their own skip rules#
requirement
Inside a math region the scanner skips \x escape pairs (so \$, \{, \}
never terminate), jumps over { … } with a brace matcher, skips "…" strings, and
treats % as a comment to end of line.
The brace matcher is what makes nesting work: a { … } body is Ohm again, so
$…$ may appear inside it, and the matcher applies the Lua trivia rules inside the
braces as well.
Inline math additionally ends at a blank line and then reports
Unclosed $ inline math at the opening $; display math scans to EOF, so a stray
$$ swallows the rest of the file before diagnosing.
- node:
splitter
☑ The LaTeX subset#
☑ Lexical rules#
data-model
- Whitespace: space, tab,
\r, and also~and&(LaTeX spacing/alignment). \nis a significant token: it separates statements.\\is also a separator.%starts a comment.- Commands are
\+ letters, or\+ one non-letter. - Layout commands are dropped:
\,\;\:\!\\quad\qquad\left\right\big…\displaystyle\textstyle\limits\nolimits\middle. - Numbers are digits with an optional single decimal point — no exponent, no hex, no leading dot.
- Identifiers are
[A-Za-z][A-Za-z0-9]*— no underscore, because_is the subscript operator; use\mathrm{my_var}. - Strings are
"…"with\n \t \r \" \\ \'escapes.
☑ Operators and precedence#
data-model
| Level | Operators |
|---|---|
| 2 | \parallel (concurrent, flattened n-ary) |
| 3 | \circ (composition, right-associative) |
| 4 | \lor \vee → or |
| 5 | \land \wedge → and |
| 7 | = == \equiv; != \neq \ne; < > \leq \geq …; \in \notin |
| 8 | .. → range, not concatenation |
| 9 | + - |
| 10 | * / \cdot \times \div, \bmod, juxtaposition (implicit product) |
^ is handled outside the table by the power rule, and is non-associative:
a^b^c is a parse error, braces are required.
Unary - binds a power, so -x^2 is -(x^2). Logical not (\neg, !) binds
looser than comparison, so \neg x = 1 is not (x == 1).
Implicit multiplication fires before a number, identifier, primary command, or a
( when the left side is not callable — 2x, 3(x+1), \pi r^2. It is
suppressed after a lambda or a string.
A newline continues the previous expression only when the next line starts with an
infix operator that is not - (otherwise a statement beginning with a negative
number would be swallowed).
☑ Subscripts and superscripts are overloaded#
data-model
_ means five different things, decided by what it follows:
| After | Meaning |
|---|---|
| an indexable expression | table index: x_i → x[i], x_{i+1} → x[ohm.add(i,1)] |
| a math function | base argument: \log_2(8) |
| an operator name | family selector: \CoT_{\Psi} |
\sum \prod \max \min |
the binder |
\while \if \for |
the condition or range group |
^ is exponentiation except as a big-operator upper bound or in \for_{i=1}^{n}.
☑ Commands#
api
Values: \frac \dfrac \tfrac, \sqrt{x} and \sqrt[n]{x},
\text{…} (and \textrm/\textit/\textbf/\texttt) for strings,
\mathrm{…}/\mathit/\mathbf/\mathsf/\var for identifiers that may contain
_, \operatorname{Name}, \mathcal{M} (memory), \Gamma (ctx), \Theta
(params), \Psi/\Sigma/\Lambda (families), \pi, \infty, \emptyset,
\top/\bot/\nil, \lfloor…\rfloor, \lceil…\rceil, \sum, \prod,
\max_, \min_, \forall, \lua{…}, \{a, b\} lists and \{a,\dots,b\}
ranges, \mapsto lambdas, \gets/\leftarrow as synonyms for :=.
Math functions: sin cos tan arcsin arccos arctan sinh cosh tanh exp ln log lg max min gcd sqrt abs sgn, callable as \sin x, \sin(x, y) or \log_{2}(8).
Greek letters compile to plain identifiers of the same name.
Statements: \while, \if/\elseif/\else, \for, \forall,
\return, \lua, and the no-ops \usepackage, \newoperator,
\documentclass.
☑ Errors name the fix#
requirement
Unknown commands are rejected by name. Lua keywords used as math identifiers come
with the LaTeX spelling: and → "(use \land)", or → "(use \lor)", not →
"(use \neg)". Also rejected: assignment to a non-assignable left side, a lambda with
a non-name parameter, \mathcal{X} for anything but M, mismatched \if branches,
and a \while/\elseif with no condition group.
☑ Compilation#
☑ Pipeline#
flow
SourceFileprecomputes line starts for offset → line/column.declaredOperatorspre-scans the raw text for\newoperator{X}anddefop("F","X", so an operator can be used before it is declared.- Split into segments; copy Lua verbatim; parse each math segment.
- Emit, padding to the source line before each statement and after each segment.
- Return
{ ok, lua, diagnostics }withok === (diagnostics.length === 0).
- node:
line-preservation
☑ Two-tier error recovery#
requirement #regression
A splitter error (unclosed math, unfinished string) aborts the whole compile and returns empty Lua — the segment boundaries are no longer trustworthy.
A parser error is caught per segment: it becomes a diagnostic, inline math emits
the literal nil, display math emits nothing, and compilation continues. Every
broken region is therefore reported in one pass, not just the first.
☑ Generated code#
data-model
Inline math becomes (expr). The shapes worth pinning:
| Construct | Lua |
|---|---|
a + b |
ohm.add(a, b) — overloaded for lists and strings |
x \in S |
ohm.contains(S, x) |
| other binary | (l OP r) with the plain Lua operator |
\Name_{\Psi}(a; k=1) |
ohm.op("Psi", "Name", {a}, {k = 1}) |
\Name_{\Psi} alone |
ohm.opref("Psi", "Name") |
f \circ g |
ohm.compose(f, g) |
a \parallel b |
ohm.par(function() return a end, function() return b end) |
\forall x \in A : e |
ohm.map(A, function(x) return e end) |
\{a, \dots, b\} |
ohm.range(a, b) |
|x| |
ohm.abs(x) |
x \mapsto e |
(function(x) return e end) |
Big operators are IIFEs accumulating into __acc:
(function() local __acc = 0; for i = 1, n do __acc = __acc + ((i ^ 2)) end; return __acc end)()
Statements map to the obvious Lua, each suffixed with "; ". A bare expression
statement becomes ohm.display(expr); a bare call does not — so a value written
on its own line is shown, while f(x) stays silent.
☑ Runtime#
☑ The VM#
component
Lua 5.4 through wasmoon (Lua compiled to WebAssembly), one engine per session, with
injectObjects: false and enableProxy: false — no live object proxies cross
the boundary, only JSON strings.
Globals persist across runs in the same session, which is what makes the console a REPL.
☑ Sandbox#
requirement #security
The prelude removes io, dofile, loadfile, package and require, and
trims os to { time, clock, date, difftime }. Filesystem access is only through
the host fs API, which is confined to the scripts folder.
Still reachable: load, debug, string, table, math, utf8,
coroutine — see the review findings.
- node:
risk-debug
☑ The async scheduler#
flow
Host calls are asynchronous, but Lua code reads as if they were not. The mechanism:
- Four JS globals are injected and then captured and niled by the prelude, so
scripts cannot reach them:
__host_call,__host_wait,__host_out,__init_json. - Every task is a coroutine.
hcall(name, …)JSON-encodes its arguments, calls__host_callto get an id, and yields(WAIT, id). - The scheduler drains the ready queue, then blocks on
__host_wait(), decodes the envelope{id, ok, value|error}and reschedules the matching task. ohm.spawn/ohm.join/ohm.allbuild on the same queues;\parallelcompiles toohm.par, which is genuinely concurrent (a test asserts peak concurrency 3).- Deadlock is an error, not a hang: an empty waiting set with unfinished tasks raises "all tasks are blocked waiting for each other".
Both queues are reset at the start of every run, so tasks from a previous run are abandoned.
☑ Errors carry the script line#
requirement #regression
Four cooperating pieces:
where()walks stack levels and returns the first frame that is not the prelude chunk — so the prefix names the user's line, not the prelude's.annotateadds achunk:line:prefix from the coroutine traceback when the message has none.cleanErrorstrips wasmoon's[string "…"]:N:wrapper and the trailingstack traceback:….errorLineextracts the number so the editor can jump to it.
The prelude must be loaded as chunk =ohm-prelude and the script as
"=" .. name for any of this to work.
- node:
line-preservation
☑ Output is streamed#
requirement
Five streams: out (print), err/warn/info (log.*), and result
(ohm.display, and the script's return value). Lines flow prelude → __host_out
→ worker message → scripts.output event as they happen, not buffered to the
end.
☑ A runaway script can always be stopped#
requirement #safety
Each run owns a worker thread. stop() clears the worker reference first,
settles the pending run as { ok: false, stopped: true }, then terminates the
thread — so the exit handler does not re-settle. Termination kills even a pure CPU
loop (while true do end), which no cooperative scheme could.
The cost is that all session state is discarded; the next run starts a fresh worker. One run at a time per session.
- spec:
01-engine-and-shell
☑ Memory#
data-model
\mathcal{M} is a persistent table. It is re-read after every run, including a
failed one, and written back only when it changed.
☑ Host API#
☑ Surface#
api
Every host object is wired so that obj.fn(x) and obj:fn(x) both work.
browser:open(url, opts),tabs(),active(),get(id)tab:go text html info url title eval query queryAll click type press waitFor scroll screenshot show hide activate closeai:generate(prompt, opts),json(prompt, opts)(strips a code fence, then falls back to brace matching before decoding),profiles(),saveProfile(),useProfile()fs:read write list exists— confined to the scripts folderproject,prompts,curves,experiments,specs,modelsmem,ctx({script, project, args, tab}),params,json,print,log,sleepohm:spawn join all sleep defop inspect text show display hostplus the codegen helpers.spec:
04-models-and-settingsspec:
08-projects-and-research
☑ Matrices#
data-model
A list is a matrix, and arithmetic is overloaded over it the way + already was.
\Tabis a builtin marker for the tab opener, so\Tab \times urlsreads as multiplication and opens one tab per url, hidden, all at once. Ten urls should not take over the window;\Tabs_{\Gamma}(urls; hidden=false)shows them.M \times fmapsfacross the matrix concurrently;n \times Mrepeats it; numbers still multiply.<-sends the right side into the left: a tab is asked (typed into and read back), a list of tabs gets one prompt each (cycled), a list gains an item.
Codegen routes * through ohm.mul and <- through ohm.send, so the
overloading lives in the runtime rather than the parser.
Structure operators, all \Sigma and all pure: Map Shuffle Rotate Reverse Transpose Zip Chunk Pick Flatten Unique Sort. Shuffle and Pick take a seed,
so a randomised run repeats.
- spec:
01-engine-and-shell— hidden tabs
☑ Chatting to a page#
flow
tab <- prompt (and \Chat_{\Lambda}) finds the largest visible text input,
marks it with a data attribute, types, presses Enter, then polls the page text until
it stops growing for two consecutive samples, and returns what was appended.
It is a heuristic by nature — that is the price of working on any chat site without a
per-site adapter — so it fails loudly ("no text input found on …") rather than
silently returning nothing, and timeout, step and settle are configurable.
- node:
matrices
☑ Qualitative coding#
api
\OpenCode_{\Psi}(corpus; k) → codes with definitions, verbatim quotes and source
indices; \AxialCode_{\Psi}(codes) → categories with typed relations (causal,
context, strategy, consequence); \SelectiveCode_{\Psi}(axial; question) → the core
category and storyline; \Codebook_{\Psi}(codes) → a markdown table.
All four ask for JSON and parse it, and share a system prompt that insists on the participants' own words and forbids invented material.
☑ Operator families#
api
Thirty built-in operators in five families:
- Ψ reasoning —
Ask CoT ToT GoT SelfAsk Plan Reflect Critique Summarize - Γ context —
Retrieve Search Fetch Rank Filter Compress MergeContext Ground Cite - ℳ memory —
MemRead MemWrite MemAppend MemSearch MemSummarize MemForget - Σ aggregate —
Join Vote Debate - Λ agent —
ReAct Verify Fork
Config is read as cfg.name or cfg[n], so \CoT(q; C) and
\CoT(q; context=C) are equivalent. ohm.defop(family, name, fn) adds one from a
script, and the pre-scan makes it usable anywhere in the same file.
ohm.compose applies rather than composes when its right operand is not callable,
which is what makes \Summarize_{\Psi} \circ \CoT_{\Psi}(q; C) work.
- spec:
09-graphs
☑ Review findings#
☐ `~=` silently compiles to `==`#
risk urgent #correctness
The lexer treats ~ as whitespace (LaTeX spacing) before consulting the symbol
table, so the ~= entry can never fire: a ~= b lexes as a, =, b and
compiles to (a == b).
A silent inversion of meaning, and the worst defect found in the language. != and
\neq are unaffected. Fix: match ~= before the whitespace rule, or reject a
bare ~ followed by =.
☐ No exponent notation, and `%` is a comment#
risk high #correctness
$1e3$ lexes as 1 then the identifier e3 and compiles to (1 * e3) — a
runtime nil-arithmetic error with no compile-time diagnostic. .5 fails to parse.
$a % b$ silently discards the rest of the line, because % is a LaTeX comment;
\bmod is the intended spelling but nothing says so.
Both deserve a diagnostic at minimum.
- node:
lexing
☐ JSON null corrupts an array#
risk high #correctness
decode maps null to nil while still advancing the index, so [1,null,2]
becomes {[1]=1,[3]=2}; the length probe then fails and encode re-emits it as the
object {"1":1,"3":2}. Only the trailing-null case is tested.
☐ `ohm.host` and `debug` are reachable from scripts#
risk high #security
ohm.host(name, …) is a public global that calls any host function by name,
bypassing the typed wrappers, and the debug library is left in place because the
prelude itself needs it. Together they widen the sandbox well past what the fs
confinement implies.
Decide explicitly: either keep them and document the trust model as "scripts are
trusted code", or make the prelude capture debug the way it captures the host
globals.
- node:
sandbox
☐ A stale host result can kill the next run#
risk
The Lua queues are reset per run, but the JS-side completed queue is not. A result delivered after its run ended is dropped by the scheduler, which may then see an empty waiting set and raise "all tasks are blocked waiting for each other" — a deadlock error whose real cause was a message from a previous run.
- node:
scheduler
☐ A failed session is cached forever#
risk
Both the worker and the in-process runner use session ??= create(…); a rejected
promise is retained, so every later run fails with the same stale error until the
worker is replaced. The same pattern bites the embedded llama.cpp loader.
- spec:
04-models-and-settings
☐ Chained comparison silently displays#
risk
a = b = c parses as ((a==b)==c), fails the assignability test, and falls
through to an expression statement — so it prints instead of reporting an error.
☐ Smaller observations#
note low
a^b^cis a parse error (braces required) — intended, but the message ("Expected end of statement") does not say so.|…|cannot nest:| |x| - |y| |mis-parses with no diagnostic...means range inside math and concatenation in the surrounding Lua — same token, two meanings, untested.- Operator calls used as statements print their result, because only
Calland raw Lua are exempt from the display rule. declaredOperatorsscans comments and strings, so adefopmentioned in a comment whitelists the name.\Psi.CoT(x)cannot be parsed although the runtime proxy supports it.- The editor's
HOST_APItable omitsproject,prompts,curvesandexperiments, so completion under-reports the real surface. InProcessRunner.stop()never settles the pending run and has no already-running guard; it is test-only, but the asymmetry with the worker runner is a trap.
Links between nodes#
graph LR
math_regions_have_their_own_skip_rules["Math regions have their own skip rules"] -.-> splitter["The scanner"]
pipeline["Pipeline"] -.-> line_preservation["Line-for-line compilation"]
sandbox["Sandbox"] -.-> risk_debug["`ohm.host` and `debug` are reachable from scripts"]
errors_carry_the_script_line["Errors carry the script line"] -.-> line_preservation["Line-for-line compilation"]
chatting_to_a_page["Chatting to a page"] -.-> matrices["Matrices"]
no_exponent_notation_and_is_a_comment["No exponent notation, and `%` is a comment"] -.-> lexing["Lexical rules"]
risk_debug["`ohm.host` and `debug` are reachable from scripts"] -.-> sandbox["Sandbox"]
a_stale_host_result_can_kill_the_next_run["A stale host result can kill the next run"] -.-> scheduler["The async scheduler"]