/ Specification
Playground Docs Performance GitHub
Chapter 7

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:

PhaseBinding KeywordPrefixBehavior
Fluidflux~Mutable — can be reassigned and modified
Crystalfix*Immutable — cannot be reassigned or modified
UnphasedletnoneDefault — phase inferred from context
Sublimatedn/an/aSpecial 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
Implementation Note 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.

Note The tree-walk interpreter (--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:

StrategyBehavior
"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:

FunctionDescription
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:

ModeBlocks
"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"