<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="../assets/xml/rss.xsl" media="all"?><rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>TinyComputers.io (Posts about Programming Languages, Language Design)</title><link>https://tinycomputers.io/</link><description></description><atom:link href="https://tinycomputers.io/categories/cat_programming-languages-language-design.xml" rel="self" type="application/rss+xml"></atom:link><language>en</language><copyright>Contents © 2026 A.C. Jokela 
&lt;!-- div style="width: 100%" --&gt;
&lt;a rel="license" href="http://creativecommons.org/licenses/by-sa/4.0/"&gt;&lt;img alt="" style="border-width:0" src="https://i.creativecommons.org/l/by-sa/4.0/80x15.png" /&gt; Creative Commons Attribution-ShareAlike&lt;/a&gt;&amp;nbsp;|&amp;nbsp;
&lt;!-- /div --&gt;
</copyright><lastBuildDate>Fri, 12 Jun 2026 02:20:44 GMT</lastBuildDate><generator>Nikola (getnikola.com)</generator><docs>http://blogs.law.harvard.edu/tech/rss</docs><item><title>Crystal by Reference: Making freeze() Mean It, Then Making It Fast</title><link>https://tinycomputers.io/posts/crystal-by-reference-making-freeze-mean-it-then-making-it-fast.html?utm_source=feed&amp;utm_medium=rss&amp;utm_campaign=rss</link><dc:creator>A.C. Jokela</dc:creator><description>&lt;div class="audio-widget"&gt;
&lt;div class="audio-widget-header"&gt;
&lt;span class="audio-widget-icon"&gt;🎧&lt;/span&gt;
&lt;span class="audio-widget-label"&gt;Listen to this article&lt;/span&gt;
&lt;/div&gt;
&lt;audio controls preload="metadata"&gt;
&lt;source src="https://tinycomputers.io/crystal-by-reference-making-freeze-mean-it-then-making-it-fast_tts.mp3" type="audio/mpeg"&gt;
&lt;/source&gt;&lt;/audio&gt;
&lt;div class="audio-widget-footer"&gt;24 min · AI-generated narration&lt;/div&gt;
&lt;/div&gt;

&lt;h2&gt;Crystal by Reference: Making freeze() Mean It, Then Making It Fast&lt;/h2&gt;
&lt;p&gt;Most programming languages make some kind of promise about immutability. JavaScript has &lt;code&gt;Object.freeze()&lt;/code&gt;. Java has &lt;code&gt;final&lt;/code&gt;. Python has tuples. Rust builds half its identity on it. The promise is always the same shape: &lt;em&gt;this value will not change, and you can build on that.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;This is a story about what happens when a language makes that promise and the implementation doesn't keep it — how the holes got there, how they were found, what it took to close them, and the surprisingly large reward waiting on the other side. The language in question is &lt;a href="https://lattice-lang.org/"&gt;Lattice&lt;/a&gt;, a small interpreted language I've been building, but you don't need to know Lattice or care about it for the story to be useful. The bugs, the debugging patterns, and the performance trade-offs are the kind that show up in any system that manages memory and makes guarantees. Lattice just happens to be where I got to watch the whole arc play out in one place.&lt;/p&gt;
&lt;h3&gt;A Promise in Three Lines&lt;/h3&gt;
&lt;p&gt;The one piece of background you need: Lattice is built around a metaphor of crystallization. Values start out &lt;em&gt;fluid&lt;/em&gt; — mutable, workable — and can be &lt;em&gt;frozen&lt;/em&gt; into immutable &lt;em&gt;crystal&lt;/em&gt;. It's the same idea as &lt;code&gt;const&lt;/code&gt; or &lt;code&gt;Object.freeze()&lt;/code&gt;, but as a runtime lifecycle you can observe and control, and it's &lt;a href="https://tinycomputers.io/posts/introducing-lattice-a-crystallization-based-programming-language.html"&gt;the central concept of the language&lt;/a&gt;. Freezing is the thing Lattice is &lt;em&gt;about&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Which makes these three lines embarrassing:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;flux a = [1, 2, 3]    // a mutable array
freeze(a)             // now immutable... supposedly
a[0] = 99             // prints [99, 2, 3]
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;That third line should be a runtime error. On the released version of the language, it just... worked. The frozen array changed. No error, no warning, nothing.&lt;/p&gt;
&lt;p&gt;I didn't find this because a user complained. I found it while reviewing the design for a performance feature — which, it turns out, is one of the most reliable ways to find soundness bugs. The feature was going to &lt;em&gt;share&lt;/em&gt; frozen data instead of copying it, and its entire safety argument was one sentence: frozen values can't change, so letting many parts of the program point at the same frozen value is safe. Before betting the memory model on that sentence, I tested it. The sentence was false.&lt;/p&gt;
&lt;h3&gt;How a Promise Breaks&lt;/h3&gt;
&lt;p&gt;It's worth dwelling on &lt;em&gt;how&lt;/em&gt; this happens, because it isn't carelessness — it's structure.&lt;/p&gt;
&lt;p&gt;Lattice has three separate execution engines that are supposed to behave identically: a simple tree-walking interpreter (the original), a bytecode virtual machine (the default — the thing that actually runs your code), and a register-based VM (an experiment in going faster). Three implementations of one language means every rule has to be enforced three times. And "frozen values reject mutation" isn't one rule in one place — it's a &lt;em&gt;cross-cutting concern&lt;/em&gt;. Arrays are assigned through one code path, map keys through another, globals through a third. There are dozens of built-in methods that modify their target: &lt;code&gt;push&lt;/code&gt;, &lt;code&gt;remove&lt;/code&gt;, &lt;code&gt;fill&lt;/code&gt;, &lt;code&gt;resize&lt;/code&gt;. Every one of those is a door, and the guard has to be standing at every door, in every engine.&lt;/p&gt;
&lt;p&gt;The audit found doors standing wide open everywhere, and — this is the telling part — &lt;em&gt;different&lt;/em&gt; doors in each engine. The bytecode VM checked local variables but not globals. The tree-walker checked maps but not arrays. The file containing all the mutating built-in methods had &lt;strong&gt;zero&lt;/strong&gt; immutability checks in it, anywhere, for any type, on any engine — so writing bytes into a frozen buffer worked everywhere. Lattice's partial-freeze feature (freeze this map, except leave this one key writable) kept its exemptions in a side table that a later full freeze never cleared, so an exemption, once granted, was permanent. The one engine that mostly enforced things had its own subtle inversion: a key &lt;em&gt;missing&lt;/em&gt; from the side table was treated as exempt rather than frozen.&lt;/p&gt;
&lt;p&gt;The most honest summary: each engine enforced the promise wherever its author happened to be thinking about the promise that day. Nobody had ever sat down and enumerated the doors.&lt;/p&gt;
&lt;p&gt;And one door had something worse than a missing guard behind it. Calling &lt;code&gt;.remove()&lt;/code&gt; on a frozen map didn't return an error on the tree-walker — it crashed the whole process with a double-free, a memory-corruption bug. The missing immutability check had been quietly standing in front of a memory-safety bug the whole time. Hold that thought; it becomes a theme.&lt;/p&gt;
&lt;p&gt;The fix wasn't clever and that's the point. Write down every door first: a matrix of nineteen small test programs, each run against all three engines, asserting that frozen things reject mutation and exempted things don't, &lt;em&gt;before&lt;/em&gt; fixing anything. Then station guards until the matrix is green. Where possible, replace scattered per-door checks with one chokepoint — all those mutating built-in methods now get rejected by a single shared classifier that all three engines consult before running any method, so the next method someone adds is guarded by default instead of by memory. The review of that change caught one engine sneaking around the chokepoint through an undocumented alias of &lt;code&gt;remove&lt;/code&gt; — which tells you the chokepoint was worth building.&lt;/p&gt;
&lt;p&gt;The enforcement work shipped as v0.5.0, and the version number was itself a decision worth a sentence: these were bug fixes, which argues for a patch release, but a patch number tells users "upgrade blindly," and this release deliberately breaks any program that was mutating frozen values and getting away with it. That's a minor bump and a bolded line in the release notes. Version numbers are communication, not bookkeeping.&lt;/p&gt;
&lt;h3&gt;The Reward: Stop Copying Things That Can't Change&lt;/h3&gt;
&lt;p&gt;Here's why all of that was act one and not the whole story.&lt;/p&gt;
&lt;p&gt;Lattice, like a lot of languages that want to make shared-state bugs impossible, copies aggressively. Pass a map to a function: copy. Send an array to another thread over a channel: copy. Spawn a thread that uses data from the parent: copy everything it touches. This is a perfectly honest design — there's no spooky action at a distance because there's no sharing — and profiling real programs showed it was also, by far, the dominant cost. Not the interpreter loop. Not allocation. &lt;em&gt;Copying data that nothing was ever going to modify.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Read that sentence next to the language's central promise and the opportunity is obvious. A frozen value can't change — now provably, on every engine. Copying a thing that cannot change is pure waste. Other ecosystems have versions of this insight: Clojure's persistent data structures share internal nodes precisely because they're immutable; Rust's &lt;code&gt;Arc&lt;/code&gt; exists to share read-only data across threads; copy-on-write strings were a whole era of C++. The common thread is always the same bargain — &lt;strong&gt;immutability, if it's actually enforced, is a license to share.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;So act two: when you freeze a container in Lattice now, the runtime &lt;em&gt;materializes&lt;/em&gt; it — takes one full snapshot of the whole structure into a sealed, self-contained memory region with an atomic reference count. From then on, "copying" that frozen value means bumping the count and handing out a 72-byte handle. Pass it to a function: a count bump. Send it across a channel to another thread: a count bump, where there used to be two full deep copies. When the last handle goes away, the region's memory is returned, all at once. Thawing a value back to mutable does a real copy — so the language's isolation model is untouched; you can never mutate something someone else can see.&lt;/p&gt;
&lt;p&gt;Not everything participates. Tiny values — numbers, short strings — keep the old behavior because copying eight bytes is cheaper than refcount traffic. Values containing closures or other inherently-shared machinery are excluded by a classifier and quietly fall back to the old copying freeze, with identical semantics. And freezing itself got &lt;em&gt;more&lt;/em&gt; expensive: that one-time snapshot is real work, about 1.6x on a freeze-heavy microbenchmark, which is now watched by an automated regression gate so it can't silently grow. You pay once at the freeze, and you stop paying forever after.&lt;/p&gt;
&lt;p&gt;The payoff, measured on the default engine:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Aliasing a frozen 200,000-element array 2,000 times: &lt;strong&gt;0.03 seconds, versus 3.49 seconds&lt;/strong&gt; with sharing disabled. About 116x.&lt;/li&gt;
&lt;li&gt;Sending a frozen 100,000-element array to another thread: &lt;strong&gt;0.06 versus 2.14 seconds.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Four threads sending and two receiving 80,000 messages of frozen data: memory usage flat at ~21 MB whether you run half or all of it — nothing accumulating, nothing leaking, verified under AddressSanitizer.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;The Bugs That Were Already There&lt;/h3&gt;
&lt;p&gt;The implementation rolled out in stages — first the machinery with nothing connected to it, then the tree-walker, then the default VM — and every stage went through independent adversarial review before merging: one reviewer checking the work against the design, another reading every changed line with a single hostile question in mind (&lt;em&gt;does everyone who frees this still own it?&lt;/em&gt;), and a third rebuilding everything from scratch and trying to break it with programs. Every stage, without exception, the review found a real bug. Two of them are worth retelling because neither was actually new — and that's what makes them interesting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The crash that had been shipping for months.&lt;/strong&gt; During the tree-walker integration, a reviewer constructed an innocuous-looking program — freeze an array of strings, run it through an iterator with a little callback — and the process aborted. The cause: the iterator code was handing values to callbacks &lt;em&gt;and then freeing those values again afterward&lt;/em&gt;, even though the callback machinery had already taken ownership and freed them. A double-free. Here's the kicker: the same program, with the freeze removed, &lt;strong&gt;crashed the previous released version of the language too.&lt;/strong&gt; This bug predated the entire project. It had been shipping for months, invisible, because the test suite happened to exercise iterators with integers — which the memory manager doesn't track — instead of strings. The new reference counts didn't create the bug. They made it &lt;em&gt;loud&lt;/em&gt;: a refcount hitting zero twice trips an assertion immediately, where a heap double-free corrupts quietly and crashes somewhere else, later, if at all.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The leak that used to be invisible.&lt;/strong&gt; On the default VM, the reviewer found that error handling — &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt;, error propagation — threw away in-flight values during stack unwinding without releasing them. Before sharing, each abandoned value leaked a private copy: a small, bounded, invisible heap leak that nobody had noticed in years. After sharing, each abandoned value held a reference count forever — a &lt;code&gt;catch&lt;/code&gt; block in a loop would pin megabytes of supposedly-reclaimable frozen data permanently. The proof-of-concept accumulated 1,500 stranded references in 300 caught errors. Same code path, same bug, years old; the new machinery just converted it from &lt;em&gt;invisible&lt;/em&gt; to &lt;em&gt;alarmed&lt;/em&gt;. The fix releases everything between the old and new stack positions, exactly once — and incidentally fixed the years-old copy leak too.&lt;/p&gt;
&lt;p&gt;There's a general lesson in this pair, and it's the thing I'd most want a reader to take away even if they never touch Lattice: &lt;strong&gt;strict resource accounting is a bug detector for bugs you already have.&lt;/strong&gt; A system that deep-copies everywhere is &lt;em&gt;forgiving&lt;/em&gt; — ownership mistakes cost you a leaked copy or a redundant copy, and nothing ever screams. The moment you introduce sharing with exact reference counts, every pre-existing ownership mistake in the codebase becomes a fire alarm. It feels like the new feature is breaking everything. It's doing the opposite: it's telling you what was already broken.&lt;/p&gt;
&lt;h3&gt;What Actually Caught the Bugs&lt;/h3&gt;
&lt;p&gt;None of these bugs were caught by the test suite being green. The suite was green the whole time — that's the uncomfortable part. They were caught by process choices that I'd defend for any project of this shape:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write the failure matrix before the fix.&lt;/strong&gt; The nineteen-test enforcement matrix, written before any guard was added, is what turned "I think there are holes" into a punch list, and it's what proves the holes stay closed on all three engines forever after.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Build a kill switch that proves equivalence.&lt;/strong&gt; The sharing machinery has an environment variable that disables every sharing path at runtime, so the whole system degrades to old-fashioned copying. CI runs the entire test suite both ways and requires identical results. Sharing is supposed to be invisible to program behavior; the oracle makes "supposed to" a checked property instead of a hope. When the two modes disagree, you've found a bug by construction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pay someone to be hostile.&lt;/strong&gt; Every merge went through review whose explicit job was to break the change, not to approve it — including constructing crashing programs, not just reading diffs. I'll be honest that much of this was orchestrated with LLM agents running the inventories, implementation, and reviews in parallel (a post for another day), but the methodological point doesn't depend on the tooling: the adversarial pass found a shippable bug at every single stage of this project. A green suite tests what you thought to test. An adversary tests what you didn't.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Keep redundant implementations honestly redundant.&lt;/strong&gt; Three engines is an expensive luxury, and this project is the best argument I have for it: nearly every bug here was visible as a &lt;em&gt;disagreement&lt;/em&gt; between engines before it was visible as a failure in any one of them. When three implementations of the same rule diverge, at least two of them are telling you something true.&lt;/p&gt;
&lt;h3&gt;Where It Stands&lt;/h3&gt;
&lt;p&gt;As I write this, the sharing work is live on two of the three engines, with the third in progress, followed by a hardening pass under ThreadSanitizer and a proper benchmark accounting. The enforcement work — the part that makes the language's central promise true — shipped in v0.5.0 across all thirteen platforms Lattice builds for, along with unified channel-send rules and a small milestone for the self-hosted compiler.&lt;/p&gt;
&lt;p&gt;The shape of the whole project is the part I keep turning over. The performance feature was only possible because the semantics were enforced. Enforcing the semantics surfaced a memory-corruption bug hiding behind a missing error check. Building the performance feature surfaced &lt;em&gt;more&lt;/em&gt; pre-existing bugs, because exact accounting converts silent corruption into immediate alarms. At every step, the work of making the promise true paid for itself in bugs found — before the optimization saved a single microsecond.&lt;/p&gt;
&lt;p&gt;Immutability that the runtime actually guarantees isn't just a correctness feature, and it isn't just a performance feature. It's a foundation that makes both kinds of claims checkable. Most promises in software are like that: the value isn't in making them. It's in building the machinery that would notice if they were false.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;Lattice is open source at &lt;a href="https://lattice-lang.org/"&gt;lattice-lang.org&lt;/a&gt;. It compiles and runs on macOS, Linux, Windows, and the BSDs with nothing beyond a C11 compiler, and you can try it in your browser at the &lt;a href="https://lattice-lang.org/playground.html"&gt;playground&lt;/a&gt;. If you want the deeper technical history, see the earlier posts on &lt;a href="https://tinycomputers.io/posts/mutability-as-a-first-class-concept-the-lattice-phase-system.html"&gt;the phase system&lt;/a&gt; and &lt;a href="https://tinycomputers.io/posts/a-stack-based-bytecode-vm-for-lattice.html"&gt;the bytecode VM&lt;/a&gt;.&lt;/p&gt;</description><category>debugging</category><category>immutability</category><category>language design</category><category>lattice</category><category>memory management</category><category>performance</category><category>programming languages</category><category>software engineering</category><category>testing</category><guid>https://tinycomputers.io/posts/crystal-by-reference-making-freeze-mean-it-then-making-it-fast.html</guid><pubDate>Fri, 12 Jun 2026 02:00:00 GMT</pubDate></item><item><title>A Stack-Based Bytecode VM for Lattice: 100 Opcodes, Serialization, and a Self-Hosted Compiler</title><link>https://tinycomputers.io/posts/a-stack-based-bytecode-vm-for-lattice.html?utm_source=feed&amp;utm_medium=rss&amp;utm_campaign=rss</link><dc:creator>A.C. Jokela</dc:creator><description>&lt;div class="audio-widget"&gt;
&lt;div class="audio-widget-header"&gt;
&lt;span class="audio-widget-icon"&gt;🎧&lt;/span&gt;
&lt;span class="audio-widget-label"&gt;Listen to this article&lt;/span&gt;
&lt;/div&gt;
&lt;audio controls preload="metadata"&gt;
&lt;source src="https://tinycomputers.io/a-stack-based-bytecode-vm-for-lattice_tts.mp3" type="audio/mpeg"&gt;
&lt;/source&gt;&lt;/audio&gt;
&lt;div class="audio-widget-footer"&gt;29 min · AI-generated narration&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;When I &lt;a href="https://tinycomputers.io/posts/from-tree-walker-to-bytecode-vm-compiling-lattice.html"&gt;first wrote about&lt;/a&gt; Lattice's move from a tree-walking interpreter to a bytecode VM, the instruction set had 62 opcodes, concurrency primitives still delegated to the tree-walker, and programs couldn't be serialized. The VM was a foundation, correct and complete enough to become the default, but clearly a starting point.&lt;/p&gt;
&lt;p&gt;That was ten versions ago. The bytecode VM now has 100 opcodes, compiles concurrency primitives into standalone sub-chunks with zero AST dependency at runtime, ships a binary serialization format for ahead-of-time compilation, includes an ephemeral bump arena for short-lived string temporaries, and (perhaps most satisfyingly) has a self-hosted compiler written entirely in Lattice that produces the same &lt;code&gt;.latc&lt;/code&gt; bytecode files as the C implementation.&lt;/p&gt;
&lt;p&gt;This post walks through what changed and why. The full technical treatment is available as a &lt;a href="https://tinycomputers.io/papers/lattice_vm.pdf"&gt;research paper&lt;/a&gt;; this is the practitioner's version.&lt;/p&gt;
&lt;h3&gt;Why Keep Going&lt;/h3&gt;
&lt;p&gt;The &lt;a href="https://tinycomputers.io/posts/from-tree-walker-to-bytecode-vm-compiling-lattice.html"&gt;original bytecode VM&lt;/a&gt; solved the immediate problems: it eliminated recursive AST dispatch overhead and gave Lattice a single execution path for file execution, the REPL, and the WASM playground. But three issues remained.&lt;/p&gt;
&lt;p&gt;First, &lt;code&gt;OP_SCOPE&lt;/code&gt; and &lt;code&gt;OP_SELECT&lt;/code&gt; (Lattice's structured concurrency opcodes) still stored AST node pointers in the constant pool and dropped into the tree-walking evaluator at runtime. This meant the AST had to stay alive during concurrent execution, which defeated one of the main motivations for having a bytecode VM in the first place.&lt;/p&gt;
&lt;p&gt;Second, the AST dependency made serialization impossible. You can serialize bytecode to a file, but you can't easily serialize an arbitrary C pointer to an AST node. Programs had to be parsed and compiled on every run.&lt;/p&gt;
&lt;p&gt;Third, the dispatch loop used a plain &lt;code&gt;switch&lt;/code&gt; statement. Not a crisis, but computed goto dispatch is a well-known improvement for bytecode interpreters, and leaving it on the table felt unnecessary.&lt;/p&gt;
&lt;p&gt;All three problems are solved now. Let me start with the instruction set, since everything else builds on it.&lt;/p&gt;
&lt;h3&gt;100 Opcodes&lt;/h3&gt;
&lt;p&gt;The instruction set grew from 62 to 100 opcodes, organized into 16 functional categories:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Representative opcodes&lt;/th&gt;
&lt;th style="text-align: right;"&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Stack manipulation&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONSTANT&lt;/code&gt;, &lt;code&gt;NIL&lt;/code&gt;, &lt;code&gt;TRUE&lt;/code&gt;, &lt;code&gt;FALSE&lt;/code&gt;, &lt;code&gt;UNIT&lt;/code&gt;, &lt;code&gt;POP&lt;/code&gt;, &lt;code&gt;DUP&lt;/code&gt;, &lt;code&gt;SWAP&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arithmetic/logical&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD&lt;/code&gt;, &lt;code&gt;SUB&lt;/code&gt;, &lt;code&gt;MUL&lt;/code&gt;, &lt;code&gt;DIV&lt;/code&gt;, &lt;code&gt;MOD&lt;/code&gt;, &lt;code&gt;NEG&lt;/code&gt;, &lt;code&gt;NOT&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitwise&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BIT_AND&lt;/code&gt;, &lt;code&gt;BIT_OR&lt;/code&gt;, &lt;code&gt;BIT_XOR&lt;/code&gt;, &lt;code&gt;BIT_NOT&lt;/code&gt;, &lt;code&gt;LSHIFT&lt;/code&gt;, &lt;code&gt;RSHIFT&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comparison&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EQ&lt;/code&gt;, &lt;code&gt;NEQ&lt;/code&gt;, &lt;code&gt;LT&lt;/code&gt;, &lt;code&gt;GT&lt;/code&gt;, &lt;code&gt;LTEQ&lt;/code&gt;, &lt;code&gt;GTEQ&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONCAT&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variables&lt;/td&gt;
&lt;td&gt;&lt;code&gt;GET/SET_LOCAL&lt;/code&gt;, &lt;code&gt;GET/SET/DEFINE_GLOBAL&lt;/code&gt;, &lt;code&gt;GET/SET_UPVALUE&lt;/code&gt;, &lt;code&gt;CLOSE_UPVALUE&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Control flow&lt;/td&gt;
&lt;td&gt;&lt;code&gt;JUMP&lt;/code&gt;, &lt;code&gt;JUMP_IF_FALSE&lt;/code&gt;, &lt;code&gt;JUMP_IF_TRUE&lt;/code&gt;, &lt;code&gt;JUMP_IF_NOT_NIL&lt;/code&gt;, &lt;code&gt;LOOP&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Functions&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CALL&lt;/code&gt;, &lt;code&gt;CLOSURE&lt;/code&gt;, &lt;code&gt;RETURN&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iterators&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ITER_INIT&lt;/code&gt;, &lt;code&gt;ITER_NEXT&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data structures&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BUILD_ARRAY&lt;/code&gt;, &lt;code&gt;INDEX&lt;/code&gt;, &lt;code&gt;SET_INDEX&lt;/code&gt;, &lt;code&gt;GET_FIELD&lt;/code&gt;, &lt;code&gt;INVOKE&lt;/code&gt;, etc.&lt;/td&gt;
&lt;td style="text-align: right;"&gt;15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exceptions/defer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUSH_EXCEPTION_HANDLER&lt;/code&gt;, &lt;code&gt;THROW&lt;/code&gt;, &lt;code&gt;DEFER_PUSH&lt;/code&gt;, &lt;code&gt;DEFER_RUN&lt;/code&gt;, etc.&lt;/td&gt;
&lt;td style="text-align: right;"&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase system&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FREEZE&lt;/code&gt;, &lt;code&gt;THAW&lt;/code&gt;, &lt;code&gt;CLONE&lt;/code&gt;, &lt;code&gt;MARK_FLUID&lt;/code&gt;, &lt;code&gt;REACT&lt;/code&gt;, &lt;code&gt;BOND&lt;/code&gt;, &lt;code&gt;SEED&lt;/code&gt;, etc.&lt;/td&gt;
&lt;td style="text-align: right;"&gt;14&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Builtins/modules&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PRINT&lt;/code&gt;, &lt;code&gt;IMPORT&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SCOPE&lt;/code&gt;, &lt;code&gt;SELECT&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Integer fast paths&lt;/td&gt;
&lt;td&gt;&lt;code&gt;INC_LOCAL&lt;/code&gt;, &lt;code&gt;DEC_LOCAL&lt;/code&gt;, &lt;code&gt;ADD_INT&lt;/code&gt;, &lt;code&gt;SUB_INT&lt;/code&gt;, &lt;code&gt;LOAD_INT8&lt;/code&gt;, etc.&lt;/td&gt;
&lt;td style="text-align: right;"&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wide variants&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONSTANT_16&lt;/code&gt;, &lt;code&gt;GET_GLOBAL_16&lt;/code&gt;, &lt;code&gt;SET_GLOBAL_16&lt;/code&gt;, &lt;code&gt;DEFINE_GLOBAL_16&lt;/code&gt;, &lt;code&gt;CLOSURE_16&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Special&lt;/td&gt;
&lt;td&gt;&lt;code&gt;RESET_EPHEMERAL&lt;/code&gt;, &lt;code&gt;HALT&lt;/code&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td style="text-align: right;"&gt;&lt;strong&gt;100&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The growth came from three directions: the integer fast-path opcodes (8 new), the wide constant variants (5 new), and the concurrency/arena opcodes. Let me explain each.&lt;/p&gt;
&lt;h4&gt;Integer Fast Paths&lt;/h4&gt;
&lt;p&gt;Tight loops like &lt;code&gt;for i in 0..1000&lt;/code&gt; spend most of their time incrementing a counter and comparing it to a bound. The generic &lt;code&gt;OP_ADD&lt;/code&gt; has to check whether its operands are integers, floats, or strings (for concatenation), which adds branching overhead on every iteration.&lt;/p&gt;
&lt;p&gt;The integer fast-path opcodes (&lt;code&gt;OP_ADD_INT&lt;/code&gt;, &lt;code&gt;OP_SUB_INT&lt;/code&gt;, &lt;code&gt;OP_MUL_INT&lt;/code&gt;, &lt;code&gt;OP_LT_INT&lt;/code&gt;, &lt;code&gt;OP_LTEQ_INT&lt;/code&gt;) skip the type check entirely and operate directly on &lt;code&gt;int64_t&lt;/code&gt; values. &lt;code&gt;OP_INC_LOCAL&lt;/code&gt; and &lt;code&gt;OP_DEC_LOCAL&lt;/code&gt; handle the &lt;code&gt;i += 1&lt;/code&gt; and &lt;code&gt;i -= 1&lt;/code&gt; patterns as single-byte instructions that modify the stack slot in place, no push or pop required.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;OP_LOAD_INT8&lt;/code&gt; encodes a signed byte directly in the instruction stream. The integer &lt;code&gt;42&lt;/code&gt; becomes two bytes (&lt;code&gt;OP_LOAD_INT8&lt;/code&gt;, &lt;code&gt;0x2A&lt;/code&gt;) instead of a three-byte &lt;code&gt;OP_CONSTANT&lt;/code&gt; plus an eight-byte constant pool entry. Any integer in [-128, 127] gets this treatment.&lt;/p&gt;
&lt;h4&gt;Wide Constant Variants&lt;/h4&gt;
&lt;p&gt;The original instruction set used a single byte for constant pool indices, limiting each chunk to 256 constants. This is fine for most functions, but the self-hosted compiler (a 2,000-line Lattice program compiled as a single top-level script) blows past that limit easily.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;OP_CONSTANT_16&lt;/code&gt;, &lt;code&gt;OP_GET_GLOBAL_16&lt;/code&gt;, &lt;code&gt;OP_SET_GLOBAL_16&lt;/code&gt;, &lt;code&gt;OP_DEFINE_GLOBAL_16&lt;/code&gt;, and &lt;code&gt;OP_CLOSURE_16&lt;/code&gt; use two-byte big-endian indices, supporting up to 65,536 constants per chunk. The compiler automatically switches to wide variants when an index exceeds 255.&lt;/p&gt;
&lt;h3&gt;The Compiler&lt;/h3&gt;
&lt;p&gt;The bytecode compiler performs a single-pass walk over the AST. It maintains a chain of &lt;code&gt;Compiler&lt;/code&gt; structs linked via &lt;code&gt;enclosing&lt;/code&gt; pointers, one per function being compiled. Variable references resolve through three tiers: local (scan the current compiler's locals array), upvalue (recursively check enclosing compilers), and global (fall through to &lt;code&gt;OP_GET_GLOBAL&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Three compilation modes handle different use cases. &lt;code&gt;compile()&lt;/code&gt; is the standard file mode: it compiles all declarations and emits an implicit call to &lt;code&gt;main()&lt;/code&gt; if one is defined. &lt;code&gt;compile_module()&lt;/code&gt; is for imports, identical to &lt;code&gt;compile()&lt;/code&gt; but skips the auto-call. &lt;code&gt;compile_repl()&lt;/code&gt; preserves the last expression on the stack as the iteration's return value (displayed with &lt;code&gt;=&amp;gt;&lt;/code&gt; prefix) and keeps the known-enum table alive across REPL iterations so enum declarations persist.&lt;/p&gt;
&lt;p&gt;The compiler implements several optimizations during code generation. Binary operations on literal operands are folded at compile time: &lt;code&gt;3 + 4&lt;/code&gt; emits a single &lt;code&gt;OP_LOAD_INT8 7&lt;/code&gt; rather than two loads and an &lt;code&gt;OP_ADD&lt;/code&gt;. The pattern &lt;code&gt;x += 1&lt;/code&gt; is detected and emitted as the single-byte &lt;code&gt;OP_INC_LOCAL&lt;/code&gt;, which modifies the stack slot in place. And every statement is wrapped by &lt;code&gt;compile_stmt_reset()&lt;/code&gt;, which appends &lt;code&gt;OP_RESET_EPHEMERAL&lt;/code&gt; to trigger the ephemeral arena cleanup.&lt;/p&gt;
&lt;h3&gt;Computed Goto Dispatch&lt;/h3&gt;
&lt;p&gt;The dispatch loop now uses GCC/Clang's labels-as-values extension for computed goto:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="cp"&gt;#ifdef VM_USE_COMPUTED_GOTO&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="k"&gt;static&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;void&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dispatch_table&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OP_CONSTANT&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lbl_OP_CONSTANT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;OP_NIL&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;lbl_OP_NIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;// ... all 100 entries&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="cp"&gt;#endif&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(;;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kt"&gt;uint8_t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;READ_BYTE&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="cp"&gt;#ifdef VM_USE_COMPUTED_GOTO&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;goto&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;dispatch_table&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="cp"&gt;#endif&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;switch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Each opcode handler ends with a &lt;code&gt;goto *dispatch_table[READ_BYTE()]&lt;/code&gt; rather than breaking back to the top of the loop. This eliminates the switch statement's bounds check and branch table indirection, replacing it with a single indirect jump. The CPU's branch predictor sees different jump sites for different opcodes, which improves prediction accuracy compared to a single switch that all opcodes funnel through.&lt;/p&gt;
&lt;p&gt;On platforms without the extension, it falls back to a standard switch. The VM works correctly either way.&lt;/p&gt;
&lt;h3&gt;Pre-Compiled Concurrency&lt;/h3&gt;
&lt;p&gt;This is the change I'm most pleased with, because it solves the problem cleanly.&lt;/p&gt;
&lt;p&gt;Lattice has three concurrency primitives: &lt;code&gt;scope&lt;/code&gt; defines a concurrent region, &lt;code&gt;spawn&lt;/code&gt; launches a task within that region, and &lt;code&gt;select&lt;/code&gt; multiplexes over channels. In the tree-walker, these work by passing AST node pointers to spawned threads, which then evaluate the subtrees independently. The bytecode VM's original implementation did the same thing: &lt;code&gt;OP_SCOPE&lt;/code&gt; stored an &lt;code&gt;Expr*&lt;/code&gt; pointer in the constant pool and called the tree-walking evaluator at runtime.&lt;/p&gt;
&lt;p&gt;The solution is to compile each concurrent body into a standalone &lt;code&gt;Chunk&lt;/code&gt; at compile time. The compiler provides two helpers: &lt;code&gt;compile_sub_body()&lt;/code&gt; for statement blocks and &lt;code&gt;compile_sub_expr()&lt;/code&gt; for expressions. Each creates a fresh &lt;code&gt;Compiler&lt;/code&gt;, compiles the code into a new chunk, emits &lt;code&gt;OP_HALT&lt;/code&gt;, and stores the resulting chunk in the parent's constant pool as a &lt;code&gt;VAL_CLOSURE&lt;/code&gt; constant.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;OP_SCOPE&lt;/code&gt; uses variable-length encoding: a spawn count, a sync body chunk index, and one chunk index per spawn body. At runtime, the VM:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Exports locals&lt;/strong&gt; to the global environment using the &lt;code&gt;local_names&lt;/code&gt; debug table, so sub-chunks can access parent variables via &lt;code&gt;OP_GET_GLOBAL&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Runs the sync body&lt;/strong&gt; (if present) via a recursive &lt;code&gt;vm_run()&lt;/code&gt; call&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Spawns threads&lt;/strong&gt; for each spawn body, each running on a cloned VM&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Joins&lt;/strong&gt; all threads and propagates errors&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;code&gt;OP_SELECT&lt;/code&gt; similarly encodes per-arm metadata: flags, channel expression chunk index, body chunk index, and binding name index. The VM evaluates channel expressions, polls for readiness, and executes the winning arm.&lt;/p&gt;
&lt;p&gt;The key insight is that sub-chunks run as &lt;code&gt;FUNC_SCRIPT&lt;/code&gt; without lexical access to the parent's locals. Since they can't use upvalues to reach into the parent frame, the VM exports the parent's live locals into the global environment before running any sub-chunk, using a pushed scope that gets popped after all sub-chunks complete. This is slightly more expensive than true lexical capture, but it keeps the sub-chunks completely self-contained: no AST, no parent frame dependency, fully serializable.&lt;/p&gt;
&lt;h3&gt;Bytecode Serialization&lt;/h3&gt;
&lt;p&gt;With AST dependency eliminated, serialization becomes straightforward. The &lt;code&gt;.latc&lt;/code&gt; binary format starts with an 8-byte header:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;[4C 41 54 43]  magic: "LATC"
[01 00]        format version: 1
[00 00]        reserved
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The rest is a recursive chunk encoding: code length + bytecode bytes, line numbers for source mapping, typed constants (with a one-byte type tag for each), and local name debug info. Constants use seven type tags:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: right;"&gt;Tag&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Encoding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: right;"&gt;0&lt;/td&gt;
&lt;td&gt;Int&lt;/td&gt;
&lt;td&gt;8-byte signed LE&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right;"&gt;1&lt;/td&gt;
&lt;td&gt;Float&lt;/td&gt;
&lt;td&gt;8-byte IEEE 754&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right;"&gt;2&lt;/td&gt;
&lt;td&gt;Bool&lt;/td&gt;
&lt;td&gt;1 byte&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right;"&gt;3&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;length-prefixed (u32 + bytes)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right;"&gt;4&lt;/td&gt;
&lt;td&gt;Nil&lt;/td&gt;
&lt;td&gt;no payload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right;"&gt;5&lt;/td&gt;
&lt;td&gt;Unit&lt;/td&gt;
&lt;td&gt;no payload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: right;"&gt;6&lt;/td&gt;
&lt;td&gt;Closure&lt;/td&gt;
&lt;td&gt;param count + variadic flag + recursive sub-chunk&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The &lt;code&gt;Closure&lt;/code&gt; tag is what makes this recursive: a function constant contains its parameter metadata followed by a complete serialized sub-chunk. Nested functions serialize naturally to arbitrary depth.&lt;/p&gt;
&lt;p&gt;The CLI integrates this cleanly:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;# Compile to .latc&lt;/span&gt;
clat&lt;span class="w"&gt; &lt;/span&gt;compile&lt;span class="w"&gt; &lt;/span&gt;input.lat&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;output.latc

&lt;span class="c1"&gt;# Run pre-compiled bytecode (auto-detects .latc suffix)&lt;/span&gt;
clat&lt;span class="w"&gt; &lt;/span&gt;output.latc

&lt;span class="c1"&gt;# Or compile and run in one step (the default)&lt;/span&gt;
clat&lt;span class="w"&gt; &lt;/span&gt;input.lat
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Loading validates magic bytes, checks the format version, and uses a bounds-checking &lt;code&gt;ByteReader&lt;/code&gt; that produces descriptive error messages for truncated or malformed inputs.&lt;/p&gt;
&lt;h3&gt;The Ephemeral Bump Arena&lt;/h3&gt;
&lt;p&gt;String concatenation is a common source of short-lived allocations. An expression like &lt;code&gt;"hello " + name + "!"&lt;/code&gt; creates intermediate strings that are immediately consumed and discarded. In a language with deep-clone-on-read semantics, these temporaries add up.&lt;/p&gt;
&lt;p&gt;The ephemeral bump arena is a simple optimization: string concatenation in &lt;code&gt;OP_ADD&lt;/code&gt; and &lt;code&gt;OP_CONCAT&lt;/code&gt; allocates into a bump arena (&lt;code&gt;vm-&amp;gt;ephemeral&lt;/code&gt;) instead of the general-purpose heap. These allocations are tagged with &lt;code&gt;REGION_EPHEMERAL&lt;/code&gt;, and &lt;code&gt;OP_RESET_EPHEMERAL&lt;/code&gt; (emitted by the compiler at every statement boundary) resets the arena in O(1), reclaiming all temporary strings at once.&lt;/p&gt;
&lt;p&gt;The tricky part is escape analysis. If a temporary string gets assigned to a global variable, stored in an array, or passed to a compiled closure, it needs to be promoted out of the ephemeral arena before the arena is reset. The VM handles this at specific escape points: &lt;code&gt;OP_DEFINE_GLOBAL&lt;/code&gt;, &lt;code&gt;OP_CALL&lt;/code&gt; (for compiled closures), &lt;code&gt;array.push&lt;/code&gt;, and &lt;code&gt;OP_SET_INDEX_LOCAL&lt;/code&gt;. Each of these calls &lt;code&gt;vm_promote_value()&lt;/code&gt;, which deep-clones the string to the regular heap if its region is ephemeral.&lt;/p&gt;
&lt;p&gt;The arena uses a page-based allocator with 4 KB pages. Resetting doesn't free pages; it just moves the bump pointer back to zero, so subsequent allocations reuse the same memory without any &lt;code&gt;malloc&lt;/code&gt;/&lt;code&gt;free&lt;/code&gt; overhead. The full design and safety proof are covered in a &lt;a href="https://tinycomputers.io/papers/lattice_arena_safety.pdf"&gt;companion paper&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Closures and the Storage Hack&lt;/h3&gt;
&lt;p&gt;The upvalue system hasn't changed architecturally since the &lt;a href="https://tinycomputers.io/posts/from-tree-walker-to-bytecode-vm-compiling-lattice.html"&gt;first VM post&lt;/a&gt;; it's still the Lua-inspired open/closed model where &lt;code&gt;ObjUpvalue&lt;/code&gt; structs start pointing into the stack and get closed (deep-cloned to the heap) when variables go out of scope. But the encoding grew to accommodate the wider instruction set.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;OP_CLOSURE&lt;/code&gt; uses variable-length encoding: a constant pool index for the function's compiled chunk, an upvalue count, and then &lt;code&gt;[is_local, index]&lt;/code&gt; byte pairs for each captured variable. &lt;code&gt;OP_CLOSURE_16&lt;/code&gt; uses a two-byte big-endian function index for chunks with more than 256 constants.&lt;/p&gt;
&lt;p&gt;The storage hack (repurposing &lt;code&gt;closure.body&lt;/code&gt; (NULL), &lt;code&gt;closure.native_fn&lt;/code&gt; (Chunk pointer), &lt;code&gt;closure.captured_env&lt;/code&gt; (ObjUpvalue** cast), and &lt;code&gt;region_id&lt;/code&gt; (upvalue count)) remains unchanged. A sentinel value &lt;code&gt;VM_NATIVE_MARKER&lt;/code&gt; distinguishes C-native functions from compiled closures:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="cp"&gt;#define VM_NATIVE_MARKER ((struct Expr **)(uintptr_t)0x1)&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;A closure with &lt;code&gt;body == NULL&lt;/code&gt; and &lt;code&gt;native_fn != NULL&lt;/code&gt; is either a C native (if &lt;code&gt;default_values == VM_NATIVE_MARKER&lt;/code&gt;) or a compiled bytecode function (otherwise). This avoids adding VM-specific fields to the &lt;code&gt;LatValue&lt;/code&gt; union, which matters when values are deep-cloned frequently.&lt;/p&gt;
&lt;h3&gt;The Self-Hosted Compiler&lt;/h3&gt;
&lt;p&gt;The file &lt;code&gt;compiler/latc.lat&lt;/code&gt; is a bytecode compiler written entirely in Lattice, approximately 2,060 lines that read &lt;code&gt;.lat&lt;/code&gt; source, produce bytecode, and write &lt;code&gt;.latc&lt;/code&gt; files using the same binary format as the C implementation:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="c1"&gt;# Use the self-hosted compiler&lt;/span&gt;
clat&lt;span class="w"&gt; &lt;/span&gt;compiler/latc.lat&lt;span class="w"&gt; &lt;/span&gt;input.lat&lt;span class="w"&gt; &lt;/span&gt;output.latc

&lt;span class="c1"&gt;# Run the result&lt;/span&gt;
clat&lt;span class="w"&gt; &lt;/span&gt;output.latc
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The architecture mirrors the C compiler: lexing via the built-in &lt;code&gt;tokenize()&lt;/code&gt; function, a recursive-descent parser, single-pass code emission, and scope management with upvalue resolution. But Lattice's value semantics required some creative workarounds.&lt;/p&gt;
&lt;p&gt;The biggest constraint is that structs and maps are pass-by-value. In C, the compiler uses a &lt;code&gt;Compiler&lt;/code&gt; struct with mutable fields: local arrays, scope depth, a chunk pointer. In Lattice, passing a struct to a function creates a copy, so mutations in the callee don't propagate back. The self-hosted compiler works around this with parallel global arrays: &lt;code&gt;code&lt;/code&gt;, &lt;code&gt;constants&lt;/code&gt;, &lt;code&gt;c_lines&lt;/code&gt;, &lt;code&gt;local_names&lt;/code&gt;, &lt;code&gt;local_depths&lt;/code&gt;, &lt;code&gt;local_captured&lt;/code&gt;. Since array mutations via &lt;code&gt;.push()&lt;/code&gt; and index assignment are in-place (via &lt;code&gt;resolve_lvalue&lt;/code&gt;), global arrays work where structs don't.&lt;/p&gt;
&lt;p&gt;Nested function compilation uses explicit &lt;code&gt;save_compiler()&lt;/code&gt; / &lt;code&gt;restore_compiler()&lt;/code&gt; functions that copy all global arrays to local temporaries and back. It's verbose but correct. The Buffer type (used for serialization output) is also pass-by-value, so a global &lt;code&gt;ser_buf&lt;/code&gt; accumulates serialized bytes across function calls.&lt;/p&gt;
&lt;p&gt;Other language constraints: no &lt;code&gt;else if&lt;/code&gt; (requires &lt;code&gt;else { if ... }&lt;/code&gt; or &lt;code&gt;match&lt;/code&gt;), mandatory type annotations on function parameters (&lt;code&gt;fn foo(a: any)&lt;/code&gt;), and &lt;code&gt;test&lt;/code&gt; is a keyword so you can't use it as an identifier.&lt;/p&gt;
&lt;p&gt;The self-hosted compiler currently handles expressions, variables, functions with closures, control flow (if/else, while, loop, for, break, continue, match), structs, enums, exceptions, defer, string interpolation, and imports. Not yet implemented: concurrency primitives and advanced phase operations (react, bond, seed). The bootstrapping chain is:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;latc.lat → [C VM interprets] → output.latc → [C VM executes]
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Full self-hosting (where &lt;code&gt;latc.lat&lt;/code&gt; compiles itself) requires adding concurrency support and closing the remaining feature gaps.&lt;/p&gt;
&lt;h3&gt;The VM Execution Engine&lt;/h3&gt;
&lt;p&gt;The VM maintains a 4,096-slot value stack, a 256-frame call stack, an exception handler stack (64 entries), a defer stack (256 entries), a global environment, the open upvalue linked list, the ephemeral arena, and a module cache. A pre-allocated &lt;code&gt;fast_args[16]&lt;/code&gt; buffer avoids heap allocation for most native function calls.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;OP_CALL&lt;/code&gt; instruction discriminates three callee types. Native C functions (marked with &lt;code&gt;VM_NATIVE_MARKER&lt;/code&gt;) get the fast path: arguments are popped into &lt;code&gt;fast_args&lt;/code&gt;, the C function pointer is invoked, and the return value is pushed. No call frame allocated. Compiled closures get the full treatment: the VM promotes ephemeral values in the current frame (so the callee's &lt;code&gt;OP_RESET_EPHEMERAL&lt;/code&gt; doesn't invalidate the caller's temporaries), then pushes a new &lt;code&gt;CallFrame&lt;/code&gt; with the instruction pointer at byte 0 of the callee's chunk. Callable structs look up a constructor-named field and dispatch accordingly.&lt;/p&gt;
&lt;p&gt;Exception handling uses a handler stack. &lt;code&gt;OP_PUSH_EXCEPTION_HANDLER&lt;/code&gt; records the current IP, chunk, call frame index, and stack top. When &lt;code&gt;OP_THROW&lt;/code&gt; executes, the nearest handler is popped, the call frame and value stacks are unwound, the error value is pushed, and execution resumes at the handler's saved IP. Deferred blocks interact correctly: &lt;code&gt;OP_DEFER_RUN&lt;/code&gt; executes all defer entries registered at or above the current frame before the frame is popped by &lt;code&gt;OP_RETURN&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Iterators avoid closure allocation entirely. &lt;code&gt;OP_ITER_INIT&lt;/code&gt; converts a range or array into an internal iterator occupying two stack slots (collection + cursor index). &lt;code&gt;OP_ITER_NEXT&lt;/code&gt; advances the cursor, pushes the next element, or jumps to a specified offset when exhausted. The tree-walker used closure-based iterators for &lt;code&gt;for&lt;/code&gt; loops; the bytecode version is simpler and avoids the allocation.&lt;/p&gt;
&lt;h3&gt;Ref&amp;lt;T&amp;gt;: The Escape Hatch from Value Semantics&lt;/h3&gt;
&lt;p&gt;Everything described so far operates in a world where values are deep-cloned on every read. Maps are pass-by-value. Structs are pass-by-value. Pass a collection to a function and the function gets its own copy; mutations don't propagate back. This is correct and eliminates aliasing bugs, but it creates a real problem: how do you share mutable state when you actually need to?&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Ref&amp;lt;T&amp;gt;&lt;/code&gt; is the answer. It's a reference-counted shared mutable wrapper, the one type in Lattice that deliberately breaks value semantics:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nc"&gt;LatRef&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;LatValue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;// the wrapped inner value&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="n"&gt;refcount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// reference count&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;When a &lt;code&gt;Ref&lt;/code&gt; is cloned (which happens on every variable read, like everything else), the VM bumps the refcount and copies the pointer. It does &lt;em&gt;not&lt;/em&gt; deep-clone the inner value. Multiple copies of a &lt;code&gt;Ref&lt;/code&gt; share the same underlying &lt;code&gt;LatRef&lt;/code&gt;, so mutations through one are visible through all others. This is the explicit opt-in to reference semantics that the rest of the language avoids.&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;let r = Ref::new([1, 2, 3])
let r2 = r              // shallow copy — same LatRef
r.push(4)
print(r2.get())          // [1, 2, 3, 4] — shared state
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;The VM provides transparent proxying: &lt;code&gt;OP_INDEX&lt;/code&gt;, &lt;code&gt;OP_SET_INDEX&lt;/code&gt;, and &lt;code&gt;OP_INVOKE&lt;/code&gt; all check for &lt;code&gt;VAL_REF&lt;/code&gt; and delegate to the inner value. Indexing into a &lt;code&gt;Ref&amp;lt;Array&amp;gt;&lt;/code&gt; indexes the inner array. Calling &lt;code&gt;.push()&lt;/code&gt; on a &lt;code&gt;Ref&amp;lt;Array&amp;gt;&lt;/code&gt; mutates the inner array directly. At the language level, a Ref mostly behaves like the value it wraps; you just get shared mutation instead of isolated copies.&lt;/p&gt;
&lt;p&gt;Ref has its own methods (&lt;code&gt;get()&lt;/code&gt;/&lt;code&gt;deref()&lt;/code&gt; to clone the inner value out, &lt;code&gt;set(v)&lt;/code&gt; to replace it, &lt;code&gt;inner_type()&lt;/code&gt; to inspect the wrapped type) plus proxied methods for whatever the inner value supports (map &lt;code&gt;set&lt;/code&gt;/&lt;code&gt;get&lt;/code&gt;/&lt;code&gt;keys&lt;/code&gt;, array &lt;code&gt;push&lt;/code&gt;/&lt;code&gt;pop&lt;/code&gt;, etc.).&lt;/p&gt;
&lt;p&gt;The phase system applies to Refs too. Freezing a Ref blocks all mutation: &lt;code&gt;set()&lt;/code&gt;, &lt;code&gt;push()&lt;/code&gt;, index assignment all check &lt;code&gt;obj-&amp;gt;phase == VTAG_CRYSTAL&lt;/code&gt; and error with "cannot set on a frozen Ref." This makes frozen Refs safe to share across concurrent boundaries; they're immutable handles to immutable data.&lt;/p&gt;
&lt;p&gt;This introduces a third memory management strategy alongside the dual-heap (mark-and-sweep for fluid values, arenas for crystal values) and the ephemeral bump arena. Refs use reference counting: &lt;code&gt;ref_retain()&lt;/code&gt; on clone, &lt;code&gt;ref_release()&lt;/code&gt; on free, with the inner value freed when the count hits zero. It's a deliberate trade-off: reference counting is simple and deterministic, and since Refs are the uncommon case (most Lattice code uses value semantics), the lack of cycle collection hasn't been an issue in practice.&lt;/p&gt;
&lt;h3&gt;Validation&lt;/h3&gt;
&lt;p&gt;The VM is validated by &lt;strong&gt;815 tests&lt;/strong&gt; covering every feature: arithmetic, closures, upvalues, phase transitions, exception handling, defer, iterators, data structures, concurrency, modules, bytecode serialization, and the self-hosted compiler.&lt;/p&gt;
&lt;p&gt;All 815 tests pass under both normal compilation and AddressSanitizer builds (&lt;code&gt;make asan&lt;/code&gt;), which dynamically checks for heap buffer overflows, use-after-free, stack buffer overflows, and memory leaks. For a VM with manual memory management, upvalue lifetime tracking, and an ephemeral arena that reclaims memory at statement boundaries, ASan validation is essential.&lt;/p&gt;
&lt;p&gt;Both execution modes (bytecode VM (default) and tree-walker (&lt;code&gt;--tree-walk&lt;/code&gt;)) share the same test suite and produce identical results:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;make&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="c1"&gt;# bytecode VM: 815 passed&lt;/span&gt;
make&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;test&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;TREE_WALK&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# tree-walker: 815 passed&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Feature parity is complete:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th style="text-align: center;"&gt;Tree-walker&lt;/th&gt;
&lt;th style="text-align: center;"&gt;Bytecode VM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phase system (freeze/thaw/clone/forge)&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Closures with upvalues&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Exception handling (try/catch/throw)&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Defer blocks&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pattern matching&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structs with methods&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enums with payloads&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Arrays, maps, tuples, sets, buffers&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iterators (for-in, ranges)&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Module imports&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency (scope/spawn/select)&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Channels&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Phase reactions/bonds/seeds&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Contracts (require/ensure)&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variable tracking (history)&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bytecode serialization (.latc)&lt;/td&gt;
&lt;td style="text-align: center;"&gt;---&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Computed goto dispatch&lt;/td&gt;
&lt;td style="text-align: center;"&gt;---&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ephemeral bump arena&lt;/td&gt;
&lt;td style="text-align: center;"&gt;---&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Specialized integer ops&lt;/td&gt;
&lt;td style="text-align: center;"&gt;---&lt;/td&gt;
&lt;td style="text-align: center;"&gt;✓&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The last four rows are VM-only features that have no tree-walker equivalent.&lt;/p&gt;
&lt;h3&gt;What's Next&lt;/h3&gt;
&lt;p&gt;The VM is feature-complete but not performance-optimized. The obvious next steps are register allocation to reduce stack traffic, type-specialized dispatch paths guided by runtime profiling, tail call optimization for recursive patterns, and constant pool deduplication across compilation units. Further out, the bytecode provides a natural intermediate representation for JIT compilation.&lt;/p&gt;
&lt;p&gt;On the self-hosting front, adding concurrency primitives to &lt;code&gt;latc.lat&lt;/code&gt; would close the gap to full self-compilation, where the Lattice compiler compiles itself, producing a &lt;code&gt;.latc&lt;/code&gt; file that can then compile other programs without the C implementation in the loop.&lt;/p&gt;
&lt;p&gt;The full technical details (including encoding diagrams, the complete opcode listing, compilation walkthroughs, and references to related work in Lua, CPython, YARV, and WebAssembly) are in the &lt;a href="https://tinycomputers.io/papers/lattice_vm.pdf"&gt;research paper&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The source code is at &lt;a href="https://baud.rs/fIe3gx"&gt;github.com/ajokela/lattice&lt;/a&gt;, and the project site is at &lt;a href="https://baud.rs/bwvnYT"&gt;lattice-lang.org&lt;/a&gt;.&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;git clone https://github.com/ajokela/lattice.git
cd lattice &amp;amp;&amp;amp; make
./clat
&lt;/pre&gt;&lt;/div&gt;</description><category>bytecode</category><category>c</category><category>closures</category><category>compilers</category><category>concurrency</category><category>interpreters</category><category>language design</category><category>lattice</category><category>phase system</category><category>programming languages</category><category>self-hosting</category><category>serialization</category><category>upvalues</category><category>virtual machine</category><guid>https://tinycomputers.io/posts/a-stack-based-bytecode-vm-for-lattice.html</guid><pubDate>Fri, 20 Feb 2026 18:00:00 GMT</pubDate></item><item><title>Introducing Lattice: A Crystallization-Based Programming Language</title><link>https://tinycomputers.io/posts/introducing-lattice-a-crystallization-based-programming-language.html?utm_source=feed&amp;utm_medium=rss&amp;utm_campaign=rss</link><dc:creator>A.C. Jokela</dc:creator><description>&lt;p&gt;Most programming languages treat mutability as a binary property. A variable is either mutable or it's not. You declare it one way, and that's the end of the story. Rust adds nuance with its ownership and borrowing model, and functional languages sidestep the question by making everything immutable by default, but the fundamental framing remains the same: mutability is a static attribute decided at declaration time.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://baud.rs/4ysPkF"&gt;Lattice&lt;/a&gt; takes a different approach. In Lattice, mutability is a &lt;em&gt;phase&lt;/em&gt;, a state that a value passes through over its lifetime, like matter transitioning between liquid and solid. A value starts as mutable &lt;strong&gt;flux&lt;/strong&gt;, and when you're done shaping it, you &lt;strong&gt;freeze&lt;/strong&gt; it into immutable &lt;strong&gt;fix&lt;/strong&gt;. Need to modify it again? &lt;strong&gt;Thaw&lt;/strong&gt; it back to flux. Want to build something complex and immutable in one shot? Use a &lt;strong&gt;forge&lt;/strong&gt; block, a controlled mutation zone whose output automatically crystallizes.&lt;/p&gt;
&lt;p&gt;This isn't just a metaphor. The phase system is woven through Lattice's entire runtime, from its type representation to its memory management architecture. This post is a deep dive into what that means, how it works at the implementation level, and why it represents a genuinely different way of thinking about the relationship between mutability and memory.&lt;/p&gt;
&lt;div class="audio-widget"&gt;
&lt;div class="audio-widget-header"&gt;
&lt;span class="audio-widget-icon"&gt;🎧&lt;/span&gt;
&lt;span class="audio-widget-label"&gt;Listen to this article&lt;/span&gt;
&lt;/div&gt;
&lt;audio controls preload="metadata"&gt;
&lt;source src="https://tinycomputers.io/introducing-lattice-a-crystallization-based-programming-language_tts.mp3" type="audio/mpeg"&gt;
&lt;/source&gt;&lt;/audio&gt;
&lt;div class="audio-widget-footer"&gt;36 min · AI-generated narration&lt;/div&gt;
&lt;/div&gt;

&lt;h3&gt;The Problem Lattice Solves&lt;/h3&gt;
&lt;p&gt;Every language designer eventually confronts the same tension: programmers need mutability to build things, but mutability is the source of most bugs. Shared mutable state causes race conditions. Unexpected mutation causes aliasing bugs. Mutable references that outlive their owners cause use-after-free errors.&lt;/p&gt;
&lt;p&gt;Different languages resolve this tension in different ways, and each approach carries trade-offs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Garbage-collected languages&lt;/strong&gt; (Java, Python, Go, JavaScript) let you mutate freely and use a garbage collector to clean up. This is convenient but pushes the cost to runtime: GC pauses, unpredictable memory usage, and no compile-time guarantees about who can modify what. You gain ease of use but lose control.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://baud.rs/gSnSwR"&gt;Rust's ownership model&lt;/a&gt;&lt;/strong&gt; provides compile-time guarantees through a sophisticated borrow checker. You can have either one mutable reference or many immutable references, but not both. This eliminates data races at compile time, but the cost is complexity: the borrow checker is notoriously difficult for newcomers, lifetime annotations add syntactic weight, and certain patterns (like self-referential structs or graph structures) require unsafe escape hatches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Functional languages&lt;/strong&gt; (Haskell, Erlang, Clojure) default to immutability and model mutation through controlled mechanisms like monads, processes, or atoms. This produces correct programs but can feel unnatural for inherently stateful problems, and persistent data structures carry performance overhead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;C and C++&lt;/strong&gt; give you full manual control and zero overhead, at the cost of memory safety. &lt;code&gt;const&lt;/code&gt; in C is advisory at best; you can cast it away, and the compiler won't stop you from freeing memory that someone else is still using.&lt;/p&gt;
&lt;p&gt;Lattice's phase system is an attempt to find a different point in this design space. The core insight is that in most programs, values have a natural lifecycle: they're constructed (requiring mutation), then used (requiring stability), and occasionally reconstructed (requiring mutation again). The phase system makes this lifecycle explicit and enforceable.&lt;/p&gt;
&lt;h3&gt;The Phase Model&lt;/h3&gt;
&lt;p&gt;Lattice has three binding keywords that correspond to mutability phases:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;flux&lt;/code&gt;&lt;/strong&gt; declares a mutable binding. A flux variable can be reassigned, and its contents can be modified in place. This is where you do your work: building arrays, populating maps, incrementing counters.&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;flux counter = 0
counter += 1
counter += 1
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;fix&lt;/code&gt;&lt;/strong&gt; declares an immutable binding. A fix variable cannot be reassigned, and its contents cannot be modified. Attempting to mutate a fix binding is an error.&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="n"&gt;fix&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;freeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;3.14159&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// pi = 2.0  -- error: cannot assign to crystal binding&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;let&lt;/code&gt;&lt;/strong&gt; is the inferred form (available in casual mode). It doesn't enforce a phase; the value keeps whatever phase tag it already has.&lt;/p&gt;
&lt;p&gt;The transitions between phases are explicit function calls:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;freeze(value)&lt;/code&gt;&lt;/strong&gt; transitions a value from fluid to crystal. In strict mode, this is a &lt;em&gt;consuming&lt;/em&gt; operation: the original binding is removed from the environment. You can't accidentally keep a mutable reference to something you've declared immutable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;thaw(value)&lt;/code&gt;&lt;/strong&gt; creates a mutable deep clone of a crystal value. The original remains frozen; you get a completely independent mutable copy.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;clone(value)&lt;/code&gt;&lt;/strong&gt; creates a deep copy without changing phase.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And then there's the &lt;strong&gt;&lt;code&gt;forge&lt;/code&gt;&lt;/strong&gt; block, which is perhaps the most interesting construct:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;fix config = forge {
    flux temp = Map::new()
    temp.set("host", "localhost")
    temp.set("port", "8080")
    temp.set("debug", "true")
    freeze(temp)
}
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;A forge block is a scoped computation whose result is automatically frozen. Inside the forge, you can use flux variables and mutate freely. But whatever value the block produces comes out crystallized. The temporary mutable state is gone; only the finished, immutable result survives.&lt;/p&gt;
&lt;p&gt;This addresses a real pain point. In functional languages, building a complex immutable data structure often requires awkward chains of constructor calls or builder patterns. In Lattice, you just... build it, mutably, in a forge block, and it comes out frozen. The forge acknowledges that construction is inherently a mutable process, while insisting that the &lt;em&gt;result&lt;/em&gt; of construction should be stable.&lt;/p&gt;
&lt;h3&gt;Under the Hood: How the Phase System Maps to Memory&lt;/h3&gt;
&lt;p&gt;Lattice is implemented as a tree-walking interpreter in C, roughly 6,000 lines across the lexer, parser, phase checker, and evaluator. The implementation reveals some interesting design decisions about how phase semantics interact with memory management.&lt;/p&gt;
&lt;h4&gt;Value Representation&lt;/h4&gt;
&lt;p&gt;Every runtime value in Lattice is a &lt;code&gt;LatValue&lt;/code&gt; struct, a tagged union carrying a type tag, a phase tag, and the value payload:&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="k"&gt;struct&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nc"&gt;LatValue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;ValueType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;type&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// VAL_INT, VAL_STR, VAL_ARRAY, VAL_MAP, ...&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="n"&gt;PhaseTag&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;// VTAG_FLUID, VTAG_CRYSTAL, VTAG_UNPHASED&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;union&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;as&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;Primitive values (integers, floats, booleans) live inline in the union, with no heap allocation. Compound values (strings, arrays, structs, maps, closures) own heap-allocated payloads. A string holds a heap-allocated character buffer. An array holds a &lt;code&gt;malloc&lt;/code&gt;'d element buffer. A map holds a pointer to an open-addressing hash table.&lt;/p&gt;
&lt;h4&gt;Deep-Clone-on-Read: Value Semantics Without a Compiler&lt;/h4&gt;
&lt;p&gt;The most consequential design decision in Lattice's runtime is that &lt;strong&gt;every variable read produces a deep clone&lt;/strong&gt;. When you access a variable, the environment doesn't hand you a reference to the stored value. It hands you a complete, independent copy.&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;env_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Env&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;const&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;char&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LatValue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;size_t&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="n"&gt;LatValue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lat_map_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="mi"&gt;-1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value_deep_clone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c1"&gt;// always a fresh copy&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/pre&gt;&lt;/div&gt;

&lt;p&gt;This is expensive. Every array access clones the entire array. Every map read clones every key-value pair. But it eliminates an entire class of bugs. There is no aliasing in Lattice. Two variables never point to the same underlying memory. When you pass a map to a function, the function gets its own copy, and mutations inside the function don't leak back to the caller. When you assign an array to a new variable, you get two independent arrays.&lt;/p&gt;
&lt;p&gt;This is the implementation strategy that makes Lattice's maps value types. In most languages, objects and collections are reference types, and assigning them to a new variable creates a new reference to the same data. In Lattice, assignment means duplication. This is closer to how values work in mathematics than how they work in most programming languages.&lt;/p&gt;
&lt;p&gt;For in-place mutation within a scope (like &lt;code&gt;array.push()&lt;/code&gt; or &lt;code&gt;map.set()&lt;/code&gt;), Lattice uses a separate &lt;code&gt;resolve_lvalue()&lt;/code&gt; mechanism that obtains a direct mutable pointer into the environment's storage, bypassing the deep clone. This means local mutations are efficient; it's only cross-scope communication that pays the cloning cost.&lt;/p&gt;
&lt;h4&gt;The Dual Heap Architecture&lt;/h4&gt;
&lt;p&gt;Lattice's memory subsystem uses what the implementation calls a &lt;code&gt;DualHeap&lt;/code&gt;: two separate allocation regions with different management strategies:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The FluidHeap&lt;/strong&gt; manages mutable data using a mark-and-sweep garbage collector. It maintains a linked list of all heap allocations, with a mark bit on each. When memory pressure crosses a threshold (1 MB by default), the GC walks all reachable values from the environment and a shadow root stack, marks what's alive, and sweeps everything else.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The RegionManager&lt;/strong&gt; manages immutable data using arena-based regions. Each freeze creates a new region backed by a page-based arena, a linked list of 4 KB pages with bump allocation. When a value is frozen, it is deep-cloned entirely into the region's arena, giving crystal data cache locality and enabling O(1) bulk deallocation when the region becomes unreachable. Regions are collected during GC cycles based on reachability analysis.&lt;/p&gt;
&lt;p&gt;The key insight here is that &lt;strong&gt;immutable and mutable data have different lifecycle characteristics&lt;/strong&gt; and benefit from different management strategies. Mutable data changes frequently and has unpredictable lifetimes, and mark-and-sweep handles this well. Immutable data, once created, never changes and tends to be long-lived, so arena-based region allocation is more efficient for this pattern, as it enables bulk deallocation and better cache locality.&lt;/p&gt;
&lt;p&gt;This is conceptually similar to generational garbage collection (where young objects are collected differently from old objects), but the split is based on &lt;em&gt;mutability&lt;/em&gt; rather than &lt;em&gt;age&lt;/em&gt;. Lattice's phase tags provide the runtime with information that generational GCs have to infer statistically.&lt;/p&gt;
&lt;p&gt;The following chart shows how this plays out in practice across several benchmark programs. Fluid peak memory represents the high-water mark of the GC-managed heap, while crystal arena data shows how much data has been frozen into arena-backed regions:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Fluid Peak vs Crystal Arena Data" src="https://tinycomputers.io/images/lattice_fluid_vs_crystal.png"&gt;&lt;/p&gt;
&lt;h4&gt;Freeze and Thaw at the Memory Level&lt;/h4&gt;
&lt;p&gt;When you call &lt;code&gt;freeze()&lt;/code&gt; on a value, the runtime creates a new crystal region with a fresh arena, deep-clones the entire value tree into it, sets the &lt;code&gt;phase&lt;/code&gt; field to &lt;code&gt;VTAG_CRYSTAL&lt;/code&gt; on every node, and frees the original fluid heap pointers. The data physically migrates from the fluid heap into arena pages. Freeze is a move operation, not just a metadata flip. This gives frozen data cache locality within contiguous arena pages and completely separates it from the garbage-collected fluid heap.&lt;/p&gt;
&lt;p&gt;But in strict mode, &lt;code&gt;freeze()&lt;/code&gt; is also a &lt;em&gt;consuming&lt;/em&gt; operation. It removes the original binding from the environment and returns the frozen value. This is effectively a move: after &lt;code&gt;freeze(x)&lt;/code&gt;, there is no &lt;code&gt;x&lt;/code&gt; anymore. You can bind the result to a new name (&lt;code&gt;fix y = freeze(x)&lt;/code&gt;), but the mutable original is gone. This prevents a common bug pattern where you freeze a value but accidentally keep mutating the original through a still-live reference.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;thaw()&lt;/code&gt; is more expensive: it performs a complete deep clone of the crystal value and then recursively sets all phase tags to &lt;code&gt;VTAG_FLUID&lt;/code&gt;. The original crystal value is untouched; you get a completely independent mutable copy. This is consistent with the principle that crystal values are permanent. Thawing doesn't melt the original; it creates a new fluid copy.&lt;/p&gt;
&lt;p&gt;In practice, both operations are fast. Across the benchmark suite, freeze and thaw costs stay well under a millisecond even for complex data structures:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Freeze/Thaw Cost by Benchmark" src="https://tinycomputers.io/images/lattice_freeze_thaw_timing.png"&gt;&lt;/p&gt;
&lt;p&gt;The number and type of phase transitions varies by workload. Some benchmarks are freeze-heavy (building immutable snapshots), others are thaw-heavy (repeatedly modifying frozen state), and some use deep clones for full value duplication:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Phase Transitions by Benchmark" src="https://tinycomputers.io/images/lattice_phase_transitions.png"&gt;&lt;/p&gt;
&lt;h3&gt;How This Compares to Existing Systems&lt;/h3&gt;
&lt;h4&gt;vs. Rust's Ownership and Borrowing&lt;/h4&gt;
&lt;p&gt;Rust solves the mutability problem at compile time through static analysis. The borrow checker ensures that mutable references are unique and that immutable references don't coexist with mutable ones. This gives Rust zero-runtime-cost safety guarantees that Lattice can't match.&lt;/p&gt;
&lt;p&gt;But Rust's approach operates at the reference level. It tracks who has access to data, not the data's intrinsic state. You can have an &lt;code&gt;&amp;amp;mut&lt;/code&gt; to data that is conceptually "done being built," or an &lt;code&gt;&amp;amp;&lt;/code&gt; to data that you wish you could modify. The permission model and the data lifecycle are orthogonal.&lt;/p&gt;
&lt;p&gt;Lattice's phase system operates on the data itself. A frozen value &lt;em&gt;is&lt;/em&gt; immutable, not because the type system prevents you from obtaining a mutable reference, but because the value has transitioned to a state where mutation doesn't apply. This is a simpler mental model at the cost of runtime enforcement rather than compile-time proof.&lt;/p&gt;
&lt;p&gt;The consuming &lt;code&gt;freeze()&lt;/code&gt; in strict mode is reminiscent of Rust's move semantics, where using a value after moving it is a compile error. Lattice achieves a similar effect at runtime: freeze consumes the binding, preventing further mutable access. It's not as strong a guarantee (runtime vs. compile time), but it's the same intuition: once you've declared something immutable, the mutable version shouldn't exist anymore.&lt;/p&gt;
&lt;h4&gt;vs. Garbage Collection&lt;/h4&gt;
&lt;p&gt;Traditional garbage collectors (Java, Go, Python) are phase-agnostic. They track reachability, not mutability. A &lt;code&gt;final&lt;/code&gt; field in Java prevents reassignment but doesn't inform the GC. An immutable object in Python is collected the same way as a mutable one.&lt;/p&gt;
&lt;p&gt;Lattice's dual-heap architecture uses phase information to make better allocation decisions. Crystal values go into arena-managed memory with reachability-based collection. Fluid values go into a mark-and-sweep heap. The GC can reason about immutable data more efficiently because it &lt;em&gt;knows&lt;/em&gt; the data won't change, so it doesn't need to re-scan crystal regions for updated references.&lt;/p&gt;
&lt;p&gt;This is a form of phase-informed memory management that, to my knowledge, doesn't have a direct precedent in mainstream languages. The closest analogy might be Clojure's persistent data structures, which are structurally shared and immutable, but Clojure doesn't use this information to drive its garbage collection strategy differently.&lt;/p&gt;
&lt;h4&gt;vs. Functional Immutability&lt;/h4&gt;
&lt;p&gt;Haskell and other pure functional languages are immutable by default, with mutation confined to monads (&lt;code&gt;IORef&lt;/code&gt;, &lt;code&gt;STRef&lt;/code&gt;) or similar controlled mechanisms. This is elegant but can be awkward for imperative algorithms where you need to build something up step by step.&lt;/p&gt;
&lt;p&gt;Lattice's forge blocks address this directly. Instead of threading a builder through a chain of pure function calls, you write imperative mutation inside a forge and get an immutable result. This acknowledges that construction and consumption are different activities that benefit from different mutability guarantees.&lt;/p&gt;
&lt;p&gt;The philosophical difference is that functional languages treat immutability as the default and mutation as the exception. Lattice treats mutability as a &lt;em&gt;phase&lt;/em&gt; that values pass through, and both flux and fix are natural, expected states, and the language provides explicit tools for transitioning between them.&lt;/p&gt;
&lt;h4&gt;vs. C/C++ Manual Memory Management&lt;/h4&gt;
&lt;p&gt;C gives you &lt;code&gt;malloc&lt;/code&gt; and &lt;code&gt;free&lt;/code&gt; and wishes you the best. C++ adds RAII, smart pointers, and &lt;code&gt;const&lt;/code&gt; correctness, but &lt;code&gt;const&lt;/code&gt; in both languages is fundamentally a compiler hint. It can be cast away, and the runtime has no awareness of it. A &lt;code&gt;const&lt;/code&gt; pointer in C doesn't prevent someone else from modifying the data through a non-const pointer to the same memory. The &lt;code&gt;const&lt;/code&gt; is a property of the &lt;em&gt;reference&lt;/em&gt;, not the &lt;em&gt;data&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Lattice's phase tags live on the data itself. When a value is crystal, it's crystal regardless of how you access it. There's no way to "cast away" a freeze; the only path back to mutability is &lt;code&gt;thaw()&lt;/code&gt;, which creates a new independent copy. This is a stronger guarantee than &lt;code&gt;const&lt;/code&gt; provides, because it operates on values rather than references.&lt;/p&gt;
&lt;p&gt;C++ move semantics share DNA with Lattice's consuming &lt;code&gt;freeze()&lt;/code&gt; in strict mode. A &lt;code&gt;std::move&lt;/code&gt; in C++ transfers ownership of resources, leaving the source in a valid-but-unspecified state. Lattice's strict freeze does something similar: it removes the binding entirely, ensuring the mutable version ceases to exist. But where C++ moves are primarily about avoiding copies for performance, Lattice's consuming freeze is about semantic correctness, ensuring that the transition from mutable to immutable is clean and total. Scott Meyers' &lt;a href="https://baud.rs/OK4IwA"&gt;Effective Modern C++&lt;/a&gt; remains the best guide to understanding these move semantics and other modern C++ patterns that Lattice's design draws from.&lt;/p&gt;
&lt;h4&gt;The Static Phase Checker&lt;/h4&gt;
&lt;p&gt;It's worth noting that Lattice doesn't rely solely on runtime enforcement. Before any code executes, a static phase checker walks the AST and catches phase violations at analysis time. This checker maintains its own scope stack mapping variable names to their declared phases and rejects programs that attempt to reassign crystal bindings, freeze already-frozen values, thaw already-fluid values, or use &lt;code&gt;let&lt;/code&gt; in strict mode where an explicit phase declaration is required.&lt;/p&gt;
&lt;p&gt;The static checker also enforces spawn boundaries. If Lattice's concurrency model (&lt;code&gt;spawn&lt;/code&gt;) is used, fluid bindings from the enclosing scope cannot be captured across the spawn point. Only crystal values can be shared into spawned computations. This is checked &lt;em&gt;before&lt;/em&gt; evaluation begins, catching potential data races at parse time rather than at runtime.&lt;/p&gt;
&lt;p&gt;This two-layer approach (static checking before evaluation, runtime enforcement during) provides confidence without requiring a full type system or borrow checker. It catches the obvious mistakes early and enforces the subtle invariants at runtime. For the theoretical foundations behind this kind of phase-based type analysis, Benjamin Pierce's &lt;a href="https://baud.rs/oMfDwe"&gt;Types and Programming Languages&lt;/a&gt; is the standard reference.&lt;/p&gt;
&lt;h3&gt;The Language Beyond Phases&lt;/h3&gt;
&lt;p&gt;While the phase system is Lattice's defining feature, the language has other characteristics worth noting.&lt;/p&gt;
&lt;p&gt;Structs in Lattice can hold closures as fields, enabling object-like patterns without a class system. A struct with function fields and a &lt;code&gt;self&lt;/code&gt; parameter in each closure behaves much like an object with methods, but the data flow is explicit, and there's no hidden &lt;code&gt;this&lt;/code&gt; pointer or vtable dispatch. When a closure captures &lt;code&gt;self&lt;/code&gt;, it receives a deep clone, ensuring that method calls don't produce spooky action at a distance.&lt;/p&gt;
&lt;p&gt;Control flow is expression-based: &lt;code&gt;if&lt;/code&gt;/&lt;code&gt;else&lt;/code&gt; blocks, &lt;code&gt;match&lt;/code&gt; expressions, and bare blocks all return values. This reduces the need for temporary variables and makes code more compositional. Error handling uses &lt;code&gt;try&lt;/code&gt;/&lt;code&gt;catch&lt;/code&gt; blocks with explicit error values rather than exception hierarchies.&lt;/p&gt;
&lt;p&gt;The self-hosted REPL is particularly notable. Written entirely in Lattice, it demonstrates that the language is expressive enough to implement its own interactive environment, parsing multi-line input, evaluating expressions, and managing session state. Running &lt;code&gt;./clat&lt;/code&gt; without arguments drops into this REPL, while &lt;code&gt;./clat file.lat&lt;/code&gt; executes a program directly.&lt;/p&gt;
&lt;p&gt;Lattice is implemented in C with no external dependencies. The entire codebase (roughly 6,000 lines across the lexer, parser, phase checker, evaluator, and data structures) compiles with a single &lt;code&gt;make&lt;/code&gt; invocation. This is a deliberate choice. The language is meant to be small, understandable, and self-contained. You can read the entire implementation in an afternoon. If you're interested in this kind of work, Robert Nystrom's &lt;a href="https://baud.rs/uTpA6y"&gt;&lt;em&gt;Crafting Interpreters&lt;/em&gt;&lt;/a&gt; is the best practical guide to building language implementations from scratch. It covers both tree-walking interpreters and bytecode VMs, and Lattice's architecture shares several design decisions with Nystrom's Lox language. For the C implementation side, Kernighan and Ritchie's &lt;a href="https://baud.rs/71h6l3"&gt;&lt;em&gt;The C Programming Language&lt;/em&gt;&lt;/a&gt; remains the definitive reference for writing the kind of clean, minimal C that Lattice targets.&lt;/p&gt;
&lt;h3&gt;Runtime Characteristics&lt;/h3&gt;
&lt;p&gt;To understand how the dual-heap architecture behaves in practice, Lattice includes a benchmark suite that exercises different memory patterns: allocation churn, closure-heavy computation, event sourcing, freeze/thaw cycles, game state rollback, long-lived crystal data, persistent tree construction, and undo/redo stacks.&lt;/p&gt;
&lt;p&gt;The overview below shows peak RSS (resident set size) alongside the number of live crystal regions at program exit. Benchmarks that use the phase system heavily (like freeze/thaw cycles and persistent trees) maintain more live regions, while purely fluid workloads like allocation churn and closure-heavy computation have none:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Peak RSS and Crystal Regions Overview" src="https://tinycomputers.io/images/lattice_overview.png"&gt;&lt;/p&gt;
&lt;p&gt;The memory churn ratio (total bytes allocated divided by peak live bytes) reveals how aggressively each benchmark recycles memory. A high ratio means the program allocates and discards data rapidly, relying on the GC to keep the working set small. Benchmarks using crystal regions (shown in purple) tend to have lower churn because frozen data is long-lived by design:&lt;/p&gt;
&lt;p&gt;&lt;img alt="Memory Churn Ratio" src="https://tinycomputers.io/images/lattice_churn_ratio.png"&gt;&lt;/p&gt;
&lt;h3&gt;Research Papers&lt;/h3&gt;
&lt;p&gt;For readers interested in the formal foundations and empirical analysis, two companion papers are available:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tinycomputers.io/papers/lattice_paper.pdf"&gt;The Lattice Phase System: First-Class Immutability with Dual-Heap Memory Management&lt;/a&gt;&lt;/strong&gt;: The full research paper covering the language design, formal operational semantics, six proved safety properties (phase monotonicity, value isolation, consuming freeze, forge soundness, heap separation, and thaw independence), implementation details of the dual-heap architecture, and empirical evaluation across eight benchmarks.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href="https://tinycomputers.io/papers/lattice_formal_semantics.pdf"&gt;Formal Semantics of the Lattice Phase System&lt;/a&gt;&lt;/strong&gt;: A standalone formal treatment containing the complete semantic domains, static phase-checking rules, big-step operational semantics, memory model, and full proofs of all six safety theorems.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;Looking Forward&lt;/h3&gt;
&lt;p&gt;Lattice is at version 0.1.3, which means it's early. The dual-heap architecture is fully wired into the evaluator. Freeze operations physically migrate data into arena-backed crystal regions, providing cache locality and O(1) bulk deallocation for immutable data. The mark-and-sweep GC handles fluid values, while crystal regions are collected through reachability analysis during GC cycles.&lt;/p&gt;
&lt;p&gt;The deep-clone-on-read strategy is correct but expensive. Future versions may introduce structural sharing for crystal values (since they can't be modified, sharing is safe) or copy-on-write semantics for fluid values that haven't actually been mutated. The phase tags provide the runtime with exactly the information needed to make these optimizations: which values can be shared safely, and which might change.&lt;/p&gt;
&lt;p&gt;There's also the question of concurrency. The phase system provides a natural foundation for safe concurrent programming: crystal values can be freely shared across threads (they're immutable), while fluid values are confined to their owning scope. The &lt;code&gt;spawn&lt;/code&gt; keyword exists in the parser and phase checker, with static analysis already preventing fluid bindings from crossing spawn boundaries, though concurrent execution isn't yet implemented.&lt;/p&gt;
&lt;p&gt;The source code is available on &lt;a href="https://baud.rs/fIe3gx"&gt;GitHub&lt;/a&gt; under the BSD 3-Clause license, and the project site is at &lt;a href="https://baud.rs/4ysPkF"&gt;lattice-lang.org&lt;/a&gt;. If you're interested in language design, memory management, or just want to play with a language that treats mutability as a physical process rather than a type annotation, it's worth a look.&lt;/p&gt;
&lt;div class="code"&gt;&lt;pre class="code literal-block"&gt;git clone https://github.com/ajokela/lattice.git
cd lattice &amp;amp;&amp;amp; make
./clat
&lt;/pre&gt;&lt;/div&gt;</description><category>c</category><category>immutability</category><category>interpreter</category><category>language design</category><category>lattice</category><category>memory management</category><category>mutability</category><category>phase system</category><category>programming languages</category><category>value semantics</category><guid>https://tinycomputers.io/posts/introducing-lattice-a-crystallization-based-programming-language.html</guid><pubDate>Tue, 10 Feb 2026 18:00:00 GMT</pubDate></item></channel></rss>