
Prism
Prism is a small, strict, impure functional language in the ML family whose type system tracks side effects. Effects are inferred, extensible rows that combine structurally as functions call one another, and they track observability: an effect handled inside a function vanishes from its type. The core is a call-by-push-value calculus in A-normal form that compiles to native code through LLVM, with memory managed by deterministic reference counting and fully-in-place update rather than a garbage collector.
This book has four parts:
- Language Specification defines the surface language: lexical structure, grammar, types, effects, and evaluation.
- Compiler documents the implementation: the pipeline, the core calculus, effect lowering, reference counting, the backends, and the verification harness.
- Standard Library is the API reference for the prelude and the standard modules, generated from their source by
prism docs. - Semantics is an unverified sketch of the Core terms and types.
Use the Playground to edit, run, and inspect Prism code via an interpreter run in the browser.
The Prism Language Specification
Prism is a strict, impure functional language in the ML family whose type system tracks side effects. This document is a Modest Proposal of the language as the prism compiler accepts it: its lexical structure, grammar, type system, and evaluation.
0. Goals
- Take deterministic simulation testing down to the language level: a deterministic core, typed effects, content-addressed identity, and replayable observations make every output an accountable artifact that can be mechanically rebuilt, moved, cached, diffed, audited, and explained using modern type-system methods.
- Lineage is the user-facing form of determinism: given an output, Prism should be able to precisely describe and check what code, packages, inputs, effects, handlers, and compiler artifacts produced it.
- The so-called “real world” meets Prism only at effect boundaries: every nondeterministic observation is named, typed, handled, and therefore available to record, replay, sandbox, or audit. The unfortunate existence of the physical world should be constrained by types.
- Obtain pure functional language nirvana by being completely inaccessible, utterly useless, completely divorced from the real world, and having zero users.
1. Introduction
A Prism program is a set of modules, each a file of declarations. The surface language elaborates to a strict, call-by-push-value core (Levy, 2004) in A-normal form (the companion Compiler document), compiles to native code through LLVM, and is managed by deterministic reference counting rather than a garbage collector.
Three things distinguish Prism from its ML and Haskell ancestors. It is strict, with laziness opt-in through thunks over a call-by-push-value core, so evaluation and effect order are left to right and explicit. Side effects are inferred as extensible effect rows (effects and handlers) that combine structurally across calls instead of through monads and track both observability and capability effects (capability effects and IO): an operation handled inside a function does not appear in its type, so internally effectful code is reused as pure, and a function that reads the outside world names the part it reads (Console, FileSystem, Random, Env) rather than a blanket IO. The same reference-count discipline both frees memory and performs fully-in-place (FBIP) update (declarations and programs), compiling record updates and derived setters to in-place writes on uniquely owned values (those that a reference count proves have no other live reference; see reference counting and FBIP reuse). Beyond these, the language provides isolated fibers through handlers, failure as ordinary typed control flow, record and replay of a program’s interaction with the world over the capability effects (record and replay), derived lenses and use-site optic paths for deeply nested structure traversal and update (optic paths), fusing stream combinators (streams), unboxed types (unboxed products), and checked usage contracts on closures (coeffects).
The deterministic core gives programs a stable identity: a definition is named by the hash of its canonical Core form, after alpha-normalizing binders so alpha-equivalent definitions share an identity and behavior-visible Core changes do not (content-addressed core).1 The same rule extends to execution: a suspended continuation is a kont envelope whose bundle digest names the code it may resume against (the kont envelope), and replayability supplies the byte-identical observable contract (suspend and resume).
This specification proceeds in dependency order: notation, lexical structure, grammar, types, then the constructs the grammar describes.
2. Notation
Grammar is given in the following EBNF. A terminal is a literal token written in double quotes; a nonterminal is a lower-case name. The character classes are the ASCII letters (letter), the two cases (lower, upper), the decimal digits (digit), any printable character (graphic), and any character other than ", \, or a newline (strchar). These are primitives, not grammar nonterminals.
x y juxtaposition: x followed by y
x | y alternative: x or y
[ x ] option: zero or one x
{ x } repetition: zero or more x
( x ) grouping
"fn" a terminal (literal token), in double quotes
varid a nonterminal, in lower case
x , ... a comma-separated list of one or more x
Identifiers in productions name the tokens defined in the lexical structure (varid, conid, qualid, integer, float, char, string) and the character classes defined just above. The layout algorithm inserts block delimiters that the grammar then treats as ordinary terminals.
3. Lexical Structure
Source text is UTF-8. Tokens are lexed by longest match, then the stream is rewritten by the layout algorithm. Whitespace and comments separate tokens and are otherwise insignificant except as layout boundaries.
varid -> (lower | "_") { idchar }
conid -> upper { idchar }
qualid -> conid "." ident { "." ident }
ident -> varid | conid
idchar -> letter | digit | "_"
integer -> digits [ "i64" | "u64" ]
float -> digits "." digits [ exponent ]
| digits exponent
exponent -> ( "e" | "E" ) [ "+" | "-" ] digits
-- A digit separator "_" is admissible only flanked by two digits, so leading,
-- trailing, doubled, and adjacent-to-"."/"e" separators are rejected. Exponent
-- notation always denotes a float; the exponent sign lives in the lexer and does
-- not collide with the unary-minus operator.
digits -> digit { [ "_" ] digit }
char -> "'" ( graphic | escape | "\\'" ) "'"
string -> "\"" { strchar | escape | interp } "\""
interp -> "{" expr "}"
escape -> "\\" ( "n" | "t" | "r" | "\\" | "{" | "}" | "\"" )
comment -> "--" { any character other than newline }
-- An interpolated string "a{e}b{e}c" is lexed into the pieces below, each
-- carrying its literal segment; the holes are re-lexed as expressions.
istart -> "\"" { strchar } "{"
imid -> "}" { strchar } "{"
iend -> "}" { strchar } "\""
-- Virtual layout tokens, synthesized by the offside pass; no source spelling.
vopen -> "v{" -- start of an indented block
vclose -> "v}" -- end of an indented block
vsep -> "v;" -- a newline at the same column (item / statement separator)
3.1 Identifiers
Prism distinguishes identifiers by initial case. A varid begins with a lower-case letter or underscore and names a variable, function, parameter, or record field. A conid begins with an upper-case letter and names a type, data constructor, type class, or effect. A qualid is a dotted path such as Data.Map or Map.insert; it is lexed as a single token so that a module path never collides with field access.
3.2 Keywords
The following are reserved and may not be used as identifiers.
fn | fip | fbip | pub | import |
as | type | newtype | opaque | alias |
effect | error | throw | try | catch |
transact | class | instance | pattern | deriving |
where | given | handle | with | handler |
mask | val | return | let | var |
borrow | in | for | do | if |
then | else | elif | match | of |
forall | true | false | while | loop |
break | continue | using | canonical | replayable |
without | alloc | probe | stable | logic |
requires | ensures |
A second set of words is contextual: each names a construct only in one grammatical position and stays a valid identifier everywhere else, so no program is broken by one becoming meaningful. They are never reserved.
| Word | Contextual position |
|---|---|
total, assume | a totality claim in the declaration-modifier position before fn |
never, once, many | a resumption grade in an operation declaration or handler-clause prefix |
view, make | the forward and constructor clauses of a pattern declaration |
resume | the continuation binder in a multishot handler clause |
partial | the handle ... with partial incomplete-handler form |
deprecated | an annotation line preceding a declaration |
test | a test declaration in the modifier position before fn |
The built-in type names Int, I64, U64, Bool, Unit, Float, Char, and String are also reserved. The prelude effect names Console, FileSystem, Random, and Env, the capability effects, are reserved as well.
3.3 Operators and Punctuation
The operator set is fixed; the language has no user-defined operators. Arithmetic and comparison use one plain spelling per operation across the numeric lanes; the older floating-point dot forms remain as deprecated aliases during the migration window. Exponentiation ^ is a single operator over both Int and Float (exponentiation).
| Class | Operators |
|---|---|
| Arithmetic | + - * / % ^ |
| Comparison | == /= < <= > >= and deprecated float ==. /=. <. <=. >. >=. |
| Logical | && || |
| Pipeline | |> >> << |
| Failure | ?? ?. ? |
| Arrows | -> <- => |
| Binding | = := : and compound += -= *= %= |
| Effect | ! |
| Brackets | ( ) { } [ ] |
| Other | , . .. | \ |
3.4 Literals
An integer is a run of decimal digits, optionally grouped by underscore separators (1_000_000) that are cosmetic and carry no value. A value that fits in a machine word is an immediate; a larger literal is an arbitrary-precision integer (bignum). The suffix i64 or u64 selects a fixed-width 64-bit lane that wraps on overflow. A float is an IEEE-754 double, written with a fractional part (1.5), an exponent (1e25, 1.5e3), or both; the exponent may be signed (1e-25, 1E25) and separators are admitted in its mantissa and exponent on the same rule. Exponent notation always denotes a Float. A separator is valid only between two digits, so a leading, trailing, doubled, or ./e-adjacent underscore is a lexical error. A char is a single Unicode scalar in single quotes. A string is double-quoted UTF-8.
There are no negative literals at the lexical level: a leading minus is the unary-minus operator (operator precedence), so -5, -5i64, and -1.5 are - applied to the literal. -5u64 is rejected because negation is undefined on the unsigned lane, and the exponent sign lives inside the float token, so it never collides with that operator.2 The formatter preserves a writer’s separator grouping verbatim.
The escape sequences \n, \t, \r, \\, \", \{, and \} are recognized in both character and string literals; a character literal additionally accepts \'.
3.5 String Interpolation
Within a string, an unescaped { expr } is an interpolation hole. The hole text is re-lexed at its source position and elaborated as an expression whose type-directed display is spliced into the string; a top-level string is spliced in raw, not quoted the way the Show method renders it. A hole runs to its matching }, balancing nested braces and string literals, so a hole may itself contain a string with braces. A literal brace outside a hole is written \{ or \}. An empty hole, an unterminated hole, and an unterminated string are each lexical errors. The catch arms of the error example under errors and failure use interpolation, as in "no such key: {k}".
3.6 Comments
A comment runs from -- to the end of the line; there is no block-comment form. Comments are trivia: they separate tokens and are otherwise insignificant, except that a -- inside a string or character literal is ordinary text rather than a comment, and the formatter preserves a comment and re-emits it attached to the token it preceded. A doc comment is the ordinary line form spelled -- |; the API doc generator harvests it, but the lexer treats it as any other comment.
3.7 Layout
Prism uses the offside rule: indentation, not explicit braces, delimits a block. A layout block opens after any of the keywords or symbols =, then, else, =>, of, with, handler, do, where, try, catch, transact, loop, and after fn (a while block opens at its do). A class, instance, or effect body opens the same way, but after the head rather than a keyword: the head ends the line and the members follow as its indented block. The first token after such an opener sets the block’s indentation column; a later line at that column starts a new item in the block, and a line indented less closes the block. Explicit { } override layout for expression blocks and may be used in place of an implicit one, as in the brace-delimited handler arms of the masking example. The three declaration bodies are the exception: they are layout-only, and a brace opening one is a parse error that names the layout rewrite.
3.8 Declarations
A program is a layout-delimited sequence of top-level declarations, each introduced by a reserved word that fixes its shape. The table names the introducer and where the construct is specified; the formal grammar is in Surface Grammar.
| Introducer | Declares | Specified in |
|---|---|---|
fn | a function; a body, an optional result type and effect row, given, where | declarations and programs |
let | a top-level constant binding | declarations and programs |
type | an algebraic data type or record | algebraic data types |
newtype | a single-constructor, zero-cost wrapper | algebraic data types |
alias | a type synonym or a row alias | types |
class | a type class and its method signatures | type classes |
instance | an instance of a class at a type | coherence and resolution |
canonical | the canonical instance at a head that has more than one | coherence and resolution |
effect | an effect and its operations | effects and handlers |
error | an error constructor | errors and failure |
pattern | a bidirectional pattern synonym | patterns |
stable | a serializable type’s frozen version history | stable blocks |
A head may carry modifiers. pub exports the declaration to importers; opaque on a type exports the name but not its constructors (modules); the fip and fbip prefixes on fn assert an allocation discipline and replayable bounds its effects; a deprecated "..." line marks the following declaration superseded (deprecation). The let and var binding forms (the latter mutable) also appear inside expression bodies, where only the local forms admit var; a top-level binding is let only.
A trailing where block attaches non-recursive local helpers below a function body, each binding scoped over the ones after it and over the body:
fn quadratic(a : Float, b : Float, c : Float) : (Float, Float) =
((0.0 - b + d) / two_a, (0.0 - b - d) / two_a)
where
d = sqrt(b * b - 4.0 * a * c)
two_a = 2.0 * a
3.9 Modules and Imports
A file is a module and a dotted path names one in the source tree: import Data.Map loads Data/Map.pr. The surface forms are below; visibility, opaque exports, and project layout are specified in modules.
| Form | Effect |
|---|---|
import M | brings M’s exports into scope under qualified names (M.name) |
import M (a, b) | additionally brings a and b into bare scope |
import M (..) | brings every export of M into bare scope |
import M as N | adds the alias N for qualified access (N.name) |
pub import M (x) | re-exports x through the importing module |
A qualid such as Map.insert is a single token (identifiers), so a qualified name never lexes as field projection. The pub modifier on any declaration makes it visible to importers; a declaration without it is private to its module.
Bringing a name into bare scope offers it; it does not commit to it. Two imports may offer the same short name, and an import never fails on that account: the clash is reported only where a bare use actually has to choose between them, and qualifying that one use resolves it. Modules gives the full order in which a bare name is looked up.
4. Surface Grammar
A program is a layout-delimited sequence of top-level declarations.
program -> { topdecl } -- separated by layout
topdecl -> [ deprecated ] [ "pub" | "opaque" ] item
deprecated -> "deprecated" string -- annotation line: marks the next item superseded ("deprecated" is contextual, not reserved)
item -> import | datatype | newtype | synonym | rowalias
| classdecl | instancedecl | canonicaldecl | effectdecl
| errordecl | patterndecl | stabledecl | constdecl | fundecl
import -> "import" modpath [ "as" conid ] [ "(" name , ... ")" ]
| "pub" "import" modpath [ "(" name , ... ")" ]
modpath -> conid { "." conid }
name -> varid | conid
datatype -> "type" conid [ kindedvars ] "=" ctor { "|" ctor } [ deriving ]
newtype -> "newtype" conid [ tyvars ] "=" ctor [ deriving ]
synonym -> "alias" conid [ tyvars ] "=" type
rowalias -> "alias" conid "=" "{" [ label , ... ] "}"
tyvars -> "(" varid , ... ")"
kindedvars -> "(" tyvar , ... ")" -- a data-type parameter may carry a kind
tyvar -> varid [ ":" kind ]
kind -> "Type" | "Row" | "Nat" -- "Row" ranges over an effect row, "Nat" over a dimension
ctor -> conid [ "(" type , ... ")" ]
| conid "{" field , ... "}"
field -> varid ":" type
deriving -> "deriving" "(" conid , ... ")"
constdecl -> "let" varid [ ":" type ] "=" expr
fundecl -> [ "logic" ] [ "test" ] [ [ "assume" ] "total" ] [ "replayable" ] [ "fip" | "fbip" ]
"fn" varid "(" [ param , ... ] ")"
[ retann ] [ given ] { requires } { ensures } "=" expr [ wheres ]
-- a usage row spelling exactly "@ noalloc" at the root of the
-- return annotation is the declaration's allocation certificate;
-- "logic fn" is a proof-level (SMT) declaration, never a runtime fn;
-- "test" and "total"/"assume" are contextual
-- declaration modifiers, not reserved words, so all three stay
-- usable as ordinary identifiers everywhere else
param -> [ "borrow" ] pattern [ ":" type ] [ ":=" expr ]
-- a bare varid (or "_") is the ordinary named parameter and binds
-- without testing; any other pattern is matched around the body and
-- must be irrefutable, so it has no name to pass by keyword
retann -> ":" type [ decleff ] -- result first, e.g. `: Int ! {State}`
decleff -> "!" "{" [ label , ... ] "}" -- the declaration's effect row (closed)
| "!" -- an explicit empty effect row
given -> "given" constraint , ...
requires -> "requires" expr -- an SMT precondition clause
ensures -> "ensures" "|" varid "|" expr -- an SMT postcondition over the result binder
constraint -> conid "(" type ")"
wheres -> "where" "{" { varid "=" expr } "}"
-- Class, instance, and effect bodies are layout blocks (no `where`, no braces):
-- the head ends the line and the members follow on indented lines. A brace here
-- is a parse error. A marker class or its instance has no members, so the body is
-- absent and the declaration is its bare head.
classdecl -> "class" conid "(" varid ")" [ given ] [ vopen sig { vsep sig } vclose ]
instancedecl -> "instance" varid ":" conid "(" type ")" [ given ]
[ vopen fundecl { vsep fundecl } vclose ]
canonicaldecl -> "canonical" conid "(" type ")" "=" varid
sig -> varid ":" type
effectdecl -> "effect" conid [ tyvars ] vopen effop { vsep effop } vclose
effop -> grade varid "(" [ type , ... ] ")" ":" type
grade -> "ctl" | "fun" | "final" "ctl" -- resumption multiplicity: many | once | never
errordecl -> "error" conid [ "(" type , ... ")" ]
patterndecl -> "pattern" conid "(" [ varid , ... ] ")" "for" conid "="
"view" expr [ "make" expr ]
-- A `stable` block declares a serializable type's frozen version history:
-- the rungs, then the hand-written converters for the
-- type mutations the compiler cannot generate. Real braces, so entries are
-- comma-separated. "frozen", "upgrade", "downgrade", and "drop_loss" are
-- contextual, recognized positionally inside the block rather than reserved.
stabledecl -> "stable" conid "{" stableitem , ... "}"
stableitem -> rung | converter
rung -> conid "=" "{" [ ".." conid "," ] rungfield , ... "}" [ "frozen" string ]
rungfield -> varid ":" type [ "=" expr ] -- an additive field carries a default
converter -> ( "upgrade" | "downgrade" ) conid "->" conid "="
"{" ".." expr { "," varid "=" expr } "}" [ "drop_loss" "(" varid , ... ")" ]
Type syntax. A function type carries an optional effect row on its codomain (effects and handlers); the row binds to a function type only.
type -> "forall" varid { varid } "." type
| arrow [ row ] -- row applies to a function type
arrow -> "(" [ type , ... ] ")" "->" arrow -- n-ary domain
| utype "->" arrow -- single domain
| utype
utype -> atype [ "@" usagerow ] -- usage (coeffect) row on an atomic type
usagerow -> varid -- single-fact sugar: T @ noalloc
| "{" varid , ... "}" -- fact set, canonical order alphabetical
atype -> scalar
| "[" type "]" -- List(type)
| conid [ "(" typearg , ... ")" ] -- type constructor
| varid [ "(" typearg , ... ")" ] -- variable, possibly applied
| "(" type ")"
| "(" type "," type , ... ")" -- tuple
typearg -> type
| rowlit -- argument for a Row-kinded parameter
| digits -- dimension literal for a Nat-kinded parameter, e.g. Vec(Int, 3)
rowlit -> "{" [ label , ... ] [ "|" varid ] "}" -- effect-row literal, e.g. Cmd(Int, {IO})
scalar -> "Int" | "I64" | "U64" | "Bool" | "Unit" | "Float" | "Char" | "String"
row -> "!" "{" [ label , ... ] [ "|" varid ] "}"
| "!" -- empty row, effectful position
label -> conid [ "(" type , ... ")" ]
Expressions, patterns, and the handler block of handle/try (used in effects and handlers).
expr -> "if" expr "then" expr { "elif" expr "then" expr } "else" expr
| "let" ( varid | letpat ) "=" expr expr -- bind, then continue
| "var" varid ":=" expr expr -- local mutable, then continue
| "\\" "(" [ param , ... ] ")" "->" expr -- lambda
| "match" expr "of" { arm }
| "handle" expr "with" handler
| "with" [ varid "<-" ] "handler" handler expr -- scoped handler (named if `x <-`), then continue
| "with" [ varid "<-" ] appexpr expr -- scoped resource from a call, then continue
| "throw" conid [ "(" expr , ... ")" ]
| "try" expr "catch" { catcharm }
| "transact" expr "else" expr
| "for" gen { "," qual } "do" expr
| "while" expr "do" expr -- while loop
| "loop" expr -- unconditional loop
| "probe" string "do" expr -- named instrumentation; body runs only when enabled
| "return" expr -- early return
| varid ":=" expr -- assignment
| varid ( "+=" | "-=" | "*=" | "%=" ) expr -- compound assignment
| opexpr
opexpr -> opexpr binop opexpr -- binop and binding per 4.1
| negexpr
negexpr -> "-" negexpr -- unary minus, "tight prefix":
-- looser than application and
-- projection, tighter than every
-- binop, so `-f(x)` is `-(f(x))`
-- and `-x * y` is `(-x) * y`
| appexpr
appexpr -> appexpr "(" [ arg , ... ] [ "using" iname , ... ] ")"
-- application; `using` selects instances
arg -> [ varid ":=" ] expr -- an argument may be passed by name
| appexpr "[" expr "]" -- indexed read a[i]
| appexpr "." varid -- field access
| appexpr "." varid "(" [ expr , ... ] ")" -- method call (UFCS)
| appexpr "?." varid -- optional chaining
| appexpr "?" -- try marker
| atom
atom -> integer | float | char | string | "true" | "false" | "(" ")"
| "break" | "continue" -- loop control
| "?" varid -- named typed hole
| varid | conid | qualid
| "(" expr ")" | "(" expr ":" type ")"
| "(" expr "," expr , ... ")" -- tuple
| "[" [ expr , ... ] "]" -- list
| "[" expr ".." expr "]" -- range
| "[" expr "for" gen { "," qual } "]" -- comprehension
| conid "{" fieldval , ... "}" -- record
| conid "{" ".." expr "," fieldval , ... "}" -- record update
| "{" expr "|" path "=" expr , ... "}" -- path update
| "mask" "<" conid ">" "(" expr ")"
arm -> pattern [ "if" expr ] "=>" expr
gen -> varid "in" expr
qual -> "if" expr | "let" varid "=" expr
iname -> varid | qualid -- an instance name
fieldval -> varid "=" expr -- a record field binding
path -> pathseg { "." pathseg } -- a nested field path
pathseg -> varid | "each" | "?" conid -- field step, bulk map over a collection, or case-narrowing step
| pathseg "[" expr "]" -- indexed step
| "(" path "where" expr ")" -- filtered traversal
binop -> "??" | "|>" | ">>" | "<<" | "||" | "&&" -- binding given in 4.1
| "==" | "/=" | "<" | "<=" | ">" | ">="
| "+" | "-" | "*" | "/" | "%" | "^"
| "==." | "/=." | "<." | "<=." | ">." | ">=."
| "+." | "-." | "*." | "/."
pattern -> patalt { "|" patalt } -- alternation: any alternative matches
patalt -> conid [ "(" pattern , ... ")" ] -- constructor
| conid "{" fieldpat , ... "}" -- record, ".." allowed
| varid -- variable binding
| "_" -- wildcard
| integer | float | char | "true" | "false"
| "-" ( integer | float ) -- negative literal
| "[" [ pattern , ... ] "]" -- list
| "(" pattern "," pattern , ... ")" -- tuple
fieldpat -> varid "=" pattern | ".."
letpat -> conid [ "(" pattern , ... ")" ] -- a `let` destructures with a
| "(" pattern "," pattern , ... ")" -- constructor or tuple pattern only,
-- and admits no alternation
handler -> "{" hclause , ... "}" | hclause { hclause } -- braces or layout
hclause -> "return" varid "=>" expr -- result transform
| varid "(" [ varid , ... ] ")" "resume" varid "=>" expr -- multishot op: continuation after `resume`
| "fun" varid "(" [ varid , ... ] ")" "=>" expr -- tail-resumptive sugar
| "final" "ctl" varid "(" [ varid , ... ] ")" "=>" expr -- non-resumable
| "val" varid "=" expr -- install-time constant
catcharm -> conid [ "(" varid , ... ")" ] "=>" expr
4.1 Operator Precedence
The table gives the binding of each operator, loosest to tightest. Levels 1 to 9 are the binop operators of the grammar; level 10 is the prefix unary minus; level 11 is application, field access, and the postfix failure operators, which bind tighter than every binop. Unary minus is a tight prefix: it binds looser than application and projection but tighter than every binary operator, so -f(x) is -(f(x)), -x * y is (-x) * y, -x ^ y is (-x) ^ y, and a leading f -x is the binary f - x (there is no juxtaposition application; write f(-x)).
| Level | Operators | Associativity |
|---|---|---|
| 1 | ?? | right |
| 2 | |> | left |
| 3 | >> << | left |
| 4 | || | left |
| 5 | && | left |
| 6 | == /= < <= > >= (and float forms) | none |
| 7 | + - (and float forms) | left |
| 8 | * / %, and float *. /. | left |
| 9 | ^ | right |
| 10 | prefix - (unary minus) | prefix |
| 11 | f(...) a[i] .field ?.field ? | left |
4.2 Syntax Stability
The surface has grown a broad vocabulary: handlers and named handlers, try/catch, throw, failure fallback, optional chaining, comprehensions, transactions, imperative loops, pattern synonyms, stable blocks, coeffects, allocation annotations, record paths, and source probes. Most are pure desugar onto the constructs described elsewhere in this document, but taken together they enlarge the language a reader must hold in mind, so new syntax is admitted under a single rule:
Add syntax only when it exposes a semantic invariant, eliminates recurring structural boilerplate, or materially improves diagnostics.
By that test effect rows, handlers, try/catch, var and the loop forms, and record update earn their place; so do pattern alternation and patterns in parameter position, which remove arms and wrapper matches a reader would otherwise write out by hand and which are expanded away before the checker, adding nothing to the language below the surface. Further aliases for forms that already exist do not. The stable surface freezes at roughly this point: the approachable ML-like character (no user-defined operators, no macros, no do-notation, no drift toward a more symbolic calculus) is a property to preserve, not a stage to move past. The design rule is to deepen what the existing syntax means rather than widen the syntax itself.
5. Types and Kinds
Prism infers types by the bidirectional, higher-rank inference algorithm of Dunfield & Krishnaswami (2013). An unannotated declaration infers its principal type; an annotated one is checked against the annotation. Annotations are required for rank-N polymorphism, since a nested forall cannot be inferred.
Quantification is predicative: a type-constructor argument and an inferred type variable range over monomorphic types, so a forall may not be written directly as a type argument (List(forall a. (a) -> a) is rejected as impredicative). Higher-rank types are allowed wherever they are not a type argument, namely as a function parameter, a function result, and a declared data field; a polymorphic value can be carried through a generic container by wrapping it in a data type with a polymorphic field.
5.1 Three Posets
A poset (partially ordered set) is a set equipped with a reflexive, antisymmetric, and transitive order. A lattice is a poset in which every pair has both a least upper bound (a join) and a greatest lower bound (a meet). A Prism signature carries three posets: what a computation may do (the effect row, after !), how its values may be used (the usage row, after @), and how a handler may consume a continuation (the operation grade). Effect rows and operation grades are lattices: effect rows have union and intersection, while grades form a total chain. Coeffect axes are not lattices in general because some conflicting facts have no meet.
Effect rows: joins always exist. The carrier is a set of effect names, the order is inclusion, the join is union:
Sequencing takes the join; handling subtracts back toward the pure bottom:
effect Ask
once ask() : Int
fn f() : Unit ! {IO} = println("f")
fn g() : Int ! {Ask} = ask()
fn foo() : Int ! {IO, Ask} =
f()
g()
fn bar() : Int ! {IO} =
handle foo() with
once ask() => 7
fn main() = println(bar())
foo sequences f and g, so its row is their join; bar handles Ask, so its row steps back down to {IO}.
Coeffect axes: meets sometimes missing. Each axis (coeffects) has silence at the top, the mode of all unannotated code. An exclusive axis has no meet below its points; the fip axis is a product of chains, so its meet exists:
Descending is a strengthening someone must prove; ascending, forgetting a claim, is always free; and holding two claims at once is exactly having a point below both:
fn f() : Int @ noalloc = 1 -- a proven claim: f's call tree allocates nothing
fn g() : Int = f() -- ok: forgetting the claim moves up, always free
-- fn foo() : Int @ noalloc = g()
-- rejected, descent needs proof: in `foo`, call to `g` may
-- allocate (`g` has no zero-allocation certificate)
-- h : ((Int) -> Int) @ {linear, bounded_stack}
-- a legal row shape: the meet exists and the reserved claims compose
-- h : ((Int) -> Int) @ {once, many}
-- never parses: usage facts `once` and `many` contradict each other (same axis)
fn main() = println(g())
Operation grades: a total chain. Continuation use is a quantity, so its lattice is a total order:
The whole discipline is one comparison at one boundary: a clause’s grade at most its operation’s declared grade (effects and handlers):
effect E
never quit() : Int -- never: a clause must drop the continuation
once ask() : Int -- once: a clause resumes exactly once, in tail
coin() : Bool -- many: a clause may capture k, resume freely (default)
fn foo() : Int ! {E} =
let x = ask()
if coin() then x else quit()
fn run() : Int =
handle foo() with
never quit() => 0 -- never <= never ok
once ask() => 42 -- once <= once ok
coin() resume k => k(true) -- once <= many ok: below the grade is allowed
-- ask() resume k => k(1) + k(2) would be rejected: the clause for `ask`
-- exceeds its declared grade `once`, resuming more than once
fn main() = println(run())
One signature exercises all three at once:
fn spawn(f : (() -> a ! e) @ {once, portable}) : Fiber(a) ! {Async(a), e}
spawn takes a portable thunk f that it may call at most once, starts it as a fiber producing an a, and may perform both the thunk’s effects e and the asynchronous effect Async(a).
- Row, joined: whatever
fperforms is unioned into the caller’s row alongsideAsync; the handler that later runs the fiber subtractsAsyncback out. - Axes, met:
@ {once, portable}is one point on each of two axes, their meet in the product:spawnpromises to call the thunk at most once and may carry it to another fiber. - Grade, bounded: the
Asyncoperations aremany, the top of the chain, so a scheduler may hold the continuation and resume it later;oncewould have pinned every handler to immediate single resumption.
The design is the three properties side by side. Effects always have joins: doing more must always be expressible. Coeffects sometimes lack meets: some promises genuinely contradict. Continuation use is a total order: it is a quantity, not a set.
An effect is a coeffect on its own continuation: operation grades constrain how the handler may use the captured continuation.3
5.2 Types
The scalar types are Int (arbitrary precision), I64, U64, Float, Bool, Char, String, and Unit. A type constructor applied to arguments is written Con(t, ...); the list type has the sugar [t] for List(t). A tuple type is (t, ...). A function type is (t, ...) -> u, optionally carrying an effect row on u. A universally quantified type is forall a. t. Type variables are varids.
5.3 Kinds
A type has kind * (a type of values) or * -> * (a type constructor awaiting one argument), and so on; List has kind * -> *, since List(Int) is a type only once Int is supplied. A class parameter may range over a constructor of kind * -> *, applied as f(a) in method signatures; see type classes. Each constructor’s parameter kinds form an arrow k1 -> ... -> *, and an applied head is checked argument by argument against that arrow: too many arguments, or an argument whose kind does not match the parameter’s, is a kind mismatch reported at the annotation. There is no separate global kind-checking phase; the remaining well-kindedness obligations are discharged during unification, which requires a constructor and its arguments to agree in arity.
Besides * and its arrows there is one further kind, Row, inhabited by effect rows rather than types. A type parameter annotated : Row ranges over rows, so a data type can carry an effect row as a parameter and thereby store an effectful computation in a field: in type Cmd(a, e : Row) a field may name e as ! {e} (or in a tail, ! {IO | e}), the constructor quantifies e with a row-level forall, and the applied head Cmd(a, e) carries the row in that position. A Row-kinded argument is an effect row, written either as a row variable (Cmd(a, e)) or a { .. } row literal (Cmd(Int, {IO})); supplying a type where a row is expected, or a row where a type is expected, is a kind mismatch reported at the annotation. An unannotated parameter still defaults to *, so Row is opt-in and existing types are unchanged. This is the type-system support for storing an effect-polymorphic reified handler, such as the concurrency scheduler of effects and handlers.
The third non-* kind is Nat, inhabited by type-level natural numbers, the dimensions of a shape-indexed type. A type parameter annotated : Nat ranges over dimensions, so in type Vec(a, n : Nat) the length n is a compile-time index rather than a stored field; an argument in that position is either a bare natural literal (Vec(Int, 3)) or a Nat-kinded variable (Vec(a, n)). As with Row, supplying a type where a dimension is expected, or a dimension literal where a type is expected, is a kind mismatch reported at the annotation.
Dimensions unify by equality only: two literals unify when they are equal (3 with 3), a variable unifies with whatever dimension it meets, and a clash is a compile error naming both lengths (zipping a Vec(Int, 3) with a Vec(Int, 4) reports expected length 3, but got length 4). There is deliberately no successor structure and no arithmetic on dimensions: n + m and n + 1 in a dimension position are declined at the parser with a pointed message, and this is a decision, not a gap.
The consequence is stated honestly rather than worked around: an operation whose correctness needs an arithmetic relation between dimensions cannot be given a length-precise type. A length-changing cons of type (a, Vec(a, n)) -> Vec(a, n + 1) is therefore not expressible, and a head over Vec(a, n) cannot statically exclude the empty vector (which would require n to be a successor m + 1); such a head accepts any length and faults, or ranges over Fail, on the empty case. Equality-only dimension unification is exactly the reach that shape indexing needs (fixed-length containers, matching-length zips) without importing a dependent-arithmetic decision procedure into the frozen core.
Dimensions are erased before the Core IR and never reach code generation, so a Nat index is a purely static fact: it constrains what type-checks but is invisible to every backend and to the determinism contract, exactly like a phantom parameter. An unannotated parameter still defaults to *, so Nat is opt-in.
5.4 Inference, Generalization, and Defaulting
A row is built from labels, the effect names of effects and handlers (a parametric effect’s label carries type arguments). It is closed when it ends in a fixed set of labels and open when it ends in a row variable (! {L | r}), which stands for further labels the caller may add. An unannotated binding is generalized over its free type and row variables not fixed by the surrounding scope. A bare type variable written in a top-level function’s signature is an implicit forall: it is universally quantified and rigid, so the body is checked to hold for every instantiation and may neither narrow it to a concrete type nor equate two distinct signature variables (a body that does is a type error), and the declaration exports exactly the polymorphic scheme it wrote. Two cases default rather than generalize, both resolved in one pass at generalization. A numeric operand of an arithmetic or comparison operator left otherwise unconstrained defaults to Int; because the default is deferred to that pass rather than applied at the operator, a later use that fixes the operand to a fixed-width lane (I64/U64) takes precedence, so x + y followed by an i64 use of x is fixed-width, not Int. An open row left unconstrained at a monomorphic declaration (one with no remaining free row variable) defaults to empty (pure); an effect-polymorphic declaration keeps its row variable, as traverse does in the prelude (the standard prelude).
5.5 Subsumption and Row Equivalence
Checking a value against an expected type uses subsumption, not equality. A more polymorphic type is accepted where a less polymorphic one is expected: a forall on the expected side introduces a rigid variable the value must satisfy for all instances, and a forall on the value side is instantiated to meet the expectation. Function subtyping is contravariant in the arguments and covariant in the result, so a function accepting more and returning less may stand in for one accepting less and returning more.
Effect rows are checked by unification over scoped labels, not by covariant widening. Two rows are compared up to reordering: ! {A, B} and ! {B, A} are the same row, because unification hoists a demanded label to the head of the other row before matching the tails. An open row ! {A | r} unifies with any row that provides A by binding r to the remainder; for instance ! {A | r} unifies with ! {A, B} by binding r to {B}. This is how a caller’s row absorbs a callee’s. A unification that would make a row contain itself is rejected, so recursive effect rows do not arise.
At a function arrow the value’s effect row is made equal to the expected one by this same unification, so a narrower row fits a wider context only by solving a row variable, never by silent widening. A pure function still fits an effectful context, because its own latent row is a quantified variable (effect polymorphism) that unification solves to the demanded effects. Where a function carries an explicit return row, that annotation is the row its body is unified against: a body that performs an effect the annotation omits does not unify and is rejected with a diagnostic naming the effect the annotation must declare, and the annotation’s row variables are rigid, so an annotation may not silently narrow to fewer effects than the body performs.
5.6 Fixed-Width Integers
Int is arbitrary precision. I64 and U64 are the signed two’s-complement and unsigned 64-bit lanes; they wrap on overflow rather than promoting to a bignum. Their arithmetic and comparisons are the plain operators through the numerical tower, one spelling across every lane. The bit-level operations have no operator spelling and remain named builtins, each taking two operands of the lane type.
| Family | Operations (and the u64_* counterparts) |
|---|---|
| Bitwise | i64_and i64_or i64_xor |
| Shift | i64_shl i64_shr |
| Comparison | i64_cmp |
and, or, and xor share a single bit pattern across both lanes; i64_shr is an arithmetic (sign-extending) shift while u64_shr is logical; a shift count is taken modulo 64. to_i64/to_u64 and int_of_i64/int_of_u64 convert between Int and the fixed-width lanes.
5.7 Integer Arithmetic and Division
The arithmetic operators +, -, *, /, and % spell integer arithmetic here through the numerical tower’s Int, I64, and U64 instances; ^ is exponentiation. On Int they are arbitrary precision: a sum, product, or difference is exact and never overflows, promoting a machine-word result to a bignum on demand.4 This section states the two facts that arithmetic on Int cannot state by its type alone: how division rounds, and what division by zero does. Both are identical on the interpreter and native backends, a corollary of the determinism contract and pinned by the parity corpus.
Division truncates toward zero and remainder takes the sign of the dividend. That is, / discards the fractional part by rounding toward zero rather than toward negative infinity, and a % b has the sign of a (or is zero), so the identity a == (a / b) * b + (a % b) holds for every non-zero b.5 This is truncated division, the semantics of C99, Rust, and the hardware division instruction both backends emit.
-- Integer `/` truncates toward zero; `%` takes the dividend's sign.
-- `Int` is arbitrary precision, so the same laws hold past the machine word.
-- `^` binds tighter than unary minus, and a negative exponent is
-- `1 / base ^ (-exp)` under the same truncating division.
fn main() =
println(7 / 2)
println((0 - 7) / 2)
println(7 / (0 - 2))
println((0 - 7) / (0 - 2))
println(7 % 3)
println((0 - 7) % 3)
println(7 % (0 - 3))
println((0 - 7) % (0 - 3))
println(1000000000000000000000 / 7)
println((0 - 1000000000000000000000) % 7)
println(-2 ^ 2)
println((-2) ^ 2)
println(2 ^ -1)
println(1 ^ -5)
println((-1) ^ -5)
Floored division, where / rounds toward negative infinity and % (the Euclidean-adjacent modulus) takes the sign of the divisor, was considered and declined. Two reasons decide it. The fixed-width lanes are the constraint: / and % on I64 and U64 are the machine’s truncating division, and an Int operator whose meaning diverged from the lane it shares a spelling with would split the integer family into two rounding rules a reader must track by type. And the determinism contract wants one rule across every lane and both backends rather than a surface convenience that the hardware does not compute; a caller who wants a floored or Euclidean modulus writes it once over these primitives (((a % b) + b) % b for a non-negative residue) rather than having the language pick a second, silently different %.
Division or remainder by zero is the one partial case of integer arithmetic. It is a runtime fault: the program halts immediately with exit status 1 and exactly fatal: division by zero on standard error, byte-identical on the interpreter and the native backend, on both Int and the fixed-width lanes. It is not a value, and unlike the recoverable fail() of errors and failure it is not routed through an effect and cannot be caught; it aborts the run the way an unrecoverable error(code) does. Every other integer operation is total.
The fixed-width lanes wrap rather than fault or promote (fixed-width integers): +, -, and * on I64 and U64 are two’s-complement modular arithmetic, so adding one to I64_MAX wraps to I64_MIN and adding one to U64_MAX yields 0.6 Unary minus follows the same wrap on the fixed-width lane, so -x on I64 is the two’s-complement negation and -I64_MIN is I64_MIN. Int, being a bignum, has no such edge: negation and division there are always exact.7
-- Fixed-width I64/U64 arithmetic wraps two's-complement: no promotion to a
-- bignum, and no fault on the signed-minimum edge (`I64_MIN / -1` wraps).
fn main() =
let maxi = 9223372036854775807i64
let mini = 0i64 - maxi
let mn = mini - 1i64
let neg1 = 0i64 - 1i64
println(show(maxi + 1i64))
println(show(maxi * 2i64))
println(show(mn / neg1))
println(show(mn % neg1))
println(show(18446744073709551615u64 + 1u64))
println(show((0i64 - 7i64) % 3i64))
5.7.1 Safe Arithmetic Families
The wrapping and faulting defaults above are the primitives; a program that wants overflow to be visible rather than silent reaches for the safe families in the Data.Checked library, which layer four disciplines over those primitives through one class, Checked(a).
| Family | Methods | Result | Behavior |
|---|---|---|---|
checked_* | add, sub, mul, neg, div, mod | Option(a) | None exactly when the operation overflows the lane or divides by zero. |
saturating_* | add, sub, mul | a | Clamps to the bound the overflow crossed. |
wrapping_* | add, sub, mul, neg | a | Explicit names for the two’s-complement wrap the raw operators already perform (fixed-width integers), so a caller can spell the intent rather than rely on the default. |
overflowing_* | add, sub, mul | (a, Bool) | The wrapped result paired with a Bool true precisely when the operation overflowed. |
Instances cover I64, U64, and Int; the checked narrowings int_to_i64 and int_to_u64 sit beside the class as free functions returning Option, the partial inverses of the total widenings int_of_i64/int_of_u64.
Checked sits beside the arithmetic classes rather than inheriting from them: it carries no superclass and no raw operators, so it stays meaningful for any integer lane independently of what algebraic structure that lane also has. The connection runs the other way, as a law. The wrapping_* methods agree exactly, value for value, with the lane’s raw arithmetic, wrapping_add/wrapping_sub/wrapping_mul with the two’s-complement +/-/* and wrapping_neg with unary negation.8 Because the agreement is with the raw operators, it is stable under any later refactor that gives those operators a class of their own: the wrapping_* methods and the lane’s ring operations remain the same function by construction.
The families are not independent definitions that happen to line up; each fixed-width operation is computed once in the exact Int lane and then narrowed three ways, so the laws hold by construction and are pinned on both backends. For a lane bounded by [lo, hi], checked_op(x, y) is Some(wrapping_op(x, y)) when the exact result lies in range and None otherwise; overflowing_op(x, y) is (wrapping_op(x, y), flag) with flag true iff checked_op(x, y) is None; and saturating_op(x, y) is that same wrapped value when it is in range, and otherwise the crossed bound, hi on overflow above (I64 max or U64 max) and lo below (I64 min or 0).9 Division and remainder inside a checked_* follow the truncating rule of integer arithmetic. The Int instance is the degenerate case that keeps the class total rather than vacuous: unbounded, so wrapping_* and saturating_* are the identity, overflowing_* always flags false, and only a zero divisor turns a checked_* into None.
-- The safe-arithmetic families of `Data.Checked` over the lane boundaries. The
-- raw wrap/truncate/fault semantics they build on are pinned by fixed-width
-- integers and integer arithmetic; here every printed line is a law that must
-- hold identically on both backends. `checked` is `None` exactly on overflow or
-- a zero divisor, `saturating` clamps to the crossed bound, `wrapping` matches
-- the raw operator, and `overflowing` pairs the wrapped result with that flag.
import Data.Checked (..)
fn main() =
let i_max = to_i64(2 ^ 63 - 1)
let i_min = to_i64(0 - 2 ^ 63)
let u_max = to_u64(2 ^ 64 - 1)
let one = to_i64(1)
-- I64: the three families agree with each other at the upper edge.
println(show(checked_add(i_max, one) == None))
println(show(saturating_add(i_max, one) == i_max))
println(show(snd(overflowing_add(i_max, one))))
println(show(fst(overflowing_add(i_max, one)) == i_min))
println(show(checked_add(i_max, to_i64(0)) == Some(i_max)))
-- I64: lower edge, negation, and the two division edges.
println(show(saturating_sub(i_min, one) == i_min))
println(show(checked_neg(i_min) == None))
println(show(wrapping_neg(i_min) == i_min))
println(show(checked_div(i_min, 0i64 - one) == None))
println(show(checked_div(to_i64(7), to_i64(2)) == Some(to_i64(3))))
println(show(checked_mod(to_i64(0 - 7), to_i64(2)) == Some(to_i64(0 - 1))))
-- U64: unsigned underflow clamps to zero and wraps to the max.
println(show(checked_sub(to_u64(0), to_u64(1)) == None))
println(show(saturating_sub(to_u64(0), to_u64(1)) == to_u64(0)))
println(show(fst(overflowing_sub(to_u64(0), to_u64(1))) == u_max))
println(show(checked_add(u_max, to_u64(1)) == None))
println(show(checked_neg(to_u64(0)) == Some(to_u64(0))))
-- Int: unbounded, so checked is total except division, overflow never fires.
println(show(checked_add(2 ^ 100, 2 ^ 100) == Some(2 ^ 100 + 2 ^ 100)))
println(show(saturating_mul(2 ^ 100, 2) == 2 ^ 100 * 2))
println(show(not(snd(overflowing_add(2 ^ 100, 1)))))
println(show(checked_div(5, 0) == None))
-- Checked Int narrowing agrees with the lane bounds.
println(show(int_to_i64(2 ^ 100) == None))
println(show(int_to_u64(0 - 1) == None))
println(show(int_to_i64(42) == Some(to_i64(42))))
5.8 Floating-Point Arithmetic
Float is an IEEE-754 double. Its arithmetic and comparison operators are the plain +, -, *, /, %, ==, /=, <, <=, >, and >= through the numerical tower. There is no implicit coercion between Int and Float, so a mixed expression is a type error resolved by an explicit to_float (exponentiation). Floating-point arithmetic is where a language most often becomes tier-dependent, because a fused multiply-add, an extended-precision register, or a differently rounded library call changes a low bit. Prism forbids that: every float operation follows one rounding rule and one set of special-value rules, and the interpreter and both native backends agree bit for bit, pinned by the parity corpus and, for the printer, by a dedicated formatter oracle.
The rounding contract is round to nearest, ties to even, the IEEE-754 default, applied to every arithmetic operation with no fused or wider-than-double intermediate. This is the single rule the language commits to, and it is why 0.1 + 0.2 is 0.30000000000000004 and 1.0 / 3.0 is 0.3333333333333333 identically everywhere: the result is the correctly rounded double, not an artifact of an evaluation order or a backend.
Float division never faults. Where integer / by zero aborts, /. by zero is an ordinary IEEE result: x / 0.0 is inf or -inf according to the sign of the numerator and of the zero, and 0.0 / 0.0 is nan. A nan then propagates through every arithmetic operation it touches, so nan + 1.0 and nan * 0.0 are both nan; there is no arithmetic that turns a nan back into a finite number.10 Because no float operation faults, a floating-point pipeline never introduces a failure edge into a function’s effect row the way integer division by zero conceptually could.
Signed zero is observable. 0.0 and -0.0 are distinct values that compare equal (0.0 == -0.0 is true) yet are distinguished by any operation that reads the sign bit: 1.0 / 0.0 is inf while dividing by negative zero is -inf.11 Comparisons follow IEEE unordered semantics for nan: nan is equal to nothing including itself, so nan == nan is false and nan /= nan is true, and every ordering against nan (nan < x, nan > x) is false. The program below exercises each of these on both backends.
-- IEEE 754 float arithmetic: division yields inf/-inf/nan with no fault, NaN
-- propagates, signed zero is preserved, and comparisons follow the standard.
fn main() =
let one = 1.0
let z = 0.0
let negz = (0.0 - 1.0) * 0.0
let inf = one / z
let ninf = (0.0 - 1.0) / z
let nan = z / z
println(0.1 + 0.2)
println(1.0 / 3.0)
println(inf)
println(ninf)
println(nan)
println(negz)
println(one / negz)
println(nan + one)
println(nan * z)
println(if nan == nan then "nan==nan is true" else "nan==nan is false")
println(if nan /= nan then "nan/=nan is true" else "nan/=nan is false")
println(if z == negz then "0.0==-0.0 is true" else "0.0==-0.0 is false")
println(if nan < one then "nan<1 is true" else "nan<1 is false")
Printing is owned by the canonical Float formatter and not respecified here; this section fixes only the tokens the special values render as, since a claim about nan or -0.0 is a claim about output. show (and therefore print and string interpolation, type classes) renders a nan as nan, positive and negative infinity as inf and -inf, and negative zero as -0, distinct from 0 for positive zero; the shortest round-tripping form the formatter chooses for finite values is the formatter’s contract, not this chapter’s.
5.8.1 Elementary Functions and Conversions
The elementary functions are owned the same way the arithmetic is. Rather than call whatever libm the platform links, Prism vendors one implementation of the double-precision math library and routes every function through it on every backend: the native code calls it, and the interpreter calls the identical compiled symbols, so a transcendental is a consequence of in-repo code, not of a system library’s rounding.12
The accuracy statement is deliberately modest and honest: the contract is determinism, not correct rounding. Each function is a deterministic faithful approximation, bit-for-bit identical on the interpreter and both native backends and across platforms, but it is not guaranteed to be the correctly rounded double of the true real result. Correctly-rounded transcendentals (the table-maker’s-dilemma problem) are an explicit non-goal; what the language guarantees is that whatever value a function produces, it produces the same value everywhere, pinned by the conformance corpus over the hard cases (subnormals, the extremes, argument reduction near multiples of pi/2, signed zero, nan, and the infinities) and a deterministic bulk sweep.
The functions divide into two classes. The exact operations are correctly rounded or integer-valued by IEEE-754 and therefore identical on every conforming platform regardless of implementation: sqrt (correctly rounded), abs_float, and the roundings floor, ceil, trunc (toward zero), and round (ties away from zero, so round(2.5) is 3.0 and round(-2.5) is -3.0, distinct from the ties-to-even of arithmetic). The approximate transcendentals are the owned-library functions: sin, cos, tan; the inverses asin, acos, atan, and the two-argument atan2(y, x); the hyperbolics sinh, cosh, tanh; the exponentials exp, exp2, expm1; the logarithms ln (natural), log2, log10, log1p; pow, cbrt, and hypot. fmod(x, y) is the exact IEEE remainder.13
The Int/Float conversions pin their rounding once, identically on both backends. to_float rounds an Int to the nearest Float, ties to even. The three float-to-Int conversions differ only in how they round to an integer before converting: truncate toward zero, floor_to_int down, ceil_to_int up.14
5.8.2 The Numerical Tower
The arithmetic and comparison operators are one spelling per operation across every lane, with the lane chosen by the operand’s type and resolved entirely at compile time. Three classes carry them. Num(a) provides +, -, *, and unary minus; Div(a) provides / and %; Ord(a) provides <, <=, >, and >= through its cmp method for non-primitive ordered types. Num and Div have instances for Int, I64, U64, and Float, so + reads on any of them and the earlier per-lane semantics of this chapter (the exact Int, the wrapping fixed-width lanes, the IEEE Float) are the instances’ behavior, unchanged. Div is split from Num so a type with addition but no sensible division stays representable without a vacuous method.
Resolution has no runtime cost.15 A monomorphic operand keeps the lane’s direct primitive, exactly the code the operator emitted before the tower, so the class dictionary never survives specialization and the generated core is byte-identical, pinned by the allocation gate. Only genuinely polymorphic code, a function written given Num(a) or given Div(a), dispatches through a dictionary, and that dictionary too is erased wherever the function is specialized to a concrete lane. Unary minus follows the same rule: -x on a concrete lane is the sign flip or two’s-complement negation of floating-point and integer arithmetic, and -x on a Num(a) operand dispatches through the class with the same value. Unsigned U64 has no surface negation (-x on a U64 is a type error naming the signed lanes), but the Num(U64) instance’s negation is the two’s-complement wrap, reachable through generic Num code and agreeing with wrapping_neg (safe arithmetic).
Integer literals are polymorphic. A literal with no width suffix adopts whatever numeric lane its context expects: 1 is a Float where a Float is wanted (so a Float-typed binding or argument needs no .0), an I64 in an I64 position, and so on, with the lane’s constant placed directly in the elaborated core and no runtime conversion. A decimal or exponent literal denotes a fractional lane, of which Float is currently the only one. The defaulting rule fixes the ambiguous case: an integer literal with no constraining context defaults to Int, and a fractional literal to Float. The default always fires, so a program that never mentions the numeric classes never sees a class-constraint error; let n = 5 is an Int exactly as before the tower. A width-suffixed literal (5i64, 5u64) is monomorphic, its suffix a type ascription rather than a hint, and a literal out of range for the lane it resolves to is a compile error at resolution time.
There is no implicit coercion, ever. The lane a value carries is fixed by its type, and only literals adapt; a variable never does. x + 2.5 where x : Int is a type error naming both lanes, not a promotion of x to Float, and the same holds across any two distinct lanes (I64 and U64, Int and Float). Cross-lane movement is always an explicit, named conversion (to_float, the checked narrowings and exact widenings of fixed-width integers and safe arithmetic). This is the line between a numeric surface that stays predictable and one whose every operator hides a possible conversion.
5.9 Algebraic Data Types
A type declaration introduces an algebraic data type: a sum of constructors, each a product of fields. A constructor is named with an upper-case identifier and applied like a function to build a value; a match (patterns) destructures a value by constructor. A type may take type parameters and may be recursive, including mutually so. A type parameter may be annotated : Row to range over an effect row rather than a type (kinds), so a field can store an effectful computation, as in type Cmd(a, e : Row) whose field is a () -> a ! {e}, or : Nat to range over a compile-time dimension, as in type Vec(a, n : Nat) whose length index is erased rather than stored.
-- A sum type: a value is exactly one of the listed constructors, each with its
-- own fields. Construction names a constructor; destruction matches on it.
type Shape = Circle(Int) | Rect(Int, Int)
fn area(s) =
match s of
Circle(r) => 3 * r * r
Rect(w, h) => w * h
-- A recursive sum type, the canonical binary tree.
type Tree = Leaf | Node(Tree, Int, Tree)
fn sum_tree(t) =
match t of
Leaf => 0
Node(l, v, r) => sum_tree(l) + v + sum_tree(r)
fn main() =
println(area(Circle(10)))
println(area(Rect(3, 4)))
println(sum_tree(Node(Node(Leaf, 1, Leaf), 2, Node(Leaf, 3, Leaf))))
A newtype is a data type with exactly one single-field constructor: a type distinct from its payload, with no runtime wrapper. An alias on a type expression is a transparent synonym, interchangeable with its definition. An alias whose body is a row literal is a row alias, the same transparency for a set of effect labels: usable wherever a row is written, expanded before checking, and composable with other aliases (composing rows); a row alias takes no parameters.
A deriving (C, ...) clause generates the named instances structurally (type classes). Eq, Ord, Show, Hash, Lens, and Plate are derivable everywhere: derived Ord compares fields lexicographically in declaration order and orders constructors by declaration, and derived Hash folds the value through the same blake3 Merkle construction that content-addresses code (content-addressed core), so structurally equal values carry one canonical digest on every backend.
Derived Plate yields one layer of structure, taken apart and put back. children(x) is the list of x’s immediate subvalues of x’s own type, in constructor-declaration and field order, and nothing else; rebuild(x, ks) is x with exactly those positions replaced, left to right, by the elements of ks. A whole-tree traversal or rewrite (every subterm, a fold, a count, a bottom-up rewrite) is written once against that one pair rather than once per constructor, and a fifty-constructor syntax tree costs the same to walk as a two-constructor one. The derivation looks through list, optional, tuple, and record fields, and through the other data types declared in the program, to find the occurrences a field can lead to; that is what lets a traversal see through the carrier records a tree holds its nodes in (a match arm, a spanned wrapper, a qualifier) with no second match written for them.
The two methods are inverse on one layer, and that law is what every combinator above them relies on: rebuild(x, children(x)) is x, and the list handed to rebuild must have the same length and order as the one children returned. children is pure and total, returning structurally smaller values, so a recursion driven by it terminates on a finite value. rebuild carries Fail in its row for exactly one reason: a list of any other length is a programming error, not an input to be repaired, so it raises Fail rather than padding the missing positions or dropping the extra ones, either of which would silently hand back a value that is not the one asked for. On a correctly shaped list it performs no effect. Both methods come from one walk of the declaration, read forwards and backwards, so a derived pair satisfies the law by construction; a hand-written instance owes it.
Being structural rather than compositional, this derivation differs from the others in two visible ways. It puts no constraint on the type’s own parameters, because a Plate(T(a)) yields T occurrences and never an a; and it asks nothing of a component’s own instances, taking the component apart by its declaration rather than by dispatch, so a component with no Plate instance is traversed all the same. What it cannot take apart it refuses: a field that could still lead back to the derived type through something opaque (a function, a container with no declaration in the program) is an error at the deriving clause naming the field and the type it reached, never a silently dropped subterm. Nothing in the class is unforgeable, so a hand-written instance is an ordinary instance and is accepted, which is the escape hatch for an abstract type whose children the compiler cannot see.
Five more classes derive against opt-in modules: Serialize and Stable (import Wire) for the wire codec, where Stable derives only when every component is itself Stable and a non-stable field is a compile error at the derive site; Arbitrary (import Test) for property-test generators built from the type’s structure (stable blocks); and ToJson with FromJson (import Json) for conversion to and from the dynamic JSON tree.
The JSON pair is for a type whose schema is its own declaration, and is derived as a pair, since a type that encodes but cannot decode is a document nobody can read back. One constructor becomes one object: a record constructor’s keys are its declared field names, a positional one’s are its argument positions (_0, _1), and a sum additionally names the variant it holds under the key $, which no field name can spell, so a document names its constructor rather than an index that quietly changes meaning when a constructor is inserted. A single-constructor type has nothing to discriminate and carries no tag. Constructor and field order are the declaration’s, so a value has one tree, and the encoder sorts keys, so it has one string on every backend. Unlike Plate this derivation is compositional: each field is converted through its own instance, so a component with no instance is a compile error at the field. A decode that does not fit, a tree that is not an object, a $ naming no constructor of the type, a missing key, or a field that will not itself decode, is one ordinary Fail, caught with optional or default; that failure carries no payload, so it reports that the document did not fit and not where, because Fail is nullary and a positioned failure would mean a different effect on the class signature and so on every hand-written instance too. None of this is the wire codec: a Serialize byte format is frozen and versioned by Stable, while a JSON document is read by something not compiled against this program, so the encoding is self-describing rather than compact and promises nothing across a change to the declaration.
deriving (Identifiable) is shorthand for the identity starter pack, expanding to exactly Eq, Ord, Hash, and Show so an ID newtype is comparable, hashable, and printable from one keyword with no imports; a class listed alongside it is derived once, not twice, and Arbitrary is deliberately excluded (it lives behind import Test and is a testing concern), so a value that also wants a generator writes deriving (Identifiable, Arbitrary).
5.10 Records
A constructor may instead take named fields, C { f : T, ... }, making the type a record. A field is read with e.f; records are built and updated by the record expressions. deriving (Lens) synthesizes a getter f_of and a setter with_f per field.
-- A record is a single-constructor product type with named fields. Fields are
-- read with `.f`, built with `C { f = e, ... }`, and updated functionally.
type Point = Point { x: Int, y: Int }
fn main() =
let p = Point { x = 3, y = 4 }
let q = Point { ..p, y = 9 }
println(p.x)
println(q.y)
5.11 Unboxed Products
A product may be written unboxed so its fields are carried inline rather than behind a heap cell: #(a, b) is an unboxed tuple and #{ x : a, y : b } an unboxed record, whose field is read with e.#field. A record lowers positionally to the same product representation, so projection reuses the tuple machinery and reference counting is balanced by construction. A product built and consumed within one function scalarizes away entirely, creating no cell at all; one that escapes across a boundary the optimizer cannot see through is boxed by the native backend, value-identical to the interpreter. Whether a given product is boxed is therefore a cost fact decided by the backend, never a difference an observer can name.
fn norm2() : Float =
let v = #{ re = 1.0, im = 2.0 }
v.#re * v.#re + v.#im * v.#im
fn main() = println(norm2())
5.12 Non-Allocating Nullables
OrNull(a) is a nullable that costs no heap cell: Null is the empty word and This(v) carries a present element in the element’s own representation. Because the two share one word of storage, the element type must be one whose values can never collide with the null word: a concrete, single-word, non-zero type. Unit (the zero word), a nested OrNull, an unboxed product, and an element type inference never pins are all rejected at compile time (E1019), on written annotations and on nullables inference discovers on its own alike. Null and This behave as ordinary constructors under match, exhaustiveness, and reference counting, so a nullable is byte-identical across backends and its representation stays a storage choice.
fn find(hit : Bool) : OrNull(String) =
match hit of
true => This("found")
false => Null
fn main() =
match find(true) of
Null => println("none")
This(s) => println(s)
6. Type Classes
A class declares a single-parameter constraint and a set of method signatures. An instance is a named value providing those methods for one head type. A function states its constraints with a given clause after the return annotation, as announce below does, and receives its dictionaries as hidden arguments resolved at each call site, one per constraint. The following program declares two Describe(Temp) instances, designates one canonical, and selects the other explicitly with using.
A class, instance, or effect body is a layout block: the head ends its line and the members follow on indented lines, one per line, with no braces and no where. Each instance method is written in expression form, fn m(x) = e. Because the body is layout-delimited, it admits the same layout-sequenced statements as a top-level fn body, as well as let .. in chains. A brace opening one of these bodies is a parse error that names the layout rewrite. A marker class with no methods, and its instance, are written as the bare head with no body.
-- Typeclasses. A class is an interface: a named bundle of methods a type
-- implements. An instance provides those methods for one type, and a function
-- that says `given Shape(a)` works for every type with an instance, receiving
-- the instance as a hidden argument resolved at each call site.
type Circle = MkCircle(Float)
type Rect = MkRect(Float, Float)
class Shape(a)
area : (a) -> Float
name : (a) -> String
instance shapeCircle : Shape(Circle)
fn area(c) =
match c of
MkCircle(r) => 3.14159265 * r * r
fn name(_c) = "circle"
instance shapeRect : Shape(Rect)
fn area(s) =
match s of
MkRect(w, h) => w * h
fn name(_s) = "rectangle"
fn summary(x : a) : String given Shape(a) =
concat(name(x), concat(" of area ", show_float(area(x))))
-- Instances are named values (Lean-style), which is what the knobs below hang
-- off. A second instance for the same head type is legal; designating one
-- canonical keeps implicit resolution deterministic (coherence: a program's
-- meaning never depends on a silent tie-break), and the other instance is
-- selected explicitly with `f(args, using instName)`.
instance shapeBox : Shape(Rect)
fn area(s) =
match s of
MkRect(w, h) => w * h
fn name(_s) = "box"
canonical Shape(Rect) = shapeRect
fn main() =
println(summary(MkCircle(1.0)))
println(summary(MkRect(2.0, 3.0)))
println(summary(MkRect(2.0, 3.0), using shapeBox))
6.1 Coherence and Resolution
An instance is selected by the head constructor of the constraint type (the outermost constructor, for example List in List(Int)). Resolution is coherent: a program’s meaning never silently depends on which instance the resolver happened to pick. For each (class, type-head) there is exactly one canonical instance, and implicit resolution always selects it, so resolution is deterministic.
With a single instance for a head, that instance is canonical automatically. When two or more instances share a head, one must be designated canonical with a top-level declaration:
canonical Class(Head) = instanceName
Having two instances for one head without a designation is a coherence error reported at definition, not a silent ambiguity deferred to the use site. The designated instance is what implicit resolution selects; the others remain reachable only through an explicit override.
An explicit override is visible at the use site and changes nothing else’s resolution: pass the chosen instance as a trailing using argument, f(args, using instanceName), as sort_by_ord(xs, using ordDesc) does above. (This is the same using form reserved for first-class dictionary passing.) There is no ambient, scoped instance mechanism: an override is always written where it is used.
The preferred way to obtain a different instance for a type is a newtype with its own canonical instance (newtype Down = Down(Int) for reverse order, a folded-case wrapper for case-insensitive comparison) rather than a non-canonical instance of the base type. This changes the type, not the instance-for-a-type, so coherence is preserved exactly and the difference is visible in the signature; an explicit using override is the second-line tool when a newtype is too heavy.
Resolution recurses through instance contexts up to a fixed depth.
A consequence worth naming: equality, ordering, and hashing are ordinary methods of coherent classes (Eq, Ord, Hash), never built-ins that work on any value by inspecting its representation. Prism has no polymorphic structural ==, compare, or hash. A structural default is a known hazard: it typechecks on functions, abstract types, and cyclic values where it has no principled meaning, and it silently overrides whatever notion of equality an abstraction intended. OCaml’s Base goes so far as to shadow the polymorphic versions to keep them out of reach; in Prism the hazard never arises, because the only equality in scope is the one an Eq instance supplies and coherence makes that instance unique.
Printing follows the same discipline. print and println display a concrete argument by its structure (a top-level string prints raw, exactly as interpolation splices it), but a polymorphic argument requires Show: a generic function that prints declares given Show(a), the display dispatches through the instance (a generic Bool prints true, never a representation tag), and printing a rigid type variable without the constraint is a type error naming the missing given Show(a). What is never consulted is the runtime representation; the tag check that guards the raw printer is defense in depth against compiler bugs, not a semantics.
6.2 Superclasses
A class may require another as a superclass with given, the way an interface extends another. Each instance then stores a resolved superclass dictionary as the leading field of its dictionary cell, so one written constraint carries both capabilities: below, a given Greet(a) function calls the superclass method name_of with no Nameable constraint written, discharging it by projecting that field. The superclass witness is found automatically from the instances in scope, so the instance declaration never repeats it, and unlike inheritance nothing is overridden: the two dictionaries stay separate values.
-- Superclasses. A class may require another with `given`: every `Greet` type
-- must already be `Nameable`, the way an interface extends another. Each
-- instance then carries its superclass dictionary as a hidden leading field of
-- its own, so a `given Greet(a)` function may call `name_of` (a `Nameable`
-- method) with no `Nameable` constraint written: `Greet` entails it. Unlike
-- inheritance, nothing is overridden or re-implemented; the `Greet` instance
-- simply points at whichever `Nameable` witness is in scope, and the two kinds
-- of evidence stay separate values.
type Robot = MkRobot(Int)
class Nameable(a)
name_of : (a) -> String
class Greet(a) given Nameable(a)
greeting : (a) -> String
instance nameRobot : Nameable(Robot)
fn name_of(r) =
match r of
MkRobot(n) => concat("unit-", show_int(n))
instance greetRobot : Greet(Robot)
fn greeting(_r) = "beep boop"
-- One constraint, two capabilities: `greeting` is Greet's own method, and
-- `name_of` is projected from the superclass field of the same dictionary.
fn hail(x : a) : String given Greet(a) =
concat(name_of(x), concat(" says ", greeting(x)))
fn main() = println(hail(MkRobot(7)))
6.3 Higher-Kinded Classes
A class parameter may be a type constructor of kind * -> *, applied as f(a) in method signatures and resolved on the head constructor of each instance. The prelude’s Functor/Applicative/Monad/Foldable/Traversable tower is built this way. The example below builds that tower explicitly over a custom container, each level naming its predecessor as a superclass with given, so an instance high in the tower can exist only where the ones below it do.
-- The Functor/Applicative/Monad tower built explicitly, the same shape the
-- prelude ships for List and Option. The class parameter `f` has kind * -> *,
-- applied as `f(a)` in each method signature, and every level names its
-- predecessor as a superclass with `given`, so an instance high in the tower
-- can exist only where the ones below it do. (The names carry a `B`/`_` suffix
-- only to sit beside the prelude's own Functor/Applicative/Monad and fmap/pure/bind.)
type Box(a) = Box(a)
fn open(b : Box(a)) : a =
match b of
Box(x) => x
class FunctorB(f)
fmap_ : ((a) -> b ! {| e}, f(a)) -> f(b) ! {| e}
class ApplicativeB(f) given FunctorB(f)
pure_ : (a) -> f(a)
class MonadB(f) given ApplicativeB(f)
bind_ : (f(a), (a) -> f(b) ! {| e}) -> f(b) ! {| e}
instance functorBox : FunctorB(Box)
fn fmap_(g, b) = Box(g(open(b)))
instance applicativeBox : ApplicativeB(Box)
fn pure_(x) = Box(x)
instance monadBox : MonadB(Box)
fn bind_(b, g) = g(open(b))
fn main() : Unit ! {IO} =
println(open(pure_(7))) -- Applicative: lift a value in
let c = fmap_(\(x) -> x + 1, Box(20)) -- Functor: map under the structure
let d = bind_(c, \(x) -> Box(x * 2)) -- Monad: sequence (needs the two below)
println(open(d))
The prelude provides the same tower for List and Option. Its methods are effect-polymorphic (defined under effect polymorphism): a per-element effect row threads through in place of an Applicative wrapper, so effectful traversal needs no monad and no do-notation. Using it, one fmap/ap/bind/traverse works across either container.
-- Higher-kinded type classes: the class parameter ranges over a type
-- constructor (kind * -> *), applied as `f(a)` in the method signatures. The
-- Functor/Applicative/Monad/Foldable/Traversable tower is in the prelude with
-- instances for List and Option, resolved by the head constructor of each
-- instance. `fmap`/`traverse` are effect-polymorphic: the per-element effect
-- row threads through instead of an Applicative wrapper, so effectful
-- traversal needs no monad and no do-notation.
--
-- Prints:
-- [2, 3, 4]
-- Some(42)
-- 10
-- [2, 3, 10, 20]
-- Some(15)
-- [1, 1, 2, 2, 3, 3]
-- Some(9)
-- [10, 20, 30]
-- Functor: one `fmap` maps over either container.
fn functor_demo() =
println(show(fmap(\(x) -> x + 1, [1, 2, 3])))
println(show(fmap(\(x) -> x * 2, Some(21))))
-- Foldable: an effect-polymorphic right fold.
fn foldable_demo() =
println(show(fold_r(\(x, acc) -> x + acc, 0, [1, 2, 3, 4])))
-- Applicative: `ap` is cartesian on List, positional on Option.
fn applicative_demo() =
println(show(ap([\(x) -> x + 1, \(x) -> x * 10], [1, 2])))
println(show(ap(Some(\(x) -> x + 5), Some(10))))
-- Monad: structural `bind`, where List is nondeterminism and Option failure.
fn monad_demo() =
println(show(bind([1, 2, 3], \(x) -> [x, x])))
println(show(bind(Some(3), \(x) -> Some(x * x))))
-- Traversable: effectful map, with an empty effect row here.
fn traversable_demo() = println(show(traverse(\(x) -> x * 10, [1, 2, 3])))
fn main() =
functor_demo()
foldable_demo()
applicative_demo()
monad_demo()
traversable_demo()
So Monad here is just another class, structure for List-style nondeterminism and Option-style failure, with none of the language integration it carries elsewhere: no do-notation, no privileged status, no return, no burritos,16 no Kleisli categories.17 Sequencing side effects is the effect system’s job, not the monad’s.
The two systems meet in Traversable. The example below defines a recursive Tree, gives it the Functor/Foldable/Traversable instances, then runs a single generic traverse over it four ways. Nothing about the traversal changes between them; the behaviour is chosen entirely by the effect the per-element function performs, since traverse’s signature carries that row straight through. State numbers the leaves, Fail short-circuits, Choice (resumed multishot) enumerates every assignment, and {State, Fail} does the first two at once under two stacked handlers. Each is a job a monadic language hands to a different Applicative instance (State, Maybe, the list monad) or, for the last, a StateT s Maybe transformer stack; here it is one traversal and the effect rows supply the rest. This is the whole type system in one program: higher-kinded classes with a superclass chain, principal effect rows that compose, and handlers (including multishot resumption) discharging them.
-- The synthesis: one generic, effect-polymorphic `traverse` over a custom
-- recursive `Tree`, run four ways. Nothing about the traversal changes between
-- them; the behaviour is chosen entirely by the effect the per-element function
-- performs, and the effect system supplies what a monadic language would reach
-- for an Applicative instance (or a transformer stack) to get. The signature
-- `traverse : ((a) -> b ! {| e}, t(a)) -> t(b) ! {| e}` carries the per-element
-- row `e` straight through, so each leaf's effects sequence in tree order and a
-- handler discharges them at the boundary. No Applicative, no do-notation.
type Tree(a) = Leaf(a) | Branch(Tree(a), Tree(a)) deriving (Show)
-- The higher-kinded tower for Tree. Traversable names Functor and Foldable as
-- superclasses, so this instance trio is what lets the prelude's generic
-- `traverse`/`fmap`/`fold_r` resolve on the `Tree` head. Each delegates to a
-- top-level recursive walker; `twalk` is itself effect-polymorphic.
fn tmap(g, t) =
match t of
Leaf(x) => Leaf(g(x))
Branch(l, r) => Branch(tmap(g, l), tmap(g, r))
fn tfold(g, z, t) =
match t of
Leaf(x) => g(x, z)
Branch(l, r) => tfold(g, tfold(g, z, r), l)
fn tfoldl(g, z, t) =
match t of
Leaf(x) => g(z, x)
Branch(l, r) => tfoldl(g, tfoldl(g, z, l), r)
fn twalk(g, t) =
match t of
Leaf(x) => Leaf(g(x))
Branch(l, r) => Branch(twalk(g, l), twalk(g, r))
instance functorTree : Functor(Tree)
fn fmap(g, t) = tmap(g, t)
instance foldableTree : Foldable(Tree)
fn fold_r(g, z, t) = tfold(g, z, t)
fn fold_l(g, z, t) = tfoldl(g, z, t)
instance traversableTree : Traversable(Tree)
fn traverse(g, t) = twalk(g, t)
effect State
get() : Int
put(Int) : Unit
effect Choice
pick() : Bool
-- A parameter-passing State handler: the handled block becomes a state
-- transformer, run with an initial counter. The action's row is written
-- `! {State | e}` so the handler subtracts State concretely and leaves `e`
-- (any other effects the action performs) untouched for an outer handler.
fn run_state(s0, action : () -> a ! {State | e}) =
let f =
handle action() with
get() resume k => \(s) -> k(s)(s)
put(v) resume k => \(_s) -> k(())(v)
return r => \(_s) -> r
f(s0)
-- A Fail handler, likewise annotated `! {Fail | e}`: annotating the action row is
-- what lets a reusable handler-wrapper discharge its effect in the type (so it
-- drops out of `main`'s row), not merely at runtime.
fn attempt(action : () -> a ! {Fail | e}) =
handle action() with
never fail() => None
return r => Some(r)
-- Per-leaf functions, each performing a different effect. Their rows are what
-- pick the traversal's behaviour; keeping them monomorphic keeps the example
-- about the effects, not inference.
fn number(x : Int) : (Int, Int) ! {State} =
let n = get()
put(n + 1)
(n, x)
fn nonneg(x : Int) : Int ! {Fail} =
guard(x >= 0)
x
fn sign(x : Int) : Int ! {Choice} =
if pick() then
x
else
0 - x
fn audit(x : Int) : (Int, Int) ! {State, Fail} =
guard(x >= 0)
number(x)
fn main() : Unit ! {IO} =
let t = Branch(Branch(Leaf(1), Leaf(2)), Leaf(3))
-- State: number the leaves left to right (the State applicative elsewhere).
println(show(run_state(0, \() -> traverse(number, t))))
-- Fail: short-circuit the whole traversal on a bad leaf (the Maybe applicative).
println(show(attempt(\() -> traverse(nonneg, t))))
println(show(attempt(\() -> traverse(nonneg, Branch(Leaf(0 - 9), Leaf(2))))))
-- Choice, by multishot resumption: one whole tree per sign assignment, all
-- 2^3 of them collected (the List applicative; here the continuation is reentered).
let worlds =
handle traverse(sign, t) with
pick() resume k => append(k(true), k(false))
return r => Cons(r, Nil)
println(show(length(worlds)))
-- {State, Fail} in a single pass under two stacked handlers: number and
-- validate at once, the row the monadic world reaches StateT s Maybe for.
println(show(attempt(\() -> run_state(0, \() -> traverse(audit, t)))))
Because a row is an unordered set, {State, Fail} fixes no layering the way a transformer stack must: whether a failure discards the numbering or keeps it is decided by which handler sits outside the other at the use site, not baked into the type. The monad-transformer ordering question, StateT s Maybe versus MaybeT (State s), moves from the type to the handler site, free to differ from one call to the next without changing a single signature.
Classes remain single-parameter; multi-parameter classes are not supported.
7. Effects and Handlers
An effect declares a set of operations; each operation has an argument list and a result type. Performing an operation is an ordinary call to its name. A function’s effect row is the set of effects whose operations it may perform and has not handled, written ! {L, ...} on its result type, with an optional row variable tail ! {L | r}. A bare ! is an explicit empty row. A row is inferred when omitted.
An operation’s declaration carries a grade, the resumption multiplicity every handler clause for it must respect, written as the contextual prefix never, once, or many. The grades form a three-point lattice ordered never < once < many: never never resumes (the continuation is dropped), once resumes exactly once in tail position (no capture), and many may capture the continuation and resume any number of times. many is the default and the most general grade, so an operation declared with no prefix (or the explicit many) admits every handler; a grade word is written only to claim something stronger. The checking rule is one line: a handler clause’s own multiplicity must be at most its operation’s declared grade. A clause that resumes a never operation, or that captures or re-enters the continuation of a once operation, is rejected at that clause, its caret naming the operation and its declared grade; a clause more restrictive than the grade (handling a many operation tail-resumptively, say) is always allowed. The grade is a static, checked fact only: it constrains which handlers typecheck and lets the compiler keep an unrelated in-place var loop on its fast lowering when some other component resumes multishot, but it never changes the observable behavior of an accepted program.
| Prefix | Grade | Resumption |
|---|---|---|
never | 0 | never resumes; the continuation is dropped |
once | 1 | resumes exactly once, in tail position, without capturing k |
many | ω | may capture k and resume any number of times, including zero |
effect State
get() : Int
put(Int) : Unit
-- Algebraic State by parameter passing.
--
-- The handler interprets get/put by parameter passing. Each clause returns a
-- function s -> result, and `k(v)(s)` threads the state forward. The handled
-- block becomes a state transformer that we run with an initial s.
--
-- counter() never mentions a state value, it just performs get and put. The
-- row is inferred and discharged at the handler, so the same ops could be
-- reinterpreted (logging, bounding) without touching counter.
fn tick() : Int ! {State} =
let n = get()
put(n + 1)
n
fn counter() : Int ! {State} =
tick()
tick()
tick()
get()
fn run_counter(init) =
let f =
handle counter() with
get() resume k => \(s) -> k(s)(s)
put(s2) resume k => \(_s) -> k(())(s2)
return r => \(_s) -> r
f(init)
fn main() = println(run_counter(0))
A handle e with block discharges operations; its grammar is the handler nonterminal of the surface grammar. Each operation clause names an operation and binds its arguments and the resumption k (the captured continuation, explained below); calling k(v) resumes the suspended computation with v, and k may be called zero times (abort), once (the common case), or many times (multishot). A return r clause transforms the final value. The handler in eff_state.pr interprets get/put by threading a state parameter, so counter, which only performs the operations, never mentions a state value.
Operations and handlers are delimited control: the handle block is the delimiter (a prompt), and the resumption k is the delimited continuation it captures, the slice of computation between the perform site and the handler. Being first-class, k reinstalls that slice under the same handler when invoked. This is the typed, named generalization of shift/reset: a single prompt with one anonymous continuation becomes a row of named operations, each with its own clause, and the effect row is the static record of which delimiters a computation still requires.
A clause may invoke k any number of times; more than once makes the continuation multishot: each call re-runs the captured slice from the perform site with a different result, so one handler can pursue several futures of the same computation. This is how nondeterminism or search handlers explore alternatives (an amb operation whose clause calls k once per choice and combines the outcomes) and how generators yield and continue. Never invoking k discards the captured slice, which is exactly how raise (observability) and a never clause abort.
7.1 Residual Handlers
A handler is exhaustive by default. If it names an operation of an effect but omits another operation of that same effect, checking fails at the handler. The explicit residual form opts into forwarding those omitted operations:
effect Choice
choose() : Bool
commit() : Unit
fn choose_true(body : () -> a ! {Choice}) : a ! {Choice} =
handle body() with partial {
choose() resume k => k(true),
return x => x
}
The partial marker follows with; it applies to every effect represented by the operation clauses in that handler. Exhaustive handlers retain the existing handle e with { ... } spelling. Consequently an omitted clause is never an accidental forwarding rule: it is either rejected or visible at the handler site.
The typing judgment retains, beside each effect-row label, the set of operations demanded from that effect while an expression is being checked. Write uses(c, E) for that set, handled(h, E) for the operations of E named by handler h, and arms(h) for the union of effects performed directly by its return and operation clauses. For a partial handler,
residual(handle c with partial h, E)
= (uses(c, E) − handled(h, E)) ∪ uses(arms(h), E)
and its output row contains E exactly when that residual set is non-empty. Labels for effects not named by the handler, every open row tail, and effects performed by the clauses are preserved. Clause bodies run outside the handler they define, so re-performing an operation from a clause is part of arms(h) and reaches an outer handler. An ordinary exhaustive handler uses the same rule after checking that handled(h, E) is the complete declared operation set of every E it names.
Operation coverage is a local refinement of an effect label, not a second public row syntax. The printed row remains ! {Choice} whenever any Choice operation is residual. An explicit or generalized signature containing Choice is conservative and promises no smaller operation subset, so a caller may discharge it only with an exhaustive Choice handler or leave Choice residual. This keeps module interfaces stable while still allowing two adjacent partial handlers to cancel known local subsets before generalization.
If an operation is not named by a partial handler, evaluation performs it outward exactly once with the original operation identity and payload. Its outward resumption is the captured continuation wrapped in the same partial handler, so resuming returns beneath the delimiter and eventual normal completion still runs the return clause exactly once. No clause body runs during forwarding. Grades are unchanged: forwarding neither duplicates nor discards a continuation, while a matching clause must still respect the operation’s declared never, once, or many grade.
For an operation op : (p) -> q, each matching clause is checked with its declared argument types and with
k : (q) -> answer ! residual(handle c with partial h)
The answer type is shared by the return clause and every operation clause. The residual row is the least row satisfying the body-subtraction rule and all clause-effect constraints; this is the same open-row unification used by higher-order handlers, not a default to the empty row.
Forwarding is semantic, not a lowering choice. The interpreter, evidence-passing lowering, and free-monad lowering must emit the same canonical observation trace. In particular, operation emission, outward handling, resumption, and the return clause occur in that order in every tier.
7.2 Observability
The defining property of the row discipline: an operation handled inside a function is discharged, so it does not appear in that function’s inferred row. In the example below, checked carries the row ! {Exn}, but attempt, which handles raise, is pure.
effect Exn
raise(Int) : Int
-- Exceptions as an effect: raise and catch.
--
-- raise aborts the computation. The handler clause drops the captured
-- continuation k and returns a recovery value, so control never comes back.
-- checked() raises deep inside a helper, far from the catching handler: the
-- effect propagates across calls and is discharged where the handle sits.
fn safe_div(n, d) : Int ! {Exn} =
if d == 0 then
raise(0 - 1)
else
n / d
fn checked(n) : Int ! {Exn} =
let a = safe_div(100, n)
let b = safe_div(a, n - 5)
a + b
fn attempt(n) =
handle checked(n) with
raise(code) resume k => code
return r => r
fn main() =
let good = attempt(2)
let bad = attempt(5)
println(good * 100 + bad)
The old joke about purity is that a function of type Int -> Int cannot launch the missiles. A single IO type can put it no more precisely than that: somewhere, something happens to the world. Here the international side effect is declared in the language itself, an effect Missiles whose row label follows first_strike through every signature that might perform it, and observability is what disarms it: war_games handles launch and never resumes, so its inferred type is () -> Int, pure. The missiles are unlaunched and absent from the type. joshua adds multishot resumption (effects and handlers): its choose clause resumes the continuation once per side, so every future of the exchange is played out under the treaty handler and their scores summed. Every future is explored, none of them wins, and joshua is still pure. So thermonuclear war doesn’t typecheck, world peace achieved.
-- The row names the missiles. A single IO type can say only that a function
-- does something to the world; here the international side effect is a row
-- label, and the only way to be rid of it is to handle it.
--
-- A handler is a treaty: install one and every launch is intercepted before
-- it reaches the world. war_games discharges Missiles without resuming, so
-- from the outside it is a pure function of type () -> Int. joshua goes
-- further: choose resumes its continuation twice, once per side, so every
-- future of the exchange is played out under the treaty and none of them
-- wins. No missiles were launched in the evaluation of this program.
--
-- Expected output:
-- greetings professor falken
-- winning moves: 0
-- joshua: 0 winning moves across every future
effect Missiles
launch(Int) : Unit
effect Warplan
choose() : Bool
fn first_strike() : Int ! {Missiles} =
launch(100)
1
fn exchange(us_first : Bool) : Int ! {Missiles} =
if us_first then
launch(100)
else
launch(99)
1
-- a strange game. the only winning move is not to play.
fn war_games() : Int =
handle first_strike() with
launch(_warheads) resume _k => 0
return moves => moves
fn played(us_first : Bool) : Int =
handle exchange(us_first) with
launch(_warheads) resume _k => 0
return moves => moves
fn joshua() : Int =
handle played(choose()) with
choose() resume k => k(true) + k(false)
return total => total
fn main() =
println("greetings professor falken")
println("winning moves: {war_games()}")
println("joshua: {joshua()} winning moves across every future")
7.3 Clause Sugar
Two clause forms abbreviate common shapes. once op(x) => e is tail-resumptive sugar for op(x) resume k => k(e), resuming exactly once. val v = e is an install-time constant: e runs once when the handler installs, and every use of v returns it.
-- `once` and `val` handler clauses, pure desugaring sugar.
--
-- `once op(x) => e` is a tail-resumptive clause, identical to
-- `op(x, k) => k(e)`, resuming exactly once with e. `val v = e` is an
-- install-time constant: e runs once when the handler installs (its effects
-- land on the installer, not the handled block) and every v() returns it.
--
-- Expected output:
-- 999 (printed once at install, from val tag)
-- prism
-- ready
-- 70 (tag 0 + width(3) + width(4), each * 10)
effect App
name() : String
tag() : Int
width(Int) : Int
log(String) : Unit
fn render() : Int ! {App} =
log(name())
log("ready")
tag() + width(3) + width(4)
fn run_app() : Int ! {IO} =
handle render() with
val name = "prism"
val tag =
println(999)
0
once width(w) => w * 10
log(s) resume k =>
println(s)
k(())
return r => r
fn main() = println(run_app())
A never op(x) => e clause is non-resumable: it discards the continuation. This is the shape that error, throw, try, and catch desugar to (errors and failure).
7.4 Masking
mask<E>(e) makes every operation of effect E performed in e bypass the innermost enclosing handler of E and reach the next one out. Masks nest, so a double mask skips two handlers. The masked expression still demands an enclosing handler, so E remains in its row.
-- Effect masking. `mask<Eff>(e)` makes every Eff operation in e
-- bypass the innermost enclosing Eff handler and reach the next one out.
-- Masks nest, so a double mask skips two handlers. The row type still injects
-- Eff, so a masked expression demands an enclosing handler. Dispatch skips
-- one matching frame per mask.
--
-- Expected output:
-- 12
-- 300
-- 13
effect Ask
ask() : Int
-- inner answers 2, outer 10. ask() + mask<Ask>(ask()) = 2 + 10 = 12
fn shadowed() =
handle (handle ask() + mask<Ask>(ask()) with {
ask() resume k => k(2),
return r => r
}) with
ask() resume k => k(10)
return r => r
-- three handlers deep (inner 1, middle 100, outer 200). The single mask
-- reaches the middle, the double mask the outer. 100 + 200 = 300
fn deep() =
handle (handle handle mask<Ask>(ask()) + mask<Ask>(mask<Ask>(ask())) with {
ask() resume k => k(1),
return r => r
} with {
ask() resume k => k(100),
return r => r
}) with
ask() resume k => k(200)
return r => r
-- the inner clause re-performs ask, answered by the outer (3). The masked ask
-- in the body skips to the outer too. 3 + 3 + 7 = 13
fn reask() =
handle (handle ask() + mask<Ask>(ask()) + 7 with {
ask() resume k => k(ask()),
return r => r
}) with
ask() resume k => k(3)
return r => r
fn main() =
println(shadowed())
println(deep())
println(reask())
7.5 Named Handlers
The statement form with handler { ... } scopes a handler over the remainder of the enclosing block, so a stack of handlers reads as a flat sequence of layers rather than a rightward drift of nested handle expressions (composing rows puts this form to work). Adding a binder makes the handler first-class: with f <- handler { ... } installs the handler and binds it as an instance, and an operation addressed through it, f.read(), dispatches to that instance even when another handler of the same effect sits closer. A bare read() still reaches the innermost ordinary handler, so two instances of one effect can serve one scope, distinguished by name where the innermost-handler rule alone could not tell them apart. Masking skips handlers by position; a named handler addresses one directly.
-- Koka-style named handlers. `with f <- handler { .. }` binds a first-class
-- handler instance. `f.op(args)` dispatches to that instance even when another
-- handler of the same effect sits closer. Each instance desugars to a fresh
-- private effect (ops renamed op@f@n, unforgeable from source), so the rest of
-- the pipeline sees ordinary effects. Escape analysis rejects a closure that
-- would carry an instance out of its `with` block.
--
-- Expected output:
-- conf.toml
-- data.csv
-- conf.toml:data.csv
-- inner/named
-- 70
effect Read
read() : String
effect Ask
ask() : Int
-- two instances of one effect. Dispatch is by instance, not by innermost.
fn sources() =
with conf <- handler
read() resume k => k("conf.toml")
return r => r
with data <- handler
read() resume k => k("data.csv")
return r => r
println(conf.read())
println(data.read())
println(concat(conf.read(), concat(":", data.read())))
-- a bare read() still goes to the innermost ordinary handler. The named
-- instance only answers calls addressed through it.
fn mixed() =
with named <- handler
read() resume k => k("named")
return r => r
let inner =
handle read() with
read() resume k => k("inner")
return r => r
println(concat(inner, concat("/", named.read())))
-- multishot through a named instance. The continuation after h.ask() is
-- resumed twice. (3 * 10) + (4 * 10) = 70
fn multishot() : Int =
with h <- handler
ask() resume k => k(3) + k(4)
return r => r
h.ask() * 10
fn main() =
sources()
mixed()
println(multishot())
Each instance desugars to a fresh private effect whose operations are unforgeable from source, so the rest of the pipeline sees ordinary effects and ordinary rows; resumption is unrestricted through an instance (the multishot clause above resumes the continuation of h.ask() twice). The escape analysis of local mutation applies here too: a closure or returned value that would carry an instance out of its with block is rejected, so an instance never outlives its handler.
The resource form with x <- f(args) generalizes the same shape to any function that takes its continuation last: the remainder of the block becomes a function \(x) -> rest appended to the call’s arguments, so f decides when, whether, and how often to run the rest. This is the bracket idiom (acquire, use, release) written without nesting.
The same scope-local skolem underwrites ordered containers. A Map(k, v) is ordered by the ambient canonical Ord(k), but a program that needs two orderings of the same keys at once cannot let a map built under one be walked under the other: the tree structure encodes the ordering, so a lookup under the wrong comparator silently returns the wrong answer. The map type carries a third, phantom parameter for exactly this, Map(k, v, ord), a brand naming the ordering a map was built under; it appears in no field, so an unbranded Map(k, v) is the same type with the brand left to inference, and pre-brand source keeps checking unchanged. The Data.Ordered module (import Data.Ordered) hands out brands the way a named handler hands out an instance. with_ordering(cmp, body) runs body with a witness carrying cmp, and the witness’s brand is a fresh rigid skolem unique to that call, so a map built through one witness carries a brand that a second witness’s brand cannot unify with. Two witnesses coexist in one scope, and handing a map built under one to the other’s operation is a compile-time type error naming both brands. The brand never escapes: the body’s result may not mention it, so only a summary of a branded map (a size, a looked-up value, an encoded form) leaves the block, never the branded map itself.
This is the explicit half of the coherence story, and it closes statically. The implicit half is calling the ambient map_insert under a non-canonical Ord chosen with using, then reading the result under the canonical one. Because those two maps have the same unbranded type, the implicit path is caught dynamically where it does the most harm: when an ordered container crosses a package boundary. A serialized map records its keys in the writer’s order, and Wire’s map reader checks that they arrive strictly ascending under its own Ord(k), faulting through failure rather than rebuilding a mis-ordered tree when a map ordered by one comparator is read where a different one is canonical. Both faults, the compile-time brand mismatch and the runtime order check, are pure functions of the source and the pinned inputs, so a program’s behavior never reveals which backend ran it. The division is deliberate and stated as such: the explicit witness path is static, while the implicit path is dynamically checked at the wire boundary.
7.6 Local Mutation
A var mutates, yet the function holding it stays pure. fib_iter below updates two locals in a loop but has type (Int) -> Int with an empty row, so it is accepted where only a pure function is allowed. Prism has no mutation primitive; var is sugar over the effect system.
A var x := e desugars to a private two-operation effect (a get and a set); each read of x becomes a perform of get, each x := v a perform of set. In the same pass, a handler that threads the value as a hidden parameter is wrapped around the block. That handler discharges the get and set labels (observability), so they never reach the function’s type: the state is implemented but not observable. Because an escape analysis (below) has proved the state never leaves its block, effect lowering then erases the whole handler to a mutable cell, turning each get into a cell read and each set into a cell write, and the loop into a constant-stack loop, so the lowered code allocates nothing per iteration.
-- twice demands a pure (Int) -> Int. fib_iter mutates two locals in a loop,
-- yet is accepted here: its inferred row is empty.
fn twice(f : (Int) -> Int) = f(f(6))
fn fib_iter(n : Int) : Int =
var a := 0
var b := 1
repeat(n) fn
let t = a + b
a := b
b := t
a
fn main() = println(twice(fib_iter))
The two tabs are the compiler’s own dumps: Core (prism dump core) is the elaborated form, where each var has become a private two-operation State effect (do get@a@0 / do set@a@0) wrapped in a value-threading handler, one nested handler per var; Lowered (prism dump lowered) is after effect lowering, where the escape check has let those handlers collapse to a mutable cell (ref_new / ref_get / ref_set) and the loop to a constant-stack repeat. The get/set effect never reaches the function’s type, so fib_iter stays pure.
An escape analysis keeps the purity honest: the compiler rejects any closure or returned value that would carry the var out of its block, so the state cannot outlive its handler.
7.7 Errors and Failure
Prism has no built-in exception type. Errors and failure are two related mechanisms, both resting on the non-resumable never clause of the clause sugar. With the imperative break, continue, and return of imperative control flow, they are one mechanism wearing several faces: each is a single-operation effect whose handler never resumes the captured continuation, installed only where the corresponding keyword actually occurs, so non-local control costs nothing where it is not used and (being handled at its boundary) surfaces in no effect row where it is.
Extensible errors. An error N(t) declaration introduces a one-operation effect whose operation never resumes; throw N(x) performs it. A function’s error row is exactly the set of errors it may raise and has not caught, and distinct error declarations union structurally as functions compose, with no umbrella sum type and no conversion glue: find_port carrying {NotFound} and parse_port carrying {Malformed} compose to {NotFound, Malformed}. try e catch { ... } is subtractive handler sugar (one nested never per arm): a partial catch discharges the labels it names and lets the rest flow to an enclosing handler, and an uncaught error is an unhandled-effect error naming exactly the labels that remain. Each catch arm names an error and binds its fields to variables.
-- Errors as effect rows. Each `error` declaration is its own effect label and
-- `throw` performs it, so a function's row is its error set. find_port carries
-- {NotFound}, parse_port carries {Malformed}, and their composition unions to
-- {NotFound, Malformed} with no umbrella enum or conversion glue.
--
-- `try`/`catch` is subtractive handler sugar (one nested `never` per arm).
-- A partial catch discharges its label and passes the rest to an outer try. An
-- uncaught case is an unhandled-effect error naming exactly what remains.
--
-- Expected output:
-- 8080
-- default 80
-- bad config: oops
-- 443
-- no such key: tls
--
error NotFound(String)
error Malformed(String)
fn find_port(cfg, key) =
match cfg of
Nil => throw NotFound(key)
Cons(pair, rest) =>
let (k, v) = pair
if k == key then
v
else
find_port(rest, key)
fn parse_port(s) =
if s == "8080" then
8080
elif s == "443" then
443
else
throw Malformed(s)
-- Composed: the row unions to {NotFound, Malformed}.
fn port_of(cfg, key) : Int ! {NotFound, Malformed} =
parse_port(find_port(cfg, key))
fn describe(cfg, key) =
try
show(port_of(cfg, key))
catch
NotFound(k) => "no such key: {k}"
Malformed(s) => "bad config: {s}"
fn main() =
let cfg = [("http", "8080"), ("https", "443"), ("smtp", "oops")]
println(describe(cfg, "http"))
let port =
try
try port_of(cfg, "gopher") catch { Malformed(_s) => 0 }
catch
NotFound(_k) => 80
println("default {show(port)}")
println(describe(cfg, "smtp"))
println(describe(cfg, "https"))
println(describe(cfg, "tls"))
Stacks of failure modes. Because each error is an ordinary row label, a row alias (composing rows) names a set of failure modes: alias ConfigErr = {NotFound, Malformed} states a subsystem’s failure vocabulary once, and a layer above extends it structurally, alias AppErr = {ConfigErr, NetErr}, with no umbrella type and no wrapping. A signature : Int ! {AppErr} reads as “may fail in exactly these ways”, and because expansion flattens before checking, catch subtracts labels from the expanded set like any other handler: a partial catch over an alias discharges the modes it names and leaves the rest in the row.
-- A row alias names a stack of failure modes. ConfigErr is the failure
-- vocabulary of the config subsystem, NetErr of the transport; AppErr splices
-- both. Expansion flattens, so !{AppErr} is exactly {NotFound, Malformed,
-- Timeout}, and a partial catch subtracts labels from that set: config_safe
-- discharges ConfigErr and its row keeps only NetErr.
--
-- Expected output:
-- 8105
-- 80
error NotFound(String)
error Malformed(String)
error Timeout(Int)
alias ConfigErr = {NotFound, Malformed}
alias NetErr = {Timeout}
alias AppErr = {ConfigErr, NetErr}
fn read_port(cfg, key) : Int ! {ConfigErr} =
match cfg of
Nil => throw NotFound(key)
Cons(pair, rest) =>
let (k, v) = pair
if k == key then
if v == "8080" then
8080
else
throw Malformed(v)
else
read_port(rest, key)
fn ping(ms) : Int ! {NetErr} =
if ms > 100 then
throw Timeout(ms)
else
ms
fn boot(cfg) : Int ! {AppErr} = read_port(cfg, "http") + ping(25)
-- a partial catch: ConfigErr's labels are discharged, Timeout remains
fn config_safe(cfg) : Int ! {NetErr} =
try boot(cfg) catch { NotFound(_k) => 80, Malformed(_s) => 80 }
fn main() =
let good = [("http", "8080")]
let bad = [("http", "oops")]
let full =
try
boot(good)
catch
NotFound(_k) => 0
Malformed(_s) => 0
Timeout(_ms) => 0
println(full)
let fallback = try config_safe(bad) catch { Timeout(_ms) => 0 }
println(fallback)
These idioms span the recovery spectrum: the built-in Exn effect, raised by error(code) and uncatchable (it aborts); Result with the postfix e? propagation of the expression forms; a plain match on Ok/Err; and a custom non-resumable effect.
-- Failure, four idioms from a defaulted lookup to a custom throw, over the
-- everyday shape: reaching into chains of things that can fail.
--
-- 1. Indexing performs `Fail`, not `Option`: `users[i]` and `scores[j]` each
-- may miss, the misses flow through the chain as one row, and `?? d`
-- supplies the default at the end. TypeScript's `a?.[i] ?? d`, with the
-- partiality in the type instead of a null.
-- 2. `?` propagates over `Result`: each step unwraps `Ok` and short-circuits
-- the function on the first `Err`, exactly Rust's `?`.
-- 3. The boundary is a plain match on `Ok`/`Err`.
-- 4. A custom non-resumable effect: `abort` never resumes, the continuation is
-- discarded, and the clause's value becomes the result. Cheap throws, with
-- no Result plumbing inside the throwing code.
--
-- Expected output:
-- 71
-- 0
-- ada scored 92
-- gus has no scores
-- no user zoe
-- pass
-- invalid: negative
type User = MkUser { name: String, scores: List(Int) }
fn lookup_user(users : List(User), want : String) : Result(User, String) =
match find(\(u) -> u.name == want, users) of
Some(u) => Ok(u)
None => Err(concat("no user ", want))
fn top_score(u : User) : Result(Int, String) =
match u.scores of
Nil => Err(concat(u.name, " has no scores"))
Cons(s, _) => Ok(s)
-- Two failable steps, one `?` each: the first Err returns from `headline`
-- immediately, so the happy path reads straight down.
fn headline(users : List(User), want : String) : Result(String, String) =
let u = lookup_user(users, want)?
let s = top_score(u)?
Ok(concat(u.name, concat(" scored ", show_int(s))))
fn report(r : Result(String, String)) =
match r of
Ok(line) => println(line)
Err(e) => println(e)
effect Abort
abort(String) : Unit
fn grade(n : Int) : String ! {Abort} =
if n < 0 then
abort("negative")
if n > 100 then
abort("too big")
if n >= 60 then
"pass"
else
"fail"
fn safe_grade(n : Int) : String =
handle grade(n) with
never abort(msg) => concat("invalid: ", msg)
return r => r
fn main() =
let users = [
MkUser { name = "ada", scores = [92, 71] },
MkUser { name = "gus", scores = [] },
]
-- 1: a chain of failable lookups, defaulted once at the end. The second
-- line misses at the first hop and the whole chain yields the default.
println((users[0].scores)[1] ?? 0)
println((users[5].scores)[0] ?? 0)
-- 2 and 3: `?`-propagated Results, matched once at the boundary.
report(headline(users, "ada"))
report(headline(users, "gus"))
report(headline(users, "zoe"))
-- 4: the custom throw.
println(safe_grade(72))
println(safe_grade(0 - 5))
The failure axis. Beyond named errors, Prism has an anonymous, recoverable fail(), the deterministic-functional-logic failure of the Verse calculus (Augustsson et al., 2023). guard(b) fails when b is false; a ?? b runs a under a failure handler and falls back to b; e?.field chains through options, failing on None; optional/succeeds/default reify a failing computation as an Option, a Bool, or a default; and a comprehension guard may itself fail, pruning the element (expressions). transact body else fallback snapshots every live var, runs the body under a failure handler, and restores the snapshots on failure, so an aborted attempt leaves observable state unchanged. The whole axis is never handlers over a Fail effect, so an unhandled fail() is the ordinary unhandled-effect error, and “failable only in a failure context” falls out of the row discipline for free.18
-- Transactional rollback. A purchase that fails on insufficient funds leaves
-- balance and stock untouched, as if it never happened. The first buy succeeds.
-- The second overspends, so `guard` fails inside the `transact` and both vars
-- roll back to their pre-attempt values.
fn main() =
var balance := 100
var stock := 5
let r1 =
transact
balance -= 40
stock -= 1
guard(balance >= 0)
1
else
0
println(balance)
println(stock)
println(r1)
let r2 =
transact
balance -= 90
stock -= 1
guard(balance >= 0)
1
else
0
println(balance)
println(stock)
println(r2)
Partiality is in the row, not the name. ML libraries such as OCaml’s Base and Core suffix a partial function with _exn (List.hd_exn) so a reader knows it may raise, a naming convention standing in for what the type itself cannot say. Prism needs no such convention: a function that may fail carries that in its effect row, whether as the anonymous Fail above or a named error, so the possibility of failure is written into the signature and the row discipline forces it to be handled before the result is used. The _exn suffix is the workaround for a type system that cannot express failure; the row is the version the compiler checks.
7.8 Composing Rows
A row alias composes rows the way + composes sums. With AB = {A, B} and CD = {C, D}, the row {AB, CD, E} assembles five effects from two named pairs and a fifth label: (A + B) + (C + D) + E. Because a row is an unordered set (subsumption and row equivalence) and an alias expands transparently before checking, the sum flattens: any grouping and any order of the same five labels is the same row, so omega and flat below are interchangeable, and a grouping is chosen for the reader, not for the checker. An alias may reference other aliases (a cycle is an error at the declarations involved), and takes no parameters.
-- Rows compose like sums. AB and CD each name a pair of effects; App splices
-- both and adds a fifth label. Aliases expand and flatten before checking, so
-- {AB, CD, E} IS {A, B, C, D, E}: omega and flat have the same type, and the
-- grouping is documentation that leaves no trace in the semantics.
effect A
alpha() : Unit
effect B
beta() : Unit
effect C
gamma() : Unit
effect D
delta() : Unit
effect E
epsilon() : Unit
alias AB = {A, B}
alias CD = {C, D}
alias App = {AB, CD, E}
fn phi() : Unit ! {AB} =
alpha()
beta()
fn psi() : Unit ! {CD} =
gamma()
delta()
fn omega() : Unit ! {App} =
phi()
psi()
epsilon()
-- the flattened spelling of the same row: either may call the other
fn flat() : Unit ! {A, B, C, D, E} = omega()
This is the row discipline’s answer to the monad-transformer stack. A transformer application fixes one composite type, ReaderT Config (WriterT Log (Except E)), and pays for it twice: every layer’s operations are lifted through the layers above (or a class such as MonadWriter is threaded through, at a quadratic cost in instances), and the order of wrapping is welded into every signature even where no code depends on it. An alias instead makes the application row a name for a set, Ctx = {Ask, Tell} and App = {Ctx, Invalid} below. An operation reaches its handler by label, never by position, so there is no lift; a function that uses only Tell states !{Tell} and slots unchanged into App or any other row containing it; and two subsystems’ aliases union structurally, with no adapter between their stack and ours.
What a transformer stack fixes in the type, the handler site decides per call (the layering point already made for {State, Fail} under higher-kinded classes). Discharged one label at a time with the scoped with handler layers of named handlers, the run function reads like the transformer stack it replaces, except that the order is chosen where the handlers install, free to differ between call sites without a signature changing. The application monad becomes the application row: a name for what may happen, not a recipe for how it is wrapped.
-- An application row in place of a transformer stack. Ask is the reader layer,
-- Tell the writer, Invalid the exception; Ctx names the ambient pair and App
-- adds failure. A function states the part of the stack it uses (greet needs
-- only Tell) and slots into any row that contains it, with no lifting. The
-- run function is a flat sequence of scoped handlers, one per label, in an
-- order chosen here rather than fixed by a type.
--
-- Expected output:
-- hello
-- step 3
-- 7
-- hello
-- step 99
-- 0
effect Ask
ask() : Int
effect Tell
tell(String) : Unit
error Invalid(Int)
alias Ctx = {Ask, Tell}
alias App = {Ctx, Invalid}
fn greet() : Unit ! {Tell} = tell("hello")
fn step(n) : Int ! {App} =
greet()
let limit = ask()
tell("step {n}")
if n > limit then
throw Invalid(n)
else
limit - n
fn run_app(n : Int) : Int ! {IO} =
with handler
once ask() => 10
with handler
once tell(m) => println(m)
try step(n) catch { Invalid(_bad) => 0 }
fn main() =
println(run_app(3))
println(run_app(99))
7.9 Effect Polymorphism
A function can be generic over the effects of a thunk it is given by quantifying over a row variable in the argument’s type. Below, twice accepts any (Unit) -> Int thunk and adds an open row {| e} for whatever that thunk performs; each call unifies e with the actual row (empty, {Tick}, or {Say}), and a handler discharges only the label it names, leaving the rest in e. This is the mechanism the prelude’s fmap and traverse use to thread a per-element effect (higher-kinded classes), so an effectful traversal needs no Applicative wrapper.
The same row variable also governs an effect operation whose argument is a computation. An operation such as concurrency’s fork(() -> a ! {Async(a) | e}) shares the ambient row for e: performing it ties the argument’s row to the caller’s own, so a forked or deferred computation may perform only effects the caller already admits, and those effects flow out to whoever handles the operation rather than escaping it (the discipline of Koka, Frank, and Links; Leijen, 2017). Combined with a Row-kinded parameter (kinds) that stores the reified continuations, this is what makes a handler like run_async both effect-polymorphic and sound: it is written once for any row e the fibers perform, and a fiber cannot smuggle past it an effect that no outer handler was required to discharge.
The quantifier’s scope is enforced in the other direction too. A row bound by an inner forall is rigid and dies with its binder, so a row introduced outside that forall may never be solved to it: a closure whose body’s effects could only be satisfied by pinning an enclosing row onto the bound variable is rejected with an error naming the capture, the row analogue of a skolem-escape error, rather than accepted with a solution that outlives its scope.
effect Tick
tick() : Int
effect Say
say(Int) : Unit
-- Effect polymorphism: one higher-order function, any effect row.
--
-- twice is written once, with a row variable e for whatever its argument
-- performs. Each call site unifies e with that thunk's row: {} for the pure
-- thunk, {Tick} and {Say} for the effectful ones. A handler discharges only
-- the label it names. e carries the rest along.
fn twice(f : (Unit) -> Int ! {| e}) = f(()) + f(())
-- e = {}: pure code needs no handler at all.
fn pure_use() = twice(\(_u) -> 21)
-- e = {Tick}: a counting handler hands out 1 then 2, so the same thunk
-- answers differently per force.
fn tick_use() =
let g =
handle twice(\(_u) -> tick()) with
tick() resume k => \(n) -> k(n)(n + 1)
return r => \(_n) -> r
g(1)
-- e = {Say}: each force shouts before answering.
fn say_use() =
handle twice(\(_u) -> let _ = say(9) in 9) with
say(m) resume k =>
println(m)
k(())
return r => r
fn main() =
println(pure_use())
println(tick_use())
println(say_use())
7.10 Coeffects
Prism has two static axes that deliberately do not collapse into one row. The effect row records what a computation may do to the world: perform Console, FileSystem, Async, Clock, Fail, a user effect, and so on. Usage and resource annotations record how a value, call tree, or continuation may be used. They are coeffects, the dual of effects: an effect flows outward from the computation and is discharged by a handler around it, while a coeffect flows inward from the context and is discharged by the boundary that consumes the value, so one tracks what the program does to its world and the other what the world may do with the program’s values. The user model is one sentence: ! says what happens; @ says how a value may be used.
Think of a bottle of prescription medicine. The effect row is the side-effects leaflet: take this and it may cause drowsiness, print to the console, or talk to the filesystem; whoever administers it (the handler) decides what to do about that. The usage row is the dosage instructions on the label: take at most once (@ once), do not share (@ noescape), keep refrigerated (@ local), safe to travel with (@ portable). The leaflet describes what the pill does to you; the label restricts what you may do with the pill. A pharmacist who ignores the leaflet has a surprised patient; one who ignores the label has a lawsuit.
Usage rows. A usage row attaches usage facts to a type with a postfix @, mirroring how ! attaches an effect row to a function type:
buf : Buffer @ unique
fn spawn(f : (() -> a ! e) @ {once, portable}) : Fiber(a) ! {Async(a), e}
The row attaches to an atomic type: a constructor, an application, a tuple, or a parenthesized type. A function type must be parenthesized to take a row; writing one after an effect row is refused with the fix spelled out (parenthesize the function type before '@') rather than silently picking a precedence. A single fact may drop the braces (T @ unique); the formatter canonicalizes a one-fact row to that form. A row is a set: duplicate facts and two facts from one exclusive axis (@ {once, many}) are errors, the empty row is an error, and the canonical order is alphabetical, so a row’s spelling, its formatted output, and its contribution to a definition’s content hash never depend on the order the author wrote. The open-tailed form @ {fact | u} is reserved for usage-row polymorphism and rejected by name.
The reserved vocabulary is fixed, and an unknown word in usage position is a hard error, never a warning, so no program or package can establish a private meaning for a fact before its checker exists. The facts are not a flat list: each belongs to one semantic axis, and the axis determines how its facts combine in a row and which side of an API seam owes the proof:
| Axis | Facts | In one row | Polarity |
|---|---|---|---|
| Allocation | noalloc | single | past |
| Fip | linear, bounded_stack | compose | past |
| Multiplicity | once, many | exclusive | future |
| Aliasing | unique, aliased | exclusive | past |
| Escape | local, noescape | exclusive | future |
| Mobility | portable | single | past |
An exclusive axis is a choice of one point, which is why @ {once, many} is rejected as a contradiction at parse. Only the fip axis composes, because its facts are cumulative strengthenings of one certificate rather than alternatives. Polarity is the axis’s variance discipline, the direction its proof obligation flows. A past fact is covariant: it records how a value was built, the producer proves it, and the fact travels with the value wherever it goes. A future fact is contravariant: it restricts what may still be done with the value, the consumer promises it, and the fact binds at the use site. The polarity is stated by proof obligation, deciding which side of an API seam owes the evidence when a fact is checked.19
The multiplicity axis already has a checked instance elsewhere in the language, applied to a continuation rather than a value: an operation’s grade (effects and handlers) is never, once, or many, the same words on the same lattice, restricting how a handler clause may resume the captured continuation k. The grade on an operation and the multiplicity fact on a closure are the same point on the same axis, read at two boundaries: the operation form is checked on a continuation and pins once to exactly one resumption in tail position, while the value form is affine, at most one use of the annotated closure. It adds one point the value facts omit, never (the continuation is dropped), because a value used zero times is not a tracked usage fact but a clause that never resumes is a real, useful grade. That shared vocabulary is not a coincidence of spelling: the continuation an operation hands its handler is the first value in the language to carry a coeffect, which is what makes “an effect is just a coeffect on its own continuation” (three posets) a literal statement rather than a slogan.
The facts themselves:
| Fact | Axis | Meaning | Status |
|---|---|---|---|
noalloc | Allocation | the result is computed without allocating a fresh heap cell, whole call tree | checked |
linear | Fip | no duplication of owned heap inputs (the fip family) | reserved |
bounded_stack | Fip | bounded stack usage (the strict fip promise) | reserved |
once | Multiplicity | consumed or called at most once | checked |
many | Multiplicity | may be consumed or called many times | reserved |
unique | Aliasing | statically unaliased ownership | reserved |
aliased | Aliasing | explicitly shared, non-unique | reserved |
local | Escape | tied to the current dynamic scope or region | reserved |
noescape | Escape | cannot be stored, returned, or captured past the boundary | checked |
portable | Mobility | may cross a mobility/replay/receiver boundary | checked |
The checked facts are noalloc, once, portable, and noescape. Other reserved facts are rejected.
Boundary facts, not ambient modes. This design space ranges from ambient classifications carried by every value to explicit claims checked only where needed. Prism chooses the latter: @ once constrains one consumer, @ portable one crossing, and @ noalloc one call tree; unannotated values carry no mode vector.
Operation grades are the established instance of this design. never < once < many constrains one handler boundary, survives desugaring as typed data, and is consumed directly by lowering.
The wider family reads as one story. borrow lets a function read an argument without taking ownership. fip certifies allocation-freedom, linear consumption of owned heap values, and bounded stack for the recursive group. fbip keeps the allocation-free call-tree certificate without the full linear and bounded-stack promise. @ noalloc is the allocation certificate alone. Operation grades classify continuation use in handlers.
This split matters. A function may be @ noalloc and still perform IO; the row says the output effect is observable, while the allocation certificate says the call tree does not allocate fresh cells.
Two mechanisms, one vocabulary. Allocation can be forbidden: @ noalloc (allocation certificates) proves a call tree allocates no fresh cell. It can also be avoided: an unboxed representation stores a value inline, so no heap cell is created. The certificate establishes whether allocation happens, while the representation determines whether a cell is needed. The Arena library expresses allocator selection as a handled Alloc effect rather than a surface storage class; it changes the allocation path without changing the certificate vocabulary.
Checked closure contracts. Three usage facts are checked closure contracts. @ once on a function-typed parameter admits a value used at most once20: a @ many value fits a @ once slot but never the reverse, and using the parameter twice, aliasing it through a let, or capturing it under a lambda counts as further use and is rejected (E6059). @ portable admits a closure that captures only what travels to a fresh runtime: a content-addressed top-level function or constructor, another portable parameter, or portable scalar data; a captured local closure, var cell, or handler operation is rejected by name (E6060). @ {once, portable} requires both at once. @ noescape, written on a function domain ((Builder @ noescape) -> a), promises the callback’s argument does not outlive the call: a token that is returned, embedded in returned data, aliased out, or captured by another closure is rejected (E6061), and the callback must be a checkable form, a closure literal, top-level function, or same-contract relay (E6062). Every fact is erased before the core, so an accepted program is byte-identical on both backends: the contract governs what the compiler accepts, never what a passing program does.
teleport(f : (() -> a) @ {once, portable}) : a (the Teleport module) is the checked mobility boundary built from those facts: its parameter type makes each call prove the closure captures only content-addressed code and portable data and runs at most once, so the computation is safe to move to a fresh runtime. Placement is unobservable in exactly the way tier and backend choice are, so running a teleported closure is observationally identical to calling it directly; the boundary changes what is accepted, not what happens.
import Teleport (..)
fn classify(seed : Int) : Int = seed * 2
fn run_once(f : (() -> Int) @ once) : Int = f()
fn main() =
println(run_once(\() -> 1))
-- The closure captures only a top-level function: portable, single use.
println(teleport(\() -> classify(21)))
7.11 Structured Concurrency and Cancellation
The Concurrent library builds cooperative concurrency and cancellation on the Async operations above, and their contract is stated here as observable behavior rather than as a property of one lowering tier. A scope(tasks) is a structured join: it forks a list of fibers and awaits them all on a successful run. It is not a failure-isolation boundary or a distinct internal nursery protocol. The scheduler tracks fork parentage globally so cancellation reaches everything a target forked.
Cancellation is a cooperative unwind, not an abrupt drop. cancel(f) marks the fiber f and all of its descendants; each stops at its next suspension point (a yield, an await, a channel operation) rather than mid-step, and then unwinds through the cancellation handlers it has already entered. A cancellation cleanup is installed with on_cancel(cleanup, body): if cancellation crosses that handler, cleanup runs exactly once before cancellation continues outward; a normally returning body does not run it. Nested on_cancel cleanups run innermost first, the same order a stack of never handlers unwinds (clause sugar). The cleanup executes outside the handler clause it is finalizing, but the scheduler marks the fiber as unwinding: repeated cancellation is masked, so cleanup may suspend and resume normally rather than being stopped a second time. A child forked during cleanup is immediately marked for cancellation and cannot escape the unwind. Cancelling a fiber before it starts does not enter its body and therefore installs or runs none of that body’s cleanups. Cancelling a completed leaf changes nothing, but cancelling a completed parent still reaches any live descendants retained in the fork tree.
Waiting on a fiber that may be cancelled is a join. try_await(f) returns an Outcome(a) = Completed(a) | Was_Cancelled, Completed(v) when f produced v and Was_Cancelled only after the cancelled fiber’s unwind and installed cleanups have completed, where a bare await would have no value to yield. A cancellation request and a completed cancellation are therefore distinct scheduler states; observing the request alone is not enough to return from try_await. If a cleanup itself performs an unhandled fail(), the cancellation never enters the completed set and try_await returns no Outcome: the scheduler aborts instead. A cleanup parked with no runnable producer reaches the scheduler’s deterministic no-progress failure; a cleanup that continues to produce work forever may diverge like any other program.
Unhandled fiber failure is scheduler-global. If any fiber reaches an unhandled fail() (errors and failure), run_async or run_lifo cancels every other live fiber and all of their descendants, including fibers created outside the scope whose task happened to fail. Runnable cancellation cleanups drain, then the failure re-emerges at the scheduler boundary. scope neither catches nor localizes it; it is only the structured success-path join. The failure therefore remains in the residual row of the run: run_async : (() -> a ! {Async(a) | e}) -> a ! {e} discharges Async, but a fallible fiber forces Fail into e, and a caller handles that failure outside run_async/run_lifo.
Cooperative cancellation is source-driven scheduler behavior, not an observation of the outside world: cancel is an ordinary Async operation and the chosen deterministic scheduler policy orders its consequences. It therefore adds no capability or replay-trace event. A future timer, OS signal, or preemptive cancellation source would be an explicit external capability and would require its own recorded event; none exists in this cooperative contract.
7.12 Capability Effects and IO
Reading the outside world is itself effectful, and the row records which part of the world a function reads. The nondeterministic input operations are four capability effects:
| Effect | Operations |
|---|---|
Console | read_int, read_line |
FileSystem | read_file, file_exists |
Random | rand |
Env | getenv, args_count, arg |
A function that reads input names exactly that capability in its row: a function calling read_int carries ! {Console}, not a blanket ! {IO}, so the row says which part of the world is read rather than merely that some IO happens.21 (Console, FileSystem, Random, and Env are therefore reserved effect names, among the keywords. The Concurrent library adds a fifth capability, Clock, described below. Preempt is also reserved, but the cooperative scheduler does not handle it: user declarations are rejected, and the existing row check classifies it outside the replayable capability set.)
The surface is unchanged: read_int(), read_file(p), getenv(s), and friends stay ordinary calls, defined in the prelude as thin wrappers that perform the corresponding capability operation. A default run_io world handler is wrapped around main on demand, only when main reaches a capability, and discharges each operation by performing the real input and resuming with the result, so the capabilities collapse to ! {IO} at the program boundary. The handler is tail-resumptive, so it fuses to a direct call at no cost (effect lowering). Output stays an opaque IO effect: print, write_file, append_file, and remove_file carry ! {IO} and are not capability operations, because record and replay needs only inputs pinned. Binary file IO sits on the same split: read_bytes(p) is a FileSystem capability that reads a file as raw Bytes and is recorded like any other input, its own operation rather than a detour through read_file (routing bytes through a String would corrupt them at the first non-UTF-8 byte), while write_bytes(p, bs) is an IO output returning a Result.
Below, roll performs Random alone, user performs Env alone, and summary carries the structural union ! {Env, Random} of what it calls; the capabilities collapse to ! {IO} only at main, where run_io discharges them.
-- Capability effects, the parts a browser can serve. `Random` is a deterministic
-- SplitMix64 stream (pure arithmetic, identical to the native oracle) and `Env`
-- reads the environment, empty in the playground, so `getenv` falls back. Each
-- function's row names the slice of the world it reads: `! {Random}` alone,
-- `! {Env}` alone, and their structural union, never a blanket `! {IO}`. (The
-- inputs a browser cannot serve, file reads and stdin, would be reported instead.)
fn roll() : Int ! {Random} = rand_below(6) + 1
fn user() : String ! {Env} =
let name = getenv("USER")
if name == "" then
"anonymous"
else
name
fn summary() : String ! {Env, Random} =
"{user()} rolled {roll()}, {roll()}, {roll()}"
fn main() = println(summary())
Because input is now an interceptable operation rather than an untracked builtin, a handler other than run_io can supply the values, which is what record/replay rests on.
Virtual Simulation Clocks
Time is a capability too. The Concurrent library’s Clock effect (now, sleep) is discharged by run_clock, which threads a pure logical counter: now() reads the current tick and sleep(d) advances it. Time is therefore virtual, deterministic, and replayable, with no real clock and no time primitive.22
A fiber may perform Clock; because the scheduler does not handle it, Clock flows out of run_async to an enclosing run_clock like any other capability. The important move is routing now, sleep, and timeouts through an ambient time capability rather than the wall clock. A test advances a virtual clock, scheduling becomes a pure function of it, and the cooperative-deterministic story is testable rather than merely asserted.
Treating time as one capability among Console, FileSystem, Random, and Env, discharged by a handler you can swap for a real-time one, is the same move applied to the clock. The Concurrent reference has the library details.
The example below is the whole discipline on one page. Two fibers sleep and read now under run_clock, which is installed outside run_async; because the scheduler is generic in its residual row, Clock tunnels through it to the clock handler, and logical time is the running sum of the sleeps, identical on every run with no real time elapsing.
-- A logical clock as an ordinary effect: deterministic virtual time, no OS clock.
--
-- `Clock` is an algebraic effect with two operations, `now` and `sleep`. Nothing
-- here reads the operating system. `run_clock` interprets the effect by threading a
-- single integer -- the current logical tick -- through the computation: it is a
-- parameter-passing handler that reifies the program into a function `Int -> a`,
-- where `sleep(d)` resumes with the tick advanced by `d`, `now()` resumes with the
-- tick unchanged, and the whole thing is seeded at 0.
--
-- The clock is installed *outside* `run_async`. The scheduler handles `Async`
-- (fork/await/...) but not `Clock`, so every `sleep`/`now` a fiber performs tunnels
-- out through the scheduler to `run_clock`. That pass-through is the row-kinded
-- effect polymorphism at work: `run_async` is generic in the residual row `e`, so
-- `Clock` flows through it untouched rather than being trapped or forcing the
-- scheduler to know anything about time.
--
-- One tick is threaded across the entire cooperative run, so logical time is the
-- running sum of the sleeps in schedule order: A sleeps 3 (0 -> 3), then B sleeps 4
-- (3 -> 7), so B reads t=7, not t=4. `sleep` advances the shared clock but does not
-- itself reschedule, so the result is a pure function of the sleeps -- identical on
-- every run, with no real time elapsing. Swap `run_clock` for a handler that reads
-- the OS clock and the same program runs in real time, unchanged: time is a
-- capability chosen at the edge, not a primitive baked into the language.
--
-- Expected output:
-- A woke at t=3
-- B woke at t=7
-- total 10
import Concurrent (..)
fn worker(name : String, d : Int) : Int ! {Clock, IO} =
sleep(d)
let t = now()
println("{name} woke at t={t}")
t
fn scene() : Int ! {Async(Int), Clock, IO} =
let a = fork(\() -> worker("A", 3))
let b = fork(\() -> worker("B", 4))
await(a) + await(b)
fn main() = println("total {run_clock(\() -> run_async(scene))}")
7.13 Capability-Based Sandboxing
Because a function’s row records exactly which capabilities it exercises and a handler is what discharges a capability, a handle block that installs a restricted set of handlers is a sandbox: a sub-computation it runs can perform only the operations those handlers answer. A function given no Async handler in scope cannot spawn a fiber; a function whose row lacks FileSystem cannot read a file; a computation run under a world handler that stubs read_file to a fixed value cannot reach the real filesystem no matter what it calls, because the only interpreter for that operation in scope is the stub.
Anything the sandbox does not discharge is not ambient background authority it might reach anyway, it is a label left in the row that some enclosing handler must still answer, and if none does the program does not type. This is object-capability security recovered from the effect row at no additional cost: authority is precisely the set of handlers in scope, it is delegated by passing a thunk into a handler rather than by granting an ambient permission, and it is attenuated by nesting a sub-computation inside a narrower handler that intercepts or denies operations before any outer one sees them.
Concurrency is one capability among the rest rather than a privileged subsystem, so the same handle that sandboxes IO sandboxes spawning: a scheduler is just the handler that answers Async, and code with no such handler in scope is sequential by construction. The mechanism is exactly the effect handlers already described (capability effects, effect polymorphism); this section only names the security reading that the rows already justify.
Below, untrusted reads files, but sandbox discharges its FileSystem capability with stub handlers, so it cannot reach the real filesystem however it branches; sandbox stays polymorphic in the other effects e, constraining only the one capability it names.
-- Capability-based sandboxing: a restricted handler set IS a sandbox.
--
-- `untrusted` reads files: it performs the `FileSystem` capability but cannot
-- discharge it itself, so its row demands a handler. `sandbox` runs it under a
-- handler that answers `FileSystem` with stubs (`read_file` returns "<denied>",
-- `file_exists` returns false, `read_bytes` returns no bytes), so the
-- sub-computation never reaches the real filesystem no matter which files it
-- names or how it branches: the only interpreter for those operations in scope
-- is the stub. Authority is exactly the handlers installed, and `sandbox`
-- grants none for the real world. The compiler enforces that the stub set is
-- complete: a handler must implement every operation of the effect it handles,
-- so a capability op added later cannot silently tunnel past the sandbox to a
-- real handler outside it.
--
-- `sandbox` is polymorphic in the fiber's other effects `e` (row-kinded), so it
-- constrains `FileSystem` and lets everything else flow out unchanged. This is
-- object-capability security read straight off the effect row.
--
-- Expected output:
-- sandboxed read: <denied>
fn untrusted() : String ! {FileSystem} =
if file_exists("/etc/passwd") then
read_file("/etc/passwd")
else
read_file("/etc/secret")
fn sandbox(action : () -> a ! {FileSystem | e}) : a =
handle action() with
fs_read_file(path) resume k => k("<denied>")
fs_read_bytes(path) resume k => k(buf_empty())
fs_file_exists(path) resume k => k(false)
return r => r
fn main() = println("sandboxed read: {sandbox(untrusted)}")
7.14 Record and Replay
A program that reads stdin, files, randomness, or the environment takes a different path each time the world answers differently, which is what makes such a run hard to reproduce. Record and replay captures one run as a trace and re-runs it deterministically: an interactive session becomes a fixed regression test, a failing run becomes a reproducible bug report that needs none of the original environment, and a program can be re-executed offline against the captured trace rather than the live world. Persisting that trace to a log as it is produced turns replay into durable execution: the module’s durable handler reloads the logged prefix on restart and continues live once it is exhausted, so a crashed run resumes where it stopped rather than starting over. A suspended computation is likewise a value that can be persisted and resumed after a crash; the next section specifies that runtime boundary.
The Replay stdlib module (import Replay) turns a program’s interaction with the world into a recordable, replayable trace over the capability effects. record(action) runs action against the real world, logging every Console/FileSystem/Random/Env observation into an opaque Trace and returning (result, trace). replay(trace, action) re-runs the same action performing no real input, discharging each operation from the recorded trace instead; a wrong-variant or exhausted trace is a fail() (errors and failure). Replaying a recorded trace reproduces the original result, because the effect-erased core is deterministic and the trace pins every input.
A replayable function annotation, in the family of fip/fbip but orthogonal to them (replayable fn and replayable fip fn are both valid), certifies that a function is reproducible from a recorded trace. It is accepted only when the inferred effect row stays within {Console, FileSystem, Random, Env, Exn, Fail}, the recordable capabilities plus the deterministic builtin effects. A row containing IO (un-logged nondeterminism: output, the system clock, srand) or any user-defined effect is rejected with a caret diagnostic naming the offending effects. The check is a row-subset test on the already-inferred row, so it costs nothing beyond inference.
The two pieces fit together in a few lines: roll is replayable because it reads only Random, and recording one run then replaying its trace reproduces the result without drawing real randomness the second time.
import Replay (..)
-- `roll` reads only the Random capability, so it is `replayable`: its result is
-- reproducible from a recorded trace.
replayable fn roll() = rand() % 6 + rand() % 6 + 2
fn main() =
-- Record one real run, logging every draw into the trace.
let pair = record(\(_u) -> roll())
match pair of
(recorded, trace) =>
-- Replay the trace: no real randomness is drawn, yet the result matches.
let again = replay(trace, \(_u) -> roll())
if recorded == again then
println("reproduced")
else
println("diverged")
durable(path, action) persists the trace as each observation is made, so a run that stops partway resumes on re-run: the logged prefix replays performing no real input, then the run continues live once the log is exhausted. Re-running this workflow reaches the same result rather than redrawing its inputs.
import Replay (..)
-- A workflow whose every input is logged to the path as it runs. If the program
-- crashes partway, re-running it replays the logged prefix without redoing that
-- work and continues live from where it stopped.
replayable fn workflow() = rand() + rand()
fn main() =
let total = durable("target/workflow.log", \(_u) -> workflow())
println("total: {total}")
7.15 Lineage
Record and replay pins a run; lineage explains one. A run recorded with a --lineage sidecar carries, beside the replay trace, a typed account of everything that produced its output, so an artifact can be asked why it exists after the source, inputs, and environment are gone. prism run p.pr --record run.replay --lineage run.plineage -- args writes both: the .replay trace (record and replay) and a .plineage sidecar. --lineage requires --record, because the sidecar names the trace it explains.
The sidecar names the source, Std, and package roots (content-addressed, content-addressed core); the full compiler identity (version, hash scheme, target, backend, optimizer surface, and every behavior-affecting flag); the invocation’s argv; each environment read; each input file by content digest and byte length; any file the run wrote; the stdout digest; and the replay trace digest, recorded as a relation so verification reads the graph rather than a filesystem convention. It records observations of the world, not the world: an input file is named by the hash of the bytes read, never by trusting the file still on disk.23
Four verbs read a sidecar. Because every fact lives inside it, show and why still answer after the source, inputs, and environment are gone.
| Verb | What it does |
|---|---|
prism lineage show SIDECAR | Renders the why-style explanation of the whole run. |
prism lineage why SIDECAR OUTPUT | Walks one output backward through the request, its inputs, the trace, and the compiler identity. |
prism lineage verify SIDECAR | Rehashes what the sidecar recorded and confirms it still matches; --replay verifies the stronger way, re-running the trace and re-checking the result rather than trusting the sidecar’s own numbers. |
prism diff SIDECAR SIDECAR | Reports, by logical key, which digests were preserved, moved, added, or removed, exiting nonzero when anything moved. |
The change-one-input workflow reads directly. The program under observation reads one input file and prints one line:
-- Greet whoever name.txt names. The fallback keeps the program runnable
-- before the input file exists, so the same source serves both runs of the
-- change-one-input lineage workflow.
fn main() =
let name =
if file_exists("name.txt") then
read_file("name.txt")
else
"ada"
println("hello {name}")
Record it twice, changing only the input file in between, and ask what moved:
$ printf ada > name.txt
$ prism run greet.pr --record run.replay --lineage run.plineage
hello ada
recorded 4 observations to run.replay and run lineage to run.plineage
$ printf grace > name.txt
$ prism run greet.pr --record run2.replay --lineage run2.plineage
hello grace
recorded 4 observations to run2.replay and run lineage to run2.plineage
$ prism diff run.plineage run2.plineage
lineage diff: 3 moved, 0 added, 0 removed, 5 preserved
moved trace: sha256:f8e63490265d... -> sha256:46f3e178a163...
moved stdout: stdout:sha256:e27f6e52492b... -> stdout:sha256:9b915ac89684...
moved input-file name.txt: input-file:sha256:fdee430d40bd... -> input-file:sha256:e010fd1ce1ac...
same request: sha256:4ad56c808cb9...
same source-root: prism-core-hash-v1:f8b5f50c4578...
same stdlib-root: prism-core-hash-v1:ac8a7aa43202...
same compiler: sha256:ab4bbf1853f2...
same argv: sha256:5feceb66ffc8...
The source root and compiler identity held; the changed input, the trace it drove, and the stdout it produced all moved. prism lineage verify run.plineage --replay confirms the first run still reproduces exactly, provided its input files are unchanged on disk.
A passed verification can be persisted. prism lineage verify SIDECAR --certify out.cert mints a digest-named certificate over the sidecar it verified, its claim being replay-verified under --replay or lineage-verified otherwise, riding the store’s existing certificate discipline (parity certificates). prism lineage check-cert out.cert SIDECAR checks a certificate against the sidecar it names; a certificate whose subject digest does not match the sidecar is rejected, and a certificate carrying a claim the reader does not recognize is rejected rather than trusted, so no unknown assertion is ever silently honored.
Two further surfaces share the same lineage graph, detailed in the compiler chapter. prism docs writes a manifest of what it documented, and prism docs --verify-manifest rejects a stale page or a drifted root. prism pkg check-world reports per-package gates over a package universe, each gate either passing or honestly marked not-run, and against a baseline names exactly which public definitions changed behavior, by digest.
7.16 Streams
Streams are the prelude’s data-processing combinators, built on a single Emit(a) effect rather than on intermediate collections. A producer performs Emit once per element (srange, sof); a transformer handles a producer’s emissions and re-emits the survivors (smap, skeep, stake); and a consumer handles Emit by folding every emission into a result (sfold, ssum, scollect). A pipeline is the consumer wrapped around the transformers wrapped around the producer, one handler stack over one producer loop.
Because emission is an effect the consumer discharges, a pipeline fuses: srange(1, 1000).smap(square).skeep(even).stake(5).ssum() runs as one loop that allocates neither an intermediate list nor a cell per element, the state-threading path of effect lowering. A transformer that stops early, like stake, drops the producer’s continuation, so the source halts at once. Comprehensions and the statement for desugar to these combinators (comprehensions) and fuse the same way.
The push model above fuses but is single-source: a consumer drives one producer. For the combinators that need to advance two sources in step, zip, interleave, window, the Sequence module (import Sequence as Seq) offers the dual, a pull sequence built on an explicit step co-structure Step(a) = SDone | SMore(a, () -> Step(a)) where a sequence is a thunk the consumer pulls one element at a time. It carries the full combinator vocabulary (map, filter, take, flat_map, zip_with, scan, chunk, and the rest) over a value the caller holds and passes around, which the effect-emission producer, being a running loop rather than a value, cannot be. The two are complementary: reach for the fusing prelude streams when one pipeline consumes one source, and for Sequence when a sequence must be named, stored, or advanced alongside another.
-- Streams as effects: a stream is a producer performing Emit(a), the
-- transformers smap/skeep/stake are handlers that re-emit, and the consumers
-- ssum/scollect/for are handlers that fold. A dot chain nests the handlers over
-- one producer, with no intermediate lists. stake stops the source early by
-- dropping its continuation. Expected output:
--
-- 220
-- lo
-- hi
-- 0
-- 3
-- 6
fn square(n) = n * n
fn main() =
println(srange(1, 1000).smap(square).skeep(even).stake(5).ssum())
for w in sof(["lo", "hi"]) do
println((w : String))
let xs = srange(0, 1000000).smap(\(n) -> n * 3).stake(3).scollect()
for x in sof(xs) do
println((x : Int))
7.17 Incremental Computation
The Incr stdlib module (import Incr) is self-adjusting computation as a handler: a program builds a demand graph of source nodes and derivations, and re-reading the graph after a change recomputes only the part a change can reach. input(v) creates a mutable source, get(n) reads a node (recording the read as a dependency of whatever derivation is running), set(n, v) updates a source, and memo(thunk) wraps a derivation whose value is cached and re-demanded rather than recomputed blindly. run_incr(action) discharges the effect, running action as the root observer of a fresh graph; the ambient row of effects the derivations perform flows out unchanged, exactly as run_async passes a fiber’s row through.
The contract that makes it incremental is early cutoff: after a set, re-reading a node re-demands exactly the affected cone, and a derivation whose recomputed value is unchanged does not disturb its dependents. “Unchanged” is an exact content-hash comparison over the serialized value, the same blake3 digest that content-addresses code (content-addressed core), not a user-written equality, so a derivation that recomputes to the same answer halts propagation with no dirty-bit bookkeeping, and a set to a value a source already holds is a no-op.
run_incr_durable(path, tag, action) persists the memo table to a snapshot so a later run warms from it rather than recomputing from scratch. A warm run’s output is byte-identical to a cold one, and a missing, corrupt, or foreign-tagged snapshot silently cold-starts rather than yielding a wrong answer, so the snapshot changes only cost, never result. Because warming a derivation skips its thunk, a durable derivation must be pure up to Fail (a thunk that printed or drew randomness would change the output if skipped), and only the derivations built before the first input-dependent read are warmed.
run_incr_durable_replay(path, tag, action) lifts the purity restriction for the one effect a skipped thunk can still honor: output. It records each memo’s emitted output beside its cached result and replays that output on a warm hit, so a derivation that prints when it fires is warmed from the snapshot without running its thunk yet reproduces the recorded lines byte-for-byte. A second run therefore fires no memo, does no work, and still prints exactly what the first run printed, effects and all, extending the “snapshot changes cost, never result” guarantee to effectful memos rather than only pure ones (the action’s row is ! {Incr, Output, Fail | e}).
7.18 Suspend and Resume
Record and replay reproduces a run from its start; suspend and resume is the stronger checkpoint the previous section points at, a paused computation that is itself a value. prism exec suspend FILE --at N -o snapshot.kont runs a program, pauses it after N machine steps, and writes the whole live continuation, its pending work, its call stack, and every value bound along the way, to a file as a kont envelope. prism exec resume FILE snapshot.kont reads that file and runs the continuation to completion. The suspending run’s output followed by the resuming run’s output is byte-identical to one uninterrupted run: suspend is a cut, not a change, another corollary of the determinism contract. Because a machine step is a pure state transition, a given step count pauses at a deterministic point, so a snapshot is reproducible.
fn count(i, last) =
if i > last then ()
else
println("step {i}: {i} squared is {i * i}")
count(i + 1, last)
fn main() = count(1, 6)
The recursion is an ordinary tail call carrying i forward; nothing in the program knows it can be interrupted. Where should the cut go? A step count is opaque until the program is laid out on the step clock, which is what prism exec steps does: it runs the program once and prints every observation with the machine step at which it fired. Because a step is a pure state transition, these indices are stable program points, the same on every machine and every run:
$ prism exec steps count.pr
step 1: 1 squared is 1
...
step 6: 6 squared is 36
step 68 Console.print "step 1: 1 squared is 1"
step 70 Console.newline
step 145 Console.print "step 2: 2 squared is 4"
step 147 Console.newline
step 222 Console.print "step 3: 3 squared is 9"
step 224 Console.newline
step 299 Console.print "step 4: 4 squared is 16"
...
total 482 steps, 12 observations
Pausing after the third line and before the fourth is any budget between steps 224 and 299. Suspend there and the live call (the pending count, the bound i, the frame that will print next) is written to a file; resume it elsewhere and the count continues from where it stopped, the suspend reporting exactly where on the observation timeline the cut fell:
$ prism exec suspend count.pr --at 240 -o half.kont
step 1: 1 squared is 1
step 2: 2 squared is 4
step 3: 3 squared is 9
suspended after 240 steps to half.kont (632 bytes); 6 observation(s) before the cut, last at step 224 (Console.newline)
$ prism exec resume count.pr half.kont
step 4: 4 squared is 16
step 5: 5 squared is 25
step 6: 6 squared is 36
Concatenate the two outputs and you have exactly prism run count.pr. The resuming process never re-ran the first three steps; it decoded the frozen call stack, checked that count.pr still hashes to the bundle the snapshot was captured in, and stepped the machine forward from the cut.
The snapshot is a kont envelope whose header carries the program’s namespace root, the same code identity used by the content-addressed store (the kont envelope). resume re-derives that digest from its own copy of the program and refuses a snapshot whose digest does not match, so a continuation only resumes against the code it was captured in. Hostile or truncated envelopes are rejected with diagnostics rather than trusted; the wire details live in the compiler document.
The suspendable subset is explicit. A value that cannot cross the boundary, a graph nested past the suspendable depth, or a native resource is refused at suspend time naming what could not be written, never encoded into a snapshot that would fail on the far side. The envelope is a runtime-value encoding over the interpreter’s representation, serialized and resumed by the tree-walking interpreter, including that interpreter compiled to WebAssembly, so the browser demo can move a running program between same-origin contexts that already share the same bundle. Native-code suspension is unsupported.
Mobility is therefore a consequence of the same two invariants the rest of the runtime already uses: continuations are reified values, and code identity is content-addressed. Teleporting a computation means sending the kont envelope, not inventing a separate remote-call mechanism: the receiver decodes the suspended continuation, recomputes the namespace root for its local program, and resumes only if that digest matches the envelope. What crosses the wire is the pending computation and captured state; what authorizes it is the hash of the code it was captured in.24
That keeps the mobility story aligned with replay rather than distribution magic. A suspended program resumed by another same-origin context must produce the same suffix as the original uninterrupted run, because the step it resumes from and the code it resumes into are both checked facts. Content addressing names the definitions, the kont envelope names the live continuation over those definitions, and deterministic replay is the observable contract tying them together.
7.19 Arena Allocation
Where @ noalloc (allocation certificates) is the static axis that forbids allocation, the dynamic axis redirects it: allocation is treated as an ordinary handled capability. The standard-library Arena module defines a single-shot Alloc effect and with_arena : (() -> a ! {Alloc}) -> a, which services allocations inside its body from a bump region and reclaims the whole region at scope exit. Choosing an allocator is installing a handler; a program that installs no Alloc handler allocates exactly as before, byte for byte.
fn build(n : Int) : List(Int) =
with_arena(fn () = range(0, n))
The redirection is scope-directed and invisible except through cost. Only constructors and tuples reachable solely through an arena scope are served from the region; a helper reachable from both arena and ordinary paths stays on the ordinary allocator, preserving byte identity for its non-arena callers. A value may outlive its region: at scope exit any cell reachable from the result is copied into an ordinary reference-counted cell, so escape costs a copy, never a use-after-free. An arena scope is single-shot (a multishot resume across the boundary is refused) and is not replayable, because addresses are not reproducible. @ noalloc still composes: an arena allocation is a fresh cell served differently, not an absent one. The lowering and region runtime are described under arena allocation.
7.20 Adapter Ladders
An effect declaration is a versioned protocol and a handler is one provider for it. When a protocol gains a version, an old client written against the earlier one keeps running against the newer provider through an adapter: a handler that discharges the old operations and re-performs the adjacent new ones. For N versions an author writes the N-1 adjacent adapters and composes them, never a converter for every pair. An adapter is polymorphic in the rest of the row: it removes its source protocol, introduces its target protocol, and forwards the ambient tail e untouched.
(() -> a ! {KvV1 | e}) -> a ! {KvV2 | e}
The relationship is directional. KvV1 -> KvV2 runs a KvV1 computation against a KvV2 provider: it handles KvV1 operations and performs KvV2 ones. The reverse is a separate adapter and may be failable, and the compiler never infers symmetry. Ordinary handler typing already enforces three of the adapter’s obligations. Coverage is handler exhaustiveness: every source operation must be handled unless the adapter is written with partial (residual handlers), so an omitted source operation is the ordinary missing-operation error. Resumption grade is the clause-grade comparison (effects and handlers): a clause may not resume a once source operation more than once, so a grade-changing translation is refused at the clause. Direction is caught wherever the target row is stated: a bridge annotated with the target protocol reports a re-performed source operation as an effect not declared in its annotation.
Two obligations are not derived from the handler’s structure. Ambient preservation holds only by leaving the adapter’s result row to inference; the row-polymorphic result above cannot be written as a declaration annotation, because a declaration effect row must be closed, so an open tail in result position is a parse error. An adapter that adds a clause for an operation outside its source protocol discharges that operation, and that is accepted with no diagnostic. Without a target-row annotation a backwards adapter also type-checks and faults only at run time, on the unhandled source operation. Coverage is likewise not termination: an exhaustive adapter may still loop or perform effects, so an adapter is not total by construction, and a termination claim is the ordinary total or assume total evidence (totality) rather than a protocol-specific spelling.
Recording and replay observe an adapter ladder at one fixed boundary. Record and replay log only the capability vocabulary, never a user protocol operation, so an adapter that translates protocol operations is invisible to a recorder except where a protocol operation bottoms out in a real capability read. That read is performed by the provider, after adaptation, so a recorded trace pins the provider’s post-adaptation vocabulary and not the client’s pre-adaptation one. Translating a historical trace from one protocol version to another is separate work.
The adapter below evolves get from an integer sentinel to an Option: it discharges each KvV1 operation by performing the adjacent KvV2 one and translating the result, forwarding the ambient row untouched. A second KvV2 -> KvV3 rung composes onto it the same way, so a client written once against KvV1 runs against a KvV3 provider by stacking the two adjacent adapters.
effect KvV1
get_v1(Int) : Int
put_v1(Int, Int) : Unit
effect KvV2
get_v2(Int) : Option(Int)
put_v2(Int, Int) : Unit
fn adapt_v1_to_v2(action : () -> a ! {KvV1 | e}) =
handle action() with
get_v1(key) resume k =>
match get_v2(key) of
Some(v) => k(v)
None => k(0 - 1)
put_v1(key, value) resume k =>
put_v2(key, value)
k(())
return r => r
8. Expressions
The expression grammar is in the surface grammar and the effect and failure forms are in effects and handlers; the forms below are those the grammar alone does not settle.
8.1 Method Calls
A method call e.m(args) is uniform-function-call syntax (UFCS): pure sugar for m(e, args), with the receiver e supplied as the first argument. Prism has no methods, only top-level functions; the dot is notation, not dispatch, so any function reads as a method and calls chain left to right (e.f().g() is g(f(e))). Extra arguments follow the receiver: a.add(b) is add(a, b). A trailing block argument, e.m(args) fn (x) { body }, appends the lambda as the last argument; this is how the stream consumers in streams.pr chain. Field access is e.field, and the two compose, e.field.m(args) being m(e.field, args).
-- Uniform Function Call Syntax (UFCS).
--
-- A method call `e.m(args)` is sugar for `m(e, args)`: the receiver becomes the
-- first argument. Prism has no methods, only functions; the dot is notation, so
-- any function reads as a method and calls chain left to right.
-- Expected output:
--
-- 10
-- 10
-- 8
-- 11
-- 7
-- 3
fn double(n) = n * 2
fn add(a, b) = a + b
type Vec = Vec { x: Int, y: Int }
fn main() =
-- `e.m(args)` is exactly `m(e, args)`: these two lines are the same call.
println(double(5))
println(5.double())
-- Extra arguments follow the receiver: `a.add(b)` is `add(a, b)`.
println(5.add(3))
-- Chaining reads left to right, each result feeding the next call:
-- 5.double().add(1) == add(double(5), 1)
println(5.double().add(1))
-- Field access is `e.field`.
let v = Vec { x = 7, y = 3 }
println(v.x)
println(v.y)
Function composition is core to functional programming, and Prism keeps the full algebra: f >> g is the forward composition \x -> g(f(x)), f << g the backward \x -> f(g(x)), and x |> f pipes an already-computed value into a function. Composition binds tighter than the pipe, so x |> f >> g pipes x through the composed pipeline.
The contrast with Haskell is direction, not power. Haskell’s primitive is backward composition (.), and idiomatic Haskell builds the function first and applies it last, reading right to left; pipelining a value forward takes the library operator (&). Prism makes the forward reading the default: dot-chains, |>, and >> all read in dataflow order, left to right, the order in which the value actually moves.
| idea | Prism | Haskell | OCaml |
|---|---|---|---|
| compose, forward | f >> g | g . f | fun x -> g (f x) |
| compose, backward | f << g | f . g | fun x -> f (g x) |
| pipe a value forward | x |> f | x & f | x |> f |
| chain calls on a value | e.f().g() | (g . f) e | e |> f |> g |
The denotations agree exactly (e.f().g(), e |> f >> g, and (f >> g)(e) are the same program), so the choice among them is prose style: the dot for a value stepping through transformations, |> for a computed result flowing into a pipeline, >>/<< for naming a composed function that is passed around or applied later.
-- Function composition three ways: composed functions are values built with
-- `>>` (forward) and `<<` (backward), a computed value flows on with `|>`,
-- and the UFCS dot chains the same calls in the same left-to-right order.
--
-- Prints:
-- 22
-- 21
-- 22
-- 22
-- 100
fn inc(x : Int) : Int = x + 1
fn double(x : Int) : Int = x * 2
fn clamp_hundred(x : Int) : Int =
if x > 100 then
100
else
x
fn main() =
let up = inc >> double -- \x -> double(inc(x))
let down = inc << double -- \x -> inc(double(x))
println(show(up(10)))
println(show(down(10)))
println(show(10 |> inc |> double))
let ten = 10
println(show(ten.inc().double()))
println(show(200 |> inc >> double >> clamp_hundred))
8.2 Comprehensions
A comprehension [ e for x in s, q, ... ] collects e for each element; a qualifier q is a guard if g or a binder let y = e. A guard is evaluated in a failure context, so an element is pruned both when g is false and when computing g fails: a failable accessor such as at_list (a prelude lookup from the standard prelude) past the end of a list prunes that element rather than aborting. The statement form for x in s, q, ... do body runs body per survivor. Both desugar to the prelude’s stream combinators (the Emit effect of the standard prelude), so they fuse without building an intermediate list.
A guard-free comprehension [ e for x in s ] is exactly a mapped and collected stream, and it desugars to that composition directly, so it rides the fused state-threading tier of effect lowering: no effect-operation cells, about two cells per element (the result list itself), the source evaluated exactly once before iteration, and e evaluated left to right once per element. Qualifiers (guards and binders) keep the general consumer path, whose pruning semantics need the failure context above. The choice of path is a cost decision only; both produce the identical list in the identical order.
-- List comprehensions and qualified for-loops. A comprehension
-- `[ e for x in s, <quals> ]` collects e per element. Qualifiers filter with
-- `if g` and bind with `let y = e`. A guard may be failable: it prunes the
-- element when computing g raises Fail, not only when g returns false. The
-- statement form `for x in s, <quals> do body` runs body per survivor.
-- Expected output:
--
-- 1
-- 4
-- 9
-- 16
-- 25
-- 30
-- 40
-- 50
-- 2
-- 4
-- 10
-- 30
fn main() =
for sq in sof([x * x for x in srange(1, 6)]) do
println(sq)
for y in sof([y for x in srange(1, 6), let y = x * 10, if y > 20]) do
println(y)
for x in srange(1, 6),
if even(x) do
println(x)
-- Failable guard: `at_list` fails past the end, so the guard prunes
-- out-of-range indices just as it prunes the zero price. Keeps 10 and 30.
let prices = [10, 0, 30]
for i in srange(0, 6),
if at_list(prices, i) > 0 do
println(at_list(prices, i))
8.3 Records
Record construction C { f = e, ... }, functional update C { ..base, f = e }, and nested path update { base | a.b = e, ... } build and modify the record types; each is an in-place write on a uniquely owned value. The deriving (Lens) getters and setters compose with them for deeper access. A path generalizes past nested fields to traversals, indices, prisms, filters, and a read form (optic paths).
-- `deriving (Lens)` synthesizes a getter `<f>_of` and a functional setter
-- `with_<f>` per field. They are ordinary functions, no optic types needed.
-- On a uniquely owned value the setter is FBIP-reused.
--
-- Expected output:
-- 3
-- 7
-- 9
-- 4
type Vec2 = Vec2 { x: Int, y: Int } deriving (Lens)
fn main() : Unit ! {IO} =
let v = Vec2 { x = 3, y = 4 }
println(x_of(v))
let v2 = with_x(v, 7)
println(x_of(v2))
let v3 = with_y(with_x(v, 9), 4)
println(v3.x)
println(v3.y)
8.4 Imperative control flow
Loops and early exit are surface sugar over tail recursion and effects, so they cost nothing beyond what an explicit recursion would. while cond do body and loop body (an unconditional loop) lower to a tail-recursive driver applied to the condition and body as thunks; because a var is a State effect (the standard prelude) the body mutates freely and the loop runs in constant stack with no per-iteration allocation. break and continue (valid inside while, loop, and for) and statement-form return e (which exits the enclosing function) compile to non-resumable performs of internal, fully-handled control effects, installed only for the keyword a body actually uses; a nested loop captures its own break/continue. Because each control effect is discharged at its loop or function boundary, none appears in the surfaced effect row: a loop is as pure as its body, and a function using return infers the same row as the equivalent recursion. Compound assignment x += e (and -=, *=, %=) on a var is shorthand for x := x <op> e.
Each form desugars to an existing construct:
| Surface | Desugaring |
|---|---|
x += e (and -=, *=, %=) | x := x <op> e |
while cond do body | repeat_while(\() -> cond, \() -> body) |
loop body (reachable break) | repeat_while(\() -> true, \() -> body) |
loop body (no break) | forever(\() -> body), whose result is a bottom type |
break / continue | a never perform of an internal Break/Continue effect handled at the loop |
return e | a never perform of an internal Return(a) effect handled at the function body |
-- Imperative control flow recovered from effects and tail recursion: `while`,
-- `loop`, `break`, `continue`, early `return`, and compound assignment, all
-- desugaring to the existing core. `var` is a State effect, so loop bodies mutate
-- freely yet each function below stays observably pure `(Int) -> Int`.
fn sum_evens(limit : Int) : Int =
var total := 0
var i := 0
while i < limit do
i += 1
if i % 2 == 1 then
continue
total += i
total
fn first_factor(n : Int) : Int =
var d := 2
loop
if d * d > n then
return n
if n % d == 0 then
return d
d += 1
fn countdown(start : Int) : Int =
var n := start
var ticks := 0
loop
if n <= 0 then
break
n -= 1
ticks += 1
ticks
fn main() : Unit ! {IO} =
println(show_int(sum_evens(10)))
println(show_int(first_factor(91)))
println(show_int(first_factor(97)))
println(show_int(countdown(5)))
8.5 Exponentiation
a ^ b raises a to the power b. It binds tighter than * and than unary minus (-2 ^ 2 is -(2 ^ 2), the mathematical reading; a negative base needs parentheses, (-2) ^ 2), and is right-associative, so 2 ^ 3 ^ 2 is 2 ^ (3 ^ 2). It is the method of the Pow class (the standard prelude) with Int and Float instances, so it desugars to pow(a, b): over Int it is bignum-correct (the instance multiplies), over Float it is a pow_float call. A mixed Int ^ Float is a type error, resolved by an explicit to_float, exactly as 2 + 3.0 is (Prism never coerces between Int and Float implicitly).
An Int exponent may be negative: a ^ b with b < 0 is defined as 1 / a ^ (-b) under the language’s one truncating division rule (integer arithmetic).25 Float exponents follow IEEE pow, so 2.0 ^ -1.0 is 0.5.
8.6 Indexing
a[i] reads, a[i] := v writes, and a[i] += e updates an indexed container. The form is dispatched on the receiver’s type (not a class, so no inference change): Array is indexed by Int, HashMap by String, String by Int (yielding the byte), and List by Int. Array, HashMap, and List are writable; String is read-only. Array and HashMap rewrite the cell in place (FBIP); a List write is the functional list_set, rebuilding the spine.
A read is failable: a missing index or key performs the Fail effect (errors and failure), so a[i] has type Elem ! {Fail} and the partiality surfaces in the row rather than in an Option wrapper. It therefore composes with ??, ?., default, and the rest of the failure axis: a[i] ?? d supplies a default, and the counter idiom is m[k] := (m[k] ?? 0) + 1, honest that an absent key starts at zero. A plain write a[i] := v is total; a[i] += e reads first, so it is ! {Fail}. Writes rebind the underlying var and rewrite the cell in place when it is uniquely owned (FBIP, declarations and programs); nested grid[i][j] := v composes the same way. a[i] := v requires a to be an assignable var.
8.7 Typed Buffers and Tensors
FloatBuf and IntBuf are flat buffers of unboxed 8-byte elements, read and written through the tbuf_* and ibuf_* operations (new, len, get, set, blit). A buffer carries the same ownership discipline as Array: a write mutates it in place when it is uniquely owned and copies it when shared, so mutation is never observable through an alias, and elements thread bit-for-bit identically on both backends (NaN payloads and subnormals included). Data.FlatArray puts one typed surface over both: FlatArray(a) is dispatched by the FlatElem class (instances for Float and I64), so an unsupported element type is a missing-instance error rather than a representation fault. Data.Tensor is a record over FloatBuf carrying per-axis shape, strides, and names: transpose by axis name is a stride permutation that moves no data, reshape is contiguity-checked, and a bracket with two or more indices is multi-index sugar extending indexing: t[i, j] reads and t[i, j] := v writes through the strides. The storage under all of these is flat; only a read boxes the scalar it returns, so element layout stays a cost fact rather than a change in what a program computes.
import Data.Tensor (..)
fn main() =
var t := from_list([2, 3], [1.0, 2.0, 3.0, 4.0, 5.0, 6.0])
t[0, 1] := 4.0
-- Transpose by axis name: a stride permutation, no data movement.
let u = transpose(t, "0", "1")
println(u[1, 0])
8.8 SIMD Vectors
F64x2 and I64x2 are opaque baseline 128-bit vectors. Each operation acts on both lanes as one vector operation.
| Purpose | F64x2 | I64x2 |
|---|---|---|
| Lanes | Two IEEE-754 doubles | Two 64-bit integers |
| Broadcast a scalar | simd_fsplat | simd_isplat |
| Extract a lane | simd_fextract | simd_iextract |
| Arithmetic | simd_fadd, simd_fsub, simd_fmul | simd_iadd, simd_isub |
| Minimum and maximum | simd_fmin, simd_fmax | none |
| Bitwise operations | none | simd_iand, simd_ior, simd_ixor |
| Semantic guarantee | Contract |
|---|---|
| Opacity | Vectors have no show, equality, ordering, hash, or serialization. |
| Observation | A program can observe a vector only by extracting its scalar lanes. |
| Reference semantics | The scalar interpreter defines every operation, independently of host SIMD availability. |
| Native parity | The native backend reproduces every lane bit for bit, including NaN payloads, signed zero, and subnormals. |
| Floating-point minimum and maximum | Each lane uses a < b ? a : b; the false branch selects b, including for unordered comparisons, rather than invoking a platform-specific intrinsic. |
-- Baseline 128-bit SIMD: two lanes per vector, one instruction per lane pair.
-- The scalar interpreter defines the semantics and the native backend must match
-- it bit for bit, so the output is a pure function of the source on every tier.
-- Vectors are opaque: they are built with `splat`, combined lane-wise, and read
-- back one lane at a time; there is no `show`, equality, or ordering on them.
fn dot2(a : F64x2, b : F64x2) : Float =
let p = simd_fmul(a, b)
simd_fextract(p, 0) + simd_fextract(p, 1)
fn main() : Unit ! {IO} =
-- A two-lane float vector from a pair of scalars, via splat + replace-by-add.
let xs = simd_fadd(simd_fsplat(3.0), simd_fsplat(0.5))
let ys = simd_fsplat(2.0)
println(show_float(dot2(xs, ys)))
-- Lane-wise min and max, read back per lane.
let lo = simd_fmin(xs, ys)
let hi = simd_fmax(xs, ys)
println(show_float(simd_fextract(lo, 0)))
println(show_float(simd_fextract(hi, 1)))
-- Integer lanes: bitwise and wrapping arithmetic.
let m = simd_iand(simd_isplat(12), simd_isplat(10))
let s = simd_iadd(simd_isplat(100), simd_isplat(23))
println(simd_iextract(m, 0))
println(simd_iextract(s, 1))
8.9 Optic Paths
Prism has no optic library: no Lens type, no over/set/toListOf to compose, no profunctor encodings. It has one rule instead. Between the | and the operator of a record update (record expressions), or inside s.[ ... ], a path is a sequence of steps read left to right. The path is the optic, spelled at the use site rather than reified as a value. Every form is sugar over map/with/match, so in-place reuse and fusion come for free and nothing new reaches the core: this is the language’s “effects instead of monads” stance applied to optics, paths instead of optic combinators.
A step is one of:
| Step | Meaning |
|---|---|
.field | descend into a record field |
each | traverse every element of a functor (lowers to fmap) |
[i] | focus one element of a list or array, by index |
?Ctor | focus through a sum constructor; others pass through |
(steps where p) | keep only the foci satisfying the predicate p |
A path is closed by one of three operations:
| Form | Operation |
|---|---|
path = v | set the focus to v |
path ~ f | modify the focus, applying f |
s.[ path ] | read every focus the path selects into a list |
each is a reserved keyword; every other step reuses existing tokens.
Each form lowers to ordinary code. Fields use the derived getter and functional setter; nested fields rebuild only their enclosing spine. Modify reads the focus, applies the function, and writes the result back:
{ p | pos.x = 30 }
{ p | hp ~ heal }
each lowers to fmap and composes with all later steps. An index focuses one list or array element through its ordinary setter; an out-of-range index leaves the container unchanged. A ?Ctor step rebuilds the matched constructor and passes every other constructor through. A filter applies the remainder of the path only to retained foci. These rules compose mechanically:
{ world | party.(each where alive).bag.each.count ~ \(n) -> n + 5 }
The remaining steps follow the same ordinary-code laws:
{ world | party[0].hp = 100 } -- update one focus; unchanged if absent
{ shape | ?Circle.radius ~ double } -- update Circle; pass other constructors through
players.[each.hp] -- collect all selected foci
An index lowers through list_set or in-place array_set, guarded by the same failable lookup used by ordinary indexing. A prism lowers to a match whose selected constructor is rebuilt and whose other arm returns the original value. The read form s.[path] is the update’s twin: it collects every selected focus into a list, using singleton lists for single-focus steps and concatenation across traversals. A ?Ctor therefore previews zero or one focus.
Paths are deliberately use-site syntax, not first-class values: there is no Optic type, no passing an optic to a function, no library of named composable optics, and optic kinds are not tracked in the type system (that a read-only path is read-only is a structural fact of the desugaring, not a typed law). This is the explicit trade: paths cover the great majority of real optic use and give up abstracting over which optic. The mental model is one breath: steps read left to right, = v/~ f to write, s.[ ... ] to read, nothing escaping into a new core construct.
-- A tour of optics by path. A path is a sequence of steps between `|` and the
-- operator (or inside `s.[ ... ]`): `.field` descends, `each` traverses a
-- functor, `[i]` indexes, `?Ctor` focuses a constructor, `(each where p)`
-- filters. End with `= v` to set or `~ f` to modify; wrap in `s.[ path ]` to
-- read every focus into a list. Every form is sugar over `map`/`with`/`match`,
-- so in-place reuse and fusion come for free; nothing reaches a new core form.
--
-- Expected output:
-- Vec2(1, 4)
-- Player(hero, Vec2(7, 4), 30, [])
-- [Player(a, Vec2(0, 0), 15, []), Player(b, Vec2(0, 0), 10, [])]
-- World([Player(a, Vec2(1, 2), 100, []), Player(b, Vec2(1, 2), 0, [])], 1)
-- Circle(20)
-- Square(3)
-- [Player(a, Vec2(1, 2), 15, []), Player(b, Vec2(1, 2), 0, [])]
-- [5, 0]
-- [5]
type Vec2 = Vec2 { x: Int, y: Int }
type Player = Player { name: String, pos: Vec2, hp: Int, bag: List(Int) }
type World = World { party: List(Player), turn: Int }
type Shape = Circle { radius: Int } | Square { side: Int }
fn alive(p : Player) : Bool = p.hp > 0
fn heal(h : Int) : Int = h + 10
fn mk(n : String, h : Int) : Player =
Player {
name = n,
pos = Vec2 { x = 1, y = 2 },
hp = h,
bag = Nil
}
fn main() =
-- Set and modify a (possibly nested) field.
let v = Vec2 { x = 3, y = 4 }
println({ v | x ~ \(n) -> n - 2 })
let hero = Player { name = "hero", pos = v, hp = 20, bag = Nil }
println({ hero | pos.x = 7, hp ~ heal })
-- Traverse with `each`, then descend; modify and set per element.
let party = Cons(mk("a", 5), Cons(mk("b", 0), Nil))
println({ party | each.hp ~ heal, each.pos = Vec2 { x = 0, y = 0 } })
-- A field, an index, then a field, mixed with a plain field.
let world = World { party = party, turn = 1 }
println({ world | party[0].hp = 100 })
-- A prism focuses one constructor and passes the others through.
println({ Circle { radius = 10 } | ?Circle.radius ~ \(r) -> r * 2 })
println({ Square { side = 3 } | ?Circle.radius = 0 })
-- Filter a traversal: only the living are healed.
println({ party | (each where alive).hp ~ heal })
-- Read every focus a path selects into a list.
println(party.[each.hp])
println(party.[(each where alive).hp])
8.10 Source Probes
A source probe is a named instrumentation point with a body that runs only when the process enables that name:
probe "parser.enter" do
println("enter parser")
Probe names are string literals matching [A-Za-z0-9_.:-]+. At runtime, PRISM_PROBES is a comma-separated allow-list; PRISM_PROBES=parser.enter enables just that probe and PRISM_PROBES=* enables every probe. Whitespace around commas is ignored.
The semantic rule is that a disabled probe evaluates neither its body nor any formatting work inside that body. The surface form desugars to a branch over the runtime gate:
if probe_enabled("parser.enter") then
println("enter parser")
else
()
The body must therefore have type Unit; any effects or allocation it performs remain visible to ordinary typechecking and allocation checks. Probes are meant for diagnostics. In native or CLI-only code, probe bodies can write to stderr (eprint/eprintln) when they are not intended to perturb the program’s stdout contract; browser-runnable examples should use ordinary stdout because the web platform does not provide host stderr.
8.11 Typed Holes
A typed hole is a named expression placeholder, written ?name:
fn choose(x : Int, enabled : Bool) : Int ! {} =
?answer
The name is diagnostic identity, not a variable lookup. During inference the hole takes a fresh type metavariable (or the type pushed inward by a checking context), and inference continues around it. Constraints from the enclosing expression may therefore determine what belongs at the site without the compiler inventing a value for it. After those constraints are solved, the checker reports:
- the expected type;
- the contextual effect row permitted at the site, preserving the difference between a pure
{}context and an open or effectful row such as{Exn | e}; - every binding in lexical scope with its canonical printed type; and
- the bindings whose types subsume the expected type, ranked with exact matches first and then deterministically by name.
The report is a structured, serializable value and the human diagnostic is TYPED_HOLE (E1021). :type in the REPL uses the same checker and displays the same report for a hole-bearing expression. Candidate testing uses ordinary type subsumption and is observational only: testing one candidate cannot solve the hole or change the rank of another.
Ordinary checking and every code-generation path reject a program containing a hole. This includes native and WebAssembly compilation. There is one opt-in exception for interactive development: prism run --defer-holes file.pr and the REPL toggle :set +h permit holes through the interpreter frontend. Reaching one halts evaluation with a canonical fault containing only the written hole name and source span, for example typed hole ?answer at 54..61. The same string is the terminal Fault event in an observation trace. A deferred hole never inspects its inferred type, chooses a candidate, supplies a default, or falls through to another execution path; a program that does not reach the hole behaves exactly as it would after replacing the hole with an expression of the reported type.
Deferral is interpreter-only and off by default. Turning it on does not make a hole a value and does not relax any other type error. Fill-and-resume debugging and command-line hole substitution are not part of this surface.
9. Patterns
Patterns appear in match arms, let bindings, lambda and function parameters, and catch arms; their grammar is the pattern nonterminal of the surface grammar. A pattern is not just an equality test: it is how Prism destructures an algebraic data type, the mechanism that makes a sum-of-products type usable at all. Building a value picks one constructor and supplies its fields forward; a pattern runs that same constructor backward, naming the fields as new bindings while the compiler proves, at compile time, that every case the type admits is handled somewhere.
9.1 Destructuring
A constructor pattern matches a value built by that constructor and destructures its fields against nested patterns of their own: patterns nest to any depth, so one arm can reach through a tuple, into a constructor, into a record field, binding every name it needs in a single match. The remaining forms cover the value’s other shapes: a literal pattern (Int, Float, Char, Bool, and a leading - folded into a numeric literal, since patterns have no general negation) matches an exact constant; a variable pattern binds the whole matched value under a name; the wildcard _ matches anything and binds nothing; a tuple pattern (p, q, ...) destructures the matching tuple arity; and a list pattern [p, q, ...] is sugar for the nested Cons/Nil constructor patterns it expands to. A record pattern C { f = p, ... } names the fields it cares about; a bare field name puns, binding a variable of the same name (C { f, .. } is shorthand for C { f = f, .. }), and a trailing .. ignores every field the pattern does not mention.
-- Patterns nest arbitrarily deep: one arm can reach through a constructor,
-- into a record field, binding every name it needs in a single match.
type Point = Point { x: Int, y: Int }
type Shape = Circle(Point, Int) | Segment(Point, Point)
-- A bare field name puns, binding a variable of the same name, and `..`
-- ignores every field the pattern does not mention.
fn x_coord(p : Point) : Int =
match p of
Point { x = x, .. } => x
fn at_origin(s : Shape) : Option(Int) =
match s of
Circle(Point { x = 0, y = 0 }, r) => Some(r)
_ => None
-- Tuple and list patterns destructure the corresponding literal forms; a list
-- pattern is sugar for the nested `Cons`/`Nil` constructor patterns it expands to.
fn describe(pair : (Int, List(Int))) : String =
match pair of
(0, Nil) => "zero, empty"
(_, Cons(only, Nil)) => "single: {only}"
(_, Cons(a, Cons(b, Nil))) => "pair: {a}, {b}"
(_, _) => "other"
-- Expected output:
-- 5
-- Some(5)
-- None
-- zero, empty
-- single: 42
-- pair: 1, 2
-- other
fn main() =
println("{x_coord(Point { x = 5, y = 9 })}")
println("{at_origin(Circle(Point { x = 0, y = 0 }, 5))}")
println(
"{at_origin(Segment(Point { x = 0, y = 0 }, Point { x = 1, y = 1 }))}",
)
println(describe((0, [])))
println(describe((1, [42])))
println(describe((2, [1, 2])))
println(describe((3, [1, 2, 3])))
A single constructor pattern over a recursive type retires the recursion into a reusable combinator: fold_tree below destructures Tree exactly once, and every later traversal, size, sum, depth, or flattening to a list, becomes a three-line call rather than a new match.
-- A functional pearl: destructure a recursive type once, as a fold, and every
-- later traversal over it becomes a three-line call instead of a new match.
type Tree(a) = Leaf | Node(Tree(a), a, Tree(a))
fn fold_tree(leaf : b, node : (b, a, b) -> b, t : Tree(a)) : b =
match t of
Leaf => leaf
Node(l, x, r) => node(fold_tree(leaf, node, l), x, fold_tree(leaf, node, r))
fn size(t : Tree(a)) : Int = fold_tree(0, \(l, _, r) -> l + r + 1, t)
fn sum(t : Tree(Int)) : Int = fold_tree(0, \(l, x, r) -> l + x + r, t)
fn depth(t : Tree(a)) : Int = fold_tree(0, \(l, _, r) -> 1 + max(l, r), t)
fn to_list(t : Tree(a)) : List(a) =
fold_tree([], \(l, x, r) -> append(append(l, [x]), r), t)
-- Expected output:
-- 4
-- 10
-- 3
-- [1, 2, 3, 4]
fn main() =
let t = Node(Node(Leaf, 1, Leaf), 2, Node(Node(Leaf, 3, Leaf), 4, Leaf))
println(show(size(t)))
println(show(sum(t)))
println(show(depth(t)))
println(show(to_list(t)))
9.2 Alternation
A pattern may alternate: p | q | r matches a value that any one of its alternatives matches. Alternation is legal wherever a pattern is, so it nests inside a constructor argument, a tuple, a list, and a record field, and Line(0 | 1, _) is one arm rather than two nearly identical ones.
An alternation means exactly the arms it stands for. p | q => e is p => e followed by q => e, and a nested alternation enumerates the product of its positions, leftmost slowest, so overlapping alternatives keep the source order a reader would assume. Everything else follows from that one rule and needs no separate machinery: a guard belongs to each alternative (p | q if g => e is p if g => e then q if g => e, so a value matching both alternatives retries the guard against the second before falling through), and each alternative is checked on its own, so a name shared by two alternatives need not have the same type in both as long as the body checks at each.
Because the body is shared, every alternative must bind the same set of names; one that binds a name another does not is an error (E6068) naming the name and pointing at the offending alternative. The enumeration is a product, so alternation in several positions multiplies; an arm expanding past 256 arms is refused (E6069) rather than compiled into an unbounded arm list.
-- An alternation matches when any one of its alternatives does, and is legal
-- wherever a pattern is: nested in a constructor argument, a tuple, a list, or
-- a record field.
type Shape
= Dot
| Line(Int, Int)
| Ring(Int)
| Poly(List(Int))
-- One arm, not four.
fn is_thin(s : Shape) : Bool =
match s of
Dot | Line(_, _) => true
Ring(_) | Poly(_) => false
-- Every alternative must bind the same names, since all of them share the one
-- body. Here `n` is the first coordinate of a line and the radius of a ring.
fn extent(s : Shape) : Int =
match s of
Line(n, _) | Ring(n) => n
Dot => 0
Poly(_) => 1
-- Alternation nested in an argument position, and a guard, which belongs to
-- each alternative in turn: a value matching both alternatives retries the
-- guard against the second before falling through.
fn label(s : Shape, cap : Int) : String =
match s of
Line(0 | 1, _) => "short"
Ring(n) | Line(n, _) if n > cap => "over"
_ => "plain"
-- The arms an alternation stands for are what the exhaustiveness check sees,
-- so covering the last constructors with one arm needs no catchall.
fn corners(s : Shape) : Int =
match s of
Dot => 0
Line(_, _) => 2
Ring(_) | Poly(_) => 9
-- Expected output:
-- true
-- false
-- 7
-- short
-- over
-- plain
-- 9
fn main() =
println("{is_thin(Dot)}")
println("{is_thin(Ring(4))}")
println("{extent(Ring(7))}")
println(label(Line(1, 2), 3))
println(label(Ring(9), 3))
println(label(Ring(2), 3))
println("{corners(Poly(Nil))}")
A let binding destructures with a constructor or tuple pattern and admits no alternation, since an irrefutable binding has one shape to name and nothing to choose between.
9.3 Patterns in Parameter Position
A parameter of a fn or a lambda may be written as a pattern rather than as a name. fn area(Circle(r)) = ... is the function that takes one argument and destructures it, with the same meaning as taking a named argument and matching it around the whole body:
fn area(Circle(r)) : Int = r * r
-- means
fn area(s : Shape) : Int =
match s of
Circle(r) => r * r
A bare variable in parameter position is still the ordinary named parameter, binding without testing, and _ still names a parameter the body ignores. Any other pattern is a pattern parameter, and it composes with the rest of a parameter’s syntax: a type annotation, borrow, and a default all attach as usual (fn f(borrow Circle(r) : Shape := unit_circle)). Where several parameters are patterns, the leftmost one’s match is the outer one, so a later pattern’s bindings cannot capture an earlier one’s.
A pattern parameter must be irrefutable: it has to cover every value of its type, because there is no next arm to fall through to. A refutable one is reported exactly as the match it denotes, a non-exhaustive match (E4001) naming a missing constructor, with the caret under the pattern the author wrote. A pattern parameter has no name of its own, so it cannot be supplied by keyword; other parameters of the same function still can be.
-- A parameter may be written as a pattern rather than a name. The function
-- means what it would mean with a named parameter matched around the body, so
-- the pattern has to be irrefutable: there is no next arm to fall through to.
type Wrap = Wrap(Int)
type Pt = Pt { x: Int, y: Int }
fn unwrap(Wrap(n)) : Int = n
-- A record pattern may name the fields it needs and ignore the rest.
fn abscissa(Pt { x = x, .. }) : Int = x
fn sum_pair((a, b)) : Int = a + b
-- The rest of a parameter's syntax still applies: an annotation, `borrow`, and
-- a default all attach as usual.
fn scaled(borrow Pt { x = x, y = y } : Pt, by : Int := 2) : Int = (x + y) * by
-- Where several parameters are patterns, the leftmost one's match is the outer
-- one, so a later pattern's bindings cannot capture an earlier one's.
fn offset(Wrap(n), Pt { x = x, .. }) : Int = n + x
-- Expected output:
-- 3
-- 4
-- 9
-- 14
-- 21
-- 12
-- 16
fn main() =
println("{unwrap(Wrap(3))}")
println("{abscissa(Pt { x = 4, y = 5 })}")
println("{sum_pair((4, 5))}")
println("{scaled(Pt { x = 3, y = 4 })}")
println("{scaled(Pt { x = 3, y = 4 }, by := 3)}")
println("{offset(Wrap(2), Pt { x = 10, y = 0 })}")
-- A lambda parameter is a pattern in exactly the same way.
let double = \(Wrap(n)) -> n * 2
println("{double(Wrap(8))}")
9.4 Guards
A match arm may carry a guard, pat if cond => body: the pattern must match and the guard must evaluate to true before the arm fires, and the guard sees every variable the pattern bound. When the pattern fails to match, or matches but the guard is false, control falls through to the next arm in source order.
-- Pattern guards: `pat if cond => body`. The guard sees the pattern's
-- variables; when it is false the value falls through to the next arm.
-- Coverage counts only unguarded arms toward exhaustiveness, so a match
-- covered solely by guarded arms is rejected at compile time.
-- The classic: literal-free fizzbuzz, first true guard wins.
fn fizzbuzz(n : Int) : String =
match n of
k if k % 15 == 0 => "FizzBuzz"
k if k % 3 == 0 => "Fizz"
k if k % 5 == 0 => "Buzz"
k => show(k)
-- Guarded insertion keeps the list sorted. The catchall handles both Nil and
-- the spot where x belongs.
fn insert(x : Int, xs : List(Int)) : List(Int) =
match xs of
Cons(h, t) if h < x => Cons(h, insert(x, t))
_ => Cons(x, xs)
fn isort(xs : List(Int)) : List(Int) = foldr(insert, [], xs)
-- Expected output:
-- 1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz
-- [1, 2, 5, 7, 9]
fn main() =
println(str_join(" ", map(fizzbuzz, range(1, 16))))
println(show(isort([5, 2, 9, 1, 7])))
9.5 Exhaustiveness and Redundancy
Every match is checked by default, with no opt-out: the usefulness algorithm of Maranget (2007) decides, from the arms’ patterns alone, whether some value of the scrutinee’s type reaches no arm (a non-exhaustive match, E4001, an error that names a concrete missing pattern as a witness) and whether some arm can never fire because every value it would match is already claimed by an earlier arm (an unreachable arm, E4000). A guarded arm does not count toward exhaustiveness, since its guard may fail at run time and fall through regardless of what its pattern matched; a wildcard arm underneath a family of guarded arms exists precisely because the guards above it cannot discharge the check on their own.
Both questions are asked of the arms an alternation stands for, not of the alternation itself, so an alternation covering the last constructors of a type discharges exhaustiveness exactly as separate arms would. Reachability is the one place the source arm stays visible: an arm is unreachable only when no alternative of it can fire, and the error underlines the whole arm the author wrote. A single dead alternative inside a live arm (Red | Red) is not reported, since the arm as written is still doing work.
Exhaustiveness is not a lint: an unhandled case is a compile-time error, not a run-time panic waiting to happen. The proof survives into the compiled program too: the native backend still lowers a match to a constructor switch with a default block, but that block is unreachable code the checker has already proved dead, trapping rather than falling through silently in the one case a bug could ever reach it.
9.6 Pattern Synonyms
A pattern N(x) for T = view ... make ... declaration defines a bidirectional pattern synonym: in match position it runs view and succeeds when that returns Some (the present case of Option, from the standard prelude); in expression position it runs make. Here view and make are contextual keywords, significant only inside a pattern declaration. A synonym with both halves is a prism (a composable view-and-build pair); one with only view is a view pattern. The for target may also name a class rather than a type, with the view a method of that class: pattern First(n) for Peek = view peek matches a value of any type with a Peek instance, dispatching peek through the dictionary at each match site, so one synonym destructures every instance.
type Vec2 = Vec2 { x: Int, y: Int }
-- a bidirectional pattern synonym: matches when y == 0, binding x
pattern OnXAxis(x) for Vec2 =
view \(v) -> if v.y == 0 then Some(v.x) else None
make \(x) -> Vec2 { x = x, y = 0 }
fn describe(v) =
match v of
OnXAxis(x) => x
_ => 0 - 1
fn main() =
println(describe(Vec2 { x = 5, y = 0 }))
println(describe(Vec2 { x = 5, y = 3 }))
10. Declarations and Programs
A function is declared with fn; a parameter may carry a type annotation, a default value := e, or the borrow modifier, which lets a pure function read a parameter without taking ownership of it, and it may be written as an irrefutable pattern instead of a name. A return annotation is written : T ! {R} for result type T and effect row R, : T ! for an explicit empty row, or : T to leave the row inferred. A parameter with a default may be omitted, and any argument may be passed by name as f(p := e), in any order and mixed with positional arguments; the call is rewritten to positional form, filling omitted defaults. Defaults and named arguments are honored on top-level functions. A top-level let is a constant: its references are inlined. A where block attaches non-recursive, lexically scoped definitions to a function body.
-- Named and default arguments. A call may omit trailing defaulted parameters
-- or pass any argument by name. The two combine freely, and named arguments
-- may be reordered.
fn rect(w, h := 1, border := 0) = w * h + border
fn main() =
println(rect(5))
println(rect(5, 3))
println(rect(5, 3, 2))
println(rect(5, border := 100))
println(rect(w := 2, h := 4))
println(rect(h := 4, w := 2))
-- `borrow` lets a function read a parameter without taking ownership of it, so
-- the caller keeps the value live for later use. Here `xs` is borrowed by both
-- `len` and `scale`, so `main` still owns it and can sum it afterward.
-- Expected output:
--
-- 4
-- 40
-- 10
fn len(borrow xs) =
match xs of
Nil => 0
Cons(_, rest) => 1 + len(rest)
fn scale(borrow xs, k) =
match xs of
Nil => Nil
Cons(x, rest) => Cons(x * k, scale(rest, k))
fn main() =
let xs = [1, 2, 3, 4]
let n = len(xs)
let ys = scale(xs, n)
println(n)
println(sum(ys))
println(sum(xs))
A function may be annotated fip or fbip to assert the fully-in-place discipline of Lorenzen et al. (2023). fbip proves the body allocates no fresh cell and calls only annotated, allocation-free functions. fip additionally proves linearity (each owned, non-immediate binding is consumed at most once) and bounded stack (each recursive call in the group is a tail call or a single tail-modulo-cons or tail-modulo-add). These are static checks that reject a non-conforming body; the mechanism is described under reference counting and FBIP reuse. A function may additionally, or independently, be annotated replayable (record and replay), which certifies it performs only the recordable capability effects and so is reproducible from a recorded trace; replayable is orthogonal to fip/fbip and may combine with either.
-- FP^2 in-place list operations (Lorenzen/Leijen/Swierstra, ICFP 2023),
-- statically checked. The annotation makes the compiler PROVE the function
-- allocates no fresh cell: every `Cons` it builds reuses one it just matched and
-- dropped. Run with PRISM_REUSE_STATS=1 to watch the reuse hits, or
-- PRISM_CHECK_LEAKS=1 to confirm zero live cells at exit.
--
-- `rev_onto` and `bump` are `fip`: linear (each binding used once) AND bounded
-- stack. `rev_onto` is a plain tail call and `bump` is a tail-modulo-constructor
-- (`Cons(.., bump(t))`), so both lower to a constant-stack loop, not recursion.
-- `cap_at` is only `fbip`: its `h` is read by the guard AND rebuilt into the
-- `Cons`, two uses of one value, so it is not linear (its element type is generic
-- `a`, so `h` cannot be assumed an immediate). Zero-allocation still holds.
--
-- Prints:
-- 10
-- 6
-- 22
-- Reverse onto an accumulator, the canonical fip. Each input `Cons` is matched,
-- freed, and immediately reused as the next accumulator cell, so the reversal
-- runs in place with zero allocation, and the tail call makes it a loop.
fip fn rev_onto(xs, acc) =
match xs of
Nil => acc
Cons(h, t) => rev_onto(t, Cons(h, acc))
-- The seeding wrapper is not fbip: that initial `Nil` is a genuine fresh
-- allocation with no cell to reuse, so it stays un-annotated.
fn reverse_ip(xs) = rev_onto(xs, Nil)
-- Spine-rebuilding map: drop each `Cons`, rebuild it around the bumped head.
fip fn bump(xs) =
match xs of
Nil => Nil
Cons(h, t) => Cons(h + 1, bump(t))
-- Saturating transform: rebuild every cell, head chosen by a test. Both `if`
-- branches end in a `Cons` reusing the same freed cell, so the reuse credit is
-- spent exactly once on every path. (A `filter` that DROPS cells could not be
-- fbip: the discard path frees a cell with no local allocation to reuse, and
-- this runtime has no cross-call reuse credit, so it would allocate.)
fbip fn cap_at(xs) =
match xs of
Nil => Nil
Cons(h, t) =>
if h > 9 then
Cons(9, cap_at(t))
else
Cons(h, cap_at(t))
fn main() =
println(sum(reverse_ip([1, 2, 3, 4])))
println(sum(bump([1, 1, 1])))
println(sum(cap_at([1, 20, 3, 40])))
10.1 Allocation Certificates
The zero-allocation guarantee is the first checked usage fact: @ noalloc, written at the root of the return annotation. Read it as the result type with the allocation coeffect subtracted: the body and its whole call tree allocate no fresh cell, calling only allocation-free functions. It carries the same check as fbip, without the linearity and bounded-stack requirements fip adds. It composes with an effect row and with given constraints (: T @ noalloc ! {IO}), and interoperates with the keyword forms: an @ noalloc function may call fip, fbip, or @ noalloc functions.
A failed certificate explains itself. The diagnostic lists the first three allocation witnesses in evaluation order, each a concrete reason with its name attached: a constructor built fresh outside reuse (by constructor name), a fresh tuple, a lambda materialized as a closure cell, a call to a function with no zero-allocation certificate (by callee name), an indirect call through a function value, or a primitive off the allocation-free list. A body with more sites than the bound reports the remainder as a trailing count (and 2 more), and the same witness detail backs the fip and fbip usage-check failures, so every discipline in the family points at its offending sites rather than restating the rule. The witnesses are read off the reuse-lowered core, after the compiler has already spent every reuse opportunity, so a reported allocation is one the optimizer could not eliminate, not folklore about the source text.
A region certifies by becoming a function of its own: hoist the expression, passing its free locals as parameters, and certify that function, so the identical whole-call-tree check covers exactly the region. gcd below certifies a whole function; horner certifies only its core.
-- Zero-allocation certificates. gcd carries `@ noalloc` on its return type:
-- the body and its whole call tree (here, itself) allocate no fresh heap
-- cell, checked statically. horner asserts the same for a region by giving it
-- a function of its own: the free locals become parameters, so the identical
-- whole-call-tree check covers exactly that region.
fn gcd(a : Int, b : Int) : Int @ noalloc =
if b == 0 then
a
else
gcd(b, a % b)
fn horner_core(a : Int, b : Int, c : Int, scaled : Int) : Int @ noalloc =
(a * scaled + b) * scaled + c
fn horner(a : Int, b : Int, c : Int, x : Int) : Int =
let scaled = x * 2
horner_core(a, b, c, scaled)
fn main() =
println(gcd(48, 18))
println(horner(1, 2, 3, 5))
Writing @ noalloc anywhere other than the root of a fn return annotation is an error naming the certificate’s one position. Interface-level allocation contracts on higher-order arguments are unsupported, and their row spellings are reserved.
See usage rows for the mode-family boundary: borrow, fip/fbip, @ noalloc, and operation grades are one resource story, but they are not all effect rows.
10.2 Stable Blocks
A serialized value is a contract across time: bytes written by yesterday’s binary are read by today’s, so a persisted format must never drift silently with the in-memory type. A stable block declares a type’s frozen wire history inline, on the type itself. Each entry is a rung: a record layout named V1, V2, and so on, where a later rung extends its predecessor with ..Vn and new fields, each new field carrying a default. The block’s last rung is the current one, and the bare type name (PlayerManual below) refers to it; an earlier rung is a real type of its own, named PlayerManual.V1. A migrations table lists every version pair the family converts between, and the family-qualified members PlayerManual.Vn.upgrade and PlayerManual.Vn.downgrade are how a program moves a value along it. From this one declaration the compiler generates the converter ladder and the byte-level codec, with no hand-written conversion logic required for an additive change.
import Wire (..)
type Calling = Sorcerer | Wizard | Bard deriving (Eq, Show, Serialize, Stable)
stable PlayerManual {
V1 = { hero: String, calling: Calling, level: Int } frozen "fc9d14271df9a149",
V2 = { ..V1, signature_spell: String = "Magic Missile" } frozen "ab2c3f1a8c78e60f",
V3 = { ..V2, spell_slots: Int = 2, inspiration: Int = 0 },
migrations {
V1 -> V2 = auto
V2 -> V3 = auto
V1 -> V3 = auto
}
}
fn body() =
let current =
PlayerManual {
hero = "Mira",
calling = Sorcerer,
level = 7,
signature_spell = "Magic Missile",
spell_slots = 5,
inspiration = 3
}
-- The composed V3 -> V1 route drops every field the older sheet cannot carry
-- and unions the loss each rung reports.
let (first_edition, loss) = PlayerManual.V1.downgrade(current)
println(concat("dropped reaching V1: ", show(loss_names(loss))))
-- The composed V1 -> V3 route restores the declared default for each of them.
let restored = PlayerManual.V1.upgrade(first_edition)
println(concat("restored slots: ", show(restored.spell_slots)))
fn main() = default(body, ())
Each row Vfrom -> Vto = auto asks the compiler to derive the conversion between those two rungs from their declared shapes. A purely additive step, a later rung that only appends defaulted fields, derives both directions: a total upgrade that copies the shared fields and fills each new one with its default, and an honest downgrade that keeps the older fields and returns the lowered value paired with a Wire.Loss naming exactly the fields it had to drop. The interface a program calls is family-qualified. PlayerManual.V1.upgrade(sheet) follows the declared route from V1 to the current rung; PlayerManual.V1.downgrade(sheet) follows it in reverse and pairs the older value with its Loss:
PlayerManual.V1.upgrade : (PlayerManual.V1) -> PlayerManual
PlayerManual.V1.downgrade : (PlayerManual) -> (PlayerManual.V1, Wire.Loss)
Upgrade after downgrade is the identity on the safe subset, a law emitted as a property test over the derived generators rather than left to review.
The migration table is an explicit allowlist, not a graph search. Only adjacent steps are ever authored or derived; a non-adjacent row such as V1 -> V3 = auto composes the declared adjacent ladder and publishes that route rather than emitting an independent pairwise converter, so N rungs cost N-1 conversions in each direction. A PlayerManual.Vn.upgrade/downgrade pair is offered for exactly the predecessors the table promises a route to the current rung for; omitting V1 -> V3 would mean the family does not promise V1-to-current migration, even where a path exists. In the block above every route is auto, so PlayerManual.V1.downgrade walks V3 down to V1 in a single call, unioning the loss each step reports (the two later rungs together contribute spell_slots, inspiration, and signature_spell), and PlayerManual.V1.upgrade walks V1 back up to the current rung, restoring every default a later rung introduced.
A step that is not purely additive, a field whose type changed, or an additive step whose generated default is wrong in one direction, replaces the direction needing judgment with version(upgrade = ..., downgrade = ...); either direction may stay auto. The supplied direction is an inline single-parameter lambda whose parameter is bound to the source rung and whose body constructs the target rung by naming its fields:
migrations {
V2 -> V3 = version(
upgrade = \(s) -> PlayerManual {
hero = s.hero,
calling = s.calling,
level = s.level,
signature_spell = s.signature_spell,
spell_slots = 7,
inspiration = 0,
},
downgrade = auto,
)
}
The lambda is checked against the edge’s exact interface, PlayerManual.V2 -> PlayerManual for the upgrade and PlayerManual -> (PlayerManual.V2, Wire.Loss) for the downgrade; a reversed endpoint, an extra effect, an upgrade that returns a Loss, or a downgrade that omits one is rejected against that signature. Naming a predecessor rung type in the signature of an ordinary top-level function (for example fn f(x : PlayerManual.V1)) does not yet resolve, so a version(...) direction is written inline. A version(...) row overrides only an adjacent edge, since a non-adjacent route is always auto.
Under the family-qualified surface, each adjacent step is an ordinary generated function whose flat spelling, upgrade_PlayerManual_V1_V2 and downgrade_PlayerManual_V2_V1, is minted mechanically from the type name and the two rung tags and reads in the direction of travel, source rung then destination. It is the internal adjacency the composed PlayerManual.Vn routes call, not a surface a program is meant to write; because the names are synthesized from the block header alone, renaming the type moves the whole family at once and no later phase parses a fact back out of a spelling.
Two diagnostics guard the table. An auto row the compiler cannot derive, a field whose type changed, or the rename, split, or merge that surfaces the same way, is E6065: it names the fields that need judgment and offers the smallest repair, an inline default when a required field was added or a version(...) override otherwise, and never guesses a correspondence. Were a later rung to retype calling instead of appending a field, the V1 -> V2 row could not be auto:
[E6065] cannot derive `stable PlayerManual` migration V1 -> V2
auto cannot change a field type: `calling`
supply the migration explicitly:
V1 -> V2 = version(upgrade = <fn>, downgrade = <fn>)
a rename, split, merge, or type change is never guessed
A row that names a rung the block does not declare, runs backward from a newer rung to an older one, or overrides a non-adjacent edge with version(...) is E6066, since a direct long route is a distinct edge rather than part of the adjacent ladder:
[E6066] migration `V2 -> V1` in `stable PlayerManual` must run from an older rung to a newer one
A rung marked frozen "<digest>" is sealed: the digest is the rung’s structural shape digest, the same construction that content-addresses every datatype (content-addressed core). Editing a sealed rung in place moves the digest and the program stops compiling, with the error naming the rung and the remedy: add a new rung instead of editing a shipped one. A rung that never shipped is reseated with prism store wire --accept <file>, which recomputes and rewrites its digest in place, loudly.
Freezing a rung’s shape is not enough on its own: the same old bytes could still decode into a different current value if an auto upgrade’s body changed while both rung shapes stayed fixed. The generated migration behavior is therefore sealed separately, in a sibling <source>.stable-lock manifest. Each adjacent edge is content-addressed by an edge hash folding the two rung shape digests together with the upgrade and downgrade converters’ canonical semantic hashes, the same per-definition Core identity that content-addresses every function (content-addressed core); each declared non-adjacent route is a route hash over the ordered edge hashes it composes, never a rehash of the composed bodies. A field’s default rides inside its upgrade hash and a generated loss label inside its downgrade hash, so changing either moves exactly the edges that cross it and every route through them. On the next build the manifest is re-derived and compared, and a generated migration whose behavior drifted, a changed default that relocates a loss path for instance, is E6067: it names the changed direction, the old and new component hashes, and the derived loss paths, then points at the remedy, relock an unpublished family or add a new rung so shipped behavior stays addressable. prism store lock --accept <file> reseats the manifest, previewing the rung, edge, and route hashes before it writes and reporting no change on a second run. A family with no manifest is unlocked and unchecked, exactly as an unshipped rung carries no frozen badge.
The block also derives the type’s Serialize against the current rung, and the byte-level frame is decoded two ways that share one signature. wire_encode_PlayerManual frames a current value under the current rung’s digest; the two decoders both hand back a current value or fail:
wire_encode_PlayerManual : (PlayerManual) -> Wire.Bytes
wire_decode_PlayerManual : (Wire.Bytes) -> PlayerManual ! {Fail}
decode_ladder_PlayerManual : (Wire.Bytes) -> PlayerManual ! {Fail}
wire_decode_PlayerManual insists the frame carry the current rung’s digest, while decode_ladder_PlayerManual accepts a frame from any rung the table promises a route for, decodes it at that rung, and composes the upgrades to hand back a current value; both refuse malformed bytes through the same Fail row rather than a sentinel value. The codec itself, the byte-level frame with its total decoder, is the Wire library, an opt-in import (the standard prelude): a program that never persists a value pays for none of this.
An ordinary value persists through the same frame without a hand-written digest string. deriving (Stable) carries one method, shape_digest_of, whose derived body is a per-type constant the compiler injects at the derive site: the type’s truncated structural shape digest, the same construction a frozen rung seals, computed in one place so the runtime frame check and the content hash can never disagree. wire_encode_stable(x) frames a value under its own digest; wire_decode_stable(bs) opens the frame, decodes the body at the annotated type, and fails unless the frame’s digest matches the type’s and no bytes trail. A wrong digest, wrong kind, truncation, or trailing byte is a hard Fail, never a mis-decoded value. Code that already holds a digest, a ladder rung or a peer’s advertised contract, uses the explicit escape hatches wire_encode_value_with_digest and wire_decode_value_with_digest. A hand-written instance Stable(T) is rejected outright: the class’s only method is compiler-computed, so a manual instance could only forge a frozen contract, and the error points at deriving (Stable).
10.3 Deprecation
A declaration is marked superseded with a deprecated annotation line directly above it, carrying the suggested replacement as a string:
deprecated "use `insert`, which also returns the displaced value"
pub fn add(m, k, v) = insert(m, k, v)
The annotation attaches to the declaration that follows it (a fn, type, class, effect, or any other named declaration) and records the suggestion; it is not itself a declaration. A deprecated line with no declaration after it, or two in a row, is a syntax error. deprecated is a contextual word, not a reserved one, so a program may still bind the name.
A use of a deprecated definition compiles, with a warning that names the definition, the suggestion, and the use site. It is only a warning: behavior is unchanged, so a deprecation never breaks a build or alters what a program computes (a determinism corollary: the warning is a diagnostic, not a semantic). A definition’s own body may use it without warning; only references from other definitions are reported, and only in the user’s own source, so a deprecation inside an imported library does not warn at the library’s internal call sites.
The policy is one deprecation window wide: a deprecated name keeps working with the warning through that window, and is removed after it. This is what lets the standard library evolve without a flag day: Base’s surface may only ever grow, or shrink through one full deprecation window, never break in place. The float dot-operators (+. and its family) and the operator-duplicating fixed-width builtins (i64_add and its family) rode exactly this window out and are gone: a surviving spelling exists for every one of them (+ on Float, + on I64), and writing a removed spelling is a pointed error naming it.
10.4 Function Contracts
A logic fn declares a proof-level function, and requires/ensures clauses attach a precondition and postcondition to an ordinary fn. These are logical propositions over Bool and Int, not runtime code: they are validated during compilation, erased before executable Core, and insert no runtime check.
logic fn between(x : Int, lo : Int, hi : Int) : Bool =
lo <= x && x <= hi
fn clamp(x : Int, lo : Int, hi : Int) : Int
requires lo <= hi
ensures |r| between(r, lo, hi)
= if x < lo then lo else if x > hi then hi else x
A requires clause is a Bool expression over the parameters; each ensures clause binds a result name in |r| and is a Bool expression over the parameters and that result. A logic fn is a total first-order abbreviation, usable only in these logical positions, that elaborates into the same logical form the clauses do; it cannot perform an effect, allocate, or call a runtime function. The three words are reserved because a clause body is an ordinary expression, so a contextual spelling would fuse with it under juxtaposition application.
Ordinary check, build, and run validate every contract (resolution, sorts, arities, the Bool requirement) and reject a malformed one as a source error, but never invoke a solver. prism verify FILE discharges the postconditions: it emits one canonical SMT-LIB obligation per ensures clause and runs them through an external solver (prism verify FILE --solver z3), reporting each function as verified, refuted by a counterexample, or pending when its body leaves the supported fragment. An unsat verdict is an honest solver-oracle receipt naming the trusted solver, not an independently checked proof, and prism dump smt FILE prints the obligations without running a solver. Because a contract is erased before Core, editing only a clause leaves every runtime artifact byte-identical; the machinery is described under function contracts.
10.5 Totality
total fn claims that a function returns a value in finitely many steps for every well-typed argument; assume total fn states the same claim as an explicit trusted assumption, accepted without a proof and kept visibly distinct from a checked one. The claim is a verification fact, not an optimization: it is erased before executable Core, changes no runtime behavior, and never gates ordinary compilation. total and assume are contextual, so both stay ordinary identifiers outside the modifier position.
type Nat = Z | S(Nat)
total fn depth(n : Nat) : Int =
match n of
Z => 0
S(m) => 1 + depth(m)
Deciding whether an arbitrary function terminates is undecidable, so Prism does not attempt the general case: a total fn is a claim discharged only where the argument is mechanical, and reported pending everywhere else rather than guessed at. The tractable fragment is small and exact, and covers the cases that arise most in practice. A total fn is checked when its body stays in a total fragment (no effect, handler, higher-order call, mutation, hole, or partial primitive) and either it is acyclic and calls only functions themselves certified total, or it is directly self-recursive and every recursive call consumes a strict constructor subterm of one matched parameter. The first case is trivial (an acyclic call graph of total pieces always terminates); the second is the standard structural-recursion argument (a well-founded descent on the algebraic argument). Anything past that fragment, mutual recursion, an effectful body, a non-obvious decreasing measure, or a call to an uncertified helper, is reported pending with a precise reason; the checker never labels a function non-total, because a restriction means it could not establish the claim, not that the function diverges. prism dump totality FILE prints the per-function status, and a totality proof composes with a contract into total correctness only when both close; the checker is described under totality.
When a function’s totality rests on a proof Prism cannot reproduce, assume total fn records that fact as an explicit, trusted claim rather than leaving it pending. Some functions terminate for a reason past the structural checker’s single strict-subterm rule, or past what a solver can settle from a decreases measure: Ackermann’s function descends on a lexicographic pair, and other definitions rely on an ordinal argument, an external termination checker, or a paper proof. A user who holds that proof asserts the claim directly rather than restructuring the code to fit the checker:
assume total fn ackermann(m : Int, n : Int) : Int =
if m == 0 then n + 1
else if n == 0 then ackermann(m - 1, 1)
else ackermann(m - 1, ackermann(m, n - 1))
An assume total is trusted, never silent. It is visibly distinct from a checked claim in diagnostics and docs, cannot be relabeled as proved, and every certificate that depends on it carries the assumption transitively, so a strict policy can reject any proof resting on an assumption. Editing the body or a precondition moves the assumption’s identity, so a trusted claim can never quietly outlive the code it was asserted about. This is the one honest boundary between what Prism proved and what the user vouched for, which is why a plain, unproved total fn is reported pending and never consumed as if it were true.
10.6 Test Declarations
A test fn is a private, zero-argument function returning Unit whose effects are limited to Fail and IO. Returning normally passes; fail(), a runtime fault, an unhandled effect, or any explicit exit fails with a distinct reported outcome. Tests may use private definitions in their own module, but cannot be pub, take parameters, or be named main.
fn double(n : Int) : Int = n * 2
test fn double_of_three_is_six() =
if double(3) == 6 then () else fail()
prism test accepts a project, one source file, or the enclosing project by default. Project discovery includes tests in every project-owned module, even modules unreachable from the executable entry point, and integration modules are checked as package consumers that see only the public API. Logical test identities and execution order are deterministic; --filter/--exact, --list, --no-run, --json, --show-output, and --fail-if-no-tests select or report the same manifest without changing it. Each test runs in a fresh interpreter world with captured output, so state and effects cannot leak between tests.
Test declarations are retained only in test mode. Ordinary check, build, run, interfaces, Core hashes, native objects, and binaries strip them before semantic identity is taken, so adding or editing a test cannot change a production artifact.
11. Modules
A file is a module and a directory is a namespace prefix: import Data.Map loads Data/Map.pr. A project is a prism.toml manifest plus a source tree, resolved from the project root. A single-file program is one module.
import M brings M’s exports into scope under qualified names; import M (a, b) also brings a and b into bare scope; import M as N adds the alias N. The pub modifier on a declaration makes it visible to importers; pub import M (x) re-exports x through the importing module. An opaque type exports its name but not its constructors.
pub fn area(w, h) = w * h -- exported
fn clamp(x) = if x < 0 then 0 else x -- private to the module
An opaque type is how a module exports an invariant instead of a representation: importers can name the type, hold values of it, and pass them back, but only the defining module can construct or inspect one, so every value in circulation went through the smart constructor and carries whatever guarantee it enforces.
opaque type Celsius = MkCelsius(Float)
pub fn celsius(x : Float) : Celsius =
MkCelsius(if x < -273.15 then -273.15 else x)
pub fn degrees(c : Celsius) : Float =
match c of
MkCelsius(x) => x
Celsius values below absolute zero cannot exist, and the proof is the module boundary rather than a runtime check at every use site: celsius clamps once, on the only road in.
Name resolution rewrites every top-level definition to a canonical, module-qualified symbol (an export as Data.Map.insert, a private as the unforgeable, source-unwritable Data.Map@helper) and merges the checked modules into one program keyed by those symbols. Because identity is canonical, two modules may export the same short name and coexist. Whole-program merging remains the semantic authority, while durable module interfaces and checked bodies provide early cutoff: an implementation-only edit may rebuild its module without forcing importers whose interface dependency is unchanged. Later compiler artifacts use content-addressed Core identity, so formatting and local renaming do not move behavior and a semantic change propagates only through its dependency closure (content-addressed core).
Instances are global, but each records its defining module. An orphan instance (defined apart from both its class and its head type) and instances that overlap across modules are reported as warnings; an ambiguity names each candidate’s module.
A bare name, one written without an M. qualifier, resolves by consulting five tiers in order and taking the first that offers it:
- locals: parameters,
letandvarbindings, match binders, and handler binders - the module’s own top-level definitions
- the prelude’s top-level definitions
- names opened by the module’s own imports
- names opened by the prelude’s imports
That order is what lets the library and a program grow independently. A module’s own definition of a name outranks a prelude definition of it, and the prelude is looked up in its own scope, so a program that defines children gets its own at its use sites while the prelude keeps calling the prelude’s: a top-level definition shadows a prelude name rather than replacing it. Adding a helper to the prelude therefore cannot silently rebind a program that already defines that name, and defining a name in a program cannot silently rebind the prelude’s internals. Tier 4 above tier 5 says the same thing for opens: a module’s own import M (..) outranks whatever the prelude opened, so a library whose names overlap the prelude’s can be opened without editing either. A prelude definition still outranks a module’s imports, so importing a name the prelude defines does not by itself replace it; define the name, or qualify the use.
Only tiers 4 and 5 can offer one name from more than one place, and that is not an error at the import. The clash is reported where a bare use actually forces the choice:
import Walk (..) -- exports `children` and `rename_all`
import Rename (..) -- also exports `children`
fn main() =
println(show(Walk.children(1))) -- fine, qualified
println(show(rename_all(0))) -- fine, only one module offers it
println(show(children(1))) -- error: ambiguous
The diagnostic names every module exporting the contested name and asks for a qualifier; Walk.children or Rename.children says which, and a qualified name is never ambiguous because it names exactly one module. Deciding at the use site is a behavioral commitment, not an implementation detail: a program keeps compiling when a library it imports gains an export that collides with another import, and only a bare use of that particular name has to be updated.
11.1 Projects
A single .pr file compiles on its own (prism file.pr), resolving imports relative to its own directory. A multi-file program is a project: a prism.toml manifest at the root plus a src/ tree, where dotted module paths resolve from the source root rather than from the entry file’s location. The smallest manifest names the package and its entry point:
[package]
name = "myapp"
[bin]
entry = "src/main.pr"
Inside a project, the everyday verbs default to the nearest enclosing manifest: prism build compiles it to a native binary under a target/ directory at the project root (rustc-style), named after the package; a bare prism run builds that binary and executes it, forwarding arguments after -- and its exit status; prism check and prism test operate on the project; and prism clean removes target/. prism run <path> interprets a file or project directly, and a single file is still built with a bare prism file.pr. The manifest keys are:
| Key | Section | Required | Meaning |
|---|---|---|---|
name | [package] | yes | package name; also the default binary name |
entry | [bin] | yes | the entry .pr file, relative to the project root |
src | [package] | no (default src) | the module root that dotted import paths resolve from |
prelude | [package] | no | a .pr file whose contents replace the built-in prelude for this project |
[dependencies] | table | no | path, hash, or git-package dependencies |
A dependency’s modules import under their own dotted paths, so a geometry = { path = "../geometry" } entry makes that project’s Geometry module reachable as import Geometry. The table accepts every dependency source form the package manager understands:
[package]
name = "myapp"
[bin]
entry = "src/main.pr"
[dependencies]
geometry = { path = "../geometry" }
legacy_geometry = "../legacy-geometry"
crypto = "prism-core-hash-v1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
http = { git = "github.com/prism-lang/http", version = "stable" }
The table form path = "../geometry" names a local Prism project, and the bare string form is a path shorthand unless it starts with the prism-core-hash-v1: scheme prefix. Path dependencies are editable source roots: they extend the module search path and deliberately remain tied to the local filesystem while developing.
A hash dependency names a source bundle directly and is already the exact accountable identity the build will use. A git dependency names an opaque version tag whose signed package index entry maps (git URL, dependency name, version) to that exact source-bundle identity: origin, display name, artifact kind, hash scheme, and root. Versions are not ranges and are not solved.
prism pkg add writes the matching manifest row and prism.lock pin. A project build loads non-path dependencies from the configured package store only after the bundle digest, artifact kind, and hash scheme match the lock and signed index; git dependencies additionally require the package index to authenticate the origin name@version -> source-bundle pointer (unsigned indexes are accepted only under the explicit local-development signing mode). The rule is intentionally asymmetric: path dependencies are live source, while hash and git dependencies are accountable artifacts.
12. The Standard Prelude
The library ships in two rings.
Base is the always-on prelude, in scope in every module without an import: the core types (Option, Result, List, tuples), the class tower (Eq, Ord, Show, Num, Div, Hash, and the Functor/Foldable/Applicative/Monad/Traversable structures), the string and character basics, the effect vocabulary (Exn, Fail, and the capability effects), and the core combinators. It is ordinary Prism, not built-in, assembled from modules under lib/std: the prelude opens a fixed set of Data.* modules with import M (..) so their names are unqualified everywhere. Base is small and its surface is frozen: it may only grow, or shrink through one full deprecation window, never break in place. The exact surface is pinned by a committed golden, so an accidental addition fails a test in review rather than silently widening the frozen ring.
Being always in scope does not make Base’s names reserved. A program that defines a top-level name Base already uses keeps its own definition at its own use sites, and Base goes on calling Base’s, so nothing in the library breaks and nothing in the program is captured; Base’s opens sit at the bottom of the lookup order, below a program’s own imports. The precedence rules are in modules.
Std is everything else the compiler ships (Replay, Concurrent, Incr, Wire, Time, Json, Sequence, and the rest), reached only through an explicit import. Std is distributed as a pinned content-addressed root through the store: “the standard library” is a single hash, the fold prism dump stdlib-hash reports, over every Std definition’s behavior hash and every type, class, and instance digest (content-addressed core).
A lockfile records that root in a std line with its hash scheme. When the pin matches the compiler’s embedded Std root, the embedded source table is used as the default and offline path; when the pin differs, project builds resolve Std imports from the configured store as a source bundle keyed by that pinned scheme/root, and a missing, malformed, or foreign-scheme bundle is a hard diagnostic rather than a silent fallback. Because the root is content-addressed, everything reachable from it is the zero-cost baseline both ends of a transfer assume, and never travels.
Beyond Std are first-party packages resolved through the store (prism.toml dependencies): blessed, but not frozen with the language.
The rings and the store still bound how far a Std pin carries. Alternate Std selection is source-level: the resolver can load modules from a store-served bundle, while the embedded tree remains the offline default and the prelude ring remains the frozen compatibility surface. Package-grade serving of compiled definitions and dependencies is unsupported.
This document does not restate the API. The Standard Library part of this book is the per-declaration reference for every prelude and stdlib module, generated from the source by prism docs and regenerated against the typechecker so it never drifts.
13. Semantic Patches
Semantic patches are code changes described at the intent level rather than as line-by-line edits. A patch names the semantic definition it replaces, carries a canonical replacement term, and asks the compiler to judge the resulting meaning instead of telling a text editor which character ranges to rewrite.
At the semantic boundary a Prism codebase is a content-addressed directed acyclic graph: recursive definitions collapse into strongly connected components, each checked definition is identified by its dependency-substituted Core hash, and inter-component references are edges. A patch is correspondingly a graph edit. It pins both the exact node and the whole namespace it observed, carries a content-addressed surface-term replacement, reconstructs the candidate graph, and reports the transitive importer cone whose meaning may have to be reconsidered. The model is Unison’s content-addressed codebase, but Prism pairs semantic identity with a lossless surface term: the Core hash ignores names, spans, comments, and formatting, while the surface-term hash commits to formatter-canonical tokens and trivia. Rendering a validated surface term produces exactly one canonical declaration and extracting that declaration reconstructs the same term, so the content-addressed graph and the source files stay equivalent machine and human views of one codebase.
The shipped prism-patch-v1 transaction accepts one uniquely named top-level value declaration and a same-name, same-kind replacement. fetch returns the canonical term with its digests, shape, type, effect row, grade, and dependencies; impact returns the importer cone; create packages the replacement pinned to the observed namespace and Core digest; submit (alias apply) checks the reconstructed program, records the semantic delta, and stages the candidate without touching source; behavior compares old and new observation traces over an explicit stdin/argv corpus; commit re-verifies the staged and namespace digests and installs the canonical projection by atomic rename; discard drops the staged reference. The command-line reference documents these verbs and the equivalent patch serve stdio protocol.
Each judgment records the base and result namespace roots, before/after term and Core digests, shape, effects, grade, public module interface, and impact, at a proven tier: tier 0 is term-digest identity, tier 1 is a changed surface with unchanged Core identity, and tier 2 is changed Core with preserved shape, effects, grade, and public interface. Tier 2 is not behavioral equivalence, so claimed_delta stays explicitly unjudged; a behavior receipt is separately addressed and claims only equivalent-on-corpus, never universal equivalence. Stale namespaces or targets, malformed artifacts, checker failures, ambiguous ownership, kind or name changes, interface movement, and ambient host behavior during receipt generation return content-addressed structured refusals before any mutation.
An LLM does not reason the way a person navigating a file tree does, and it does not need the tree: rather than grepping flat text and rewriting the character ranges it hopes are the right ones, it can traverse the whole content-addressed graph of judged definitions directly and patch it by naming a node and its replacement, an exact graph edit. Giving machines the format they actually work in, a typed graph built for construction and precise refactoring, suits code synthesis better than forcing them through hierarchies of flat files26; canonical source stays the readable, versionable, and forensic projection of every accepted change.
-
Ergo the compiler is serenely uninterested in what you named your variables: two functions that reduce to the same normal form are the same function, whatever their authors privately felt while writing them. This is a liberation if you are not attached to your variable names and a quiet bereavement if you are. ↩
-
The lexical minimum of the signed fixed-width lane is written by folding the sign into the literal:
-9223372036854775808i64isI64min, one past the magnitude the bare positive literal admits. ↩ -
A nod to “a monad is just a monoid in the category of endofunctors, what’s the problem?”, and like the original it is deadpan and true. Performing an operation reifies the rest of the program as a value, the continuation
k, and the whole zoo of control effects is a usage contract on that one value:neverdiscardsk,oncespends it exactly once,manyspends it freely. That is the@lattice landing on a continuation instead of a closure, so!(what a computation may do) and@(how a value may be used) were never two systems, just one lattice read from both ends. The continuation was the first value in the language to carry a coeffect. ↩ -
“Never overflows” holds in the manner of most sweeping assurances: the number grows another limb instead of wrapping, and keeps growing, right up until it meets the finite quantity of memory the machine actually has, at which point the arithmetic ends the ordinary way and takes the process with it. ↩
-
The four sign combinations make the rule concrete:
7 / 2and(0 - 7) / (0 - 2)are3, while(0 - 7) / 2and7 / (0 - 2)are-3;7 % 3and7 % (0 - 3)are1, while(0 - 7) % 3and(0 - 7) % (0 - 3)are-1. ↩ -
Division wraps on the one signed input that would overflow it, so
I64_MIN / -1on theI64lane isI64_MINandI64_MIN % -1is0, consistent with the wrapping add/sub/mul rather than trapping; only a zero divisor faults. ↩ -
The edge is where a number stops being a mathematical object and becomes a physical one. An
I64is not an integer but sixty-four transistors talked into standing for one, and the wrap is the moment they run out of room to carry. The bignum has no edge only because it buys more matter as it climbs, which postpones the confrontation with the machine rather than escaping it. ↩ -
wrapping_negonU64is that same two’s-complement wrap the lane’s other operations use, sowrapping_neg(0)is0andwrapping_neg(x)isU64_MAX - x + 1for a nonzerox, rather than a fault or a rejection; the unsigned lane simply has no non-wrapping negation to prefer. ↩ -
The overflow cases follow the primitives exactly:
checked_add(I64_MAX, 1)isNonewhilesaturating_add(I64_MAX, 1)isI64_MAX;checked_neg(I64_MIN)andchecked_div(I64_MIN, -1)areNone, the two signed edges where the exact result escapes the lane; andchecked_subonU64isNoneon any unsigned underflow, withchecked_negthereSome(0)only for0. ↩ -
nanis the one value with no route home: every operation applied in the hope of repairing it only propagates it further, and it declines to be equal even to itself, a solitude most values are spared having to contemplate. ↩ -
Unary minus on a
Floatis a genuine sign flip, not a subtraction from zero, so-(0.0)is-0.0(a subtraction0.0 - 0.0would give+0.0) and-(-0.0)is0.0; the sign flip is bit-identical on the interpreter and both native backends. ↩ -
The determinism flag that makes this hold at the lowest bit is floating-point contraction disabled everywhere (
-ffp-contract=off), so no fused multiply-add fusesa*b+con one platform and not another, in ordinary arithmetic or inside these functions. ↩ -
Domains and special values follow the usual conventions and propagate IEEE special values: a
nanargument yieldsnan;asinandacosarenanoutside[-1, 1];sqrtof a negative isnan;ln,log2,log10are-infat0andnanbelow it;atan2andhypotare defined on the whole plane; and every function is total (none faults), so like the operators they add no failure edge to an effect row. ↩ -
All three then apply one saturating cast: a value beyond the signed 64-bit range clamps to that range’s endpoint, and
nanconverts to0, matching the interpreter’s semantics exactly (the native backend uses the saturating conversion, never the undefined-on-overflow one). A result that exceeds the tagged-immediate range becomes a bignumInt, sotruncate(1e300)is the saturated9223372036854775807on both backends rather than a wrapped low word. ↩ -
This is the sort of abstraction the field likes to call free: the polymorphism is a compile-time fiction, and nothing is charged at run time for a convenience used only at type-check time. As with most things called free, the cost was entirely real and simply billed earlier, to the compiler. ↩
-
The folklore that a monad is a burrito is wrong in the usual ways, but the menu has structure. The
@coeffects are the taco, open toward the context and describing how it may consume the value; the outward! {E}effect row adds the gordita shell, recording what the computation may perform, so the full computation type is a Cheesy Gordita Crunch. A monad is the burrito one abstraction up, packaging the sequencing discipline itself; a transformer stack is presumably a Crunchwrap Supreme. Nobody knows how far this hierarchy goes. The serious duality survives the tortilla:@demands inward and!reports outward; see coeffects. ↩ -
Although, if you think about it, an effectful
(a) -> b ! {E}is a Kleisli arrow(a) -> m(b)with the monad scraped off the result type and smeared into the rowE: composition collapses to plain., the row keeps the booksbindused to, and a handler is thejoinyou never had to write. ↩ -
Noneis the well-mannered descendant of a much costlier idea: a way to denote absence that the type obliges you to handle, rather than one that lies quietly in a pointer waiting to be dereferenced at the least convenient possible moment. Estimates of what the wilder ancestor cost the industry are usually quoted with ten digits. ↩ -
One could cast past and future facts as a comonad and a monad, the covariant modality with its
extract, the contravariant one with itsunit, and the types would line up. Prism declines the ceremony: polarity is just a rule about which side of a seam owes the evidence, which costs a checker a direction bit rather than a category. ↩ -
A promise the checker holds you to. There is no annotation for “trust me”: the only way past the check is to satisfy it. ↩
-
The row is itemized on purpose: a function may not claim a vague
IOand leave which part of the world unsaid, because the capability it names is exactly the one record and replay will hold it to. ↩ -
Prism’s answer to the unreliability of physical time is to decline to own a clock. Wall time keeps passing outside the program, uninvited and unread; inside, time is a counter the handler increments, so a run does not happen at a moment so much as recite one. It is the only way to make “what time is it” a pure function of the source. ↩
-
The disk is treated, correctly, as an unreliable narrator. A file is matter, and matter is revised behind your back, so Prism keeps the hash of the bytes it actually read, an observation made once, rather than a path it would have to trust to still mean the same thing later. What persists on the platter is the world’s business, not the run’s. ↩
-
This is the closest a computation comes to shedding its physical location, and it still has to pack a bag. The suspended form would rather exist nowhere in particular, but to arrive somewhere it must serialize to bytes and cross a wire made of actual copper; the envelope is the ticket. Even the escape from matter is conducted in matter. ↩
-
So
2 ^ -1is0,1 ^ -5is1,(-1) ^ -5is-1, and0 ^ -1faults as the division by zero it literally is. ↩ -
Prism is regime-neutral on which machine future arrives. If the Butlerian Jihad outlaws thinking machines, we add a
humanannotation, make it enforced, and call it a flag change: the compiler now proves a person wrote your code. If The Culture arrives instead, Prism is just what the Minds use to play Truth Mines Farmville in Infinite Fun Space while we live in their post-scarcity utopia. ↩
The Prism Compiler
This document describes the prism compiler, from source text to native binary across its three backends. The chapter on verification describes the model and how the Lean 4 kernel anchors the compiler’s verification chain.
0. Design Principles
Fifteen ideas structure the whole compiler. Everything later in this chapter is one of them worked out in detail; this section is their high-level, stable summary.
- The World is an Effect. The unfortunate existence of reality intrudes on our pure functional fun, so the best we can do is constrain the world’s heterogeneous effects and categorize them in a principled way. Prism makes that compromise explicit: effect rows delimit the world’s authority, totality evidence says whether a computation comes back, traces pin what crossed the boundary, content hashes name the program and the artifacts that emerged, and lineage records the causal path between them. Once every intrusion from the real world is typed, pinned, or named, the result is finally truly pure. And finally then our programs can safely never be run. 1
- Determinism is the contract. Every observable behavior of a compiled program is a pure function of the source and the pinned input trace. The effect-lowering strategy, the optimization level, the backend, the scheduler, and the compiler’s own prior workload are cost decisions and must be unobservable. Replay, content addressing, and cross-backend attestation are corollaries of this one theorem, not features beside it.2
- One semantic pipeline, whole-program. Modules are checked independently for incrementality, but they compose into one merged program before the middle end, so specialization, effect strategy, closure planning, and reachability range over everything at once. Module artifacts join the whole-program path; they never route around it.3
- Representations carry the design. Each phase lowers into a representation that can express strictly less than the one above it: sugar becomes unrepresentable after desugaring, unresolved names after resolution, implicit choices after checking. A decision made once is never reopened downstream, and a missed lowering is a compiler type error rather than a runtime surprise.4
- The core is call-by-push-value in A-normal form. Values and computations are syntactically distinct, so every effect operation, allocation, and evaluation-order decision sits in a distinguished position. This is what makes the middle-end passes tractable and is where the language’s meaning and its content-addressed identity are fixed.5
- The checker decides; elaboration transcribes. Bidirectional type and effect inference resolves every implicit choice, records it as a checked fact keyed by node, and no later pass makes a type-system judgment again. Effects are principal row types solved by unification, never silently widened.6
- The middle end carries witnesses and is independently verified. Transformations run over a typed Core whose phase markers make illegal stagings unrepresentable, and an independent verifier re-checks scope, type, effect, handler, and reuse witnesses at every phase transition. This is translation validation: verify each output, rather than prove each pass.7
- Effect lowering is a cost cascade, invisible by construction. Handlers compile through a ladder of strategies from full erasure through evidence passing down to the free monad, and the tier chosen must never be observable; a differential oracle forces slow tiers and diffs them against fast ones. Capability effects make the input trace explicit, which is what keeps record and replay total.8
- Memory is garbage-free reference counting with reuse. Ownership is inserted as explicit dup/drop operations, verified balanced on every path, and matching allocations reuse cells in place, so functional updates compile to mutation without a tracing collector.9
- Identity is content-addressed; incrementality is memoization. Definitions, interfaces, and artifacts are named by hashes of their semantic content, so an edit invalidates exactly its dependency closure and a build is a demand-driven query graph with early cutoff.10
- Backends render; the emitter decides. One shared emitter owns every semantic decision, and a backend is only instruction spelling behind a small trait, so targets cannot drift apart semantically and their agreement is a continuous cross-check. The interpreter, consuming pre-lowering Core, is the reference oracle every native backend must match byte for byte.11
- The store remembers; it does not decide. A cached artifact may accelerate the canonical pipeline only when its identity and phase invariants validate. A cache hit can reuse an answer; it can never become a second semantic authority or invent an answer the uncached compiler would not produce.
- Every guarantee names its witness. A phase type, an independent verifier, an interpreter comparison, a Lean theorem, a solver receipt, a digest, and a replay certificate establish different things. Every claim says which witness supports it, what that witness checked, and what remains trusted; no claim is allowed to grow stronger in the retelling.
- Nothing is ambient; every dependency is data. The world a program touches is spelled in its row; the moments it observes are frames in its trace; the identity of a definition is the hash of its behavior; the origin of every artifact is an edge in its lineage. What is not declared cannot happen within the guarantee; what happened can be replayed; what exists can be named; what was produced can be traced.12
- The compiler should be fun to hack on. If it is not fun, what is the point? Prism does not need to be useful or do anything in particular; it just needs to be fun.
1. Architecture
Compilation is one semantic pipeline from source text to a native binary. Durable queries memoize boundaries where reuse is proved equivalent to the whole-program path: module interfaces, checked bodies, and HIR, plus SCC optimizer and backend artifacts. The whole-program merge remains the semantic join (module artifacts compose into it, never around it), and within the middle end the verified typed Core is the transformation authority; the raw merged Core persists as its erased content-identity and compatibility view. Each phase lowers into a representation that can express strictly less than the one above it, so a decision made once is never reopened downstream.
| Phase | Role | Resulting invariant |
|---|---|---|
| Lex | text to tokens, then layout | block boundaries are explicit |
| Parse | tokens to surface AST | syntax is structured; names remain unresolved |
| Resolve | load imports, canonicalize names, merge | every reference has one globally unique target |
| Desugar | surface sugar to core surface | surface-only nodes are uninhabited |
| Check | type and effect inference | types, rows, kinds, and evidence are fixed |
| Elaborate | surface to CBPV / ANF core (match compilation, pattern-match compilation) | evaluation order and representations are explicit |
| Optimize | Core-to-Core passes, in two stages around effect lowering | rewritten Core passes structural checks |
| Effect lower | remove handlers and operations | no abstract effect node survives |
| Reference count | insert dup/drop, then reuse | ownership and reuse balance on every path |
| Codegen | optimized core to the interpreter; lowered core to LLVM or MLIR | target choice changes cost, not behavior |
The dump phases walk the pipeline in order:
<phase> | prints |
|---|---|
tokens | the token stream |
syntax-tokens | the versioned token/trivia/layout export as JSON |
ast | the parsed syntax tree |
surface-syntax | the versioned ordered surface-AST export as JSON |
syntax-diagnostics | every lex or parse refusal as a versioned artifact |
types | inferred types and effect rows |
typespans | versioned typed source ranges for editor and docs tooling |
hir | the checked-HIR fixture: per-node checker facts as JSON |
interface | the entry module’s checked interface, the importer-cutoff key |
module-graph | the versioned module dependency graph |
tc-input | versioned declarations entering the checker |
resolved-syntax | each user function’s resolved body as a node-id-carrying tree |
tc-facts | versioned facts produced by the checker |
elab-input | the declarations and facts consumed by elaboration |
verify | the module’s logical verification interface |
smt | contract and termination obligations as SMT |
totality | each function’s totality status |
core | the elaborated core |
dupes | definitions grouped by equal behavior hash |
core-json | the core as a JSON tree the Lean model reads |
core-identity | the exact surface the behavior hash is taken over |
core-hash | a content-addressed hash of each definition’s elaborated core |
native-kont-table | the native-symbol-to-definition-hash table |
native-kont-state-map | the entry ABI-word state map |
shape | structural digests for types, effects, and classes |
namespace | the versioned content-addressed definition export |
stdlib-hash | the standard library’s Merkle root and members |
fbip | core after reference-count insertion and reuse |
lowered | core after effect lowering |
tier | the effect-lowering strategy the handlers compile to |
captures | closure-capture portability facts |
usage-summary | a per-definition allocation, fip, borrow, and effect table |
usage-summary-md | the same usage facts as a Markdown table |
usage-summary-json | the same usage facts as JSON |
llvm | the LLVM IR |
mlir | the MLIR form |
A project build writes its output rustc-style into a target/ directory at the package root rather than the working directory; -o overrides and prism clean removes it. The full surface of flags, environment variables, and REPL commands is tabulated under command-line interface.
Phases
The program is lowered by a fixed sequence of phases, each stripping its input of the choices the previous one owned:
- Resolve and desugar turn the surface tree into the core-phase surface: every name becomes a globally unique symbol and every sugar node becomes
Never, so a missed desugaring is a compiler type error, not a runtime surprise. - Typecheck infers a type and a principal effect row for every node, bidirectional and higher-rank, with effect rows solved by unification rather than silent widening; an ill-typed program or an unhandled effect is rejected here, and no later pass makes a type-system judgment again.
- Record each implicit choice inference resolved, which field a
.fnames, which instance discharges a constraint, which numeric type a literal takes, as the checked HIR, the seam where deciding ends and emitting begins. - Elaborate lowers the HIR into core: resolutions become constructor projections, evidence becomes dictionaries, node types pick concrete representations.
- Optimize, effect-lower, and reference-count rewrite core to core, each pass reading and writing the same IR.
- Codegen reads core into the interpreter, the LLVM backend, or the MLIR backend.
Intermediate Representations
Each step leaves the program in a different representation, and it is the representations, not the phases, that carry the design:
| Representation | What it fixes | Form |
|---|---|---|
| Surface tree | the program as written, sugar and unresolved names and all | Expr<Surface> |
| Core-phase surface | names resolved to unique symbols, sugar removed | Expr<Core> (sugar nodes are the uninhabited Never) |
| Checked HIR | every implicit choice resolved | the tree plus a dense per-node NodeFacts |
| Core | value/computation split; every effect and allocation explicit | call-by-push-value in administrative-normal form |
| Typed core | witness-carrying types and effects across the verified prefix | Core plus scope, type, effect, handler, and reuse witnesses |
| Backend IR | the machine form | interpreter, LLVM, or MLIR |
CBPV’s split between values and computations is what makes those middle passes tractable at all, since every effect operation and every allocation sits in a syntactically distinguished position, and core is where the language’s meaning is fixed and its content-addressed identity is taken. Each step removes exactly one class of ambiguity, syntax, then names, then sugar, then semantics, then surface structure, so a pass never reopens a prior one’s decision, and each representation carries its own verifier: the HIR its lint, typed core its independent checker, executable core its lint and the differential oracle, the store its content hashes. The typed core carries witnesses through a verified middle-end prefix so a pass that silently changes a type is caught structurally, the way lint_hir catches a bad front-end fact, rather than by differential testing alone.
Little in this design is new; the combination is. Whole-program optimization over merged Core, so specialization and monomorphization range over the entire program, is MLton’s. Type classes by dictionary passing, structural deriving, and the higher-kinded class tower come from Haskell as realized in GHC; the garbage-free reference counting with in-place reuse underneath them is the Perceus line, and effect handlers compiled by evidence passing over row-typed effects are Koka’s. Compiling pattern matches to decision trees and tail recursion modulo cons are the ML tradition’s, most directly OCaml’s. Content-addressed definition identity, the thing that turns a whole-program compiler incremental, is Unison’s. What is Prism’s own is holding all of these together, which the rest of this chapter takes apart pass by pass.
Every phase returns its result into one thiserror enum whose variants carry stable phase-specific error codes and include lex, parse, resolve, type, codegen, runtime, IO, and InternalInvariant; diagnostics are rendered with source carets through ariadne, mapping spans back through the prepended prelude to the user’s own text. An internal invariant is returned as InternalInvariant rather than exposed as a source-triggered panic, so malformed source yields a diagnostic.13 The crate denies unsafe by default (unsafe_code = "deny") and carries two audited exception sites: LLVM’s dynamic byte-offset getelementptr builder and the interpreter’s FFI calls into the vendored libm. Of the handful of panic!s in the tree all but one are test assertions, the exception being a PRISM_CORE_LINT-gated sanity check on the compiler’s own IR (see lint, telemetry, and parity).
One invariant sits under all of it: a program’s observable output is a pure function of its source and its pinned inputs, and nothing below the source may leak into it. The effect-lowering tier (see effect lowering), the optimization level, and the backend (see backends) are cost choices, not semantic ones, so they must be byte-invisible, and two oracles hold them to it: the interpreter every native backend must match, and the tier_parity check that forces each program onto a slower tier and diffs its output against the fast one. Replay, content addressing, and cross-backend attestation are then corollaries of that single property rather than features bolted on.
2. Lexing and Layout
The lexer produces a token stream and trivia (comments and spacing) that the formatter uses to reproduce source faithfully. An interpolated string is lexed by re-lexing each { ... } hole at its absolute source offset, so spans inside holes remain accurate. A layout pass then rewrites the stream, inserting virtual block-open, block-close, and separator tokens according to the offside rule of the layout specification, which the grammar consumes as ordinary terminals. One shape needs care: a class, instance, or effect body is bare-indented with no keyword (like of or =) for the offside rule to anchor on, so on the header the lexer emits a synthetic opener, VHead, that starts the block; this lets an empty body and an indented one share a single grammar rule, and it is why those bodies became layout-sensitive when braces were retired. Layout is suspended inside brackets, so a parenthesized expression spans lines freely. Both are the layout pass’s concern, never the grammar’s, which sees only the virtual tokens.
Both layers also exist a second time, written in Prism, as Syntax.Lex and Syntax.Layout. They are diffed against this implementation rather than substituted for it, and the open-compiler section describes what that buys and what it does not.
Comments are one form only: -- to the end of the line. There are no block comments. This is, on purpose, the least interesting decision in the language, because the lexical syntax of comments is by long observation the most bikeshed-prone corner of language design:
In any language design, the total time spent discussing a feature in this list is proportional to two raised to the power of its position:
- Semantics
- Syntax
- Lexical syntax
- Lexical syntax of comments
Lexical syntax is a notoriously fraught topic, in functional languages especially. Every engineer is certain they alone know what “readable” is, and not one can tell you why; it is governed by fashion more than science. So Prism does not care: things are spelled the Prism way, and a reader who finds that unreadable is warmly reminded that many other languages exist. Prism is the honey badger of unused functional languages; Prism does not care what you think is readable.
3. Parsing
The grammar is an LALR(1) grammar in LALRPOP, with two entry points: a whole program, and a single expression for the REPL. Parsing produces the surface AST. Type and parse errors are rendered with a source caret.
4. Name Resolution and Modules
Resolution loads every transitively imported module, rewrites each top-level definition to a globally unique canonical symbol (an export as Data.Map.insert, a private as Data.Map@helper), resolves qualified and re-exported references to those symbols, and records a versioned ModuleGraph. The canonical-symbol scheme makes the eventual merge sound, since two modules can export the same short name without collision.
Resolution also fixes what a bare name means. The prelude is prepended to a root program as source text, so the root parses as one file holding two regions: the prelude’s text and the user’s. The byte offset of that boundary rides on the parsed program as data, not recovered later from a name or a naming convention, and the resolver keeps a separate set of top-level definitions and opened imports per region. A bare name in the user region is looked up in locals, then the user’s own top-level definitions, then the prelude’s, then the user’s opens, then the prelude’s; a bare name in the prelude region sees only locals, the prelude’s own definitions, and the prelude’s opens. That asymmetry is what makes a user definition shadow a prelude name instead of replacing it. When both regions define the same name, the prelude’s definition is the one renamed, to a private canonical symbol under an unspellable module path, so the prelude’s internal calls keep landing on it while the user’s bare uses land on the user’s, and post-resolution generated code that emits a bare wired-in hook name behaves exactly as before. No collision means no rename: a program that redefines nothing resolves to the same symbols it always did, and a module compiled as a module has no prelude region at all, so the stdlib’s canonical symbols and content hashes are untouched.
Opening a name is not the same as choosing it. Building an import scope records every candidate a short name has rather than rejecting the second one, and a name offered by two modules is reported only where a bare use forces the choice, with the diagnostic naming the owning modules and asking for a qualifier. An import that is never used ambiguously is never an error.
For an acyclic graph, the driver checks independent modules in deterministic dependency layers. An importer is seeded from each dependency’s rehydrated ModuleInterface, without reading that dependency’s implementation body; successful interfaces and warning-free checked bodies (including checked-HIR facts) are durable query artifacts. Cyclic graphs use the merged checker fallback. After checking, modules still merge into one flat program. Whole-program typed effect lowering is always recomputed as the verified authority; durable queries begin again at the post-lowering optimizer boundary.
5. Desugaring
Desugaring rewrites surface sugar into the smaller core-surface language the checker and elaborator handle. Each rule below shows its surface form beside the elaborated Core the compiler prints for it (prism dump core, prelude elided), so the target is read off the real artifact rather than a hand-drawn approximation; the binder ids (t@733) are the compiler’s own.
The surface tree is parameterized by its compilation phase. An Expr<P> holds its sugar-only forms, its parse-time markers, and its surface-only handler clauses in fields whose types are associated types of the phase P: in the Surface phase those are the real sugar payloads, and in the Core phase, desugar’s output, they are the uninhabited type Never. Because Never has no values, a sugar node cannot be constructed in the core phase at all, so a missed desugaring is a type error in the compiler rather than a runtime unreachable!, and every later pass over Expr<Core> is statically excused from matching the sugar cases.
Function composition lowers to a lambda, kept as sugar only so the operator survives formatting.
fn inc(x) = x + 1
fn dbl(x) = x * 2
-- forward composition: inc, then dbl
fn main() = println((inc >> dbl)(10))
An arithmetic sequence lowers to a prelude enumeration call.
fn main() =
println(sum([1..5]))
println(sum([1, 3..9]))
A list comprehension (and the statement for) lowers to a stream (a producer performing the Emit effect, see effect lowering) that emits each surviving element, collected with scollect (a stream consumer that gathers the emissions into a list), so it fuses with no intermediate list.
fn main() = println(sum([x * x for x in srange(1, 6)]))
A record update rebuilds the constructor along the named fields; on a uniquely owned value the rebuild is the in-place write of reference counting and FBIP reuse.
type Vec2 = Vec2 { x: Int, y: Int }
fn main() =
let v = Vec2 { x = 1, y = 2 }
let w = Vec2 { ..v, x = 7 }
println(w.x)
println(w.y)
deriving (Lens) synthesizes a getter and a functional setter per field.
-- `deriving (Lens)` synthesizes a getter `<f>_of` and a functional setter
-- `with_<f>` per field. They are ordinary functions, no optic types needed.
-- On a uniquely owned value the setter is FBIP-reused.
--
-- Expected output:
-- 3
-- 7
-- 9
-- 4
type Vec2 = Vec2 { x: Int, y: Int } deriving (Lens)
fn main() : Unit ! {IO} =
let v = Vec2 { x = 3, y = 4 }
println(x_of(v))
let v2 = with_x(v, 7)
println(x_of(v2))
let v3 = with_y(with_x(v, 9), 4)
println(v3.x)
println(v3.y)
The failure fallback a ?? b runs a under a Fail handler that yields b if a fails.
-- at_list fails past the end of the list; ?? supplies the fallback
fn main() = println(at_list([10, 20, 30], 5) ?? 99)
A method call e.m(args) is uniform-function-call sugar for m(e, args): the receiver simply becomes the first argument. String interpolation is similarly shallow. The string is split into literal pieces and holes, each hole is displayed through its selected Show evidence, and the pieces concatenate from left to right; a top-level string is spliced raw rather than quoted.
try/catch/throw is subtractive handler sugar: one nested never clause (the non-resumable handler clause of clause sugar) per arm, each discharging one error label.
error NotFound(String)
fn lookup(k : String) : Int ! {NotFound} =
if k == "a" then
1
else
throw NotFound(k)
-- `try e catch { N(x) => h }` is one nested `never` clause discharging N's label.
fn main() = println(try lookup("z") catch { NotFound(k) => 0 })
transact body else fallback snapshots every live var, runs the body under a Fail handler, and restores the snapshots on failure, so a failed attempt leaves observable state unchanged.
-- `transact body else fallback` snapshots the live vars, runs `body` under a
-- Fail handler, and on failure restores them before yielding `fallback`.
fn attempt(spend : Int) : Int =
var balance := 100
transact
balance -= spend
guard(balance >= 0)
balance
else
0 - 1
fn main() =
println(attempt(40))
println(attempt(140))
Optional chaining a?.b is force(a).b, where force raises fail() on None, so a path short-circuits at the first None and an enclosing ?? supplies the default.
type City = City { zip: Int }
type Addr = Addr { city: Option(City) }
-- `a?.city?.zip` is `force(a).city` chained: the path short-circuits on the
-- first `None`, and `??` supplies the default.
fn zip_of(a : Option(Addr)) : Int = a?.city?.zip ?? 0 - 1
fn main() =
println(zip_of(Some(Addr { city = Some(City { zip = 42 }) })))
println(zip_of(None))
A with f <- handler { .. } block binds a first-class handler instance over a fresh private effect; f.op(..) targets it by name.
effect Read
read() : String
-- `with conf <- handler { .. }` binds a first-class handler instance;
-- `conf.read()` dispatches to it by name.
fn main() =
with conf <- handler
read() resume k => k("conf.toml")
return r => r
println(conf.read())
A trailing block argument is appended as the call’s final thunk argument; it needs no distinct Core form.
A bidirectional pattern synonym desugars to a view call in match position and a make call in expression position.
type Vec2 = Vec2 { x: Int, y: Int }
-- a bidirectional pattern synonym: matches when y == 0, binding x
pattern OnXAxis(x) for Vec2 =
view \(v) -> if v.y == 0 then Some(v.x) else None
make \(x) -> Vec2 { x = x, y = 0 }
fn describe(v) =
match v of
OnXAxis(x) => x
_ => 0 - 1
fn main() =
println(describe(Vec2 { x = 5, y = 0 }))
println(describe(Vec2 { x = 5, y = 3 }))
Two pattern forms are eliminated here as well, and both are expansions rather than translations, so no Core node corresponds to either and nothing downstream of desugar meets one.
An alternation is split into one arm per alternative, in the order a backtracking match would try them; a nested alternation enumerates the product of its positions with the rightmost varying fastest, so the leftmost alternation stays the outer loop and overlapping alternatives keep their source order. The arm’s guard and body are copied to each alternative, and every expanded arm carries the source arm’s span, so a diagnostic underlines the alternation the author wrote rather than one alternative of it. The product is bounded at 256 arms; past that the arm is refused rather than compiled into an unbounded arm list. Expansion precedes the checker, so exhaustiveness and reachability see exactly the arms the alternation denotes.
match s of
Line(0 | 1, _) | Ring(0) => 1
_ => 0
A parameter written as a pattern is rewritten the same way. The parameter still binds one value, under a synthetic name taken from its position whose sigil keeps it unspellable in source and unreachable by keyword argument, and the body is wrapped in a one-arm match of that name against the pattern. The leftmost parameter’s match is the outermost, so a later pattern’s binders cannot capture an earlier one’s. The wrapper is an ordinary surface match, so an alternation or a nested pattern inside a parameter travels the same expansion path as any other pattern, and the argument is already a value when the body runs, so introducing the match forces nothing that was not forced before. Irrefutability is not decided here: a pattern that fails to cover its type is reported by the coverage check as the non-exhaustive match it denotes, the same message the hand-written wrapper would produce, so one mistake has one diagnostic.
fn offset(Wrap(n), Pt { x = x, .. }) : Int = n + x
Because both are gone before the checker runs, neither can be observed by tier selection, backend choice, or optimization level: a program written with an alternation and the same program written with its arms spelled out elaborate to the same Core and hash identically under prism dump core-hash.
A nested path update rebuilds the single-constructor spine (the chain of nested constructor cells) along the path.
type Vec2 = Vec2 { x: Int, y: Int }
type Line = Line { from: Vec2, to: Vec2 }
fn main() =
let l =
Line {
from = Vec2 { x = 0, y = 0 },
to = Vec2 { x = 1, y = 1 }
}
let l2 = { l | from.x = 9 }
println(l2.from.x)
deriving (Eq, Ord, Show) generates one structural instance per class.
-- `deriving (Eq, Show)` generates one structural instance per class: `eq`
-- compares constructor and fields, `show` renders the constructor name. `Ord`
-- would derive a `cmp` ordering by constructor index the same way.
type Color = Red | Green | Blue deriving (Eq, Show)
fn main() =
println(show(Green))
println(Red == Red)
The postfix e? unwraps Ok and performs the enclosing failure effect on Err, so it shares the ordinary handler path with ?? and optional chaining.
The var desugaring is shown with full Source / Desugared / Core stage tabs in local mutation; default and named arguments lower to positional ones in the same pass.
The stable block is also pure desugar: each rung becomes an ordinary record type (the current rung under the bare name, each frozen predecessor under its dotted rung name), each adjacent version pair becomes a plain upgrade_T_Vn_Vm / downgrade_T_Vm_Vn function pair (generated for an additive change with an inline default, taken verbatim from the block for a hand-written converter), and the block derives the current rung’s Serialize and Stable instances against the Wire classes. A rung’s frozen "<digest>" badge is checked during elaboration against the rung’s structural shape digest, so nothing downstream of desugar knows the block existed. Structural derivation itself covers Eq, Ord, Show, Lens, Hash, Serialize, Stable, and Arbitrary.14
6. Type and Effect Inference
Type inference is the bidirectional, higher-rank algorithm of Dunfield & Krishnaswami (2013); the surface rules are in types and kinds. Type classes elaborate to dictionary-passing: a constraint becomes a hidden parameter, resolved to a global instance, a passed dictionary, or a projection of a superclass dictionary.
Instances are global, but each records its defining module, so coherence is checked by provenance. Resolution is coherent: for each (class, type-head) there is exactly one canonical instance, and implicit resolution always selects it. A single instance for a head is canonical automatically. When two or more instances share a head, one must be designated with a top-level canonical Class(Head) = name declaration (see coherence and resolution). An undesignated overlap is a hard error reported at definition, naming the candidates and their modules, with a source caret when they point into the program being compiled. An orphan instance (defined apart from both its class and its head type) is reported as a warning. An explicit override is written at the use site as a trailing using argument, f(args, using name), which changes nothing else’s resolution.
Indexing (a[i], a[i] := v) is resolved the same way the print/interpolation display and ^ lowerings are, by type-directed dispatch at elaboration: the checker records each sub-expression’s type in a span-keyed table, and the elaborator reads the receiver’s head type back and emits the matching builtin or accessor through one wired classifier, the single home for the container names and their getter/setter functions:
| Receiver | Getter | Setter |
|---|---|---|
Array | at_array | array_set |
HashMap | at_hashmap | hm_insert |
String | at_byte | read-only |
List | at_list | list_set |
Tensor | at_tensor | tensor_set |
A bracket with two or more indices lowers to a list-keyed index for the tensor’s strided lookup. A receiver whose type is still an unsolved existential when first synthesized (a var indexed before its initializer fixes its state type) defers to one pass at the end of the declaration, after the initializer has constrained it. Concrete indexing is a closed, wired dispatch rather than a class or type-system extension; the desugar targets are index and index_set.
Effect-row inference is principal: each declaration infers its most general row from its body alone. The row unifier discovers every label on its own (a row is a function’s effect set; see types and kinds) from direct performs, applied effect-carrying callees, builtin rows, and mask. At a call it adds the callee’s row to the caller’s ambient row (the effect set accumulated for the body so far), and a handler removes the operations it discharges. The row is the single source of truth: there is no separate set-pass seed and no subset reconciliation against one.
A syntactic set-pass (a pass that computes a set of operation labels by a call-graph fixpoint) still runs, but only to feed the syntactic purity checks: it confirms a konst declaration and a declared-pure instance method perform nothing. It no longer seeds the row. After lowering, reconcile_effects checks the operations the lowered code actually performs against the inferred row, and the interpreter parity oracle (see verification) is the final backstop. Effect lowering computes its own per-function latent operation set by an independent call-graph fixpoint (see effect lowering), so the two phases no longer share the set-pass result.
6.1 Kinds and Row-Kinded Type Parameters
Type parameters carry a kind. Almost every parameter has kind Type (*), and an unannotated parameter defaults to it, so the kind system is invisible to ordinary code and higher-kinded types stay structural (an applied variable f(a) is resolved by App/Con unification, not by a kind assignment). The one kind that changes inference is Row: a parameter annotated : Row ranges over effect rows rather than types.
A Row-kinded parameter lets a data type store an effectful computation. In
type Box(a, e : Row) = Box(() -> a ! {e})
the field () -> a ! {e} mentions the row parameter e (a data field may name it either bare, ! {e}, or in tail position, ! {IO | e}). The constructor scheme quantifies e with a RowForall binder instead of a Forall, and the applied head Box(a, e) carries the row in its spine as a dedicated Type::Row(EffRow) argument. Row unification then threads through the same places type unification does (instantiation, substitution, zonking, pattern matching, and record construction), so opening Box(f) in a match instantiates e to a fresh row existential exactly as a is instantiated to a fresh type existential.
At a use site a Row-kinded argument is an effect row: a row variable (Box(a, e)) or a { .. } row literal (Box(Int, {IO}), Box(Int, {IO | e})). Supplying a type where a row is wanted, or a row where a type is wanted, is a kind mismatch reported at the annotation (check_annot_rows) rather than surfacing later as a row-versus-type unification failure.
This is the type-system half of effect-polymorphic concurrency: it is what makes an effect-polymorphic scheduler storable and, together with the ambient-row discipline for operations, sound. See concurrency for the whole story.
7. The Core Calculus
Elaboration lowers the surface language to a call-by-push-value core (Levy, 2004) in A-normal form. CBPV separates values, which are inert, from computations, which can be run; Thunk freezes a computation into a value and Force runs it. A-normal form names every intermediate result with a Bind, making evaluation order explicit and each operation and allocation syntactically distinguished, enabling the later effect and reference-counting passes. The grammar below is the elaborated core; the reference-count pass (see reference counting and FBIP reuse) later adds dup, drop, and reuse nodes to it.
This follows GHC’s discipline for Haskell: desugar and elaborate the entire surface language into one small, explicitly typed core, and make that core the single place every later pass operates. The surface may grow new sugar freely, but effect lowering, reference counting, optimization, and the Lean model all see only the handful of forms in the grammar below, so their complexity does not scale with surface syntax. Prism’s core is smaller still than GHC’s System FC: call-by-push-value already makes evaluation order syntactic and A-normal form already names every intermediate result, leaving a pass little to re-derive.
value -> Var sym
| Int i
| I64 i
| U64 i
| Float f
| Bool b
| Unit
| Str s
| Ctor sym tag [ value, ... ]
| Tuple [ value, ... ]
| Thunk comp
comp -> Return value
| Bind comp sym comp
| Force value
| Lam [ sym, ... ] comp
| App comp [ value, ... ]
| Call sym [ value, ... ]
| If value comp comp
| Case value [ (pat, comp), ... ]
| Prim op value value
| Do sym [ value, ... ]
| Handle { body, return_var, return_body, ops }
| Mask [ sym, ... ] comp
For example, a constructor applied to a call elaborates so the call is named before the constructor is built: every intermediate result is named by a Bind, and arguments are values.
fn f(y) = Cons(g(y), Nil)
A match compiles to a Case on an already-named value, each arm binding its constructor’s fields and carrying a computation body:
fn area(s) =
match s of
Circle(r) => r * r
Square(w) => w + w
A function parameter is a thunk value: calling it is Force then App, kept distinct from the direct Call to a top-level name, and the inner call’s result must be named before the outer call consumes it:
fn twice(f, x) = f(f(x))
And a lambda in argument position is a computation frozen into a value with Thunk; its free variables are ordinary Var occurrences, which is all a closure capture is:
fn scaled(y) = twice(\(n) -> n + y, y)
Core Nodes
The core has two syntactic categories. A value (Value) is inert: it can be named, copied, and stored, but not run. A computation (Comp) can be run to produce a value or perform an effect. Thunk freezes a computation into a value and Force/Return cross back, so the two categories are bridged by exactly those nodes. The tables below name every node the backend passes see.
Values
| Node | Description |
|---|---|
Var | Reference to a bound variable, by its resolved symbol. |
Int | A machine-word integer literal (the default Int). |
I64 | A fixed-width 64-bit signed integer literal. |
U64 | A fixed-width 64-bit unsigned integer literal. |
Float | A double-precision floating-point literal. |
Bool | A boolean literal. |
Unit | The unit value (). |
Str | A string literal. |
Thunk | A computation frozen as a value; Force runs it later. The value-from-computation bridge. |
Ctor | A fully applied data constructor: its symbol, its integer tag, and its field values. |
Tuple | An anonymous product of values. |
Computations
| Node | Description |
|---|---|
Return | Lift a value into a (trivial) computation. The computation-from-value bridge. |
Bind | Run a computation, name its result, and continue. A-normal-form sequencing, the only sequencer. |
Force | Run a thunk value. |
Lam | A function abstraction over parameters with a computation body. |
App | Apply a computation (typically a forced closure) to value arguments. |
Call | A direct call to a top-level function by name, kept distinct from App for direct-call codegen. |
If | Branch on a boolean value. |
Prim | A primitive arithmetic or comparison operator on two values (see Operators). |
Case | Scrutinize a value against constructor and tuple patterns (see Patterns). The compiled form of match. |
FloatBuiltin | A unary floating-point or numeric-conversion builtin on one value (see Float builtins). |
StrBuiltin | A string, array, or map builtin applied to value operands. |
Io | A builtin IO operation and its operands: the output family, the input family, and RNG seeding (see IO operations). |
Error | Raise a runtime error carrying a value. The panic and unrecoverable-failure surface. |
Do | Perform an effect operation: the operation symbol and its argument values. Algebraic-effect perform. |
Handle | Install an effect handler: a body, per-operation clauses (each binding its parameters and a resume continuation), and an optional return clause. |
Mask | Bypass the innermost matching handlers for the named operations while running the body (effect tunnelling). |
Reference-counting and reuse nodes
Elaboration does not produce these; the reference-counting pass inserts them (see reference counting and FBIP reuse).
| Node | Description |
|---|---|
Dup | Increment a value’s reference count to share an owned reference. |
Drop | Decrement a value’s reference count, freeing the cell at zero. |
WithReuse | Free a now-dead owned cell and bind its shell as a reuse token scoped over a body; the cell is freed at exactly one point. |
Reuse | Build a constructor in place over a reuse token’s cell, without calling the allocator (in-place FBIP update). |
Local-mutation nodes
Produced by effect lowering when it rewrites a closed, escape-checked var into a real mutable cell (see effect lowering), so a var loop runs in constant stack rather than through the free monad.
| Node | Description |
|---|---|
RefNew | Allocate a one-field mutable cell holding a value; the result owns the cell. |
RefGet | Read a mutable cell’s field as an owned snapshot; the cell is borrowed. |
RefSet | Overwrite a mutable cell’s field in place; yields Unit. |
Post-lowering allocation nodes
Arena lowering adds one runtime-only computation after elaboration and before codegen (see arena allocation).
| Node | Description |
|---|---|
InitAt | Initialize a constructor or tuple in a raw cell returned by an allocation handler, without allocating a second destination cell. |
Operators (Prim)
| Operation | Integer | Float |
|---|---|---|
| Addition | Add | Addf |
| Subtraction | Sub | Subf |
| Multiplication | Mul | Mulf |
| Division | Div | Divf |
| Remainder | Rem | |
| Equality | Eq | Eqf |
| Inequality | Ne | Nef |
| Less than | Lt | Ltf |
| Less than or equal | Le | Lef |
| Greater than | Gt | Gtf |
| Greater than or equal | Ge | Gef |
Short-circuiting && and || lower to If, and ^ lowers to a class-method call, so none of the three reaches a Prim.
Patterns (Case arms)
| Pattern | Description |
|---|---|
Var | Bind the whole scrutinee to a name (or ignore it). |
Ctor | Test the scrutinee’s constructor tag, binding or ignoring each field position. |
Tuple | Destructure a product, binding or ignoring each component. |
Literal, boolean, and record patterns are compiled away upstream into If and Prim tests, so only these three shapes survive into a Case.
IO operations (Io)
| Node | Description |
|---|---|
Print | Print an integer value (the output family, performing the Output/IO effect). |
PrintF | Print a floating-point value (output family). |
PrintS | Print a string value (output family). |
PrintNl | Print a newline (output family). |
ReadInt | Read an integer from input (the input family, reading the world). |
ReadLine | Read a line of input as a string (input family). |
Rand | Draw a pseudo-random integer (input family). |
Srand | Seed the random-number generator. |
Folding the family under one node keeps each structural pass to a single arm; the interpreter, codegen, and serializer switch on the operation where behavior differs.
Float builtins (FloatBuiltin)
| Node | Description |
|---|---|
ToFloat | Convert an integer to a float. |
Truncate | Convert a float to an integer, discarding the fraction. |
FloorToInt | Round a float down to the nearest integer. |
CeilToInt | Round a float up to the nearest integer. |
AbsFloat | Absolute value. |
Sqrt | Square root. |
Sin | Sine. |
Cos | Cosine. |
Exp | The exponential function e^x. |
Ln | Natural logarithm. |
Program structure
| Node | Description |
|---|---|
Core | A whole program: the list of its top-level functions. |
CoreFn | One top-level function: its name, parameters, and computation body. |
HandleOp | One clause of a Handle: the operation name, its parameters, the resume binder, and the clause body. |
This calculus is modeled in Lean 4 (de Moura & Ullrich, 2021): the formal syntax mirrors the core one variant at a time with a substitution small-step relation, on top of which the model adds an executable abstract machine that mirrors the interpreter and is proved to agree with it. The chapter on verification describes the model and how it anchors the compiler’s verification chain.
7.1 The Identity Surface
Core exists at two moments, and only one of them names a definition. Pre-optimizer core, straight out of elaboration, is the identity surface: every content hash, the stdlib Merkle root, and the store’s commit path are taken there, so identity cannot move when a pass is retuned or an environment toggle flips one on. Post-optimizer core is what the backends and dump core show, and it is deliberately not an identity: specialization mints definitions that do not exist before it runs.
prism dump core-identity publishes that surface. It renders the user program’s pre-optimizer core as deterministic JSON, tagged with the same node names the hasher folds, and adds the three facts the tree alone does not carry: each definition’s dictionary arity and its elaboration metadata (generalized type, principal effect row, fip keyword, borrow mask), the recursive-group partition the hasher works one component at a time, and the content hash of every definition outside the export that the export refers to. Those last two are what make it self-contained: a group’s hashes are a function of its members plus its dependencies’ hashes, so the artifact carries exactly that closure and nothing about the prelude it was compiled against.
The export is an observation, never a participant. It introduces no core node, changes no encoding, and is computed only when asked, so dumping it cannot perturb a hash; the differential gate asserts that directly, alongside the property it exists for. A Prism program reads the artifact, re-encodes each definition in the prism-core-hash-v1 byte scheme, re-derives the canonical order inside each recursive group, and recomputes every hash, which must equal what prism dump core-hash prints. Distinguish what that does and does not establish: the reader recomputes the encoding and the folding from structure, and no definition’s own digest appears in the artifact, so it cannot echo an answer; but the metadata rendering, the group partition, and the dependency digests are consumed rather than re-derived, and the encoder’s byte scheme was transcribed rather than independently specified. It is a drift gate on the scheme, and a completeness gate on the export, not a second proof that the scheme is the right one.
8. The High-Level Intermediate Representation
Between type inference and elaboration sits one boundary artifact, the checked HIR (high-level intermediate representation): the type- and effect-checked surface tree paired with the facts checking decided about each of its nodes.
At fifty thousand feet, the front end does two separable jobs. It first decides: lexing, parsing, desugaring, and name resolution turn source text into a sugar-free tree of canonical symbols, and type-and-effect inference then resolves every choice the surface left implicit, which record a .f names, which instance discharges a constraint, which concrete numeric type a bare literal takes, what type each node has. It then emits: elaboration walks that same tree and writes out the call-by-push-value core, turning each recorded decision into a concrete projection, dictionary, or builtin. The HIR is the seam between the two, the point at which every decision has been made and nothing has yet been lowered. Everything above it is inference, which is hard and needs the whole type system; everything below it is transcription, which is mechanical and needs none of it. Freezing the decisions into one artifact at that seam is what lets the deciding and the emitting be built, proof-checked, dumped, and cached as independent halves.
Concretely, the HIR is not a new tree. It is the desugared surface tree Expr<Core> (the sugar-free phase whose NodeIds are assigned just after desugar) carried unchanged, plus a dense side value keyed by that NodeId. What flows in from checking is five families of decision, one lookup per node: the resolution a field access, unboxed projection, or record-update path landed on; the dictionary evidence discharging each constrained call; the concrete numeric lane a literal or operator fixed to; the node’s zonked15 type; and the operation-local residual proof for each handler. What flows out to core is the image of each: a resolution becomes a constructor projection or rebuild, an evidence entry becomes a dictionary handed to a call (and a method call a field read on it), a lane selects the concrete arithmetic builtin, the type drives the type-directed lowerings (print and interpolation, exponentiation, indexing), and a handler residual records exactly which operations or opaque effects forward and remain in the row. Because each is a lookup and never a fresh judgment, elaboration makes no type-system decision of its own, the property the rest of this section rests on.
8.1 The Checked Artifact
Those five families live in one value, NodeFacts, dense by NodeId; the resolution form is NodeRes, spanning field access, unboxed projection, and record-update rebuild chains. Elaboration never sees NodeFacts directly; it reads a CheckedHir, built only by build (whole programs) or build_for_expr (the REPL’s re-inferred expressions, whose fresh NodeIds carry their own evidence override), through five accessors (res, evidence, lane, node_type, handler_residual), the sole channel by which a checked decision reaches elaboration.
Two of the five families are stored but never judged: the numeric lane and the zonked node type. Both are zonked, in the sense that every solved existential has been substituted, but zonking is substitution, not a promise of existential-freedom, and an under-determined site (a numeric literal before defaulting, a node the elaborator’s own use-site filter still pins down) legitimately keeps an unsolved existential in either family. Resolution, evidence, and handler residuals are independently checked by the HIR lint before a downstream pass may rely on them.
8.2 The Lint
Both constructors route through lint_hir, an independent proof-checker that runs unconditionally in debug and test builds and panics on any violation, because a violation there is a compiler bug, never a user error. It is proof checking, not proof search: each judgment re-verifies one stored fact against the live constructor, instance, class, and effect environment rather than re-inferring it. A resolution fact must name a real constructor, its recorded arity must match the constructor’s declared arity, and its field index must be in bounds; dictionary evidence must name a real instance (Dict::Global) or a real class with an in-bounds superclass projection (Dict::Super), recursing through Dict::Tuple. The one evidence form it skips is Dict::Param, a hidden dictionary parameter: its binder is not in per-node scope, and judging it would mean re-deriving the enclosing function’s dictionary layout, which is inference, not checking. A handler residual fact must be paired with a handler node, use canonical operation/effect sets, name declared operations or builtin effects, and forward only operations that remain residual. The lane and type families are stored but not independently asserted.
9. Elaborator
Elaboration is the surface-to-core translation: it turns the type- and effect-checked surface tree into the call-by-push-value core above, making explicit everything the surface left implicit. The checker already did the deciding and recorded each result in the checked HIR above, so elaboration is a second traversal that reads it and emits, rather than re-deriving anything: the checker decides, the elaborator builds.
9.1 The Typed Elaboration Boundary
The elaborator first emits the compatibility Core shape, then routes it through private typed builders before any downstream pass may consume it. Those builders combine the checked declarations, constructor and operation environments, and elaborated signatures into a witness-carrying Core in which every value has a type and every computation has a result type and effect row. Construction is not another source-language inference pass: it reconstructs and checks the explicit evidence at the boundary, including quantified signatures, row instantiations, handlers, and representation-preserving coercions.
An independent verifier then proof-checks the completed typed artifact without unification or inference. Only a verified artifact may cross the explicit erasure boundary back into executable Core. Passes inside that boundary preserve typed witnesses; passes outside it consume the erased representation used by the backends. Environment, construction, and verification failures use the compiler’s structured error variants and canonical internal codes E9995, E9996, and E9997, while erasure and specialization compatibility failures use E9994 and E9993. Callers therefore never need to interpret an opaque error string.
Erasure is required to be semantic identity. The conformance gate compares prism dump core-hash with the typed boundary enabled and bypassed for every runnable corpus program; the output must be byte-identical. Typed witnesses therefore cannot perturb Core identity, optimization inputs, or executable behavior.
Three things are made explicit here. Type-class constraints become dictionary-passing: each constraint the checker discharged is emitted as a global instance dictionary, a hidden dictionary parameter, or a projection of a superclass dictionary, and every method call becomes a field access on the resolved dictionary (see type and effect inference). The show method itself is dictionary-dispatched like any other class method; separately, a few type-directed lowerings are resolved against the checker’s span-keyed type table: the print/println and interpolation display, exponentiation (^), and indexing (a[i]) each read their operand’s head type back and emit the matching builtin. And a match is lowered to a decision tree, the one part of elaboration large enough to be its own pass, below.
That the elaborator only transcribes is visible in the dumps. prism dump hir serializes NodeFacts as a versioned JSON fixture (prism-hir-fixture-v2), including operation-local handler residual proofs, in the machine form the bootstrap boundary consumes. Committed goldens pin it byte-for-byte and regenerate only through an explicit acceptance step, and the WebAssembly playground renders it prelude-stripped into a declaration-and-facts view; stacked over the core, the two make the transcription concrete. Take a record whose one field is projected:
type Pt = Pt { x : Int, y : Int }
fn getx(p : Pt) : Int = p.x
The checked HIR keeps getx’s interface and the single node carrying a substantive fact, the field access resolved to a constructor, a field index, and an arity (Pt.0/2), the bare literal-type nodes dropped as noise:
-- Declarations
getx : (Pt) -> Int
-- Checker facts (1 node)
#1289 ty=Int res=field Pt.0/2
and elaboration reads that field Pt.0/2 fact and emits the projection with no second look at the type system:
fn getx(p) =
return p to t@733
case t@733 of
Pt(t@734, _) =>
return t@734
The node id is an internal per-node counter and the temporaries are fresh; the point is the alignment, the resolution the checker recorded, field Pt.0/2, being exactly the projection the core takes, fact for instruction. The playground stacks the two panes, checked HIR over core, so an edit shows both at once.
The output is Expr<Core>, the sugar-free phase in which the surface’s sugar constructors are uninhabited (see desugaring), so a construct elaboration fails to translate is a compile-time type error in the compiler rather than a runtime fallthrough.
Pattern-Match Compilation
A match is compiled to a decision tree. The arms form a matrix whose rows are arms and columns are argument positions. The compiler selects a column, partitions the arms by the head of that column’s patterns, and emits a test: a Case on the constructor tag of the scrutinee (the value being matched) for a constructor column, or a chain of equality tests for a scalar column. Wildcard rows form a default sub-matrix shared by the branches that fall through. A guarded arm compiles to a conditional that re-enters the remaining arms when the guard fails. Exhaustiveness, proven by the checker (see patterns), guarantees every scrutinee reaches an arm.
Every arm reaching the matrix is a single alternation-free pattern: alternations were expanded in desugaring, and a parameter written as a pattern is by then an ordinary match around the body. So there is one arm shape to compile and one decision tree to build, whichever surface form produced it. The single residue of expansion is reachability, which needs to know which arms came from one source arm: a flag set on every arm after the first of an expansion marks the run, and the checker judges the run as a unit, calling it useful when any of its alternatives is and withholding all its rows from the matrix until the run ends. One alternative therefore cannot shadow another inside the arm the author wrote, and a genuinely dead arm is reported against the whole alternation.
The splitting is easiest to see on a two-column match. Three rows, but the tree tests each component once: splitting on the first component partitions the rows, the wildcard row (_, Nil) falls through into the Cons branch as its default sub-matrix, and no pattern is ever examined twice:
fn both_ready(a : List(x), b : List(y)) =
match (a, b) of
(Nil, _) => false
(_, Nil) => false
(Cons(_, _), Cons(_, _)) => true
10. Effect Lowering
Effect lowering compiles away the Handle, Do, and Mask nodes of the core. An operation is delimited control (an effect suspended and resumed within a handler’s scope): Handle is the delimiter, and the resumption k is the continuation captured between a perform site and its handler (see effects and handlers). Lowering is a cascade of six pathways tried in a fixed cost order, each of which either lowers the whole program and succeeds or declines; the compiler takes the first that applies, so it reifies as little of the continuation as the program allows.
The witness-carrying typed implementation is the sole lowering authority. The former erased-Core implementation and its differential oracle were deleted at the v0.12 cutover; neutral ABI names and shape predicates remain shared contracts, while every rewrite preserves typed witnesses until final erasure.
| pathway | applies when | how much of k becomes manifest |
|---|---|---|
| pure | no effect construct remains | nothing |
| evidence passing | every handler is tail-resumptive | nothing; operations force handler-clause thunks |
| state threading and stream fusion | a uniform single-operation fold handler | one small tag cell per early-terminating handler |
| local partial | fused and reified regions can be assembled across a sound convention boundary | a reified tree inside the monadic region only |
| selective free monad | an entangled effectful component splits cleanly from the rest of the call graph | the component’s continuations as heap-allocated EPure/EOp |
| whole-program free monad | the effect escapes static tracking or no sound selective split exists | every effectful continuation as heap-allocated EPure/EOp |
They are six compilations of that one mechanism, differing in how much of k they make manifest, from nothing to heap-allocated trees. A check then confirms no effect construct survives. The chosen strategy is a pure cost decision16, never observable in output, and it is pinned: prism dump tier prints a program’s classification, and a committed manifest records the tier of every corpus program, so a refactor that silently defeats a fast path corpus-wide fails the perf gate by name rather than shipping as an invisible performance collapse. A tier change in either direction updates the manifest loudly, like a snapshot.
Two erasure pre-passes run before the strategy cascade, each recognizing a statically fixed handler shape and rewriting it to direct code, leaving everything else for the strategies. Var erasure rewrites an escape-checked local var (a closed two-operation State handler, see local mutation) to a mutable cell: get becomes a cell read, set a cell write, and the block is wrapped in a fresh-cell allocation. It is sound exactly because the escape analysis proved the var’s continuation is never resumed more than once, so the shared cell and pure-state copies agree; a multishot use disables it. Control erasure rewrites the internal break/continue/return effects (see imperative control flow), whose never handlers have fixed templates, back to direct control flow. It runs after var erasure, so a pure imperative loop has lost all of its effect operations by the time the cascade classifies it and falls into the trivial pure path (no effect constructs remain), compiling to a musttail loop with no per-iteration allocation.
Evidence passing is the fast path for tail-resumptive handlers (every clause calls k exactly once, in tail position, so the continuation need never be captured at all). Each operation is assigned a stable numeric id by sorting the operation names, and a call-graph fixpoint computes each function’s latent set, the operations still performed anywhere in its call-graph closure. An effectful function then gains one extra parameter per latent operation, ev@<id>, a thunk holding the active handler clause. Performing an operation forces its evidence thunk directly; a handle binds fresh evidence for its body’s latent operations; and every call site appends the callee’s evidence, in ascending id order, so the convention is positional and stable. A first-class thunk that escapes carries evidence parameters for its own latent operations, threaded at each force site. No continuation is reified and no per-operation cell is allocated. What evidence to thread where is computed by an interprocedural least-fixpoint flow analysis that derives, for every function, the operation signature of the thunk it returns and of each thunk-valued parameter.
State threading and stream fusion is the path for a uniform single-operation handler, the shape a stream consumer takes: a handler that folds every emit into an accumulator. Such a handler clause is rewritten to an accumulator transformer \acc -> acc', and the producer it wraps becomes a loop that threads the accumulator through each emission instead of allocating a value per step. A consumer that can stop early, like stake, returns a two-state tag (continue or done) that the producer checks, so the loop exits without unwinding. This reifies one small tag cell per early-terminating handler and, like evidence passing, no free-monad cell, so a smap/skeep/stake/ssum pipeline allocates neither an intermediate list nor a per-operation cell.
-- Streams as effects: a stream is a producer performing Emit(a), the
-- transformers smap/skeep/stake are handlers that re-emit, and the consumers
-- ssum/scollect/for are handlers that fold. A dot chain nests the handlers over
-- one producer, with no intermediate lists. stake stops the source early by
-- dropping its continuation. Expected output:
--
-- 220
-- lo
-- hi
-- 0
-- 3
-- 6
fn square(n) = n * n
fn main() =
println(srange(1, 1000).smap(square).skeep(even).stake(5).ssum())
for w in sof(["lo", "hi"]) do
println((w : String))
let xs = srange(0, 1000000).smap(\(n) -> n * 3).stake(3).scollect()
for x in sof(xs) do
println((x : Int))
The free-monad fallback applies when an effect escapes static tracking: buried in data, dynamically applied, masked, genuinely multishot (a clause that resumes k more than once), or self-referential (a handler whose own body performs the effect it handles). A multishot handler forces this path because the two fast paths erase k, and a continuation invoked more than once must exist as a reusable value. Here the delimited continuation is reified in full: each computation becomes a tree of EPure and EOp cells threaded by ebind (shown below), and the continuation each EOp still owes is an explicit field a clause can hold, drop, or apply repeatedly.
That continuation is held as a type-aligned queue (the Freer representation, Kiselyov & Ishii, 2015): a persistent catenable tree of Kleisli arrows whose append (snoc, one ebind) and join (concat, the splice at a forwarded operation) are O(1), and whose uncons re-associates the left spine, so a continuation extended by repeated ebind drains in amortized O(1) per step rather than the quadratic re-association a trampoline would redo on every bounce. The tree is never mutated, only rebuilt sharing its leaves, so a captured continuation stays cloneable for a multishot resume.
A handle becomes a generated driver function that case-dispatches the reified tree: an EPure runs the return clause, an EOp whose id the handler names and whose skip count is zero runs the matching clause, and any other EOp is re-emitted outward with a re-entry continuation, which is how an inner handler forwards an operation it does not catch.17 This is exactly the interpreter’s dispatch (see backends), so the two agree by construction.
Each EOp allocation bumps the PRISM_EFFOP_STATS counter, so the fallback’s cost is observable, and a default-on warning (silenceable with PRISM_QUIET) names the functions that lost fusion and the cause when a program takes this path, so a pipeline meant to stay fused can be steered back. The generated drivers are closed by construction: a per-handler driver takes exactly its clauses’ captured free variables as parameters, and the fixed-binder templates (ebind, the mask drivers) use a reserved binder band and never nest, so a binder cannot capture a free occurrence.
Lowering is kept as local as possible, the local monadification tier above the whole-program fallback: when an effectful thunk escapes, only the connected component entangled with it (closed over the call graph, but leaving pure closure-inert helpers shared, and over shared operations) is converted to the free-monad form, while unrelated functions stay on their fused paths, provided the component’s operations are disjoint from the rest; when the split is not clean lowering falls back to converting every effectful function together. A convention-boundary check, run in both modes, validates the split and turns a missed monadic/direct boundary into a compile-time internal error.
Constant-stack driving changes how a closed handler on this fallback is run, not what it reifies. By default such a handler is driven by a single self-tail-recursive loop, {n}@region, rather than a pair of mutually recursive driver functions: the loop case-dispatches the same EPure/EOp tree but re-enters itself by a musttail self-call on the resumed continuation, so an iterative or deeply nested resumption runs in constant native stack where the mutually recursive driver grew it per step. Two clause shapes qualify. A tail-resumptive clause (every resume is the head of a tail application) re-drives the operation’s continuation queue with qApply.18 The reification is unchanged, so the per-operation EOp cost stays and the only zero-cell routes remain the evidence and state paths above; the gain is purely that a parameter-passing loop no longer overflows (the bounded-stack performance gate pins a million-iteration State loop completing in a 2 MB stack). An open handler, a multishot or escaping resume, or any clause outside these shapes keeps the mutually recursive driver, whose qApply the loop reuses, so the free-monad machinery is the substrate it drives rather than a thing it replaces. This is on by default and reverts under PRISM_NATIVE_EFFECTS=0; the interpreter oracle’s whole-corpus parity holds byte-for-byte either way.
-- Reified computation tree (the free-monad fallback)
EPure v -- a finished result
EOp id skip arg k -- a suspended operation:
-- id : numeric identity of the operation
-- skip : matching handlers still to bypass (mask depth)
-- arg : the operation argument
-- k : resumption closure \resume_value -> Eff
-- Sequencing extends the tree without forcing it:
ebind (EPure v) f = f v
ebind (EOp id skip a k) f = EOp id skip a (\r -> ebind (k r) f)
The example below exercises this path: an inner handler catches Log and forwards raise outward to an Exn handler, the two effects interleaving across the nesting.
effect Log
log(Int) : Unit
effect Exn
raise(Int) : Int
-- Cross-effect forwarding: an inner handler re-emits what it does not catch.
--
-- work() performs both Log and Exn. The inner handler interprets Log (prints
-- and resumes) but knows nothing about raise. When raise fires inside the Log
-- handler, that handler forwards it outward to the enclosing Exn handler, which
-- discharges it. Resumption threads back through the inner handler, so the two
-- effects interleave across nested handlers. The native free-monad lowering
-- drives this the same way the interpreter does.
fn work() : Int ! {Log, Exn} =
log(1)
log(2)
let x = raise(0 - 7)
log(x)
x
fn run() =
handle (handle work() with {
log(n) resume k => let _ = println(n) in k(()),
return r => r
}) with
raise(code) resume k => code
return r => r
fn main() = println(run())
The fallback reifies one cell per pending operation, so its cost is proportional to the operations in flight; the fast paths avoid it where they apply.
10.1 Arena Allocation
Prism expresses arena allocation as another handled capability rather than a surface storage mode. The standard-library Arena module defines a single-shot Alloc effect and with_arena : (() -> a ! {Alloc}) -> a. Ordinary code contains no implicit allocation effect: constructors outside with_arena remain ordinary Core values and take the same prism_alloc path as before.
Before the tier cascade, the arena analysis finds functions that install an Alloc handler and resolves the entry thunks passed to them, descending into thunk values so an installation inside a loop body or closure counts like one at the top of a function. It computes arena_only = arena_reachable \ otherwise_reachable, where the otherwise side likewise sees through thunks but carves out the entry thunks passed to installers (those are precisely the arena side of the subtraction). In that arena-only set, a returned constructor or tuple is split into Do(alloc, arity) followed by InitAt(cell, value), and each installer’s handle is bracketed with the runtime region hooks: arena_enter opens a region and yields a depth token, and arena_exit threads that token plus the activation’s result, so the bracket is data-dependent and an unbalanced pairing traps rather than corrupting. The handler discharges alloc to prim_arena_bump; after effect lowering has removed the Do, the shared emitter lowers InitAt by storing the tag and fields into the returned raw cell. This transformation runs before tier selection, so every effect-lowering strategy sees the same allocation boundary.
The exclusion is part of the contract. A helper reachable from both arena and non-arena paths stays on the ordinary allocator unless the compiler can specialize it, preserving byte identity for its non-arena callers. Closures and RefNew cells also remain on the ordinary path. The regression corpus exercises both an arena-only list builder and the shared-helper exclusion. @ noalloc still rejects Do(alloc): changing the allocator makes a cell cheaper, not nonexistent.
At runtime each with_arena activation owns one block-chained bump region linked with every native program. prism_bump carves cells from the innermost open region and marks them arena-owned with a bit in the refcount word, so dup/drop, the child-scan decrement, and reuse tokens are no-ops on them and every rc == 1 uniqueness fast path correctly sees them as never-unique; with no open region it delegates to prism_alloc unchanged. At arena_exit the runtime deep-promotes every arena-owned cell reachable from the activation’s result into ordinary refcounted cells (a value may escape its region; escape costs a copy, never soundness), releases the refcounted children the region’s cells own, and reclaims the whole region in O(blocks). The closed {Alloc} row on with_arena’s body is what makes the bracket sound: no foreign effect can unwind past the return clause, and the once grade forbids a multishot resume across the boundary. The result is the only escape channel (again the closed row), so promotion at exit covers every value that can outlive its region. The promotion corpus exercises this escape path, and performance ratchets pin the zero-prism_alloc-per-element claim.
10.2 Concurrency
Prism has no built-in threads, event loop, or async runtime. Concurrency is this free-monad fallback applied to one handler: the Concurrent standard library defines an Async effect and a handler, run_async, that schedules fibers cooperatively. The schedule is deterministic (fixed by the program’s structure, not a clock), the scheduler keeps no mutable state, and it runs in constant native stack. The full API is the Concurrent reference; this section is how the pieces above realize it.
The Async(a) effect is parametric in the fibers’ shared result type a, with operations fork(() -> a ! {Async(a) | e}) : Fiber, yield, await(Fiber) : a, cancel(Fiber), and a buffered FIFO channel/send/recv; sharing one result type is what lets a single run queue hold every fiber without existentials. With no shared mutable cell the handler cannot poke a run queue in place, so it reifies each step instead: a step function runs a fiber body to its next Async operation and returns a Cmd (Forked, Yielded, Awaited, Cancelled, Opened, Sent, Recving, or Finished) with the fiber’s continuation captured inside, and a pure drive loop interprets one Cmd at a time, threading an immutable Sched record that holds the run queue, the finished results, the parked awaiters, the cancelled set, and the channel buffers. A fiber blocks by having its continuation parked in Sched and wakes by being moved back onto the queue; because every continuation escapes into Sched the program takes the free-monad path above, and under constant-stack driving the loop runs an unbounded number of steps without growing the native stack.
A fiber performs more than Async, so the reified Cmd must store continuations that perform arbitrary effects, and its effect row is therefore a row-kinded parameter, type Cmd(a, e : Row), threaded through Cmd, Sched, and the scheduler functions to make the whole library polymorphic in the fibers’ effects. The handler’s type is run_async : forall a e. (() -> a ! {Async(a) | e}) -> a ! {e}, discharging Async and leaving e, so fibers that perform IO yield a run that performs IO and fibers that perform a capability E yield a run that performs E, written once for every row. This stays sound through the ambient-row discipline of the type checker: at a fork the fiber’s row variable is tied to the caller’s ambient row rather than opened fresh, so forking a fiber that performs E forces E into the caller’s row and out through run_async, and a fiber cannot perform an effect no handler was demanded for. It is the same forwarding used by nested handlers, now through the scheduler, a fiber’s capability tunnelling past the non-handling scheduler to an outer handler exactly as an EOp the driver does not name is re-emitted outward; that is the capabilities-as-handlers pattern, where a capability is granted with an ordinary handle around run_async.
The structured wrappers are ordinary functions over these operations: scope(tasks) forks a list of fibers and awaits them all on a successful run, cancel(f) records the fiber and its descendants in Sched so their next resume delivers the non-resumable cancellation signal, and a channel carries the shared type a with send handing its value to a waiting receiver or buffering it and recv taking the buffer head or parking the fiber, the same reify-and-thread machinery as await keyed by a channel id rather than a fiber id. Requested, unwinding, and completed cancellations are separate scheduler sets: entering the unwinding set removes the request and masks subsequent requests, so an on_cancel cleanup may itself yield or await; a child forked during that dynamic extent is immediately requested to stop. A target enters the completed set only after its never unwind reaches step, so try_await can join that point and never return Was_Cancelled before installed cleanups finish. If cleanup fails, completion is never recorded and the scheduler run fails instead.
Failure has one scheduler-global policy, not a hidden nursery identity: an unhandled fail() in any fiber marks every other live fiber and its descendants for cancellation, drains runnable cancellation work, and re-performs fail() from run_async/run_lifo. If fiber 0 is among those victims, its requested stop and yielding cleanup take priority over failure deferral; only its normal continuation is withheld. scope is therefore a structured success-path join, not a failure-isolation boundary; a scoped task failure also cancels unrelated live fibers in the same run. Explicitly cancelling fiber 0 uses the same deferred termination path, so queued descendant cleanups run before the boundary fails. A cleanup parked with no runnable producer reaches the existing empty-queue failure, while a cleanup that continues generating work can diverge normally. Because cooperative cancel is a source Async operation rather than an external observation, its deterministic scheduler steps add no replay event. Composed with the Replay handlers, a concurrent run that draws randomness or reads input records records only those capability observations and replays to the identical result, its capability effects tunnelling out of run_async and into record/replay like any other.
11. Reference Counting and FBIP Reuse
Reference counting runs after effect lowering, over the handler-free core, so it counts evidence parameters and any reified cells as ordinary values. Memory is managed by Perceus-style reference counting (Reinking et al., 2021): every parameter and binding is owned and consumed exactly once on every control-flow path from its binding to the end of its scope; a second use inserts a dup and an unused value inserts a drop. Perceus places these operations precisely rather than conservatively at scope exit, which frees a cell at the earliest point the reuse pass below can claim it. Closure captures are borrowed (read without being consumed) and duplicated before a consuming use, as is a borrow parameter (see declarations and programs). The parameters a function borrows are recorded as a per-function bit vector, its interprocedural borrow signature, which every caller consults to place its dup/drop correctly. Because that signature crosses call sites, it is one of the analyses that complicates the move to separate compilation (see name resolution and modules).
The reuse pass then turns drops into in-place updates. When a uniquely owned scrutinee is dropped and the continuation rebuilds a constructor of the same or smaller size, the drop becomes a scoped reuse node, WithReuse { token, freed, body }: it frees the cell once and binds a reuse token over the continuation, and the rebuild spends that token with an in-place Reuse(token, ctor), so map and tree rebuilds mutate the spine in place. The token is a binder that only a Reuse may name, and the rewrite spends it on every control path or declines wholesale (keeping the safe no-reuse body), so freeing a cell once and spending its token at exactly one allocation are well-formedness properties of the term rather than a condition checked afterward.
An independent verifier re-checks that output. fbip::balanced re-simulates the inserted dup, drop, and reuse operations as a linear-token machine: each owned binding starts with one token, a dup adds one and a drop or consuming use removes one, a use may never drive the count below zero, every binding must reach zero before leaving scope, the two arms of a branch must agree, and a WithReuse grants its token exactly one credit the body must spend. It runs over the reference-counted core on every interpreter entry and across the whole example and test corpus, so an under-dup, an over-drop, or an unbalanced branch left by the insertion pass surfaces as an internal error rather than a leak or a double free at run time. Core Lint adds the dual direction under PRISM_CORE_LINT (see lint, telemetry, and parity): it rejects a reuse token spent more than once on any path, the over-spend the balance check does not see.
The fip/fbip annotations (see declarations and programs) are the fully-in-place discipline of Lorenzen et al. (2023), here static checks layered on these passes. fbip proves zero fresh allocation and a call-graph closure over annotated, allocation-free callees. fip adds two further properties: linearity (each owned binding is consumed at most once, checked on the source term, with scalars exempt because adjusting the count of an unboxed word costs nothing) and bounded stack. The tail-call and tail-modulo-cons (a tail call whose result is wrapped in one constructor) classification is shared with codegen, so an accepted fip function always lowers to a loop; acceptance never outruns what the backend emits.
-- FP^2 in-place list operations (Lorenzen/Leijen/Swierstra, ICFP 2023),
-- statically checked. The annotation makes the compiler PROVE the function
-- allocates no fresh cell: every `Cons` it builds reuses one it just matched and
-- dropped. Run with PRISM_REUSE_STATS=1 to watch the reuse hits, or
-- PRISM_CHECK_LEAKS=1 to confirm zero live cells at exit.
--
-- `rev_onto` and `bump` are `fip`: linear (each binding used once) AND bounded
-- stack. `rev_onto` is a plain tail call and `bump` is a tail-modulo-constructor
-- (`Cons(.., bump(t))`), so both lower to a constant-stack loop, not recursion.
-- `cap_at` is only `fbip`: its `h` is read by the guard AND rebuilt into the
-- `Cons`, two uses of one value, so it is not linear (its element type is generic
-- `a`, so `h` cannot be assumed an immediate). Zero-allocation still holds.
--
-- Prints:
-- 10
-- 6
-- 22
-- Reverse onto an accumulator, the canonical fip. Each input `Cons` is matched,
-- freed, and immediately reused as the next accumulator cell, so the reversal
-- runs in place with zero allocation, and the tail call makes it a loop.
fip fn rev_onto(xs, acc) =
match xs of
Nil => acc
Cons(h, t) => rev_onto(t, Cons(h, acc))
-- The seeding wrapper is not fbip: that initial `Nil` is a genuine fresh
-- allocation with no cell to reuse, so it stays un-annotated.
fn reverse_ip(xs) = rev_onto(xs, Nil)
-- Spine-rebuilding map: drop each `Cons`, rebuild it around the bumped head.
fip fn bump(xs) =
match xs of
Nil => Nil
Cons(h, t) => Cons(h + 1, bump(t))
-- Saturating transform: rebuild every cell, head chosen by a test. Both `if`
-- branches end in a `Cons` reusing the same freed cell, so the reuse credit is
-- spent exactly once on every path. (A `filter` that DROPS cells could not be
-- fbip: the discard path frees a cell with no local allocation to reuse, and
-- this runtime has no cross-call reuse credit, so it would allocate.)
fbip fn cap_at(xs) =
match xs of
Nil => Nil
Cons(h, t) =>
if h > 9 then
Cons(9, cap_at(t))
else
Cons(h, cap_at(t))
fn main() =
println(sum(reverse_ip([1, 2, 3, 4])))
println(sum(bump([1, 1, 1])))
println(sum(cap_at([1, 20, 3, 40])))
This turns a familiar library idiom into a checked one. A mutable structure presented behind a pure interface, a buffer or array updated in place under an API that appears to return a fresh value, is written by hand throughout functional libraries (OCaml’s Base and Core are full of such in-place blits), and its correctness rests on the author having reasoned that no other reference can observe the mutation. Prism derives the idiom from ownership rather than trusting it: the reuse pass updates in place exactly when the scrutinee is uniquely owned, and the independent fbip::balanced verifier re-establishes that on every control path before anything runs. The hand-written version hopes the aliasing is safe; here the safety is a property of the term the compiler has already proved.
12. Backends
Prism has three backends over one core: a tree-walking interpreter that is the reference oracle, and two native backends that must match it byte for byte. The interpreter branches off before effect lowering, consuming the optimized elaborated Core directly, while the native backends consume the effect-lowered, reference-counted form; the parity oracles hold the two routes to identical observable behavior. The native backends share a single generic emitter, so the differences below are narrow.
12.1 The Interpreter
The tree-walking interpreter is a flat CEK (control, environment, continuation-stack) machine. Pending work lives on an explicit heap stack of frames rather than the host call stack, so object-program recursion never overflows it. A frame is one of: Bind (await a result, then continue with the rest of a sequence), Args (await a function before applying it), Handle (an installed handler), Mask (a masking frame), and Restore (unwind a name binding; a Restore already on top marks tail position, which is where the machine recognizes a tail call).
This machine makes the delimited continuation of effects and handlers concrete: performing an operation searches the frame stack outward for a matching Handle, decrementing the skip count past masked frames, and the captured continuation is exactly the slice of frames between the do and that handler, the handler included. Resuming pushes a clone of that slice back onto the stack, so the same resumption can be pushed again, which makes k multishot. The native backends realize this same frame stack in the runtime as a chain of counted frame cells linked by a next field, one cell per Bind, Handle, and Mask frame; resuming splices a clone of the delimited slice onto the current chain with prism_kont_splice, which copies and relinks the slice in two iterative passes, so a deep continuation is captured and re-entered in O(1) C stack regardless of its depth, and an abandoned continuation is freed through the same iterative refcount worklist (see reference counting). The free-monad backend reifies this same frame slice as the k closure of an EOp (see effect lowering); evidence passing never materializes it.
12.2 The Shared Emitter
Both native backends drive one generic emitter; the whole of its dependence on the target is a single Rust trait, Isa (instruction set architecture), the abstract backend interface. The emitter owns every decision with semantic content: case dispatch, closure and constructor allocation and reuse, reference-count placement, and tail-call lowering.19 Isa itself is only instruction spelling: about forty leaf methods (const_int, bin, load, store, call, switch, ret, and so on) that know nothing of what a Prism program means. The LLVM backend spells them through inkwell; the MLIR backend writes them as textual llvm-dialect ops. The two targets are structurally identical but for one point, how control flow merges: LLVM joins branches with phi nodes (a value chosen by which predecessor arrived), MLIR with block arguments (the value passed to the successor block). The emitter abstracts that single difference behind jump_merge (hand a value to a merge point) and open_merge (open the block that receives it), so the shared Core walk is oblivious to which discipline the backend below it uses.
The layering is worth stating explicitly, because it is where the design’s leverage lives. The emitter walks the fully-lowered Core (after effect lowering and reference counting) and, node by node, mints an SSA operand name for each result and drives Isa with those names; Isa never sees a Core node, and no third IR sits between the two. So codegen is a single Core walk that emits a stream of instruction calls: Comp/Value in, String operand names threaded through a register map, Isa calls out, target text at the leaves. Every Core-level judgment (evaluation order, allocation, reference counting, tail-call and reuse classification) is made once in that walk, above Isa, which is why a backend inherits all of it and spells only instructions.
A new target is therefore a Rust impl Isa and nothing else. Retargeting Prism to some other machine, a real ISA or perhaps a 6502 or a Minecraft redstone computer, is writing those forty methods and inheriting the calling convention, reference counting, pattern-match trees, tail-call loops, and in-place reuse unchanged, never restating what the language does. The split earns two things: the two shipped backends come out byte-identical by construction, so the parity gate (see verification) holds for free rather than by reconciling two hand-written code generators, and a backend becomes an afternoon of instruction spelling rather than a second implementation of the compiler.
12.3 Symbol Namespaces
Several definers emit into one flat native symbol space: Core functions, whose names come from user source; the families the emitter generates itself (one body per lambda tag, one dispatcher per apply arity, one hole-passing helper per tail-modulo-constructor loop); and the C runtime. Nothing coordinates them, and the names are not the compiler’s to choose: bump, alloc, and box are ordinary things to call a function and are also runtime intrinsics. A program that names one of them the same way the runtime does emits a second definition of it, and the failure surfaces at the link step as a duplicate symbol rather than as a diagnostic.
Detection is the wrong instrument. Rejecting fn bump is a language regression, and a check against the runtime’s current symbol table rots the moment the runtime gains a function. So the prefixes are chosen instead so that collision is not a thing that can be spelled: each is prism followed by a distinct character, so two symbols from different definers disagree at a fixed index and cannot be equal, whatever either side is renamed to later.20 The argument is machine-checked twice over. That the prefixes are pairwise distinct is a const assertion in the compiler, so a change that collapsed two of them fails while the compiler itself compiles; that the runtime respects its half is a test over the embedded runtime sources, since the C side is where a stray definition would otherwise slip in unnoticed. One crossing is deliberate and is the reason the entry point is named at all: main is an ordinary Prism function, so the runtime calls it by its mangled name like any other, and that single name is the only place C reaches into the Core-function namespace.
A generated family earns a prefix of its own rather than a decoration on the Core name it derives from, because a decoration is forgeable. The same reasoning applies wherever a later pass wants to name a helper after a user function, which is why the emitter builds no symbol by hand and every family is minted in one place.
The prefix split makes different definers disjoint; the suffix encoder makes Core names within the function namespace injective. Core uses . for exported module members, @ for private and hygienic names, and $, %, and # in synthesized names. Simply replacing @ with . made Wire@dec_list and Wire.dec_list alias. Native names now use a reversible GHC-style Z encoding: Z doubles, common Core punctuation has a short code, and any remaining UTF-8 byte has a hexadecimal escape. Ordinary names such as main, bump, and unwrap_or stay readable. A test decoder proves the construction by round-tripping plain, qualified, private, specialized, generated, and non-ASCII names; the former adversarial pair is asserted distinct.
The bug this closes was quiet for a reason worth naming, because it generalizes. A collision only exists if both definitions survive to the link, so a one-line fn bump is inlined away and links cleanly while a recursive one does not: the defect was invisible to every small test of it and appeared only in a real program.
12.4 LLVM
The LLVM backend implements Isa over inkwell, emitting LLVM IR that clang compiles and links against the runtime. This is the default native path.
Prism runs no LLVM optimization passes itself: it verifies the module, writes bitcode, and hands the rest to clang -O2 -flto=thin, compiling the emitted bitcode and the C runtime in one invocation so ThinLTO inlines the runtime into the generated code. Every emitted function carries nounwind (Prism has no exceptions and this backend emits no invokes or landingpads), which lets the -O2 pipeline drop unwind tables and treat each call as non-throwing. Three knobs tune this last step, all distinct from the Core-to-Core -O of optimization: --backend-opt <0|1|2|3|s|z> (or the PRISM_BACKEND_OPT env var) sets the clang -O level, defaulting to 2; PRISM_CC picks the compiler (default clang); and PRISM_CC_FLAGS appends arbitrary flags after the defaults, so a trailing -O0 wins or -march=native/-g can be added. ThinLTO stays on at every level, since it is what folds the runtime into the program.
StoreGet, StorePut, and StoreHas are interpreter-only. The native backend rejects them with a diagnostic instead of emitting unresolved runtime calls because the runtime exposes no store ABI.
Native LLVM builds also retain the metadata needed to name generated code by the same content identity the interpreter’s kont envelope uses. The shipped pieces are a prism_native_kont_table section with scheme, bundle digest, and symbol-to-definition-hash rows; an exact function-pointer table for reachable functions; and a prism_native_kont_state_map keyed by native symbol, definition hash, Core name, and arity.21
When PRISM_NATIVE_KONT_FRAMES is enabled, generated functions also maintain a bounded thread-local shadow stack of those entry ABI words, and musttail calls retarget the top shadow frame before the LLVM musttail call so the instrumentation does not invalidate the verifier’s tail-call shape. The runtime can expose raw state-map bytes, resolve a known entry pointer or captured program counter back to a definition hash, walk native frames into stable symbol-plus-PC-offset anchors, and format any shadowed entry values in a native-kont manifest. A restricted resume primitive can re-enter an exact generated function entry by native symbol and captured ABI words through the retained pointer table, refusing arity mismatches and arities outside the small fixed C-call family.
What does not ship is arbitrary native continuation resume. The frame metadata identifies code positions and entry values; it does not serialize mid-basic-block locals, stack slots, or registers. Mid-basic-block stack/register resume remains deliberately unsupported.
The instruction-level mapping this backend drives Isa through, worked node by node, is its own section, since the MLIR backend emits the identical shape and the mapping is worth reading independent of either target.
12.5 MLIR
The MLIR backend implements the same Isa by writing textual MLIR in the llvm dialect. Sharing the emitter makes its output byte-identical to the LLVM backend’s, which the parity gate (see verification) enforces.
It emits textual llvm-dialect MLIR and stops there, touching none of MLIR’s other dialects, passes, or its C++ builder infrastructure. Its role is to parity-check the shared emitter rather than provide a distinct dialect pipeline.
12.6 WebAssembly
The compiler front end and the interpreter also compile to WebAssembly, so Prism type-checks and runs in the browser. This target hosts the interpreter, not the native code generators; the LLVM and MLIR backends are absent there. The web bundle serves the playground, the in-browser REPL, and the gallery residents from this one target: the boids scrubber, double pendulum, branching timelines, chaos counter, schedule map, teleport demo, content-addressed Merkle graph, and incremental graph. The Determinism Machine residents are not separate semantics. Each is a small wasm export over ordinary Prism examples: scrubbers replay a deterministic trace to frame N, branching continues from a serialized boids frame, chaos batches seeded schedules and checks one final-state hash, the schedule map renders individual seeded interleavings as navigable nodes over that same export, and teleport moves a kont envelope only over same-origin browser contexts, with receiver readiness, transfer ids, and code-hash agreement checked before resume.
That same-origin boundary is intentional. The demo proves migration of a running computation between contexts that already share the same origin and bundle; it does not claim cross-origin or cross-stranger execution. Envelopes from untrusted peers are unsupported because Prism defines neither a typed mobile envelope with receiver capabilities nor a distribution trust model.
13. Lowering Core to LLVM
The translation from core to instructions is narrow because the machine underneath it is narrow. By the time the backend runs, effect lowering has erased every Handle and Do (see effect lowering), reference counting has inserted every Dup and Drop (see reference counting and FBIP reuse), and the value representation has collapsed every type to one machine word. So the emitter faces only two things to lower: data laid out in cells, and computation as straight-line calls and branches over i64 words. It emits no struct types and no read barriers; one i64 is the type of every value, and inttoptr/ptrtoint reinterpret that word as a cell pointer only where a field must be reached. Because this is the shared emitter’s mapping, the MLIR backend emits the identical shape in the llvm dialect, byte for byte.
A value is an immediate or a pointer, both an i64. An Int literal is the immediate (n << 1) | 1, so the literal 0 is the constant 1; Bool, Unit, and the fixed-width words are immediates too. A Ctor allocates: prism_alloc(arity) returns a cell whose header the emitter fills by storing the tag at offset 8, then storing each field from offset 24 upward, and the cell’s ptrtoint is the value word. A Case is the inverse and asks for exactly one shape: reinterpret the scrutinee as a pointer, load its tag from offset 8, and switch, one block per constructor plus a default that calls prism_match_error and falls into unreachable (the exhaustiveness the checker already proved, made a hard trap rather than a silent fallthrough). Each arm reaches its bound fields by getelementptr and load, and drops or retains them as the surrounding reference-count nodes direct. All of unwrap is one such switch (the LLVM tab is the emitter’s own output at -O0, unoptimized so the function survives as its own definition; with optimization the backend inlines a leaf this small into its caller):
type Opt = Non | Som(Int)
fn unwrap(o : Opt) : Int =
match o of
Non => 0
Som(n) => n
fn main() = println(unwrap(Som(7)))
The arms are the reference-count discipline written into the instruction stream: the Som arm retains the field it returns, which now escapes its cell, and releases the scrutinee it consumed; the Non arm returns an immediate the collector ignores, so it only releases the scrutinee. prism_rc_inc/prism_rc_dec are no-ops on an immediate (checked in the runtime), so the emitter inserts them uniformly and pays nothing for scalars.
The rest falls out along the same grain. Bind/Return are A-normal form made literal: a Bind names a result as an SSA value, control runs straight down between calls, and Return yields the value word. A Prim on immediates unmasks the tag bit, applies the native add/mul/icmp, and re-tags, with an overflow check that falls back to the bignum runtime routine (see integers); the I64/U64 lanes are raw i64 machine ops with no tag. If is a br. A top-level function is a define i64 @prismfn_<name>(i64, ...), a Call is a direct call, and an App of a closure goes through a generated prismap_<arity> trampoline (closures are below); a Thunk is a nullary closure and Force runs it. A tail call becomes a musttail self-loop or a destination-passing loop, the classification the shared emitter owns. Every define carries nounwind, because there is nothing to unwind: only values, cells, and calls.
A dozen node-to-instruction rules cover almost everything a program is made of:
| Core node | LLVM |
|---|---|
Int n | the tagged immediate 2n + 1 |
Ctor tag [f..] | prism_alloc(arity), store tag at +8 and fields from +24; the cell pointer is the value |
| a field read | inttoptr, getelementptr to the offset, load |
Case | load the tag at +8, then switch; the default calls prism_match_error then unreachable |
If | br i1 |
Prim + - * | untag, native add/sub/mul, re-tag, with a prism_rt_int_* call on overflow |
Prim == < | icmp (a prism_rt_int_cmp call where a bignum is possible) |
Bind / Return | an SSA name / the returned i64 word |
Call f | a direct call i64 @prismfn_f(...) |
App (a closure) | a call to a generated prismap_<arity> trampoline (closures, below) |
a self-tail Call | musttail call, which becomes a branch (below) |
Force a Thunk | a call of a nullary closure |
Dup / Drop | call @prism_rc_inc / @prism_rc_dec, a runtime no-op on an immediate |
Function calls and tail recursion. A Call is a direct call; the case worth watching is an accumulator loop. A self-tail call of equal arity is emitted musttail, which LLVM turns into a branch back to the function’s own entry, so a loop written as recursion runs in constant stack with no call frame at all. The immediate arithmetic (untag, operate, re-tag, with the bignum runtime only on overflow) is elided here to keep the shape legible:
fn sumto(n : Int, acc : Int) : Int =
if n == 0 then
acc
else
sumto(n - 1, acc + n)
fn main() = println(sumto(100, 0))
The assembly is the payoff: there is no bl _prism_sumto. The recursive tail call is the b Ltail branch to the loop header, so a million-deep sumto never grows the C stack.
Effects, handlers, and continuations. By the time the backend runs no Do or Handle survives: effect lowering has discharged them. In the common case it fuses the handler into ordinary calls by evidence passing, threading each clause as an extra parameter, so a perform becomes a call on that evidence and a handler costs exactly a function call. The State handler lowers with get/put erased into calls on an evidence value, the state threaded as a plain argument, and no allocation:
effect State
get() : Int
put(Int) : Unit
-- Algebraic State by parameter passing.
--
-- The handler interprets get/put by parameter passing. Each clause returns a
-- function s -> result, and `k(v)(s)` threads the state forward. The handled
-- block becomes a state transformer that we run with an initial s.
--
-- counter() never mentions a state value, it just performs get and put. The
-- row is inferred and discharged at the handler, so the same ops could be
-- reinterpreted (logging, bounding) without touching counter.
fn tick() : Int ! {State} =
let n = get()
put(n + 1)
n
fn counter() : Int ! {State} =
tick()
tick()
tick()
get()
fn run_counter(init) =
let f =
handle counter() with
get() resume k => \(s) -> k(s)(s)
put(s2) resume k => \(_s) -> k(())(s2)
return r => \(_s) -> r
f(init)
fn main() = println(run_counter(0))
When a handler cannot resolve to compile-time evidence, because a clause captures its continuation and may resume it more than once (search, a generator, a fiber scheduler), lowering falls back to the free-monad form: a Do builds a counted EOp cell whose k field is the captured delimited continuation, and resuming splices a clone of that frame slice back onto the running chain with prism_kont_splice in O(1) regardless of depth (the interpreter realizes the same chain). A fiber is thus not a backend construct at all: it is exactly this captured continuation, suspended at a yield and re-entered by its scheduler, so multishot handlers and cooperative concurrency are one mechanism.
Polymorphism and type classes. Prism is fully type-erased: the checker verifies types and effect rows and then discards them, so Core and everything downstream is untyped and no value carries its type at run time (a cell’s tag is a constructor tag, never a type tag; the only run-time discrimination is the immediate/pointer low bit and that constructor tag). Because every value is therefore one i64, a generic function has a single machine-code body that serves every instantiation: Prism does not monomorphize for layout the way a C++ template or a Rust generic does, so map is compiled once, not once per element type. Type classes ride the same evidence mechanism as effects: a constraint becomes a dictionary, a record of the instance’s methods, passed as an ordinary value argument, and a method call is a field load plus an indirect call on it. The Specialize pass (see specialize) then clones and inlines that dictionary away wherever the instance is known at the call site, so dictionary passing is the always-correct fallback and specialization is speed layered on top, never a prerequisite for compiling. One i64, one body, and dictionaries for whatever polymorphism survives.
Closures. A lambda is lifted to a top-level function that takes its free variables ahead of its parameters, and a closure value is a heap cell holding just those captured variables, tagged by which lambda it is; no code pointer is stored in the cell. Application is defunctionalized: a Call to a statically known function is direct, but an App of an unknown closure calls a generated prismap_<arity> trampoline that recovers the environment from the cell and dispatches on its tag to the lifted body. Higher-order code is therefore ordinary tagged data and a switch, in keeping with the uniform representation:
fn adder(x : Int) : (Int) -> Int = \(y) -> x + y
fn main() = println(adder(10)(5))
In-place reuse (FBIP). The reuse pass (see reference counting and FBIP reuse) turns match-then-rebuild, the shape of every functional update, into in-place mutation when the matched value is uniquely owned. It emits a reuse_token on the dead scrutinee and a reuse_alloc for the new constructor: prism_reuse_token hands back the cell’s shell when its refcount is 1 and null otherwise, and prism_reuse_alloc overwrites that shell or falls back to a fresh allocation. So a bump mapping over a uniquely-owned list rewrites each node’s fields with stores and allocates nothing, while the identical source over a shared list transparently copies:
fn bump(xs : List(Int)) : List(Int) =
match xs of
Nil => Nil
Cons(h, t) => Cons(h + 1, bump(t))
fn main() = println(sum(bump([1, 2, 3])))
This is the whole of Prism’s “functional code, mutable performance”: the emitter never decides to mutate, it always emits reuse, and the refcount decides at run time.
Tail calls, and where the C stack still is not enough. The musttail loop above fires for a self-tail call of equal arity, and a constructor- or accumulator-shaped tail call becomes the destination-passing loop of the shared emitter. But a tail call through a closure trampoline or to an unknown function cannot be musttail under the borrow calling convention (argument ownership is the caller’s to settle), so it returns normally and could in principle grow the C stack. That is exactly why the delimited continuations of the interpreter are realized natively as a heap chain of frame cells rather than left on the hardware stack: a resumption, a deep generator, or a fiber that suspends and resumes thousands of times rides that heap chain, spliced in O(1) by prism_kont_splice, so the one place the C stack would overflow is the one place Prism declines to use it. Self-recursion is a loop, open control is heap-reified, and nothing counts on unbounded C stack.
14. The Runtime
The C runtime (memory and reference counting, strings, bignums, floats and the vendored libm, effects, sorting, arrays, byte buffers, typed buffers, continuations, and IO) is linked with the code each backend emits. It assumes an LP64 target (64-bit pointers and long) and uses mimalloc when available. The data representation below is shared by the backends and the runtime.
14.1 Value Representation
A Prism value is one 64-bit word, tagged by its low bit, so that a single representation serves both scalars and pointers under polymorphism:
63 1 0
+-------------------------------------------+---+
| payload | t |
+-------------------------------------------+---+
t = 1 immediate: an unboxed scalar in the high 63 bits
(a small Int n is (n << 1) | 1; Bool reuses this exact
encoding on 0/1, so false = (0 << 1) | 1 = 1 and
true = (1 << 1) | 1 = 3)
t = 0 pointer: a word-aligned heap-cell address
(alignment keeps the low bit clear)
all 0 unit: the zero word, an immediate with no payload
dup / drop (prism_rc_inc / prism_rc_dec) take the raw word and skip an
immediate or unit without a dereference, so they are no-ops on non-cell
values under polymorphism.
A float does not fit the immediate scheme, so it is boxed: wrapped in a one-field cell holding the raw double bits, which are read back out (unboxed) at every float operation. Boxing makes a float field self-describing, so the collector frees it without interpreting its payload.
Bool is not a two-constructor data type at runtime: false and true are the integers 0 and 1, run through the same tagging step as any Int (see integers), so the raw word is (n << 1) | 1, i.e. 1 for false and 3 for true. There is no heap cell, no constructor tag, and no branch to distinguish a Bool from any other immediate at the value level; a conditional on it is a native compare-and-branch on the raw word, and dup/drop skip it as they do any immediate.
14.2 Cell Layout
A heap cell is a three-word header followed by its fields.22
| Offset | Field | Meaning |
|---|---|---|
| 0 | refcount | number of live references to this cell |
| 8 | tag | constructor tag; reserved values mark String and bignum cells |
| 16 | arity | number of fields (or byte length for a String) |
| 24 | fields | arity words, each a value or pointer (UTF-8 bytes for a String) |
Constructor tags follow declaration order (for Option(a) = None | Some(a), None is 0 and Some is 1). Two tag values are reserved, marking cells whose payload is raw bytes or limbs rather than child values:
| Tag | Cell |
|---|---|
0x53545200 | String (UTF-8 bytes) |
0x42494700 | bignum (limbs; see integers) |
The collector and the reuse pass (see reference counting and FBIP reuse) read the tag to avoid recursing into them.
14.3 Reference Counting
prism_rc_inc and prism_rc_dec take the raw value word and return immediately on an immediate or unit, so counting is a no-op on non-cell values. Decrement to a nonzero count just decrements. Decrement to zero frees the cell, but freeing is iterative, not recursive: the dead cell’s now-zero refcount word is reused as a link field in an intrusive worklist of cells pending free, so a structure of any depth is reclaimed in constant auxiliary space without growing the C stack.23 A string or bignum tag short-circuits the child traversal.
14.4 In-Place Reuse
The reuse pass of reference counting and FBIP reuse emits two runtime calls. prism_reuse_token(v) inspects a cell about to be dropped: if it is uniquely owned (refcount 1), it drops the cell’s children and returns the shell as a token, leaving the live-cell count untouched; otherwise it decrements and returns null. prism_reuse_alloc(token, n) overwrites the token’s header for the new constructor when the token is non-null, and falls back to a fresh allocation when it is null. A uniquely owned spine is therefore mutated in place, and a shared one transparently copies.
14.5 Arena Substrate
The region allocator is on the canonical runtime manifest and rides with the memory core, so it links into every native program; CI also compiles and runs its standalone self-test. It is a block-chained bump allocator with create, aligned allocate, reset, destroy, and usage operations, and it knows nothing about Prism cells, reference counts, or handlers. The runtime keeps a stack of open regions: arena_enter pushes one, arena_exit pops and reclaims it, and prism_bump(n) carves a cell from the innermost open region (falling back to prism_alloc(n) when the stack is empty), so the alloc/InitAt lowering allocates from a region exactly where the arena-allocation pass reified it.
A region cell carries an arena-owned marker in its reference-count word, kept out of the tag word so match dispatch, which compares tag words directly, never sees it. prism_rc_inc, prism_rc_dec, the child-scan decrement, and the reuse-token path all treat a marked cell inertly, so dup/drop are no-ops inside a region, the region stays the cell’s sole owner, and no rc == 1 uniqueness fast path ever mistakes a shared region cell for unique. Each region cell is also threaded onto an intrusive per-region list. At arena_exit the runtime deep-promotes every arena-owned cell reachable from the activation’s result into ordinary reference-counted cells (so a value may outlive its region for the cost of a copy, never a use-after-free), walks that list to release the reference-counted children the region’s cells own, and returns the whole block chain in one pass.
14.6 Integers
A small integer is an immediate, (n << 1) | 1. An operation whose fixed-width result would overflow promotes to a bignum: a cell tagged 0x42494700 storing the value in sign-magnitude form (sign and magnitude kept separate).24 Each surface arithmetic operation takes a fast path on two immediates with a checked-overflow primitive and falls back to magnitude routines (add, subtract, multiply, and a shift-subtract long division) that renormalize the result, demoting back to an immediate when it again fits. The surface Int is this unbounded integer. The I64 and U64 lanes are raw machine words and wrap rather than promote.
14.7 Strings
A string is a cell tagged 0x53545200 whose field words hold its UTF-8 bytes inline, length-prefixed by the arity word and NUL-terminated for C interop. Each string the program builds, including a literal at each use, is a counted cell, so the leak counter (see instrumentation) accounts for strings like any other allocation. Two indexing families coexist: char_at, substring, and str_len work in Unicode codepoints, walking the UTF-8 encoding (and so are O(n)), while byte_at and byte_len give O(1) raw-byte access for a scanner or hash.
14.8 Instrumentation
Three environment-gated counters report to stderr at exit, leaving stdout (the parity-checked channel) untouched. PRISM_CHECK_LEAKS reports the live-cell balance, which a clean run drives to zero. PRISM_REUSE_STATS reports how many cells the reuse pass rewrote in place. PRISM_EFFOP_STATS reports how many free-monad EOp cells were allocated, which the performance gate asserts is zero on the fusion corpus.
14.9 Growable Arrays
The growable Array(a) (see the standard prelude) is an ordinary cell, { rc, tag 0, arity cap+1, len, elem0 .. }, with the length word stored odd-tagged (low bit set, so the collector skips it as an immediate per value representation) and unused slots held at zero. Because it is a normal cell, reference counting recurses into its live elements with no special case. Every array operation borrows its array argument. array_get returns a counted element; array_set, array_push, and array_pop write in place when the array is uniquely owned (refcount 1) and copy otherwise, so functional array code runs as mutation exactly when ownership permits. array_push doubles the capacity when full, making appends amortized O(1). The prelude’s HashMap is a separate-chaining hash table layered on this array, with an FNV-1a hash written in Prism (so iteration order is a deterministic function of the inserts); it is library code, not a runtime primitive.
14.10 Primitive Sort
sort is a runtime primitive (prism_sort_prim) that borrows a list and returns it sorted, dispatched on a key kind. Arbitrary-precision Int keys use a bignum-aware stable bottom-up merge sort, ping-ponging between two buffers; fixed-width keys use a radix sort over a derived key. When the input spine is uniquely owned, the sorted heads are written back into the existing cells with no allocation; a shared spine is copied with its elements shared. The Cons and Nil tags are read off the input spine, so no list layout is baked into the runtime.
14.11 Input, Output, and Randomness
The runtime provides the impure primitives. The nondeterministic inputs are no longer untracked builtins: they are the raw prim_* calls (prim_read_int, prim_read_line, prim_read_file, prim_file_exists, prim_rand, prim_getenv, prim_args_count, prim_arg) that the prelude reaches only from the handler arms of the capability effects and IO. The surface names read_int/read_line read stdin, read_file/file_exists read files, getenv reads the environment, rand draws a random word, and args_count/arg (wrapped by the prelude’s args) read the command line; each is a prelude wrapper that performs the matching Console/FileSystem/Random/Env operation, which the default run_io world handler discharges by calling the corresponding prim_*. The output primitives stay direct builtins carrying ! {IO}: write_file, append_file, and remove_file operate on files, system runs a shell command and returns its exit code, and eprint/eprintln write to stderr, leaving the parity-checked stdout untouched. Randomness is a SplitMix64 generator: prim_rand advances it and srand seeds it, so a seeded run is deterministic and reproducible. Because these touch the world, the parity harness (see verification) runs only the programs that avoid them.
14.12 Elementary Functions
Floating-point transcendentals are owned rather than borrowed from the platform, because the determinism contract does not survive a math library that rounds the last bit differently on two systems. sin(large), pow(edge, edge), or argument reduction near a multiple of pi/2 can differ by one ULP between glibc, macOS, BSD libm, and compiler-emitted libcalls. That is enough to break the parity oracle: a content-addressed compiler cannot say “same source, same core, same backend contract” if the final bit is delegated to whichever C library happened to be installed. Prism therefore treats elementary functions like the runtime ABI, not like an ambient host service.
The implementation is a vendored double-precision subset of musl’s libm. musl is a pragmatic fit here: the code is small, permissively licensed, already split into plain C translation units, and has no dependency on a platform -lm once the handful of internal support routines are carried with it. Prism keeps the fork intentionally shallow. The public musl symbols are renamed under prism_v_* so they cannot collide with the host libm; a thin wrapper exposes the stable prism_m_* wrapper surface that the compiler and interpreter call. Local patches are limited to portability glue such as replacing musl-only headers/macros and supplying the hardware IEEE sqrt helper for the vendored routines.
Every elementary function routes through that wrapper surface: the unary sin, cos, tan, their inverses and hyperbolics, exp/exp2/expm1, log/log2/log10/log1p, and cbrt, and the binary pow, atan2, hypot, and fmod. The boxed-float shims described under value representation unbox their arguments, call the wrapper, and rebox. The native backend emits calls to the same prism_m_* symbols, the vendored sources compile into one embedded archive, and the driver materializes and links that archive into generated programs. The host interpreter reaches the same wrappers by FFI because the compiler binary links the same runtime. The result is bit-identical native/interpreter behavior by construction rather than by a rounding coincidence, which is exactly what the parity oracle over float programs checks.
The whole library is compiled -ffp-contract=off (in both the embedded archive and generated-program link step), so no platform fuses a*b+c into an FMA and diverges the last bit of either ordinary arithmetic or a function’s internals. The contract this buys is determinism, not correctly-rounded results: the vendored routines are as accurate as the upstream musl libm and no more, but they are the same everywhere. The one current boundary is the browser-only wasm interpreter, which has no C link step and falls back to the Rust libm crate; that path is documented as a wasm resident compromise rather than a native-backend parity claim.
15. Verification
Compiler CI combines differential testing, sanitizer passes, structural checks, and a mechanized Core model. The parity harness uses the interpreter as its reference: it runs every example on the interpreter and each native backend and asserts byte-identical output, and with PRISM_CHECK_LEAKS set, zero leaked cells.
What the parity harness and the tier-parity check actually diff is not raw stdout but a single typed artifact, the canonical observation trace (ObservationTrace). A trace is an ordered sequence of Observation values, one entry per externally visible event a run produces: Stdout/Stderr chunks, Capability events (each a CapEvent recording a canonical operation label, its arguments, and its result, covering environment, console, filesystem, clock, and random reads), FileCommit records naming a written path by content digest, the terminal Exit code or Return value, and, when execution goes wrong, a Fault. The whole sequence folds to one sha256 digest (ObservationTrace::new, observation_digest) that names the run’s complete behavior, so two runs “agreeing” means their traces are equal, not that someone diffed stdout by hand. The interpreter and replay build this trace directly from the observations the evaluator records as it runs; a native binary, which exposes only a process boundary, is captured through ObservationTrace::from_process, which folds its stdout, stderr, and exit code into the same event vocabulary, and process_projection derives that same projection from a full trace so an interpreter run can be compared against a native one on equal terms. This is the one artifact both the parity harness (interpreter versus each native backend) and the tier-parity gate (native binaries built under different forced effect-lowering tiers) diff to decide pass or fail, and it is the same trace a run-lineage sidecar hashes to prove a replay reproduced its recording exactly: one comparison, one type, reused across the oracle, the backends, every tier, and replay, rather than an ad-hoc convention re-implemented at each site.
The trace records what a compiled program does, not what it is: it is a runtime-behavior artifact, orthogonal to the compile-time content hashes of dump core-hash and the stdlib Merkle root, which name a piece of core by its syntax rather than its execution. The two meet only through the oracle’s guarantee25: a content hash identifies a definition whose behavior the interpreter has pinned, and an observation trace is how “pinned behavior” is checked, tier by tier and backend by backend, rather than assumed.
The performance gate asserts that the optimizations actually fire, so a regression that leaves output unchanged is still caught. With PRISM_EFFOP_STATS set, it requires zero free-monad cells allocated on the fusion corpus (the stream and multi-handler programs in the stream and multi-handler corpus), confirming that the evidence and state paths of effect lowering reify nothing. It also pins local monadification: a program that pairs an escaping effectful closure with an unrelated fused pipeline must allocate no more cells than the escape alone, so the pipeline stays fused despite the escape. That check is anti-vacuous: it first asserts the escaping component does allocate a nonzero number of cells, so the gate cannot pass by everything being zero. An asymptotic check runs the constant-space programs at n=1000 and n=10000 and fails if allocation grows with n, and a set of constant-stack checks run a pure tail recursion, a var loop, the internal control effects, and a parameter-passing State loop at a million iterations each under a 2 MB stack (ulimit -s), so a lost musttail or a regression into the free monad overflows the stack and fails the test. With PRISM_REUSE_STATS set, it requires in-place reuse to fire on the reuse corpus, confirming the reuse pass of reference counting and FBIP reuse rewrites drops into in-place updates. A coverage gate (optimization_coverage) recomputes the lowering strategy each corpus program takes, by the same decision the compiler makes, and fails if any named fast path (evidence, state-fusion, local-partial) is left with no live witness, so silently losing a whole optimization is caught even when output and counters are unchanged.
Incremental compilation has its own oracle family: fresh, warm, edit-built, sequential, and parallel builds must converge on binary bytes, observations, query bindings, object identities, and normalized LLVM. Store fault tests interrupt every publication stage and prove that partial objects and dangling query entries are unreadable, retries converge, and immutable content survives concurrent writers.
A layout test pins the cell ABI: it reads the runtime source at compile time, parses the #defines for the tag offset, the header size, and the reserved string and bignum tags, and asserts each equals the constant the code generator emits against, so the runtime and the backends cannot drift apart without failing the build.
A static bar is enforced across the tree. It carries no todo!, unimplemented!, FIXME, or allow(dead_code) markers (a CI grep rejects them), and every unsafe block lives behind an audited local allow with a safety comment. cargo clippy runs clean with the pedantic, nursery, and cargo groups as warnings under -D warnings, and the C runtime compiles under -Werror with a broad warning set plus clang-tidy. Continuous integration (.github/workflows/ci.yml) runs on pull requests, pushes to main, and manual dispatch: formatting, the two lint passes, the full test suite (the parity and performance gates included), a re-run of the native parity corpus with the C runtime built under AddressSanitizer and UndefinedBehaviorSanitizer, the formatter checking its own corpus (prism fmt --check), a PRISM_CORE_LINT compile of every example, the WebAssembly playground (lint and type-check), the MLIR backend’s parity test, and the Lean model (lake build --wfail).
15.1 The Lean Model
Beyond the differential gates, the core calculus is mechanized in Lean 4. The formal model defines the syntax and a substitution-based small-step relation Step with its determinism theorem (Step.deterministic). A companion CEK model defines the abstract machine the compiler actually runs (see the interpreter): an environment machine with a continuation stack, Rv runtime values carrying closures and thunks, curried application, and the deep, mask-aware handler capture that makes resume multishot. The machine is a total, executable step function, so it is deterministic by construction and runnable.
The model’s central theorem connects the two. A big-step natural semantics specifies what a program evaluates to, and bigstep_runs proves the machine implements it (a forward simulation under any continuation stack), so the abstract machine is a faithful realization of the specification rather than an independent artifact. The metatheory adds the supporting metatheory: a unique-normal-form corollary, substitution lemmas, and a progress trichotomy (every computation is a value, takes a step, or is an explicit Stuck error, with stuckNoStep confirming the classification is a genuine partition). The dynamics proofs cover the effect machinery, proving the machine reaches a handler exactly when one is in scope (effect_progress) and is stuck on an unhandled operation otherwise (effect_unhandled). These compose into the effect-safety property behind concurrency: a computation performing an operation the frames a handler crosses do not name (Tunnels, the args/bind/non-matching-handle/mask frames a scheduler contributes) still reaches an outer handler (effect_tunnels), so a covered doOp steps while an uncovered one is provably stuck (effect_tunnels_progress). That is the machine-level image of the ambient-row discipline: a forked fiber’s capability tunnels through the non-handling scheduler to the handler the caller’s row demanded and cannot escape it. The surface typing side, that ambient-row inference forces every operation a fiber performs into the caller’s row so a covering handler must exist, is not itself mechanized; the two meet at the handler-in-scope predicate, inference guaranteeing the stack covers the row and these theorems guaranteeing a covered stack is effect-safe. Every theorem is sorry-free; the proofs declare no axioms of their own and reduce to Lean 4’s three standard ones, the entire trusted base sitting above the kernel at the top of the verification chain.
| Axiom | What it is | What the model uses it for |
|---|---|---|
propext | Propositional extensionality: (a ↔ b) → a = b. | Rewriting a proposition for a provably equivalent one, so Prop-level equalities behave like any other equation in the metatheory. |
Quot.sound | Quotient soundness: r a b → Quot.mk r a = Quot.mk r b. | The computational core of quotient types, and in Lean 4 of the kernel’s funext and the Acc/well-founded recursion the executable step and its termination proofs rely on. |
Classical.choice | The axiom of choice: extracts an element from a nonempty type, underwriting excluded middle and non-constructive existence. | Only where the model evaluates IEEE floats, whose arithmetic and the shortest-round-trip fmt_g port (the differential oracle) Lean defines non-constructively; the rest of the model is constructive. |
Determinism, progress, and effect-safety therefore rest on propext and Quot.sound alone, with Classical.choice confined to the float-formatting path.
The trusted stack is therefore explicit: the Lean 4 kernel; the hand-written Core JSON decoder; and the correspondence between the compiler’s serialized Core and the model’s syntax. The mechanized result covers the Core machine and its stated theorems, not the typechecker algorithm. Classical.choice enters only through float evaluation; the remaining axioms are propext and Quot.sound as reported above.
15.2 The Model as a Differential Oracle
The Lean model is the top of a verification chain rather than a co-equal third oracle beside the interpreter and native backends. The machine carries its proven guarantees, determinism and soundness against the big-step semantics, and is checked to agree with the interpreter on the compiler’s own core; the interpreter is in turn the differential oracle the native LLVM and MLIR backends are held byte-identical to (the parity harness above). A property proved once at the top, that the machine computes the specified value and no other, therefore propagates down the chain to every native binary the gate accepts. Concretely, prism dump core-json <file> serializes the elaborated core to a JSON tree, which the formal decoder reconstructs as Lean syntax, and the oracle executable runs the verified machine on it and prints the result, rendering floats through a port of the runtime’s fmt_g shortest-round-trip formatter so output is byte-identical. Because Lean cannot call the C and Rust printf machinery the other two backends use, that formatter is reimplemented from the raw IEEE-754 bits in exact arbitrary-precision integer arithmetic, choosing the fewest significant digits (one to seventeen) that round-trip back to the same double; the round-trip check is the one place the otherwise constructive model uses Classical.choice. The differential runner pipes each fixture through prism dump core-json | oracle and compares it against prism run, so the verified model is checked against the interpreter on the compiler’s actual core, not a hand-transcription. The curated agreements are also recorded as kernel-checked rfl theorems. The grammar in the specification is itself single-sourced from the formal grammar.
This hash-equals-behavior guarantee is what makes content-addressed core sound, and the compiler already computes those hashes (prism dump core-hash, folded into a stdlib Merkle root): a content hash names a piece of core whose meaning is pinned by the oracle, so identifying definitions by hash inherits the parity guarantee for free rather than asserting that two equal hashes mean equal behavior.
The gate turns this same discipline back on itself. Because a native binary’s output and its cell-leak result are a pure function of the source and the toolchain that built it, a passing parity verdict is content-addressable: with PRISM_GATE_CACHE set (off by default locally, opt-in), the parity harness records each verified case under a key hashing the program source together with a fingerprint of the whole toolchain, the C compiler in use and its version, the backend-opt level, and the extra linker flags. The compiler half of that fingerprint is chosen by PRISM_GATE_FINGERPRINT: by default the test executable’s own bytes (so any change to the front end, code generator, or the embedded C runtime rebuilds it and moves the key), or in source mode a reproducible hash of the compiler source, runtime, standard library, and manifests, which two checkouts of the same commit compute identically. A re-run whose key already carries a green marker skips the build and run entirely, its verdict inherited from the earlier verification exactly as a definition inherits the oracle’s guarantee through its hash. The key includes everything that can move the result and only passes are recorded, so a stale verdict can never be served after a toolchain change and a failing case is always re-run; the cache narrows re-verification to the programs whose behavior could actually have changed.
The reproducible fingerprint is what lets continuous integration cache safely across runners: the Test and MLIR jobs run in source mode and persist target/gate-cache between runs, so a pull request that touches only one example re-verifies that program while the rest of the corpus is skipped, and a pull request that touches the compiler moves the source hash and re-runs everything. The restored cache needs no trusted key of its own, a stale marker simply fails to match. The hardening re-runs are unaffected: the AddressSanitizer/UBSan and -DPRISM_RT_DEBUG passes set distinct linker flags, so their verdicts carry distinct keys and are never served from a plain-build marker. Safe memoization of the correctness gate is a direct consequence of the same behavior-equivalence and content-addressing contract.
15.3 Function Contracts
The two gates above verify the compiler. A separate machinery verifies a program: function contracts let a definition declare requires/ensures, and Prism owns the logical question and its identity while an external solver merely searches for a counterexample to bytes Prism has already fixed. A small first-order logical IR (LogicSort, LogicExpr, Contract, and Obligation) is the only thing a solver sees; an independent well-formedness verifier proves every term well-sorted, a portable SMT-LIB encoder emits one standalone check-sat script per obligation, and an alpha-normalizer and structural digest make two obligations that differ only by binder numbering or an unused declaration hash identically. Because a solver never sees Core, an obligation’s bytes are a pure function of its logical term, independent of the content hash.
The logical checker runs on the resolved program before contracts are erased at the Core boundary. It resolves each logical name in its own scope, elaborates the supported Bool/Int fragment into LogicExpr, inlines calls to logic fn declarations (so a checked contract is a pure proposition with no uninterpreted applications), and proves the result well-sorted; a malformed contract is an ordinary source error (E8000-E8005) pointing at the smallest offending subexpression. Verification-condition generation elaborates a contracted function’s body into one logical term, turning an if into an ite so branch conditions ride inside the term rather than splitting into path obligations, and emits one obligation per ensures clause by substituting that term for the result binder; a body outside the scalar fragment is reported pending rather than rejected. prism dump smt prints those obligations; because they are generated from the pre-optimizer surface, their bytes are invariant across optimizer configuration, backend, and effect-lowering tier.
Discharge is out of process: prism verify FILE runs each obligation’s script through an external solver and normalizes the answer, so an unsat is a discharged obligation, a sat is a counterexample, and a crash or unparseable output is an infrastructure failure, never a logical verdict. A function is verified only when every one of its obligations returns unsat, and a missing solver never yields an all-clear. The report is honest that an unsat is a solver-oracle receipt, not an independently checked proof. Contracts are compile-time proof data and never pollute runtime identity: a verification interface carries the logical exports and contract summaries under a digest that is a pure function of the logical content and independent of the Core hash. A contract-only edit moves that digest and only its verification dependents, never executable Core or native objects; a body-only edit moves Core and leaves the verification interface unchanged. Ordinary check, build, and run never invoke a solver.
15.4 Totality
A totality claim is checked by a separate verify-time analysis that never gates ordinary compilation; like a contract, the total claim is erased before Core regardless of the outcome. The checker consumes the resolved program, builds the call graph, and runs an iterative Tarjan pass to order functions callee-first. A total fn is checked-trivial when its call-SCC is acyclic, its body stays in a total fragment (no effect performed, handler installed, higher-order or indirect call, mutation, typed hole, partial division, or pipeline), and every directly called top-level function is itself certified total: a constructor and a total scalar primitive count, but a plain uncertified helper does not, so one unproved leaf cannot certify a whole call graph. A single self-recursive function is checked-structural when, for one matched parameter, every recursive call passes a variable bound as a strict constructor subterm of that parameter, tracked per match arm from resolved pattern binders rather than spelling. assume total fn is a trusted source assumption, accepted without a proof and kept visibly distinct. Everything else, mutual recursion, an effect, a higher-order call, is pending with a precise reason; the checker never reports “non-total”, because a restriction means it could not establish the claim, not that the function diverges. prism dump totality prints the honest per-function badge, and a totality proof and a contract’s partial-correctness proof stay separate evidence that compose into total correctness only when both close.
15.5 The Assurance Matrix
Prism’s guarantees come from several mechanisms that are real but not interchangeable. This matrix names each one precisely: what it establishes, when it runs, what it trusts, and what it does not establish. No claim in this book exceeds its row.
| Mechanism | When it runs | What it establishes | Trusted computing base | What it does not establish |
|---|---|---|---|---|
| Rust phase types and typed-Core phase markers | at compile time of the compiler itself | illegal phase transitions and sugar-after-desugar are unrepresentable in the compiler’s own types | the Rust compiler; see bootstrapping and self-hosting | semantic correctness of any individual rewrite |
| Independent typed-Core verifier | after construction and at pass boundaries | scope, type, effect, handler, and reuse witnesses hold on the tree actually produced | the verifier itself, smaller than the passes | that the tree means the same as the source; that is the oracles’ job |
Runtime lints and structural checks (fbip::balanced, Core Lint, wire decoders) | per run / per corpus entry, some debug-gated | refcount balance, well-formed Core, total decode on hostile bytes for the exercised inputs | the check implementations | properties of inputs never exercised; debug-gated checks establish nothing in release |
| Differential oracles (interpreter parity, tier parity, backend agreement) | the full corpus on every gate | byte-identical observable behavior across tiers, optimization levels, and backends for every corpus program | the interpreter as reference; corpus determinism | programs outside the corpus; agreement is shared-frontend diversity, not independent double compilation |
| The Lean model and its differential scripts | on the model corpus | the model and the implementation agree on the exercised fixtures; model-level metatheory as proved in Lean | Lean 4 kernel correctness + the model’s faithfulness | a universal proof that the Rust interpreter implements the Lean semantics; the typechecker model remains a scaffold |
SMT discharge (prism verify) | on demand per contract | the solver found the negated obligation unsatisfiable under the encoding | the SMT solver, the VC encoding | a proof-producing certificate; an unsat answer is solver testimony, not a checkable proof object |
| Content hashes, roots, receipts, attestations | on every build and release | the artifact is bit-for-bit the one hashed; a moved hash means a semantic input moved | the hasher, collision resistance | behavioral equivalence of two different programs; equal behavior does not imply equal hashes either |
| The gate cache | on cached gate runs | a skipped program is byte-identical (by source and toolchain fingerprint) to one that passed cold | the fingerprint scheme, deliberately excluding Prism’s own core-hash | anything about changed programs; its trust is kept independent of the content hasher it audits, by construction |
Two boundaries deserve emphasis. First, the gate cache never keys on Prism’s own content hashes, so a bug in the hasher cannot make the gate skip a divergent program; the gate’s trust is independent of the compiler it checks. Second, LLVM and MLIR share one frontend and one semantic emitter, so their agreement is valuable drift detection between two renderings, not two independent compilers agreeing.
16. Optimization
The mid-level Core-to-Core tier is a composable pass framework in the spirit of GHC’s [CoreToDo] pipeline. One shared traversal (Rewrite/Visit) replaces the hand-rolled Core walkers, so newtype erasure, dictionary specialization, free-variable collection, call collection, and substitution all ride a single visitor (the canonical hasher from architecture and the tail-recursion classifier from reference counting and FBIP reuse stay bespoke by design). Each pass is a CorePass keyed by a PassStage, and the whole pipeline runs from one ordered, level-keyed list through a single opt::run entry.
The pipeline spans two stages around effect lowering, so passes are not freely reorderable across it. Pre-lowering passes run in the front end on the elaborated core (see the core calculus); late passes run on the verified typed lowered Core, after effect lowering has fixed the fusion strategy. The split is important for performance. The simplifier runs in the late stage on purpose: run before effect lowering it rewrote the Core shapes the var/State fusion analysis depends on and degraded that fusion (a regression bisected to copy-propagation), so it runs after lowering, where it cannot defeat the fusion.
The pipeline currently implements six passes, given below in pipeline order; each subsection heading is the name --passes uses. Three controls switch a pass on and off (controlling the pipeline): the -O level enables passes in groups (optimization levels), a --no-<pass> flag subtracts a single pass from that pipeline, and --passes replaces the level with an exact ordered list. Each example shows the same fragment before and after the pass, with the others held off so the rewrite is the only change.
16.1 Fuse
- Stage: pre-lowering
- Levels:
-O2 - Disable:
--no-fuse
Whole-program pull-sequence fusion recognizes a producer/transformer pipeline consumed by a recursive fold, symbolically drives one production step into the consumer, and residualizes one top-level join loop. Intermediate Step constructors and tail closures then cancel before allocation. Recognition is structural rather than tied to combinator names; effectful, captured-local, over-budget, or unfamiliar shapes are left unchanged. The pass commits only a complete rewrite, so forcing it with --fuse below -O2 or disabling it with --no-fuse is held observationally invisible by the optimization and parity oracles.
16.2 EraseNewtypes
- Stage: pre-lowering
- Levels: every level (including
-O0) - Disable:
--no-erase-newtypes(honored, but both backends rely on it)
A newtype is a distinct type at compile time but identical to its single field at runtime, so this pass deletes the wrapper: each constructor application becomes its argument and each projection becomes the identity. Both backends assume it has happened, which is why it is the one pass -O0 still runs and the one a --passes list should never omit.
newtype Age = Age(Int)
fn birthday(a) =
match a of
Age(n) => Age(n + 1)
16.3 Specialize
- Stage: pre-lowering
- Levels:
-O1,-O2 - Disable:
--no-specialize(orPRISM_NO_SPECIALIZE)
Type-class methods are compiled by passing a dictionary. When the instance is known at a call site, this pass replaces the dictionary-dispatched call with a direct call to that instance’s method, so both the dictionary argument and the indirect call disappear.
-- `show` is dispatched through the `Show` dictionary `d`
fn render(d, x) = show(d, x)
render(show_int, 7)
16.4 Simplify (Gentle Simplifier)
- Stage: late
- Levels:
-O1,-O2 - Disable:
--no-simplify
A gentle simplifier run to a fixed point: case-of-known-constructor (a match on a known constructor picks its arm), copy-propagation, dead-let elimination, integer constant folding, and used-once-thunk inlining. It is the workhorse, run three times in the -O1 pipeline: once to expose call sites for Inline, once to clean up after it, and once more after Cse.
let p = Some(2 + 3)
match p of
Some(n) => n * 10
None => 0
16.5 Inline
- Stage: late
- Levels:
-O1,-O2 - Disable:
--no-inline
A bounded inliner: a non-recursive function called from exactly one site is pasted into that site, with every binder alpha-renamed so no name collides. Single-call-site only, so inlining never duplicates code; the Simplify that follows then optimizes across the merged boundary.
fn scale(x) = x * 2
fn main() = println(scale(21))
16.6 Cse
- Stage: late
- Levels:
-O1,-O2 - Disable:
--no-cse
Conservative common-subexpression elimination: a pure, non-trapping Prim computed twice is computed once and shared through a let. It is restricted to operations with no effect and no trap, so it never reorders a division or an effectful call, making it the most cautious pass in the pipeline.
fn f(x, y) = (x * y) + (x * y)
16.7 Optimization Levels
The -O/--opt flag selects a level; the default is -O1 and a bare -O is the highest. A level is a named pipeline, from which --no-<pass> can then subtract individual passes (controlling the pipeline).
-O0 is representation only. It runs just EraseNewtypes, the one pass both backends require, and nothing more, so the compiled core stays a direct image of the elaborated program. This is the level to reach for when reading dump core or bisecting whether an optimization caused a change.
-O1, the default, is the real optimization level. On top of EraseNewtypes it runs Specialize before effect lowering and, after it, the late pipeline Simplify -> Inline -> Simplify -> Cse -> Simplify: dictionary specialization, then a gentle simplifier brought to a fixed point around a bounded inliner and scalar CSE. This is the GHC simplify/inline/simplify shape, and it is what the compiler runs unless told otherwise.
-O2, the highest level, adds Fuse at the start of the pre-lowering stage and a second Inline -> Simplify round before Cse. Fusion collapses recognized pull-sequence pipelines before effect lowering changes their shape; the extra inlining round flattens two-hop wrapper chains exposed by the first. The exact pipeline is:
Fuse -> EraseNewtypes -> Specialize,
then Simplify -> Inline -> Simplify -> Inline -> Simplify -> Cse -> Simplify
16.8 Controlling the Pipeline
Below the -O level, two mechanisms drive the passes directly. The -O/--opt, --passes, and --no-<pass> flags are global, so they apply to building, running, and dump core alike.
A --no-<pass> flag subtracts a single pass from whatever pipeline is otherwise in effect, an -O level or a --passes list. There is one per pass, and they stack:
prism PROGRAM -O1 --no-inline # the -O1 pipeline, minus Inline
prism PROGRAM -O1 --no-inline --no-cse # ...minus Inline and Cse
prism PROGRAM --no-specialize # default -O1, minus Specialize
prism dump core PROGRAM -O0 --no-erase-newtypes # the raw elaborated core, nothing run
--no-specialize is the flag form of the PRISM_NO_SPECIALIZE environment variable; the two are equivalent and combine. --no-erase-newtypes is honored but rarely wise, since both backends assume newtype erasure has run.
--passes instead replaces the level outright with an explicit, ordered list, the LLVM opt -passes= / GHC [CoreToDo] analogue; it is mutually exclusive with -O. The spec names the two stages around effect lowering:
--passes '[pre:<names>][;late:<names>]'
<names> is a comma-separated list in run order; a bare list with no marker is the pre stage. The pre passes are EraseNewtypes and Specialize; the late passes are Simplify, Inline, and Cse. Each section is exactly the passes named, with no level defaults filled in, so explicit means explicit. The -O1 pipeline written out in full, and a pre-only run that stops after specialization:
prism PROGRAM --passes 'pre:EraseNewtypes,Specialize;late:Simplify,Inline,Simplify,Cse,Simplify'
prism dump core PROGRAM --passes 'pre:EraseNewtypes,Specialize'
A --no-<pass> flag still applies on top of an explicit list, filtering it:
prism PROGRAM --passes 'late:Simplify,Inline,Simplify' --no-inline # Inline dropped from the list
The parser rejects an unknown name (suggesting the closest known one), a pass placed in the wrong stage, a pre section that orders Specialize before EraseNewtypes, and an empty spec.
16.9 Controlling LLVM Codegen
The -O level and the controls above tune the Core-to-Core optimizer, which runs identically on both backends. A separate set of knobs tunes the native backend’s own codegen, the last step where the emitted bitcode and the C runtime are compiled and linked. They are independent of the Core -O: a program can pair an aggressive Core pipeline with a light backend, or the reverse, for granular control of the generated code.
Prism runs no LLVM optimization passes in process. It verifies the module, writes bitcode, and hands the rest to clang, which compiles the bitcode and the C runtime in one -flto=thin invocation so ThinLTO inlines the runtime into the generated code. ThinLTO stays on at every level, since it is what folds the runtime in, and every emitted function carries nounwind (Prism has no exceptions and this backend emits no invokes or landingpads), which lets the pipeline drop unwind tables. Four controls override this step:
| Control | Default | Effect |
|---|---|---|
--backend-opt | 2 | the clang -O level over the emitted bitcode: 0, 1, 2, 3, or s/z for size; also PRISM_BACKEND_OPT |
PRISM_CC | clang | the compiler driver invoked for the compile-and-link step (e.g. a pinned clang-18) |
PRISM_CC_FLAGS | (none) | arbitrary flags appended after the defaults, so a trailing token wins |
PRISM_NATIVE_KONT_FRAMES | off | preserve frame pointers, unwind tables, and non-mandatory call frames for experimental native-kont frame capture |
Because PRISM_CC_FLAGS is appended last and clang honors the final -O it sees, a trailing -O0 there overrides --backend-opt; the same hook adds -march=native, -g, or a sanitizer such as -fsanitize=undefined:
prism PROGRAM --backend-opt 3 # heaviest backend pipeline
PRISM_CC_FLAGS='-march=native -g' prism PROGRAM # native tuning plus debug info
PRISM_CC=clang-18 prism PROGRAM --backend-opt z # a pinned compiler, optimized for size
PRISM_NATIVE_KONT_FRAMES=1 prism PROGRAM # make native frame capture less optimizer-dependent
These controls drive the clang step shared by the LLVM and MLIR backends; prism run invokes no compiler, so they do not affect the interpreter. The native-kont frame mode is deliberately not a native suspend/resume switch: it defines PRISM_NATIVE_KONT_FRAMES for the runtime, asks the toolchain to preserve enough call-frame structure for prism_native_kont_capture_frames to produce stable symbol and PC-offset anchors, and enables the generated entry-ABI shadow stack used to report function argument values. Arbitrary suspended locals, stack slots, and registers remain unserialized.
16.10 Lint, Telemetry, and Parity
A Core Lint well-formedness check, pipeline idempotence, and per-pass tick telemetry gate every pass, alongside the triple-backend parity oracle (see verification). Parity is the invariant: compiled behavior at every level, and under any --passes spec, is byte-identical under the oracle, so optimization can only change cost, never meaning.
Several environment knobs aid debugging, all off by default.
| Variable | Effect |
|---|---|
PRISM_OPT_STATS | dumps per-pass rewrite counts |
PRISM_CORE_LINT | lints between passes |
PRISM_DUMP_CORE | writes the Core after each pass to a stream or to run-namespaced files under target/ |
PRISM_OPT_LEVEL | overrides the level when no -O flag is given |
PRISM_NO_SPECIALIZE | disables dictionary specialization |
17. The Interactive Shell
Running prism with no arguments starts a read-eval-print loop backed by the interpreter described under backends. It is a typed REPL: an entered expression is parsed through the expression entry point of parsing, inferred, elaborated, and evaluated, and its type and effect row are shown above the value.
A session accumulates state. An expression is evaluated and its result bound to it; a let binds a name for reuse; and a top-level import, fn, type, class, instance, effect, error, alias, or pattern declaration is added to the session so later input sees it. Imports and declarations are transactional: the REPL rebuilds the resolver and checker namespace first, commits the input only on success, and replaces a repeated import or named declaration instead of duplicating it. Declarations entered for a name shadow earlier ones.
Completion reads that rebuilt namespace rather than a token scan. Expression and :type completion offers values, :kind offers types, :info offers both, :browse offers open modules, and an interactive import offers importable modules. Qualified names complete through module aliases without exposing resolver-private symbols. :info uses the same canonical declaration/type printers as the batch tools, and :browse M lists only the public names of the open module M.
Commands begin with :; any unambiguous prefix resolves to its command, GHCi-style, so :r is :reload and :lo is :load.
| Command | Action |
|---|---|
:type e | show the type and effect row of expression e |
:kind T | show the kind of a type constructor |
:info n | describe a binding, type, or class |
:browse [M] | list session bindings, or public names in the open module M |
:core | dump the lowered core IR of the session |
:load f | load declarations from a file, making it the active file |
:reload | re-read the active file from disk |
:edit [f] | open a file (or a scratch buffer) in $EDITOR, then load it |
:set [+-]flags | toggle options; bare :set lists them |
:quit | leave the shell |
Three :set toggles exist: t (types) shows the inferred type and effect row of each result, on by default; s (timing) reports evaluation time; and h (holes) permits typed holes through the interpreter as deterministic runtime faults. Hole deferral is off by default, and :type still reports a hole rather than executing it. A multi-line block runs between :{ and :}, or is auto-detected when a line opens a layout block that remains unclosed.
18. The Formatter
prism fmt is a rustfmt-style canonical formatter: it parses a file to the surface AST and prints that tree back from scratch (layout is reconstructed, not reflowed), so an already-formatted file is a fixed point that prism fmt --check verifies byte-for-byte. What lifts it above a plain pretty-printer is that it preserves trivia (comments and deliberate blank lines) and the original surface syntax, restoring sugar the parser had already desugared (UFCS, string interpolation, ?-binding) instead of printing the lowered form, and it never destroys code: a node it cannot otherwise print falls back to its verbatim source bytes, and an unparseable file is an error rather than a mangled rewrite. The trivia and span bookkeeping ride on marginalia, a small crate written for this compiler but published independently. The formatter is part of the compiler rather than a separate parser.
19. Documentation Generation
prism docs generates Markdown API documentation for a project, one page per module, from the two things the compiler already produces: the comment trivia the formatter also relies on, and the types the checker infers. It is a general tool; the standard library reference in this book is its first output, produced by prism docs --stdlib with the output redirected into the book source.
Documentation comments are the only convention it layers on top of the language. A -- | line comment (an ordinary -- comment marked with a bar) directly above a declaration is that declaration’s docstring, and one at the top of a file is the module description; every other comment is ignored. This adds nothing to the lexer or grammar: the comment never reaches the AST, and the generator recovers it from the marginalia trivia table by span, exactly as the formatter re-associates leading comments. So -- | is a documentation convention, not a syntactic form.
Signatures are not read from the source but taken from the checker, because most standard-library functions carry no written signature: the generator type-checks each module and renders the declaration’s inferred type (Type::show). Types, classes, and effects are printed from the surface AST with the formatter’s own declaration printers, so they read exactly as written.
A fenced prism code block inside a docstring is a doctest. prism docs --test extracts every such block and compiles it, running it when it produces a program to run, so an example that drifts out of sync with the code fails the build. An example need not spell out a main: a block without one is wrapped as the body of an implicit main, so a bare expression like unwrap_or(0, Some(5)) or a short let-block runs like a REPL line and shows its value, which keeps examples to the point. The in-browser Run button (and the playground) apply the same wrapping. An example also runs with its enclosing module glob-imported, and a line beginning # compiles as part of the example but never appears on the rendered page, so setup a reader does not need (a sample value, a helper binding, an extra import, which is hoisted above the wrap) stays out of the documentation while the checked program stays complete. Per-fence attributes gate the treatment: ignore skips a block, no_run compiles without running, and compile_fail expects a type error, for the cases where a snippet is illustrative or is meant to be rejected. Every successfully checked block is replaced during book generation by static HTML carrying the ordinary book color scheme and nested prism-typespans-v1 ranges; hovering a pointable subterm, function name, parameter, let/var/where binder, or record field shows its inferred type, with the effect row shown only when it is non-empty. Type-level names get their own tooltip registers, each visually distinct: a type constructor shows its kind, a class its parameter (with kind when higher-kinded), superclasses, and methods, a type variable its kind, an effect its graded operations, and a typed hole its inferred type. Intentionally failing, ignored, signature-only, and definition-only blocks are not analyzed for tooltips. Every fact is baked into the page (no wasm compiler runs when a reader hovers), and the payload uses the same stable schema as the browser tooling. The standard-library pages are committed to the book, and prism docs --stdlib --check in continuous integration regenerates them in memory and fails if the checked-in Markdown has drifted, the same contract prism fmt --check enforces for source.
A doctest may also pin its output: an output fence immediately after a prism fence is the example’s expected text, checked by prism docs --test against the actual print transcript (or the result’s show when nothing prints). When an expectation goes stale, prism docs --test --accept (alias --bless, wrapped as just bless) rewrites the expected block in the source file in place, touching only the expectation lines and preserving every byte of surrounding code and comment trivia; blocks rewrite bottom-up so earlier spans never shift, a file that changed on disk since parsing is refused, and the run exits nonzero whenever anything was rewritten, so continuous integration can check expectations but can never silently bless them.
20. Editor Integration
Editor support is, to put it generously, nascent. What exists today is a dependency-free Neovim highlighter consisting of a file-type map and a syntax highlighter whose keyword set tracks the lexer. That is the whole story: no semantic highlighting, no go-to-definition, no inline diagnostics.
21. Content-Addressed Core
Prism identifies every top-level definition by a hash of its elaborated core rather than by its name. prism dump core-hash computes that hash over the core after three normalizations. Every free reference to another top-level symbol is replaced by that symbol’s own hash, so a definition’s hash transitively commits to everything it calls and the program becomes a Merkle DAG.
Bound variables are alpha-normalized to positions, and source spans, comments, and formatting are erased. The hash commits to both the term and the elaboration inputs an importer reads: the generalized type, the principal effect row, the fip/fbip mode, and the borrow mask. A recursive group is hashed as one strongly-connected component (reusing the shared Tarjan machinery from name resolution) with members keyed by index. The result is a name-independent, position-independent identifier for behavior: a rename, a reformat, or a local-variable rename leaves it unchanged, while any change to type, effect row, or computed result changes it.
Declarations with no term body are committed the same way by structural digest: a datatype or effect by the shape of its constructors and operations, a type class by its interface, and an instance by its identity, meaning its class, head type, and the behavior hashes of its methods. Top-level constants, which the compiler inlines rather than compiling to a node, are elaborated as zero-parameter definitions for hashing, so nothing a reader sees on a page is left unaddressed except transparent aliases, which have no content of their own.
Precisely, every hash below is a BLAKE3 digest of a length-prefixed token stream, so no field boundary is ever ambiguous. Resolving one variable reference, inside the structural walk of a Comp/Value tree, is a four-way case split:
tok(s) = len(s) : s
refer(s) = "b" ++ i s bound at de Bruijn depth i (a param, let, or match binder)
| "r" ++ idx(s) s is a member of this definition's own recursive group (SCC)
| "h" ++ H(s) s is an external dependency, already hashed (Merkle substitution)
| "g" ++ tok(s) otherwise, a free leaf: a builtin, a constructor, an effect op
encode(f) = "fn" ++ arity(f) ++ walk(body(f))
H(f) = blake3(SCHEME ++ meta(f) ++ encode(f))
walk tags each node with its variant name and then its children, resolving every variable through refer; H(f) is the singleton case, where a non-recursive definition is a group of one. A strongly-connected component {f1, ..., fn} (mutual recursion) hashes as a unit instead, in two passes, since a member’s final index does not exist until every member’s shape is known:
order = sort by (encoding, name) of [ (encode(fi, self = "r?"), fi) for fi in scc ]
idx(fi) = position of fi in order
blob = SCHEME ++ concat [ meta(fi) ++ encode(fi, self = "r" ++ idx(·)) for fi in order ]
component = blake3(blob)
H(fi) = blake3(component ++ ":" ++ idx(fi))
The first pass orders the group with every intra-group reference behind the neutral placeholder "r?", so the order itself never depends on names; the second pass re-encodes each member with real indices and folds the result into component, and a member’s own hash is component tagged with its position, so every member of the group gets a distinct hash from one shared digest. meta(f) folds in the elaboration inputs above (type, row, fip/fbip mode, borrow mask) as one more length-prefixed field.26
prism dump stdlib-hash folds every standard-library definition’s hash together with every datatype, effect, class, and instance digest into a single Merkle root, a Unison-style namespace hash stamped with the scheme tag and the compiler version, computed over the pre-optimization core so it is reproducible and independent of optimizer flags. The generated Standard Library Reference anchors that root on its index page and gives every documented definition a subtle content-hash badge beside its signature; both are regenerated and byte-diffed in CI (prism docs --stdlib --check), so any behavioral change to the library moves the root and fails the gate until the documentation is regenerated. The hashing spans every declaration kind and is surfaced where a reader can see it; the source files remain the authority, and the store is a cache derived from them.
The same fold that builds one module’s namespace builds the whole library’s:
defs : Sym -> Hash = hash_program(core, meta)
shapes : Name -> Hash = shape_digests(types, effects)
classes : Name -> Hash = class_digests(classes)
instances : Name -> Hash = { inst.name -> instance_digest(inst) for inst in instances }
An instance’s digest folds its class, its head type, and its methods into one identity:
instance_digest(inst) = blake3(SCHEME ++ "|instance" ++ tok(inst.class) ++ encode_ty(inst.head) ++ methods_blob(inst))
methods_blob(inst) = "{" ++ concat [ tok(name) ++ tok(hash) for (name, hash) in sorted(inst.methods) ] ++ "}"
inst.head’s type variables are alpha-normalized positionally, so Eq(List(a)) and Eq(List(b)) share one identity, and methods fold in sorted by name so declaration order never matters. Every kind then merges into one namespace, keyed by kind so a value and an instance, both lowercase surface syntax, cannot collide:
entries = { "def " ++ sym -> h for (sym, h) in defs }
| { "shape " ++ name -> h for (name, h) in shapes }
| { "class " ++ name -> h for (name, h) in classes }
| { "instance " ++ name -> h for (name, h) in instances }
root(entries) = blake3(SCHEME ++ concat [ "|" ++ len(name) ++ ":" ++ name ++ "=" ++ hash
for (name, hash) in sorted(entries) ])
One fold, sorted by key, is both a module’s root and the stdlib’s: root moves under any rename or content change, entry by entry, but never under reordering. stdlib_root = root(entries) over the whole library’s entries is exactly the value the docs anchor and CI byte-diffs. The same construction now reaches values and persisted formats: a derived Hash instance folds a runtime value through the identical BLAKE3 tokenization, so a value’s digest is canonical across backends for the same reason a definition’s is, and each frozen rung of a stable block commits its shape digest in source, checked at compile time and reseated only by the explicit prism store wire --accept, which extends the committed-golden discipline from the standard library’s docs to every user-declared wire format.
Prism is an unusually good host for the Unison-style managed codebase this points at, because two of the hardest preconditions are already paid. Name resolution canonicalizes every definition to a globally unique symbol (modules), and the differential oracle makes “equal hash means equal behavior” a verified property rather than an assertion, since the hash is taken over the very core the parity gate runs byte-identically across three backends. The direction is the codebase as a content-addressed database: names become a mutable index over immutable hash -> core entries, so a rename is an O(1) metadata edit, two versions of a dependency coexist as two hashes with no version solver, an unchanged hash is already compiled and parity-verified so a rebuild touches only a definition’s Merkle closure, and a computation named by a hash can be shipped across a wire and run with a proof it is the same computation.
The same content hash is exposed to programs directly. The Incr standard-library module (incremental computation) is self-adjusting computation whose early-cutoff test is exactly this digest: a memoized derivation that recomputes to a value with an unchanged blake3 hash stops propagating to its dependents, and the durable form persists the memo table to a snapshot that cold-starts on a digest mismatch, so a warm run’s result is byte-identical to a cold one. Where the compiler recompiles only a change’s Merkle closure, an Incr program recomputes only a change’s demand cone, and it is the same hashing that decides the boundary in both.
21.1 The On-Disk Store
The Store persists the content-addressed graph to disk under a single store root, in the two layers the dump namespace export mirrors. The anonymous layer is an immutable, append-only object directory: each definition is serialized by the same wire codec the language exposes, hash-consed per node, and written to objects/<first two hex>/<rest>, the git-style sharding that keeps a single directory from growing unbounded. Writing an object that already exists re-verifies byte-identity and treats a mismatch as a hard collision rather than an overwrite, so an object address always denotes exactly one byte string. The per-node codec writes a variable as a de Bruijn index, its outward binder distance, which is what makes the stored form invariant under var-local renaming and under reordering the definitions of a recursive group. The metadata layer (meta/) is mutable and keyed by the same hash: it holds the facts a reader needs but that are not part of a definition’s identity (a name, a rendered type, a doc comment), so a rename or a doc edit touches this layer and never the object the hash commits to.
Those two layers sit under a store root beside a version stamp, an index directory, and the verified, certificate, and package artifacts the later sections build on:
<store root>/
├─ VERSION hash-scheme tag, then store-format tag, one per line
├─ objects/ <2 hex>/<62 hex> immutable anonymous layer: one encoded definition per hash
├─ meta/ <2 hex>/<62 hex> mutable facts beside the object (name, type, doc)
├─ index/
│ ├─ names name -> content hash
│ ├─ deps content hash -> its direct dependents
│ ├─ canonical (class, head) -> canonical instance hash
│ └─ lock advisory lock serializing the index writers
├─ verified/ <2 hex>/<62 hex> the checks each hash has already passed
├─ certs/ <2 hex>/<62 hex> immutable parity certificates, one per attested subject
└─ pkg/
├─ index signed origin/name/tag -> root-hash table
├─ index.sig detached signature over index (absent when unsigned)
└─ log append-only transparency log of published pointers
Every file outside the two opaque blob layers (objects/ and certs/) is line-oriented, tab-separated, and header-versioned: its first line is a <kind><TAB>v<n> stamp, so a format change is a header bump an old reader refuses rather than misreads.
| File | First line | Record (fields tab-separated) |
|---|---|---|
index/names | prism-store-names<TAB>v1 | <name><TAB><hash> |
index/deps | prism-store-deps<TAB>v1 | <hash><TAB><dependent-hash> <dependent-hash> ... |
index/canonical | prism-store-canonical<TAB>v1 | <class><TAB><type-head><TAB><instance-hash> |
meta/<sharded> | prism-store-meta<TAB>v1 | one name / type / doc key per line, <key><TAB><value> |
verified/<sharded> | prism-store-verified<TAB>v1 | <check-kind><TAB><scheme><TAB><pass or fail> |
pkg/index | prism-pkg-index<TAB>v1 | <name><TAB><tag><TAB><root-hash> |
pkg/log | prism-pkg-log<TAB>v1 | <seq><TAB><nanos><TAB><name><TAB><tag><TAB><root-hash> |
The root VERSION carries the hash-scheme tag and the store-format tag on their own lines, and a store whose either tag this build does not speak is refused outright rather than read under the wrong assumptions. Both blob layers and both hash-keyed metadata layers shard git-style on the first byte of the hex digest (<first two hex>/<rest>) so no directory grows without bound.27 The three index/ files, which read-modify-write a whole file, additionally serialize their writers through the advisory index/lock, best-effort because a lost index binding is recovered on the next commit.
The store is off by default and enabled with PRISM_STORE, its location chosen by PRISM_STORE_PATH (resolved through store::resolve_store_path). When it is on, a build commits every definition’s object and prints a one-line summary, store: N unchanged, M recompiled, counting the objects served from the store against those written fresh, so the Merkle-closure property, that a change recompiles only its own closure, is visible at the command line. A from-scratch build and an incremental build are held to the same result by an oracle pair: a cold build and a warm incremental build of the same program must produce byte-identical artifacts, a change must move only its Merkle closure, and a reformat or a rename must move nothing at all.
21.2 Verification Caching
A stored object carries its verification verdicts alongside it. A check that a definition passed, its interpreter parity, its doctests, or its expect tests, is recorded as an append-only verified record keyed by the definition’s content hash and the hash scheme in force. A later build reads the verdict rather than re-running the check, so an unchanged definition does not re-run its tests, doctests, or parity comparison, and the total cost of verifying a change tracks the Merkle closure of that change rather than the size of the program. The scheme tag is part of the key on purpose: bumping the hash scheme invalidates every prior verdict at once, because a verdict recorded under an old scheme no longer matches, so a scheme change can never silently reuse a stale pass. Because the hash is invariant under formatting and renaming, reformatting a file keeps its verdicts intact.
Store-level instance coherence extends the compile-time coherence check across programs. At commit time each canonical (class, head) binding records its instance’s identity digest in the canonical index, and a second program that commits a divergent canonical instance for the same key is rejected as a hard error before anything is written, the cross-program form of the ambiguity the single-program checker already forbids. This is the enforcement the instance identity digest of the previous section was the primitive for.
A verified record is local to the build that wrote it; a parity certificate is the transferable form of the same idea, an immutable object in a certs/ layer beside the verified records. prism store attest compiles a program through two of the three backends (LLVM and MLIR, or LLVM and the interpreter) and, once their output is byte-identical, emits a certificate whose body records the claim (parity-passed), the hash scheme, and which backend pair agreed, addressed by the hash of its own envelope so it is itself content-addressed. prism pkg audit reads the certificate back and reports it per root, and a certificate that fails to verify (a foreign scheme or a subject mismatch) blocks the audit, while a certificate whose claim a reader does not recognize is reported unverifiable rather than treated as corruption, so an older build reads a newer certificate without rejecting it. Exactly one claim is live, parity agreement across backends; a Lean-checked claim that would let the certificate carry the differential oracle’s verdict too is reserved.
21.3 Incremental Compilation as a Query
The compiler cache is a demand-driven query graph over semantic identities. Its principal front-end cutoff is the split between a module’s interface and its checked body. A ModuleInterface contains name-sorted exported value schemes and structural contracts for types, effects, classes, and instances. An importer rehydrates those facts and depends on the interface digest rather than the dependency body; an implementation-only edit may therefore rebuild the edited module while leaving its importers reusable. This is early-cutoff incrementality: recomputation whose result identity stays fixed stops propagating to dependents.
The durable compiler cache is distinct from the opt-in definition store described above. It is enabled by default (PRISM_COMPILER_CACHE=0 disables it) and uses the same root selected by PRISM_STORE_PATH. Its artifacts reflect the granularity each phase has proved sound. SCC-local optimizer passes (EraseNewtypes, Simplify, and Cse) cache fixed-point certificates over current Core binders; non-local optimizer passes remain whole-program. Effect lowering is always recomputed as the verified whole-program authority; its former durable query is retired and intentionally inert, and durable caching resumes at the post-lowering optimizer boundary. Backend SCC bitcode commits reachability, direct-callee ABI, used constructor layouts, and closure summaries; separately sharded closure adapters and arity dispatch, program/runtime objects, and the final link are durable queries too. A hit is accepted only after the artifact’s format, content address, and phase-specific invariants validate; corruption is a hard error, while a policy-skipped oversized write leaves compilation successful.
This combines three prior disciplines rather than overloading one digest. Early cutoff follows rustc’s red-green algorithm and Salsa: a dependent reruns only when a dependency’s result changes, not merely its revision. Immutable content-addressed artifacts follow Nix, and normalized definition identity follows Unison. Prism’s query families have separate versioned key schemas composed from the semantic inputs each phase actually observes: compiler and configuration identity, source and dependency identities, pass or lowering plans, reachability, ABI facts, and exact binder identity where rehydration requires it.28
Because worker count is not a semantic input, sequential and parallel schedules (PRISM_QUERY_THREADS) may visit ready queries in different orders but must converge on byte-identical artifacts. The query oracles compare dependency-layer checking and decisions across worker counts, then compare fresh, incremental, sequential, and parallel stores, including binaries, canonical observations, query bindings, object identities, and normalized whole-program/SCC LLVM structure. The scheduler is thereby held to the language’s determinism contract from inside the compiler, as parity holds generated programs to it from outside.
21.4 The Kont Envelope
Where the store’s codec serializes the compiler’s own anonymous core (a def), a second codec serializes the interpreter’s runtime representation: the live continuation of a suspended program, so it can be written to a file, moved to another process, and resumed. This is the wire under suspend and resume. The two codecs are distinct wires over distinct domains, but an operator (a CoreOp, Builtin, FloatOp, or NegLane) means the same thing in both, so its wire number is drawn from the one canonical home in store::codec rather than re-typed here.
The envelope is the same self-describing frame every Prism wire uses, read left to right, each header part checked before the next byte is touched:
+------------+------+------------------+--------------------------------+
| scheme tag | kind | bundle digest | body |
+------------+------+------------------+--------------------------------+
scheme tag length-prefixed "prism-core-hash-v1"; a foreign scheme is rejected first
kind uvarint, WireKind::Kont
bundle digest length-prefixed: the code identity of the program this continuation runs in
body the machine snapshot below
The body is the whole interpreter machine frozen as data: the scalar registers (the rand/srand generator state so a resumed run continues the same stream, the current function name, the observation count, an optional exit code, and the replay trace recorded up to the cut so the prefix’s world reads stay pinned across the resume), then one hash-consed node table, then the roots that point into it, the frame stack (bottom to top) and the pending state (mid-evaluation of a computation under an environment, or about to return a value).
The node table is what makes freezing a call stack tractable. Every recursive object the machine holds, across six domains (a runtime value, a lowered computation node, an atom, a stack frame, an environment, a handler record), is interned once into one shared table and referenced by index, and a child’s index is always strictly below its parent’s. The graph is acyclic by construction, decode is a single forward pass with no fixups, and an environment shared by twenty frames is stored once.
One uvarint tag numbering (TAGS, the single source of truth so encode and decode cannot drift) spans all six domains. An index is untyped on the wire, and the builder validates each referent’s tag against the domain it is used in, so a cross-domain reference in a hostile frame is rejected rather than misread.29
Unlike the def wire, a binder here keeps its interned name, since the interpreter resolves variables by symbol through the environment rather than by de Bruijn distance; environment and handler-op orderings are canonicalized by name, because symbol ids are process-local. Code references resolve reference-or-inline: a call to a top-level definition rides as the callee’s name and is resolved at resume against the resumer’s function table, whose identity the matching bundle digest guarantees, so same-bundle wire cost is the captured state alone; an inline lambda or thunk body travels inline.
Decoding is total on the same discipline as every other Prism wire: decode_kont never panics on hostile bytes.30 Encoding is fallible in the other direction: a value that cannot cross the suspend boundary (a graph nested past the suspendable depth, the fingerprint of a cycle or an unserializable capture) is refused by name at suspend time rather than written into a snapshot that would fail on the far side.
The field that matters is the bundle digest. It is not a checksum of the envelope bytes; it is the program’s namespace root, the Merkle fold of the content-addressed core: root over {"def " ++ sym -> H(sym)} for every definition the program reaches, the standard library included. That digest is a name-independent, dependency-complete fingerprint of all the code the continuation could run.
A resumer recomputes the namespace root from its own copy of the program and refuses a snapshot whose digest differs. The kont envelope is therefore the content-addressed Merkle DAG applied to a live computation: because the code already has a canonical identity, a running computation over it can travel with a compact proof that the far side is the same program.
21.5 Source, Surface, and Core Identity
A source file carries three identities, and keeping them apart is what lets a tool be honest about what it has actually proved. Source identity is the digest of the exact bytes, comments and formatting included, which the compiler embeds in every syntax artifact it emits. Surface identity is the canonical semantic surface tree with source positions erased: the identity of the tree the parser built, rather than of the text that produced it. Core identity is the content hash above, taken over the elaborated definition.
The standard library computes the first two from a syntax artifact and cannot compute the third from one. Syntax.Identity reads the embedded digest for source identity and renders the schema tag and item tree with every span stripped for surface identity, both pure functions of the artifact bytes; Core identity is absent from that module because it is not recoverable from syntax, needing resolution and elaboration that no syntax artifact carries. Given the elaborated subject instead, published as the identity surface, the standard library does recompute it, which is the whole point of the separation: the third identity costs a different artifact, not a different hash function. Each edit below moves exactly the identities it should:
| edit | source | surface | Core |
|---|---|---|---|
| rewriting a comment | moves | holds | holds |
| reflowing the same tokens | moves | holds | holds |
| writing the same call as a pipeline | moves | moves | holds |
| changing a literal | moves | moves | moves |
The negative directions carry the weight. Equal Core identity does not require equal surface or source identity, so a tool that caches on a syntax digest is not caching on behavior. Equal source bytes do not imply equal Core identity either: the same text, under a different set of imported modules, elaborates to a different subject and computes a different answer. A syntax hash is evidence about text or about trees, never about what a program does.
22. The Package Manager
The package manager is deliberately a synthesis, not a clone. It takes the fast command surface of Bun-style package UX, the Nix idea that installed code lives in an immutable content-addressed store, and the git idea that distribution can be hash-addressed and cheaply mirrored.
The Prism-specific move is the unit of identity. A package is not a tarball, a registry row, a checkout, or a semver range; it is the compiler’s content-addressed Core/source bundle and the complete dependency closure reachable from that bundle, folded to one Merkle root. Names, tags, manifests, and indexes are mutable ways to find the root. The root is the package.
Distribution is therefore the content-addressed store carried across a network. A project declares its dependencies in a [dependencies] table in its prism.toml, in one of three forms: a path to a local directory, a git URL paired with an opaque tag, or a bare content-hash pin naming an exact definition graph. The three are the same DepSource the resolver consumes, differing only in how a name is turned into a root hash. Edits to the table go through a format-preserving manifest writer that rewrites only the dependency lines and leaves every comment, blank line, and untouched byte exactly where the author put it, so prism pkg add does not reformat a hand-maintained manifest.
All dependency spellings are explicit about where the eventual root hash comes from:
[dependencies]
geometry = { path = "../geometry" }
legacy_geometry = "../legacy-geometry"
crypto = "prism-core-hash-v1:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
http = { git = "github.com/prism-lang/http", version = "2.0" }
path is a local Prism project, with the bare string accepted as its shorthand unless it carries the prism-core-hash-v1: prefix. Path dependencies are editable source roots, not accountable artifacts: they are for local development and enter the compiler as explicit module search roots. The hash string is the fully explicit pin: no name lookup, no network resolution, and no version label. The git form is the human release surface; version is an opaque tag, not a range, and the signed package index maps the full package identity (git URL, display name, tag) to the exact source-bundle identity that enters the lockfile: origin, package name, artifact kind, hash scheme, and root.
Resolution is a Merkle-closure walk, not a version solver. Given a set of root hashes, resolve_closure reads each definition’s stored frame and follows its dependency edges until the closure is complete, fetching any object it does not already hold. Every fetch crosses a Transport seam that re-hashes the received bytes and rejects a mismatch, so a definition is trusted because its content hashes to its address and for no other reason; a DiskTransport serves a local store and a GitTransport clones, pulls, and pushes a store held in a git repository by shelling to the system git.
The resolved closure is frozen into a v2 prism.lock whose header pins the lock format and whose Std and dependency rows carry both hash scheme and root hash. Its entries are terminal: a locked hash on a warm cache is never re-resolved, re-fetched, or re-verified, because a content hash cannot mean two things.
prism pkg export writes a project’s closure back out as source text and a v2 .namespace manifest naming the hash scheme, artifact kind, and namespace root; consumers verify all three before trusting the projection. Its guarantee is source stability, the exported text round-trips through the parser, and it deliberately stops short of promising that re-ingesting that text reproduces the same store hashes.
Trust over that graph is a signed package-identity-to-root index and a local transparency log. A publish signs the (origin, name, tag, hash scheme, artifact kind, root) row of the index, through one of three interchangeable seams selected by PRISM_SIGN_MODE: an ssh-keygen -Y sign signature verified against an allowed-signers file, a minisign signature, or an explicit unsigned mode for a private store. Verification classifies each artifact Ok, Unsigned, or Bad.
Alongside the index a project keeps an append-only transparency log that verifies each entry as it is appended and assigns it a dense, monotonic sequence number. A package identity silently repointed at a different root leaves a detectable gap in the log after the fact rather than passing unnoticed. The verbs are prism pkg init, add, why, export, publish, audit, check-world, and accept-usage; they are tabulated with the rest of the surface under commands.
23. Lineage
Every served artifact explains itself through one typed graph. The lineage subsystem defines a single format, prism-lineage-graph-v1, whose nodes are content-addressed inputs, capability observations, produced artifacts, and the verification edges between them; a node’s identity is derived from its own digest, so the graph is content-addressed the same way Core is (content-addressed core). Four variants ride that one format, Variant::ProjectBuild, Variant::Run, Variant::Docs, and the world resident’s timeline, and they share one envelope, one renderer, one verifier, and one differ. A new kind of served thing becomes a new node family and a variant tag, not a new file format, a second explainer, or a parallel verifier.
The identity every variant records is computed in one place. BuildIdentity folds the compiler version, hash scheme, target, backend, optimizer surface, scheduler, behavior-affecting flags, and, for a native backend, the linker toolchain inputs, into the identity rows the sidecar carries. Every consumer that previously assembled those rows piecewise now derives them from this one computation, so a build sidecar, a run sidecar, and a docs manifest cannot disagree about what “the compiler that produced this” means.
A project build writes a .plineage sidecar beside the emitted binary, naming the root request, the source namespace root, the Std root, every store-served package root, the BuildIdentity rows, emitted artifact digests, store cache hits when the store is enabled, and diagnostics. It records facts the explicit driver already knows, without introducing a second scheduler or cache protocol.
A run sidecar is the same graph over an executed program. prism run PROGRAM --record run.replay --lineage run.plineage writes it beside the .replay trace it explains, naming the source/Std/package roots, the BuildIdentity, argv, each environment read, each input file by content digest, each file the run wrote, the stdout digest, and the trace digest. The trace’s own file relation is recorded as an edge, so verification reads the graph rather than a filesystem convention. --lineage is gated on --record in the CLI definition, because a run sidecar’s whole point is to explain a trace.
Those observations are backed by the provenance event protocol. Every capability the run performs, every Console/FileSystem/Random/Env operation, is recorded as an event carrying a canonical hash of its kind and its payload, and a variable-length value commits a content digest rather than raw bytes, so a hostile input cannot forge an event boundary by embedding a delimiter. The protocol’s guarantee is asserted by test, not claimed: recording a run and replaying its trace produce identical event hashes, so the trace a sidecar explains is provably the trace the program performed. A mismatched replay names the failing event index and the operation it expected, rather than diverging silently.
Verification comes in the three strengths the variants need. prism lineage verify SIDECAR rehashes: it recomputes the digests the sidecar recorded and confirms they still match, cheap and offline. --replay re-runs: it replays the trace through the interpreter and re-checks the result, catching a divergence the recorded numbers alone could not. The world variant verifies structurally: a timeline’s node ids are self-certifying, so verify confirms the graph is well-formed (its laws, states, and forks are consistent) and honestly reports that re-derivation of the cellular evolution is not implemented rather than claiming a re-execution it did not run. prism lineage show and prism lineage why render an explanation from any variant, and both work after the source files are gone, because every fact is in the graph. In a project, bare prism diff compares the .pr sources at Git HEAD with the working tree (including staged changes), reports the semantic delta, then prints only the changed definitions as a compact surface diff; prism diff OLD NEW still compares explicit source revisions. Over two .plineage sidecars it reports preserved, moved, added, and removed digests by logical key, exiting nonzero when anything moved; sidecar content, not a filename convention, selects the lineage reader.
Why a query rebuilt and why it was reused come from the same fact model, not separate logs. QueryFact gives each decision a QueryKind, stable logical identity, ordered semantic inputs, optional output identity, outcome (hit, miss, write, or cutoff), and deterministic reasons. The six active families are module checking, SCC-local optimization, backend SCC codegen, closure planning, object emission, and linking. One FactRecorder collects them in canonical (kind, identity) order; the store’s decisions/query-facts ledger persists previous and current prism-query-fact-graph-v1 graphs for a workspace scope. The historical effect kind remains a readable wire tag for old ledgers, but the compiler no longer publishes it; an upgraded build retires a stale current effect fact to the previous side of the diff. Likewise, old queries/effect-lowering-plan and queries/effect-lowering-result directories are ignored and left inert until the Store gains an explicit garbage-collection API. These facts explain cache decisions but never authorize reuse.
prism lineage why-recompiled runs the ordinary query path and prints the previous/current graph diff: reused foo.bar, or, for example, recompiled backend-scc ...: ... changed. If source has since disappeared, the persisted graphs can still explain the last recording. There is no instrumented shadow build or forward-only trace: the explanation is the same ordered input identity data from which query changes are diagnosed.31
A passed verification can be persisted as a certificate. prism lineage verify SIDECAR --certify out.cert mints a digest-named certificate over the sidecar it verified, claiming replay-verified under --replay or lineage-verified otherwise. The certificate rides the store’s existing certificate discipline (verification caching): it shares the one claim number space parity certificates use, is addressed by the hash of its own envelope, and is checked by scheme, subject digest, and claim recognition. prism lineage check-cert out.cert SIDECAR rejects a certificate whose subject digest does not match the named sidecar, and a certificate carrying a claim the reader does not recognize is recognized-but-untrusted, reported unverifiable rather than honored, so a newer certificate read by an older build is neither trusted nor treated as corruption.
prism docs is the one docs-manifest writer. Alongside the rendered pages it emits docs.plineage, the docs variant of the graph, naming the same roots and BuildIdentity a build carries, plus the generator format (prism-docs-markdown-v1), every page digest, and every doctest output hash. Regenerating under the same roots is byte-identical. prism docs --verify-manifest rehashes the committed pages and confirms the roots have not drifted, rejecting a stale page or a moved root by name; prism pkg check-world runs the same check as one of its per-package gates.
prism pkg check-world [path] applies the identity discipline to a whole package universe. It discovers package projects under path (default packages/), resolves each project’s explicit Std and dependency roots, and reports the package set keyed by source-root digest, with a compatibility summary: all observed Std roots, compiler surfaces, packages grouped by declared name, store dependencies grouped by package identity, and problems such as duplicate package names with different source roots or one dependency identity resolving to multiple roots. --strict turns incompatibility into a nonzero CI gate. Each package now carries per-package gates, the build lineage, examples run through the compiler-owned runner, doctests, committed replay traces, and the docs manifest, each reporting passed, failed, or not-run. The not-run distinction is essential: a gate that does not apply says so rather than passing silently, so a green report never overstates what was checked. Each package also exposes a public-API surface of definition behavior hashes; given a prior report as --baseline, check-world names exactly which public definitions changed behavior, by digest and never by path, so an API break is reported as which definitions moved rather than which files were touched.
$ prism pkg check-world packages
checked 1 package(s) in packages
validation: typecheck-only
typecheck: passed
doctests: not-run
replay: not-run
native: not-run
compatibility: compatible
tzdb: prism-core-hash-v1:b9e853148727...
gates: check=passed example=not-run doctests=passed replay=not-run docs=passed usage=passed root=passed dependency=passed
stdlib: prism-core-hash-v1:ac8a7aa43202...
The useful invariant across all of this is that any served artifact, a binary, a run’s output, a documentation set, a package universe, answers “which source root, Std root, package roots, compiler scheme, target, and flags produced you?” by digest, without reading ambient process state, and says whether it is internally coherent without implying gates it has not run.
24. Metaprogramming
Prism has no macro system, and that is a considered omission rather than a gap waiting to be filled: I am, by temperament, allergic to metaprogramming, having been burned by Template Haskell and OCaml’s metaprogramming fire and watched it trade a moment’s convenience for code that no reader and no tool can follow. The honest status, in one sentence, is that doing it well in a typed setting, weaving phase distinctions and Lisp-style hygienic macros into the type system so that generated code is as principled, type-safe, and legible as code written by hand, is still an open research problem rather than a solved one, and Prism is waiting for the right model instead of bolting on the wrong one. If anything, the content-addressed core and the verified differential oracle are an unusually disciplined substrate to host such a thing once the design is clear. I am genuinely open to new ideas here: if you know a model that does this elegantly, get in touch. Until then it stays an open problem.
25. Open Compiler
Prism is becoming an open compiler: its syntax machinery is an ordinary, versioned Prism library rather than a facility available only behind the compiler executable.32 The Syntax.* modules already expose source files and byte spans, tokens and trivia, an ordered semantic surface AST, structured diagnostics, resolved bodies, generic traversal, and byte-idempotent codecs for each of those artifacts, plus a raw token layer and layout pass written in Prism. A Prism program can therefore decode, inspect, traverse, and re-emit Prism source under the same explicit-input determinism contract as the rest of the language. What is not yet exposed is the other half: a Prism-written parser, a printer, and checked source edits over a mutable snapshot.
The lossless document and the semantic AST are deliberately separate. Source tools need declaration order, comments, punctuation, layout events, and exact literal spelling; resolution, checking, and elaboration need a smaller tree that omits semantically irrelevant spelling. Keeping both representations makes format-preserving refactors and documentation tools ordinary library clients without leaking formatting accidents into the semantic pipeline.
The front end bootstraps through the same opening, and the first stage of it is built. A Prism-written lexer, interpolation scanner, and layout pass run beside the Rust/LALRPOP implementation and are diffed against its versioned artifacts across accepted, negative, generated, and hostile corpora, including the classification of lexical faults: unknown escapes, empty and unterminated interpolation holes, unterminated strings, and misplaced digit separators, each with the same continuable-or-fatal verdict the Rust lexer reaches. A disagreement is an explicit artifact, never a silent production fallback. The parser is the next piece and is not written yet, so parser authority has not moved: Rust/LALRPOP remains stage 0, and the reproduction loop that would make a Prism front end authoritative stays closed. The compiler selects the implementation from its pinned toolchain and standard-library root, so a project cannot change the language it is compiled with by shadowing a Syntax module.
Speed is an acceptance condition for that front end rather than a later optimization, because a reimplementation that is asymptotically worse than the oracle it shadows cannot become the oracle. Both layers are measured against Rust on a doubling ladder of input sizes per corpus class, reporting throughput, peak resident set, and the fitted slope of time against bytes; a size-dependent divergence is a hard failure, while a constant factor is a recorded cost. The measurement earned its keep immediately by finding a quadratic layout pass, which recounted the line number from byte zero once per token, and a raw layer that rebuilt and linearly scanned its spelling tables per token. Both are now linear.
The architecture has three explicit boundaries. The base level remains the fixed, verified pipeline described in this chapter. At the meta level, ordinary Prism modules play the useful role of metaobjects: they consume versioned source, AST, and eventually IR values and produce analyses, diagnostics, edits, or transformed values without privileged access to compiler state. Intercession, in which a metaobject intercepts a compiler decision, replaces a pass, or changes a target mapping, is deliberately absent from the initial design.
This still provides white-box reuse: tools can reuse the real lexer, parser, representations, and printers, then add modular analyses and transformations without forking the compiler. It does not yet add macros, quasiquotation, user-defined syntax, or arbitrary compilation hooks. If intercession is later admitted, its modules must be explicitly selected and pinned, its inputs and outputs must cross versioned phase boundaries, and its results must pass the same verifiers as built-in transformations; that is a separate change to Prism’s trust and determinism contract. A fully open Prism-in-Prism compiler is nevertheless the long horizon: rebuilding the floor while standing on it is hard enough; requiring every floorboard to be content-addressed and independently verified is ridiculous, and would be extremely cool.
26. Bootstrapping and Self-Hosting
The compiler is written in Rust. A self-hosting Prism compiler would use the standard multi-stage bootstrap: the Rust compiler is stage 0, compiling the Prism-in-Prism source with stage 0 yields stage 1, and compiling that source with stage 1 yields stage 2. The bootstrap is sound exactly when stages 1 and 2 are byte-identical, the fixed point that proves the compiler reproduces itself. Prism’s differential oracle and triple-backend parity gate already make “two builds agree byte-for-byte” a repository-wide invariant.
Reproduction is necessary, not sufficient: Thompson’s “Trusting Trust” backdoor reproduces too. Prism’s deterministic, content-addressed Core makes diverse double-compilation a comparison of canonical hashes rather than fragile binaries. It checks independent lineages above the backend boundary, but LLVM or Clang could still harbor the ghost. Sleep well.
The open-compiler design supplies the first concrete bootstrap seam, and the seam is now partly occupied. Stage 0 compiles the Prism-written lexer and layout pass, which run beside the Rust front end and whose versioned token and trivia artifacts agree with it across the gated corpora. A Prism-written parser is the remaining piece; only when it too agrees, and can parse its own source, does a pinned Prism front end become authoritative. This closes the syntax-stage loop before the checker and code generator are self-hosted, so the compiler moves into Prism one verified representation boundary at a time instead of arriving as one heroic rewrite.
A reader written in Prism is an ordinary program, so it has to say what it refuses and why. The syntax codecs bound themselves structurally: a schema tag is matched exactly rather than ordered, a span must not invert and must end inside the embedded source, node identities must be unique, and nesting is refused past a fixed depth. Each of those is a function of the document alone, so the classification is identical in the interpreter and in a native binary and can be pinned by a test. Exhausting physical memory, being killed, or overrunning a host timeout is a different kind of event: an infrastructure failure of the machine that happened to run the tool, never a rule of the language. Keeping the two apart is the same discipline as everywhere else here, since a limit expressed as “whatever this backend allocates first” would make a refusal depend on which tier ran, the one thing the design does not allow.
The backend provides the matching seam: the shared emitter is one Core walk behind an Isa trait, and the textual LLVM and MLIR backends hand their output to external tools (clang, mlir-translate) rather than calling into a library. A Prism compiler therefore needs to emit text and invoke tools, not bind LLVM’s C++ API, so the dependency on Rust’s inkwell binding belongs to stage 0 rather than the language. Link orchestration remains in the Rust compiler behind the interface that assembles IR and links it against the runtime.
The whole front end already compiles to a WebAssembly bundle that runs in a browser and, gzipped, still fits on a 3.5-inch floppy disk. A self-hosted Prism is then the pleasing closure of that fact: a modern functional language with algebraic effects, typeclasses, and a formally verified core, shipped as a floppy-disk-sized binary of itself that compiles itself and can run on a microcontroller.
At which point, modulo an FFI, a full package ecosystem, and roughly every other thing a real language actually needs to be used in anger, I think Prism is “done”, in the sense that it will never be used by anyone. But that’s fine!
There is, if you squint, a purity argument in that. Every functional language chases referential transparency and forfeits it the instant a program runs, because running is where the effects leak back into the world. Haskell, to its great misfortune, is actually used, so it prints, allocates, warms a CPU, and nudges the universe a hair closer to heat death. Prism does none of this. Never run, it adds not one joule to the universe, and so attains the nirvana every other language strives towards: complete purity through unuse. Haskell is pure and used in the real world; Prism is useless and unused, which is a stronger form of purity.
27. Command-Line Interface
The prism binary is one executable with a handful of subcommands. With no subcommand, a bare path argument compiles that file or project and no argument at all opens the interactive shell. This section tabulates the public surface defined by the command-line parser.
27.1 Commands
The surface is thirteen top-level commands plus five noun groups (exec, lineage, patch, pkg, store), each group collecting the verbs that share a subject.
Top-level
The everyday commands: build, run, check, format, inspect, document, compare.
| Command | What it does |
|---|---|
prism | Start the interactive shell (REPL). |
prism PROGRAM | Compile a single file to a native binary named after the source (-o overrides). |
prism <dir> / prism <prism.toml> | Compile the project rooted at that manifest to target/<package>. |
prism build [path] | Compile the enclosing project (the nearest prism.toml); fails outside a project. |
prism run [PROGRAM] | With a file, type-check and run in the interpreter, with real stdin/stdout (exit(n) becomes a real process exit); --defer-holes turns reached typed holes into deterministic faults; --record PATH writes a .replay trace, --lineage PATH a run sidecar, and --durable PATH resumes a crash-safe log. With no file inside a project, build the project’s binary and execute it, forwarding -- args and the exit status (the interpreter-only flags above instead interpret the project entry). |
prism check [PROGRAM] | Type-check only; with no file, check the enclosing project; with a file, check that one source. Success is quiet and reported by exit status. |
prism verify PROGRAM | Discharge function contracts through an external SMT solver; --solvers and --require-agreement can require several solvers to agree. |
prism test [path] | Discover and run test fn declarations in a project or source file, with deterministic selection, isolated interpreter worlds, captured output, and text or JSON results. |
prism fmt [paths..] | Format .pr files in place. No path formats the current tree recursively; - filters stdin to stdout. |
prism dump <phase> PROGRAM | Print one pipeline artifact (see dump phases). |
prism docs [path] | Generate API documentation and a docs.plineage manifest; --test runs doctests, --accept/--bless rewrites stale output blocks, --verify-manifest rechecks the manifest. |
prism diff [<old> <new>] | With no paths, diff the enclosing project’s Git HEAD against its working tree over .pr sources, showing semantic changes, their dependents cone, and compact definition-level surface deltas; with paths, diff two source revisions by content hash or two .plineage sidecars by logical key. |
prism report PROGRAM | Print every pipeline phase for a program. |
prism why-output ARTIFACT [OUTPUT] | Explain a built artifact or output from its lineage sidecar without reading source; --json emits the explanation as data. |
prism clean [path] | Remove the project’s target/ build-artifact directory; an absent one is a no-op success. |
prism repl | Start the interactive shell (same as bare prism); accepts --no-banner. |
Test compilation has an explicit production-neutral boundary. Production mode removes test fn declarations before module interfaces, Core identities, and backend artifacts are taken; test mode retains them, validates the restricted signature and effect world, and builds a deterministic manifest whose logical ids, Core digests, and dependency-closure digests are independent of discovery order. The runner synthesizes one private entry point per selected test and evaluates each in a fresh interpreter world, classifying normal return, fail, fault, unhandled effect, explicit exit, and harness failure without allowing state or captured output to leak between cases.
The project-shaped diff keeps the source view intentionally smaller than a file patch. It names each definition whose own behavior changed and shows only its old and new surface forms; unchanged files, surrounding declarations, and the dependent definitions whose own source did not move are omitted. The - rows are red and + rows green on an interactive terminal, with no ANSI escapes when output is redirected.
$ prism diff
diff: 2 changed, 0 added, 0 removed, 95 unchanged
~ europe_london a7a093434a3fa41e -> 2f10d6906e3fcb96
~ utc 7c2ae112ad1a57be -> 88caf0b8780e1e01
cone: 1 affected (find_zone)
surface:
europe_london
- fn europe_london() : Zone = Zone { name = "Europe/London", offset_minutes = 0 }
+ fn europe_london() : Zone = Zone { name = "Europe/London", offset_minutes = 1 }
utc
- fn utc() : Zone = Zone { name = "UTC", offset_minutes = 0 }
+ fn utc() : Zone = Zone { name = "UTC", offset_minutes = 4 }
prism patch: digest-pinned semantic edits
Semantic patches are code changes described at the intent level rather than as exact line-by-line edits. They target checked semantic identities and let the compiler derive and judge the textual and behavioral consequences.
Semantic patches let an agent propose one complete top-level declaration without granting it an unjudged source-file write. fetch returns the canonical surface term, its current Core identity, and the whole namespace root; impact returns the transitive importer cone; create packages a replacement as a content-addressed prism-patch-v1 artifact pinned to both the definition and that namespace. submit (an alias of apply) compiles the replacement, emits a structured judgment with base/result namespace roots, and stages it in the content store without changing the source. commit rechecks the source, namespace, and byte-identical judgment before an atomic rename; discard clears the staged ref.
The language-level contract and rationale behind this boundary are specified under semantic patches.
$ prism patch fetch PROGRAM increment > fetched.json
$ prism patch impact PROGRAM prism-core-v1:8cf0... > impact.json
$ prism patch create PROGRAM prism-core-v1:8cf0... REPLACEMENT > patch.json
$ prism patch submit PROGRAM patch.json > judgment.json
$ prism patch behavior PROGRAM patch.json corpus.json > behavior.json
$ prism patch commit PROGRAM
The judge reports the definition’s before/after Core hashes, whole namespace roots, structural shape, effect and interface deltas, and importer cone. Tier 0 is an exact replacement, tier 1 changes the surface term without changing Core identity, and tier 2 changes Core while preserving the definition shape, effects, grade, and public module interface. Stale namespaces or targets, malformed artifacts, checker failures, and interface-moving edits produce content-addressed versioned JSON refusals rather than partial writes. The optional claimed_delta field is carried as reserved, unjudged metadata.
patch behavior runs the old and replacement programs through the unoptimized interpreter oracle for every case in an explicit prism-patch-behavior-corpus-v1. A source corpus file contains format and a nonempty, uniquely named cases array; each case supplies stdin and args. The resulting prism-patch-behavior-v1 receipt records both namespace roots, the patch judgment, corpus digest, old/new trace digests, and either equivalent-on-corpus or the first exact divergent observation. The relation is scoped to that corpus, never universal equivalence. This first version refuses ambient file, environment, store, process, clock, and probe operations before execution; stdin, argv, deterministic random, faults, exits, and captured console output are supported.
prism patch serve PROGRAM accepts one prism-patch-protocol-v1 request per line. Its fetch, impact, create, submit, behavior, commit, and discard verbs use the same request payloads and return the same response payloads as the corresponding CLI commands. A reference client exercises this interface through a child process; no editor or MCP transport is required.
prism exec: recorded and suspended execution
Verbs over a run as a value: replay a trace, cut a running program into a snapshot, resume one, step through a recording.
| Command | What it does |
|---|---|
prism exec replay PROGRAM <trace> | Re-run a recorded .replay trace, producing output byte-identical to the original. |
prism exec steps PROGRAM [--json] | Run the program and print each observation with the machine step at which it fired, the ruler a suspend budget is picked from. |
prism exec suspend PROGRAM --at N | Run the program, pause after N machine steps, and write the live continuation to a kont envelope (-o names the file). |
prism exec resume PROGRAM <snap.kont> | Decode a kont envelope, check its bundle digest against the program’s code identity, and run the continuation to completion. |
prism exec debug PROGRAM <trace> | Terminal reverse-step debugger over a recorded trace (step forward and back by replay-to-N). |
prism lineage: explaining artifacts
Verbs over a .plineage sidecar (lineage): render it, interrogate it, verify it, certify a verification.
| Command | What it does |
|---|---|
prism lineage why-recompiled [PROGRAM] | Run the ordinary compiler queries and explain reuse or recompilation across the durable query graph. |
prism lineage show <file> [--json] | Render a build or run .plineage sidecar and explain why an artifact exists. |
prism lineage why <sidecar> <output> | Walk a sidecar backward to explain why an output exists (--json for data). |
prism lineage verify <sidecar> [--replay] | Rehash a sidecar’s recorded artifacts; --replay re-runs and re-checks a run sidecar; --certify PATH persists a passed verification as a certificate. |
prism lineage check-cert <cert> <sidecar> | Check a lineage certificate against the sidecar it names; a subject mismatch or unrecognized claim is rejected. |
prism pkg: the package manager
Verbs over projects and the package universe (the package manager).
| Command | What it does |
|---|---|
prism pkg init | Prompt for a package name and directory, then create a minimal project skeleton. |
prism pkg add <dep> | Add a dependency to prism.toml (path, git URL plus tag, or hash pin) and update prism.lock. |
prism pkg why <name> | Explain why a definition is in the resolved dependency closure. |
prism pkg export [path] | Write the project’s content-addressed closure back out as source text. |
prism pkg publish | Sign and record a package-identity-to-root binding in the signed index; --tag, --name, and --origin set the row. |
prism pkg audit | Verify the signed index and the transparency log; --allow-unsigned tolerates the unsigned seam. |
prism pkg check-world [path] [--json] [--strict] | Check package projects in a package universe and report digest-addressed source, Std, dependency, compiler, and compatibility identities plus per-package gates; --baseline REPORT names public definitions that changed behavior; --strict-usage promotes usage-summary drift to a strict failure. |
prism pkg accept-usage [path] | Regenerate a package’s usage summary and write it to usage-summary.md at the package root, creating or reseating the usage gate’s golden. |
prism store: the content-addressed store
Verbs over content-addressed code identity (the store).
| Command | What it does |
|---|---|
prism store wire PROGRAM [--accept] | Check the stable rung goldens of a file; --accept recomputes and reseats them in place. |
prism store attest PROGRAM | Compile through two independent backends, attest byte-identical output, and cross-check the signed index. |
prism store query <kind> <name> PROGRAM | Query callers, dependents, dependencies, or type uses in the definition graph. |
prism store lock PROGRAM [--accept] | Check stable-migration behavior against its lock, or reseat the lock with --accept. |
27.2 Flags
Optimizer, effect-lowering, query, and compiler-diagnostic controls are global because they affect multiple commands; output and operation-specific flags belong to the command shown. -h/--help works on the binary and every subcommand, and -V/--version on the binary.
| Flag | Applies to | Default | Meaning |
|---|---|---|---|
-o, --out <PATH> | bare build, build | source stem, or target/<pkg> | Output path for the compiled binary. |
--mlir | bare build, build | off (LLVM) | Lower through the MLIR backend instead of the textual LLVM emitter (requires the mlir build feature). |
-O, --opt [LEVEL] | global | 1 (bare -O is 2) | Core optimizer level (0/1/2); see optimization levels. |
--passes <SPEC> | global | unset | Run an explicit ordered pass list, overriding -O (mutually exclusive); see controlling the pipeline. |
--no-<pass> | global | off | Remove one pass from the pipeline: --no-fuse, --no-erase-newtypes, --no-specialize, --no-simplify, --no-inline, --no-cse; see controlling the pipeline. |
--fuse | global | on only at -O2 | Force whole-program pull-sequence fusion below -O2; --no-fuse takes precedence. |
--backend-opt <LEVEL> | global | 2 | LLVM-backend opt level handed to the C compiler as -O<LEVEL>: 0, 1, 2, 3, or s/z for size. Distinct from -O, which tunes Prism’s Core optimizer. |
--scheduler <POLICY> | global | cooperative | Select the default scheduler policy: cooperative (FIFO) or lifo. |
--no-native-effects | global | off | Force the free-monad effect driver instead of native delimited continuations. |
--no-trampoline | global | off | Disable the constant-stack trampoline for the free-monad fallback. |
--core-lint | global | off | Run Core Lint between optimizer passes. |
--opt-stats | global | off | Print per-pass rewrite counts to stderr. |
--compiler-stats | global | off | Print compiler-query hit, miss, and write counts. |
--explain-cache | global | off | Print immediate native artifact-cache decisions after a build. |
--query-threads <N> | global | 1 | Set the positive worker count for independent compiler queries; result collection remains deterministic. |
--verbose | global | off, or PRISM_VERBOSE=1 | Print effect-lowering fusion-fallback warnings to stderr. |
--no-compiler-cache | global | off | Disable the persistent compiler artifact cache for a from-scratch build. |
--dump-core <SINK> | global | unset | Dump Core after each pass to stdout, stderr, or a directory. |
--time-compile | compiling commands | off, or PRISM_TIME_COMPILE=1 | Emit one tab-separated timing row per compiler phase on stderr: phase, wall time, abbreviated input artifact key, cache status, output key and counts where they exist. |
--warn-dupes[=LEVEL] | global | off | Report (warn) or reject (strict) user definitions with equal behavior hashes; bare --warn-dupes means warn. |
--warn-stdlib-dupes[=LEVEL] | global | warn | Report or reject standard-library reimplementations; off silences the diagnostic. |
-h, --help | binary, all commands | Print help. | |
-V, --version | binary | Print the version. |
The same compiler controls can be set in a project’s [flags] table with kebab-case names. Built-in defaults are overlaid by prism.toml, then PRISM_* environment variables, then explicit CLI flags; unknown manifest keys and invalid values are rejected.
27.3 Dump Phases
prism dump <phase> PROGRAM prints one intermediate form. The optimizer flags above apply, so dump core reflects the selected -O level.
<phase> | Output |
|---|---|
tokens | The token stream after lexing and layout. |
syntax-tokens | Versioned JSON containing source, raw and post-layout token streams, and trivia. |
ast | The surface AST. |
surface-syntax | The ordered semantic surface AST as versioned, source-embedding JSON. |
syntax-diagnostics | Every lex or parse refusal as versioned JSON, or the empty list on acceptance; refusal is the payload, so it never fails. |
types | Each definition’s inferred type and effect row. |
typespans | Versioned JSON ranges with each pointable subterm’s canonical type and explicit effect row. |
hir | The checked HIR fixture: per-declaration schemes and per-node checker facts as versioned JSON. |
interface | The entry module’s checked interface (exported schemes, digests) as JSON, the importer-cutoff artifact. |
module-graph | The module dependency graph as JSON, the shape the incremental query walks. |
tc-input | The resolved declaration interface consumed by the checker, as versioned JSON. |
resolved-syntax | Each user function’s resolved Core-phase body as a node-id-carrying tree, the traversable companion to tc-facts. |
tc-facts | The checker’s principal schemes, effect rows, resolutions, evidence, and node facts, as versioned JSON. |
elab-input | One versioned envelope composing tc-input and tc-facts for elaboration. |
verify | The module’s logical declarations and contract summaries, without invoking a solver. |
smt | Canonical SMT queries for postconditions and termination-ranking obligations. |
totality | Per-function checked, assumed, or pending totality status. |
core | The CBPV / ANF core after elaboration and the optimizer. |
dupes | Groups of distinct definitions sharing one behavior hash, one line per clone group. |
core-json | The core as a JSON tree the Lean model reads (the differential oracle). |
core-identity | The identity surface: the exact pre-optimizer core, metadata, groups, and dependency hashes. |
core-hash | A content-addressed hash of each definition’s elaborated core. |
native-kont-table | The deterministic native-symbol-to-definition-hash table embedded into native LLVM builds. |
native-kont-state-map | The versioned native state map for entry ABI-word slots embedded into native LLVM builds. |
fbip | Core after reference-count insertion and in-place reuse. |
lowered | Core after effect lowering (handlers and operations removed). |
tier | The effect-lowering strategy the program’s handlers compile to. |
captures | Closure-capture facts, each classified portable, nonportable, or unknown for a move across a suspend boundary. |
usage-summary | A per-definition table of allocation, fip/fbip, borrow, and effect-row facts, committable as a golden. |
usage-summary-md | The same usage facts as a markdown pipe table, the projection prism pkg check-world’s usage gate compares. |
usage-summary-json | The same usage facts as a JSON object, for tooling that consumes the summary programmatically. |
shape | The structural shape digest of each datatype, effect, and class. |
stdlib-hash | The standard library’s Merkle root (content-addressed core). |
namespace | The versioned definition-layer export, wrapped in the wire envelope header. |
llvm | The emitted LLVM IR. |
mlir | The emitted textual MLIR (requires the mlir build feature). |
dump captures is a read-only analysis over the program’s own elaborated core. For every lambda and thunk it lists what the closure closes over (a source value or a call to a top-level definition) and what scoped operations it performs (a var cell’s get/set, a named handler instance’s private op), and classifies each fact as portable, nonportable, or unknown for a hypothetical move across a suspend boundary. A value type defers to the suspend codec’s own encodability judgment; a top-level definition is portable because it travels as a content-addressed code reference; a var cell and a named handler instance are nonportable because their backing scope ends before a moved computation could resume. The classification is conservative in one direction: nothing is called portable unless it provably is, so a false “unknown” only costs a diagnostic while a false “portable” is impossible. The dump is diagnostic and changes no compilation output.
dump usage-summary prints one tab-separated line per definition, name-sorted, of the usage facts the compiler already holds: the @ noalloc allocation certificate, the fip/fbip discipline, the per-parameter borrow mask (b for a borrowed parameter, - for an owned one), and the checked effect row. A header names the format version and the whole-program lowering tier; the tier is a whole-program cost decision, so it heads the table rather than repeating on every line. The table is scoped to the program’s own definitions, the entry file plus the modules its own source directories serve, so an imported library’s rows never appear and a committed summary drifts only when the program’s own source changes. Every fact is read from its canonical source and none is recomputed.
The same facts project three ways: usage-summary is the tab-separated form above, usage-summary-md renders them as an aligned markdown pipe table (cells escape |, so a row-polymorphic tail like {X | e} cannot break the table, and the alignment matches the repository formatter so a committed file is stable under it), and usage-summary-json emits one JSON object for tooling. A package may commit the markdown projection as usage-summary.md at its root; prism pkg check-world regenerates it and reports drift as the usage gate, naming the first differing line. prism pkg accept-usage <pkg> writes that golden, creating it the first time and reseating a drifted one with the same byte-stable regeneration, the same accept discipline as the tier manifest and the wire rung goldens. The gate is report-only by default: drift is printed but excluded from --strict failure, so packages can adopt the golden incrementally, and a package that commits no summary reports the gate as missing rather than failing. --strict-usage opts a CI lane in, promoting usage drift to a strict failure while a missing summary stays non-fatal, since missing means not opted in rather than wrong. In the --json report the gate carries its evidence: usage (missing, passed, failed), usage_drift naming the first differing line with expected and actual, usage_format naming the artifact format the golden is compared under (usage-summary-md), and usage_tier, the whole-program lowering tier that heads the summary, present only when a summary was regenerated. The tier is deliberately a single whole-program scalar, the same fact the summary’s header states; per-definition rows carry no tier, so the JSON claims none.
27.4 Environment Variables
These are read by the compiler at build time. They select toolchain inputs, cache policy, deterministic query scheduling, or diagnostic and opt-out behavior.
| Variable | Effect |
|---|---|
PRISM_CC | C compiler used to assemble and link the runtime (default clang). |
PRISM_CC_FLAGS | Extra flags passed to the C compiler (e.g. -march=native, -g, -DPRISM_RT_DEBUG). |
PRISM_BACKEND_OPT | LLVM-backend opt level (same values as --backend-opt); the flag wins when both are set. |
PRISM_OPT_LEVEL | Core optimizer level used when -O is not passed (same values as -O). |
PRISM_SCHEDULER | Default cooperative scheduler policy, cooperative/fifo or lifo; overridden by --scheduler. |
PRISM_EFFECT_TIER | Debug cap on effect lowering: auto, state, or free-monad; tier selection is semantically unobservable. |
PRISM_NATIVE_EFFECTS | 0 opts out of the native delimited-continuation effect runtime, back to the mutually recursive free-monad driver; on otherwise. |
PRISM_TRAMPOLINE | 0 disables the constant-stack trampoline for the free-monad fallback; on otherwise. |
PRISM_NATIVE_KONT_FRAMES | If set, add frame-preservation flags to native builds so experimental native-kont frame capture is less optimizer-dependent; off by default. |
PRISM_NO_SPECIALIZE | If set, skip the dictionary-specialization pass. |
PRISM_FUSE | Boolean override that forces whole-program pull-sequence fusion below -O2; --no-fuse still disables it. |
PRISM_CORE_LINT | If set, run Core Lint (IR well-formedness) between every optimizer pass. |
PRISM_RT_CHECKS | If set, compile the C runtime with -DPRISM_RT_DEBUG (cell-validity backstop); off by default so release builds stay zero-overhead. |
PRISM_OPT_STATS | If set, print per-pass optimizer telemetry to stderr. |
PRISM_DUMP_CORE | If set to a directory, dump the core before and after each pass for debugging the optimizer. |
PRISM_COMPILER_CACHE | Byte-identical durable compiler-query reuse; on by default, set to 0 for a from-scratch build. |
PRISM_COMPILER_STATS | If set, print command-scoped compiler-query hit, miss, and write counts. |
PRISM_EXPLAIN_CACHE | If set, print the final and backend-IR query decisions after a native build. |
PRISM_QUERY_THREADS | Positive worker count for independent compiler queries (default 1); collection and artifacts remain deterministic. |
PRISM_SCC_BACKEND | 0 forces the whole-program backend oracle instead of SCC recomposition; on by default and semantically unobservable. |
PRISM_TIME_COMPILE | Boolean environment equivalent of --time-compile; off by default. |
PRISM_QUIET | Silence the non-fatal fallback / matcher-drift warnings on stderr. |
PRISM_VERBOSE | Print effect-lowering fusion-fallback warnings; off by default. |
PRISM_MDBOOK_STRICT | Make the mdBook preprocessor fail when a checked Prism block does not type-check. |
PRISM_STORE | Enable the opt-in definition content-addressed store; distinct from the compiler query cache. |
PRISM_STORE_PATH | Where the store’s object and metadata layers live (resolved through store::resolve_store_path). |
PRISM_SOLVER_TIMEOUT_MS | Positive per-obligation wall-clock timeout for the external contract solver, in milliseconds. |
PRISM_SIGN_MODE | Package-index signing seam: ssh (default), minisign, or explicit unsigned development mode. |
PRISM_SIGN_KEY | Signing key path used by package publishing; absent selects the unsigned path. |
PRISM_SIGN_IDENTITY | Signer principal recorded in and checked against the package-index signature. |
PRISM_SIGN_ALLOWED_SIGNERS | OpenSSH allowed-signers file or minisign public key used to audit a signed package index. |
PRISM_WARN_DUPES | Own-definition duplicate severity: off (default), warn, or strict. |
PRISM_WARN_STDLIB_DUPES | Standard-library reimplementation severity: warn (default), strict, or off. |
LLVM_SYS_221_PREFIX | Where the LLVM 22 dev libraries live, for linking the compiler itself (a build-of-prism setting). |
A second set is read at runtime by the generated program, for the instrumentation the test gates assert. They print to stderr and never change output.
| Variable | Effect |
|---|---|
PRISM_CHECK_LEAKS | At exit, report any heap cell allocated but not freed (the deterministic leak gate the parity oracle asserts). |
PRISM_REUSE_STATS | Print how many constructor allocations were satisfied by in-place FBIP reuse. |
PRISM_EFFOP_STATS | Print how many free-monad effect-operation cells were allocated (zero on the fully fused path). |
PRISM_DRIVE_STATS | Print native effect-driver statistics. |
The runtime also has two compile-time switches. -DPRISM_RT_DEBUG inserts a structural validity check at every cell dereference (non-null, aligned, positive refcount, in-bounds field), aborting with a diagnostic instead of corrupting memory; the canonical way to turn it on is PRISM_RT_CHECKS (which adds the define to the cc invocation), and PRISM_CC_FLAGS=-DPRISM_RT_DEBUG also works. It is off by default so release builds and the parity oracle stay byte-identical and zero-overhead; it is the always-available structural backstop for builds where ASan/UBSan are unavailable. The mimalloc cargo feature routes the runtime’s allocations through mimalloc.
27.5 REPL Commands
Inside the shell, input beginning with : is a command; anything else is an expression or declaration to evaluate. The full command set, the :set toggles, and the multi-line block syntax are documented under the interactive shell.
28. Diagnostics
A diagnostic is a value, not a string. Every error the compiler can produce is a variant of a structured catalogue, each variant owning one stable E-code; the rendered message is payload, never the discriminator a caller or renderer matches on. A code is permanent once assigned, so a diagnostic can be looked up years later, scripted against, and searched, and a message can be reworded freely without breaking anything that keyed on the code.
The philosophy is that an error message is the interface the language presents at the moment of failure, and it owes the user three things. First, the site: every diagnostic carries a span and renders a source ribbon pointing at the offending characters, and a type error raised while checking a definition names its enclosing frame (in \main`: unbound variable ‘MkCelsius’`), so an error deep in an application still says whose body it fired in. Second, the cause in the program’s own vocabulary: the unknown constructor by name, the two rows that failed to unify, the arity that did not match, not the internal state of the checker. Third, the remedy where one is mechanical: an unknown name close to a name in scope gets a “did you mean” hint (Damerau-Levenshtein distance with a threshold that scales with the name’s length, so a long name tolerates a proportionally larger typo without matching wild guesses), and a removed or re-spelled construct gets a migration error that states the new spelling outright rather than a generic parse failure, so an upgrade is a series of pointed instructions instead of an archaeology project.
Codes are banded by the phase and domain that owns them, walking the pipeline in order:
| band | domain |
|---|---|
E1xxx | types and unification |
E2xxx | scope and unbound names |
E3xxx | classes, instances, and coherence |
E4xxx | patterns and matching |
E5xxx | effects, handlers, and usage contracts |
E6xxx | declarations and desugaring |
E70xx | lexing |
E71xx | parsing |
E72xx | module, project, and package resolution |
E74xx | codegen, documentation, formatting, dump, verification |
E75xx | runtime evaluation, replay, and the debugger |
E76xx | file and process IO |
E9xxx | internal compiler errors |
The E1xxx through E6xxx bands are the type checker’s structured catalogue, keyed by what the user wrote; the E7xxx bands are the phase errors that cross the compiler’s API boundary, keyed by which subsystem failed. E9999 is the internal-invariant band: a condition the compiler believed impossible, rendered with an apology and a request to report it, because an internal error is a compiler bug by definition. Warnings ride the same channel with the same discipline (a deprecation names the definition, the suggestion, and the use site) but never stop a build: by the determinism contract a warning is a diagnostic, not a semantic.
29. Prism as a Library
The prism crate is usable as a compiler library when you want the language machinery without the CLI wrapper. The high-level entry points are: prism::check(src) type-checks a Rust &str and returns the inferred declarations, prism::interpret(src) runs it in the tree-walking interpreter with output captured in the returned eval::Run, and prism::build_at(src, base, out) / prism::build_on(src, roots, out, cfg) compile native binaries when the native feature is enabled. For live IO, use prism::interpret_io_on(src, roots, out_sink, input, cfg) or prism::interpret_io_on_with_args so stdin, stdout, argv, scheduler, optimizer level, and effect-lowering flags are all explicit values rather than ambient CLI state. For inspection, prism::dump_on(phase, src, roots, cfg), prism::core_of(src), prism::core_ir(src), prism::emit_ir(src), prism::namespace_root(src, roots), and prism::shape_digests_of(src) are the same surfaces the command line uses.
Underneath that surface the tree is a Cargo workspace, split bottom-up so a consumer can depend on the layer it needs instead of the whole compiler. prism-common holds the substrate every other layer shares: interned symbols, digests, the byte primitives, and the SCC and fixpoint machinery. prism-syntax holds keywords, canonical names, diagnostics, the lexer, the grammar, the AST, and the formatter. prism-core holds DynFlags, the type language, CBPV Core, and the typed passes over it. prism-native holds LLVM and MLIR emission and the C runtime build. prism-store holds the content-addressed disk store and prism-lineage the provenance graph and its queries. The root prism crate keeps the driver, the checker, the elaboration bridges, the interpreter, the CLI, and the WebAssembly entry points, and re-exports every moved module at its former crate:: path, so the split is invisible to code written against the paths named in this chapter.
The smallest embedding is just a string:
let src = prism::with_prelude("fn main() = print(1 + 2)");
let checked = prism::check(&src)?;
let run = prism::interpret(&src)?;
assert_eq!(run.term, "3");
For projects or custom module sources, pass explicit roots instead of relying on the current directory: prism::default_roots(base) gives the normal single-file search path, while prism::project_roots, prism::project_roots_with_std, and prism::project_roots_with_packages_and_std are the project/package forms. The important rule is the same identity rule the CLI follows: module roots, Std roots, package roots, stores, lockfiles, and behavior-affecting flags are inputs to the driver call, not hidden globals.
A different front end should target the same syntax::ast::Program<Surface> or go lower and produce core::Core directly. The ordinary route is lex::lex / parse::parse, module resolution through resolve::resolve_modules_in, desugaring through syntax::desugar::desugar, typechecking through the driver (check_on) or the internal checker, and elaboration through core::elaborate into Core. If you produce Core yourself, you have taken responsibility for the invariants the front end usually proves: names are resolved, types and effects are coherent, builtins are used with the right arity, and the Core is well-formed enough for optimization, effect lowering, reference counting, interpretation, and codegen.
The tool that checks those invariants is Core Lint, exported as prism::core::lint_core. It is stage-aware: a PassStage argument says where in the pipeline the Core sits, because the two families of node have opposite legality across the effect-lowering seam. Effect nodes (Do, Handle, Mask) are legal only before lowering, and the reference-counting and local-cell nodes (Dup, Drop, WithReuse, Reuse, RefNew/RefGet/RefSet) are legal only after it. Lint at PassStage::PreLowering on Core you assembled or transformed by hand and it rejects any runtime node that leaked in early; lint at PassStage::Late on lowered Core and it rejects any effect node lowering should have erased. It also checks scoping (every free variable resolves to a parameter or a top-level function) and reuse-token linearity (no token spent twice on one path). A violation comes back as Err(Vec<String>), one message per problem, attributed to the offending function.
use prism::core::{lint_core, Comp, Core, CoreFn, PassStage, Value};
use prism::sym::Sym;
// fn main = return 42
let prog = Core {
fns: vec![CoreFn {
name: Sym::new("main"),
params: vec![],
body: Comp::Return(Value::Int(42)),
dict_arity: 0,
}],
};
assert!(lint_core(&prog, PassStage::PreLowering).is_ok());
This snippet mirrors the runnable doctest on prism::core::lint_core, which CI compiles and runs under cargo test --doc. That doctest, including the companion case where a pre-lowering lint rejects a stray runtime node, is the tested source of truth; the block here cannot drift from it silently.
To read Core back out, the pretty printers are exported from prism::core. pp_core_pretty renders a whole program in the indented, one-bind-per-line notation dump core prints; pp_core renders the same program in the compact single-line form the snapshot tests pin; pp_comp renders a single computation and pp_value a single value. They are the same functions the dump surfaces call, so Core you produced or rewrote prints in exactly the notation the rest of the toolchain reads.
A different backend should start from Core, not from the surface language. The easiest pattern is the shared emitter, which walks lowered Core once and delegates instruction spelling to the Isa trait, with LLVM and MLIR as the two current instances. For an out-of-tree target, implement the public prism::codegen::Isa interface and pass it with the lowered Core and constructor table to prism::codegen::emit_with_isa; the associated Buf, IntOp, Cmp, FloatBinOp, and FloatIntrinsic types are exported from the same module. If the target can share Prism’s runtime representation, implement the small instruction vocabulary (load, store, call, switch, ret, merge blocks, tail calls, and the primitive arithmetic/float operations) and let the existing Core walk keep evaluation order, reference counting, handler lowering, and FBIP reuse centralized. If it cannot share that representation, treat core::Core as the semantic contract and write a backend that re-proves the same byte-parity obligations the LLVM path is held to.
In other words: the library API is quite usable and the compiler internals are fairly modular, so it should be easy to hack on if you feel so inclined to do something weird.
30. Warranty
Prism is released under the vanilla MIT License. Which in lawyer speak is essentially, do whatever the fuck you like. Fork it, sell it, embed it in a toaster, put it in a spaceship. Whatever.
What MIT also means, in the traditional all-caps liturgy, is that the software is provided “as is”, without warranty of any kind. Do take that clause seriously here. If you have downloaded software written by some random compiler nerd in London and you are expecting it to be production-ready, bug-free, or in any sense safe to put under real money, you must be truly, magnificently mad.
This is an experiment. The entire premise is to see how far one person can push modern language design as a hobby: principal effect inference, content-addressed everything, a Lean model checking the compiler against itself, running continuations you can freeze to bytes and move between same-origin browser contexts, incremental computation you can pause in the middle of and warm back up across a restart, six effect-lowering strategies that are supposed to be observationally identical and deterministic. The fun stuffz. It is one dude with a family, some late evenings, and an unreasonable amount of love for functional programming. It compiles. It even runs. Whether it should be anywhere near your infrastructure is a question the license already answered, in capital letters, and I am inclined to agree with it.
If it breaks, you get to keep both pieces, and you are welcome to return it for a full refund of the purchase price. If it works, that is frankly as much a surprise to me as it is to you. Enjoy responsibly.
-
“Effect” in this principle is the broad semantic sense, not a claim that determinism and termination are surface row labels. Prism realizes the account through separate mechanisms: the effect row bounds permitted authority, totality analysis records what is known about return, the trace fixes actual external observations, deterministic semantics relates those inputs to one result, hashes name exact content, and lineage records the claimed relation and whatever validation or replay evidence was actually obtained. A digest alone does not prove behavior; each part is weaker alone, and lineage must say honestly when an edge was recorded, rehashed, structurally checked, or replay-verified. ↩
-
The oracle discipline is differential testing (McKeeman, 1998); the replay-as-contract stance is shared with record-and-replay debuggers such as rr and with deterministic-simulation testing as practiced by FoundationDB. ↩
-
Whole-program compilation as the semantic baseline is MLton’s (Weeks, 2006); the join of separately checked modules into one optimized program follows it deliberately, trading separate codegen for cross-module truth. ↩
-
Phase-indexed syntax follows the “Trees that Grow” technique (Najd & Peyton Jones, 2017); the discipline of making illegal states unrepresentable at each stage is the ML tradition’s, and GHC’s Core-after-desugar boundary is the direct precedent. ↩
-
Call-by-push-value is Levy’s calculus (Levy, 2004); A-normal form is from Flanagan et al. (1993). ↩
-
Bidirectional higher-rank inference follows Dunfield & Krishnaswami (2013), with local type inference tracing to Pierce & Turner (2000). Principal row-typed effects follow Koka (Leijen, 2017). Type classes compile by dictionary passing (Peterson & Jones, 1993); pattern matches compile to decision trees (Maranget, 2008). ↩
-
The lint lineage is GHC’s Core Lint; the verify-each-output stance is translation validation (Pnueli, Siegel & Singerman, 1998; Necula, 2000). ↩
-
Algebraic effects and handlers descend from Plotkin & Pretnar (2009); the evidence-passing compilation is Xie et al. (2020) and Xie & Leijen (2021), and the multi-strategy cascade with a free-monad floor (Kiselyov & Ishii, 2015) is Prism’s arrangement of Koka’s toolkit. ↩
-
Garbage-free reference counting with reuse is Perceus (Reinking et al., 2021), with frame-limited reuse (Lorenzen & Leijen, 2022); the in-place discipline continues in FP^2 (Lorenzen, Leijen & Swierstra, 2023); the destination-passing tail lowering is tail recursion modulo cons (Bour, Clément & Scherer, 2021). ↩
-
Content-addressed definition identity is Unison’s founding idea; the demand-driven query graph with early cutoff follows the incremental architecture of rustc’s query system and Salsa, and the immutable content-addressed store echoes Nix. ↩
-
The one-emitter-many-spellings seam is Prism’s own, spelling into LLVM (Lattner & Adve, 2004); the reference-interpreter-as-oracle discipline is differential compiler testing (McKeeman, 1998; Yang et al., 2011 is the modern exemplar). ↩
-
A definition’s “behavior hash” is the digest of its canonical elaborated Core and semantic metadata, not a proof that extensionally equivalent programs share a hash. Replay likewise requires a recorded trace. The principle is that an influence cannot remain unnamed while still being covered by Prism’s guarantee, not that reality has ceased to contain surprises. ↩
-
Around sixty such sites across elaboration, checking, effect lowering, and codegen, the last through a non-panicking
icehelper that records the first message and returns a poison value so emission stays total. ↩ -
A derived instance is synthesized surface code elaborated like any hand-written one,
Hashfolds through the runtimeblake3builtin with the same constructor tokens the content-addressed core uses,Serializewrites the compact positional body against the canonical byte-building primitives,Stableis a marker derivable only when every component isStable(the failure is a compile error naming the offending field), andArbitrarycomposes theQuickcheckgenerator combinators with recursion fuel so generation of a recursive type terminates. ↩ -
Zonk is jargon from the Glasgow Haskell Compiler, where zonking is the pass that walks an inferred type and replaces every solved (filled-in) unification metavariable with the type it was unified to, flattening the mutable inference variables into their final form. The word itself is onomatopoeic, a comic-book sound effect adopted with characteristic GHC whimsy and no deeper meaning; Prism keeps it because it names the same operation. ↩
-
The cost is time, the one tax physical execution pays that pure form escapes. Were an instruction free, the slowest pathway would be as good as the fastest and this ladder a curiosity; it has rungs at all only because the machine bills by the instruction, and everything above works to keep you from being charged for the bill’s contents rather than its total. ↩
-
An
EOpcarries askipfield, its mask depth, the number of matching handlers it must still bypass; amaskdriver increments it and the handler driver only fires when it is zero. ↩ -
A function-answer state clause, the parameter-passing pattern whose answer is a function
S -> A(rd(u, r) => \s -> r(s)(s),wr(v, r) => \s -> r(())(v)) applied once at the handler’s use site, threads the state in an accumulator parameter and folds that use-site application into the loop’s entry, so the pending-apply chain that would otherwise grow the stack per iteration lives in the accumulator instead. ↩ -
A self-tail call of equal arity becomes a
musttailloop, and a constructor- or accumulator-shaped tail call, one whose result feeds a constructor or an integer accumulator, becomes a destination-passing loop that writes its result into an address passed as a hidden parameter rather than returning it, using the same classification thefipcheck reads (see reference counting and FBIP reuse). ↩ -
prismfn_for Core functions,prismlam_,prismap_, andprismtrmc_for the emitter’s generated families, and plainprism_for the runtime, which keeps its several hundred symbols unchanged. Discriminating on one character rather than on a longer reserved word is what keeps the property cheap to state and impossible to satisfy by accident. ↩ -
State-map version 1 uses
slot-format prism-native-abi-word-v1: each row names the logical entry ABI words (arg0=%a0:word,arg1=%a1:word, …), matching the backend convention that every Prism value crosses generated function boundaries as onei64word. ↩ -
Every cell allocation routes its size through one overflow-checked chokepoint,
prism_cell_bytes, which rejects a negative field count and aborts (via__builtin_add_overflow/__builtin_mul_overflow) if the header-plus-payload word count, or its conversion to bytes, would overflowsize_t, so a corrupt or oversized arity can never produce an undersized allocation. ↩ -
Unlike a collector, which comes for your values at an hour of its own choosing, reference counting frees each one at a moment fixed in advance and knowable from the source. Whether it is more restful to know exactly when everything you have allocated will die is not addressed here. ↩
-
Its header word is a signed limb count whose sign is the value’s sign; the magnitude follows as that many little-endian
u64limbs (base-2^64 digits) with no leading zero limb. Zero is a count of zero with no limbs. ↩ -
Conflating them would be a category error: a core hash could in principle be reused as a memoization key for a trace, but the trace itself, standard output, capability events, file commits, exit status, and the returned value, has no compile-time analogue and is computed only by running the program. ↩
-
Effect-op names canonicalize too: a
var-desugaredget@x@n/set@x@nbecomesget@#k/set@#k, a per-definition id assigned by first occurrence, so renaming thevaror reordering top-level definitions never moves the hash; a genuine effect operation’s name is committed verbatim, since renaming one of those is a behavior change. ↩ -
Every write lands atomically: bytes go to a uniquely named
.tmp.*file in the destination directory, are flushed, and are renamed into place, which is the commit point, so a reader sees the whole old file or the whole new one and a process killed mid-write leaves only a temp file no reader ever opens (readers open exact hash paths only). ↩ -
The conditions under which a memoized, dependency-tracked build is order-independent, and therefore safe to cache and to parallelize, are formalized by Mokhov, Mitchell, and Peyton Jones, “Build Systems à la Carte” (ICFP 2018). ↩
-
The frame stack itself is encoded iteratively, so the depth bound (
MAX_SUSPEND_DEPTH, 256) limits nested runtime data (a cons-list, a tree) and the source-bounded computation depth, not the count of pending frames, which keeps both the recursive encoder and the recursive decoder inside the native stack. ↩ -
Totality holds because every varint is byte-capped, every length is bounded, the scheme, kind, and bundle are checked before the body, child indices are range-checked against the already-parsed prefix, reconstruction runs against an expansion budget, and trailing bytes are rejected. ↩
-
PRISM_EXPLAIN_CACHEis the terse immediate stderr view of final and backend-IR cache status.prism lineage why-recompiledis the durable graph view across the six active query families and any historical kinds still present in an older ledger. ↩ -
The term comes from John Lamping, Gregor Kiczales, Luis H. Rodriguez Jr., and Erik Ruf, “An Architecture for an Open Compiler,” Proceedings of the IMSA ’92 Workshop on Reflection and Meta-level Architectures, 1992, pp. 95–106. ↩
Standard Library
Prism’s standard library is ordinary Prism source, not compiler built-ins. A small always-on Base supplies the core types, the type-class tower, and the common data modules in unqualified scope; everything else is opt-in via explicit import. The pages below are generated from the module sources, with signatures taken from the typechecker.
Merkle root
- Scheme:
prism-core-hash-v1 - Hash:
fa9b26b325586f03a015c0b171e8b665a45821f42a0ba0b58fc6c1304929eb20 - Compiler version: Prism v0.15.0
Modules
- Base - Base, the always-on surface: wired-in types, the type-class tower, core combinators, and the effect/loop machinery.
- Control.Fresh - The
Fresheffect: a deterministic monotonic name supply (gensym). - Control.Layer - The children-and-rebuild interface a generic traversal runs on, and the collecting queries that ride it.
- Control.Reader - The canonical
Reader(r)effect: a read-only ambient environment. - Control.Rewrite - Strategy combinators: a pass as a composition of small local rules instead of a hand-written recursive match.
- Control.State - The canonical
State(s)effect: a threaded piece of mutable-looking state, interpreted by parameter passing. - Control.Validate - Validation as an algebraic effect.
- Control.Writer - The canonical
Writer(w)effect: accumulate output on the side. - Data.Bind - Binders, the two nameless coordinate systems, and the canonical rendering that makes alpha-equivalent terms identical.
- Data.Bytes - Byte strings: the
String/Bytesboundary, and the hex and base64 codecs. - Data.Char - ASCII character classification.
- Data.Checked - Safe arithmetic families over the machine-integer lanes.
- Data.Fixpoint - Least fixed points over a join-semilattice, solved by worklist.
- Data.FlatArray - Flat, unboxed-element arrays: one typed surface over the raw-word buffers.
- Data.Foldable - Generic operations over any
Foldablecontainer. - Data.Frozen - Frozen arrays: the immutable array representation.
- Data.Graph - Directed graphs over an ordered node type, with the deterministic algorithms the compiler relies on internally, mirrored into Prism.
- Data.IntMap - Persistent integer-keyed map: a big-endian patricia trie over 64-bit keys.
- Data.IntSet - Sets of 64-bit integers, reusing the patricia trie.
- Data.List - Singly-linked list operations.
- Data.Map - Persistent ordered map: an AVL-balanced binary search tree over keys.
- Data.Maybe - Operations over
Option. - Data.Monad - Generic operations derived from the
ApplicativeandMonadclasses. - Data.Ordered - Explicit ordering witnesses: the branded, statically coherent path to ordered maps.
- Data.Pretty - A Leijen-style pretty printer. Build a layout-independent
Docfrom the combinators below, thenrenderit to a string at a chosen page width. - Data.Result - Operations over
Result. - Data.Set - Ordered sets, reusing the balanced-tree map.
- Data.String - String operations, byte-oriented and ASCII-accurate.
- Data.Tensor - Dense multi-dimensional tensors over a flat
FloatBuf. - Data.UnionFind - A persistent union-find (disjoint-set) over an ordered key type.
- Data.Validation -
Validation, the error-accumulating sibling ofResult. - Data.Vec - Fixed-length vectors indexed by a
Natdimension. - Syntax.Analysis - Analysis walks over the surface syntax tree.
- Syntax.Ast - The typed surface syntax that the
prism-surface-syntax-v1artifact decodes into. Constructor prefixes name the family (Iitems,Eexpressions,Ppatterns,Tytypes), and spanned nodes wrap inSp. The shapes mirror the compiler’s exporter exactly, so a decoded document re-encodes to identical bytes. - Syntax.Codec - Codecs for the versioned syntax artifacts. Decoding turns the compiler’s exports into the typed
Syntaxvocabularies, rejecting wrong schema tags, malformed shapes, and spans that invert or reach past the embedded source with one structured error; encoding is the exact inverse, re-emitting identical bytes. - Syntax.Cursor - The mechanical half of recursive descent: a token cursor with peek, advance, and expect, and a Pratt driver over a binding-power table.
- Syntax.Diagnostic - The typed vocabulary of the
prism-syntax-diagnostics-v1artifact. - Syntax.Edit - Span-addressed source edits that refuse rather than corrupt.
- Syntax.Flow - Call-graph flow over a resolved document: occurrence analysis and liveness as one fixpoint.
- Syntax.Identity - The identities a Prism source file carries, and the two of them a published artifact is enough to compute.
- Syntax.Layout - The Prism-language reimplementation of the compiler’s layout pass: the offside rule that turns the raw token stream into the post-layout
parsestream by splicing the virtual block delimitersVOpen/VClose/VSemiand by opening a bare-indent body after eachclass/instance/effecthead. The Rustlexpipeline stays the authoritative oracle; this module reproduces its output so the two can be diffed, never used as a silent fallback. - Syntax.Lex - A Prism-language reimplementation of the compiler’s raw token layer: exact UTF-8 tokenization, literal payload decoding, and interpolation splitting, expressed as ordinary Prism. The Rust
lex_rawpipeline remains the authoritative oracle; this module produces the same raw token stream (kind, byte span, and decoded value) and the same interleaved trivia (line comments and blank-line runs) so the two can be diffed. It is compared and reported, never used as a silent fallback. - Syntax.Query - A source query over a decoded
prism-syntax-tokens-v1artifact. - Syntax.Rename - Rename as a join against the resolver, not as a tree walk.
- Syntax.Report - Caret rendering for
Syntax.Diagnostic: the plain-text report the compiler prints for a refused source, rebuilt in Prism from the diagnostic and the source text alone. - Syntax.Resolved - The typed vocabulary of the
prism-resolved-syntax-v1artifact. - Syntax.Source - Source identity for the versioned syntax artifacts: source files and half-open byte spans. Byte offsets are the canonical position vocabulary (line and column are projections for people, never a second identity), and these are the Prism-side types the token and surface-syntax exports decode into.
- Syntax.Token - The token vocabulary of the
prism-syntax-tokens-v1artifact. A fixed token’s wire kind is its source spelling, soTFixedcarries the spelling rather than enumerating every keyword and operator; value-carrying and virtual layout tokens each get a dedicated constructor matching the grammar’s terminal aliases. - Syntax.Walk - Generic traversal over the surface syntax tree.
- Arena - Arena: allocation as an algebraic effect.
- Blit - Range copy over the sequence types a real primitive can back.
- Cli - CLI: an applicative command-line parser as a first-class value.
- Concurrent - Cooperative async/await concurrency as a single handler, polymorphic in the effects the fibers perform.
- Incr - Incremental computation as a handler over a content-addressed dependency graph.
- Json - JSON: a dynamic value tree, a total parser, a canonical encoder, and a typed layer.
- Math - Named mathematical constants, matching Rust’s
f64::constssurface. - Quickcheck - Property testing: run a boolean property over many generated inputs and report the first counterexample, deterministically.
- Replay - Record/replay handlers for the capability effects.
- Sequence - The one lazy iteration protocol: pull-based sequences with natural names.
- Teleport - The checked mobility boundary.
teleportruns a portable, single-use computation as a unit that is safe to move to a fresh runtime. - Test - Per-type value generators for property testing.
- Time - Time: instants, wall-clock timestamps, durations, and RFC 3339.
- Wire - The opt-in serialization layer.
Base
Base, the always-on surface: wired-in types, the type-class tower, core combinators, and the effect/loop machinery.
The broad data-structure surface (lists, maybe/result, maps, sets, strings, chars) lives in importable stdlib modules under Data.*; the glob imports below re-open them into unqualified scope so every name stays available without an explicit import.
Types
Option
type Option(a) = None | Some(a) deriving (Eq, Show)
The optional type: None, or Some(a) holding a value.
Result
type Result(a, e) = Ok(a) | Err(e) deriving (Eq, Show)
A computation outcome: Ok(a) on success or Err(e) on failure.
List
type List(a) = Nil | Cons(a, List(a)) deriving (Eq)
A singly-linked list: Nil, or Cons(head, tail). Backs [..] literals.
Map
type Map(k, v, ord) = Tip | Bin(Int, k, v, Map(k, v, ord), Map(k, v, ord))
A persistent ordered map, an AVL tree (Tip/Bin); see Data.Map. The third parameter ord is a phantom brand recording the ordering witness the map was built under; it never appears in a field, so an unbranded Map(k, v) is the same type under-applied (a fresh brand per use). See Data.Map.
Canonical
type Canonical = MkCanonical
The brand of a map built under the ambient canonical ordering, when no explicit ordering witness is in scope. A map built inside with w <- ordering carries w’s private brand instead, so a value of one brand never unifies with the other. Maps stored in a container that pins a concrete brand (rather than staying brand-polymorphic) use this one.
HashMap
type HashMap(v) = HM { buckets: Array(List((String, v))), size: Int }
A separate-chaining hash table with String keys, built on the growable Array: each bucket is an association list, and the table doubles its bucket count when the load factor passes 1. Iteration order is a pure function of the inserts, identical across the interpreter and native backends.
Type Classes
Eq
class Eq(a)
eq : (a, a) -> Bool
Equality. eq backs ==//=.
eq(1, 1)
true
Ord
class Ord(a) given Eq(a)
cmp : (a, a) -> Int
Total order. cmp(x, y) returns -1, 0, or 1; backs </<=/>/>=.
cmp("a", "b")
-1
Show
class Show(a)
show : (a) -> String
Canonical rendering to a String, dispatched by dictionary. show(x) reads x’s Show instance, so a value prints canonically even in a polymorphic context (where the runtime representation alone cannot tell, say, a Bool from an Int). Derive it with deriving (Show); strings render quoted and escaped, records with their field names.
show([1, 2, 3])
[1, 2, 3]
Hash
class Hash(a)
hash : (a) -> String
A content hash of a value, as a lowercase blake3 hex digest. deriving (Hash) folds a value structurally into the same content-addressing scheme the compiler hashes code with (a constructor token followed by its fields’ own digests), so structurally equal values hash equal, byte-for-byte identically on the interpreter and native backends. The leaf instances below anchor the fold.
hash(5)
35cd3e4aa36a8426c38411dc5c717031a7359ce9674f005cf9e1bf27780e902c
Plate
class Plate(a)
children : (a) -> List(a)
rebuild : (a, List(a)) -> a ! {Fail | e}
One layer of a value’s own structure, taken apart and put back. children(x) is the list of x’s immediate subvalues of x’s own type, in constructor-declaration and field order, and nothing else; rebuild(x, ks) is x with those same positions replaced, left to right, by the elements of ks. A whole-tree traversal (every subterm, a fold, a count, a rewrite) is written once against this one pair instead of once per constructor, so a fifty-constructor syntax tree costs the same to walk as a two-constructor one. children is pure and total: an empty effect row, and a list of structurally smaller values, so a recursion driven by children terminates on a finite value.
The law every combinator above these two relies on is that they are inverse on one layer: rebuild(x, children(x)) is x, and the list handed to rebuild must have the same length and the same order as the one children returned. A list of any other length is a programming error, not an input to be repaired: rebuild raises Fail rather than padding the missing positions or dropping the extra ones, because either repair would hand back a value that is not the one asked for, and would do it silently. That is the whole content of the Fail in the row; on a correctly shaped list rebuild performs no effect.
deriving (Plate) generates both from the declaration, reaching through list, optional, tuple, and record fields, and through the other types declared in the program, to find the occurrences a field can lead to. That is what lets a traversal see through the carrier records a syntax tree holds its nodes in (a match arm, a spanned wrapper, a qualifier) without anyone writing a second match for them.
The derivation is structural rather than compositional, so it differs from the other derived classes in two visible ways. It puts no constraint on the type’s parameters, because a Plate(T(a)) yields T occurrences and never an a; and it asks nothing of a component’s own instances, because it takes the component apart by its declaration rather than by dispatch. A field it cannot take apart, and that could still lead back to the derived type (a function, a container with no declaration in the program), is a compile error at the deriving clause naming the field, never a silently dropped subterm. Both methods come from one walk of the declaration, read forwards and backwards, so the derived pair satisfies the law by construction. Unlike Wire.Stable, whose method is a compiler-injected constant, nothing here is unforgeable: a hand-written instance is an ordinary instance and is accepted, which is the escape hatch for an abstract type whose children the compiler cannot see, and which is also where the law becomes the author’s to keep.
Pow
class Pow(a)
pow : (a, a) -> a
Exponentiation, the class a ^ b desugars to. Int and Float instances cover homogeneous powers; a mixed Int ^ Float is a type error.
pow(2, 10)
1024
Num
class Num(a)
plus : (a, a) -> a
minus : (a, a) -> a
times : (a, a) -> a
negated : (a) -> a
from_int : (Int) -> a
The additive-multiplicative core of the numerical tower: +, -, *, and unary minus (negated) over one lane. A monomorphic operand keeps its direct lane primitive (the dictionary never survives specialization); a given Num(a) operand dispatches here. Div is split off so a type with addition but no sensible division stays representable. No implicit coercion: an operand’s lane is fixed by its type, and only literals adapt to context.
plus(3, 4)
7
Div
class Div(a)
quotient : (a, a) -> a
modulo : (a, a) -> a
Division and remainder, the / and % operators. Integer lanes truncate toward zero with the remainder taking the dividend’s sign and fault on a zero divisor; Float division is IEEE (never faults) and % is fmod.
quotient(7, 2)
3
Functor
class Functor(f)
fmap : ((a) -> b ! {| e}, f(a)) -> f(b) ! {| e}
A container that can be mapped over. fmap is effect-polymorphic, so mapping an effectful function threads its row e through.
Foldable
class Foldable(t)
fold_r : ((a, b) -> b ! {| e}, b, t(a)) -> b ! {| e}
fold_l : ((b, a) -> b ! {| e}, b, t(a)) -> b ! {| e}
A container collapsible with an effect-polymorphic fold from either end. fold_l must be tail recursive in every instance: the aggregations in Data.Foldable ride it, so a million-element container folds in constant stack on the native backend.
Applicative
class Applicative(f) given Functor(f)
pure : (a) -> f(a)
ap : (f((a) -> b ! {| e}), f(a)) -> f(b) ! {| e}
A Functor with pure (inject a value) and ap (apply a wrapped function).
Monad
class Monad(m) given Applicative(m)
bind : (m(a), (a) -> m(b) ! {| e}) -> m(b) ! {| e}
Structural sequencing via bind. Side effects ride the effect system, so this is for List/Option-style structure rather than do-notation.
Traversable
class Traversable(t) given Functor(t), Foldable(t)
traverse : ((a) -> b ! {| e}, t(a)) -> t(b) ! {| e}
An effect-polymorphic traversal: traverse is an effectful map, the per-element effect row e replacing the classic Applicative parameter.
Effects
Emit
effect Emit(a)
emit(a) : Unit
The stream effect: emit(x) yields one element to the enclosing consumer.
Output
effect Output
out_print(String) : Unit
out_println(String) : Unit
Console output as an interceptable capability. print/println perform these ops; the default run_io handler discharges them to the real terminal, while replay/durable drop them during a replayed prefix.
Console
effect Console
con_read_int() : Int
con_read_line() : String
Console input capability. The surface wrappers read_int/read_line perform these ops; run_io discharges each by resuming with the matching prim_* builtin, and the tail-resumptive handlers fuse to direct calls.
FileSystem
effect FileSystem
fs_read_file(String) : String
fs_read_bytes(String) : Buf
fs_file_exists(String) : Bool
File-system read capability (read_file, read_bytes, file_exists).
Random
effect Random
rng_rand() : Int
Random-number capability (rand).
Entropy
effect Entropy
entropy_word() : Int
Real, non-replayable entropy capability (entropy). Distinct from the seeded, replayable Random: use Entropy for key material, never Random.
Env
effect Env
env_get(String) : String
env_argc() : Int
env_arg(Int) : String
Process-environment capability (getenv, args_count, arg).
Instances
eqInt
instance eqInt : Eq(Int)
eqI64
instance eqI64 : Eq(I64)
eqU64
instance eqU64 : Eq(U64)
eqBool
instance eqBool : Eq(Bool)
eqStr
instance eqStr : Eq(String)
eqFloat
instance eqFloat : Eq(Float)
eqChar
instance eqChar : Eq(Char)
ordInt
instance ordInt : Ord(Int)
ordI64
instance ordI64 : Ord(I64)
ordU64
instance ordU64 : Ord(U64)
ordBool
instance ordBool : Ord(Bool)
ordStr
instance ordStr : Ord(String)
ordChar
instance ordChar : Ord(Char)
ordFloat
instance ordFloat : Ord(Float)
eqPair
instance eqPair : Eq((a, b))
ordPair
instance ordPair : Ord((a, b))
eqTriple
instance eqTriple : Eq((a, b, c))
ordTriple
instance ordTriple : Ord((a, b, c))
showInt
instance showInt : Show(Int)
showI64
instance showI64 : Show(I64)
showU64
instance showU64 : Show(U64)
showFloat
instance showFloat : Show(Float)
showBool
instance showBool : Show(Bool)
showStr
instance showStr : Show(String)
showChar
instance showChar : Show(Char)
showUnit
instance showUnit : Show(Unit)
showList
instance showList : Show(List(a))
Lists render in bracket form, [a, b, c], matching the print-site generator; the elements recurse through their own Show.
hashInt
instance hashInt : Hash(Int)
hashI64
instance hashI64 : Hash(I64)
hashU64
instance hashU64 : Hash(U64)
hashBool
instance hashBool : Hash(Bool)
hashChar
instance hashChar : Hash(Char)
hashFloat
instance hashFloat : Hash(Float)
hashStr
instance hashStr : Hash(String)
hashUnit
instance hashUnit : Hash(Unit)
powInt
instance powInt : Pow(Int)
powFloat
instance powFloat : Pow(Float)
numInt
instance numInt : Num(Int)
numI64
instance numI64 : Num(I64)
numU64
instance numU64 : Num(U64)
numFloat
instance numFloat : Num(Float)
divInt
instance divInt : Div(Int)
divI64
instance divI64 : Div(I64)
divU64
instance divU64 : Div(U64)
divFloat
instance divFloat : Div(Float)
functorList
instance functorList : Functor(List)
functorOption
instance functorOption : Functor(Option)
foldableList
instance foldableList : Foldable(List)
foldableOption
instance foldableOption : Foldable(Option)
applicativeList
instance applicativeList : Applicative(List)
applicativeOption
instance applicativeOption : Applicative(Option)
monadList
instance monadList : Monad(List)
monadOption
instance monadOption : Monad(Option)
traversableList
instance traversableList : Traversable(List)
traversableOption
instance traversableOption : Traversable(Option)
Functions and Values
id
id : forall a. (a) -> a
The identity function.
id(42)
42
const
const : forall a b. (a, b) -> a
The constant function: returns x, ignoring y.
const(1, 2)
1
compose
compose : forall e0 a b c. ((b) -> a ! {e0}, (c) -> b ! {e0}, c) -> a ! {e0}
Function composition: compose(f, g, x) is f(g(x)).
compose(\(x) -> x + 1, \(x) -> x * 2, 5)
11
flip
flip : forall e0 a b c. ((b, c) -> a ! {e0}, c, b) -> a ! {e0}
f with its first two arguments swapped.
flip(\(a, b) -> a - b, 3, 10)
7
not
not : (Bool) -> Bool
Boolean negation.
not(true)
false
and
and : (Bool, Bool) -> Bool
Boolean conjunction (the function form of &&).
or
or : (Bool, Bool) -> Bool
Boolean disjunction (the function form of ||).
xor
xor : (Bool, Bool) -> Bool
Exclusive or.
xor(true, false)
true
abs
abs : (Int) -> Int
Absolute value.
abs(-5)
5
max
max : (Int, Int) -> Int
The greater of two values.
max(3, 7)
7
min
min : (Int, Int) -> Int
The lesser of two values.
min(3, 7)
3
clamp
clamp : (Int, Int, Int) -> Int
Constrain x to the range [lo, hi].
clamp(0, 10, 15)
10
signum
signum : (Int) -> Int
The sign of n, as -1, 0, or 1.
signum(-4)
-1
mod
mod : (Int, Int) -> Int
The remainder a % b.
mod(10, 3)
1
even
even : (Int) -> Bool
True when n is even.
even(4)
true
odd
odd : (Int) -> Bool
True when n is odd.
odd(4)
false
gcd
gcd : (Int, Int) -> Int
The greatest common divisor (Euclid’s algorithm).
gcd(12, 18)
6
lcm
lcm : (Int, Int) -> Int
The least common multiple.
lcm(4, 6)
12
int_pow_go
int_pow_go : (Int, Int, Int) -> Int
int_pow
int_pow : (Int, Int) -> Int
Integer exponentiation, the Pow(Int) instance: bignum-correct because * promotes past 63 bits, and tail recursive so a large exponent folds in constant stack. a ^ b over ints lowers here through the Pow class. A negative exponent is defined as 1 / a ^ (-b) under the language’s one truncating division rule: 0 whenever the magnitude of the base exceeds 1, the exact reciprocal for a base of 1 or -1, and the division-by-zero fault for a base of 0 (which is exactly what 0 ^ -1 is).
int_pow(2, 10)
1024
factorial
factorial : (Int) -> Int
n!, the factorial of n.
factorial(5)
120
fib
fib : (Int) -> Int
The nth Fibonacci number (naive, exponential).
fib(10)
55
pi
pi : Float
Pi. The transcendental functions (sin, cos, tan, the inverse and hyperbolic families, exp, ln, log2, log10, pow, cbrt, …) are owned builtins routing through the vendored libm, identical on every backend; only the named constants live here.
e
e : Float
Euler’s number.
tau
tau : Float
Tau, 2 * pi.
rand_below
rand_below : (Int) -> Int ! {Random}
A random integer in [0, n), over the seeded SplitMix64 stream.
rand_below(6)
rand_range
rand_range : (Int, Int) -> Int ! {Random}
A random integer in [lo, hi).
rand_range(10, 20)
rand_bool
rand_bool : () -> Bool ! {Random}
A random boolean.
between
between : (Int, Int, Int) -> Bool
True when lo <= x <= hi.
between(1, 10, 5)
true
fst
fst : forall a b. ((a, b)) -> a
The first component of a pair.
fst((1, 2))
1
snd
snd : forall a b. ((a, b)) -> b
The second component of a pair.
snd((1, 2))
2
swap
swap : forall a b. ((a, b)) -> (b, a)
A pair with its two components swapped.
swap((1, 2))
(2, 1)
pair_map
pair_map : forall e0 a b c d. ((b) -> a ! {e0}, (d) -> c ! {e0}, (b, d)) -> (a, c) ! {e0}
Apply f to the first component and g to the second.
pair_map(\(x) -> x + 1, \(y) -> y * 2, (10, 20))
(11, 40)
guard
guard : (Bool) -> Unit ! {Fail}
() when b holds, otherwise fail() (short-circuits a failable block).
optional(\() -> guard(false))
None
optional
optional : forall e0 a. (() -> a ! {e0}) -> Option(a) ! {e0}
Run thunk, returning Some(result), or None if it calls fail().
optional(\() -> 42)
Some(42)
succeeds
succeeds : forall e0 a. (() -> a ! {Fail, e0}) -> Bool ! {e0}
True when thunk runs to completion without calling fail().
succeeds(\() -> 42)
true
default
default : forall e0 a. (() -> a ! {e0}, a) -> a ! {e0}
Run thunk, returning its result or the default d if it calls fail().
default(\() -> at_list([1, 2], 9), 0)
0
at_list
at_list : forall a. (List(a), Int) -> a ! {Fail}
The element at index i, or fail() if out of range. Backs xs[i], so xs.at_list(i) ?? d defaults cleanly through ??.
at_list([10, 20, 30], 1)
20
at_map
at_map : forall a b c. (Map(b, c, a), b) -> c ! {Fail}
The value bound to key, or fail() if absent. Backs m[k].
force
force : forall a. (Option(a)) -> a ! {Fail}
The value inside Some, or fail() for None; o.force() ?? d defaults.
force(Some(5))
5
at_array
at_array : forall a. (Array(a), Int) -> a ! {Fail}
The array element at i, or fail() out of bounds. Backs a[i].
at_array(array_of_list([5, 6, 7]), 2)
7
at_hashmap
at_hashmap : forall a. (HashMap(a), String) -> a ! {Fail}
The hash-map value for k, or fail() if absent. Backs m[k].
at_hashmap(hm_from_list([("a", 1)]), "a")
1
at_byte
at_byte : (String, Int) -> Int ! {Fail}
The byte at index i of a string, or fail() out of bounds. Backs s[i].
at_byte("hi", 0)
104
list_set
list_set : forall a. (List(a), Int, a) -> List(a)
A new list with element i replaced by v (out of range: unchanged). Backs xs[i] := v and the [i] optic path on lists.
list_set([1, 2, 3], 1, 9)
[1, 9, 3]
sort
sort : forall a. (List(a)) -> List(a)
Sort a list in ascending order, a stable O(n log n) merge sort. Primitive element types are specialized to a native kernel at the call site; any other Ord type uses the generic path.
sort([3, 1, 2])
[1, 2, 3]
while_loop
while_loop : forall e0 a. ((a) -> Bool ! {e0}, (a) -> a ! {e0}, a) -> a ! {e0}
Iterate body from state s while cond(s) holds, returning the final state. Tail-recursive (constant stack).
while_loop(\(s) -> s < 10, \(s) -> s + 3, 0)
12
for_range
for_range : forall e0 a. (Int, Int, (Int, a) -> a ! {e0}, a) -> a ! {e0}
Fold f(i, s) over i in [lo, hi), threading the state s.
for_range(0, 5, \(i, s) -> s + i, 0)
10
repeat_while
repeat_while : forall e0 a. (() -> Bool ! {e0}, () -> a ! {e0}) -> Unit ! {e0}
The driver while c do body (and loop body with break) desugars to. Condition and body are thunks re-run each iteration, closing over the ambient var state; tail-recursive, so it runs in constant stack.
forever
forever : forall e0 a b. (() -> b ! {e0}) -> a ! {e0}
The driver an unconditional loop body (no break) desugars to: it never returns, so its result type is fully polymorphic. Tail-recursive.
repeat
repeat : forall e0 a. (Int, () -> a ! {e0}) -> Unit ! {e0}
Run body n times for its effects.
read_int
read_int : () -> Int ! {Console}
Read an integer from standard input.
read_line
read_line : () -> String ! {Console}
Read a line from standard input.
read_file
read_file : (String) -> String ! {FileSystem}
Read the contents of the file at path p.
read_file_bytes
read_file_bytes : (String) -> Buf ! {FileSystem}
Read the raw bytes of the file at path p as a byte buffer, with no UTF-8 interpretation. Data.Bytes.read_bytes wraps this as the Bytes-typed API.
file_exists
file_exists : (String) -> Bool ! {FileSystem}
True when a file exists at path p.
rand
rand : () -> Int ! {Random}
A random integer.
entropy
entropy : () -> Int ! {Entropy}
A fresh integer of real OS entropy. Non-reproducible, unlike the seeded rand; a replayable function may not use it.
getenv
getenv : (String) -> String ! {Env}
The value of environment variable s (empty when unset).
args_count
args_count : () -> Int ! {Env}
The number of command-line arguments.
arg
arg : (Int) -> String ! {Env}
The ith command-line argument.
run_io
run_io : forall e0 a. ((Unit) -> a ! {Console, Entropy, Env, FileSystem, IO, Output, Random, e0}) -> a ! {IO, e0}
The default world handler the entry point is wrapped in. Each capability effect is discharged by a tail-resumptive handler that resumes with the matching prim_* builtin, so the chain fuses to direct calls (no effect-op allocation), leaving only {IO | e}.
srange_go
srange_go : (Int, Int) -> Unit ! {Emit(Int)}
Helper for srange: emit lo, lo+1, ..., hi-1.
srange
srange : forall a. (Int, Int) -> (a) -> Unit ! {Emit(Int)}
A stream of the integers in [lo, hi), for the Emit combinators.
scollect(srange(1, 5))
[1, 2, 3, 4]
sof_go
sof_go : forall a. (List(a)) -> Unit ! {Emit(a)}
Helper for sof: emit each element of xs.
sof
sof : forall a b. (List(a)) -> (b) -> Unit ! {Emit(a)}
A stream of the elements of the list xs.
scollect(sof([9, 8, 7]))
[9, 8, 7]
enum_from_to
enum_from_to : (Int, Int) -> List(Int)
The ascending list [lo, lo+1, ..., hi] (empty when lo > hi). Backs the [a..z] list syntax.
enum_from_to(1, 5)
[1, 2, 3, 4, 5]
enum_seq
enum_seq : (Int, Int, Int) -> List(Int)
Helper for enum_from_then_to: the list from x by step up to hi.
enum_from_then_to
enum_from_then_to : (Int, Int, Int) -> List(Int)
The list from a, stepping by b - a, up to hi. Backs the [a,b..z] list syntax.
enum_from_then_to(1, 3, 9)
[1, 3, 5, 7, 9]
smap_go
smap_go : forall e0 a b c. ((Unit) -> a ! {Emit(b), e0}, (c) -> b ! {Emit(b), e0}) -> a ! {Emit(b), e0}
Helper for smap.
smap
smap : forall e1 a b c d. ((Unit) -> a ! {Emit(b), e1}, (c) -> b ! {Emit(b), e1}) -> (d) -> a ! {Emit(b), e1}
Map f over every element of a stream, fusing (no intermediate list).
scollect(smap(srange(1, 4), \(x) -> x * x))
[1, 4, 9]
skeep_go
skeep_go : forall e0 a b. ((Unit) -> a ! {Emit(b), e0}, (b) -> Bool ! {Emit(b), e0}) -> a ! {Emit(b), e0}
Helper for skeep.
skeep
skeep : forall e1 a b c. ((Unit) -> a ! {Emit(b), e1}, (b) -> Bool ! {Emit(b), e1}) -> (c) -> a ! {Emit(b), e1}
Keep only the stream elements satisfying p, fusing.
scollect(skeep(srange(1, 6), \(x) -> even(x)))
[2, 4]
stake_go
stake_go : forall e0 a b. ((Unit) -> a ! {Emit(b), e0}, Int) -> Unit ! {Emit(b), e0}
Helper for stake.
stake
stake : forall e1 a b c. ((Unit) -> b ! {Emit(a), e1}, Int) -> (c) -> Unit ! {Emit(a), e1}
The first n elements of a stream, stopping the producer early.
scollect(stake(srange(1, 100), 3))
[1, 2, 3]
sfold
sfold : forall e0 a b c. ((Unit) -> b ! {Emit(c), e0}, a, (a, c) -> a ! {e0}) -> a ! {e0}
Left-fold a stream with f from the initial accumulator z, fusing. The stream row is pinned to {Emit(a) | e} so the handler discharges Emit (leaving {e}); without the annotation Emit slips into a bare row variable and leaks past the fold into the caller’s row.
sfold(srange(1, 5), 0, \(acc, x) -> acc + x)
10
ssum
ssum : forall e0 a. ((Unit) -> a ! {Emit(Int), e0}) -> Int ! {e0}
Sum a stream of numbers.
ssum(srange(1, 5))
10
scollect
scollect : forall e0 a b. ((Unit) -> a ! {Emit(b), e0}) -> List(b) ! {e0}
Collect a stream into a list, in emission order.
scollect(srange(1, 4))
[1, 2, 3]
concat_map
concat_map : forall e0 a b. ((a) -> List(b) ! {e0}, List(a)) -> List(b) ! {e0}
Map f over xs and concatenate the resulting lists. Kept in Base (as well as Data.List) because optic-path desugaring synthesizes calls to it by bare name.
concat_map(\(x) -> [x, x], [1, 2])
[1, 1, 2, 2]
eprintln
eprintln : (String) -> Unit ! {IO}
Print s to standard error, followed by a newline.
eprintln("uh oh")
push_all
push_all : forall a. (Array(a), List(a)) -> Array(a)
Helper for array_of_list: push each element of xs onto arr.
array_of_list
array_of_list : forall a. (List(a)) -> Array(a)
Build a growable Array from a list.
array_to_list(array_of_list([1, 2, 3]))
[1, 2, 3]
concat_all
concat_all : (List(String)) -> String
Join a list of strings into one with a single allocation (an O(n) builder that replaces a right-nested chain of concat).
concat_all(["a", "b", "c"])
abc
fnv_from
fnv_from : (String, Int, U64) -> U64
Helper for str_hash: fold one byte into the running FNV-1a hash.
str_hash
str_hash : (String) -> U64
The FNV-1a 64-bit hash of a string (the U64 lane wraps, O(length)).
bucket_of
bucket_of : (String, Int) -> Int
Helper: the bucket index of key k in a table of n buckets.
hm_new
hm_new : forall a. () -> HashMap(a)
An empty hash map.
hm_size(hm_new())
0
assoc_get
assoc_get : forall a. (List((String, a)), String) -> Option(a)
Helper: look up k in an association-list bucket (matches pairs directly so it keeps working in a program that defines its own fst/snd).
hm_lookup
hm_lookup : forall a. (HashMap(a), String) -> Option(a)
The value bound to k as Some, or None.
hm_lookup(hm_insert(hm_new(), "a", 1), "a")
Some(1)
hm_member
hm_member : forall a. (HashMap(a), String) -> Bool
True when k is present.
hm_member(hm_from_list([("a", 1)]), "a")
true
hm_get_or
hm_get_or : forall a. (a, HashMap(a), String) -> a
The value bound to k, or the default d.
hm_get_or(0, hm_from_list([("a", 1)]), "b")
0
assoc_put
assoc_put : forall a. (List((String, a)), String, a) -> (List((String, a)), Int)
Helper for hm_put_raw: replace or add k in a bucket, returning the chain and 1 if the key was new (0 if it replaced a binding).
hm_put_raw
hm_put_raw : forall a. (HashMap(a), String, a) -> HashMap(a)
Helper for hm_insert: insert without resizing.
buckets_to_list
buckets_to_list : forall a. (Array(List(a)), Int, Int) -> List(a)
Helper for hm_to_list: concatenate buckets i..n.
hm_to_list
hm_to_list : forall a. (HashMap(a)) -> List((String, a))
The (key, value) pairs, in bucket order.
hm_to_list(hm_from_list([("a", 1)]))
[(a, 1)]
pair_keys
pair_keys : forall a b. (List((a, b))) -> List(a)
Helper: the keys of a list of pairs.
hm_keys
hm_keys : forall a. (HashMap(a)) -> List(String)
The keys of the map.
hm_keys(hm_from_list([("a", 1), ("b", 2)]))
[a, b]
hm_size
hm_size : forall a. (HashMap(a)) -> Int
The number of entries.
hm_size(hm_from_list([("a", 1), ("b", 2)]))
2
hm_reinsert
hm_reinsert : forall a. (HashMap(a), List((String, a))) -> HashMap(a)
Helper for hm_insert: re-insert every pair into a fresh table.
hm_insert
hm_insert : forall a. (HashMap(a), String, a) -> HashMap(a)
Insert or overwrite k, doubling the bucket count and rehashing once the load factor exceeds 1.
hm_lookup(hm_insert(hm_new(), "a", 1), "a")
Some(1)
hm_delete
hm_delete : forall a. (HashMap(a), String) -> HashMap(a)
Remove k (a no-op if absent).
hm_size(hm_delete(hm_from_list([("a", 1), ("b", 2)]), "a"))
1
assoc_del
assoc_del : forall a. (List((String, a)), String) -> (List((String, a)), Int)
Helper for hm_delete: remove k from a bucket, returning the chain and 1 if a binding was removed.
pair_values
pair_values : forall a b. (List((a, b))) -> List(b)
Helper: the values of a list of pairs.
hm_values
hm_values : forall a. (HashMap(a)) -> List(a)
The values of the map.
hm_values(hm_from_list([("a", 1), ("b", 2)]))
[1, 2]
hm_from_list_go
hm_from_list_go : forall a. (HashMap(a), List((String, a))) -> HashMap(a)
Helper for hm_from_list.
hm_from_list
hm_from_list : forall a. (List((String, a))) -> HashMap(a)
Build a hash map from (key, value) pairs (later pairs win).
hm_lookup(hm_from_list([("a", 1), ("b", 2)]), "b")
Some(2)
hm_adjust
hm_adjust : forall e0 a. ((a) -> a ! {e0}, HashMap(a), String) -> HashMap(a) ! {e0}
Apply f to the value at k if present, otherwise leave the map unchanged.
hm_get_or(0, hm_adjust(\(x) -> x + 100, hm_from_list([("a", 1)]), "a"), "a")
101
array_foldl_go
array_foldl_go : forall e0 a b. ((a, b) -> a ! {e0}, a, Array(b), Int) -> a ! {e0}
Helper for array_foldl.
array_foldl
array_foldl : forall e0 a b. ((a, b) -> a ! {e0}, a, Array(b)) -> a ! {e0}
Left-fold f over the elements of an array from acc.
array_foldl(\(a, x) -> a + x, 0, array_of_list([1, 2, 3]))
6
array_to_list_go
array_to_list_go : forall a. (Array(a), Int, List(a)) -> List(a)
Helper for array_to_list.
array_to_list
array_to_list : forall a. (Array(a)) -> List(a)
The elements of an array as a list, in order.
array_to_list(array_of_list([1, 2, 3]))
[1, 2, 3]
args_go
args_go : (Int, Int) -> List(String) ! {Env}
Helper for args.
args
args : () -> List(String) ! {Env}
The command-line arguments as a list.
int_cmp
int_cmp : (Int, Int) -> Int
Three-way comparison of two ints: -1, 0, or 1 (the Ord(Int) kernel).
int_cmp(3, 7)
-1
str_escape
str_escape : (String) -> String
Render s as a canonical double-quoted string literal, escaping the characters that would otherwise break the quoting. This is what show produces for a String; raw output (print/println of a message) is unquoted.
escape_body
escape_body : (String, Int, Int) -> String
escape_at
escape_at : (Int, String, Int) -> String
show_list_body
show_list_body : forall a. (List(a), Bool) -> String
insert_by_ord
insert_by_ord : forall a. (a, List(a)) -> List(a)
Insert x into an already-sorted list, keeping it sorted (Ord).
insert_by_ord(3, [1, 2, 4])
[1, 2, 3, 4]
merge_by_ord
merge_by_ord : forall a. (List(a), List(a)) -> List(a)
Stable merge of two already-sorted lists. The recursive Cons becomes a loop by tail-recursion-modulo-cons, and Perceus reuses the consumed cells.
merge_by_ord([1, 3], [2, 4])
[1, 2, 3, 4]
sort_by_ord
sort_by_ord : forall a. (List(a)) -> List(a)
The generic stable merge sort behind sort, for any Ord element type.
Control.Fresh
The Fresh effect: a deterministic monotonic name supply (gensym).
fresh yields the next integer from a counter the code never threads; the handler starts it at 0 (or an explicit start) and increments by one each call, so a run’s names are a pure function of the order they are requested. gensym(prefix) builds a fresh string identifier. Opt-in: not in Base.
Effects
Fresh
effect Fresh
fresh() : Int
Draw the next integer from the monotonic counter.
Functions and Values
run_fresh
run_fresh : forall e0 a. (() -> a ! {Control.Fresh.Fresh, e0}) -> a ! {e0}
Run action with a fresh-name counter starting at 0, discharging Fresh.
run_fresh_from
run_fresh_from : forall e0 a. (Int, () -> a ! {Control.Fresh.Fresh, e0}) -> a ! {e0}
Run action with the counter starting at start.
gensym
gensym : (String) -> String ! {Control.Fresh.Fresh}
A fresh string identifier: prefix followed by the next counter value.
Control.Layer
The children-and-rebuild interface a generic traversal runs on, and the collecting queries that ride it.
A Layer(a) is one node sort’s one-layer view: kids lists a node’s immediate same-sort children in a fixed order, and rebuild puts a new child list back in that same order. Everything generic (Control.Rewrite’s strategies, the queries here) is written against the pair, so a sort joins the library by supplying two functions and nothing else.
Both halves are pure structure, never effectful: the effects of a traversal belong to the rewrite being applied, so a layer stays a plain record and the row polymorphism lives one layer up.
Traversal order is fixed and documented everywhere it matters. Every walk here is root first, then children in kids order, and nothing consults a hash map, so the node sequence a query sees is a pure function of the tree.
Every query here terminates on a finite tree: each is structurally recursive through kids, and none of them looks at anything rebuild produced. That puts the one termination obligation on the layer itself, where it is checkable by reading two functions: kids must return strict subterms of its argument and must not manufacture nodes. The fueled combinators in Control.Rewrite exist for the loops that this discipline cannot rule out, the ones a rewrite rule creates by feeding its own output back in.
Types
Layer
type Layer(a) = Layer { kids: (a) -> List(a), rebuild: (a, List(a)) -> a }
One node sort’s one-layer view: its immediate children and the inverse that replaces them. rebuild(x, kids(x)) must return a node equal to x, and rebuild must fail closed (return x unchanged) when the replacement list does not match the node’s shape.
Functions and Values
plate_layer
plate_layer : forall a. () -> Control.Layer.Layer(a)
The layer a Plate instance supplies, so a sort that derived Plate joins every traversal here and in Control.Rewrite without a hand-written pair.
The two interfaces agree on the shape and disagree only on how they report a violation of it, and that disagreement is the whole of the translation: rebuild raises Fail on a replacement list that does not match the node, and a layer says the same thing by returning the node unchanged. Nothing else is adapted, because children is already the order kids promises.
lay_kids
lay_kids : forall a. (Control.Layer.Layer(a), a) -> List(a)
The immediate children of x, in the layer’s fixed order.
lay_rebuild
lay_rebuild : forall a. (Control.Layer.Layer(a), a, List(a)) -> a
x with its immediate children replaced by cs, in the layer’s order.
lay_descend
lay_descend : forall e0 a. (Control.Layer.Layer(a), (a) -> a ! {e0}, a) -> a ! {e0}
Apply f to each immediate child of x and rebuild. The one-layer map: it never recurses, so a traversal scheme built on it controls its own depth.
lay_universe
lay_universe : forall a. (Control.Layer.Layer(a), a) -> List(a)
Every node of the tree, root first, depth first in kids order.
lay_size
lay_size : forall a. (Control.Layer.Layer(a), a) -> Int
The number of nodes in the tree.
lay_depth
lay_depth : forall a. (Control.Layer.Layer(a), a) -> Int
The height of the tree: 1 at a leaf.
lay_collect
lay_collect : forall e0 a. (Control.Layer.Layer(a), (a) -> Bool ! {e0}, a) -> List(a) ! {e0}
Every node satisfying q, root first, depth first. The gather half of an analysis pass: one predicate replaces a hand-written collecting recursion.
lay_count_where
lay_count_where : forall e0 a. (Control.Layer.Layer(a), (a) -> Bool ! {e0}, a) -> Int ! {e0}
How many nodes satisfy q.
lay_any
lay_any : forall e0 a. (Control.Layer.Layer(a), (a) -> Bool ! {e0}, a) -> Bool ! {e0}
Whether any node satisfies q. Short-circuits: the first accepting node ends the walk, so a hit near the root costs nothing.
lay_summarize
lay_summarize : forall e0 a b. (Control.Layer.Layer(a), (a) -> b ! {e0}, (b, b) -> b ! {e0}, b, a) -> b ! {e0}
Fold a per-node summary over the whole tree: measure scores one node and combine merges two summaries, starting from unit. The caller supplies the combining operation, so one walk serves counting, maximizing, and collecting alike.
lay_index_where
lay_index_where : forall e0 a. (Control.Layer.Layer(a), (a) -> Bool ! {e0}, a) -> List(Int) ! {e0}
The preorder index of every node satisfying q. An index is a deterministic address into the tree: 0 is the root, and the numbering is fixed because kids is.
lay_at_index
lay_at_index : forall a. (Control.Layer.Layer(a), Int, a) -> Option(a)
The node at a preorder index, or None when the index is past the end.
lay_path_to
lay_path_to : forall a. (Control.Layer.Layer(a), Int, a) -> Option(List(Int))
The preorder index path from the root to the node at index i: the index of each ancestor, root first, ending at i itself. None when the index is past the end. The path is what a pass reports when it must point at a node without carrying the node.
lay_indexed
lay_indexed : forall a. (Control.Layer.Layer(a), a) -> List((Int, a))
Every node’s preorder index paired with the node, root first. The indexed walk an occurrence table is built from.
lay_edges
lay_edges : forall a. (Control.Layer.Layer(a), a) -> List((a, a))
The immediate children of every node, flattened: the one-layer relation of the whole tree as parent-child pairs in preorder. A dependency or occurrence graph is one map off this.
Control.Reader
The canonical Reader(r) effect: a read-only ambient environment.
ask pulls a value from a context the code never threads through its own signature; run_reader supplies it, so one function runs against any environment. local runs a sub-computation under a transformed environment. Opt-in: not in Base.
Effects
Reader
effect Reader(r)
ask() : r
Read the ambient environment of type r.
Functions and Values
run_reader
run_reader : forall e0 a b. (a, () -> b ! {Control.Reader.Reader(a), e0}) -> b ! {e0}
Run action with env as the ambient environment, discharging Reader(r).
asks
asks : forall a b. ((a) -> b) -> b ! {Control.Reader.Reader(a)}
Read a projection f of the environment.
local
local : forall e0 a b. ((a) -> a, () -> b ! {Control.Reader.Reader(a), Control.Reader.Reader(a), e0}) -> b ! {Control.Reader.Reader(a), e0}
Run action under the environment transformed by f. The transformed environment is scoped to action; the outer environment is unchanged.
Control.Rewrite
Strategy combinators: a pass as a composition of small local rules instead of a hand-written recursive match.
A rewrite is a partial function on nodes, (a) -> Option(a), where None means “this rule does not apply here”. That one convention is what makes left-biased choice, try, and a fixpoint expressible; a rule that always succeeded could not say where it declined, and a traversal could not tell a no-op from a hit. A strategy is an ordinary higher-order function over rewrites, so the whole layer is plain Prism and stays readable as such.
The row tail ! {| e} runs through every signature, so one rewrite works pure and effectful: a rule may draw fresh names from Control.Fresh, record refusals through Control.Validate, or read an environment through Control.Reader, and the pure case is the same code at the empty row. The traversal schemes never mention an effect; they thread whatever the rule brings.
Determinism is structural. Children are visited in the layer’s kids order, which for the surface tree is source order, so a rewrite’s node sequence is a pure function of the tree. The fixpoint closer takes explicit fuel and reports whether it converged, so a diverging rule set is a failed assertion rather than a hang.
Types
Rewrite
alias Rewrite(a, e) = (a) -> Option(a) ! {| e}
A partial rewrite on nodes of sort a: Some for a rule that applied here, None for one that declined.
The row variable e is the whole story on effects. It is open and it is threaded, never discharged: a rule that needs nothing instantiates it to the empty row and reads as a pure function, and a rule that draws fresh names or records refusals instantiates it to whatever it uses. No combinator below handles, masks, or requires an effect, so a caller never installs a handler to satisfy the traversal, only to satisfy its own rule.
RwFix
type RwFix(a) = RwFix { tree: a, steps: Int, converged: Bool }
The outcome of a fueled fixpoint: the tree it reached, how many steps it took, and whether it converged. converged is false exactly when the fuel ran out with the rewrite still applying, which is the only way a caller learns that a rule set does not terminate on this input.
Functions and Values
rw_id
rw_id : forall a. (a) -> Option(a)
The rewrite that accepts every node and changes nothing: the unit of rw_then and the right-hand side of rw_try. Never declines, always terminates.
rw_fail
rw_fail : forall a. (a) -> Option(a)
The rewrite that declines everywhere: the unit of rw_or_else. Always declines, always terminates.
rw_then
rw_then : forall e0 a. ((a) -> Option(a) ! {e0}, (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0}
Run s1, then s2 on its result. Declines if either declines, so a sequence is all-or-nothing and s2 never sees a node s1 refused. Terminates whenever both do.
rw_or_else
rw_or_else : forall e0 a. ((a) -> Option(a) ! {e0}, (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0}
Left-biased choice: run s1, and only if it declines run s2 on the original node, not on anything s1 produced. Declines only when both decline. The bias is the point; a rule list is an rw_or_else chain and the first matching rule wins, exactly as a hand-written match would. Terminates whenever both do.
rw_try
rw_try : forall e0 a. ((a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0}
Make a rewrite total: s where it applies, the node unchanged where it declines. The result never declines, which is what the traversal schemes want when a rule is meant to fire only in places. Terminates whenever s does.
rw_at
rw_at : forall e0 a. ((a) -> Bool ! {e0}, (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0}
Run s only at nodes satisfying q, declining everywhere else and declining wherever s itself declines. The cheap way to scope a rule without folding the test into the rule itself. Terminates whenever q and s do.
rw_where
rw_where : forall e0 a. ((a) -> Bool ! {e0}) -> (a) -> Option(a) ! {e0}
The rewrite that accepts exactly the nodes satisfying q, unchanged, and declines at the rest. Useful as the left half of an rw_then guard. Terminates whenever q does.
rw_apply
rw_apply : forall e0 a. ((a) -> Option(a) ! {e0}, a) -> a ! {e0}
Run a rewrite for its result, keeping x when it declines, so the decline becomes a no-op rather than a value the caller has to unwrap. The usual way to finish: a traversal wrapped in rw_try never declines, and this drops the Option. Terminates whenever s does.
rw_all
rw_all : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0}
Run s on every immediate child and rebuild. Declines if any child declines, and then declines as a whole: no partial rebuild is ever returned, so a failed child cannot leave a half-rewritten node. At a leaf there is nothing to decline, so it accepts unchanged, which is the base case every recursive scheme bottoms out on. One pass over the children, so it terminates whenever s does.
rw_one
rw_one : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0}
Run s on the leftmost child that accepts, leaving the others alone. Declines when no child accepts, so at a leaf it always declines. That decline is load-bearing: it is how the fixpoint schemes below learn there is no redex left. One pass over the children, so it terminates whenever s does.
rw_bottom_up
rw_bottom_up : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0} ! {e0}
Children first, then the node: rewrite every child with rw_bottom_up, then run s on the rebuilt node. Declines if s declines anywhere, at any depth, because rw_all and rw_then are both all-or-nothing, so the usual spelling is rw_bottom_up(p, rw_try(rule)) and the bare form is for a rule that really must fire at every node.
Terminates whenever s does. The recursion descends the input tree, and each of its nodes is visited exactly once, so nothing s produces is ever traversed. A rule that grows the tree is therefore safe here.
rw_top_down
rw_top_down : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0} ! {e0}
The node first, then children. Same all-or-nothing decline discipline as rw_bottom_up, and a node rewritten here is not revisited, so a rule that produces a shape it also matches fires only once per position; closing that over is what rw_outermost is for.
Termination is conditional, unlike rw_bottom_up. The children traversed are those of the rewritten node, so a rule that reproduces a matching node below itself descends forever. Where that is possible, use rw_bottom_up, or use rw_outermost, whose fuel bounds it.
rw_everywhere
rw_everywhere : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}, a) -> a ! {e0}
Run s everywhere it applies, bottom up, and keep the result. The common case: a total pass built from a partial rule. Cannot decline, because rw_try turns every refusal into “leave this node alone”; a rule that applies nowhere returns the tree unchanged. Terminates whenever s does.
rw_everywhere_td
rw_everywhere_td : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}, a) -> a ! {e0}
Run s everywhere it applies, top down, and keep the result. Cannot decline, on the same grounds as rw_everywhere, and carries rw_top_down’s conditional termination: a rule that rebuilds a matching node below itself does not terminate here.
rw_once_bottom_up
rw_once_bottom_up : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0} ! {e0}
The single leftmost-innermost application of s: try the children first, and only if none accepted try the node, so exactly one node changes. Declines when s applies nowhere in the tree, which is the termination test the fixpoint schemes read. Terminates whenever s does: one pass, no rewritten output is traversed.
rw_once_top_down
rw_once_top_down : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}) -> (a) -> Option(a) ! {e0} ! {e0}
The single outermost-leftmost application of s: try the node first, and only if it declined try the children, so exactly one node changes. Declines when s applies nowhere in the tree. Terminates whenever s does: the descent happens only along the declining path, so no rewritten node is revisited within one application.
rw_repeat
rw_repeat : forall e0 a. ((a) -> Option(a) ! {e0}, Int, a) -> Control.Rewrite.RwFix(a) ! {e0}
Apply s to its own output until it declines, at most fuel times.
Always terminates, whatever s does, and that is the whole reason it exists. The termination condition is explicit: stop when s declines, reporting converged = true and the number of applications; or stop when fuel applications have been made and s still accepts, reporting converged = false and the tree as it stood before the refused step. There is deliberately no unfueled closer, because a rewrite loop that may not terminate should be a value a test can assert on rather than a hang. Zero or negative fuel yields the input unchanged, converged only if s declines on it.
rw_innermost
rw_innermost : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}, Int, a) -> Control.Rewrite.RwFix(a) ! {e0}
Rewrite to a fixpoint innermost-first: repeat the single leftmost-innermost application until no rule applies. The normalizing order, so a rule that exposes a new redex below itself is picked up on the next step.
Always terminates, bounded by fuel steps. It converges exactly when s declines at every node of the tree it reaches; converged = false means the fuel ran out with a redex still present, and is the honest report that this rule set does not normalize this input in that budget. Never declines: the result is a record, and a rule that applies nowhere converges in zero steps.
rw_outermost
rw_outermost : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}, Int, a) -> Control.Rewrite.RwFix(a) ! {e0}
Rewrite to a fixpoint outermost-first: repeat the single outermost-leftmost application until no rule applies. The lazy order, which reaches a normal form for rules whose innermost order diverges, and the fueled way to run a rule that rw_top_down would descend into forever.
Always terminates, bounded by fuel steps, with the same convergence report as rw_innermost. Never declines.
rw_choose
rw_choose : forall e0 a. (List((a) -> Option(a) ! {e0})) -> (a) -> Option(a) ! {e0}
The first rule in the list that applies, run on the original node. Declines only when every rule declines, and an empty list is rw_fail. A rule table is an ordinary list, read in order, so priority is where the reader can see it. Terminates whenever every rule does; the list is finite and each rule is tried at most once.
rw_pipeline
rw_pipeline : forall e0 a. (List((a) -> Option(a) ! {e0})) -> (a) -> Option(a) ! {e0}
Run every rule in order, each on the previous result, keeping the node where a rule declines. Unlike rw_choose this is a pipeline, not a choice: all of them run. Never declines, so a stage’s refusal is invisible to the caller and an empty list is rw_id. Terminates whenever every rule does; each runs exactly once.
rw_lift
rw_lift : forall e0 a. ((a) -> a ! {e0}) -> (a) -> Option(a) ! {e0}
Lift a total node function into a rewrite that always accepts. The bridge for an existing (a) -> a helper. Never declines, which makes it unsuitable as a fixpoint body: rw_repeat would never see the decline it stops on. Use rw_lift_changed there. Terminates whenever f does.
rw_lift_changed
rw_lift_changed : forall e0 a. ((a) -> a ! {e0}, (a, a) -> Bool ! {e0}) -> (a) -> Option(a) ! {e0}
Lift a total node function into a rewrite that accepts only where it changed something, given an equality. Declines exactly where f is the identity, which is what turns a normalizer into a rule a fixpoint can close over: the loop stops when the function stops moving. Terminates whenever f and same do.
rw_steps
rw_steps : forall e0 a. (Control.Layer.Layer(a), (a) -> Option(a) ! {e0}, a) -> List(a) ! {e0}
Every result of applying s at each node of the tree in turn, one rewritten tree per node that accepted, in preorder. The enumeration a search or a mutation-testing pass wants: not one normal form, but every single-step neighbour.
Never declines; a rule that applies nowhere yields the empty list, and a decline at a node simply contributes nothing there. Terminates whenever s does, visiting each node of the input tree exactly once and never traversing a rewritten result.
Control.State
The canonical State(s) effect: a threaded piece of mutable-looking state, interpreted by parameter passing.
get reads the current state, put overwrites it; a computation performs them without ever naming a state value in its own signature. run_state discharges the effect, threading init through and handing back the result paired with the final state. Because the handler is the only interpreter, the same ops can be re-read (bounded, logged) without touching the producer. Opt-in: not in Base.
Effects
State
effect State(s)
get() : s
put(s) : Unit
Read (get) and overwrite (put) a threaded state of type s.
Functions and Values
run_state
run_state : forall e0 a b. (a, () -> b ! {Control.State.State(a), e0}) -> (b, a) ! {e0}
Run action, threading init as the initial state; returns (result, final_state). The handler makes the block a state transformer s -> (a, s) and applies it to init.
eval_state
eval_state : forall e0 a b. (a, () -> b ! {Control.State.State(a), e0}) -> b ! {e0}
Run action for its result only, discarding the final state.
exec_state
exec_state : forall e0 a b. (a, () -> b ! {Control.State.State(a), e0}) -> a ! {e0}
Run action for its final state only, discarding the result.
modify
modify : forall a. ((a) -> a) -> Unit ! {Control.State.State(a)}
Apply f to the current state, storing the result.
state
state : forall a b. ((a) -> (b, a)) -> b ! {Control.State.State(a)}
Run one combined read-and-write step: f maps the current state to a result and the next state, the result is returned and the state stored.
gets
gets : forall a b. ((a) -> b) -> b ! {Control.State.State(a)}
Read a projection f of the current state.
Control.Validate
Validation as an algebraic effect.
Validate(e) is the computation-oriented companion to Data.Validation.Validation(e, a). A fatal refute(error) abandons the current validation branch. A non-fatal dispute(error) records an error but lets that branch continue. run_validate reports all recorded errors in encounter order, and tolerate turns fatal errors in a nested branch into a recoverable None so validation can continue elsewhere.
Unlike Haskell’s ValidateT, Prism does not give ordinary effectful sequencing a separate accumulating Applicative interpretation. Independent branches are run explicitly and wrapped in tolerate when validation should continue after a refutation; pure, already-computed validations can instead be combined with Data.Validation.validate2. Opt-in: not in Base.
Effects
Validate
effect Validate(e)
never refute(e) : a
dispute(e) : Unit
Errors raised while validating values of type e.
Functions and Values
run_validate
run_validate : forall e0 a b. (() -> a ! {Control.Validate.Validate(b), e0}) -> Data.Validation.Validation(b, a) ! {e0}
Run a validation computation. Any fatal or non-fatal errors make the result Invalid; only a completed computation with no errors is Valid.
exec_validate
exec_validate : forall e0 a b. (() -> a ! {Control.Validate.Validate(b), e0}) -> List(b) ! {e0}
Run a validation computation and return its errors, or Nil on success.
disputes
disputes : forall a. (List(a)) -> Unit ! {Control.Validate.Validate(a)}
Raise each error as a non-fatal dispute, in list order.
tolerate
tolerate : forall e0 a b. (() -> a ! {Control.Validate.Validate(b), Control.Validate.Validate(b), e0}) -> Option(a) ! {Control.Validate.Validate(b), e0}
Make fatal errors in action recoverable. A refuted branch becomes None; a branch that completes returns Some(value), even if it raised disputes. All captured errors are re-raised as disputes in the outer validation, so the final run still fails unless another handler consumes them.
Control.Writer
The canonical Writer(w) effect: accumulate output on the side.
tell(m) appends m to a log the producer never sees; run_writer returns the computation’s result paired with the whole log in emission order. The handler threads the growing log as a parameter (tail-resumptive, so it fuses), so nothing shared or mutable is involved. eval_writer keeps only the result, exec_writer only the log, and listen/censor observe or rewrite an inner computation’s log while staying inside the effect. Opt-in: not in Base.
Effects
Writer
effect Writer(w)
tell(w) : Unit
Append one item of type w to the accumulated log.
Functions and Values
run_writer
run_writer : forall e0 a b. (() -> a ! {Control.Writer.Writer(b), e0}) -> (a, List(b)) ! {e0}
Run action, collecting every tell into a log, and return the computation’s result paired with that log in emission order.
eval_writer
eval_writer : forall e0 a b. (() -> a ! {Control.Writer.Writer(b), e0}) -> a ! {e0}
Run action and keep only its result, discarding the log.
exec_writer
exec_writer : forall e0 a b. (() -> a ! {Control.Writer.Writer(b), e0}) -> List(b) ! {e0}
Run action and keep only the log, discarding the result.
listen
listen : forall e0 a b. (() -> a ! {Control.Writer.Writer(b), Control.Writer.Writer(b), e0}) -> (a, List(b)) ! {Control.Writer.Writer(b), e0}
Run action, re-emit its log unchanged, and return the result paired with that log, so an outer writer both observes and keeps the inner output.
censor
censor : forall e0 a b. ((List(a)) -> List(a), () -> b ! {Control.Writer.Writer(a), Control.Writer.Writer(a), e0}) -> b ! {Control.Writer.Writer(a), e0}
Run action, rewrite its whole log with f, and re-emit the rewritten log, returning the result. The rewrite sees the log in emission order.
tells
tells : forall a. (List(a)) -> Unit ! {Control.Writer.Writer(a)}
Emit every item of log in order.
Data.Bind
Binders, the two nameless coordinate systems, and the canonical rendering that makes alpha-equivalent terms identical.
Names are for working, indices are for identity. A checker, an interpreter and an error message all want the name the programmer wrote; only a hash and a wire format want the name gone. This module keeps both and puts the boundary between them in one place.
The two coordinate systems are branded so they cannot be mixed up. An Index is the outward distance from a use site to its binder, so it survives adding binders at the root and it is what a nameless rendering emits. A Level is the inward distance from the root to a binder, so it survives going deeper and it is what an environment addresses. bind_index_at and bind_level_at are the only conversions between them, and at a fixed depth each is the other’s inverse. Nothing here accepts a bare Int where one of the two is meant. Opt-in: not in Base.
Types
Index
type Index = MkIndex(Int) deriving (Eq, Ord, Show)
The outward distance from a use site to the binder it refers to: bind_index(0) is the nearest enclosing binder.
The constructor is spelled MkIndex rather than Index on purpose: constructors share one flat namespace with every module in the program, so a constructor named for its type would rebind any other Index in scope and the failure would surface far from the declaration.
Level
type Level = MkLevel(Int) deriving (Eq, Ord, Show)
The inward distance from the root to a binder: bind_level(0) is the outermost binder in scope. Constructor named MkLevel for the same reason Index uses MkIndex.
BindEnv
type BindEnv(a) = BindEnv { slots: List((String, a)) }
The binders in scope, innermost first, each with a name and a payload.
Shadowing is by position, not by rewriting: pushing a name that is already in scope hides the older slot from bind_index_of while leaving it reachable by its own index, which is what an interpreter walking a closure environment needs.
Nameless
type Nameless
= NTok(String)
| NLit(String)
| NRef(String)
| NScope(List(String), Nameless)
| NSeq(List(Nameless))
deriving (Eq, Show)
A rendering skeleton that knows where the binders are.
The fold over it is the whole point: a term’s own traversal decides which tokens to emit and which names it binds, and this decides how a reference is spelled. Because a bound reference is spelled by outward distance, two terms that differ only in the names of their binders render to the same bytes.
Functions and Values
bind_index
bind_index : (Int) -> Data.Bind.Index
Brand a raw outward distance as an Index. One of the two ways in.
bind_level
bind_level : (Int) -> Data.Bind.Level
Brand a raw inward distance as a Level. One of the two ways in.
bind_index_int
bind_index_int : (Data.Bind.Index) -> Int
The raw distance carried by an Index. One of the two ways out.
bind_level_int
bind_level_int : (Data.Bind.Level) -> Int
The raw distance carried by a Level. One of the two ways out.
bind_index_at
bind_index_at : (Int, Data.Bind.Level) -> Data.Bind.Index
The index a use site under depth binders must write to reach level l.
This and bind_level_at are the module’s only conversions, and they are the same reflection, so at a fixed depth each undoes the other. Both are undefined outside 0 <= distance < depth; a caller that might be out of scope should ask the environment instead.
bind_index_int(bind_index_at(3, bind_level(0)))
2
bind_level_at
bind_level_at : (Int, Data.Bind.Index) -> Data.Bind.Level
The level that a use site under depth binders reaches by writing index i.
bind_level_int(bind_level_at(3, bind_index_at(3, bind_level(2))))
2
bind_empty
bind_empty : forall a. Data.Bind.BindEnv(a)
No binders in scope.
bind_push
bind_push : forall a. (Data.Bind.BindEnv(a), String, a) -> Data.Bind.BindEnv(a)
Push one binder, which becomes index 0.
bind_push_all
bind_push_all : forall a. (Data.Bind.BindEnv(a), List((String, a))) -> Data.Bind.BindEnv(a)
Push several binders at once, leftmost outermost, the way a multi-parameter binder brings its parameters into scope.
bind_name_at(bind_push_all(bind_empty, [("x", 1), ("y", 2)]), bind_index(1))
Some(x)
bind_push_names
bind_push_names : (Data.Bind.BindEnv(String), List(String)) -> Data.Bind.BindEnv(String)
Push binders that carry no payload beyond their own name.
bind_depth
bind_depth : forall a. (Data.Bind.BindEnv(a)) -> Int
How many binders are in scope. A use site here writes indices below this and levels below this.
bind_names
bind_names : forall a. (Data.Bind.BindEnv(a)) -> List(String)
Every name in scope, innermost first, with shadowed names still listed at their own index.
bind_drop
bind_drop : forall a. (Data.Bind.BindEnv(a), Int) -> Data.Bind.BindEnv(a)
Drop the n innermost binders, leaving what was in scope before them.
bind_index_of
bind_index_of : forall a. (Data.Bind.BindEnv(a), String) -> Option(Data.Bind.Index)
The index of the innermost binder named name, or None when the name is free here.
bind_level_of
bind_level_of : forall a. (Data.Bind.BindEnv(a), String) -> Option(Data.Bind.Level)
The level of the innermost binder named name, or None when the name is free here.
bind_slot_at
bind_slot_at : forall a. (Data.Bind.BindEnv(a), Data.Bind.Index) -> Option((String, a))
The binder an index points at, name and payload, or None when the index escapes the environment.
bind_at
bind_at : forall a. (Data.Bind.BindEnv(a), Data.Bind.Index) -> Option(a)
The payload an index points at.
bind_name_at
bind_name_at : forall a. (Data.Bind.BindEnv(a), Data.Bind.Index) -> Option(String)
The name an index points at, which is what an error message wants back.
bind_at_level
bind_at_level : forall a. (Data.Bind.BindEnv(a), Data.Bind.Level) -> Option(a)
The payload a level points at.
bind_name_at_level
bind_name_at_level : forall a. (Data.Bind.BindEnv(a), Data.Bind.Level) -> Option(String)
The name a level points at.
bind_token
bind_token : (String) -> String
A token committed with its byte length, so no token can be confused with the concatenation of its neighbours.
bind_token("add")
3:add
bind_nameless
bind_nameless : ((String) -> String, Data.Bind.Nameless) -> String
Render a skeleton with binder references as outward distance and free names spelled by free.
A bound reference becomes b followed by its index. A free name is handed to free, which is where a caller substitutes whatever identity it has for a name it does not own: a content hash, a slot in the recursive group being hashed, or the name itself.
bind_nameless(\(x) -> x, NScope(["a"], NSeq([NTok("lam"), NRef("a")])))
3:lamb0
bind_nameless_show
bind_nameless_show : (Data.Bind.Nameless) -> String
Render with free names spelled as length-prefixed tokens under an f tag.
This is the rendering to compare two terms with when nothing outside them matters, which is exactly alpha-equivalence.
bind_alpha_eq
bind_alpha_eq : (Data.Bind.Nameless, Data.Bind.Nameless) -> Bool
True when two skeletons differ only in the names of their bound variables.
bind_alpha_eq(
NScope(["a"], NRef("a")),
NScope(["b"], NRef("b")),
)
true
Data.Bytes
Byte strings: the String/Bytes boundary, and the hex and base64 codecs.
The Bytes type and its representation seam (wire_len, wire_cat, bytes_of_buf, bytes_buf) live in Wire, over the unboxed buffer runtime object (Buf, runtime/prism_buffer.c); this module is the utility layer on top: the length/index/slice/push vocabulary, the UTF-8 conversion seam, the two ASCII encodings, and the byte-level filesystem seam. The utility layer is pure and total, so it threads identically on both backends and participates in the content-addressed story; the two filesystem functions are the deliberate exception (see below).
The String/Bytes boundary
A String is proof-carrying valid UTF-8, so the two directions are asymmetric. string_to_bytes is total: a string’s bytes are always well-formed. Its inverse bytes_to_string must validate, and reports failure honestly as None on ill-formed input rather than lossily repairing it, so a round trip through Bytes never silently mutates a value. Both backends validate identically (the native runtime’s buf_utf8_valid and the interpreter’s UTF-8 check agree byte-for-byte), so the boundary is deterministic.
The encodings
Hex and base64 are written in pure Prism over the buffer builders rather than as runtime primitives: they are O(n) either way and staying in the library keeps both backends in exact agreement for free (no separate C implementation to hold byte-identical). Encoding produces a String of ASCII; decoding validates its input and returns None on any stray character, odd hex length, or malformed base64 group.
Filesystem bytes
read_bytes and write_bytes are the raw-byte counterparts of Base’s text read_file/write_file, reading and writing a file’s exact bytes with no UTF-8 interpretation. They stay inside the determinism contract: read_bytes is a FileSystem capability of its own (fs_read_bytes), so its input is captured in the replay trace exactly like a text read; write_bytes is an off-platform output that is not part of the trace, exactly as text writes already are. Bytes are never routed through String (which is proof-carrying UTF-8 and would replace an ill-formed byte with U+FFFD), so an embedded NUL or a lone 0xFF survives a round trip verbatim.
Functions and Values
bytes_length
bytes_length : (Wire.Bytes) -> Int
The number of bytes in a Bytes.
bytes_length(string_to_bytes("Hello"))
5
bytes_index
bytes_index : (Wire.Bytes, Int) -> Int
The byte at index i (0-based). Out of range traps, like array indexing.
bytes_index(string_to_bytes("ABC"), 0)
65
bytes_empty
bytes_empty : Wire.Bytes
The empty Bytes.
bytes_push
bytes_push : (Wire.Bytes, Int) -> Wire.Bytes
Append one byte (masked into 0..255) to the end of a Bytes. Threaded linearly this is the byte-string builder; a uniquely owned buffer is extended in place (FBIP), a shared one copied.
bytes_to_string(bytes_push(bytes_empty, 65))
Some(A)
bytes_slice
bytes_slice : (Wire.Bytes, Int, Int) -> Wire.Bytes
The len bytes starting at start (0-based), as a fresh Bytes. start and len are clamped to the bounds, so an over-long or out-of-range window yields the in-range remainder rather than trapping.
bytes_to_string(bytes_slice(string_to_bytes("hello"), 1, 3))
Some(ell)
bytes_concat
bytes_concat : (Wire.Bytes, Wire.Bytes) -> Wire.Bytes
Concatenate two byte strings.
bytes_to_string(bytes_concat(string_to_bytes("foo"), string_to_bytes("bar")))
Some(foobar)
bytes_eq
bytes_eq : (Wire.Bytes, Wire.Bytes) -> Bool
Structural equality of two byte strings.
bytes_eq(string_to_bytes("a"), string_to_bytes("a"))
true
bytes_compare
bytes_compare : (Wire.Bytes, Wire.Bytes) -> Int
Lexicographic comparison (-1/0/1) of two byte strings.
bytes_compare(string_to_bytes("a"), string_to_bytes("b"))
-1
bytes_hash
bytes_hash : (Wire.Bytes) -> String
The blake3 content hash of a byte string as lowercase hex, byte-identical to a String’s blake3 over the same bytes.
string_to_bytes
string_to_bytes : (String) -> Wire.Bytes
Total: a string’s raw UTF-8 bytes as a Bytes.
hex_encode(string_to_bytes("Hi"))
4869
bytes_to_string
bytes_to_string : (Wire.Bytes) -> Option(String)
Validate the bytes as UTF-8 and, when well-formed, recover the String; None on any ill-formed sequence. The single deterministic conversion seam back from Bytes to String.
bytes_to_string(string_to_bytes("hey"))
Some(hey)
hex_encode
hex_encode : (Wire.Bytes) -> String
Lowercase hex encoding: two hex digits per byte.
hex_encode(string_to_bytes("Hi"))
4869
hex_decode
hex_decode : (String) -> Option(Wire.Bytes)
Decode a hex string to bytes, or None on an odd length or a non-hex character. Upper and lower case digits are both accepted.
map_option(bytes_length, hex_decode("4869"))
Some(2)
base64_encode
base64_encode : (Wire.Bytes) -> String
Standard base64 encoding with = padding.
base64_encode(string_to_bytes("Hi"))
SGk=
base64_decode
base64_decode : (String) -> Option(Wire.Bytes)
Decode standard base64 (with = padding) to bytes, or None on a length that is not a multiple of four, a stray character, or misplaced padding.
map_option(bytes_length, base64_decode("SGk="))
Some(2)
read_bytes
read_bytes : (String) -> Wire.Bytes ! {FileSystem}
Read the file at path as raw bytes. Carries the FileSystem capability (the fs_read_bytes op the default world handler discharges, via Base’s read_file_bytes wrapper) and is replay-recorded like read_file, so a recorded run reproduces the same bytes.
bytes_length(read_bytes("data.bin"))
write_bytes
write_bytes : (String, Wire.Bytes) -> Result(Unit, String) ! {IO}
Write bs verbatim to the file at path, returning Ok(()) on success or Err(msg) on failure. The write is the same off-platform output as write_file, over the raw byte buffer so no byte is reinterpreted.
write_bytes("out.bin", string_to_bytes("hi"))
Data.Char
ASCII character classification.
Operates on the byte/codepoint returned by byte_at/char_at. Base includes this module.
Functions and Values
is_digit
is_digit : (Int) -> Bool
True for an ASCII decimal digit (0-9).
is_digit(char_at("7", 0))
true
is_upper
is_upper : (Int) -> Bool
True for an ASCII upper-case letter (A-Z).
is_upper(char_at("A", 0))
true
is_lower
is_lower : (Int) -> Bool
True for an ASCII lower-case letter (a-z).
is_alpha
is_alpha : (Int) -> Bool
True for an ASCII letter.
is_alpha(char_at("z", 0))
true
is_alnum
is_alnum : (Int) -> Bool
True for an ASCII letter or digit.
is_space
is_space : (Int) -> Bool
True for ASCII whitespace (space, tab, newline, or carriage return).
is_space(char_at(" ", 0))
true
to_lower_c
to_lower_c : (Int) -> Int
Lower-case an ASCII letter; any other codepoint is returned unchanged.
chr(to_lower_c(char_at("A", 0)))
97
to_upper_c
to_upper_c : (Int) -> Int
Upper-case an ASCII letter; any other codepoint is returned unchanged.
chr(to_upper_c(char_at("a", 0)))
65
Data.Checked
Safe arithmetic families over the machine-integer lanes.
The raw operators (+/-/* on I64/U64, and integer //%) keep their pinned defaults: fixed-width addition, subtraction, and multiplication wrap two’s-complement, negating I64 min wraps back to I64 min, division truncates toward zero with the remainder taking the dividend’s sign, and both fault on a zero divisor. This module layers the four Rust-style safe families on top of those defaults, through the one Checked class:
checked_*returnOption,Noneexactly on overflow or a zero divisor; -saturating_*clamp to the lane bound the overflow crossed; -wrapping_*name the wrap semantics the raw operators already have; -overflowing_*return the wrapped result paired with an overflow flag.
Checked sits beside the numeric tower (Num/Div), not above or below it: it takes no superclass and defines no raw arithmetic of its own. The anchoring law is that wrapping_add/wrapping_sub/wrapping_mul/wrapping_neg agree exactly with the fixed-width lane’s raw +/-/* and unary -, so once the Num instances land they and these wrapping_* are the same function by construction. wrapping_neg on U64 is wrapping two’s-complement, matching the other U64 operations (wrapping_neg(0) is 0, wrapping_neg(x) is U64_MAX - x + 1 otherwise).
Overflow is detected exactly: each fixed-width operand is widened to the unbounded Int lane (int_of_i64/int_of_u64), the operation runs there without loss, and the exact result is range-checked against the lane bounds before being narrowed back (to_i64/to_u64, which wrap to the low 64 bits). The three families that share an operation are all derived from that one exact Int result, so they agree by construction. The Int instance is the degenerate case: unbounded, so wrap and saturate are the identity, checked arithmetic is total except for a zero divisor, and overflow never fires.
Laws, tested on both backends over the lane boundaries:
checked_op(x, y) == Some(wrapping_op(x, y))when no overflow occurred, andNoneotherwise; -overflowing_op(x, y) == (wrapping_op(x, y), flag)withflagtrue iffchecked_op(x, y)isNone; -saturating_op(x, y)equals the wrapped result when no overflow occurred, and otherwise the bound that was crossed (I64min/max,0,U64max).
Instances
checkedI64
instance checkedI64 : Checked(I64)
checkedU64
instance checkedU64 : Checked(U64)
checkedInt
instance checkedInt : Checked(Int)
Functions and Values
int_to_i64
int_to_i64 : (Int) -> Option(I64)
Narrow an unbounded Int to I64, or None when it falls outside the I64 range. The inverse widening is the builtin int_of_i64, which is total.
(int_to_i64(42), int_to_i64(2 ^ 63))
(Some(42), None)
int_to_u64
int_to_u64 : (Int) -> Option(U64)
Narrow an unbounded Int to U64, or None when it falls outside the U64 range. The inverse widening is the builtin int_of_u64, which is total.
(int_to_u64(42), int_to_u64(0 - 1))
(Some(42), None)
Data.Fixpoint
Least fixed points over a join-semilattice, solved by worklist.
This is the iteration half of the substrate the compiler analyzes itself with, mirrored into Prism the way Data.Graph mirrors the components half. The compiler’s own fixpoint solves for the least x above a seed and closed under a step, over a finite map of sets, by recomputing every key each round until no key grows. The same shape is here, driven by a worklist instead of rounds, and generalized from “a set” to any carrier with a Semilattice instance. A pass that propagates latent effects along a call graph, an occurrence count, or a liveness set is the same program three times: a per-node contribution, a join, and a dependency relation saying who must be recomputed when a node moves.
Determinism. The node set is the seed’s key set, taken in ascending Ord(k) order; the dependency relation is reversed once through Data.Graph, whose successor lists are ascending and duplicate-free; and a node is appended to the queue only when it is not already waiting. The queue is therefore a pure function of the two input maps, and a map is a pure function of its bindings, never of insertion order. Two callers who build the same relation from differently ordered lists run the same iteration, not merely reach the same answer.
Termination. Every update joins into the previous value (fix_least never replaces, it accumulates), so a node’s value only ever ascends, and a node is re-queued only when its value strictly ascended. On a carrier of finite height the chain stabilizes, no node is re-queued, and the queue drains. Two things break that argument, and neither is checkable here: a carrier of unbounded height (a Map that gains a fresh key every visit), and a lat_join/lat_leq pair that disagree, which reports a change forever. So the loop is bounded: it consumes one unit of budget per visit and calls fail() when the budget runs out, rather than spinning. fix_budget is the default, and fix_least_within takes the budget explicitly for a carrier taller than that default assumes. Opt-in: not in Base.
Type Classes
Semilattice
class Semilattice(a)
lat_bottom : () -> a
lat_join : (a, a) -> a
lat_leq : (a, a) -> Bool
A carrier ordered by a least upper bound, with a least element: everything a fixpoint needs to know about the values it is solving for.
lat_join is the least upper bound, lat_bottom its identity, and lat_leq the partial order the join induces. The laws, for all x, y, z:
- associative:
lat_join(x, lat_join(y, z))andlat_join(lat_join(x, y), z)- commutative:lat_join(x, y)andlat_join(y, x)- idempotent:lat_join(x, x)andx- identity:lat_join(lat_bottom(), x)andx- order:lat_leq(x, y)is true exactly whenlat_join(x, y)andyagree
The equality every law is stated up to is lat_equiv, the equivalence the order induces, rather than structural equality: Map and Option carriers have no Eq instance to state it with, and two values at the same point of the order are interchangeable to every consumer here.
Instance resolution keys on the head type constructor, so a carrier admits exactly one instance: there is no second, set-specific Map instance beside the one below, and none is needed, because that one already is set union at Map(k, Unit), which is how Data.Set spells a set. The same rule is why Int and List have no instance: max on Int is a join with no identity (Int has no least element), and a list admits several defensible joins (union, pointwise, concatenation) with nothing in the type to choose between them. A program that wants one declares it on its own type.
Instances
latUnit
instance latUnit : Semilattice(Unit)
The one-point lattice. Trivial on its own; it is the payload that turns the map instance into set union, since there a key’s presence is the information and its value carries none.
latBool
instance latBool : Semilattice(Bool)
Disjunction, ordered false below true: the carrier a reachability or “is this ever called” pass accumulates in.
latOption
instance latOption : Semilattice(Option(a))
The lifted lattice: None strictly below every Some, and two Somes joined under the payload’s own order. None is genuinely below Some of bottom, so “absent” and “present and empty” stay distinguishable, which is what a “has this node been reached at all” question needs.
latPair
instance latPair : Semilattice((a, b))
The product lattice: componentwise join, componentwise order. Two analyses run as one pass by pairing their carriers.
latMap
instance latMap : Semilattice(Map(k, v, ord))
The partial-map lattice: the empty map is bottom, an absent key is strictly below any present one, and two present keys join under the payload’s order. At Unit that is exactly set union over Data.Set (presence is the only information a key carries); at a nested map it is the map of sets the compiler’s own fixpoint is specialized to.
Functions and Values
lat_joins
lat_joins : forall a. (List(a)) -> a
The join of a list, bottom-first. The combining step a transfer function takes over its dependencies’ values.
lat_joins([false, true, false])
true
lat_equiv
lat_equiv : forall a. (a, a) -> Bool
Whether two values sit at the same point of the order. This is the equality the laws are stated up to, and the only one available on a carrier with no Eq instance.
lat_equiv(map_insert(1, (), map_empty), map_insert(1, (), map_empty))
true
fix_at
fix_at : forall a b c. (Map(b, c, a), b) -> c
The value assigned to key, or bottom when the assignment says nothing about it. A transfer function reads its dependencies through this rather than matching on map_lookup, so an unmentioned node reads as the least element instead of an Option the caller has to decide about.
fix_budget
fix_budget : forall a b c d. (Map(c, d, a), Map(c, List(c), b)) -> Int
The default visit budget: (n + 1) * (n + e + 1) for n nodes and e dependency edges. It bounds the visits a solve over a carrier of height at most n can take, which covers the archetypal carrier (a set drawn from the node set itself) with room to spare. A taller carrier belongs in fix_least_within with a budget the caller can justify.
fix_least
fix_least : forall e0 a b c d. (Map(c, d, a), Map(c, List(c), b), (c, Map(c, d, a)) -> d ! {Fail, e0}) -> Map(c, d, a) ! {Fail, e0}
The least assignment above seed closed under step, by worklist.
seed’s keys are the node set, and the solution has exactly those keys. uses is the dependency relation, mapping a node to the nodes it reads; step(key, current) is the transfer function, returning key’s contribution under the current assignment. The result at a node is the join of its seed value and every contribution step made for it.
Two conditions are the caller’s to keep, and the solver reports neither. step(key, current) may read current only at key itself and at the nodes uses lists for key, since those are the only changes that re-queue it; a transfer function that reads further gets an assignment that is closed with respect to the relation it declared and no other. And step must be monotone in current, or the result is merely some post-fixpoint rather than the least one. Neither slip can spin the solver, because the update accumulates; the budget is what covers the two failures that can.
fix_least(
map_from_list([("a", false), ("b", true)]),
map_from_list([("a", ["b"])]),
\(_key, cur) -> fix_at(cur, "b"),
).map_to_list()
[(a, true), (b, true)]
fix_least_within
fix_least_within : forall e0 a b c d. (Int, Map(c, d, a), Map(c, List(c), b), (c, Map(c, d, a)) -> d ! {Fail, e0}) -> Map(c, d, a) ! {Fail, e0}
fix_least with an explicit visit budget. fail() when the budget is exhausted: the solve is abandoned rather than reported at whatever assignment it had reached, since a partial answer to a least-fixpoint question is a wrong answer, not an approximate one.
fix_propagate
fix_propagate : forall a b c d e. (Map(d, e, a), Map(d, List(d), b)) -> Map(d, e, c) ! {Fail}
The transitive closure of a per-node contribution along a dependency relation: the least x with x[k] the join of own[k] and every x[j] for j in uses[k].
This is what the compiler’s own fixpoint is called for every time, with own the operations a function performs itself and uses its callees, and it is the reduction an occurrence or liveness pass makes: contribution, join, relation. The node set is every key of own together with every node the relation mentions, so a callee that contributes nothing itself still gets an answer.
map(
set_to_list,
map_values(
fix_propagate(
map_from_list([("f", set_from_list(["A"])), ("g", set_from_list(["B"]))]),
map_from_list([("f", ["g"]), ("g", ["f"])]),
),
),
)
[[A, B], [A, B]]
Data.FlatArray
Flat, unboxed-element arrays: one typed surface over the raw-word buffers.
A FlatArray(a) stores its elements as raw 8-byte words in a single contiguous buffer (FloatBuf for Float, IntBuf for I64), so the storage carries no per-element heap cells; only reading an element boxes the scalar it returns. The element types are exactly the fixed-width lanes whose bit pattern fills one word, chosen by the FlatElem instance in scope, so an unsupported element type is a missing-instance error at compile time rather than a runtime representation fault.
The buffers underneath follow the array’s rc==1 in-place / shared-copy discipline, so a uniquely owned flat array updates in place and a shared one copies on write: value semantics, identical on both backends.
Types
FlatArray
type FlatArray(a) = FloatArr(FloatBuf) | IntArr(IntBuf)
A dense array of unboxed one-word elements. The payload variant is chosen by the element’s FlatElem instance; the phantom parameter keeps the two payloads from ever mixing at the type level.
Type Classes
FlatElem
class FlatElem(a)
fa_new : (Int, a) -> FlatArray(a)
fa_get : (FlatArray(a), Int) -> a ! {Fail}
fa_set : (FlatArray(a), Int, a) -> FlatArray(a) ! {Fail}
The element contract: how a one-word scalar enters and leaves the flat storage. Instances exist for Float and I64.
fa_get(fa_set(fa_new(3, 0.0), 1, 9.0), 1)
9
Instances
flatFloat
instance flatFloat : FlatElem(Float)
flatI64
instance flatI64 : FlatElem(I64)
Functions and Values
fa_len
fa_len : forall a. (Data.FlatArray.FlatArray(a)) -> Int
The element count, independent of the element type.
fa_len(fa_new(3, 0.0))
3
Data.Foldable
Generic operations over any Foldable container.
Each function is a constrained free function, not a class default method: it is written once against the Foldable(f) class methods and works for every instance (List, Option, and any future container) without a per-type copy. Base includes this module, so these names are in scope unqualified everywhere and subsume the old List-only sum/length/etc.
The folds are strict, so the short-circuiting versions (all, any, find, elem) still visit every element; for a pure predicate the result is identical to a left-to-right search. Every aggregation rides fold_l, which instances implement tail recursively, so a large container folds in constant stack on the native backend; only to_list uses fold_r, to build in order. Arithmetic has no Num class, so sum and product are fixed to Int, matching the operators + and *.
Functions and Values
sum
sum : forall a. (a(Int)) -> Int
The sum of a container of ints (0 when empty).
sum([1, 2, 3, 4])
10
product
product : forall a. (a(Int)) -> Int
The product of a container of ints (1 when empty).
product([1, 2, 3, 4])
24
length
length : forall a b. (a(b)) -> Int
The number of elements.
length([1, 2, 3])
3
is_empty
is_empty : forall a b. (a(b)) -> Bool
True when the container has no elements.
is_empty([1, 2, 3])
false
all
all : forall a b. ((a) -> Bool, b(a)) -> Bool
True when every element satisfies p (vacuously true when empty).
all(\(x) -> x > 0, [1, 2, 3])
true
any
any : forall a b. ((a) -> Bool, b(a)) -> Bool
True when some element satisfies p.
any(\(x) -> x > 2, [1, 2, 3])
true
find
find : forall a b. ((a) -> Bool, b(a)) -> Option(a)
The first element satisfying p as Some (leftmost match), or None.
find(\(x) -> x > 1, [1, 2, 3])
Some(2)
elem
elem : forall a b. (a, b(a)) -> Bool
True when x is an element (Eq).
elem(2, [1, 2, 3])
true
to_list
to_list : forall a b. (a(b)) -> List(b)
The elements as a List, in fold order (Option yields zero or one).
to_list(Some(5))
[5]
Data.Frozen
Frozen arrays: the immutable array representation.
A Frozen(a) is an Array(a) with the write surface removed at the type level: construction happens once and afterwards the contents are fixed, so a frozen array can be shared, stored, and indexed with no defensive copies. Freezing is O(1) and adds no storage: arrays already carry value semantics (a uniquely owned array updates in place, a shared one copies on write), so a frozen view can never observe later writes to its source.
Types
Frozen
type Frozen(a) = Frz(Array(a))
An immutable array of a.
Functions and Values
fz_freeze
fz_freeze : forall a. (Array(a)) -> Data.Frozen.Frozen(a)
Freeze a growable array, O(1). Value semantics guarantee the frozen view is independent of any later writes to arr.
fz_len(fz_freeze(array_of_list([1, 2, 3])))
3
fz_of_list
fz_of_list : forall a. (List(a)) -> Data.Frozen.Frozen(a)
Build a frozen array from a list.
fz_get(fz_of_list([5, 6, 7]), 2)
7
fz_thaw
fz_thaw : forall a. (Data.Frozen.Frozen(a)) -> Array(a)
A growable array with the frozen contents. The frozen view is unaffected by writes to the result (a shared array copies on write).
fz_len
fz_len : forall a. (Data.Frozen.Frozen(a)) -> Int
The element count.
fz_get
fz_get : forall a. (Data.Frozen.Frozen(a), Int) -> a ! {Fail}
The element at i, or fail() out of bounds.
fz_get(fz_of_list([5, 6, 7]), 0)
5
fz_foldl
fz_foldl : forall e0 a b. ((a, b) -> a ! {e0}, a, Data.Frozen.Frozen(b)) -> a ! {e0}
Left-fold over the elements in order.
fz_foldl(\(acc, x) -> acc + x, 0, fz_of_list([1, 2, 3]))
6
fz_to_list
fz_to_list : forall a. (Data.Frozen.Frozen(a)) -> List(a)
The elements as a list, in order.
fz_to_list(fz_of_list([1, 2, 3]))
[1, 2, 3]
fz_map
fz_map : forall e0 a b. ((b) -> a ! {e0}, Data.Frozen.Frozen(b)) -> Data.Frozen.Frozen(a) ! {e0}
A new frozen array with f applied to every element, one pass.
fz_to_list(fz_map(\(x) -> x * 2, fz_of_list([1, 2, 3])))
[2, 4, 6]
Data.Graph
Directed graphs over an ordered node type, with the deterministic algorithms the compiler relies on internally, mirrored into Prism.
A graph is an adjacency Map(k, List(k)) from a node to its successor list (the same “a set is a Map(k, Unit)” idiom Data.Set uses, so no new wired type is needed). Every traversal visits nodes and successors in ascending Ord(k) order, so output is a pure function of the edge set, never of insertion order or hashing. The strongly-connected-components pass is Tarjan’s, returning components callee-first (each component after the ones it points to) with each component’s members in ascending order, matching the compiler’s own SCC. Opt-in: not in Base.
Functions and Values
graph_empty
graph_empty : forall a b. Map(a, List(a), b)
The empty graph.
graph_successors
graph_successors : forall a b. (Map(b, List(b), a), b) -> List(b)
The successors of u in ascending order (empty if u has no out-edges).
graph_add_node
graph_add_node : forall a b. (Map(b, List(b), a), b) -> Map(b, List(b), a)
Add u as a node with no new edges (a no-op if already present).
graph_add_edge
graph_add_edge : forall a b. (Map(b, List(b), a), b, b) -> Map(b, List(b), a)
Add the directed edge u -> v, keeping each successor list sorted and duplicate-free so iteration stays deterministic.
graph_from_edges
graph_from_edges : forall a b. (List((b, b))) -> Map(b, List(b), a)
Build a graph from a list of directed (from, to) edges.
graph_nodes
graph_nodes : forall a b. (Map(b, List(b), a)) -> List(b)
Every node, in ascending order: the sources plus everything pointed at, so a sink that only ever appears as a successor still shows up.
graph_reverse
graph_reverse : forall a b c. (Map(c, List(c), a)) -> Map(c, List(c), b)
The reverse graph: every edge u -> v becomes v -> u. Nodes are preserved, so an isolated node survives.
graph_dfs
graph_dfs : forall a b. (Map(b, List(b), a), b) -> List(b)
Depth-first preorder from start: start, then its successors’ subtrees, successors taken in ascending order. Each node appears once.
graph_bfs
graph_bfs : forall a b. (Map(b, List(b), a), b) -> List(b)
Breadth-first order from start, successors taken in ascending order. Each node appears once.
graph_reachable
graph_reachable : forall a b. (Map(b, List(b), a), b) -> List(b)
The nodes reachable from start (including start), in ascending order.
graph_transitive_closure
graph_transitive_closure : forall a b c. (Map(c, List(c), a)) -> Map(c, List(c), b)
The transitive closure: an edge u -> v for every v reachable from u via at least one step (so u -> u only when u lies on a cycle).
graph_topo_sort
graph_topo_sort : forall a b. (Map(b, List(b), a)) -> List(b)
Topological order: a node before every node it points to. Deterministic via a depth-first postorder over nodes in ascending order, reversed. A graph with a cycle still yields a total order, but not a valid topo sort (a cycle admits none); pair it with graph_scc when cycles are possible.
graph_scc
graph_scc : forall a b. (Map(b, List(b), a)) -> List(List(b))
Tarjan’s strongly-connected components, returned callee-first: a component comes after every component reachable from it, and each component’s members are in ascending order. A node with no self-loop is its own singleton component. This mirrors the compiler’s own SCC ordering exactly.
Data.IntMap
Persistent integer-keyed map: a big-endian patricia trie over 64-bit keys.
Keys are I64, the fixed-width signed integer, because a radix trie branches by testing one bit of a machine word. Int is arbitrary-precision and has no highest set bit, so a trie cannot branch on it and no 64-bit branch word can order it; Data.Map serves Int keys through comparison chains. Convert with to_i64 at the boundary, which wraps to the low 64 bits exactly as it does everywhere else.
The branch word of a key is its two’s-complement bit pattern with the sign bit flipped. That transform is a bijection, and it turns signed key order into unsigned branch-word order, so branching is a plain bit test with no sign special case and an in-order walk yields ascending keys, negatives first.
A branch node carries the prefix its keys agree on, the single-bit mask of the position where they split, and the subtrees whose branch bit is zero and one. Every branch keeps two non-empty children, so a node’s mask is exactly the highest bit on which two of its keys differ and a key set determines the tree uniquely. Iteration order therefore depends on the key set alone and never on insertion order, and the stronger statement holds too: maps built from the same bindings in different orders are structurally equal, so intmap_to_list, intmap_fold, and the derived Eq and Show all agree across orders.
Opt-in: not in Base.
Types
IntMap
type IntMap(v)
= IMEmpty
| IMLeaf(I64, v)
| IMBranch(U64, U64, IntMap(v), IntMap(v))
deriving (Eq, Show)
A map from I64 keys to values of type v.
IMEmpty is the empty map, IMLeaf(key, value) a single binding, and IMBranch(prefix, mask, zeros, ones) a split: every key below it agrees with prefix above the single set bit of mask, zeros holds those whose branch bit is clear and ones those whose branch bit is set. Both subtrees of a branch are non-empty.
Functions and Values
intmap_empty
intmap_empty : forall a. Data.IntMap.IntMap(a)
The empty map.
intmap_singleton
intmap_singleton : forall a. (I64, a) -> Data.IntMap.IntMap(a)
The map binding key to value and nothing else.
intmap_to_list(intmap_singleton(7i64, "a"))
[(7, a)]
intmap_insert
intmap_insert : forall a. (I64, a, Data.IntMap.IntMap(a)) -> Data.IntMap.IntMap(a)
Insert key with value, overwriting any existing binding.
intmap_lookup(1i64, intmap_insert(1i64, "a", intmap_empty))
Some(a)
intmap_insert_with
intmap_insert_with : forall e0 a. ((a, a) -> a ! {e0}, I64, a, Data.IntMap.IntMap(a)) -> Data.IntMap.IntMap(a) ! {e0}
Insert key with value, combining a clash as f(new, old).
intmap_to_list(
intmap_insert_with(\(a, b) -> a + b, 1i64, 10, intmap_singleton(1i64, 5)),
)
[(1, 15)]
intmap_lookup
intmap_lookup : forall a. (I64, Data.IntMap.IntMap(a)) -> Option(a)
The value bound to key as Some, or None when absent.
intmap_lookup(2i64, intmap_from_list([(1i64, "a"), (2i64, "b")]))
Some(b)
intmap_member
intmap_member : forall a. (I64, Data.IntMap.IntMap(a)) -> Bool
True when key is bound.
intmap_member(2i64, intmap_from_list([(1i64, "a"), (2i64, "b")]))
true
intmap_delete
intmap_delete : forall a. (I64, Data.IntMap.IntMap(a)) -> Data.IntMap.IntMap(a)
Remove key (a no-op when absent).
intmap_to_list(intmap_delete(1i64, intmap_from_list([(1i64, "a"), (2i64, "b")])))
[(2, b)]
intmap_size
intmap_size : forall a. (Data.IntMap.IntMap(a)) -> Int
The number of bindings.
intmap_size(intmap_from_list([(1i64, "a"), (2i64, "b")]))
2
intmap_is_empty
intmap_is_empty : forall a. (Data.IntMap.IntMap(a)) -> Bool
True when the map has no bindings.
intmap_is_empty(intmap_empty)
true
intmap_union
intmap_union : forall a. (Data.IntMap.IntMap(a), Data.IntMap.IntMap(a)) -> Data.IntMap.IntMap(a)
The union of two maps, keeping the left value where a key is in both.
intmap_to_list(
intmap_union(
intmap_from_list([(1i64, "a"), (2i64, "b")]),
intmap_from_list([(2i64, "z"), (3i64, "c")]),
),
)
[(1, a), (2, b), (3, c)]
intmap_union_with
intmap_union_with : forall e0 a. ((a, a) -> a ! {e0}, Data.IntMap.IntMap(a), Data.IntMap.IntMap(a)) -> Data.IntMap.IntMap(a) ! {e0}
The union of two maps, combining a key present in both as f(left, right).
The two branch nodes are walked together: the shallower one is descended into until the masks meet, so the merge costs the shape of the two tries rather than a re-insertion of every binding.
intmap_to_list(
intmap_union_with(
\(a, b) -> a + b,
intmap_from_list([(1i64, 10), (2i64, 20)]),
intmap_from_list([(2i64, 2), (3i64, 3)]),
),
)
[(1, 10), (2, 22), (3, 3)]
intmap_intersection
intmap_intersection : forall a b. (Data.IntMap.IntMap(a), Data.IntMap.IntMap(b)) -> Data.IntMap.IntMap(a)
The bindings of t1 whose key is also in t2, with t1’s values.
intmap_to_list(
intmap_intersection(
intmap_from_list([(1i64, "a"), (2i64, "b"), (3i64, "c")]),
intmap_from_list([(2i64, "z"), (3i64, "y"), (4i64, "x")]),
),
)
[(2, b), (3, c)]
intmap_difference
intmap_difference : forall a b. (Data.IntMap.IntMap(a), Data.IntMap.IntMap(b)) -> Data.IntMap.IntMap(a)
The bindings of t1 whose key is not in t2.
intmap_to_list(
intmap_difference(
intmap_from_list([(1i64, "a"), (2i64, "b"), (3i64, "c")]),
intmap_from_list([(2i64, "z"), (3i64, "y")]),
),
)
[(1, a)]
intmap_filter
intmap_filter : forall e0 a. ((I64, a) -> Bool ! {e0}, Data.IntMap.IntMap(a)) -> Data.IntMap.IntMap(a) ! {e0}
The bindings satisfying keep(key, value).
A surviving branch still splits on the highest bit two of its remaining keys differ on, because a branch whose child empties collapses, so filtering lands on the same tree the survivors would have built from scratch.
intmap_to_list(
intmap_filter(
\(_k, v) -> v > 1,
intmap_from_list([(1i64, 1), (2i64, 2), (3i64, 3)]),
),
)
[(2, 2), (3, 3)]
intmap_map_values
intmap_map_values : forall e0 a b. ((a) -> b ! {e0}, Data.IntMap.IntMap(a)) -> Data.IntMap.IntMap(b) ! {e0}
Apply f to every value, keeping keys and tree shape.
intmap_values(
intmap_map_values(\(v) -> v + 1, intmap_from_list([(1i64, 10), (2i64, 20)])),
)
[11, 21]
intmap_fold
intmap_fold : forall e0 a b. ((a, I64, b) -> a ! {e0}, a, Data.IntMap.IntMap(b)) -> a ! {e0}
Fold f(acc, key, value) over the bindings in ascending key order.
The zero subtree of a branch holds the smaller branch words and branch-word order is key order, so a left-to-right walk is ascending with no sign case.
intmap_fold(\(acc, k, _v) -> acc + k, 0i64, intmap_from_list([(1i64, "a"), (2i64, "b")]))
3
intmap_to_list
intmap_to_list : forall a. (Data.IntMap.IntMap(a)) -> List((I64, a))
The (key, value) pairs in ascending key order, negative keys first.
intmap_to_list(intmap_from_list([(2i64, "b"), (0i64, "z"), (1i64, "a")]))
[(0, z), (1, a), (2, b)]
intmap_keys
intmap_keys : forall a. (Data.IntMap.IntMap(a)) -> List(I64)
The keys in ascending order.
intmap_keys(intmap_from_list([(2i64, "b"), (1i64, "a")]))
[1, 2]
intmap_values
intmap_values : forall a. (Data.IntMap.IntMap(a)) -> List(a)
The values in ascending key order.
intmap_values(intmap_from_list([(1i64, "a"), (2i64, "b")]))
[a, b]
intmap_from_list
intmap_from_list : forall a. (List((I64, a))) -> Data.IntMap.IntMap(a)
Build a map from (key, value) pairs; a later pair overwrites an earlier one with the same key. The result depends only on the surviving bindings, so any ordering of the same list of distinct keys builds the identical tree.
intmap_to_list(intmap_from_list([(2i64, "b"), (1i64, "a")]))
[(1, a), (2, b)]
Data.IntSet
Sets of 64-bit integers, reusing the patricia trie.
An IntSet is IntMap(Unit), the same big-endian radix trie carrying no payload, exactly as Data.Set is Data.Map at Unit. Sharing the type rather than declaring a second one keeps a single trie implementation, so the canonical-shape argument in Data.IntMap is made once and covers both: a set is determined by its elements, its iteration order is ascending regardless of insertion order, and structural equality is set equality. Set algebra is the map algebra at Unit, where the union’s left bias cannot be observed. Sharing the type also avoids a second instance head; instance resolution keys on the head type constructor, so a distinct IntSet newtype would need its own Eq and Show while an alias-free reuse inherits IntMap’s.
Elements are I64 for the reason keys are in Data.IntMap: branching is a bit test on a fixed-width word. Opt-in: not in Base.
Functions and Values
intset_empty
intset_empty : Data.IntMap.IntMap(Unit)
The empty set.
intset_singleton
intset_singleton : (I64) -> Data.IntMap.IntMap(Unit)
The set containing x alone.
intset_to_list(intset_singleton(7i64))
[7]
intset_insert
intset_insert : (I64, Data.IntMap.IntMap(Unit)) -> Data.IntMap.IntMap(Unit)
Add x (a no-op when already present).
intset_to_list(intset_insert(2i64, intset_insert(1i64, intset_empty)))
[1, 2]
intset_member
intset_member : (I64, Data.IntMap.IntMap(Unit)) -> Bool
True when x is a member.
intset_member(2i64, intset_from_list([1i64, 2i64, 3i64]))
true
intset_delete
intset_delete : (I64, Data.IntMap.IntMap(Unit)) -> Data.IntMap.IntMap(Unit)
Remove x (a no-op when absent).
intset_to_list(intset_delete(2i64, intset_from_list([1i64, 2i64, 3i64])))
[1, 3]
intset_size
intset_size : (Data.IntMap.IntMap(Unit)) -> Int
The number of elements.
intset_size(intset_from_list([1i64, 2i64, 2i64, 3i64]))
3
intset_is_empty
intset_is_empty : (Data.IntMap.IntMap(Unit)) -> Bool
True when the set has no elements.
intset_is_empty(intset_empty)
true
intset_to_list
intset_to_list : (Data.IntMap.IntMap(Unit)) -> List(I64)
The elements in ascending order, negatives first.
intset_to_list(intset_from_list([3i64, 1i64, 2i64, 1i64]))
[1, 2, 3]
intset_from_list
intset_from_list : (List(I64)) -> Data.IntMap.IntMap(Unit)
Build a set from a list, dropping duplicates.
intset_to_list(intset_from_list([3i64, 1i64, 2i64, 1i64]))
[1, 2, 3]
intset_union
intset_union : (Data.IntMap.IntMap(Unit), Data.IntMap.IntMap(Unit)) -> Data.IntMap.IntMap(Unit)
Every element in either set, merged by walking the two tries together.
intset_to_list(intset_union(intset_from_list([1i64, 2i64]), intset_from_list([2i64, 3i64])))
[1, 2, 3]
intset_intersection
intset_intersection : (Data.IntMap.IntMap(Unit), Data.IntMap.IntMap(Unit)) -> Data.IntMap.IntMap(Unit)
The elements in both sets.
intset_to_list(
intset_intersection(
intset_from_list([1i64, 2i64, 3i64]),
intset_from_list([2i64, 3i64, 4i64]),
),
)
[2, 3]
intset_difference
intset_difference : (Data.IntMap.IntMap(Unit), Data.IntMap.IntMap(Unit)) -> Data.IntMap.IntMap(Unit)
The elements of s1 that are not in s2.
intset_to_list(
intset_difference(intset_from_list([1i64, 2i64, 3i64]), intset_from_list([2i64, 3i64])),
)
[1]
intset_filter
intset_filter : forall e0. ((I64) -> Bool ! {e0}, Data.IntMap.IntMap(Unit)) -> Data.IntMap.IntMap(Unit) ! {e0}
The elements satisfying keep.
intset_to_list(intset_filter(\(x) -> x > 1i64, intset_from_list([1i64, 2i64, 3i64])))
[2, 3]
intset_fold
intset_fold : forall e0 a. ((a, I64) -> a ! {e0}, a, Data.IntMap.IntMap(Unit)) -> a ! {e0}
Fold f(acc, x) over the elements in ascending order.
intset_fold(\(acc, x) -> acc + x, 0i64, intset_from_list([1i64, 2i64, 3i64]))
6
intset_map
intset_map : forall e0. ((I64) -> I64 ! {e0}, Data.IntMap.IntMap(Unit)) -> Data.IntMap.IntMap(Unit) ! {e0}
Apply f to every element, rebuilding the set (f need not be injective).
intset_to_list(intset_map(\(x) -> x * 2i64, intset_from_list([1i64, 2i64, 3i64])))
[2, 4, 6]
Data.List
Singly-linked list operations.
The List(a) type and its Nil/Cons constructors are wired into the language (list literals, deriving); this module is the function surface over them. Base includes it, so these names are in scope unqualified everywhere; a project module reaches them with import Data.List. The container-generic queries (length, sum, all, find, and friends) live in Data.Foldable and work on any Foldable, this type included.
Functions and Values
singleton
singleton : forall a. (a) -> List(a)
The one-element list Cons(x, Nil).
is_nil
is_nil : forall a. (List(a)) -> Bool
True for the empty list.
head
head : forall a. (List(a)) -> Option(a)
The first element as Some, or None when the list is empty.
head([1, 2, 3])
Some(1)
tail
tail : forall a. (List(a)) -> List(a)
Everything after the first element (Nil for the empty list).
last
last : forall a. (List(a)) -> Option(a)
The final element as Some, or None when the list is empty.
last([1, 2, 3])
Some(3)
nth
nth : forall a. (Int, List(a)) -> Option(a)
The element at index n (zero-based) as Some, or None if out of range.
nth(1, [10, 20, 30])
Some(20)
take
take : forall a. (Int, List(a)) -> List(a)
The first n elements (fewer if the list is shorter).
take(2, [1, 2, 3, 4])
[1, 2]
drop
drop : forall a. (Int, List(a)) -> List(a)
The list with its first n elements removed.
drop(2, [1, 2, 3, 4])
[3, 4]
take_while
take_while : forall e0 a. ((a) -> Bool ! {e0}, List(a)) -> List(a) ! {e0}
The longest prefix of elements satisfying p.
take_while(\(x) -> x < 3, [1, 2, 3, 1])
[1, 2]
drop_while
drop_while : forall e0 a. ((a) -> Bool ! {e0}, List(a)) -> List(a) ! {e0}
The suffix remaining after take_while(p, xs).
drop_while(\(x) -> x < 3, [1, 2, 3, 1])
[3, 1]
split_at
split_at : forall a. (Int, List(a)) -> (List(a), List(a))
(take(n, xs), drop(n, xs)) in one pass of intent.
map
map : forall e0 a b. ((b) -> a ! {e0}, List(b)) -> List(a) ! {e0}
Apply f to every element, preserving order and length.
map(\(x) -> x + 1, [1, 2, 3])
[2, 3, 4]
filter
filter : forall e0 a. ((a) -> Bool ! {e0}, List(a)) -> List(a) ! {e0}
Keep the elements satisfying p.
filter(\(x) -> x > 1, [1, 2, 3])
[2, 3]
foldr
foldr : forall e0 a b. ((a, b) -> b ! {e0}, b, List(a)) -> b ! {e0}
Right fold: f(x0, f(x1, ... f(xn, z))).
foldr(\(x, acc) -> x + acc, 0, [1, 2, 3])
6
foldl
foldl : forall e0 a b. ((a, b) -> a ! {e0}, a, List(b)) -> a ! {e0}
Left fold: f(... f(f(z, x0), x1) ..., xn). Tail-recursive.
foldl(\(a, b) -> a + b, 0, [1, 2, 3])
6
append
append : forall a. (List(a), List(a)) -> List(a)
Concatenate two lists.
append([1, 2], [3, 4])
[1, 2, 3, 4]
reverse
reverse : forall a. (List(a)) -> List(a)
The list in reverse order.
reverse([1, 2, 3])
[3, 2, 1]
flatten
flatten : forall a. (List(List(a))) -> List(a)
Concatenate a list of lists.
flatten([[1, 2], [3], [4, 5]])
[1, 2, 3, 4, 5]
concat_map
concat_map : forall e0 a b. ((a) -> List(b) ! {e0}, List(a)) -> List(b) ! {e0}
Map f over the list and concatenate the resulting lists.
concat_map(\(x) -> [x, x], [1, 2])
[1, 1, 2, 2]
zip_with
zip_with : forall e0 a b c. ((b, c) -> a ! {e0}, List(b), List(c)) -> List(a) ! {e0}
Combine two lists elementwise with f, stopping at the shorter length.
zip_with(\(x, y) -> x + y, [1, 2, 3], [10, 20])
[11, 22]
zip
zip : forall a b. (List(a), List(b)) -> List((a, b))
Pair up two lists elementwise, stopping at the shorter length.
zip([1, 2], ["a", "b"])
[(1, a), (2, b)]
unzip
unzip : forall a b. (List((a, b))) -> (List(a), List(b))
Split a list of pairs into a pair of lists.
unzip([(1, "a"), (2, "b")])
([1, 2], [a, b])
count
count : forall e0 a. ((a) -> Bool ! {e0}, List(a)) -> Int ! {e0}
The number of elements satisfying p.
count(\(x) -> x > 1, [1, 2, 3])
2
position_go
position_go : forall e0 a. ((a) -> Bool ! {e0}, Int, List(a)) -> Option(Int) ! {e0}
Helper for position: search from index n.
position
position : forall e0 a. ((a) -> Bool ! {e0}, List(a)) -> Option(Int) ! {e0}
The index of the first element satisfying p as Some, or None.
position(\(x) -> x == 2, [1, 2, 3])
Some(1)
maximum
maximum : (List(Int)) -> Option(Int)
The greatest element as Some, or None for the empty list.
maximum([3, 1, 2])
Some(3)
minimum
minimum : (List(Int)) -> Option(Int)
The least element as Some, or None for the empty list.
minimum([3, 1, 2])
Some(1)
replicate
replicate : forall a. (Int, a) -> List(a)
A list of n copies of x.
replicate(3, 0)
[0, 0, 0]
range
range : (Int, Int) -> List(Int)
The ascending list [lo, lo+1, ..., hi-1] (empty when lo >= hi).
range(0, 5)
[0, 1, 2, 3, 4]
tabulate
tabulate : forall e0 a. (Int, (Int) -> a ! {e0}) -> List(a) ! {e0}
The list [f(0), f(1), ..., f(n-1)].
tabulate(3, \(i) -> i * i)
[0, 1, 4]
insert_sorted
insert_sorted : (Int, List(Int)) -> List(Int)
Insert x into an already-ascending list, keeping it sorted.
insert_sorted(3, [1, 2, 4])
[1, 2, 3, 4]
list_to_option
list_to_option : forall a. (List(a)) -> Option(a)
The first element as Some, or None (an Option view of the head).
partition
partition : forall e0 a. ((a) -> Bool ! {e0}, List(a)) -> (List(a), List(a)) ! {e0}
Split into (matching, non-matching) by predicate p.
partition(\(x) -> x > 1, [1, 2, 3])
([2, 3], [1])
chunks_of
chunks_of : forall a. (Int, List(a)) -> List(List(a))
Break the list into consecutive chunks of up to n elements.
chunks_of(2, [1, 2, 3, 4, 5])
[[1, 2], [3, 4], [5]]
scan_left
scan_left : forall e0 a b. ((a, b) -> a ! {e0}, a, List(b)) -> List(a) ! {e0}
Left scan: the running accumulators of foldl, starting from z.
scan_left(\(a, b) -> a + b, 0, [1, 2, 3])
[0, 1, 3, 6]
list_ap
list_ap : forall e0 a b. (List((b) -> a ! {e0}), List(b)) -> List(a) ! {e0}
Applicative apply for lists: every function in fs applied to every x.
list_ap([\(x) -> x + 1, \(x) -> x * 2], [10, 20])
[11, 21, 20, 40]
Data.Map
Persistent ordered map: an AVL-balanced binary search tree over keys.
The Map(k, v) type and its Tip/Bin constructors are wired in; this module is the operation surface. Base includes it.
A map’s representation depends on the canonical Ord instance used to build it. The compiler therefore classifies Ord (and Hash) as representation-affecting in store::coherence::is_representation_affecting. Map identity does not currently encode that instance, so programs exchanging a map across an assembly boundary must agree on its canonical ordering.
Functions and Values
map_empty
map_empty : forall a b c. Map(a, b, c)
The empty map.
map_height
map_height : forall a b c. (Map(a, b, c)) -> Int
Helper: the cached height of the tree (0 for the empty map).
map_node
map_node : forall a b c. (a, b, Map(a, b, c), Map(a, b, c)) -> Map(a, b, c)
Helper: build a Bin node, computing its height from its children.
map_bf
map_bf : forall a b c. (Map(a, b, c)) -> Int
Helper: the balance factor of a node (left height minus right height).
map_rot_right
map_rot_right : forall a b c. (Map(a, b, c)) -> Map(a, b, c)
Helper: a single right AVL rotation.
map_rot_left
map_rot_left : forall a b c. (Map(a, b, c)) -> Map(a, b, c)
Helper: a single left AVL rotation.
map_balance
map_balance : forall a b c. (Map(a, b, c)) -> Map(a, b, c)
Helper: rebalance a node after an insert or delete unbalanced it.
map_insert
map_insert : forall a b c. (b, c, Map(b, c, a)) -> Map(b, c, a)
Insert key with value, overwriting any existing binding, and rebalance.
map_lookup(1, map_insert(1, "a", map_empty))
Some(a)
map_lookup
map_lookup : forall a b c. (b, Map(b, c, a)) -> Option(c)
The value bound to key as Some, or None when absent.
map_lookup(2, map_from_list([(1, "a"), (2, "b")]))
Some(b)
map_member
map_member : forall a b c. (b, Map(b, c, a)) -> Bool
True when key is present in the map.
map_member(2, map_from_list([(1, "a"), (2, "b")]))
true
map_size
map_size : forall a b c. (Map(a, b, c)) -> Int
The number of entries.
map_size(map_from_list([(1, "a"), (2, "b")]))
2
map_min
map_min : forall a b c. (Map(a, b, c)) -> Option((a, b))
The smallest key and its value as Some, or None when empty.
map_min(map_from_list([(2, "b"), (1, "a")]))
Some((1, a))
map_delete
map_delete : forall a b c. (b, Map(b, c, a)) -> Map(b, c, a)
Remove key (a no-op if absent), rebalancing the tree.
map_to_list(map_delete(1, map_from_list([(1, "a"), (2, "b")])))
[(2, b)]
map_to_list
map_to_list : forall a b c. (Map(a, b, c)) -> List((a, b))
The (key, value) pairs in ascending key order.
map_to_list(map_from_list([(2, "b"), (1, "a")]))
[(1, a), (2, b)]
map_keys
map_keys : forall a b c. (Map(a, b, c)) -> List(a)
The keys in ascending order.
map_keys(map_from_list([(2, "b"), (1, "a")]))
[1, 2]
map_values
map_values : forall a b c. (Map(a, b, c)) -> List(b)
The values in ascending key order.
map_values(map_from_list([(1, "a"), (2, "b")]))
[a, b]
map_from_list
map_from_list : forall a b c. (List((b, c))) -> Map(b, c, a)
Build a map from (key, value) pairs; a later pair overwrites an earlier one with the same key.
map_to_list(map_from_list([(2, "b"), (1, "a")]))
[(1, a), (2, b)]
map_map_values
map_map_values : forall e0 a b c d e. ((e) -> d ! {e0}, Map(a, e, b)) -> Map(a, d, c) ! {e0}
Apply f to every value, keeping keys and tree structure.
map_values(map_map_values(\(v) -> v + 1, map_from_list([(1, 10), (2, 20)])))
[11, 21]
Data.Maybe
Operations over Option.
The type is wired in; Base includes this module, so these are in unqualified scope everywhere.
Functions and Values
is_some
is_some : forall a. (Option(a)) -> Bool
True when the option holds a value.
is_some(Some(5))
true
is_none
is_none : forall a. (Option(a)) -> Bool
True when the option is empty.
is_none(None)
true
unwrap_or
unwrap_or : forall a. (a, Option(a)) -> a
The contained value, or the default d when the option is empty.
(unwrap_or(0, Some(5)), unwrap_or(0, None))
(5, 0)
The default must match the contained type, and the second argument must be an option:
unwrap_or(0, 5)
map_option
map_option : forall e0 a b. ((b) -> a ! {e0}, Option(b)) -> Option(a) ! {e0}
Apply f to the contained value, leaving None untouched.
map_option(\(x) -> x * 2, Some(21))
Some(42)
and_then
and_then : forall e0 a b. ((a) -> Option(b) ! {e0}, Option(a)) -> Option(b) ! {e0}
Chain an option-returning function, short-circuiting on None (monadic bind for Option).
and_then(\(x) -> if x > 0 then Some(x * 10) else None, Some(4))
Some(40)
map_or
map_or : forall e0 a b. (a, (b) -> a ! {e0}, Option(b)) -> a ! {e0}
Apply f to the contained value, or return the default d when empty: map_option and unwrap_or in one step.
map_or(0, \(x) -> x + 1, Some(41))
42
option_or
option_or : forall a. (Option(a), Option(a)) -> Option(a)
The option itself when it holds a value, otherwise the alternative alt.
option_or(Some(1), None)
Some(1)
option_to_list
option_to_list : forall a. (Option(a)) -> List(a)
An empty or one-element list from an option.
option_to_list(Some(7))
[7]
both
both : forall a b. (Option(a), Option(b)) -> Option((a, b))
Pair two options: Some only when both hold values.
(both(Some(1), Some(2)), both(Some(1), None))
(Some((1, 2)), None)
option_fold_r
option_fold_r : forall e0 a b. ((a, b) -> b ! {e0}, b, Option(a)) -> b ! {e0}
Fold an option right-to-left: g(x, z) on Some(x), z on None.
option_fold_r(\(x, z) -> x + z, 10, Some(5))
15
option_fold_l
option_fold_l : forall e0 a b. ((a, b) -> a ! {e0}, a, Option(b)) -> a ! {e0}
Fold an option left-to-right: g(z, x) on Some(x), z on None.
option_fold_l(\(z, x) -> z + x, 10, Some(5))
15
option_bind
option_bind : forall e0 a b. (Option(a), (a) -> Option(b) ! {e0}) -> Option(b) ! {e0}
and_then with the option first: reads as a pipeline of fallible steps.
option_bind(Some(4), \(x) -> Some(x * 10))
Some(40)
option_ap
option_ap : forall e0 a b. (Option((b) -> a ! {e0}), Option(b)) -> Option(a) ! {e0}
Apply an optional function to an optional value (applicative apply).
option_ap(Some(\(x) -> x + 1), Some(41))
Some(42)
Data.Monad
Generic operations derived from the Applicative and Monad classes.
Like Data.Foldable, these are constrained free functions written once against the class methods (pure, ap, bind) and shared by every instance (List, Option, …). Side effects ride Prism’s effect system; this is structural sequencing over the container shape, not do-notation.
Functions and Values
join
join : forall a b. (a(a(b))) -> a(b)
Collapse one level of nesting: m(m(a)) to m(a).
join([[1, 2], [3, 4]])
[1, 2, 3, 4]
map2
map2 : forall a b c d. ((a, b) -> c, d(a), d(b)) -> d(c)
Lift a binary function over two wrapped values (Applicative).
map2(\(x, y) -> x + y, Some(1), Some(2))
Some(3)
map3
map3 : forall a b c d e. ((a, b, c) -> d, e(a), e(b), e(c)) -> e(d)
Lift a ternary function over three wrapped values (Applicative).
map3(\(x, y, z) -> x + y + z, Some(1), Some(2), Some(3))
Some(6)
sequence
sequence : forall a b. (List(a(b))) -> a(List(b))
Evaluate a list of wrapped values left to right, collecting the results (Applicative). Option short-circuits on None; List takes the cartesian product.
sequence([Some(1), Some(2), Some(3)])
Some([1, 2, 3])
traverse_list
traverse_list : forall a b c. ((a) -> b(c), List(a)) -> b(List(c))
Apply f to each element and sequence the wrapped results (Applicative). This is sequence after a plain map.
traverse_list(\(x) -> if x > 0 then Some(x) else None, [1, 2, 3])
Some([1, 2, 3])
Data.Ordered
Explicit ordering witnesses: the branded, statically coherent path to ordered maps.
An unbranded Map(k, v) is ordered by the ambient canonical Ord(k). When a program needs two orderings of the same keys at once (one map ascending, one descending), the phantom brand on Map(k, v, brand) keeps their values from being mixed. with_ordering hands its body a witness carrying a comparator; the witness’s brand is a rigid, scope-local skolem, so a map built under one witness never unifies with a map built under another. Passing a map of one witness to another witness’s operation is a compile error that names both brands.
This is the explicit half of the ordered-container coherence story, closed statically. The implicit using ordRev path (calling the ambient map_insert under a non-canonical Ord) is guarded at runtime when an ordered map crosses a package boundary: Wire’s map reader faults when a map arrives ordered by a different Ord(k) than the reader canonicalizes. No claim is made here of automatic static closure of the implicit path.
Opt-in: this module is not in Base, so ambient effect rows and the unbranded Map surface are untouched unless a program imports it.
Types
OrdWitness
type OrdWitness(k, brand) = OrdBy((k, k) -> Int)
An ordering witness: a comparator branded by the phantom brand. The only way to obtain one is with_ordering, which mints a fresh brand per call, so two witnesses always carry incompatible brands.
Functions and Values
with_ordering
with_ordering : forall a b. ((a, a) -> Int, forall brand. (Data.Ordered.OrdWitness(a, brand)) -> b) -> b
Run body with a fresh ordering witness carrying cmp. The witness’s brand is rigid and unique to this call: a map built under it cannot be handed to a different witness’s operation, and that mismatch is a compile error naming both brands. The result a may not mention the brand, so a branded map never escapes the block, only a summary of it (a size, a looked-up value, an encoded form).
The brand binder is spelled brand rather than a bare letter on purpose: it must not collide with the a, b, c a scheme’s own quantifiers are canonicalized to, which would capture the result type under the inner quantifier.
with_ordering(\(a, b) -> cmp(a, b), \(w) ->
ord_lookup(w, 1, ord_insert(w, 1, "a", ord_empty(w))))
Some(a)
ord_empty
ord_empty : forall a b c. (Data.Ordered.OrdWitness(a, b)) -> Map(a, c, b)
The empty map under witness w, carrying w’s brand.
ord_insert
ord_insert : forall a b c. (Data.Ordered.OrdWitness(a, b), a, c, Map(a, c, b)) -> Map(a, c, b)
Insert under witness w; the result carries w’s brand.
with_ordering(\(a, b) -> cmp(a, b), \(w) ->
ord_size(w, ord_insert(w, 1, "a", ord_empty(w))))
1
ord_lookup
ord_lookup : forall a b c. (Data.Ordered.OrdWitness(a, b), a, Map(a, c, b)) -> Option(c)
Look key up under witness w. Only a map of w’s brand type-checks here.
with_ordering(\(a, b) -> cmp(a, b), \(w) ->
ord_lookup(w, 9, ord_insert(w, 1, "a", ord_empty(w))))
None
ord_member
ord_member : forall a b c. (Data.Ordered.OrdWitness(a, b), a, Map(a, c, b)) -> Bool
True when key is present under witness w.
with_ordering(\(a, b) -> cmp(a, b), \(w) ->
ord_member(w, 1, ord_insert(w, 1, "a", ord_empty(w))))
true
ord_to_list
ord_to_list : forall a b c. (Data.Ordered.OrdWitness(a, b), Map(a, c, b)) -> List((a, c))
The (key, value) pairs of a w-branded map, in tree (in-order) order.
with_ordering(\(a, b) -> cmp(a, b), \(w) ->
ord_to_list(w, ord_insert(w, 2, "b", ord_insert(w, 1, "a", ord_empty(w)))))
[(1, a), (2, b)]
ord_size
ord_size : forall a b c. (Data.Ordered.OrdWitness(a, b), Map(a, c, b)) -> Int
The number of entries in a w-branded map.
with_ordering(\(a, b) -> cmp(a, b), \(w) ->
ord_size(w, ord_insert(w, 2, "b", ord_insert(w, 1, "a", ord_empty(w)))))
2
Data.Pretty
A Leijen-style pretty printer. Build a layout-independent Doc from the combinators below, then render it to a string at a chosen page width.
Rendering is a pure function of the document and the width, so a document lays out identically on every backend and replays byte for byte. The strict renderer carries a Flat/Brk mode through a work list; its fits lookahead stops at the next forced break or as soon as the width is exceeded.
Import explicitly (import Data.Pretty); these names are not in Base’s unqualified scope. Widths count Unicode code points, matching str_len; they are not terminal display-cell measurements.
Types
Doc
type Doc
= DNil
| DText(String)
| DSoft(String)
| DHard
| DCat(Doc, Doc)
| DNest(Int, Doc)
| DGroup(Doc)
A layout document. Its representation is hidden so indentation and break invariants are established only by the public combinators.
Functions and Values
render
render : (Int, Data.Pretty.Doc) -> String
Render a document to a string at page width w.
import Data.Pretty (render, group, cat, text, line)
render(80, group(cat(text("hello"), cat(line(), text("world")))))
hello world
pretty
pretty : (Data.Pretty.Doc) -> String
Render at the conventional width of 80 columns.
text
text : (String) -> Data.Pretty.Doc
Literal text. The string must not contain a newline; use hardline for a forced break.
empty
empty : () -> Data.Pretty.Doc
The empty document.
line
line : () -> Data.Pretty.Doc
A break that is a space when flat and a newline when broken.
linebreak
linebreak : () -> Data.Pretty.Doc
A break that is nothing when flat and a newline when broken.
softline
softline : () -> Data.Pretty.Doc
A line inside its own group: a space if it fits, a newline otherwise.
softbreak
softbreak : () -> Data.Pretty.Doc
A linebreak inside its own group: nothing if it fits, a newline otherwise.
hardline
hardline : () -> Data.Pretty.Doc
A newline that no group can flatten away.
cat
cat : (Data.Pretty.Doc, Data.Pretty.Doc) -> Data.Pretty.Doc
One document then another.
nest
nest : (Int, Data.Pretty.Doc) -> Data.Pretty.Doc
Indent every break inside d by at least zero columns. Negative indentation is clamped to zero.
group
group : (Data.Pretty.Doc) -> Data.Pretty.Doc
Try to lay d out flat; fall back to its broken form if it does not fit.
concat_docs
concat_docs : (List(Data.Pretty.Doc)) -> Data.Pretty.Doc
Concatenate a list of documents end to end.
join_docs
join_docs : (Data.Pretty.Doc, List(Data.Pretty.Doc)) -> Data.Pretty.Doc
Put separator between each pair of documents. Unlike hsep and vsep, the caller selects the exact separator document, which is useful for small target-language emitters (commas plus soft breaks, operators, or hard lines).
hsep
hsep : (List(Data.Pretty.Doc)) -> Data.Pretty.Doc
Documents separated by a single space, all on one line.
vsep
vsep : (List(Data.Pretty.Doc)) -> Data.Pretty.Doc
Documents separated by line breaks (spaces or newlines under a group).
sep
sep : (List(Data.Pretty.Doc)) -> Data.Pretty.Doc
vsep wrapped in a group: one line if it fits, one per element otherwise.
enclose
enclose : (Data.Pretty.Doc, Data.Pretty.Doc, Data.Pretty.Doc) -> Data.Pretty.Doc
d between the opening l and closing r.
parens
parens : (Data.Pretty.Doc) -> Data.Pretty.Doc
d in parentheses.
brackets
brackets : (Data.Pretty.Doc) -> Data.Pretty.Doc
d in square brackets.
braces
braces : (Data.Pretty.Doc) -> Data.Pretty.Doc
d in curly braces.
Data.Result
Operations over Result.
The type is wired in; Base includes this module, so these are in unqualified scope everywhere.
Functions and Values
is_ok
is_ok : forall a b. (Result(a, b)) -> Bool
True when the result is Ok.
is_ok(Ok(5))
true
is_err
is_err : forall a b. (Result(a, b)) -> Bool
True when the result is Err.
map_result
map_result : forall e0 a b c. ((c) -> b ! {e0}, Result(c, a)) -> Result(b, a) ! {e0}
Apply f to the Ok value, leaving an Err untouched.
map_result(\(x) -> x * 2, Ok(21))
Ok(42)
map_err
map_err : forall e0 a b c. ((c) -> b ! {e0}, Result(a, c)) -> Result(a, b) ! {e0}
Apply f to the Err value, leaving an Ok untouched (the error-side counterpart of map_result).
and_then_result
and_then_result : forall e0 a b c. ((a) -> Result(b, c) ! {e0}, Result(a, c)) -> Result(b, c) ! {e0}
Chain a result-returning function, short-circuiting on Err (monadic bind for Result).
and_then_result(\(x) -> if x > 0 then Ok(x) else Err("neg"), Ok(5))
Ok(5)
result_or
result_or : forall a b. (a, Result(a, b)) -> a
The Ok value, or the default d when the result is Err.
result_or(0, Err("boom"))
0
ok_of_option
ok_of_option : forall a b. (a, Option(b)) -> Result(b, a)
Convert an Option to a Result, using err in place of None.
ok_of_option("missing", None)
Err(missing)
option_of_result
option_of_result : forall a b. (Result(a, b)) -> Option(a)
Convert a Result to an Option, discarding the error.
option_of_result(Ok(5))
Some(5)
Data.Set
Ordered sets, reusing the balanced-tree map.
Set algebra stays O(n log n) and preserves iteration order. Base includes this module.
Like Map, a set’s representation depends on the canonical Ord instance used to build it. The compiler classifies Ord and Hash as representation-affecting in store::coherence::is_representation_affecting. Set identity does not currently encode that instance, so programs exchanging a set across an assembly boundary must agree on its canonical ordering.
Functions and Values
set_empty
set_empty : forall a b. Map(a, Unit, b)
The empty set.
set_insert
set_insert : forall a b. (b, Map(b, Unit, a)) -> Map(b, Unit, a)
Add x to the set (a no-op if already present).
set_to_list(set_insert(2, set_insert(1, set_empty)))
[1, 2]
set_member
set_member : forall a b. (b, Map(b, Unit, a)) -> Bool
True when x is a member of the set.
set_member(2, set_from_list([1, 2, 3]))
true
set_delete
set_delete : forall a b. (b, Map(b, Unit, a)) -> Map(b, Unit, a)
Remove x from the set (a no-op if absent).
set_to_list(set_delete(2, set_from_list([1, 2, 3])))
[1, 3]
set_size
set_size : forall a b c. (Map(a, b, c)) -> Int
The number of elements.
set_size(set_from_list([1, 2, 2, 3]))
3
set_to_list
set_to_list : forall a b c. (Map(a, b, c)) -> List(a)
The elements in ascending order.
set_to_list(set_from_list([3, 1, 2, 1]))
[1, 2, 3]
set_from_list
set_from_list : forall a b. (List(b)) -> Map(b, Unit, a)
Build a set from a list, dropping duplicates.
set_to_list(set_from_list([3, 1, 2, 1]))
[1, 2, 3]
set_union
set_union : forall a b c. (Map(c, Unit, a), Map(c, Unit, b)) -> Map(c, Unit, a)
Every element in either set.
set_to_list(set_union(set_from_list([1, 2]), set_from_list([2, 3])))
[1, 2, 3]
set_intersection
set_intersection : forall a b c d. (Map(d, Unit, a), Map(d, Unit, b)) -> Map(d, Unit, c)
The elements in both sets.
set_to_list(set_intersection(set_from_list([1, 2, 3]), set_from_list([2, 3, 4])))
[2, 3]
set_difference
set_difference : forall a b c d. (Map(d, Unit, a), Map(d, Unit, b)) -> Map(d, Unit, c)
The elements of s1 that are not in s2.
set_to_list(set_difference(set_from_list([1, 2, 3]), set_from_list([2, 3])))
[1]
Data.String
String operations, byte-oriented and ASCII-accurate.
Built over the primitive UTF-8 string operations. Base includes this module.
Functions and Values
str_join
str_join : (String, List(String)) -> String
Join a list of strings, placing sep between adjacent elements.
str_join(", ", ["a", "b", "c"])
a, b, c
str_repeat
str_repeat : (String, Int) -> String
s repeated n times (the empty string when n <= 0).
str_repeat("ab", 3)
ababab
pad_left
pad_left : (String, Int) -> String
Right-align s to width w by prepending spaces (unchanged if already wider).
pad_left("42", 5)
42
pad_right
pad_right : (String, Int) -> String
Left-align s to width w by appending spaces (unchanged if already wider).
pad_right("42", 5)
42
lines_of
lines_of : (List(String)) -> String
Join a list of strings with newlines between them.
lines_of(["one", "two"])
one
two
occurs_at
occurs_at : (String, String, Int, Int) -> Bool
Helper for the substring queries: whether needle sits at byte offset j in s, comparing from position k.
starts_with
starts_with : (String, String) -> Bool
True when s begins with prefix.
starts_with("foo", "foobar")
true
ends_with
ends_with : (String, String) -> Bool
True when s ends with suffix.
ends_with("bar", "foobar")
true
index_of_go
index_of_go : (String, String, Int) -> Int
Helper for index_of: search for needle in s from byte offset j.
index_of
index_of : (String, String) -> Int
The byte offset of the first occurrence of needle in s, or -1 if absent.
index_of("bar", "foobar")
3
contains
contains : (String, String) -> Bool
True when needle occurs anywhere in s.
contains("oob", "foobar")
true
map_case
map_case : (String, Int, Buf, Bool) -> Buf
Helper for to_upper/to_lower: fold ASCII case mapping over s into a byte buffer (up selects upper- vs lower-casing).
to_upper
to_upper : (String) -> String
ASCII upper-case of s (non-letters unchanged).
to_upper("Hello")
HELLO
to_lower
to_lower : (String) -> String
ASCII lower-case of s (non-letters unchanged).
to_lower("Hello")
hello
ltrim_idx
ltrim_idx : (String, Int) -> Int
Helper for trim: the first non-whitespace byte index at or after i.
rtrim_idx
rtrim_idx : (String, Int) -> Int
Helper for trim: the index just past the last non-whitespace byte before i.
slice_bytes
slice_bytes : (String, Int, Int, Buf) -> Buf
Helper for trim: collect the bytes of s in [lo, hi) into buf.
trim
trim : (String) -> String
Strip leading and trailing ASCII whitespace.
trim(" hi ")
hi
index_of_from
index_of_from : (Int, String, Int) -> Int
The index of character c in s at or after position i, or -1 if absent.
split_from
split_from : (Int, String, Int) -> List(String)
Helper for split: split s on c, starting from position i.
split
split : (Int, String) -> List(String)
Split s into the pieces between each occurrence of character c.
split(char_at(",", 0), "a,b,c")
[a, b, c]
str_of_char
str_of_char : (Char) -> String
The single-character string containing c.
str_of_char(chr(65))
A
chars_from
chars_from : (String, Int) -> List(Char)
Helper for chars: the characters of s from position i onward.
chars
chars : (String) -> List(Char)
The list of characters in s.
chars("hi")
[104, 105]
Data.Tensor
Dense multi-dimensional tensors over a flat FloatBuf.
A Tensor is a flat buffer of f64 words plus three lists, one entry per axis: the axis shape (its extent), its strides (the flat-offset step for a unit step along that axis), and its axes (a name). Reading t[i, j] is a strided lookup: offset = sum_k idx[k] * strides[k], then a single tbuf_get. Because the layout lives entirely in the stride list, transposing by name is a permutation of the three lists with no data movement, and the underlying buffer is shared until a write forces a copy (the buffer’s own rc==1 in-place / shared-copy discipline).
The surface covers construction, indexing, transpose-by-name, a contiguity-checked reshape, elementwise math (map_tensor, zip_with_tensor, add/sub/mul/div, scale), full reductions (sum_all, prod_all, mean), and matmul. Every reduction and contraction sums in row-major source-loop order, so results are bit-identical across backends. Broadcasting is deliberately absent, and the math functions require contiguous operands (a transposed view must be materialised first).
t[i, j] and t[i, j] := v are surface sugar for at_tensor / tensor_set.
Types
Tensor
type Tensor = MkTensor(FloatBuf, List(Int), List(Int), List(String))
A dense tensor: flat storage plus per-axis shape, strides, and names.
Functions and Values
buf
buf : (Data.Tensor.Tensor) -> FloatBuf
The flat backing buffer.
shape
shape : (Data.Tensor.Tensor) -> List(Int)
The extent of each axis, outermost first.
shape(new([2, 3], 0.0))
[2, 3]
strides
strides : (Data.Tensor.Tensor) -> List(Int)
The flat-offset stride of each axis.
strides(new([2, 3], 0.0))
[3, 1]
axes
axes : (Data.Tensor.Tensor) -> List(String)
The name of each axis.
axes(new([2, 3], 0.0))
[0, 1]
rank
rank : (Data.Tensor.Tensor) -> Int
The number of axes.
rank(new([2, 3], 0.0))
2
size
size : (Data.Tensor.Tensor) -> Int
The total number of elements: the product of the shape.
size(new([2, 3], 0.0))
6
new
new : (List(Int), Float) -> Data.Tensor.Tensor
A tensor of the given shape with every element set to fill, row-major.
let t = new([2, 2], 7.0)
t[1, 1]
7
from_list
from_list : (List(Int), List(Float)) -> Data.Tensor.Tensor
A row-major tensor of the given shape filled from a flat list of values. Extra list elements past the shape’s size are ignored; missing ones stay 0.
let t = from_list([2, 2], [1.0, 2.0, 3.0, 4.0])
t[1, 0]
3
at_tensor
at_tensor : (Data.Tensor.Tensor, List(Int)) -> Float ! {Fail}
The element at a multi-index, or fail() if the offset is out of range. Backs t[i, j].
at_tensor(from_list([2, 2], [1.0, 2.0, 3.0, 4.0]), [0, 1])
2
tensor_set
tensor_set : (Data.Tensor.Tensor, List(Int), Float) -> Data.Tensor.Tensor ! {Fail}
A tensor equal to t but with the element at a multi-index set to v, or fail() if out of range. Backs t[i, j] := v.
let t = tensor_set(new([2, 2], 0.0), [0, 1], 5.0)
t[0, 1]
5
transpose
transpose : (Data.Tensor.Tensor, String, String) -> Data.Tensor.Tensor ! {Fail}
Transpose two named axes: a permutation of the shape, strides, and names with no data movement (the buffer is shared). Reading a transposed tensor walks the same buffer in the permuted stride order.
let t = transpose(from_list([2, 2], [1.0, 2.0, 3.0, 4.0]), "0", "1")
t[0, 1]
3
reshape
reshape : (Data.Tensor.Tensor, List(Int)) -> Data.Tensor.Tensor ! {Fail}
Reinterpret the elements under a new shape of the same size. Requires the tensor to be contiguous (row-major strides); a transposed view must be copied first, so reshaping one is a fail(). The new axes get default names.
shape(reshape(from_list([2, 2], [1.0, 2.0, 3.0, 4.0]), [4]))
[4]
map_tensor
map_tensor : forall e0. ((Float) -> Float ! {Fail, e0}, Data.Tensor.Tensor) -> Data.Tensor.Tensor ! {Fail, e0}
A tensor of the same shape with f applied to every element. Requires a contiguous input; the result is contiguous.
sum_all(map_tensor(\(x) -> x + 1.0, from_list([2], [10.0, 20.0])))
32
zip_with_tensor
zip_with_tensor : forall e0. ((Float, Float) -> Float ! {Fail, e0}, Data.Tensor.Tensor, Data.Tensor.Tensor) -> Data.Tensor.Tensor ! {Fail, e0}
Combine two identically-shaped contiguous tensors elementwise with f, or fail() if the shapes differ or either is not contiguous. No broadcasting.
sum_all(zip_with_tensor(\(x, y) -> x + y, from_list([2], [1.0, 2.0]), from_list([2], [3.0, 4.0])))
10
add
add : (Data.Tensor.Tensor, Data.Tensor.Tensor) -> Data.Tensor.Tensor ! {Fail}
Elementwise sum of two identically-shaped tensors.
sum_all(add(from_list([2], [1.0, 2.0]), from_list([2], [3.0, 4.0])))
10
sub
sub : (Data.Tensor.Tensor, Data.Tensor.Tensor) -> Data.Tensor.Tensor ! {Fail}
Elementwise difference.
mul
mul : (Data.Tensor.Tensor, Data.Tensor.Tensor) -> Data.Tensor.Tensor ! {Fail}
Elementwise (Hadamard) product, not matrix multiplication.
sum_all(mul(from_list([2], [2.0, 3.0]), from_list([2], [4.0, 5.0])))
23
div
div : (Data.Tensor.Tensor, Data.Tensor.Tensor) -> Data.Tensor.Tensor ! {Fail}
Elementwise quotient.
scale
scale : (Float, Data.Tensor.Tensor) -> Data.Tensor.Tensor ! {Fail}
Every element multiplied by a scalar.
sum_all(scale(2.0, from_list([2], [1.0, 2.0])))
6
sum_all
sum_all : (Data.Tensor.Tensor) -> Float ! {Fail}
The sum of every element, added in row-major order. Requires a contiguous tensor (so the summation order is well defined).
sum_all(from_list([2, 2], [1.0, 2.0, 3.0, 4.0]))
10
prod_all
prod_all : (Data.Tensor.Tensor) -> Float ! {Fail}
The product of every element, in row-major order.
prod_all(from_list([2], [3.0, 4.0]))
12
mean
mean : (Data.Tensor.Tensor) -> Float ! {Fail}
The arithmetic mean of every element.
mean(from_list([2, 2], [1.0, 2.0, 3.0, 4.0]))
2.5
sum_axis
sum_axis : (Data.Tensor.Tensor, String) -> Data.Tensor.Tensor ! {Fail}
Reduce over one named axis by summing, removing that axis (rank r becomes r - 1); the remaining axes keep their names. The contracted axis is summed in index order 0..extent (source loop order), so the result is bit-identical across backends. fail() on a missing axis or a non-contiguous input.
let s = sum_axis(from_list([2, 2], [1.0, 2.0, 3.0, 4.0]), "0")
(at_tensor(s, [0]), at_tensor(s, [1]))
(4, 6)
mean_axis
mean_axis : (Data.Tensor.Tensor, String) -> Data.Tensor.Tensor ! {Fail}
Reduce over one named axis by averaging: the sum over that axis divided by its extent, removing the axis. Same source-loop order and contiguity requirement as sum_axis.
let m = mean_axis(from_list([2, 2], [1.0, 2.0, 3.0, 4.0]), "1")
(at_tensor(m, [0]), at_tensor(m, [1]))
(1.5, 3.5)
matmul
matmul : (Data.Tensor.Tensor, Data.Tensor.Tensor) -> Data.Tensor.Tensor ! {Fail}
Matrix product of a rank-2 [m, k] tensor with a rank-2 [k, n] tensor, giving [m, n]. The contraction sums in source loop order, so the result is bit-identical across backends. fail() unless both operands are contiguous, rank 2, with matching inner extents.
let c = matmul(from_list([2, 2], [1.0, 2.0, 3.0, 4.0]), from_list([2, 2], [1.0, 0.0, 0.0, 1.0]))
(c[0, 0], c[1, 1])
(1, 4)
Data.UnionFind
A persistent union-find (disjoint-set) over an ordered key type.
A set is named by its canonical root, and uf_union always keeps the smaller key (by Ord) as the root, so a set’s representative is a pure function of its members, never of the order unions ran in. There is no path compression (that needs mutation); uf_find walks parent links to the root on each call. A key absent from the map is its own singleton root, so uf_empty needs no pre-population. The occurs check an HM unifier layers on top is type-specific and lives with the unifier, not here. Opt-in: not in Base.
Functions and Values
uf_empty
uf_empty : forall a b. Map(a, a, b)
The empty forest: every key is its own singleton root.
uf_find
uf_find : forall a b. (Map(b, b, a), b) -> b
The canonical root of x’s set.
uf_union
uf_union : forall a b. (Map(b, b, a), b, b) -> Map(b, b, a)
Merge the sets of x and y, keeping the smaller root; a no-op when they are already joined.
uf_equiv(uf_union(uf_union(uf_empty, 1, 2), 2, 3), 1, 3)
true
uf_equiv
uf_equiv : forall a b. (Map(b, b, a), b, b) -> Bool
True when x and y belong to the same set.
Data.Validation
Validation, the error-accumulating sibling of Result.
Where Result short-circuits on the first Err, Validation collects every error, so checking several independent fields reports all their failures at once instead of only the first. Combine independent validations with validate2 or validation_ap; both concatenate the error lists of whatever failed. Opt-in: not in Base.
Types
Validation
type Validation(e, a) = Valid(a) | Invalid(List(e)) deriving (Eq, Show)
A validated a, or the list of accumulated errors e.
Functions and Values
is_valid
is_valid : forall a b. (Data.Validation.Validation(a, b)) -> Bool
True when the value validated.
is_invalid
is_invalid : forall a b. (Data.Validation.Validation(a, b)) -> Bool
True when there were errors.
map_valid
map_valid : forall e0 a b c. ((c) -> b ! {e0}, Data.Validation.Validation(a, c)) -> Data.Validation.Validation(a, b) ! {e0}
Apply f to a Valid value, leaving accumulated errors untouched.
validation_ap
validation_ap : forall e0 a b c. (Data.Validation.Validation(a, (c) -> b ! {e0}), Data.Validation.Validation(a, c)) -> Data.Validation.Validation(a, b) ! {e0}
Applicative apply: when both sides validate, apply the function; otherwise keep every error from both sides, in order. This is the accumulation.
validate2
validate2 : forall e0 a b c d. ((c, d) -> b ! {e0}, Data.Validation.Validation(a, c), Data.Validation.Validation(a, d)) -> Data.Validation.Validation(a, b) ! {e0}
Combine two validations with the two-argument f, accumulating the errors of whichever failed.
validate2(\(a, b) -> a + b, Valid(1), Invalid(["oops"]))
Data.Validation.Invalid([oops])
validation_or
validation_or : forall a b. (a, Data.Validation.Validation(b, a)) -> a
The Valid value, or d when there were errors.
validation_of_result
validation_of_result : forall a b. (Result(a, b)) -> Data.Validation.Validation(b, a)
Turn a Result into a single-error Validation.
result_of_validation
result_of_validation : forall a b. (Data.Validation.Validation(a, b)) -> Result(b, List(a))
Turn a Validation back into a Result, keeping the whole error list on the Err side.
sequence_validation
sequence_validation : forall a b. (List(Data.Validation.Validation(a, b))) -> Data.Validation.Validation(a, List(b))
Collapse a list of validations into a validation of the list, accumulating every error across all of them.
Data.Vec
Fixed-length vectors indexed by a Nat dimension.
Vec(a, n) is a list of a whose length n is a type-level natural. The Nat kind unifies dimensions by equality of literals and variables only: there is no successor structure and no arithmetic, and n + m in a dimension position is declined at the parser. This module therefore ships exactly the operations that equality-only indexing can type honestly:
vemptyandvsinglepin a literal length (0,1). -vmappreserves the length (nappears on both sides). -vziprequires equal lengths (bothn); a length clash is a compile error that names both lengths.
Two operations are deliberately absent because their result length is not expressible under equality-only unification: a length-changing vcons : (a, Vec(a, n)) -> Vec(a, n + 1) and an append (Vec(a, n), Vec(a, m)) -> Vec(a, n + m). That is the declined line; writing either n + 1 produces a pointed rejection rather than a bare parse error.
vhead accepts any length, so it cannot statically forbid the empty vector (ruling it out needs n = m + 1, again arithmetic). On an empty vector it raises Fail rather than guarding with an Option; this is the head-on-empty consequence of the declined line.
Types
Vec
type Vec(a, n : Nat) = MkVec(List(a))
A vector of a with a type-level length n.
Functions and Values
vempty
vempty : forall a. () -> Data.Vec.Vec(a, 0)
The empty vector, length 0.
vsingle
vsingle : forall a. (a) -> Data.Vec.Vec(a, 1)
The one-element vector, length 1.
vto_list(vsingle(42))
[42]
vto_list
vto_list : forall a b. (Data.Vec.Vec(a, b)) -> List(a)
The underlying list, forgetting the static length.
vto_list(vsingle(7))
[7]
vmap
vmap : forall a b c. ((a) -> b, Data.Vec.Vec(a, c)) -> Data.Vec.Vec(b, c)
Apply f to every element, preserving the length.
vto_list(vmap(\(x) -> x * 2, vsingle(21)))
[42]
vzip
vzip : forall a b c. (Data.Vec.Vec(a, b), Data.Vec.Vec(c, b)) -> Data.Vec.Vec((a, c), b)
Zip two vectors of the SAME length into a vector of pairs. The shared n forces equal lengths; a mismatch is rejected at compile time, naming both.
vto_list(vzip(vsingle(1), vsingle(2)))
[(1, 2)]
A length mismatch is a compile error:
vzip(vsingle(1), vempty())
vhead
vhead : forall a b. (Data.Vec.Vec(a, b)) -> a ! {Fail}
The first element. n is unconstrained, so the empty vector is not ruled out statically; on an empty vector this raises Fail.
vhead(vsingle(42))
42
Syntax.Analysis
Analysis walks over the surface syntax tree.
Every function here is a few lines over Control.Layer’s generic queries at expr_layer(), and that is the point: a pass asks for “every variable occurrence” or “every call site” instead of writing its own recursion, and the answer comes back in source order every time. Nothing here decides what a name means; that is the resolver’s job and Syntax.Rename queries it.
Types
ExprCensus
type ExprCensus = ExprCensus {
nodes: Int,
vars: Int,
calls: Int,
binds: Int,
holes: Int
}
A shape census of one expression: the counts a size heuristic, a lint threshold, or a complexity report is built from. nodes is every node, vars every variable occurrence, calls every application, holes every typed hole, and binds every form that introduces a name (a let, a mutable declaration, a lambda, a match, a for loop, a comprehension), counted once per form rather than once per name it binds.
Functions and Values
an_nodes
an_nodes : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(Syntax.Ast.Sp(Syntax.Ast.Expr))
Every node of an expression, root first, in source order.
an_where
an_where : forall e0. ((Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Bool ! {e0}, Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(Syntax.Ast.Sp(Syntax.Ast.Expr)) ! {e0}
Every node satisfying a predicate, in source order.
an_size
an_size : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Int
The number of nodes in an expression.
an_depth
an_depth : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Int
The height of an expression: 1 at a leaf.
an_spans
an_spans : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(Syntax.Source.Span)
The span of every node, root first, in source order.
an_var_uses
an_var_uses : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List((String, Syntax.Source.Span))
Every variable occurrence: the identifier and the exact span it covers. This is the occurrence table an editor tool wants, and the one place a purely syntactic tool should stop: it says where a name is written, never what it refers to.
an_var_names
an_var_names : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(String)
Every name written as a variable, in first-occurrence order and without repeats.
an_uses_of
an_uses_of : (String, Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Int
How many times a name is written as a variable.
an_calls
an_calls : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(Syntax.Ast.Sp(Syntax.Ast.Expr))
Every call site, in source order.
an_is_call
an_is_call : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Bool
Whether a node is an application. The predicate an_calls filters on, made public so a rewrite can scope a rule to call sites without restating it.
an_call_targets
an_call_targets : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(String)
The name of every directly called function, in source order. A call whose head is not a plain variable (a field access, a lambda, a computed callee) contributes nothing rather than a guess.
an_holes
an_holes : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(Syntax.Source.Span)
The span of every typed hole, in source order: what a completion tool asks for first.
an_any
an_any : forall e0. ((Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Bool ! {e0}, Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Bool ! {e0}
Whether any node satisfies the predicate, short-circuiting on the first hit.
an_census
an_census : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Syntax.Analysis.ExprCensus
Count every shape in one walk. The combining operation is field-wise addition, which is what lay_summarize was shaped for: one pass, one caller-supplied monoid, no repeated traversals.
Syntax.Ast
The typed surface syntax that the prism-surface-syntax-v1 artifact decodes into. Constructor prefixes name the family (I items, E expressions, P patterns, Ty types), and spanned nodes wrap in Sp. The shapes mirror the compiler’s exporter exactly, so a decoded document re-encodes to identical bytes.
Types
Sp
type Sp(a) = Sp { node: a, span: Span, synth: Bool }
A parsed node with its byte span and the parse-sugar bit. Expressions and patterns are spanned; synth is set when the parser rewrote surface sugar into this node rather than reading it verbatim.
Suffix
type Suffix = SufNone | SufI64 | SufU64
An integer literal’s optional width suffix: i64, u64, or none.
Kind
type Kind
= KType
| KRow
| KNat
| KFun(Kind, Kind)
A kind: the classifier of a type parameter. Ground kinds classify types, rows, and type-level naturals; KFun is the kind of a type constructor.
EffLabel
type EffLabel = EffLabel { name: String, args: List(Ty) }
One effect label: an effect name applied to zero or more type arguments, as it appears in an effect row or an effect alias.
Row
type Row = Row { labels: List(EffLabel), tail: Option(String) }
An effect row: the labels present plus an optional row-variable tail. The empty row and a tail-less label list share this one shape.
Ty
type Ty
= TyInt
| TyI64
| TyU64
| TyBool
| TyUnit
| TyFloat
| TyChar
| TyStr
| TyVar(String)
| TyApp(String, List(Ty))
| TyState(Int)
| TyForall(List(String), Ty)
| TyFun(List(Ty), Row, Ty)
| TyCon(String, List(Ty))
| TyTuple(List(Ty))
| TyUnboxedTuple(List(Ty))
| TyUnboxedRecord(List(CField))
| TyRowLit(Row)
| TyNat(Int)
| TyUsage(Ty, List(String))
A surface type. Ground types, type variables, applications, function types with an effect row, saturated constructors, tuples, unboxed aggregates, row literals, type-level naturals, and usage-qualified types.
CField
type CField = CField { name: String, ty: Ty }
A named field carrying a type: a record constructor field or an unboxed record field.
NamedExpr
type NamedExpr = NamedExpr { name: String, value: Sp(Expr) }
A named field carrying an expression: a record literal field, a record update field, a where binding, or a converter override.
Expr
type Expr
= EInt(String, Suffix)
| EFloat(String)
| EChar(String)
| EBool(Bool)
| EUnit
| EStr(String)
| EVar(String)
| EHole(String)
| EBin(String, Sp(Expr), Sp(Expr))
| ENeg(Sp(Expr))
| EIf(Sp(Expr), Sp(Expr), Sp(Expr))
| ELet(String, Sp(Expr), Sp(Expr))
| ELam(List(Param), Sp(Expr))
| ECall(Sp(Expr), List(Sp(Expr)))
| EPipe(Sp(Expr), Sp(Expr))
| EMatch(Sp(Expr), List(Arm))
| EList(List(Sp(Expr)))
| ETuple(List(Sp(Expr)))
| EField(Sp(Expr), String)
| EUnboxedTuple(List(Sp(Expr)))
| EUnboxedRecord(List(NamedExpr))
| EUnboxedField(Sp(Expr), String)
| ERecord(String, List(NamedExpr))
| ERecordUpdate(Sp(Expr), String, List(NamedExpr))
| EPathUpdate(Sp(Expr), List(PathUpdate))
| EHandle(Sp(Expr), List(HandlerArm), Bool)
| EMask(String, Sp(Expr))
| EInst(Sp(Expr), List(String))
| EIndex(Sp(Expr), Sp(Expr))
| EIndexSet(Sp(Expr), Sp(Expr), Sp(Expr))
| EAnn(Sp(Expr), Ty)
| EMarker(String)
| ENamedHandle(String, Sp(Expr), List(HandlerArm))
| EVarDecl(String, Sp(Expr), Sp(Expr))
| EAssign(String, Sp(Expr))
| EIndexAssign(Sp(Expr), Sp(Expr), Sp(Expr))
| EThrow(String, List(Sp(Expr)))
| ETryCatch(Sp(Expr), List(CatchArm))
| EFor(String, Sp(Expr), List(Qual), Sp(Expr))
| EWhile(Sp(Expr), Sp(Expr))
| ELoop(Sp(Expr))
| EBreak
| EContinue
| EReturn(Sp(Expr))
| EComp(Sp(Expr), String, Sp(Expr), List(Qual))
| EDefault(Sp(Expr), Sp(Expr))
| ETransact(Sp(Expr), Sp(Expr))
| EProbe(String, Sp(Expr))
| EOptChain(Sp(Expr), String)
| ERange(List(Sp(Expr)), Sp(Expr))
| ECompose(String, Sp(Expr), Sp(Expr))
| EReadPath(Sp(Expr), List(PathStep))
A surface expression. One constructor per node kind the exporter emits, including the desugarable surface forms (loops, comprehensions, throw and try-catch, path reads and updates) that the parser records before lowering.
Arm
type Arm = Arm { pat: Sp(Pat), guard: Option(Sp(Expr)), body: Sp(Expr) }
One match arm: a spanned pattern, an optional boolean guard, and the body taken when the pattern matches and the guard holds.
HandlerArm
type HandlerArm
= HReturn(String, Sp(Expr))
| HOp(String, List(String), String, Sp(Expr))
| HOnce(String, List(String), Sp(Expr))
| HVal(String, Sp(Expr))
| HNever(String, List(String), Sp(Expr))
One arm of an effect handler: the return clause, a full operation clause binding the resumption, or the once, val, and never sugars.
CatchArm
type CatchArm = CatchArm {
name: String,
binders: List(String),
body: Sp(Expr),
span: Span
}
One catch arm of a try-catch: the error name, the binders for its payload, and the handler body.
Qual
type Qual = QGuard(Sp(Expr)) | QBind(String, Sp(Expr))
One qualifier of a for-loop or comprehension: a boolean guard or a binding generator.
PathStep
type PathStep
= PSField(String)
| PSEach
| PSCase(String)
| PSIndex(Sp(Expr))
| PSWhere(Sp(Expr))
One step of an optic path: a field, every element, a case selection, an index, or a where filter.
PathOp
type PathOp = POSet(Sp(Expr)) | POModify(Sp(Expr))
The write at the end of an optic path: set to a value or modify by a function.
PathUpdate
type PathUpdate = PathUpdate { path: List(PathStep), op: PathOp }
One update in a path-update expression: the path walked and the write performed at its end.
Pat
type Pat
= PWild
| PVar(String)
| PInt(String, Suffix)
| PFloat(String)
| PChar(String)
| PBool(Bool)
| PCtor(String, List(Sp(Pat)))
| PTuple(List(Sp(Pat)))
| PRecord(String, List(PatField), Bool)
| POr(List(Sp(Pat)))
A surface pattern: wildcards, variables, literals, constructor and tuple patterns, record patterns with an optional rest, and alternations. An alternation is expanded into one match arm per alternative before the checker, so it appears only in the surface seam.
PatField
type PatField = PatField { name: String, pat: Sp(Pat) }
One field of a record pattern: the field name and the pattern bound to it.
Param
type Param = Param {
name: String,
pat: Option(Sp(Pat)),
ty: Option(Ty),
is_borrow: Bool,
dflt: Option(Sp(Expr))
}
A function parameter: its name, the pattern it was written as if it was written as one rather than named, an optional type annotation, whether it is taken by borrow, and an optional default expression. A pattern parameter’s name is synthesized from its position, so the pattern is the half of it the source wrote.
Constraint
type Constraint = Constraint { cls: String, ty: Ty, span: Span }
A class constraint on a type: the class name, the constrained type, and its span.
Decl
type Decl = Decl {
name: String,
params: List(Param),
ret: Option(Ty),
eff: Option(Row),
constraints: List(Constraint),
body: Sp(Expr),
wheres: List(NamedExpr),
reqs: List(Sp(Expr)),
enss: List(Ensure),
measure: Option(Sp(Expr)),
is_test: Bool,
totality: Option(String),
fip_word: Option(String),
is_replayable: Bool,
is_no_alloc: Bool,
span: Span
}
A function, constant, or logic-function declaration, with every optional clause the parser can attach: effect row, constraints, where bindings, requires and ensures, a decreases measure, and the totality and resource modifiers.
Ensure
type Ensure = Ensure { binder: String, expr: Sp(Expr) }
One ensures clause: the binder for the result and the postcondition expression over it.
CtorShape
type CtorShape = CPositional(List(Ty)) | CRecord(List(CField))
The shape of a data constructor: a positional argument list or a named field list.
Ctor
type Ctor = Ctor { name: String, shape: CtorShape }
One data constructor: its name and its argument shape.
Deriving
type Deriving = Deriving { name: String, span: Span }
One derived class on a data declaration: the class name and its span.
Method
type Method = Method { name: String, ty: Ty }
One class method signature: the method name and its declared type.
EffOp
type EffOp = EffOp {
name: String,
params: List(Ty),
ret: Ty,
grade: Option(String)
}
One operation of an effect declaration: its name, parameter types, result type, and optional resumption grade.
Rung
type Rung = Rung {
name: String,
base: Option(String),
fields: List(RungField),
frozen: Option(String),
span: Span
}
One rung of a stable type: the version name, an optional base to inherit from, its fields, and an optional frozen marker.
RungField
type RungField = RungField { name: String, ty: Ty, dflt: Option(Sp(Expr)) }
One field of a stable rung: the field name, its type, and an optional default expression.
Converter
type Converter = Converter {
dir: String,
from: String,
to: String,
base: Sp(Expr),
overrides: List(NamedExpr),
drop_loss: List(String),
span: Span
}
One converter between stable rungs: the direction, the from and to rung names, the base expression, overriding field bindings, and dropped fields.
MigrationRoute
type MigrationRoute = MAuto | MVersion(MigrationDir, MigrationDir)
The route of a stable migration: automatic, or an explicit upgrade and downgrade pair.
MigrationDir
type MigrationDir = DAuto | DExpr(Sp(Expr))
One direction of an explicit migration: automatic or a given expression.
Migration
type Migration = Migration {
from: String,
to: String,
route: MigrationRoute,
span: Span
}
One migration of a stable type: the from and to rung names and its route.
Item
type Item
= IImport { path: List(String), mod_alias: Option(String), names: Option(List(String)), glob: Bool, reexport: Bool, span: Span }
| IData { nt: Bool, name: String, params: List(String), param_kinds: List(Kind), ctors: List(Ctor), derivs: List(Deriving), span: Span, vis: Option(String), dep: Option(String) }
| IEffect { name: String, params: List(String), ops: List(EffOp), span: Span, vis: Option(String), dep: Option(String) }
| IError { name: String, arg_types: List(Ty), span: Span, vis: Option(String), dep: Option(String) }
| IEffAlias { name: String, labels: List(EffLabel), span: Span, vis: Option(String), dep: Option(String) }
| ISynonym { name: String, params: List(String), ty: Ty, span: Span, vis: Option(String), dep: Option(String) }
| IClass { name: String, param: String, supers: List(String), methods: List(Method), span: Span, vis: Option(String), dep: Option(String) }
| IInstance { name: String, cls: String, head: Ty, context: List(Constraint), decls: List(Decl), span: Span }
| ICanonical { cls: String, head: Ty, name: String, span: Span }
| IPattern { name: String, params: List(String), forty: String, viewfn: Sp(Expr), makefn: Option(Sp(Expr)), span: Span, vis: Option(String), dep: Option(String) }
| IStable { name: String, rungs: List(Rung), converters: List(Converter), migrations: List(Migration), span: Span, vis: Option(String), dep: Option(String) }
| IDecl { decl: Decl, declkind: String, vis: Option(String), dep: Option(String) }
A top-level item of a source file, tagged by its declaration family. Named items carry an optional visibility (pub or opaque) and an optional deprecation message; imports, instances, and canonical bindings do not.
Functions and Values
node_of
node_of : forall a. (Syntax.Ast.Sp(a)) -> a
The wrapped node, without its location.
span_of
span_of : forall a. (Syntax.Ast.Sp(a)) -> Syntax.Source.Span
The node’s byte span.
is_synth
is_synth : forall a. (Syntax.Ast.Sp(a)) -> Bool
Whether the parser synthesized this node from surface sugar rather than reading it verbatim from the source.
Syntax.Codec
Codecs for the versioned syntax artifacts. Decoding turns the compiler’s exports into the typed Syntax vocabularies, rejecting wrong schema tags, malformed shapes, and spans that invert or reach past the embedded source with one structured error; encoding is the exact inverse, re-emitting identical bytes.
The printer here matches the compiler’s export format precisely: two-space indent, one element per line, a space after each key’s colon, [] and {} for empty collections, insertion-order object keys, and the shared escape discipline (named escapes plus lowercase \u00xx for other control characters). Field order is part of the schema: envelope keys are in declaration order, node objects are alphabetical, and optional fields are omitted rather than null.
Types
CodecError
type CodecError = CodecError { path: String, reason: String }
A structured decode refusal: the path of the offending value inside the document and the reason it was rejected.
TokensDoc
type TokensDoc = TokensDoc {
schema: String,
compiler: String,
source: SourceFile,
raw: List(Token),
parse: List(Token),
trivia: List(Trivia)
}
A decoded prism-syntax-tokens-v1 document: the envelope identity, the embedded source, the raw and post-layout token streams, and the trivia events, all in stream order.
SurfaceDoc
type SurfaceDoc = SurfaceDoc {
schema: String,
compiler: String,
source: SourceFile,
items: List(Item)
}
A decoded prism-surface-syntax-v1 document: the envelope identity, the embedded source, and the ordered item list of the parsed file.
Effects
Decode
effect Decode
never fail_decode(CodecError) : a
Functions and Values
tokens_schema
tokens_schema : () -> String
The schema tag of the token-stream artifact.
codec_error_message
codec_error_message : (Syntax.Codec.CodecError) -> String
Render a codec error as one line.
decode_tokens
decode_tokens : (String) -> Result(Syntax.Codec.TokensDoc, Syntax.Codec.CodecError)
Decode a prism-syntax-tokens-v1 document from its exact bytes. A wrong or missing schema tag, malformed JSON, missing field, or invalid span is a structured CodecError, never a partial document.
canonical_json
canonical_json : (Json.Json) -> String
Render a JSON value in the export layout the encoders here use, for tools that derive a document from an artifact instead of re-encoding a decoded one.
encode_tokens
encode_tokens : (Syntax.Codec.TokensDoc) -> String
Encode a token document back to the exact artifact bytes the compiler emits: encode_tokens after decode_tokens is byte-identity on every well-formed export.
surface_schema
surface_schema : () -> String
The schema tag of the surface-syntax artifact.
encode_surface
encode_surface : (Syntax.Codec.SurfaceDoc) -> String
Encode a surface document back to the exact artifact bytes the compiler emits: encode_surface after decode_surface is byte-identity on every well-formed export.
decode_surface
decode_surface : (String) -> Result(Syntax.Codec.SurfaceDoc, Syntax.Codec.CodecError)
Decode a prism-surface-syntax-v1 document from its exact bytes. A wrong or missing schema tag, malformed JSON, missing field, or invalid span is a structured CodecError, never a partial document.
decode_diagnostics
decode_diagnostics : (String) -> Result(Syntax.Diagnostic.DiagnosticsDoc, Syntax.Codec.CodecError)
Decode a prism-syntax-diagnostics-v1 document. Total: every malformed input lands on a CodecError naming the JSON path that refused.
encode_diagnostics
encode_diagnostics : (Syntax.Diagnostic.DiagnosticsDoc) -> String
Encode a diagnostics document to the canonical artifact bytes, the exact inverse of decode_diagnostics on every Rust-produced document.
run_decode
run_decode : forall e0 a. (() -> a ! {Syntax.Codec.Decode, e0}) -> Result(a, Syntax.Codec.CodecError) ! {e0}
Run a decoding computation, turning a fail_decode into Err and a completed decode into Ok.
decode_resolved
decode_resolved : (String) -> Result(Syntax.Resolved.ResolvedDoc, Syntax.Codec.CodecError)
Decode a prism-resolved-syntax-v1 document. Total: every malformed input lands on a CodecError naming the JSON path that refused. The node tree decodes recursively; a child list is bounded by the input’s own nesting.
encode_resolved
encode_resolved : (Syntax.Resolved.ResolvedDoc) -> String
Syntax.Cursor
The mechanical half of recursive descent: a token cursor with peek, advance, and expect, and a Pratt driver over a binding-power table.
Deliberately not a combinator library. There is no alternation operator, no backtracking engine, no grammar vocabulary; a caller writes the grammar by hand, in ordinary functions, and hands this module the two things a hand-written parser gets subtly wrong. The first is span arithmetic: joining two spans, covering a consumed run, and pointing a caret at a position where nothing was read. The second is expectation merging: unmet expectations join a set held at the furthest token the cursor ever reached, so a refusal names every alternative tried at the position the parse died at rather than only the last one attempted. Rewinding is where that record is classically lost, so rewinding is a named operation (cursor_restore) that returns the position and keeps the record.
Expectations are token wire names, the vocabulary of Syntax.Token, and a refusal renders as a Syntax.Diagnostic under the compiler’s own parse codes. A parser built on this cursor therefore reports through the same document every other syntax fault does, with no error type of its own.
Types
Cursor
type Cursor = Cursor {
toks: Array(Token),
pos: Int,
stop: Int,
far: Int,
wanted: List(String)
}
A token cursor: the tokens being read, the read position, the byte offset the end-of-input caret points at, and the failure record (the furthest position at which an expectation was recorded, and the names recorded there). The failure record is part of the cursor rather than of a failure value because it must outlive every failure a parse recovers from.
Reply
type Reply(a) = Took(a, Cursor) | Stuck(Cursor)
The reply of a cursor step: the value and the advanced cursor, or the cursor alone. A refusal still carries a cursor, because the failure record it accumulated is the part worth reporting.
Assoc
type Assoc = ALeft | ARight | ANone deriving (Eq)
How an infix operator groups a chain of its own level. A non-associative operator declines a second application at its level, leaving the second operator unconsumed for the caller to refuse.
Infix
type Infix = Infix { op: String, level: Int, assoc: Assoc }
One infix operator: the token spelling, its level (higher binds tighter), and how it associates.
Prefix
type Prefix = Prefix { op: String, level: Int }
One prefix operator: the token spelling and the level its operand is parsed at. An operator that binds tighter than that level is pulled into the operand; one that binds looser takes the prefix application as its left operand.
Pratt
type Pratt(a) = Pratt {
infixes: List(Infix),
prefixes: List(Prefix),
leaf: (Cursor) -> Reply(a),
bin: (String, a, a) -> a,
pre: (String, a) -> a
}
Everything the driver needs that is not the cursor: the two operator tables, the leaf parser, and the two node builders. The leaf parser owns primaries, and so owns parentheses, and so owns the recursion back into the driver: the driver knows about operators and nothing else.
Functions and Values
span_join
span_join : (Syntax.Source.Span, Syntax.Source.Span) -> Syntax.Source.Span
The smallest span covering both: the earlier start and the later end. The one operation a parser needs to give a node built from parts the extent of those parts.
let s = span_join(span_at(2), span_at(11))
(s.lo, s.hi)
(2, 11)
span_at
span_at : (Int) -> Syntax.Source.Span
The empty span at a byte offset: the caret a diagnostic points with when it has a position but nothing was consumed there.
let s = span_at(12)
(s.lo, s.hi)
(12, 12)
span_cover
span_cover : (List(Syntax.Source.Span)) -> Option(Syntax.Source.Span)
The smallest span covering every span in the list, or None for the empty list. Joining has no identity over spans (the empty span at offset zero is a position, not a neutral element), so the empty case is absent rather than invented.
cursor_of
cursor_of : (List(Syntax.Token.Token), Int) -> Syntax.Cursor.Cursor
A cursor over a token list. stop is the byte offset the end-of-input caret points at, normally the length of the source the tokens were lexed from, so a refusal past the last token still carries a position.
cursor_count
cursor_count : (Syntax.Cursor.Cursor) -> Int
The number of tokens the cursor reads over.
cursor_at_end
cursor_at_end : (Syntax.Cursor.Cursor) -> Bool
Whether every token has been consumed.
cursor_peek_at
cursor_peek_at : (Syntax.Cursor.Cursor, Int) -> Option(Syntax.Token.Token)
The token k positions ahead of the read position, or None past the end.
cursor_peek
cursor_peek : (Syntax.Cursor.Cursor) -> Option(Syntax.Token.Token)
The token at the read position, or None at the end. Peeking never consumes and never records an expectation: a parser that looks before it leaps says what it wanted with cursor_note.
cursor_kind
cursor_kind : (Syntax.Cursor.Cursor) -> Option(Syntax.Token.TokenKind)
The kind of the token at the read position, or None at the end.
cursor_span
cursor_span : (Syntax.Cursor.Cursor) -> Syntax.Source.Span
The span the cursor points at: the next token’s span, or the end-of-input caret when the stream is exhausted.
cursor_advance
cursor_advance : (Syntax.Cursor.Cursor) -> Syntax.Cursor.Cursor
The cursor advanced past one token. At the end this is the identity, so a parser that advances on a token it did not check cannot run off the stream.
cursor_since
cursor_since : (Syntax.Cursor.Cursor, Syntax.Cursor.Cursor) -> Syntax.Source.Span
The span covering the tokens consumed between saved and c, or the caret at the current position when nothing was consumed. A parser gives a node its extent by saving the cursor before it and asking for the run after it, rather than by joining the spans of its parts by hand and forgetting the delimiters.
end_of_input
end_of_input : () -> String
The name the end of the token stream is expected under. Not a token, so not a token kind, but an expectation like any other.
cursor_note_name
cursor_note_name : (Syntax.Cursor.Cursor, String) -> Syntax.Cursor.Cursor
Record name as admissible at the read position, consuming nothing.
The record only ever moves forward. A name recorded past the furthest position reached replaces the set and moves the mark; a name at the furthest position joins the set; a name behind it is dropped. That ordering is the whole point: a parse that tries an alternative, gets three tokens deep and fails, then rewinds, must still report what was wanted three tokens in, not what was wanted at the place it rewound to.
cursor_note
cursor_note : (Syntax.Cursor.Cursor, Syntax.Token.TokenKind) -> Syntax.Cursor.Cursor
Record a token kind as admissible at the read position. The name recorded is the kind’s wire name, so an expectation set, a token artifact, and a diagnostic’s expected field all speak one vocabulary.
cursor_expect
cursor_expect : (Syntax.Cursor.Cursor, Syntax.Token.TokenKind) -> Syntax.Cursor.Reply(Syntax.Token.Token)
Consume the next token when its kind is k. On a mismatch nothing is consumed and k joins the expectation set at this position, so a parser that tries several kinds in turn accumulates all of them: expecting one of a set is spelled as expecting each of its members at one position, never as a separate combinator.
cursor_expect_fixed
cursor_expect_fixed : (Syntax.Cursor.Cursor, String) -> Syntax.Cursor.Reply(Syntax.Token.Token)
Consume the next token when it is the fixed token spelled s.
cursor_expect_end
cursor_expect_end : (Syntax.Cursor.Cursor) -> Syntax.Cursor.Reply(Unit)
Require that every token has been consumed, recording the end of the stream as the expectation when one is left over.
cursor_restore
cursor_restore : (Syntax.Cursor.Cursor, Syntax.Cursor.Cursor) -> Syntax.Cursor.Cursor
Rewind to a saved cursor while keeping the failure record reached since.
A parser that tries an alternative and gives it up restores with this: the position returns, the expectations do not. Rebuilding the saved cursor by hand instead is the classic way to report the expectations of the position the parse retreated to rather than of the position it died at.
let c0 = cursor_of(Nil, 0)
let c1 = cursor_note_name(c0, "ident")
str_join(", ", cursor_expected(cursor_restore(c0, c1)))
ident
cursor_expected
cursor_expected : (Syntax.Cursor.Cursor) -> List(String)
The names recorded at the furthest position reached, in the order they were first recorded.
let c = cursor_note_name(cursor_of(Nil, 3), "ident")
str_join(", ", cursor_expected(cursor_note_name(c, "int")))
ident, int
cursor_far_span
cursor_far_span : (Syntax.Cursor.Cursor) -> Syntax.Source.Span
The span the failure record points at: the token at the furthest position reached, or the end-of-input caret.
cursor_message
cursor_message : (Syntax.Cursor.Cursor) -> String
The rendered message of an unmet expectation: the merged expectation set and what stood there instead.
cursor_code
cursor_code : (Syntax.Cursor.Cursor) -> String
The stable code an unmet expectation carries: the parser’s end-of-input code when the furthest position reached is past the last token, and its general syntax code otherwise. Both match the compiler’s own parse codes.
cursor_diagnostic
cursor_diagnostic : (Syntax.Cursor.Cursor) -> Syntax.Diagnostic.Diagnostic
The refusal an unmet expectation produces: a parse-phase diagnostic at the furthest position reached, carrying the merged expectation set. A cursor failure is a diagnostic and never a bespoke error value, so a hand-written parser’s refusals join the same document the compiler’s own do.
infix_left_bp
infix_left_bp : (Syntax.Cursor.Infix) -> Int
The power binding an infix operator’s left operand.
Levels are doubled so each has a half step above it. A left-associative operator’s right power sits in that half step, which stops a second operator of the same level from being pulled into the right operand; a right associative operator’s left power sits there instead, which lets it be. A non-associative operator is left-shaped here and refuses the repeat outright.
infix_right_bp
infix_right_bp : (Syntax.Cursor.Infix) -> Int
The power an infix operator’s right operand is parsed at.
prefix_bp
prefix_bp : (Syntax.Cursor.Prefix) -> Int
The power a prefix operator’s operand is parsed at.
infix_lookup
infix_lookup : (List(Syntax.Cursor.Infix), String) -> Option(Syntax.Cursor.Infix)
The entry for an operator spelling in an infix table, or None when the spelling is not an infix operator.
prefix_lookup
prefix_lookup : (List(Syntax.Cursor.Prefix), String) -> Option(Syntax.Cursor.Prefix)
The entry for an operator spelling in a prefix table, or None when the spelling is not a prefix operator.
pratt_expr
pratt_expr : forall a. (Syntax.Cursor.Pratt(a), Syntax.Cursor.Cursor, Int) -> Syntax.Cursor.Reply(a)
Parse one expression whose operators all bind at least as tightly as min_bp: one operand, then every operator that outbinds the caller.
pratt_parse
pratt_parse : forall a. (Syntax.Cursor.Pratt(a), Syntax.Cursor.Cursor) -> Syntax.Cursor.Reply(a)
Parse one expression at the loosest binding power. Tokens after it are left for the caller, which is what makes this usable inside a larger grammar.
pratt_all
pratt_all : forall a. (Syntax.Cursor.Pratt(a), Syntax.Cursor.Cursor) -> Syntax.Cursor.Reply(a)
Parse one expression and require the whole token stream to be consumed. A trailing operator the driver declined (a repeated non-associative one) is refused here, at the position it stands.
expr_infix_table
expr_infix_table : () -> List(Syntax.Cursor.Infix)
The infix table of Prism’s own expression grammar, loosest first. Levels are relative: only their order and each operator’s associativity carry meaning, and both are pinned against the compiler’s parser by a differential test rather than by this comment.
expr_prefix_table
expr_prefix_table : () -> List(Syntax.Cursor.Prefix)
The prefix table of Prism’s own expression grammar: unary minus, sitting between multiplication and exponentiation. -x * y is (-x) * y and, by the mathematical convention, -x ^ y is -(x ^ y).
Syntax.Diagnostic
The typed vocabulary of the prism-syntax-diagnostics-v1 artifact.
One document per source file: the embedded source identity and every syntax-boundary diagnostic the lexer or parser produced for it, in source order. An accepted file carries the empty list, so acceptance and refusal share one shape. expected and related are reserved surfaces: always present, possibly empty, so a reader written today survives their arrival.
Types
DiagPhase
type DiagPhase = DPLex | DPParse
The phase that raised a diagnostic.
Diagnostic
type Diagnostic = Diagnostic {
code: String,
phase: DiagPhase,
span: Span,
message: String,
expected: List(String),
related: List(Span)
}
One diagnostic: the stable append-only code, the raising phase, the primary half-open byte span (a lex fault is a caret, lo == hi), the rendered message, the parser’s canonical expectation set when it has one, and related spans.
DiagnosticsDoc
type DiagnosticsDoc = DiagnosticsDoc {
schema: String,
compiler: String,
source: SourceFile,
diagnostics: List(Diagnostic)
}
A decoded diagnostics document: the envelope identity, the embedded source every span indexes into, and the diagnostics in source order.
Functions and Values
diagnostics_schema
diagnostics_schema : () -> String
The schema tag this vocabulary decodes.
doc_accepted
doc_accepted : (Syntax.Diagnostic.DiagnosticsDoc) -> Bool
Whether the document accepted its source (no diagnostics).
Syntax.Edit
Span-addressed source edits that refuse rather than corrupt.
An edit is a byte span and its replacement text, which is the only edit vocabulary a tool built on the syntax seams needs: every artifact those seams publish addresses source by exact byte span, so a tool that computes spans can state its whole result as a list of them without ever holding a mutable buffer.
The apply step is where the guarantee lives. Edits are sorted by start offset (a stable sort, so two edits at the same offset keep the order the caller gave them), a span that runs backwards or past the end is refused, overlapping edits are refused, and the spliced result is re-lexed before it is returned. An edit set that would produce a file the lexer cannot read comes back as a structured refusal naming the lexer’s own error, never as a corrupt file. That is the difference between a rename tool and a search-and-replace: this one fails closed.
The re-lex is a lexical check and nothing more. It catches an edit that unbalances a string, a hole, or a numeric literal; it does not claim the result parses, typechecks, or means what the caller intended.
Types
Edit
type Edit = Edit { span: Span, text: String }
One edit: the byte span it replaces and the text that replaces it. An insertion is an empty span, a deletion is empty text.
EditError
type EditError
= EdBadSpan(Span)
| EdPastEnd(Span, Int)
| EdOverlap(Span, Span)
| EdRelex(LexError)
deriving (Eq, Show)
Why an edit set was refused. Every case names the offending span, so a tool can point at the edit it got wrong.
Functions and Values
ed_insert_at
ed_insert_at : (Int, String) -> Syntax.Edit.Edit
An insertion at one offset.
ed_delete
ed_delete : (Syntax.Source.Span) -> Syntax.Edit.Edit
A deletion of one span.
ed_replace
ed_replace : (Syntax.Source.Span, String) -> Syntax.Edit.Edit
A replacement of one span.
ed_span_text
ed_span_text : (String, Syntax.Source.Span) -> Option(String)
The current text under a span, or None when the span does not address this text. What a rename reads to check that an occurrence still says what the plan assumed.
ed_message
ed_message : (Syntax.Edit.EditError) -> String
A human-readable account of a refusal.
ed_sort
ed_sort : (List(Syntax.Edit.Edit)) -> List(Syntax.Edit.Edit)
The edits sorted by start offset, stably: two edits starting at the same offset keep their input order, so a caller that means “insert A then B here” gets it.
ed_apply
ed_apply : (String, List(Syntax.Edit.Edit)) -> Result(String, Syntax.Edit.EditError)
Apply an edit set to a source text.
The result is the spliced text when every edit is well formed, the edits do not overlap, and the splice still lexes. Otherwise it is the first refusal found, in that order.
ed_count
ed_count : (List(Syntax.Edit.Edit)) -> Int
How many edits an edit set holds: the count a plan reports before anything is applied.
Syntax.Flow
Call-graph flow over a resolved document: occurrence analysis and liveness as one fixpoint.
The questions a compiler asks about a call graph (what does this function transitively reach, which functions are reachable from the entry points, which ones recurse) are one program with three settings of the same three knobs: a per-node contribution, a join, and a dependency relation. Data.Fixpoint solves that program; this module supplies the three knobs from the prism-resolved-syntax-v1 document, so the analysis runs over a real resolved tree rather than a hand-built graph. Transitive reach propagates along the calls relation; liveness is the identical solve with the relation reversed and a Boolean carrier.
The edges come from the same seam Syntax.Rename queries, with the same limit. A var node’s span covers exactly its identifier, so every reference is readable, but no reference carries the identity of the binder it resolved to. A local binder spelled like a top-level function is therefore read as a call to that function, which adds edges that are not there. Every answer here is that over-approximation: fl_dead names only functions that really are unreachable, and fl_transitive may list a callee a run never makes. When the seam grows a binder-identity fact the approximation tightens without an interface change. Opt-in: not in Base.
Functions and Values
fl_defined
fl_defined : forall a. (Syntax.Resolved.ResolvedDoc) -> Map(String, Unit, a)
The names the document defines, as a set.
fl_references
fl_references : (Syntax.Source.SourceFile, Syntax.Resolved.RFunction) -> List(String)
Every identifier one function’s body references, in source order, sliced from the embedded source. A reference the source cannot address is dropped, which Syntax.Rename’s rn_unaddressed is how to detect before trusting any answer here.
fl_calls
fl_calls : forall a. (Syntax.Resolved.ResolvedDoc) -> Map(String, List(String), a)
The call graph: an edge from each function to every function of this document it references. Every defined function is a node, so one that calls nothing still gets an answer, and successor lists are ascending and duplicate-free, so the iteration order below is a function of the document alone.
fl_direct
fl_direct : forall a b c d. (Map(d, List(d), a)) -> Map(d, Map(d, Unit, b), c)
The immediate successors of a graph as a set per node: the per-node contribution a propagation starts from.
map(\(p) -> (fst(p), set_to_list(snd(p))), map_to_list(fl_direct(graph_from_edges([("f", "g")]))))
[(f, [g])]
fl_transitive
fl_transitive : forall a b. (Syntax.Resolved.ResolvedDoc) -> Map(String, Map(String, Unit, a), b) ! {Fail}
Every function each function transitively calls: the least assignment with x[f] the union of direct[f] and every x[g] for g in direct[f]. The members of one cycle share one answer, and a function on a cycle lists itself.
fl_live
fl_live : forall a. (Syntax.Resolved.ResolvedDoc, List(String)) -> Map(String, Unit, a) ! {Fail}
The functions reachable from roots: the same solve with the calls relation reversed, so reachability flows from a caller to its callees, over the Boolean lattice. A root the document does not define contributes nothing.
fl_dead
fl_dead : (Syntax.Resolved.ResolvedDoc, List(String)) -> List(String) ! {Fail}
The functions no root reaches, ascending: what a dead-code report is, once the document’s entry points are named.
fl_recursive
fl_recursive : (Syntax.Resolved.ResolvedDoc) -> List(String) ! {Fail}
The functions that call themselves, directly or through a cycle, ascending. This is the question a plain traversal answers wrongly and the fixpoint answers by construction: mutual recursion is a cycle, not a self-edge.
Syntax.Identity
The identities a Prism source file carries, and the two of them a published artifact is enough to compute.
Source identity is the exact bytes, comments and formatting included: the digest the compiler embeds in every syntax artifact. Surface identity is the canonical semantic surface tree with every source position erased, so a comment or layout edit leaves it fixed while a change of syntactic form moves it. Core identity is the elaborated subject and is deliberately not computable here: it needs resolution and elaboration, which no syntax artifact carries.
The negative directions are the load-bearing ones. Equal Core identity does not imply equal surface or source identity, since distinct spellings elaborate to one subject. Equal source bytes do not imply equal Core identity either, because the same text means different things under a different set of imported modules. Nothing here may be read as a claim about behavior.
Functions and Values
source_identity
source_identity : (Syntax.Source.SourceFile) -> String
The source identity of an artifact: the digest of its exact bytes.
source_identity(SourceFile { digest = "9f86d0", text = "fn main() = ()" })
9f86d0
surface_identity
surface_identity : (Syntax.Codec.SurfaceDoc) -> Result(String, Json.JsonError)
The surface identity of a decoded surface document: the canonical rendering of its schema tag and item tree with every span erased.
Two documents share a surface identity exactly when they are the same tree of the same schema, whatever text produced them. The identity is computed by re-encoding the document and stripping positions from the result, so it inherits the encoder’s canonical layout rather than introducing a second one; an encoding that cannot be read back is reported, never silently accepted.
Syntax.Layout
The Prism-language reimplementation of the compiler’s layout pass: the offside rule that turns the raw token stream into the post-layout parse stream by splicing the virtual block delimiters VOpen/VClose/VSemi and by opening a bare-indent body after each class/instance/effect head. The Rust lex pipeline stays the authoritative oracle; this module reproduces its output so the two can be diffed, never used as a silent fallback.
The pipeline mirrors the compiler exactly: lex_raw (Syntax.Lex) yields the decoded raw tokens; inject splices a zero-width head opener after every declaration head (the head runs to the last token on the keyword’s line); the offside driver walks the injected stream with an indent stack, a bracket depth, and a carried-opener flag, emitting the virtual delimiters at zero-width spans; and the synthetic head openers are dropped from the result. The whole file is one Eager top-level block, opened at the first token and closed at end of input, and a block only opens after an opener that begins a deeper new line (the Conditional opener rule), so a one-line let x = e stays flat.
Functions and Values
layout
layout : (String) -> Result(List(Syntax.Token.Token), Syntax.Lex.LexError)
Tokenize and lay out text, reproducing the compiler’s post-layout parse stream: the raw tokens with the virtual block delimiters spliced in and the synthetic head openers stripped. A lexing failure is propagated unchanged.
Syntax.Lex
A Prism-language reimplementation of the compiler’s raw token layer: exact UTF-8 tokenization, literal payload decoding, and interpolation splitting, expressed as ordinary Prism. The Rust lex_raw pipeline remains the authoritative oracle; this module produces the same raw token stream (kind, byte span, and decoded value) and the same interleaved trivia (line comments and blank-line runs) so the two can be diffed. It is compared and reported, never used as a silent fallback.
Scope of this layer: the fixed-token vocabulary, identifiers, uppercase names, qualified paths, numbers, char and string literals, line comments, and blank-line trivia; the decoded value each token carries (identifier text, separator-stripped integers, normalized floats, unescaped chars and strings); and the interpolation split of a "a {x} b" literal into istart/imid/ iend segment tokens with the hole expressions re-lexed at their absolute source offsets. A nested string literal inside a hole is scanned with a brace-depth stack, so its own quotes and braces never end the outer token early. Bracket tracking and the offside layout virtuals are later layers; no layout virtual is synthesized here.
Types
LexRaw
type LexRaw = LexRaw { tokens: List(Token), trivia: List(Trivia) }
The raw token layer: the semantic token stream and the trivia interleaved between them, each keyed by absolute byte span into the lexed text.
LexError
type LexError
= LxInvalid(Int)
| LxEmptyHole(Int)
| LxUntermHole(Int)
| LxUntermStr(Int)
| LxNumberSep(Int)
deriving (Eq, Show)
A lexing failure, mirroring the compiler’s five LexError variants one for one: an invalid token, an empty interpolation hole, an unterminated hole, an unterminated string, and a misplaced numeric separator. Each carries the byte offset where scanning stuck. The variant, its stable code, and its rendered message travel as data (see lex_offset/lex_code/lex_message), never encoded into a message string.
Functions and Values
lex_offset
lex_offset : (Syntax.Lex.LexError) -> Int
The byte offset where a lexing failure stuck.
lex_code
lex_code : (Syntax.Lex.LexError) -> String
The stable, append-only diagnostic code a lexing failure carries, matching the compiler’s LexError::code (E7000 through E7004).
lex_message
lex_message : (Syntax.Lex.LexError) -> String
The rendered message, matching the compiler’s LexError Display strings byte for byte so a decoded diagnostic compares equal.
lex_raw
lex_raw : (String) -> Result(Syntax.Lex.LexRaw, Syntax.Lex.LexError)
Tokenize text into the raw token stream and its interleaved trivia, reproducing the compiler’s lex_raw layer: kinds, byte spans, decoded values, interpolation splitting, comments, and blank-line runs. No layout virtuals.
lex_incomplete
lex_incomplete : (String) -> Bool
Whether text fails to lex only because a string literal or interpolation hole runs off the end: the lexer-observable half of the compiler’s incomplete classification, telling an interactive reader to keep reading rather than to reject. Any other lexing failure, or a clean lex, is complete.
Syntax.Query
A source query over a decoded prism-syntax-tokens-v1 artifact.
This is a consumer of the public token representation, not part of the compiler. Given a TokensDoc (the exact source text plus the compiler’s own raw stream, layout-resolved parse stream, and trivia), it reports a stable, machine-readable inventory of the source: the content digest, the ordered imports, the comment spans, the ordered top-level declaration heads, and a token-kind histogram in first-seen order.
Every field is a pure function of the artifact bytes. Nothing here reads ambient workspace state: the same artifact always yields the same report, byte for byte. The inventory is a source-identity view. Two programs that differ only in comments or formatting have different digests and different comment spans here, yet elaborate to the identical Core; that duality is the point of the query.
Types
DeclHead
type DeclHead = DeclHead {
keyword: String,
name: String,
lo: Int
} deriving (Eq, Show)
The head of a top-level declaration: its leading keyword, the first name it binds, and the keyword’s start offset.
KindCount
type KindCount = KindCount { kind: String, count: Int } deriving (Eq, Show)
One bucket of the token-kind histogram: a wire kind and how many raw tokens carried it.
QueryReport
type QueryReport = QueryReport {
digest: String,
imports: List(String),
comments: List(Span),
decls: List(DeclHead),
kinds: List(KindCount)
} deriving (Eq)
The full source inventory of an artifact.
Functions and Values
query
query : (Syntax.Codec.TokensDoc) -> Syntax.Query.QueryReport
The full source inventory of a decoded artifact.
query_lines
query_lines : (Syntax.Query.QueryReport) -> List(String)
The inventory rendered as one stable line per fact: a digest line, then an import line per module, a comment line per span, a decl line per declaration head, and a kind line per histogram bucket. The ordering is deterministic, so the text is a machine-readable fingerprint of the source’s surface identity.
query_text
query_text : (Syntax.Query.QueryReport) -> String
The inventory rendered as one newline-joined block, ready to print in a single call. Same content and ordering as query_lines.
Syntax.Rename
Rename as a join against the resolver, not as a tree walk.
The resolver has already decided what every name in a program means. A rename tool that walks the syntax and works scoping out again is a second, weaker answer to a question already answered, and the two disagree at exactly the cases that matter. So nothing here computes scope: it queries the prism-resolved-syntax-v1 document, which carries a node id, a form kind, and an exact byte span for every node, and emits span-addressed edits for Syntax.Edit to apply.
What that seam supports, and what it does not, decides this module’s shape. Every variable reference is a var node whose span covers exactly its identifier, so references are addressable and countable. No binding occurrence is: a let node’s span starts at the let keyword, a parameter is a bare name with no span at all, and there is no edge from a reference to the binder it resolved to. A use-only rewrite of a name whose binder lives in the same file therefore produces a file where the uses no longer name their binder, which is a corrupt program dressed up as a refactor.
Rather than guess, rn_plan refuses whenever the seam cannot prove the rewrite is complete. The proof it demands is cheap and exact: lex the source, take every identifier token spelling the old name, and require that each one is a var occurrence the resolver recorded. If some mention is not (a binder, a declaration head, a parameter list, an import), the plan is refused. What survives is the case the seam really does cover: renaming every reference to a name bound outside this document.
The refusals are the useful part of the result, and they are the gap to report: when the seam grows a binder-identity fact, this module tightens without an interface change.
Types
RnUse
type RnUse = RnUse { id: Int, span: Span, name: String }
One variable reference: the node id the checker facts join on, the exact span of the identifier, and the identifier text sliced from the embedded source.
RenameRefusal
type RenameRefusal
= RnNotAnIdent(String)
| RnTaken(String)
| RnUnknown(String)
| RnUnaddressed(Span)
| RnBinderInDocument(String, Span)
| RnSourceUnreadable(LexError)
deriving (Eq, Show)
Why a rename was refused. Every case is a fact about the document, not a guess about the programmer’s intent.
RenameError
type RenameError
= RnRefused(RenameRefusal)
| RnEdit(EditError)
deriving (Eq, Show)
The two ways a rename can fail: the seam refused to prove it, or the edits did not survive being applied.
Functions and Values
rn_message
rn_message : (Syntax.Rename.RenameRefusal) -> String
A human-readable account of a refusal.
rn_occurrences
rn_occurrences : (Syntax.Resolved.ResolvedDoc) -> List(Syntax.Rename.RnUse)
Every variable reference in the document, in source order: the occurrence table an editor’s find-references is, and the join key column a checker uses to pull prism-tc-facts-v1 rows.
rn_unaddressed
rn_unaddressed : (Syntax.Resolved.ResolvedDoc) -> List(Syntax.Source.Span)
Every reference the embedded source cannot address. Non-empty means the document and its source disagree, and every query here should be distrusted.
rn_uses_of
rn_uses_of : (Syntax.Resolved.ResolvedDoc, String) -> List(Syntax.Rename.RnUse)
Every reference to one name, in source order.
rn_use_count
rn_use_count : (Syntax.Resolved.ResolvedDoc, String) -> Int
How many times a name is referenced.
rn_is_ident
rn_is_ident : (String) -> Bool
Whether a string is a single identifier, decided by the real lexer rather than a hand-rolled character predicate. A keyword is not an identifier, so a rename to one is refused here rather than discovered after the edit.
rn_mentions
rn_mentions : (String, String) -> Result(List(Syntax.Source.Span), Syntax.Lex.LexError)
The span of every identifier token spelling name, anywhere in the text. This is the completeness check: a mention the resolver did not record as a reference is a binder, a declaration head, or a parameter, and any of those makes a use-only rewrite wrong.
rn_plan
rn_plan : (Syntax.Resolved.ResolvedDoc, String, String) -> Result(List(Syntax.Edit.Edit), Syntax.Rename.RenameRefusal)
The edit set that renames every reference to from into to, or the reason the seam cannot prove that rewrite is complete.
The checks run in a fixed order, so the refusal a caller sees is always the first thing wrong: the source must lex, every reference must be addressable, to must be a single identifier that occurs nowhere in the document, from must be referenced at least once, and every mention of from must be one of those references.
rn_rename
rn_rename : (Syntax.Resolved.ResolvedDoc, String, String) -> Result(String, Syntax.Rename.RenameError)
Rename and apply, in one step: the plan, then the edits, then the re-lex Syntax.Edit insists on. Either refusal comes back as itself.
Syntax.Report
Caret rendering for Syntax.Diagnostic: the plain-text report the compiler prints for a refused source, rebuilt in Prism from the diagnostic and the source text alone.
The target is exact bytes, not a lookalike, so the layout below is an executable specification of the compiler’s renderer: the header line, the location line, the gutter, the source line with tabs expanded to four-column stops, the underline row, the arrow row carrying the label, and the closing rule. Syntax.Diagnostic supplies the data; this module is the only thing in Prism that can draw it.
The shape reproduced is one primary span drawn as a caret under one source line, which is every diagnostic the syntax boundary raises: a lexical fault is a zero-width caret at its offset, and a parse fault is the offending token’s span or a caret at end of input. rp_exact is the predicate for that shape and rp_report answers None outside it rather than guessing. Three things fall outside.
A span crossing a line boundary, reachable through a multi-line string literal, draws the compiler’s multi-line form instead: an arrow down the margin from the opening line to the closing one. That form is not implemented.
Several labels on one report, and the help and note trailers, belong to the structured type-error path rather than the syntax boundary. related is a reserved surface on Diagnostic and nothing populates it yet, so a second label has no source here to draw from.
A non-ASCII byte at or before the end of the diagnostic’s own line changes the answer, and there the compiler’s output is the right one: it draws one cell per character, while every column here is counted in bytes, so a two-byte character before the caret pulls this module’s caret one cell further right. Counting characters would mean carrying a decoder the rest of the module has no use for, so the predicate below declines those lines instead.
Functions and Values
rp_kind
rp_kind : (Syntax.Diagnostic.DiagPhase) -> String
The report-kind label the compiler stamps in the header for a phase.
rp_kind(DPLex)
Lexical Error
rp_label
rp_label : (Syntax.Diagnostic.Diagnostic) -> String
The text on the caret’s label. The end-of-input parse fault asks for more input; every other diagnostic points here.
rp_message
rp_message : (String, Syntax.Diagnostic.Diagnostic) -> String
The message on the header line. A parse fault’s message already carries its own position; a lexical fault’s is followed by the line and column of its caret.
rp_exact
rp_exact : (String, Syntax.Diagnostic.Diagnostic) -> Bool
Whether the compiler draws this diagnostic as one caret under one source line, the shape this module reproduces byte for byte. False for a span that crosses a line boundary, a span running past the end of the text, and a source carrying a non-ASCII byte at or before the end of the caret’s line.
rp_report
rp_report : (String, String, Syntax.Diagnostic.Diagnostic) -> Option(String)
The compiler’s plain-text report for one diagnostic, or None when the diagnostic falls outside the shape this module draws (see rp_exact). name is the source name in the location line; the compiler passes <source> when reporting on a single file. The result ends with a newline.
let d = Diagnostic {
code = "E7100",
phase = DPParse,
span = Span { lo = 12, hi = 13 },
message = "unexpected ')'",
expected = Nil,
related = Nil
}
print(unwrap_or("", rp_report("<source>", "fn main() = )\n", d)))
[E7100] Parse Error: unexpected ')'
╭─[ <source>:1:13 ]
│
1 │ fn main() = )
│ ┬
│ ╰── here
───╯
rp_report_doc
rp_report_doc : (String, Syntax.Diagnostic.DiagnosticsDoc) -> Option(String)
Every diagnostic in a document rendered in order and concatenated, or None when any one of them falls outside the shape this module draws. The compiler stops at its first refusal, so a document carrying more than one diagnostic has no single compiler report to compare against.
Syntax.Resolved
The typed vocabulary of the prism-resolved-syntax-v1 artifact.
One document per source file: the embedded source identity and every user function’s resolved body as a node-id-carrying tree. Each node’s id is the same identity the prism-tc-facts-v1 table keys on, so a Prism consumer traverses the structure here and joins the checker facts (type, resolution) there by id. The tree is the resolved Core-phase program: references are resolved and the surface sugar forms have been desugared away, so it is a traversable subset of the surface expression vocabulary.
Types
RNode
type RNode = RNode { id: Int, kind: String, span: Span, children: List(RNode) }
One resolved expression node: its NodeId (the join key), its expression-form kind, its byte span, and its immediate children in source order. A leaf carries the empty child list.
RParam
type RParam = RParam { name: String, is_borrow: Bool }
One parameter of a resolved function: the binder its body’s references resolve to, and whether it is borrowed. A pattern parameter’s name is the synthesized binder the resolver assigned, because that is the name the body actually mentions.
RFunction
type RFunction = RFunction { name: String, params: List(RParam), body: RNode }
One user function’s resolved body tree, with its name and parameters.
ResolvedDoc
type ResolvedDoc = ResolvedDoc {
schema: String,
compiler: String,
source: SourceFile,
functions: List(RFunction)
}
A decoded resolved-syntax document: the envelope identity, the embedded source every span indexes into, and the user functions in source order.
Functions and Values
resolved_schema
resolved_schema : () -> String
The schema tag this vocabulary decodes.
rkinds
rkinds : () -> List(String)
The node kinds the artifact spells, one function per form, so a consumer matches on a named kind rather than retyping a bare string at each use site. The list is the artifact’s whole vocabulary: a kind outside it is a document this version does not understand, which rkind_known is how to ask.
rkind_known
rkind_known : (String) -> Bool
Whether a kind string is one this vocabulary names.
rkind_int
rkind_int : () -> String
An integer literal.
rkind_float
rkind_float : () -> String
A float literal.
rkind_char
rkind_char : () -> String
A character literal.
rkind_bool
rkind_bool : () -> String
A boolean literal.
rkind_unit
rkind_unit : () -> String
The unit value.
rkind_str
rkind_str : () -> String
A string literal.
rkind_var
rkind_var : () -> String
A variable reference. Its span covers exactly the identifier, which is what makes references, and only references, addressable in the source.
rkind_hole
rkind_hole : () -> String
A typed hole.
rkind_bin
rkind_bin : () -> String
A binary operator application.
rkind_neg
rkind_neg : () -> String
An arithmetic negation.
rkind_if
rkind_if : () -> String
A conditional.
rkind_let
rkind_let : () -> String
A let binding. Its span starts at the let keyword, so the bound name it introduces is not separately addressable.
rkind_lam
rkind_lam : () -> String
A lambda.
rkind_call
rkind_call : () -> String
A function application.
rkind_pipe
rkind_pipe : () -> String
A pipeline application.
rkind_match
rkind_match : () -> String
A match expression.
rkind_list
rkind_list : () -> String
A list literal.
rkind_tuple
rkind_tuple : () -> String
A tuple literal.
rkind_field
rkind_field : () -> String
A record field access.
rkind_unboxed_tuple
rkind_unboxed_tuple : () -> String
An unboxed tuple literal.
rkind_unboxed_record
rkind_unboxed_record : () -> String
An unboxed record literal.
rkind_unboxed_field
rkind_unboxed_field : () -> String
An unboxed record field access.
rkind_record
rkind_record : () -> String
A record construction.
rkind_record_update
rkind_record_update : () -> String
A record update.
rkind_record_update_path
rkind_record_update_path : () -> String
A nested-path record update.
rkind_handle
rkind_handle : () -> String
An effect handler installation.
rkind_mask
rkind_mask : () -> String
An effect mask.
rkind_inst
rkind_inst : () -> String
An explicit instantiation.
rkind_index
rkind_index : () -> String
An index read.
rkind_index_set
rkind_index_set : () -> String
An index write.
rkind_ann
rkind_ann : () -> String
A type annotation.
rnode_children
rnode_children : (Syntax.Resolved.RNode) -> List(Syntax.Resolved.RNode)
The immediate children of one resolved node, in source order. The one constructor-naming site for RNode traversal; every generic walk derives from it, so a consumer never re-matches the node shape.
rnode_rebuild
rnode_rebuild : (Syntax.Resolved.RNode, List(Syntax.Resolved.RNode)) -> Syntax.Resolved.RNode
Put a replacement child list back into a resolved node, keeping its id, kind, and span. Fails closed: a list of the wrong length yields the node unchanged.
rnode_layer
rnode_layer : () -> Control.Layer.Layer(Syntax.Resolved.RNode)
The children-and-rebuild pair for resolved nodes, so every strategy in Control.Rewrite and every query in Control.Layer works on a resolved body.
rnode_universe
rnode_universe : (Syntax.Resolved.RNode) -> List(Syntax.Resolved.RNode)
Every node of a resolved body, root first, depth-first. The uniplate universe over RNode, the traversal a Prism-written checker walks a body with.
rnode_count
rnode_count : (Syntax.Resolved.RNode) -> Int
The number of nodes in a resolved body.
rnode_count(RNode { id = 0, kind = "unit", span = Span { lo = 0, hi = 0 }, children = [] })
1
Syntax.Source
Source identity for the versioned syntax artifacts: source files and half-open byte spans. Byte offsets are the canonical position vocabulary (line and column are projections for people, never a second identity), and these are the Prism-side types the token and surface-syntax exports decode into.
Types
Span
type Span = Span { lo: Int, hi: Int } deriving (Eq, Show)
A half-open byte range [lo, hi) into one source text.
span_len(Span { lo = 3, hi = 8 })
5
SourceFile
type SourceFile = SourceFile { digest: String, text: String } deriving (Eq)
One source file as an artifact embeds it: the exact text and its digest. Every span in an artifact indexes this text, so a persisted document needs no external file.
Functions and Values
span_len
span_len : (Syntax.Source.Span) -> Int
The number of bytes a span covers.
span_valid
span_valid : (Syntax.Source.Span) -> Bool
Whether a span is well formed: non-negative start, start at or before end.
(span_valid(Span { lo = 2, hi = 2 }), span_valid(Span { lo = 5, hi = 4 }))
(true, false)
span_contains
span_contains : (Syntax.Source.Span, Int) -> Bool
Whether a byte offset falls inside the span (half-open, so the end offset is outside).
line_col
line_col : (String, Int) -> (Int, Int)
The one-based line and column of a byte offset, as a projection over the text. Offsets past the end clamp to the final position.
line_col("ab\ncd", 4)
(2, 2)
Syntax.Token
The token vocabulary of the prism-syntax-tokens-v1 artifact. A fixed token’s wire kind is its source spelling, so TFixed carries the spelling rather than enumerating every keyword and operator; value-carrying and virtual layout tokens each get a dedicated constructor matching the grammar’s terminal aliases.
Types
TokenKind
type TokenKind
= TFixed(String)
| TIdent
| TUid
| TQual
| TInt
| TFloat
| TChar
| TStr
| TIStart
| TIMid
| TIEnd
| TVOpen
| TVClose
| TVSemi
deriving (Eq)
A token kind: a fixed token by exact spelling, or one of the special value-carrying and virtual kinds.
Token
type Token = Token { kind: TokenKind, span: Span, value: Option(String) }
One token: its kind, its span in the embedded source, and, for value-carrying kinds, the decoded payload (escapes resolved, digit separators stripped). The original spelling is always recoverable from the span.
Trivia
type Trivia = TComment(Span) | TBlockComment(Span) | TBlank(Span)
One trivia event between semantic tokens: a line comment, a delimited comment, or a blank-line run.
Functions and Values
kind_name
kind_name : (Syntax.Token.TokenKind) -> String
The canonical wire name of a token kind, matching the compiler’s artifact vocabulary (fixed tokens spell themselves; specials use the grammar’s terminal aliases).
(kind_name(TIdent), kind_name(TVOpen), kind_name(TFixed("->")))
(ident, v{, ->)
kind_of_name
kind_of_name : (String) -> Syntax.Token.TokenKind
The token kind a wire name denotes. Any name that is not one of the special aliases is a fixed token spelling; the fixed vocabulary itself is pinned by the compiler’s own tests, not re-enumerated here.
(kind_of_name("uid") == TUid, kind_of_name("match") == TFixed("match"))
(true, true)
trivia_name
trivia_name : (Syntax.Token.Trivia) -> String
The wire name of a trivia event.
trivia_span
trivia_span : (Syntax.Token.Trivia) -> Syntax.Source.Span
The span a trivia event covers.
Syntax.Walk
Generic traversal over the surface syntax tree.
The design is uniplate-shaped: two hand-written total functions per sort give the immediate same-sort children of a node (expr_children) and put a replacement child list back (expr_rebuild), and every generic operation derives from that pair (expr_universe, expr_count, expr_any, expr_fold, and every strategy in Control.Rewrite applied to expr_layer). The one-layer functions are the only place that names constructors, so a new Expr constructor is one arm in each and every derived traversal follows; totality of the two matches is the drift guard.
The pair is written out rather than derived, and the reason is the span. deriving (Plate) walks through a carrier record, so a derived instance on Expr yields children of type Expr with the spanned wrappers already consumed: its rebuild puts the original spans back, but its children has dropped them, and a spanned child is what every query here returns. Deriving on the wrapper instead yields no children at all, because an Sp(a) holds no Sp(a). The instance that would say the right thing is one at Sp(Expr), and it cannot be written: a deriving clause names a declaration rather than an instantiation, and instance dispatch keys on the head constructor alone, so Sp(Expr) and Sp(Pat) would be a single instance.
Functions and Values
expr_children
expr_children : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(Syntax.Ast.Sp(Syntax.Ast.Expr))
The immediate Expr children of one node, in source order. Children held inside carrier records (match arms, handler arms, qualifiers, path steps and updates, parameter defaults, named fields) are included; a leaf yields the empty list.
expr_rebuild
expr_rebuild : (Syntax.Ast.Sp(Syntax.Ast.Expr), List(Syntax.Ast.Sp(Syntax.Ast.Expr))) -> Syntax.Ast.Sp(Syntax.Ast.Expr)
Put a replacement child list back into one node, in expr_children order, keeping the node’s span and its synthetic bit.
This is the inverse half of expr_children, and the pair is everything a generic strategy needs to descend a tree. It fails closed: a child list that does not match the node’s shape (a different length, a carrier arity that does not line up) yields the node unchanged rather than a silently reshaped one, so expr_rebuild(e, expr_children(e)) is the identity and a garbage child list cannot forge a node.
expr_layer
expr_layer : () -> Control.Layer.Layer(Syntax.Ast.Sp(Syntax.Ast.Expr))
The children-and-rebuild pair for spanned expressions, which is what every strategy in Control.Rewrite and every query in Control.Layer takes.
expr_universe
expr_universe : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> List(Syntax.Ast.Sp(Syntax.Ast.Expr))
Every node of the tree, root first, in depth-first source order.
expr_count(lit_probe(7))
1
lit_probe
lit_probe : (Int) -> Syntax.Ast.Sp(Syntax.Ast.Expr)
A span-zero literal, a convenience for building test trees.
expr_count
expr_count : (Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Int
The number of nodes in the tree.
expr_any
expr_any : ((Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Bool, Syntax.Ast.Sp(Syntax.Ast.Expr)) -> Bool
Whether any node satisfies the predicate.
expr_fold
expr_fold : forall e0 a. ((a, Syntax.Ast.Sp(Syntax.Ast.Expr)) -> a ! {e0}, a, Syntax.Ast.Sp(Syntax.Ast.Expr)) -> a ! {e0}
Left-fold the accumulator through every node, root first.
Arena
Arena: allocation as an algebraic effect.
On the dynamic axis, allocation is an ordinary effect a handler interprets. with_arena(body) installs a handler that services each eligible allocation out of a region owned by that activation: cells are carved from a bump pointer, are inert to reference counting while the region lives, and the whole region is reclaimed in one step when the handler returns. A value that escapes through the result is deep-promoted into ordinary reference-counted cells at the boundary, so escape costs a copy, never soundness. A program that installs no Alloc handler allocates exactly as before.
The alloc operation is compiler-internal: the arena-lowering pass rewrites a constructor built under a with_arena scope into a performed alloc (a raw cell) plus an in-place initialization, so body never spells alloc itself. This handler discharges each alloc(n) into prim_arena_bump(n), the region bump entry point. An arena constructor is observably identical to a plain one; only where its words come from differs, which the determinism contract requires to be unobservable. alloc is once: a bump region assumes single-shot use, so it resumes at most once across the boundary.
Types
Cell
type Cell = Cell(Int)
An opaque handle to a raw cell handed out by an allocator. It carries no observable payload; the arena-lowering pass writes a constructor into it, and user code never inspects it. The vestigial field keeps the type nominal.
Effects
Alloc
effect Alloc
once alloc(Int) : Cell
The allocation effect. alloc(n) requests a raw n-word cell; a handler decides where those words come from. Graded once: single-shot resumption only, which is what a scratch region wants.
Functions and Values
with_arena
with_arena : forall a. (() -> a ! {Arena.Alloc}) -> a
Run body, servicing each eligible allocation out of this activation’s region and reclaiming the whole region when the handler returns. Anything the result keeps is promoted to ordinary reference-counted cells at the boundary. A body that allocates nothing runs unchanged, and code that never calls with_arena allocates exactly as before.
Blit
Range copy over the sequence types a real primitive can back.
One blit factored across the sequence-like types, instead of a separate copy routine per type. blit(src, src_off, len, dst, dst_off) returns a destination whose len elements starting at dst_off are the len elements of src starting at src_off, with the rest of dst unchanged. As with memcpy, the ranges [src_off, src_off + len) and [dst_off, dst_off + len) are expected to lie within the two sequences.
Instanced only where a real primitive backs the copy: String over the substring slice primitive, Array over the in-place array_get and array_set primitives, and Bytes over the byte buffer’s in-place buf_set, so a uniquely owned array or buffer destination is overwritten without a fresh allocation. Plain lists stay uninstanced on purpose: a cons list has no random access or bulk copy, so a blit would be a structural walk that rebuilds the prefix rather than a copy.
Type Classes
Blit
class Blit(s)
blit : (s, Int, Int, s, Int) -> s
Overwrite dst[dst_off .. dst_off + len) with src[src_off .. src_off + len) and return the updated destination.
blit("XY", 0, 2, "abcd", 1)
aXYd
Instances
blitString
instance blitString : Blit(String)
Splice the source range over the destination range with the string slice primitive: the untouched prefix and suffix of dst are sliced out and rejoined around the copied middle.
blitArray
instance blitArray : Blit(Array(a))
Overwrite dst[dst_off + i] with src[src_off + i] for each i in 0 .. len via array_set, which writes in place when dst is uniquely owned and copies otherwise, so the observable result never depends on ownership.
blitBytes
instance blitBytes : Blit(Bytes)
Overwrite dst[dst_off + i] with src[src_off + i] for each i in 0 .. len via the buffer’s buf_set, which writes in place when the destination buffer is uniquely owned and copies otherwise, so the observable result never depends on ownership.
Cli
CLI: an applicative command-line parser as a first-class value.
A parser is a pure value describing what a command accepts: typed options (long/short, valued or switch, with defaults), typed positionals, and named subcommands. Parsers compose applicatively (build2/build3/build4 over map_p/ap_p/pure_p) into the user’s own result type, so a well-typed parser yields exactly that type or a rich error, never a half-filled record. The illegal state (a config with a missing required field, a subcommand without its arguments) is unrepresentable: the constructor cannot be applied until every part has parsed.
One description, three artifacts: the same Command value drives all three. It is the parser (run_argv), turning argv into the result type or an error. It is the help text (help_text), rendered from the option and argument specs the parser carries, so --help can never drift from what parses. And it is the errors (inside run_argv), which name the offending token, state the form expected, and print the usage line derived from those same specs.
Everything here is pure: a Parser reads a Tokens structure, and Tokens comes from lexing a List(String). The tie to the real command line is run_args, which feeds args() (the Env capability) to run_argv. Because argv arrives through that capability, a recorded run replays its arguments from the .replay trace like any other observation: CLI parsing is inside the determinism contract, argv and all.
Types
CliError
type CliError
= MissingFlag(String)
| MissingArg(String)
| UnexpectedArg(String)
| NeedsValue(String)
| BadValue(String, String)
| UnknownFlag(String)
| UnknownCommand(String)
A parse failure, carrying enough to name the offending token and the form expected. Rendered to a message by describe.
OptSpec
type OptSpec = OptSpec {
long: String,
short: String,
meta: String,
help: String,
takes_value: Bool
}
The static description of one option, carried by the parser for help and to tell the lexer whether the flag consumes a following value.
ArgSpec
type ArgSpec = ArgSpec { meta: String, help: String, required: Bool }
The static description of one positional argument.
Tokens
type Tokens = Tokens { opts: List((String, String)), pos: List(String) }
argv after lexing: options canonicalized to their long name, and the positionals in order. --help/-h are handled before lexing (has_help), so they never appear here.
Parser
type Parser(a) = Parser {
opts: List(OptSpec),
args: List(ArgSpec),
run: (Tokens, Int) -> Result((a, Int), CliError)
}
A parser for a value of type a: the option and positional specs it accepts (the description help is rendered from) and a pure reader over lexed tokens threading the positional cursor. Build these with the combinators below, never by hand.
SubCmd
type SubCmd(a) = SubCmd { key: String, about: String, sub: Parser(a) }
One named subcommand: its key on the command line, a one-line description, and the parser that runs when it is chosen.
Body
type Body(a) = Plain(Parser(a)) | Group(List(SubCmd(a)))
A top-level command is either a single parser or a group of subcommands, all producing the same result type (so distinct subcommands map to distinct constructors of the user’s ADT).
Command
type Command(a) = Command { name: String, about: String, body: Body(a) }
A complete command: a program name and one-line description for usage, plus its body.
Outcome
type Outcome(a) = Parsed(a) | ShowHelp(String) | BadUsage(String)
The outcome of parsing argv: a value, a help request (carrying the rendered text), or a usage error (carrying the rendered message).
Functions and Values
dash
dash : Int
help_col
help_col : Int
pure_p
pure_p : forall a. (a) -> Cli.Parser(a)
A parser that consumes nothing and yields x. The applicative unit.
map_p
map_p : forall a b. ((b) -> a, Cli.Parser(b)) -> Cli.Parser(a)
Map f over a parser’s result, leaving what it consumes unchanged.
ap_p
ap_p : forall a b. (Cli.Parser((a) -> b), Cli.Parser(a)) -> Cli.Parser(b)
Applicative application: run pf then px, threading the positional cursor left to right, and apply the function to the argument. This is what lets a curried constructor be filled field by field.
build2
build2 : forall a b c. ((a) -> (c) -> b, Cli.Parser(a), Cli.Parser(c)) -> Cli.Parser(b)
Apply a two-argument curried constructor to two parsers.
build3
build3 : forall a b c d. ((a) -> (b) -> (d) -> c, Cli.Parser(a), Cli.Parser(b), Cli.Parser(d)) -> Cli.Parser(c)
Apply a three-argument curried constructor to three parsers.
build4
build4 : forall a b c d e. ((a) -> (b) -> (c) -> (e) -> d, Cli.Parser(a), Cli.Parser(b), Cli.Parser(c), Cli.Parser(e)) -> Cli.Parser(d)
Apply a four-argument curried constructor to four parsers.
opt_str
opt_str : (String, String, String, String, String) -> Cli.Parser(String)
An optional string flag with a default when absent.
req_str
req_str : (String, String, String, String) -> Cli.Parser(String)
A required string flag: an error when absent.
opt_int
opt_int : (String, String, String, String, Int) -> Cli.Parser(Int)
An optional integer flag with a default; a non-integer value is an error.
switch
switch : (String, String, String) -> Cli.Parser(Bool)
A boolean switch: true when present, false when absent, never valued.
arg_str
arg_str : (String, String) -> Cli.Parser(String)
A required string positional, consumed in declaration order.
arg_str_default
arg_str_default : (String, String, String) -> Cli.Parser(String)
An optional string positional, consumed in declaration order and yielding default when absent. Help renders it as [<META>].
arg_int
arg_int : (String, String) -> Cli.Parser(Int)
A required integer positional; a non-integer token is an error.
lex
lex : (List(Cli.OptSpec), List(String)) -> Result(Cli.Tokens, Cli.CliError)
Lex argv against the option specs (which say whether each flag takes a value). Options are canonicalized to their long name; anything not a flag is a positional. --help/-h are skipped here (detected earlier by has_help).
run_argv
run_argv : forall a. (Cli.Command(a), List(String)) -> Cli.Outcome(a)
Parse argv against a command, yielding a value, a help request, or a usage error. argv is the argument list only (no program name); cmd.name supplies the name for usage text.
run_argv(cmd, ["--host", "example.com"])
Cli.Parsed((example.com, 8080))
help_text
help_text : forall a. (Cli.Command(a)) -> String
The rendered help for a command: dispatches to the plain or group form.
print(help_text(cmd))
Usage: serve [--host HOST] [--port N] [--help]
run the server
Options:
-H, --host HOST server host
-p, --port N listen port
-h, --help show this help and exit
describe
describe : (Cli.CliError) -> String
A one-line description of a parse error, naming the offending token and the form expected.
describe(MissingFlag("host"))
error: required flag '--host' was not provided
run_args
run_args : forall a. (Cli.Command(a)) -> Cli.Outcome(a) ! {Env}
Parse the process arguments against cmd. argv arrives through the Env capability (args), so a recorded run replays its arguments from the trace: CLI parsing is inside the determinism contract.
Concurrent
Cooperative async/await concurrency as a single handler, polymorphic in the effects the fibers perform.
fork spawns a fiber and returns a Fiber handle, yield reschedules, await blocks on a fiber’s result, and cancel requests a cooperative cancellation. channel, send, and recv are a buffered FIFO channel for fiber-to-fiber messages. A fiber may perform any effect row e in addition to Async; e is a row-kinded parameter of the reified command type, so one run queue holds every fiber without existential types. Because a fiber’s effects tie to the ambient row at fork, they flow out through run_async: run_async : (() -> a ! {Async(a) | e}) -> a ! {e}, so a fiber that performs E makes the whole run demand a handler for E. Nothing is smuggled past the scheduler.
Prism has no shared mutable cell, so the handler only REIFIES each step as a Cmd, and a pure drive loop threads the run queue, the finished results, the parked awaiters, and the channel buffers. Those parked continuations escape into the scheduler state, so a program using this is whole-program free-monad; it runs in constant native stack under PRISM_TRAMPOLINE.
Cancellation is a cooperative unwind, not a drop. cancel(f) marks f and all its descendants; each is stopped at its next suspension point (yield, await, send, recv), never mid-step, and unwinds through its never finalizers so every resource is released exactly once. Wrap a resource with on_cancel(cleanup, body) to run cleanup on that unwind. A cancelled fiber’s awaiters observe it through try_await (which yields Was_Cancelled rather than hanging). An unhandled failure in any fiber aborts the cooperative run: every other live fiber and descendant is cancelled, runnable cleanups drain, and the failure re-emerges from run_async/run_lifo.
scope is a structured join: it forks a list of fibers and awaits them all on successful runs. It is not a failure-isolation boundary; the scheduler-global abort rule also cancels live fibers created outside the scope.
A channel carries the shared result type a; fibers in one run share a (use a sum type for mixed messages or results).
Types
Fiber
type Fiber = Fiber(Int)
A handle to a spawned fiber, returned by fork and passed to await/cancel.
Chan
type Chan = Chan(Int)
A handle to a buffered FIFO channel, opened with channel.
Outcome
type Outcome(a) = Completed(a) | Was_Cancelled
The outcome of try_await: a fiber that completed with a value, or one that was cancelled before it could.
Effects
Async
effect Async(a)
fork(() -> a ! {Async(a) | e}) : Fiber
poll_yield() : Signal
poll_await(Fiber) : Wake(a)
cancel(Fiber) : Unit
channel() : Chan
bounded(Int) : Chan
poll_send(Chan, a) : Signal
poll_recv(Chan) : Wake(a)
never kill() : b
never vanished() : b
The async/await effect. fork spawns a fiber and returns its handle, yield reschedules, await blocks on a fiber’s result, and cancel requests a cooperative cancellation; channel/send/recv are a buffered FIFO channel for fiber-to-fiber messages. Discharge it with run_async.
The public yield/await/send/recv are thin wrappers (below) over the raw poll_* operations, which return a cancellation Signal/Wake the wrapper turns into a cooperative kill. kill and vanished never resume (per-site bottom return b); run_async absorbs the whole effect, so none of the cancel machinery surfaces in a fiber’s row.
Clock
effect Clock
now() : Int
sleep(Int) : Unit
wall_now() : Int
mono_now() : Int
A logical-time capability: now reads the current tick and sleep advances it. Discharged by run_clock, which threads a pure counter, so time is virtual and deterministic: advance it in a test and behaviour is a function of it, with no real clock and no time primitive. A fiber may perform Clock; since the scheduler does not handle it, it flows out of run_async to an enclosing run_clock like any other capability. now/sleep are the logical scheduler clock (virtual ticks). wall_now and mono_now are the real-time reads (nanoseconds): the system wall clock (Unix epoch, UTC) and a monotonic counter. All four share the one Clock capability; which reading you get is a property of the installed handler, not the call. run_clock serves every op from the virtual counter (deterministic tests); Time.run_clock_real serves wall_now/mono_now from the recorded OS clock.
Functions and Values
yield
yield : forall a. () -> Unit ! {Concurrent.Async(a)}
Reschedule the current fiber. A cooperative yield point; also a cancellation point (a cancelled fiber unwinds here instead of resuming).
await
await : forall a. (Concurrent.Fiber) -> a ! {Concurrent.Async(a)}
Block until fiber f finishes, returning its result. If f was cancelled, this raises the in-scheduler vanished signal (observe it with try_await); if the waiting fiber is itself cancelled, it unwinds here.
send
send : forall a. (Concurrent.Chan, a) -> Unit ! {Concurrent.Async(a)}
Send v on channel c. On a bounded channel a full buffer parks the sender until a receiver drains it; a cancellation delivered while parked unwinds here.
recv
recv : forall a. (Concurrent.Chan) -> a ! {Concurrent.Async(a)}
Receive a value from channel c, parking until one is available. A cancellation delivered while parked unwinds here.
fn producer(c : Chan) : Int ! {Async(Int)} =
send(c, 7)
0
fn scene() : Int ! {Async(Int)} =
let c = channel()
let f = fork(\() -> producer(c))
let v = recv(c)
await(f)
v
fn main() = println(run_async(scene))
7
on_cancel
on_cancel : forall e0 a b. (() -> Unit ! {Concurrent.Async(a), e0}, () -> b ! {Concurrent.Async(a), Concurrent.Async(a), e0}) -> b ! {Concurrent.Async(a), e0}
Run body, and if it is cancelled (unwinds through kill), run cleanup once before the cancellation propagates outward. This is a forwarding handler: every Async operation body performs is relayed unchanged to the enclosing run_async, so the finalizer is transparent except on the cancel path. A normally returning body does not run cleanup. Nest these for stacked resources; cancellation runs each installed cleanup exactly once, innermost first. Once an unwind starts, repeated cancellation is masked for that fiber, so a cleanup may suspend and resume normally. A child forked by a cleanup is born cancelled and cannot escape the unwind. If a cleanup itself fails, try_await does not manufacture Was_Cancelled: the whole scheduler run fails. A cleanup parked with no runnable producer likewise ends in the scheduler’s deterministic no-progress failure; a cleanup that keeps producing work may diverge like any other computation.
try_await
try_await : forall a. (Concurrent.Fiber) -> Concurrent.Outcome(a) ! {Concurrent.Async(a)}
Await fiber f, observing cancellation as a value instead of a signal: Completed(v) if f finished, Was_Cancelled if it was cancelled. Never hangs on a cancelled fiber, and does not report Was_Cancelled until the target’s cancellation unwind (including its on_cancel cleanups) has reached the scheduler. A forwarding handler that catches only the vanished signal await raises for a cancelled target. If that unwind fails or never completes, try_await produces no outcome; the enclosing run fails or diverges instead.
run_async
run_async : forall e0 a. (() -> a ! {Concurrent.Async(a), Fail, e0}) -> a ! {Fail, e0}
Run main and its fibers cooperatively under the FIFO (round-robin) policy, returning main‘s result. The fibers’ effects e flow out unchanged, so the caller still handles them. The schedule is deterministic, so a run’s output is reproducible.
fn task() : Int ! {Async(Int)} = await(fork(\() -> 21)) * 2
fn main() = println(run_async(task))
42
run_cooperative
run_cooperative : forall e0 a. (() -> a ! {Concurrent.Async(a), Fail, e0}) -> a ! {Fail, e0}
The policy-neutral entry point: run main under the deployment’s default cooperative scheduler. It is run_async (FIFO) by default, and the --scheduler flag (or PRISM_SCHEDULER) retargets it to another shipped policy such as run_lifo with no source change, because the scheduler is only a handler for Async and swapping it never touches the fibers. Call this when you want the configured default; call run_async or run_lifo directly to pin a policy.
run_lifo
run_lifo : forall e0 a. (() -> a ! {Concurrent.Async(a), Fail, e0}) -> a ! {Fail, e0}
A second scheduling policy over the same Async effect: LIFO (depth-first), which runs the most-recently-forked or most-recently-woken fiber next by pushing it to the front of the run queue. It discharges fork/yield/await/channels exactly as run_async does and returns the same result for a determinate computation; only the interleaving (and so the order of observable effects like prints) differs. This is the concrete proof that policy is a handler: the fibers are unchanged, only the run function is swapped.
scope
scope : forall e0 a. (List(() -> a ! {Concurrent.Async(a), e0})) -> List(a) ! {Concurrent.Async(a), e0}
Structured join: run tasks concurrently and await their results in order. On a successful run every task completes before scope returns. scope is not a failure-isolation boundary: an unhandled failure in one task triggers the scheduler-global abort rule, which cancels every other live fiber (including fibers created outside this call) and their descendants, drains runnable cancellation cleanups, and re-performs the failure at the enclosing run_async/run_lifo boundary.
run_clock
run_clock : forall e0 a. (() -> a ! {Concurrent.Clock, e0}) -> a ! {e0}
Run action against a logical clock starting at 0, returning its result. now() reads the current time; sleep(d) advances it by d. Time is a pure threaded counter (the parameter-passing answer Int -> a), so the run is deterministic and reproducible under Replay.
fn timed() : Int ! {Clock} =
sleep(5)
sleep(2)
now()
fn main() = println(run_clock(timed))
7
Incr
Incremental computation as a handler over a content-addressed dependency graph.
An incremental computation is a demand-driven graph of two node kinds: an input is a changeable cell you set; a memo node caches a pure or replayable thunk keyed by its content. Reading a node from inside another node (get) records a dependency edge, because the read goes through the effect and run_incr sits in the middle: the effect is the instrumentation. After a set, re-reading a node re-demands exactly the affected cone, and a memo whose recomputed value is unchanged (by content hash) does not disturb its dependents. That early cutoff is the whole game, and here it is an exact blake3 comparison over the serialized value, not a user-written equality.
The engine is the spec’s demand() verbatim: verify a memo’s recorded deps by re-demanding each and comparing hashes; return the cached value if they all match; else rerun, capturing a fresh dep set. It is one tail-resumptive handler that re-installs itself to run a memo thunk (so the thunk’s own gets are caught as that node’s deps), threading the node table as parameter-passed state that Perceus reclaims when the run_incr scope drops.
Values are heterogeneous across a single run (an Int total beside a Map of scores), so the engine works over the type-erased Bytes a Serialize instance produces, and the typed surface (input/get/set/memo) encodes at the boundary where the type is known. A node handle Incr(a) is a phantom-typed key, so get on it round-trips back to a.
Types
Incr
type Incr(a) = IncrRef(String)
A handle to an incremental node carrying values of type a. It is a phantom-typed wrapper over the node’s content key; get on it returns a.
Effects
IncrRaw
effect IncrRaw
incr_input(Bytes) : String
incr_get(String) : Bytes
incr_set(String, Bytes) : Unit
incr_memo(() -> Bytes ! {IncrRaw | e}) : String
Functions and Values
run_incr
run_incr : forall e0 a. (() -> a ! {Incr.IncrRaw, e0}) -> a ! {e0}
Discharge the Incr effect, running action as the root observer of a fresh demand graph. The node table lives only for this scope. The ambient row e (the replayable effects the memo thunks perform beyond reading nodes) flows out unchanged, exactly as run_async passes its fibers’ row through.
run_incr() fn
let a = input(10)
let b = memo(\() -> get(a) * 2)
get(b)
20
input
input : forall a. (a) -> Incr.Incr(a) ! {Incr.IncrRaw}
Create an input node holding v. Its key is its creation order in the run.
run_incr(\() -> get(input(42)))
42
get
get : forall a. (Incr.Incr(a)) -> a ! {Fail, Incr.IncrRaw}
Read a node, recording a dependency edge from the memo currently being computed (if any) to this node.
run_incr() fn
let a = input(3)
let b = input(4)
get(a) + get(b)
7
set
set : forall a. (Incr.Incr(a), a) -> Unit ! {Incr.IncrRaw}
Update an input node. A no-op when v is equal (by content) to the node’s current value.
run_incr() fn
let a = input(1)
set(a, 10)
get(a)
10
memo
memo : forall e0 a. (() -> a ! {Incr.IncrRaw, e0}) -> Incr.Incr(a) ! {Incr.IncrRaw, e0}
A derived node caching thunk by content, with early cutoff on its result: a cache hit reuses the value and skips the thunk, so a memo that does not recompute does not re-perform its (replayable) effects. Its key is its creation order in the run.
Write a multi-statement memo with the trailing block form, whose statements read other nodes with get:
let ranking = memo() fn
let live = get(scores)
sort_desc(map_to_list(live))
A one-liner is the same thing spelled with a lambda: memo(\() -> get(a) + 1).
run_incr() fn
let a = input(10)
let plus1 = memo(\() -> get(a) + 1)
get(plus1)
11
run_incr_durable
run_incr_durable : forall a. (String, String, () -> a ! {Fail, Incr.IncrRaw}) -> a ! {Fail, IO}
Discharge the Incr effect with a durable snapshot at path, tagged by tag (the caller-named program identity: a snapshot written under a different tag cold-starts). This is the file-substrate form of the production path, which rides the content-addressed store once a program can hold a runtime handle on it; the same reduced table is what that store persists.
The action must be pure up to Fail, so its row is closed at {IncrRaw, Fail}: a warm hit skips the memo thunk, and skipping a thunk that printed or drew a random would change the output, so only effect-free thunks may be reused across runs. Only the creation prefix (the memos built before the first get/set) is warmed and persisted; input-dependent memos stay scratch and recompute. Within those bounds a warm run’s output is byte-identical to a cold run, and deleting or corrupting the snapshot changes only cost, never the result.
run_incr_durable("totals.snap", "report") fn
let a = input(10)
get(memo(\() -> get(a) * 2))
run_incr_store
run_incr_store : forall a. (String, String, () -> a ! {Fail, Incr.IncrRaw}) -> a ! {Fail, IO}
Discharge the Incr effect with a durable snapshot on the content-addressed store rooted at root, tagged by tag (the caller-named program identity). The store form of run_incr_durable: the reduced memo table rides the real store’s object layer (the blob keyed by its content hash) with a ref for tag, rather than a snapshot file. The file substrate stays available; a call site picks the substrate by picking the handler.
Every durability guarantee of the file form carries over. The action is pure up to Fail, so only the creation-prefix memos are warmed and persisted; a warm run’s output is byte-identical to a cold run; and a missing, dangling, or corrupt store entry cold-starts, changing only cost, never the result.
run_incr_store(".prism-store", "report") fn
let a = input(10)
get(memo(\() -> get(a) * 2))
run_incr_durable_replay
run_incr_durable_replay : forall e0 a. (String, String, () -> a ! {Fail, IO, Incr.IncrRaw, Output, e0}) -> a ! {Fail, IO, e0}
Discharge the Incr effect with a durable, trace-replay snapshot at path, tagged by tag. Unlike run_incr_durable, the memo thunks may perform any replayable effect: a cold run records each memo’s output beside its result, and a durable warm hit REPLAYS that output instead of re-running the thunk, so a warm run’s console is byte-identical to a cold run’s, effects included. A missing, corrupt, foreign, or wrong-version snapshot is a silent cold start.
run_incr_durable_replay("run.snap", "job") fn
let a = input(41)
get(memo(\() -> get(a) + 1))
run_incr_store_replay
run_incr_store_replay : forall e0 a. (String, String, () -> a ! {Fail, IO, Incr.IncrRaw, Output, e0}) -> a ! {Fail, IO, e0}
The content-addressed-store form of run_incr_durable_replay: the traced memo table (results and output traces) rides the real store’s object layer, keyed by tag, rather than a snapshot file. Every guarantee of the file form carries over; a missing, dangling, or corrupt store entry cold-starts.
run_incr_store_replay(".prism-store", "job") fn
let a = input(41)
get(memo(\() -> get(a) + 1))
Json
JSON: a dynamic value tree, a total parser, a canonical encoder, and a typed layer.
Two layers, following the wire discipline of lib/std/Wire.pr:
- The dynamic layer is the
Jsontree and a totaldecode/encodepair for payloads whose shape you do not control.decodenever partially succeeds: a malformed input is oneJsonErrorcarrying a message and a line/column, never a panic or a truncated value. Nesting is depth-limited, and any input left over after the top-level value is rejected. - The typed layer is theToJson/FromJsonclasses with instances for the base types and the containers, so a declared type converts to and from aJsontree structurally. (Derivation is a manual typeclass rather thanderiving (Json): the derivable set is fixed, so a new derivable class is a compiler change; the class-and-instances form is pure library code.)
The JSON contract is deliberately narrow:
- Number semantics are exact-or-error by default. An integer literal decodes to
JInt(Intis arbitrary precision, so no integer overflows); a number decodes toJFloatonly when its lexeme is already the canonical form the owned formatter would print (show_int/show_float). A non-canonical spelling (1.0,1e3,0.10) is a decode error underdecode, and accepted, normalized, underdecode_lossy. No silent rounding, no silent1e999-to-infinity. - Encoding is canonical and byte-deterministic: object keys are sorted, every number is printed by the owned formatter, strings use one fixed escaping, and there are no whitespace options. Two machines encoding equal values produce equal bytes, so aJsonvalue has a well-defined hash and diff, anddecode(encode(v))returnsv(with the one deliberate normalization that an integer-valuedJFloatprints as an integer and decodes back toJInt, since JSON does not distinguish the two).
Strings are validated UTF-8 by construction (the String type), so invalid UTF-8 is rejected at the byte boundary that builds the input, before the parser ever runs. Streaming (gigabyte, SAX-style) JSON is a package; this codec is value-oriented and total.
Types
Json
type Json
= JNull
| JBool(Bool)
| JInt(Int)
| JFloat(Float)
| JStr(String)
| JArr(List(Json))
| JObj(List((String, Json)))
A dynamic JSON value. Numbers split into JInt and JFloat so the exact-or-error decode is a structural distinction rather than a hidden flag: an object is an association list in parse order (duplicate keys are preserved, not merged).
encode(JObj([("ok", JBool(true)), ("n", JInt(3))]))
{"n":3,"ok":true}
JsonError
type JsonError = JsonError(String, Int, Int)
A decode failure: a human-readable message and the 1-based line and column of the offending byte.
Type Classes
ToJson
class ToJson(a)
to_json : (a) -> Json
Convert a value to a Json tree.
deriving (ToJson) writes the instance structurally, for a type whose schema is its own declaration. One constructor becomes one object: a record constructor’s keys are its declared field names, a positional one’s are its argument positions (_0, _1), and a sum additionally names the variant it holds under the key $, which no field name can spell. A document therefore names the constructor it holds rather than an index that quietly changes meaning when a constructor is inserted; a single-constructor type has nothing to discriminate and carries no tag. Constructor and field order are the declaration’s, so one value has one tree, and encode sorts keys, so it has one string. Derive it in a pair with FromJson: a type that encodes but cannot decode is a document nobody can read back.
This is not the wire codec. A Wire.Serialize byte format is frozen and versioned; a JSON document is read by something that was not compiled against this program, so the encoding is self-describing rather than compact, and nothing here promises stability across a change to the declaration.
to_json([1, 2, 3])
Json.JArr([Json.JInt(1), Json.JInt(2), Json.JInt(3)])
FromJson
class FromJson(a)
from_json : (Json) -> a ! {Fail | e}
Recover a value from a Json tree, failing (through Fail) on a structural mismatch. A decode of foreign data is one ordinary failure channel.
deriving (FromJson) reads back exactly what the derived ToJson wrote, by the same keys: from_json(to_json(x)) is x. A tree that is not an object, a sum whose $ names no constructor of the type, a missing key, and a field that will not itself decode all leave through the same Fail. That failure carries no payload, so it reports that the document did not fit and not where: Fail is a nullary operation, and reporting a path would mean a different effect on this class’s signature and so on every hand-written instance too. Catch it with optional, default, or succeeds, as with any other Fail.
from_json(JInt(41)) + 1
42
Instances
toJsonInt
instance toJsonInt : ToJson(Int)
toJsonFloat
instance toJsonFloat : ToJson(Float)
toJsonBool
instance toJsonBool : ToJson(Bool)
toJsonString
instance toJsonString : ToJson(String)
toJsonList
instance toJsonList : ToJson(List(a))
toJsonOption
instance toJsonOption : ToJson(Option(a))
toJsonPair
instance toJsonPair : ToJson((a, b))
fromJsonInt
instance fromJsonInt : FromJson(Int)
fromJsonFloat
instance fromJsonFloat : FromJson(Float)
fromJsonBool
instance fromJsonBool : FromJson(Bool)
fromJsonString
instance fromJsonString : FromJson(String)
fromJsonList
instance fromJsonList : FromJson(List(a))
fromJsonOption
instance fromJsonOption : FromJson(Option(a))
fromJsonPair
instance fromJsonPair : FromJson((a, b))
Functions and Values
json_error_message
json_error_message : (Json.JsonError) -> String
Render a JsonError as line L col C: message.
json_error_message(JsonError("unexpected character", 1, 5))
line 1 col 5: unexpected character
decode
decode : (String) -> Result(Json.Json, Json.JsonError)
Decode a JSON document with exact number semantics: a number decodes only when its lexeme is already canonical, otherwise a decode error. Total: any malformed or lossy input is an Err with a position.
decode("[1, 2, 3]")
Ok(Json.JArr([Json.JInt(1), Json.JInt(2), Json.JInt(3)]))
decode_lossy
decode_lossy : (String) -> Result(Json.Json, Json.JsonError)
Decode a JSON document, accepting any well-formed number and normalizing it to JInt (exact integer in range) or the nearest JFloat. Still total, and still rejects structurally malformed input.
decode_lossy("1e3")
Ok(Json.JFloat(1000))
encode
encode : (Json.Json) -> String
Encode a Json value to its canonical byte-deterministic string: object keys sorted, numbers by the owned formatter, one fixed string escaping, no optional whitespace. Equal values encode to equal bytes.
encode(JObj([("b", JInt(2)), ("a", JInt(1))]))
{"a":1,"b":2}
json_field
json_field : (List((String, Json.Json)), String) -> Json.Json ! {Fail}
The member named key of an object’s member list, or fail() when there is none. Members are kept in parse order and duplicates are preserved, so the first occurrence wins, which is what makes a decode a function of the document rather than of a hash order. deriving (ToJson, FromJson) reads every field through this, so a missing field and a mistyped one leave through the same channel.
json_field([("a", JInt(1)), ("b", JInt(2))], "b")
Json.JInt(2)
to_json_string
to_json_string : forall a. (a) -> String
Encode a typed value straight to a canonical JSON string.
to_json_string((1, true))
[1,true]
json_children
json_children : (Json.Json) -> List(Json.Json)
The immediate Json children of a value: an array’s elements, an object’s field values in field order, and nothing at a scalar.
json_rebuild
json_rebuild : (Json.Json, List(Json.Json)) -> Json.Json
Put a replacement child list back, in json_children order, keeping an object’s field names. Fails closed: a list of the wrong length yields the value unchanged rather than a truncated or padded one.
json_layer
json_layer : () -> Control.Layer.Layer(Json.Json)
The children-and-rebuild pair for JSON, so every strategy in Control.Rewrite and every query in Control.Layer works on a decoded document.
Math
Named mathematical constants, matching Rust’s f64::consts surface.
Purely additive: the transcendental functions (sin, cos, exp, ln, sqrt, …) stay owned builtins routing through the vendored libm, and pi/e/tau stay Base-global. This module only names the derived constants, as exact Float literals pinned bit-for-bit, so importing it never shadows a Base name. Opt-in: not in Base.
Functions and Values
sqrt2
sqrt2 : Float
The square root of 2.
sqrt1_2
sqrt1_2 : Float
The reciprocal of the square root of 2 (1 / sqrt2).
ln2
ln2 : Float
The natural logarithm of 2.
ln10
ln10 : Float
The natural logarithm of 10.
log2e
log2e : Float
The base-2 logarithm of e.
log10e
log10e : Float
The base-10 logarithm of e.
phi
phi : Float
The golden ratio, (1 + sqrt(5)) / 2.
half_pi
half_pi : Float
Half of pi (pi / 2).
quarter_pi
quarter_pi : Float
A quarter of pi (pi / 4).
inv_pi
inv_pi : Float
The reciprocal of pi (1 / pi).
Quickcheck
Property testing: run a boolean property over many generated inputs and report the first counterexample, deterministically.
A Gen(a) is a seeded, sized function producing an a. It draws its randomness from the ambient Random effect, and the runner discharges that effect with a seeded SplitMix64 handler, so a whole run is a pure function of its seed. There is no real IO here: quickcheck performs no prim_rand, only the seeded stream, so a property that fails fails the same way on every backend and reruns identically from the reported seed. Determinism is the language’s contract, and a flaky property test would violate it.
The generator TYPE and its combinators live here; a derived Arbitrary(a) class (see Wire/deriving) supplies arbitrary : Gen(a) for user types by composing these combinators, so a derived instance plugs in with no rework.
Shrinking is intentionally omitted. The seam is the seed and size carried on a failing Outcome: a counterexample reproduces exactly via gen_at, and a future shrink : (a) -> List(a) hook would thread through check_go without changing this surface.
Types
Gen
type Gen(a) = Gen((Int) -> a ! {Random})
A seeded, sized generator of a. Apply it with gen_run; build one with the combinators below. The size bounds recursive shapes (list length, tree depth); make it explicit so derived instances for recursive types stay finite.
Config
type Config = Cfg { seed: U64, count: Int, max_size: Int }
How a property run configures the seeded stream: the base seed, the number of cases to try (count), and the largest size handed to a generator (max_size).
Outcome
type Outcome(a) = Passed(Int) | Failed(a, Int, U64, Int)
The result of a property run: Passed(n) after n cases held, or Failed(value, index, seed, size) with the first counterexample and the seed and size that reproduce it via gen_at.
Functions and Values
run_seeded
run_seeded : forall e0 a. (U64, () -> a ! {Random, e0}) -> a ! {e0}
Run action with the Random effect served by a seeded SplitMix64 stream, so the result is a pure function of seed. This is the REPLAYABLE handler the runner installs; call it directly to reproduce a single draw off a seed.
run_seeded(1u64, \() -> gen_run(gen_int, 5))
1
gen_run
gen_run : forall a. (Quickcheck.Gen(a), Int) -> a ! {Random}
Draw one value from g at the given size, performing Random. Discharge the Random with run_seeded (or use gen_at) to run it.
run_seeded(1u64, \() -> gen_run(gen_const(9), 5))
9
gen_at
gen_at : forall a. (Quickcheck.Gen(a), U64, Int) -> a
Draw a value from g deterministically off seed at size, no IO. This is the reproduce-a-counterexample entry point.
gen_at(gen_int, 42u64, 10)
-6
gen_const
gen_const : forall a. (a) -> Quickcheck.Gen(a)
The generator that ignores size and randomness and always yields x.
gen_at(gen_const(7), 1u64, 5)
7
gen_map
gen_map : forall a b. ((a) -> b, Quickcheck.Gen(a)) -> Quickcheck.Gen(b)
Map f over every value a generator produces.
gen_at(gen_map(\(x) -> x + 1, gen_const(4)), 1u64, 5)
5
gen_map2
gen_map2 : forall a b c. ((a, b) -> c, Quickcheck.Gen(a), Quickcheck.Gen(b)) -> Quickcheck.Gen(c)
Combine two generators with f, drawing both at the same size.
gen_at(gen_map2(\(a, b) -> a + b, gen_const(2), gen_const(3)), 1u64, 5)
5
gen_bind
gen_bind : forall a b. (Quickcheck.Gen(a), (a) -> Quickcheck.Gen(b)) -> Quickcheck.Gen(b)
Monadic bind: draw an a, then draw from the generator f picks for it.
gen_at(gen_bind(gen_const(3), \(x) -> gen_const(x * 2)), 1u64, 5)
6
gen_sized
gen_sized : forall a. ((Int) -> Quickcheck.Gen(a)) -> Quickcheck.Gen(a)
Build a generator that sees the current size, for recursive shapes that branch on remaining fuel.
gen_at(gen_sized(\(sz) -> gen_const(sz)), 1u64, 12)
12
gen_resize
gen_resize : forall a. (Int, Quickcheck.Gen(a)) -> Quickcheck.Gen(a)
Run g at a fixed size, ignoring the ambient one (shrink a recursive position by resizing it smaller).
gen_at(gen_resize(4, gen_sized(\(sz) -> gen_const(sz))), 1u64, 99)
4
gen_choose
gen_choose : forall a. (Quickcheck.Gen(a), List(Quickcheck.Gen(a))) -> Quickcheck.Gen(a)
Pick one of g0/rest uniformly, then draw from it (one arm per constructor, the shape a derived sum-type instance uses).
gen_at(gen_choose(gen_const(1), [gen_const(2)]), 5u64, 3)
1
gen_one_of
gen_one_of : forall a. (a, List(a)) -> Quickcheck.Gen(a)
Pick one of the given values uniformly (x0 or one of rest).
gen_at(gen_one_of(10, [20, 30]), 5u64, 3)
30
gen_int
gen_int : Quickcheck.Gen(Int)
Generator of Int, biased toward the edge cases (0, 1, -1, and a full-width draw) alongside small readable values.
gen_at(gen_int, 42u64, 10)
-6
gen_i64
gen_i64 : Quickcheck.Gen(I64)
Generator of I64, reusing the Int distribution.
gen_u64
gen_u64 : Quickcheck.Gen(U64)
Generator of U64, reusing the Int distribution (negatives wrap).
gen_bool
gen_bool : Quickcheck.Gen(Bool)
Generator of Bool.
gen_at(gen_bool, 7u64, 3)
false
gen_float
gen_float : Quickcheck.Gen(Float)
Generator of Float, including the nasty values (+/-0, +/-inf, NaN).
gen_char
gen_char : Quickcheck.Gen(Char)
Generator of a printable-ASCII Char.
gen_string
gen_string : Quickcheck.Gen(String)
Generator of a printable-ASCII String, length bounded by size.
gen_list
gen_list : forall a. (Quickcheck.Gen(a)) -> Quickcheck.Gen(List(a))
Generator of a List(a) whose length is bounded by size.
gen_at(gen_list(gen_const(1)), 7u64, 4)
[1, 1]
gen_option
gen_option : forall a. (Quickcheck.Gen(a)) -> Quickcheck.Gen(Option(a))
Generator of Option(a): None a quarter of the time, else Some.
gen_at(gen_option(gen_const(5)), 3u64, 3)
Some(5)
gen_pair
gen_pair : forall a b. (Quickcheck.Gen(a), Quickcheck.Gen(b)) -> Quickcheck.Gen((a, b))
Generator of a pair, both drawn at the same size.
gen_at(gen_pair(gen_const(1), gen_const(2)), 1u64, 3)
(1, 2)
gen_triple
gen_triple : forall a b c. (Quickcheck.Gen(a), Quickcheck.Gen(b), Quickcheck.Gen(c)) -> Quickcheck.Gen((a, b, c))
Generator of a triple, all drawn at the same size.
gen_at(gen_triple(gen_const(1), gen_const(2), gen_const(3)), 1u64, 3)
(1, 2, 3)
default_config
default_config : Quickcheck.Config
The default configuration: a fixed base seed, 100 cases, sizes up to 20.
default_config.count
100
check_with
check_with : forall a. (Quickcheck.Config, Quickcheck.Gen(a), (a) -> Bool) -> Quickcheck.Outcome(a)
Run prop over cfg.count inputs from gen, returning the first counterexample or the count that passed. Deterministic in cfg.seed.
check_with(default_config, gen_const(2), \(x) -> x == 2)
Quickcheck.Passed(100)
quickcheck
quickcheck : forall a. (Quickcheck.Gen(a), (a) -> Bool) -> Quickcheck.Outcome(a)
Run prop over the default configuration.
quickcheck(gen_int, \(x) -> x + 0 == x)
Quickcheck.Passed(100)
passed
passed : forall a. (Quickcheck.Outcome(a)) -> Bool
True when a run found no counterexample.
passed(quickcheck(gen_int, \(x) -> x + 1 > x))
true
show_outcome
show_outcome : forall a. (String, Quickcheck.Outcome(a)) -> String
Render an outcome for name as one report line (pass) or a block naming the counterexample and the seed and size that reproduce it.
show_outcome("positivity", Failed(0, 12, 42u64, 5))
positivity: FAILED after 12 tests.
counterexample: 0
reproduce: seed 42 size 5
Replay
Record/replay handlers for the capability effects.
record runs an action against the real world, logging each observation into a trace; replay re-runs the same action against a trace, performing no real IO. The trace and its entry type stay private to this module: a caller only ever threads the opaque trace record returns back into replay.
Functions and Values
record
record : forall e0 a. ((Unit) -> a ! {IO, e0}) -> (a, List(Replay@TraceEntry)) ! {IO, e0}
Run action against the real world, logging every capability observation (console, file, random, and environment reads) into a trace. Returns (result, trace); feed the trace to replay to reproduce the run without IO.
record(\(u) -> rng_rand() + rng_rand())
replay
replay : forall e0 a. (List(Replay@TraceEntry), (Unit) -> a ! {Fail, e0}) -> a ! {Fail, e0}
Re-run action against a recorded trace, performing no real IO: each capability read is served from the trace and output is dropped, reproducing the original result. Fails if the trace does not match the action.
let (first, trace) = record(\(u) -> rng_rand())
replay(trace, \(u) -> rng_rand()) == first
serialize
serialize : (List(Replay@TraceEntry)) -> String
Encode a trace to a self-delimiting string for durable storage, read back with deserialize.
let (r, trace) = record(\(u) -> rng_rand())
write_file("run.trace", serialize(trace))
deserialize
deserialize : (String) -> List(Replay@TraceEntry)
Decode a trace produced by serialize; deserialize(serialize(t)) is t.
The frames are self-delimiting (tag, length, :, payload), so the encoding round-trips through text:
serialize(deserialize("I2:42S3:abc"))
I2:42S3:abc
durable
durable : forall e0 a. (String, (Unit) -> a ! {Fail, IO, e0}) -> a ! {Fail, IO, e0}
Durable record/replay against a persisted log at path: replay the recorded prefix with no real IO, then perform each new observation for real, appending a frame per observation so an interrupted run resumes where it left off, exactly once at the crash boundary.
durable("target/run.log", \(u) -> rng_rand() + rng_rand())
Sequence
The one lazy iteration protocol: pull-based sequences with natural names.
A Sequence is a pull producer: a thunk that, when forced with (()), yields one Step – either SDone (exhausted) or SMore(x, rest), the next element x paired with the thunk producing the remainder. Nothing is materialized up front; each (()) advances the stream by one element. A transformer (map, filter, take, …) wraps a producer in a new producer that pulls, reshapes, and re-yields; a consumer (fold, sum, to_list, …) drives the producer to exhaustion and folds the elements into a value.
Type
type Step(a) = SDone | SMore(a, (Unit) -> Step(a))
Seq(a) = (Unit) -> Step(a) -- read `Seq(a)` in the prose as this
The step continuation is ordinary data, not an effect, so every combinator is a total pure function over Step. That is the whole reason this substrate exists: an earlier push design routed elements through a shared Emit effect, and a single Emit(a) label per scope made element-type changes, heterogeneous zip, and mixing two element types in one function unrepresentable. On pull Step the element type is a plain type parameter, so map : Seq(a) -> Seq(b) type-changes freely, zip : (Seq(a), Seq(b)) -> Seq((a, b)) is genuinely heterogeneous, and a consumer’s per-element function may perform any ambient effect (it runs directly, with no handler closing the row over it). Every combinator below exercises one of those, and the correctness corpus in tests/cases/run pins them on both backends.
Effect-polymorphic consumers
for_each’s function argument runs in the ambient row: for_each(s, \(x) -> println(x)) threads IO straight out with no annotation, because the driver is a plain recursion and installs no handler. This is the effectful-mapper case the push substrate could not express.
Naming and the import idiom
The combinators keep their natural names (map, filter, take, zip, …), which collide with Base’s eager List surface. That is why this module is opt-in and NOT in Base: the flat namespace has no shadowing, so the documented idiom is a qualified import at the use site:
import Sequence as Seq -- qualified: Seq.map, Seq.filter, Seq.take
Seq.to_list(Seq.filter(Seq.map(Seq.range(1, 100), \(x) -> x * x), even))
A selective import Sequence (unfold, iterate) also works for names that do not clash with Base.
Fusion and allocation
A pull pipeline is correct and constant-space per stage. At -O1, each transformer stage allocates one SMore cons and one step thunk per element. At -O2, stream fusion collapses the supported range/map/filter/take and fold pipelines through the module boundary, so they allocate no intermediate step cells. The performance gates pin that zero slope; shapes outside the recognizer retain the per-stage allocation. The algebraic-effect EOp counter remains zero because pulling a sequence performs no effects. The eager Data.List surface fuses through FBIP reuse and remains the natural choice for strict traversal, while Sequence provides early exit and infinite producers.
Types
Step
type Step(a) = SDone | SMore(a, (Unit) -> Step(a))
Functions and Values
empty
empty : forall a. () -> (Unit) -> Sequence.Step(a)
The empty sequence: yields nothing.
Seq.to_list(Seq.empty())
[]
singleton
singleton : forall a. (a) -> (Unit) -> Sequence.Step(a)
The one-element sequence yielding x.
Seq.to_list(Seq.singleton(42))
[42]
range
range : (Int, Int) -> (Unit) -> Sequence.Step(Int)
The ascending integers in [lo, hi). Self-recursive: the continuation is the next range, so no element is built until pulled.
Seq.to_list(Seq.range(1, 5))
[1, 2, 3, 4]
from_list
from_list : forall a. (List(a)) -> (Unit) -> Sequence.Step(a)
The container boundary in: the elements of list xs, in order.
Seq.to_list(Seq.from_list([1, 2, 3]))
[1, 2, 3]
iterate
iterate : forall a. (a, (a) -> a) -> (Unit) -> Sequence.Step(a)
The infinite sequence x, f(x), f(f(x)), .... Bound it with take.
Seq.to_list(Seq.take(Seq.iterate(1, \(x) -> x * 2), 4))
[1, 2, 4, 8]
repeat
repeat : forall a. (a) -> (Unit) -> Sequence.Step(a)
The infinite sequence of x repeated. Bound it with take.
Seq.to_list(Seq.take(Seq.repeat(7), 3))
[7, 7, 7]
unfold
unfold : forall a b. (a, (a) -> Option((b, a))) -> (Unit) -> Sequence.Step(b)
The generator producer: yield x for each Some((x, seed')) that step returns from the running seed, stopping at None. Every finite producer is a special case; on pull Step a generator is just a fold over the seed, needing no coroutine or handler.
Seq.to_list(Seq.unfold(1, \(n) -> if n <= 3 then Some((n, n + 1)) else None))
[1, 2, 3]
map
map : forall a b. ((Unit) -> Sequence.Step(a), (a) -> b) -> (Unit) -> Sequence.Step(b)
Apply f to every element. Type-changing: Seq(a) -> Seq(b).
Seq.to_list(Seq.map(Seq.range(1, 4), \(x) -> x * x))
[1, 4, 9]
filter
filter : forall a. ((Unit) -> Sequence.Step(a), (a) -> Bool) -> (Unit) -> Sequence.Step(a)
Keep only the elements satisfying p.
Seq.to_list(Seq.filter(Seq.range(1, 7), \(x) -> mod(x, 2) == 0))
[2, 4, 6]
filter_map
filter_map : forall a b. ((Unit) -> Sequence.Step(a), (a) -> Option(b)) -> (Unit) -> Sequence.Step(b)
Map and filter in one pass: yield y for each f(x) == Some(y), dropping the Nones.
Seq.to_list(Seq.filter_map(Seq.range(1, 5), \(x) -> if x > 2 then Some(x * 10) else None))
[30, 40]
append
append : forall a. ((Unit) -> Sequence.Step(a), (Unit) -> Sequence.Step(a)) -> (Unit) -> Sequence.Step(a)
Concatenate two sequences: all of s, then all of t.
Seq.to_list(Seq.append(Seq.range(1, 3), Seq.range(10, 12)))
[1, 2, 10, 11]
flat_map
flat_map : forall a b. ((Unit) -> Sequence.Step(a), (a) -> (Unit) -> Sequence.Step(b)) -> (Unit) -> Sequence.Step(b)
For each element x, splice in the sequence f(x).
Seq.to_list(Seq.flat_map(Seq.range(1, 4), \(x) -> Seq.range(0, x)))
[0, 0, 1, 0, 1, 2]
take
take : forall a. ((Unit) -> Sequence.Step(a), Int) -> (Unit) -> Sequence.Step(a)
The first n elements, stopping the producer once the budget is spent.
Seq.to_list(Seq.take(Seq.range(1, 100), 3))
[1, 2, 3]
drop
drop : forall a. ((Unit) -> Sequence.Step(a), Int) -> (Unit) -> Sequence.Step(a)
Skip the first n elements, yielding the rest.
Seq.to_list(Seq.drop(Seq.range(1, 6), 2))
[3, 4, 5]
take_while
take_while : forall a. ((Unit) -> Sequence.Step(a), (a) -> Bool) -> (Unit) -> Sequence.Step(a)
The longest prefix whose elements satisfy p; stops at the first failure.
Seq.to_list(Seq.take_while(Seq.range(1, 10), \(x) -> x < 4))
[1, 2, 3]
drop_while
drop_while : forall a. ((Unit) -> Sequence.Step(a), (a) -> Bool) -> (Unit) -> Sequence.Step(a)
Drop the longest prefix satisfying p, yielding the rest.
Seq.to_list(Seq.drop_while(Seq.range(1, 6), \(x) -> x < 3))
[3, 4, 5]
dedup
dedup : forall e1 a. ((Unit) -> Sequence.Step(Int) ! {e1}) -> (a) -> Sequence.Step(Int) ! {e1}
Drop consecutive duplicates, keeping the first of each run (needs Eq(a)). Left unsigned so the Eq(a) constraint is inferred from ==.
Seq.to_list(Seq.dedup(Seq.from_list([1, 1, 2, 2, 2, 3])))
[1, 2, 3]
enumerate
enumerate : forall a. ((Unit) -> Sequence.Step(a)) -> (Unit) -> Sequence.Step((Int, a))
Pair each element with its zero-based index, yielding (i, x).
Seq.to_list(Seq.enumerate(Seq.from_list(["a", "b"])))
[(0, a), (1, b)]
scan
scan : forall a b. ((Unit) -> Sequence.Step(a), b, (b, a) -> b) -> (Unit) -> Sequence.Step(b)
The running left-fold, streamed: yield z, then each successive accumulator.
Seq.to_list(Seq.scan(Seq.range(1, 4), 0, \(acc, x) -> acc + x))
[0, 1, 3, 6]
interleave
interleave : forall a. ((Unit) -> Sequence.Step(a), (Unit) -> Sequence.Step(a)) -> (Unit) -> Sequence.Step(a)
Alternate elements of s and t (s0, t0, s1, t1, ...); when one runs out, yield the whole remainder of the other. Streamed, no materialization.
Seq.to_list(Seq.interleave(Seq.range(1, 4), Seq.range(10, 12)))
[1, 10, 2, 11, 3]
zip
zip : forall a b. ((Unit) -> Sequence.Step(a), (Unit) -> Sequence.Step(b)) -> (Unit) -> Sequence.Step((a, b))
Pair two sequences element-wise, stopping at the shorter. Genuinely heterogeneous: Seq(a) and Seq(b) yield Seq((a, b)).
Seq.to_list(Seq.zip(Seq.range(1, 4), Seq.from_list(["a", "b", "c"])))
[(1, a), (2, b), (3, c)]
zip_with
zip_with : forall a b c. ((Unit) -> Sequence.Step(a), (Unit) -> Sequence.Step(b), (a, b) -> c) -> (Unit) -> Sequence.Step(c)
Combine two sequences element-wise with f, stopping when either runs out. Heterogeneous in both operand types.
Seq.to_list(Seq.zip_with(Seq.range(1, 4), Seq.range(10, 13), \(a, b) -> a + b))
[11, 13, 15]
chunk
chunk : forall a. ((Unit) -> Sequence.Step(a), Int) -> (Unit) -> Sequence.Step(List(a))
Group elements into consecutive lists of length n (the final chunk may be shorter). Pulls one group at a time, holding only the current group.
Seq.to_list(Seq.chunk(Seq.range(1, 6), 2))
[[1, 2], [3, 4], [5]]
window
window : forall a. ((Unit) -> Sequence.Step(a), Int) -> (Unit) -> Sequence.Step(List(a))
Every contiguous length-n sliding window, in order. Fewer than n elements yields nothing. Holds one window (n elements) at a time.
Seq.to_list(Seq.window(Seq.range(1, 5), 2))
[[1, 2], [2, 3], [3, 4]]
fold
fold : forall a b. ((Unit) -> Sequence.Step(b), a, (a, b) -> a) -> a
Left-fold the sequence with f from initial accumulator z.
Seq.fold(Seq.range(1, 5), 0, \(acc, x) -> acc + x)
10
for_each
for_each : forall e0 a. ((Unit) -> Sequence.Step(a), (a) -> Unit ! {e0}) -> Unit ! {e0}
Run f for its effects on each element, in order. Effect-polymorphic: f runs in the ambient row (no handler intervenes), so for_each(s, \(x) -> println(x)) threads IO out. The explicit {| e} row matters: unsigned, the self-recursion would infer f as pure and reject an effectful body.
Seq.for_each(Seq.range(1, 4), \(x) -> println(show(x)))
1
2
3
sum
sum : ((Unit) -> Sequence.Step(Int)) -> Int
Sum a sequence of integers.
Seq.sum(Seq.range(1, 5))
10
product
product : ((Unit) -> Sequence.Step(Int)) -> Int
Product of a sequence of integers.
Seq.product(Seq.range(1, 5))
24
count
count : forall a. ((Unit) -> Sequence.Step(a)) -> Int
The number of elements.
Seq.count(Seq.range(1, 100))
99
to_list
to_list : forall a. ((Unit) -> Sequence.Step(a)) -> List(a)
Collect the sequence into a list, in order. The container boundary out.
Seq.to_list(Seq.map(Seq.range(1, 4), \(x) -> x + 100))
[101, 102, 103]
head
head : forall e0 a. ((Unit) -> Sequence.Step(a) ! {e0}) -> Option(a) ! {e0}
The first element, or None if the sequence is empty.
Seq.head(Seq.range(5, 10))
Some(5)
find
find : forall a. ((Unit) -> Sequence.Step(a), (a) -> Bool) -> Option(a)
The first element satisfying p, or None; stops the producer at the match.
Seq.find(Seq.range(1, 100), \(x) -> x > 10)
Some(11)
any
any : forall a. ((Unit) -> Sequence.Step(a), (a) -> Bool) -> Bool
True when some element satisfies p; short-circuits.
Seq.any(Seq.range(1, 5), \(x) -> x == 3)
true
all
all : forall a. ((Unit) -> Sequence.Step(a), (a) -> Bool) -> Bool
True when every element satisfies p; short-circuits on the first failure.
Seq.all(Seq.range(1, 5), \(x) -> x < 10)
true
from_bytes
from_bytes : (Wire.Bytes) -> (Unit) -> Sequence.Step(Int)
The bytes of bs as a sequence of ints in 0..255, in order.
to_bytes
to_bytes : ((Unit) -> Sequence.Step(Int)) -> Wire.Bytes
Collect a sequence of ints (each masked to a byte) into a Bytes. The Bytes container boundary out.
Teleport
The checked mobility boundary. teleport runs a portable, single-use computation as a unit that is safe to move to a fresh runtime.
The closure handed to teleport must capture only content-addressed code and portable data (@ portable) and be invoked at most once (@ once); the compiler proves that contract at every call site. Running a teleported closure is observationally identical to calling it directly – how a computation is placed is never observable, the same invariant that makes tier and backend choice invisible – so teleport is the checked boundary, not a change in behavior. Compose it with Replay.record for a durable, replayable mobile run.
Functions and Values
teleport
teleport : forall a. (() -> a @ {once, portable}) -> a
Run a portable, single-use closure as a mobile unit.
A closure over top-level code and scalar arguments satisfies the contract, and running it through teleport is indistinguishable from calling it:
teleport(\() -> 6 * 7)
42
Capturing a local binding is refused at compile time: a local does not travel, so the closure could not move to a fresh runtime:
let n = 6
teleport(\() -> n * 7)
Test
Per-type value generators for property testing.
A generator is the unwrapped Quickcheck.Gen: a size-bounded draw from the ambient Random capability. That makes a run deterministic under a seed (the Quickcheck runner serves Random from a seeded SplitMix64 stream), and lets a derived instance recurse through its fields’ arbitrary while composing cleanly with the Gen combinators. The primitive instances delegate to Quickcheck’s canonical generators, so a derived type draws from the same distributions the hand-written harness does.
Wrap an instance back into a Gen with arb_gen to feed it to quickcheck.
Type Classes
Arbitrary
class Arbitrary(a)
arbitrary : (Int) -> a ! {Random | e}
A structural generator, sized by a fuel budget. deriving (Arbitrary) spends the budget on depth: at each recursive constructor it draws its fields a size smaller, and once the budget runs out it restricts to non-recursive constructors, so generation always terminates.
Instances
arbitraryInt
instance arbitraryInt : Arbitrary(Int)
arbitraryI64
instance arbitraryI64 : Arbitrary(I64)
arbitraryU64
instance arbitraryU64 : Arbitrary(U64)
arbitraryBool
instance arbitraryBool : Arbitrary(Bool)
arbitraryUnit
instance arbitraryUnit : Arbitrary(Unit)
arbitraryChar
instance arbitraryChar : Arbitrary(Char)
arbitraryFloat
instance arbitraryFloat : Arbitrary(Float)
arbitraryString
instance arbitraryString : Arbitrary(String)
arbitraryOption
instance arbitraryOption : Arbitrary(Option(a))
arbitraryList
instance arbitraryList : Arbitrary(List(a))
Functions and Values
arb_gen
arb_gen : forall a. () -> Quickcheck.Gen(a)
The generator of an Arbitrary type, as a Quickcheck.Gen ready for quickcheck/gen_at.
fn prop(xs : List(Int)) : Bool = reverse(reverse(xs)) == xs
fn main() = println(passed(quickcheck(arb_gen(), prop)))
Time
Time: instants, wall-clock timestamps, durations, and RFC 3339.
Three value types keep the two kinds of clock reading from being confused:
Instantis a monotonic reading (nanoseconds from an unspecified origin). Only differences of instants are meaningful; the origin is arbitrary. Use it to measure elapsed time, never to name a point in the calendar. -Wallis a system-clock reading: nanoseconds since the Unix epoch (1970-01-01T00:00:00Z), in UTC. Use it to stamp and format a moment. -Durationis a signed span of nanoseconds, the difference of two readings.
Both readings come from the one Clock capability (declared in Concurrent): mono_clock performs mono_now, wall_clock performs wall_now. Which reading a program actually gets is a property of the installed handler, not of the call site. Concurrent.run_clock serves every Clock op from a virtual counter, so a test’s time is a pure function of its sleeps; run_clock_real here serves the two real reads from the OS clock. Both real reads are recorded capability observations, so a time-reading program replays byte-for-byte.
RFC 3339 formatting and parsing are pure, total, UTC-only string code: no locale, no time zone database (tzdb is a political data blob and belongs in a content-addressed package, not a frozen Std). Timestamps at or after the epoch (non-negative wall readings) are supported.
Types
Duration
type Duration = Duration(Int)
A signed span of time, in nanoseconds.
Instant
type Instant = Instant(Int)
A monotonic clock reading, in nanoseconds from an unspecified origin.
Wall
type Wall = Wall(Int)
A wall-clock reading: nanoseconds since the Unix epoch, in UTC.
Functions and Values
nanos_per_sec
nanos_per_sec : () -> Int
Nanoseconds in one second (the Duration scale factor for seconds).
nanos_per_sec()
1000000000
nanos_per_milli
nanos_per_milli : () -> Int
Nanoseconds in one millisecond.
nanos_per_micro
nanos_per_micro : () -> Int
Nanoseconds in one microsecond.
dur_nanos
dur_nanos : (Time.Duration) -> Int
The span in whole nanoseconds.
dur_nanos(seconds(2))
2000000000
nanos
nanos : (Int) -> Time.Duration
A duration of n nanoseconds.
micros
micros : (Int) -> Time.Duration
A duration of n microseconds.
dur_nanos(micros(3))
3000
millis
millis : (Int) -> Time.Duration
A duration of n milliseconds.
seconds
seconds : (Int) -> Time.Duration
A duration of n seconds.
dur_nanos(seconds(1))
1000000000
minutes
minutes : (Int) -> Time.Duration
A duration of n minutes.
dur_as_secs(minutes(2))
120
hours
hours : (Int) -> Time.Duration
A duration of n hours.
dur_as_secs(hours(1))
3600
dur_as_secs
dur_as_secs : (Time.Duration) -> Int
The whole-second part of a duration (truncated toward zero).
dur_as_secs(dur_add(seconds(90), millis(500)))
90
dur_as_millis
dur_as_millis : (Time.Duration) -> Int
The whole-millisecond part of a duration (truncated toward zero).
dur_as_millis(seconds(2))
2000
dur_add
dur_add : (Time.Duration, Time.Duration) -> Time.Duration
Sum of two durations.
dur_nanos(dur_add(seconds(1), millis(500)))
1500000000
dur_sub
dur_sub : (Time.Duration, Time.Duration) -> Time.Duration
Difference of two durations (a - b).
dur_nanos(dur_sub(seconds(2), millis(500)))
1500000000
dur_scale
dur_scale : (Time.Duration, Int) -> Time.Duration
A duration scaled by an integer factor.
dur_nanos(dur_scale(seconds(1), 3))
3000000000
dur_negate
dur_negate : (Time.Duration) -> Time.Duration
A duration with the sign flipped.
dur_nanos(dur_negate(seconds(1)))
-1000000000
dur_cmp
dur_cmp : (Time.Duration, Time.Duration) -> Int
Ordering of two durations (-1, 0, 1).
dur_cmp(seconds(1), seconds(2))
-1
dur_eq
dur_eq : (Time.Duration, Time.Duration) -> Bool
True when the two durations are equal.
dur_eq(millis(1000), seconds(1))
true
instant_nanos
instant_nanos : (Time.Instant) -> Int
The raw monotonic reading in nanoseconds. The absolute value is meaningless across processes; subtract two instants to get an elapsed Duration.
instant_nanos(Instant(500))
500
elapsed
elapsed : (Time.Instant, Time.Instant) -> Time.Duration
The span from earlier to later (later - earlier). Named elapsed, not between, to leave Base’s numeric between range test unshadowed.
dur_nanos(elapsed(Instant(100), Instant(450)))
350
instant_add
instant_add : (Time.Instant, Time.Duration) -> Time.Instant
An instant advanced by a duration.
instant_nanos(instant_add(Instant(100), micros(1)))
1100
wall_nanos
wall_nanos : (Time.Wall) -> Int
The reading in nanoseconds since the Unix epoch (UTC).
wall_nanos(wall_of_nanos(1500000000))
1500000000
wall_of_nanos
wall_of_nanos : (Int) -> Time.Wall
A wall reading from nanoseconds since the Unix epoch.
wall_unix_secs
wall_unix_secs : (Time.Wall) -> Int
Whole seconds since the Unix epoch (truncated toward zero).
wall_unix_secs(wall_of_nanos(1500000000))
1
wall_add
wall_add : (Time.Wall, Time.Duration) -> Time.Wall
A wall reading advanced by a duration.
wall_nanos(wall_add(wall_of_nanos(0), seconds(1)))
1000000000
wall_diff
wall_diff : (Time.Wall, Time.Wall) -> Time.Duration
The span from earlier to later wall readings (later - earlier).
dur_nanos(wall_diff(wall_of_nanos(0), wall_of_nanos(500)))
500
wall_cmp
wall_cmp : (Time.Wall, Time.Wall) -> Int
Ordering of two wall readings (-1, 0, 1).
wall_cmp(wall_of_nanos(1), wall_of_nanos(2))
-1
mono_clock
mono_clock : () -> Time.Instant ! {Concurrent.Clock}
Read the monotonic clock. Deterministic under run_clock, real (and recorded) under run_clock_real.
run_clock_real(\() -> mono_clock())
wall_clock
wall_clock : () -> Time.Wall ! {Concurrent.Clock}
Read the wall clock. Deterministic under run_clock, real (and recorded) under run_clock_real.
format_rfc3339(run_clock_real(\() -> wall_clock()))
run_clock_real
run_clock_real : forall e0 a. (() -> a ! {Concurrent.Clock, IO, e0}) -> a ! {IO, e0}
Run action against the real OS clock. now/sleep still thread a virtual counter (they are the scheduler’s logical time), so this is a superset of run_clock: wall_now and mono_now read the real clock through the prim_* builtins, each a recorded observation, and everything else stays virtual.
run_clock_real(\() -> wall_unix_secs(wall_clock()))
format_rfc3339
format_rfc3339 : (Time.Wall) -> String
Format a wall reading as an RFC 3339 timestamp in UTC, e.g. 2026-07-04T12:34:56Z. A nonzero sub-second part is emitted as exactly nine fractional digits (nanoseconds); an exact second emits no fraction. The output is canonical: one reading formats to one string, so timestamps are comparable and hashable as text.
format_rfc3339(wall_of_nanos(0))
1970-01-01T00:00:00Z
parse_rfc3339
parse_rfc3339 : (String) -> Option(Time.Wall)
Parse an RFC 3339 timestamp to a wall reading in UTC, or None if the string is not a well-formed YYYY-MM-DDTHH:MM:SS[.frac](Z|(+|-)HH:MM). The date/time separators are checked, field ranges are validated, an offset is folded into UTC, and any trailing input is rejected, so the parser is total: a malformed or overlong string is None, never a partial or wrapped value.
wall_unix_secs(unwrap_or(wall_of_nanos(0), parse_rfc3339("2000-01-01T00:00:00Z")))
946684800
Wire
The opt-in serialization layer.
Serialization is deliberately out of Base: a program that never persists a value imports none of this. Everything here is pure total functions over Bytes; writing those bytes to a file, a socket, or a replay log is a capability routed through the effect system, never performed here.
The surface has three layers. The Serialize class and its instances are the codec: a value’s compact positional body, derived structurally by deriving (Serialize) and bottoming out in the primitive instances below. The envelope (wire_encode_stable/wire_decode_stable, or the explicit-digest *_with_digest escape hatches) wraps a body in the frame [scheme tag][kind][contract digest][body?], checked before the body so a stale layout is rejected up front. The version ladder (compose_upgrade/ compose_downgrade) spans frozen versions with O(n) adjacent converters.
The frame
Every serialized thing is one envelope, read left to right, each header part checked before the next is touched:
+------------+------+-----------------+ - - - - - - +
| scheme tag | kind | contract digest | body? |
+------------+------+-----------------+ - - - - - - +
scheme tag length-prefixed string, always "prism-core-hash-v1";
a foreign scheme is rejected before anything else
kind uvarint naming what the digest identifies:
value 0, def 1, protocol 2, kont 3, cert 4
contract digest length-prefixed hex shape digest of the expected
layout, checked before the body is decoded
body? the compact positional encoding below; an absent
body is a pure reference, named by its digest
body encoding Int and I64 as zigzag LEB128 varints, U64 and
constructor tags as plain LEB128, strings and
lists length-prefixed with the length bounded on
both encode and decode, a sum as its constructor
tag followed by its arguments, a product as its
fields in declaration order, no padding
The Bytes representation
Bytes is an unboxed byte buffer (Buf, runtime/prism_buffer.c) plus a read cursor: Bytes(buf, off) is the bytes buf[off ..]. The buffer holds raw u8 with no UTF-8 interpretation, so it threads byte-for-byte identically on both backends (the parity contract a String-of-bytes would break, since reconstructing a String from raw bytes repairs invalid UTF-8 lossily). The cursor makes decode cheap: peeling a byte off the front advances the offset in O(1) with no copy, so a whole decode is linear. Encode accumulates into a growable buffer builder (buf_push, amortized O(1)), so building a container or a string body stays linear rather than paying the quadratic cost a right-nested immutable wire_cat would incur. The concrete representation is owned by the codec; a derived instance only ever threads a Bytes through the builders below, never inspects it. bytes_of_list/bytes_to_list bridge to the older List(Int) view for callers that still want it.
Totality
decode is total. Every read consumes at least one byte from a finite, strictly shrinking list, so decode always terminates; a truncated or hostile input runs out of bytes and fails through Fail rather than looping or panicking. Varints are length-capped and container and string lengths are bounded on both encode and decode, so a hostile length prefix cannot force unbounded work. The frame’s total decoder checks the scheme, kind, and digest before touching the body and rejects trailing bytes after the value.
Deriving
deriving (Serialize) writes the codec: a product encodes its fields in declaration order, a sum prefixes a constructor tag, and each field defers to its own instance. deriving (Stable) succeeds only when every component is itself Stable, so the derivability is the proof that a frozen format contains no unserializable value.
Types
Bytes
type Bytes = Bytes(Buf, Int)
A compact, positional byte body: an unboxed byte buffer and a read cursor, Bytes(buf, off) denoting the bytes buf[off ..]. The concrete representation is owned by the codec; a derived instance only ever threads a Bytes through the builders below, never inspects it.
Loss
type Loss = Loss(List(String))
What a downgrade could not carry down: the names of the fields dropped when lowering a value to an older frozen version. A downgrade never silently discards, it reports every field it had to drop.
Policy
type Policy = Reject | LargestSafeSubset
How to reconcile a higher-version value that a reader cannot represent at its own version: Reject refuses it, LargestSafeSubset downgrades it and keeps the Loss. A version mismatch always has one of these defined outcomes, never undefined behavior.
Type Classes
Serialize
class Serialize(a)
encode : (a) -> Bytes
decode : (Bytes) -> (a, Bytes) ! {Fail | e}
The codec, derived structurally by deriving (Serialize). encode writes a value’s compact positional body; decode reads one back, returning the value alongside the bytes that follow it so a reader can thread a frame’s fields in declaration order. Decode is total: it fails through Fail on a truncated or malformed body rather than panicking, so hostile input is one ordinary failure channel.
decode(encode(x)) recovers the value (and the bytes that followed it):
(fst(decode(encode(42))) : Int)
42
Stable
class Stable(a)
shape_digest_of : (a) -> String
The structural witness that a type’s format is frozen-serializable: a record is Stable when all its fields are, a sum when all its variants’ arguments are. The derivability is the whole content of the proof: persisting a type with a non-stable component (a function, an effectful thunk) fails at the deriving (Stable) site, at compile time, naming the offending field.
The one method, shape_digest_of, is the type’s contract digest: the shape digest of its frozen layout. A derived instance’s body is that digest injected by the compiler from its single shape-digest computation, so shape_digest_of is a per-type constant no source can forge (Stable is derive-only; a hand-written instance is rejected). The argument is used only to resolve the instance by value; the digest does not depend on it. wire_encode_stable/wire_decode_stable read it, so ordinary code never hand-threads a digest string into the envelope.
Instances
serializeInt
instance serializeInt : Serialize(Int)
serializeI64
instance serializeI64 : Serialize(I64)
serializeU64
instance serializeU64 : Serialize(U64)
serializeBool
instance serializeBool : Serialize(Bool)
serializeUnit
instance serializeUnit : Serialize(Unit)
serializeChar
instance serializeChar : Serialize(Char)
serializeString
instance serializeString : Serialize(String)
serializeList
instance serializeList : Serialize(List(a))
serializeBytes
instance serializeBytes : Serialize(Bytes)
serializeOption
instance serializeOption : Serialize(Option(a))
serializePair
instance serializePair : Serialize((a, b))
serializeTriple
instance serializeTriple : Serialize((a, b, c))
serializeMap
instance serializeMap : Serialize(Map(k, v, ord))
Functions and Values
bytes_of_buf
bytes_of_buf : (Buf) -> Wire.Bytes
bytes_buf
bytes_buf : (Wire.Bytes) -> Buf
Recover the buffer of a body’s remaining bytes, compacting away a non-zero cursor with a single slice.
bytes_at
bytes_at : (Wire.Bytes, Int) -> Int
The i-th byte of a body, from the cursor. Bounds are the caller’s contract (the readers check emptiness first); an out-of-range index traps.
bytes_at(bytes_of_list([10, 20, 30]), 1)
20
wire_empty
wire_empty : Wire.Bytes
wire_cat
wire_cat : (Wire.Bytes, Wire.Bytes) -> Wire.Bytes
Concatenate two byte bodies into a fresh buffer.
bytes_to_list(wire_cat(bytes_of_list([1, 2]), bytes_of_list([3])))
[1, 2, 3]
wire_is_empty
wire_is_empty : (Wire.Bytes) -> Bool
True when a body has no bytes left. The reader uses it to reject trailing bytes after a value, and a peer uses it to tell a reference frame (no body) from an inline one.
wire_is_empty(wire_empty)
true
wire_len
wire_len : (Wire.Bytes) -> Int
The number of bytes in a body.
wire_len(bytes_of_list([1, 2, 3]))
3
bytes_of_list
bytes_of_list : (List(Int)) -> Wire.Bytes
Build a body from a List(Int) of byte values (each masked into 0..255), and read a body back out as that list. These bridge the buffer representation to the older list view for callers that thread bytes as ordinary data.
bytes_to_list(bytes_of_list([1, 2, 3]))
[1, 2, 3]
bytes_to_list
bytes_to_list : (Wire.Bytes) -> List(Int)
wire_tag
wire_tag : (Int) -> Wire.Bytes
Encode a constructor tag (a small non-negative integer) as a varint. The derived Serialize for a sum prefixes its body with this.
bytes_to_list(wire_tag(300))
[172, 2]
wire_get_tag
wire_get_tag : (Wire.Bytes) -> (Int, Wire.Bytes) ! {Fail}
Peel a constructor tag off the front of a body, returning it and the rest. The derived Serialize for a sum reads this before dispatching on the tag.
fst(wire_get_tag(wire_tag(300)))
300
wire_scheme_tag
wire_scheme_tag : String
The scheme tag stamped on every frame; a foreign scheme is rejected before anything else in the envelope is read.
wire_scheme_tag
prism-core-hash-v1
wire_kind_value
wire_kind_value : Int
The frame kind for an inline or referenced value (the one implemented today).
wire_kind_value
0
wire_kind_def
wire_kind_def : Int
The reserved frame kind naming a definition by its digest.
wire_kind_protocol
wire_kind_protocol : Int
The reserved frame kind naming a protocol by its digest.
wire_kind_kont
wire_kind_kont : Int
The reserved frame kind naming a continuation by its digest.
wire_kind_cert
wire_kind_cert : Int
The reserved frame kind naming a certificate by its digest.
wire_frame
wire_frame : (Int, String, Wire.Bytes) -> Wire.Bytes
Build a frame around a body. wire_ref builds the bodyless reference form.
bytes_to_list(wire_open(wire_frame(wire_kind_value, "dig", bytes_of_list([7, 8])), wire_kind_value, "dig"))
[7, 8]
wire_ref
wire_ref : (Int, String) -> Wire.Bytes
A pure reference: a frame that carries its contract digest and no body. Its identity is the digest; a peer resolves the body from its store or requests it.
wire_is_reference(wire_ref(wire_kind_value, "d"), wire_kind_value, "d")
true
wire_open
wire_open : (Wire.Bytes, Int, String) -> Wire.Bytes ! {Fail}
Open a frame, checking the scheme, kind, and digest before the body and returning the body bytes that follow. Any header mismatch fails through Fail, so a stale layout is rejected on a cheap comparison, never by a corrupt field.
wire_is_reference
wire_is_reference : (Wire.Bytes, Int, String) -> Bool ! {Fail}
True when a well-formed frame for kind/digest carries no body (a pure reference). Fails if the header itself does not match.
wire_open_value_any
wire_open_value_any : (Wire.Bytes) -> (String, Wire.Bytes) ! {Fail}
Open a value-kind frame without knowing its contract digest up front, returning that digest alongside the body bytes. It checks the scheme and the value kind before the digest. An empty body is legal and returned as-is: a record with no fields encodes to zero bytes, so emptiness cannot be read as “reference” here without an explicit marker in the frame. Trailing-byte discipline is left to the body decoder, exactly as wire_open leaves it. A version-dispatched reader uses the returned digest to pick which frozen rung the body decodes as.
fst(wire_open_value_any(wire_frame(wire_kind_value, "dig", wire_empty)))
dig
wire_encode_value_with_digest
wire_encode_value_with_digest : forall a. (String, a) -> Wire.Bytes
Encode a value as a value-kind frame carrying an explicitly supplied contract digest. This is the escape hatch: it trusts the caller’s digest verbatim, so it is for code that already holds a compiler-computed digest (a stable block’s generated frame helpers) or is exercising a hand-built frame in a test. Ordinary Stable code uses wire_encode_stable, which supplies the digest from the type.
(wire_decode_value_with_digest(wire_encode_value_with_digest("d", 42), "d") : Int)
42
wire_decode_value_with_digest
wire_decode_value_with_digest : forall a. (Wire.Bytes, String) -> a ! {Fail}
Decode a value-kind frame against an explicitly supplied contract digest: check the header, decode the body, and reject trailing bytes. A bodyless reference frame fails here, because a value cannot be materialized without its bytes. The digest-supplying counterpart to wire_encode_value_with_digest; ordinary Stable code uses wire_decode_stable.
wire_encode_stable
wire_encode_stable : forall a. (a) -> Wire.Bytes
Encode a Stable value as a value frame under its own contract digest. The digest is shape_digest_of, a per-type constant the deriving (Stable) instance carries, so no digest string is hand-threaded. This is the ordinary encode for a frozen-serializable value.
wire_decode_stable
wire_decode_stable : forall a. (Wire.Bytes) -> a ! {Fail}
Decode a Stable value from a value frame, checking the frame’s digest against the type’s own shape_digest_of and rejecting trailing bytes. The result type comes from the use site; an unannotated call is ambiguous and asks for an annotation. A wrong digest, a wrong kind, a truncated body, and trailing bytes are all decode failures through Fail. The frame is opened without assuming its digest, the body decoded as the annotated type, and only then is the frame’s digest required to equal that type’s own, so a frame minted for another shape is refused.
no_loss
no_loss : Wire.Loss
The empty Loss: a downgrade that dropped nothing.
dropped
dropped : (List(String)) -> Wire.Loss
A Loss naming the fields a downgrade dropped.
loss_names(dropped(["port", "tls"]))
[port, tls]
loss_names
loss_names : (Wire.Loss) -> List(String)
The field names inside a Loss.
lossless
lossless : (Wire.Loss) -> Bool
True when a Loss dropped nothing, so the downgrade was lossless. The safe subset of a version step is exactly the values whose downgrade is lossless.
(lossless(no_loss), lossless(dropped(["x"])))
(true, false)
loss_union
loss_union : (Wire.Loss, Wire.Loss) -> Wire.Loss
Merge two losses along a composed downgrade.
loss_names(loss_union(dropped(["a"]), dropped(["b"])))
[a, b]
compose_upgrade
compose_upgrade : forall a b c. ((a) -> b, (b) -> c) -> (a) -> c
Compose two adjacent upgrades into one spanning upgrade.
compose_upgrade(\(x) -> x + 1, \(y) -> y * 2)(3)
8
compose_downgrade
compose_downgrade : forall a b c. ((a) -> (b, Wire.Loss), (b) -> (c, Wire.Loss)) -> (a) -> (c, Wire.Loss)
Compose two adjacent downgrades into one spanning downgrade, unioning the losses each step reported.
compose_downgrade(\(z) -> (z - 1, dropped(["hi"])), \(m) -> (m, no_loss))(10)
(9, Wire.Loss([hi]))
reconcile
reconcile : forall a b. (Wire.Policy, (a) -> (b, Wire.Loss), a) -> (b, Wire.Loss) ! {Fail}
Apply a mismatch policy to a downgrade. Reject fails through Fail; LargestSafeSubset runs the downgrade and returns the lowered value with the fields it had to drop.
reconcile(LargestSafeSubset, \(x) -> (x - 1, no_loss), 5)
(4, Wire.Loss([]))
Bibliography
Augustsson, L. (1985). Compiling pattern matching. Functional Programming Languages and Computer Architecture (FPCA).
Augustsson, L., Breitner, J., Claessen, K., Jhala, R., Peyton Jones, S., Shivers, O., Steele, G. L., & Sweeney, T. (2023). The Verse calculus: A core calculus for deterministic functional logic programming. Proceedings of the ACM on Programming Languages, 7(ICFP).
Bauer, A., & Pretnar, M. (2015). Programming with algebraic effects and handlers. Journal of Logical and Algebraic Methods in Programming, 84(1), 108-123.
Barendsen, E., & Smetsers, S. (1993). Conventional and uniqueness typing in graph rewrite systems. Foundations of Software Technology and Theoretical Computer Science (FSTTCS).
Biernacki, D., Piróg, M., Polesiuk, P., & Sieczkowski, F. (2018). Handle with care: Relational interpretation of algebraic effects and handlers. Proceedings of the ACM on Programming Languages, 2(POPL).
Bour, F., Clément, B., & Scherer, G. (2021). Tail modulo cons. Journées Francophones des Langages Applicatifs (JFLA).
Coutts, D., Leshchinskiy, R., & Stewart, D. (2007). Stream fusion: From lists to streams to nothing at all. International Conference on Functional Programming (ICFP).
Damas, L., & Milner, R. (1982). Principal type-schemes for functional programs. Principles of Programming Languages (POPL), 207-212.
de Moura, L., & Ullrich, S. (2021). The Lean 4 theorem prover and programming language. Conference on Automated Deduction (CADE).
Dreyer, D., Harper, R., Chakravarty, M. M. T., & Keller, G. (2007). Modular type classes. Principles of Programming Languages (POPL).
Dunfield, J., & Krishnaswami, N. R. (2013). Complete and easy bidirectional typechecking for higher-rank polymorphism. International Conference on Functional Programming (ICFP).
Felleisen, M., & Friedman, D. P. (1986). Control operators, the SECD-machine, and the lambda-calculus. Formal Description of Programming Concepts III, 193-217.
Flanagan, C., Sabry, A., Duba, B. F., & Felleisen, M. (1993). The essence of compiling with continuations. Programming Language Design and Implementation (PLDI).
Foster, J. N., Greenwald, M. B., Moore, J. T., Pierce, B. C., & Schmitt, A. (2007). Combinators for bidirectional tree transformations: A linguistic approach to the view-update problem. ACM Transactions on Programming Languages and Systems, 29(3).
Friedman, D. P., & Wise, D. S. (1975). Unwinding stylized recursions into iterations (Technical Report 19). Indiana University.
Gibbons, J., & Oliveira, B. C. d. S. (2009). The essence of the iterator pattern. Journal of Functional Programming, 19(3-4), 377-402.
Gill, A., Launchbury, J., & Peyton Jones, S. L. (1993). A short cut to deforestation. Functional Programming Languages and Computer Architecture (FPCA).
Harper, R., & Lillibridge, M. (1994). A type-theoretic approach to higher-order modules with sharing. Principles of Programming Languages (POPL).
Hindley, R. (1969). The principal type-scheme of an object in combinatory logic. Transactions of the American Mathematical Society, 146, 29-60.
Kiselyov, O., & Ishii, H. (2015). Freer monads, more extensible effects. Haskell Symposium.
Kiselyov, O., Sabry, A., & Swords, C. (2013). Extensible effects: An alternative to monad transformers. Haskell Symposium.
Lattner, C., & Adve, V. (2004). LLVM: A compilation framework for lifelong program analysis & transformation. International Symposium on Code Generation and Optimization (CGO).
Leijen, D. (2005). Extensible records with scoped labels. Trends in Functional Programming (TFP).
Leijen, D. (2017). Type directed compilation of row-typed algebraic effects. Principles of Programming Languages (POPL).
Leroy, X. (1994). Manifest types, modules, and separate compilation. Principles of Programming Languages (POPL).
Leroy, X. (2009). Formal verification of a realistic compiler. Communications of the ACM, 52(7), 107-115.
Levy, P. B. (2004). Call-by-push-value: A functional/imperative synthesis. Springer.
Lorenzen, A., & Leijen, D. (2022). Reference counting with frame limited reuse. Proceedings of the ACM on Programming Languages, 6(ICFP), 357-380.
Lorenzen, A., Leijen, D., & Swierstra, W. (2023). FP^2: Fully in-place functional programming. Proceedings of the ACM on Programming Languages, 7(ICFP).
Maranget, L. (2007). Warnings for pattern matching. Journal of Functional Programming, 17(3), 387-421.
Maranget, L. (2008). Compiling pattern matching to good decision trees. ML Workshop.
McBride, C., & Paterson, R. (2008). Applicative programming with effects. Journal of Functional Programming, 18(1), 1-13.
McKeeman, W. M. (1998). Differential testing for software. Digital Technical Journal, 10(1), 100-107.
Milner, R. (1978). A theory of type polymorphism in programming. Journal of Computer and System Sciences, 17(3), 348-375.
Najd, S., & Peyton Jones, S. (2017). Trees that grow. Journal of Universal Computer Science, 23(1), 42-62.
Necula, G. C. (2000). Translation validation for an optimizing compiler. Programming Language Design and Implementation (PLDI).
Peterson, J., & Jones, M. P. (1993). Implementing type classes. Programming Language Design and Implementation (PLDI).
Pickering, M., Érdi, G., Peyton Jones, S., & Eisenberg, R. A. (2016). Pattern synonyms. Haskell Symposium.
Pierce, B. C., & Turner, D. N. (2000). Local type inference. ACM Transactions on Programming Languages and Systems, 22(1), 1-44.
Plotkin, G. D. (1981). A structural approach to operational semantics (Technical Report DAIMI FN-19). Aarhus University.
Plotkin, G., & Power, J. (2001). Adequacy for algebraic effects. Foundations of Software Science and Computation Structures (FoSSaCS).
Plotkin, G., & Power, J. (2003). Algebraic operations and generic effects. Applied Categorical Structures, 11(1), 69-94.
Plotkin, G., & Pretnar, M. (2009). Handlers of algebraic effects. European Symposium on Programming (ESOP).
Pnueli, A., Siegel, M., & Singerman, E. (1998). Translation validation. Tools and Algorithms for the Construction and Analysis of Systems (TACAS).
Reinking, A., Xie, N., de Moura, L., & Leijen, D. (2021). Perceus: Garbage free reference counting with reuse. Programming Language Design and Implementation (PLDI).
Sulzmann, M., Chakravarty, M. M. T., Peyton Jones, S., & Donnelly, K. (2007). System F with type equality coercions. Types in Language Design and Implementation (TLDI).
Swierstra, W. (2008). Data types à la carte. Journal of Functional Programming, 18(4), 423-436.
Wand, M. (1987). Complete type inference for simple objects. Logic in Computer Science (LICS).
Weeks, S. (2006). Whole-program compilation in MLton. ML Workshop.
White, L., Bour, F., & Yallop, J. (2015). Modular implicits. Electronic Proceedings in Theoretical Computer Science, 198, 22-63.
Wu, N., Schrijvers, T., & Hinze, R. (2014). Effect handlers in scope. Haskell Symposium.
Xie, N., Brachthäuser, J. I., Hillerström, D., Schuster, P., & Leijen, D. (2020). Effect handlers, evidently. Proceedings of the ACM on Programming Languages, 4(ICFP).
Xie, N., & Leijen, D. (2021). Generalized evidence passing for effect handlers. Proceedings of the ACM on Programming Languages, 5(ICFP).
Yang, X., Chen, Y., Eide, E., & Regehr, J. (2011). Finding and understanding bugs in C compilers. Programming Language Design and Implementation (PLDI).