Phase System
The phase system is the defining feature of Lattice. Inspired by states of matter, every value exists in a phase that governs its mutability. Phases are tracked at runtime and enforced through explicit transition operations.
Phase Tags
Every value carries one of four phase tags:
| Phase | Binding Keyword | Prefix | Behavior |
|---|---|---|---|
| Fluid | flux | ~ | Mutable — can be reassigned and modified |
| Crystal | fix | * | Immutable — cannot be reassigned or modified |
| Unphased | let | none | Default — phase inferred from context |
| Sublimated | n/a | n/a | Special transition state from crystal |
Phase Transitions
Phase transitions are explicit operations that change a value's phase tag:
freeze()
freeze_expr ::= "freeze" "(" expression ")" [ where_clause ] [ except_clause ] where_clause ::= "where" closure except_clause ::= "except" "[" identifier { "," identifier } "]"
Transitions a fluid value to crystal phase. The value becomes immutable. When the argument is an identifier, the variable is updated in place.
The optional where clause attaches a crystallization contract — a closure
that validates the value before freezing. If the closure returns false, the
freeze fails with a runtime error.
The optional except clause lists struct fields that should remain fluid
even after the struct is frozen.
flux x = 42 freeze(x) // x is now crystal — assignment would fail // With crystallization contract flux config = Map::new() config.set("port", 8080) freeze(config) where |v| { v.has("port") } // With except clause (partial freeze) flux s = Widget { name: "btn", clicks: 0 } freeze(s) except [clicks] // s.name is frozen, s.clicks remains mutable
freeze() where and freeze() except are fully supported in
tree-walk mode (--tree-walk). In the default bytecode VM, these clauses
are parsed but not yet compiled — they are silently ignored.
thaw()
Transitions a crystal value back to fluid phase, making it mutable again.
fix x = 42 thaw(x) x = 99 // Now allowed
clone()
Creates a deep copy of a value. The cloned value is independent — modifying the clone does not affect the original.
anneal()
anneal_expr ::= "anneal" "(" expression ")" closure
Temporarily thaws a crystal value, applies a transformation, and re-freezes the result. The closure receives the thawed value and returns the new value to be frozen.
fix data = freeze([1, 2, 3]) fix updated = anneal(data) |v| { v.push(4) v } // updated = [1, 2, 3, 4] (crystal)
sublimate()
Performs a shallow freeze: the top-level structure is locked (the array cannot be pushed to or popped from, the map cannot have keys added or removed) but nested values remain mutable. This is useful when you want to fix the shape of a collection while still allowing element modification.
flux data = [1, 2, 3] sublimate(data) data[0] = 99 // OK — element mutation allowed // data.push(4) // ERROR — structural change blocked print(phase_of(data)) // "sublimated"
crystallize()
crystallize_expr ::= "crystallize" "(" expression ")" block
A scoped crystallization that executes a block with the value temporarily frozen. After the block completes, the original phase is restored.
--tree-walk) implements full scoped behavior
with automatic phase restore. The bytecode VM compiles crystallize as a
simple freeze and discards the block body entirely.
Forge Blocks
Forge blocks provide a controlled mutation pattern for building immutable values. Inside a forge block, you can freely use mutable operations. The result is typically frozen before being bound to an immutable variable.
fix result = forge { flux buf = [] for i in 0..10 { buf.push(i * i) } freeze(buf) }
Phase Constraints on Parameters
Function parameters can specify required phases using the ~ (fluid) and
* (crystal) type prefixes:
fn mutate(data: ~Array) { // data must be fluid — can be modified data.push(42) } fn read_only(data: *Array) -> Int { // data must be crystal — guaranteed immutable return data.len() }
Bonds
Bonds create phase dependencies between variables. When a target variable's phase changes, bonded dependent variables are automatically affected based on a strategy.
The bond() function takes a target variable name, a dependent variable name,
and a strategy string:
| Strategy | Behavior |
|---|---|
"mirror" | When target freezes, dependent is also frozen |
"inverse" | When target freezes, dependent is thawed (and vice versa) |
"gate" | Dependent must be frozen before target can freeze |
flux primary = 10 flux backup = 20 bond("primary", "backup", "mirror") freeze(primary) // backup is also frozen (mirror strategy)
Reactions
Reactions register callbacks that fire when a variable's phase changes (freeze, thaw, anneal, or sublimate). The callback receives two arguments: the phase name as a string and the current value.
flux counter = 0 react(counter, |phase, val| { print("phase changed to ${phase}: ${val}") }) freeze(counter) // prints: phase changed to crystal: 0
Use unreact() to remove all reactions from a variable.
Temporal Tracking
Lattice can record the history of phase transitions for tracked variables. Three built-in functions support temporal tracking:
| Function | Description |
|---|---|
track("var") | Enable history recording for a variable (by name) |
phases("var") | Return an array of {phase, value} maps representing mutation history |
rewind("var", n) | Get the value from n steps back in history |
Seed and Grow
seed() attaches a deferred crystallization contract to a variable. The
contract is not checked immediately — it is validated later when grow()
is called. If the contract fails, grow() returns an error.
flux config = Map::new() seed(config, |v| { v.has("host") && v.has("port") }) config.set("host", "localhost") config.set("port", 8080) grow("config") // validates and freezes
Pressure
pressurize() restricts structural mutations on arrays and maps without
fully freezing them. This provides fine-grained control over which operations are allowed:
| Mode | Blocks |
|---|---|
"no_grow" | Blocks push, insert (array cannot grow) |
"no_shrink" | Blocks pop, remove (array/map cannot shrink) |
"no_resize" | Blocks both grow and shrink |
"read_heavy" | Same as "no_resize" |
Use depressurize() to remove pressure and pressure_of() to
query the current pressure mode.
Alloy Types (Per-Field Phases)
Struct declarations can annotate individual fields with phase keywords, creating mixed-phase structs where each field has an independent phase:
struct Config { host: fix String, retries: flux Int } let c = Config { host: "localhost", retries: 3 } c.retries = 5 // OK — retries is flux // c.host = "x" // ERROR — host is fix
Querying Phase
The phase_of() built-in returns a string describing the current phase
of a value: "fluid", "crystal", "unphased",
or "sublimated".
flux x = 42 print(phase_of(x)) // "fluid" freeze(x) print(phase_of(x)) // "crystal"
Lattice