The surface-form file extension changes from .ailx to .ail. AILang's
authoring surface now uses the same .ail stem as its canonical JSON
form (.ail.json), giving the language a single coherent extension
family: .ail is the LLM-authored Form A, .ail.json is the canonical
JSON-AST Form B.
Scope (touched):
- 61 example renames examples/**/*.ailx → .ail (git mv)
- 1 rename experiments/.../rendered/ailx.md → ail.md
- 35 content-edited live-toolchain files (crates/, docs/DESIGN.md,
docs/roadmap.md, docs/PROSE_ROUNDTRIP.md, skills/, bench/reference/*.c,
experiment crates under experiments/.../{render,harness,master})
- Experiment-crate cohort rename Cohort::Ailx → Cohort::Ail,
Form::Ailx → Form::Ail, per_cohort/ailx → per_cohort/ail,
{form-only: ailx} → {form-only: ail}, ```ailx → ```ail
Out of scope (deliberately untouched, to preserve honest history):
- docs/journal-archive.md (content-frozen per CLAUDE.md)
- docs/journals/, docs/specs/, docs/plans/, bench/orchestrator-stats/
- experiments/.../runs/ (frozen LLM-output artefacts; models actually
saw .ailx — renaming would falsify the experimental record)
Verification: cargo build/test --workspace green; experiment crate
cargo test green; bench/check.py + compile_check.py + cross_lang.py
all 0-regressed; negative grep for ailx|Ailx|AILX outside the
out-of-scope paths returns zero matches.
Opens immediate follow-up: roadmap.md P2 todo `ail check`/build/run
accept .ail extension — after this rename, .ail is canonical
authoring surface but the CLI still produces a misleading JSON-parse
error on `ail check foo.ail`. That's the next iter.
25 KiB
AILang authoring reference
1. Header
AILang is a small data-as-source functional language designed for an LLM author. This document is the complete authoring reference. Every well-formed program is a module. Read the whole thing once before you write code; the order of sections is the order of dependencies.
2. Modules and the on-disk form
A module is the top-level container. It has a name (matching the file
stem), an optional list of imports, and a flat list of definitions.
Every program you write is exactly one module. Cross-module imports
are out of scope for the tasks in this experiment — every task is a
single self-contained module with an empty imports array.
A definition is the unit that gets a content hash. There are five kinds of definition: function, constant, data type, class, and instance. Their order within the module is preserved on disk but is not load-bearing semantically — forward references are legal.
The schema of the on-disk form is fixed. The toolchain rejects any module that does not conform.
{form-only: json} The on-disk form is a single JSON object with the following required top-level fields, in canonical key order:
schema: the string"ailang/v0". Any other value is rejected at load time.name: the module name (matches the file stem on disk).imports: array of import objects. May be empty.defs: array of definition objects. Order is the declared order; the hash is computed over the canonical-keys-sorted byte form so inserting fields in source order is safe.
Canonical key order means object keys are emitted in sorted order
when the toolchain computes the canonical bytes for hashing; for
authoring you can write keys in whatever order is clearest. Numeric
literals are bare JSON numbers; strings are JSON strings. Every
definition object carries a kind discriminator (fn, const,
type, class, instance). Every term object carries a t
discriminator. Every type object carries a k discriminator. Every
pattern object carries a p discriminator. Every literal object
carries a kind discriminator.
{/form-only}
{form-only: ail}
The AIL surface is parenthesised tagged-head Lisp-style notation.
Every form begins with ( followed by a tag identifier; positional
arguments follow; the form closes with ). There are no infix
operators, no operator precedence, no implicit conversions.
A module starts with (module NAME ...) and contains import forms
followed by definition forms. Whitespace and ;-prefixed line
comments are insignificant; indentation is for readability only.
Identifier characters allowed: letters, digits, _, -, +, *,
/, <, >, =, !, ?, %. An identifier may not start with a
digit. String literals are double-quoted with \", \\, \n, \t
escapes; integer literals are signed decimal; the (unit) keyword
denotes the unit value.
The AIL form is the printed dual of the JSON form: ail parse file.ail produces the same canonical bytes as the JSON form would,
and ail render file.ail.json produces the AIL text. Round-trip
through this pair is gated by an integration test.
{/form-only}
{example: fn_returns_int}
3. Types
A type is one of four shapes. Every type position in a function signature must be filled in; there is no implicit type inference at the binding form. Constructor types may carry type arguments. Functions are first-class — function types appear as parameter and return types.
A type constructor names a base type or a user-defined ADT. The
prelude provides Int, Bool, Str, Float, Unit. User ADTs are
introduced with the data form (see section 6); after declaration
their name is usable wherever a type constructor is expected.
A type variable is a placeholder introduced by forall at the top
of a function signature. Within the signature the variable behaves
as an opaque type; the toolchain instantiates it at each call site.
A function type names parameter types, parameter modes (see section 4), a return type, a return mode, and an effect row (see section 8).
A forall is universal quantification: it lists the type variables
bound and the body type they appear in. Forall is only valid at the
top of a function or constant signature.
{form-only: json}
Type::Con: {"k": "con", "name": "Int"} for a base type; for a
parameterised type, {"k": "con", "name": "List", "args": [...]}.
The args field is omitted when empty (canonical-bytes stable).
Type::Var: {"k": "var", "name": "a"}. Variable names are bare
identifiers; conventionally lower-case single letters.
Type::Fn: {"k": "fn", "params": [...], "ret": ..., "effects": [...]}
with optional param_modes and ret_mode fields. The effects
array is a set; sort and dedup at authoring time for readability.
Type::Forall: {"k": "forall", "vars": [...], "body": ...} with an
optional constraints array for class constraints (see section 10).
{/form-only}
{form-only: ail}
Type::Con: Int, Bool, Str, Float, Unit, or a user type
name. For parameterised types: (con List Int) reads "List of Int";
nested: (con Map (con Str) (con Int)).
Type::Var: a bare identifier alone in type position (no wrapper),
e.g. a inside a (forall (a) ...). Type variables are
syntactically distinguished from type constructors by being declared
in the enclosing forall.
Type::Fn: (fn-type (params T1 T2) (ret RET) (effects E1 E2)). The
parameter and effect lists may be empty. Modes wrap the relevant
type: (own T) or (borrow T) — see section 4.
Type::Forall: (forall (a b) BODY-TYPE) quantifies over a and
b. Class constraints attach as (forall (a) (constraints (Eq a)) BODY-TYPE).
{/form-only}
{example: forall_polymorphic}
4. Mode annotations
Every function parameter carries a mode. The mode says how the
callee may treat the value: implicit is the legacy default
(treated as own), own means the caller transfers ownership and
the callee consumes the value, borrow means the caller retains
ownership and the callee may inspect but not consume.
The mode also applies to the return value: ret_mode says whether
the function transfers ownership to the caller (own) or returns a
borrow (borrow). For most functions the return mode is own or
implicit; borrow returns are rare and used only for accessor-style
functions.
Modes are mandatory on every parameter and on the return position. The schema rejects unannotated fn signatures only when annotations are explicitly requested; for compatibility the legacy form (no mode field at all) is treated as all-implicit.
{form-only: json}
On Type::Fn, two optional fields carry modes:
param_modes: array of strings"implicit","own", or"borrow", one per parameter, in the same order asparams. The array is omitted from the canonical bytes when every entry is"implicit"; this is purely a serialisation optimisation. Author the field explicitly when at least one parameter isownorborrow.ret_mode: a single string of the same vocabulary. Omitted when it would be"implicit".
Example: a fn that borrows two ints and returns an int:
"param_modes": ["borrow", "borrow"], ret_mode omitted (implicit).
A fn that consumes a list and returns a new list:
"param_modes": ["own"], "ret_mode": "own".
{/form-only}
{form-only: ail}
Modes are surface wrappers around the type in parameter and return
position. Inside a (fn-type ...):
(params (own T1) (borrow T2))—T1is owned,T2is borrowed.(ret (own T))— the function returns an owned value.- A bare type with no mode wrapper is implicit (legacy default).
When all parameters and the return are implicit, the surface omits the mode wrappers entirely; this is the canonical form for fns that do not yet need explicit annotations. Adding modes is a forward change: an existing fn keeps the same canonical bytes as long as no annotation is added.
Writing (borrow T) on a parameter the typechecker wants own
for surfaces as the over-strict-mode diagnostic — advisory, and
suppressable via the fn def's suppress clause. The mode is part
of the fn's signature and contributes to its content hash, so
making the choice explicit at authoring time avoids surprise
hash changes when the typechecker's defaults shift.
{/form-only}
{example: param_modes_all}
5. Functions
A function definition binds a name to a body of code. The signature declares the parameter types, the return type, and the effect set (if any). The body is an expression — there is no statement form. Functions are first-class values: they may be passed to other functions, returned, stored in data structures.
Inside a function body, four term forms appear most often: a variable reference (the name of a parameter, a local binding, or a top-level definition); a function application (a callee plus positional arguments); a lambda (an anonymous function with its own signature); a literal value (see section 9).
Lambdas carry their own parameter list, parameter types, return
type, and effect set. Lambdas may close over names from the
enclosing lexical scope; the closure is captured by value (the
captured values are reference-counted under --alloc=rc).
A let binds a name to the value of one expression for the
duration of another expression. A letrec is the recursive variant:
the bound name is visible inside its own body. Both are
expressions, not statements — they have a value.
{form-only: json}
Function definition: {"kind": "fn", "name": ..., "type": ..., "params": [...], "body": ...} plus optional doc.
Term variants relevant here:
- Variable reference:
{"t": "var", "name": "x"}. - Application:
{"t": "app", "fn": ..., "args": [...]}. - Lambda:
{"t": "lam", "params": [...], "paramTypes": [...], "retType": ..., "effects": [...], "body": ...}. - Let:
{"t": "let", "name": "x", "value": ..., "body": ...}. - Letrec:
{"t": "letrec", "name": "go", "type": ..., "params": [...], "body": ..., "in": ...}.
The paramTypes and retType keys in lam use camelCase; the rest
use snake_case. This is historical and the canonical bytes preserve
the difference.
{/form-only}
{form-only: ail}
Function definition: (fn NAME (doc STRING)? (type TYPE) (params NAME*) (body TERM)). The doc clause is optional but recommended.
Term forms relevant here:
- Variable reference: a bare identifier, e.g.
xor+. - Application:
(app CALLEE ARG*)— head isapp, then the callee term, then positional arguments. - Lambda:
(lam (params NAME*) (paramTypes TYPE*) (ret TYPE) (effects EFFECT*) BODY). - Let:
(let (NAME VALUE) BODY)— bindsNAMEtoVALUEinBODY. - Letrec:
(letrec NAME (type FN-TYPE) (params NAME*) BODY IN-BODY)—NAMEis recursively visible insideBODY.
Inside any term position, an integer literal is a bare decimal
number, a string is a double-quoted string, a bool is true or
false, unit is (unit).
{/form-only}
{example: fn_calls_prelude}
{example: fn_with_lambda}
6. Algebraic data types
A data type declaration introduces a new named type with one or more constructors. Each constructor takes zero or more positional argument types and produces a value of the declared type. ADTs may be parameterised by type variables; the variables are listed before the constructors and may appear in the constructor argument types.
A constructor is invoked at the term level by naming the type and
the constructor plus the positional arguments. The runtime
representation under --alloc=rc is a tagged heap cell with one
slot per constructor field; the tag identifies which constructor
was used.
Constructor names live in their own namespace, separate from function names. The same identifier may be a function and a constructor without conflict, though for readability the convention is constructors are capitalised and functions are lower-case.
{form-only: json}
Type definition: {"kind": "type", "name": ..., "vars": [...], "doc": ..., "ctors": [...]}. The vars array is the list of type
parameter names; omitted when empty.
Each ctor: {"name": "Cons", "fields": [TYPE, TYPE, ...]}.
fields is a positional list of field types.
Constructor invocation: {"t": "ctor", "type": "List", "ctor": "Cons", "args": [...]}. The type field disambiguates which ADT
the constructor belongs to (necessary because constructor names are
not globally unique). args is positional and matches the
constructor's declared fields in order; nullary constructors
carry "args": [].
{/form-only}
{form-only: ail}
Type definition: (data NAME (vars TYVAR*)? (doc STRING)? (ctor CTOR-NAME ARG-TYPE*)*). The vars clause is optional; nullary
constructors have an empty argument list.
Example: (data List (ctor Nil) (ctor Cons Int (con List))) is a
monomorphic Int list with a Nil and a Cons constructor.
Constructor invocation: (ctor TYPE-NAME CTOR-NAME ARG*). The
TYPE-NAME precedes the constructor name so the parser does not
need a separate registry to resolve overlapping constructor names
across ADTs. Example: (ctor List Cons 1 (ctor List Nil)) builds a
single-element list.
{/form-only}
{example: data_simple}
7. Pattern matching
Pattern matching is the way to inspect an ADT value. A match
form takes a scrutinee expression plus one or more arms; each arm
pairs a pattern with a body. Arms are tried top-to-bottom; the
first matching arm's body is evaluated. The toolchain checks
exhaustiveness: every constructor of the scrutinee's type must be
reachable through some arm, otherwise the typechecker errors.
There are four pattern shapes. A wildcard matches anything and binds nothing. A variable pattern matches anything and binds the matched value to the named identifier for use inside the arm body. A literal pattern matches only when the scrutinee is bit-equal to the literal value; literal patterns work on Int, Bool, Str, and Unit (Float patterns are rejected at typecheck — see section 9). A constructor pattern matches when the scrutinee is built by the named constructor and recursively matches the constructor's fields.
{form-only: json}
Match expression: {"t": "match", "scrutinee": ..., "arms": [...]}.
Each arm: {"pat": PATTERN, "body": TERM}.
Pattern variants:
- Wildcard:
{"p": "wild"}— no other fields. - Variable:
{"p": "var", "name": "h"}— binds the scrutinee toh. - Literal:
{"p": "lit", "lit": LITERAL}— samelitshape as a term literal. - Constructor:
{"p": "ctor", "ctor": "Cons", "fields": [PATTERN, PATTERN, ...]}— positional sub-patterns matching the constructor's declared field types.
Pattern variables are linear: each name appears at most once in a single pattern. Repeating a name is a typecheck error. {/form-only}
{form-only: ail}
Match expression: (match SCRUT (case PAT BODY) (case PAT BODY) ...). Arms are introduced by (case ...); the order matters (top-
to-bottom).
Pattern shapes:
- Wildcard:
_— bare underscore is the wildcard pattern. - Variable: a bare identifier alone, e.g.
h, binds the scrutinee. - Literal: a literal value alone, e.g.
0,true,"abc",(unit), matches bit-equal scrutinees. - Constructor:
(ctor CTOR-NAME SUBPAT*)— note notypeslot here, because the constructor name is resolved against the scrutinee's type.
Pattern variables introduced by a (case PAT BODY) are in scope
inside BODY and bind there only. They do not leak into sibling
arms or into code outside the match.
{/form-only}
{example: data_with_match}
{example: match_literal_pattern}
8. Effects
Every function type carries an effect row — a set of effect labels
that the function may raise. The two currently wired effects are
IO (required to call effect operations like io/print_int) and
Diverge (used for functions that may not terminate). An empty
effect row marks the function as pure.
Effect operations are reached through the do term, not through a
regular function application. The do form names the operation
(e.g. io/print_int) and lists the operation's arguments; the
typechecker resolves the operation against the prelude's effect-op
table, checks the arguments, and accumulates the operation's
effect label into the enclosing function's effect row.
A seq term sequences two effectful expressions: the left-hand
side is evaluated for its effects and its result discarded, then
the right-hand side is evaluated and its value is the value of the
whole expression. seq is the canonical way to perform multiple
IO operations one after the other inside a function body.
{form-only: json}
Effect row on Type::Fn: the effects field is a JSON array of
strings (the effect labels). [] means pure. ["IO"] is the
common effectful case. The toolchain compares effect rows modulo
order; for readability sort the array alphabetically.
Effect operation invocation: {"t": "do", "op": "io/print_int", "args": [INT-TERM]}. The op is a string naming the prelude
operation. The args list matches the operation's declared
parameter types.
Sequencing: {"t": "seq", "lhs": EFFECT-TERM, "rhs": EFFECT-TERM}.
The value of the seq is the value of rhs; lhs's value is
discarded but its effects count toward the enclosing fn's row.
{/form-only}
{form-only: ail}
Effect row on a fn type: (effects E1 E2 ...) inside the
(fn-type ...). Empty row: omit the (effects) clause or write
(effects). Common effectful row: (effects IO). Order is sorted
in canonical output.
Effect operation: (do OP-NAME ARG*) — the leading do head is
the parser's signal that the next identifier is an effect op, not a
function. Example: (do io/print_int 42) prints the integer 42
and raises the IO effect.
Sequencing: (seq LHS RHS). The value of (seq X Y) is Y's
value; X's value is discarded. Chains of sequencing are written
nested: (seq A (seq B C)) runs A, then B, then yields C's
value.
{/form-only}
{example: fn_with_do_seq}
9. Literals
There are five literal shapes. An integer literal is a signed
64-bit decimal. A boolean literal is true or false. A string
literal is a UTF-8 sequence in double quotes; AILang restricts the
authored byte set to ASCII printable (Decision 6 Constraint 3) — non-
ASCII bytes are an authoring error. A unit literal is the value of
type Unit, written (unit) in AIL and {"kind": "unit"} in JSON.
A Float literal is an IEEE-754 binary64 value. When you author
Floats, write the decimal form (3.14) — the AIL parser converts
to the 64-bit bit pattern and emits the canonical 16-character
lowercase hex string into the JSON. You never author the hex
encoding by hand; the toolchain owns that representation. NaN and
±Inf are representable through the prelude constants nan, inf,
and neg_inf (see section 11).
Float pattern matching is rejected at typecheck. IEEE-== makes
Float patterns semantically dubious (NaN never matches; equality
is bit-exact, not approximate), so the language hard-rejects them
to push authors toward comparison-based dispatch.
{form-only: json} Literal variants and their JSON shapes:
- Int:
{"kind": "int", "value": 42}— value is a JSON number. - Bool:
{"kind": "bool", "value": true}— value is a JSON bool. - Str:
{"kind": "str", "value": "hi"}— value is a JSON string. - Unit:
{"kind": "unit"}— no value payload. - Float:
{"kind": "float", "bits": "400921fb54442d18"}— thebitsfield is a 16-character lowercase hex string of the IEEE- 754 binary64 bit pattern. The hex-string path keeps NaN and ±Inf representable (JSON numbers cannot encode them) and avoids serialisation drift acrossserde_jsonversions.
Float bit patterns are computed by the toolchain on ail parse,
not by the author. When you write 3.14 in AIL, the parser emits
"bits": "40091eb851eb851f" in the JSON form.
{/form-only}
{form-only: ail} Literal surface forms:
- Int: bare signed decimal, e.g.
42,-7,0. - Bool:
trueorfalseas bare keywords. - Str: double-quoted with
\",\\,\n,\tescapes, e.g."hello". Bytes outside ASCII printable are rejected by the authoring rule (Decision 6 Constraint 3). - Unit: the keyword
(unit). - Float: bare decimal with at least one digit on each side of the
point, e.g.
3.14,0.5,-2.0. The parser converts to the bit-pattern hex string when emitting JSON; you never author the hex form.
There is no separate syntax for exponent-form floats in the v1
authoring surface; for non-finite values use the prelude constants
nan, inf, neg_inf.
{/form-only}
{example: floats}
{example: bool_str}
10. Typeclasses
A typeclass is a named bundle of operations that may be implemented for multiple types. The class declaration lists the methods; each instance declaration provides the bodies for a specific type. The compiler resolves method calls at the call site and monomorphises the implementation: there is no runtime dispatch.
A class is parameterised by a single type variable (multi-parameter classes are not supported in v1). Methods are declared with their full type signature; the class parameter appears as a type variable inside the method type. Methods may carry an optional default body that instances inherit unless they explicitly override.
Class constraints attach to polymorphic function types: a function
forall a. (Eq a) => (a, a) -> Bool requires its callers to
provide an Eq instance for a at the call site. The constraint
appears inside the forall quantifier, in the constraints slot.
The prelude provides four built-in classes: Eq (equality), Ord
(ordering with <, <=, >, >=), Num (arithmetic — +, -,
*, /, neg), and Bounded. Their instances for Int, Bool,
Str, Float, and Unit are built in; you do not declare them.
{form-only: json}
Class declaration: {"kind": "class", "name": "Show", "param": "a", "methods": [...]}. Each method: {"name": "show", "type": ..., "default": ...} — type is the method's full signature with the
class parameter appearing as a Type::Var; default is an
optional body (omit when the method is abstract-required).
Instance declaration: {"kind": "instance", "class": "Show", "type": ..., "methods": [...]}. The type field is the concrete
type the class is applied to. Each method: {"name": "show", "body": TERM}.
Constraint on a polymorphic fn: inside Type::Forall, the
constraints field is an array of constraint objects: {"class": "Eq", "type": TYPE}. Empty when the polymorphic fn has no class
constraints; omitted from canonical bytes when empty.
{/form-only}
{form-only: ail}
Class declaration: (class NAME (param TYVAR) (method NAME (type METHOD-TYPE) (default BODY)?)*). The default clause is
optional; methods without a default must be supplied by every
instance.
Instance declaration: (instance NAME (type CONCRETE-TYPE) (method NAME BODY)*). The method bodies must match the class's method
signatures with the class parameter substituted to the concrete
type.
Constraints inside a forall: (forall (a) (constraints (Eq a) (Ord a)) BODY-TYPE). Each constraint is (CLASS-NAME TYPE) — usually
the type is a single bound type variable but a concrete type is
also legal (typically a typecheck error unless an instance exists).
{/form-only}
{example: class_def}
{example: instance_def}
11. The prelude
The prelude provides a fixed set of operators, conversion functions, and effect operations that are in scope without any import. The operators are polymorphic over a small set of types and resolve at the call site by the resolved argument type.
| Name | Type | Notes |
|---|---|---|
+ - * / |
forall a. (a, a) -> a |
Int and Float; codegen filters at use site |
% |
(Int, Int) -> Int |
Int only |
== != < <= > >= |
forall a. (a, a) -> Bool |
Int, Bool, Str, Float, Unit |
not |
(Bool) -> Bool |
Boolean negation |
neg |
forall a. (a) -> a |
Int and Float negation |
int_to_float |
(Int) -> Float |
Widening; codegen uses sitofp |
float_to_int_truncate |
(Float) -> Int |
Saturating truncation to zero |
float_to_str |
(Float) -> Str |
Formatted decimal |
is_nan |
(Float) -> Bool |
True only for NaN bit patterns |
nan inf neg_inf |
Float |
Bare-value constants |
__unreachable__ |
forall a. a |
Polymorphic bottom; LLVM unreachable |
Effect operations (reached through do, not app):
| Op | Signature | Effect |
|---|---|---|
io/print_int |
(Int) -> Unit |
IO |
io/print_bool |
(Bool) -> Unit |
IO |
io/print_str |
(Str) -> Unit |
IO |
io/print_float |
(Float) -> Unit |
IO |
int_to_str is intentionally NOT in the prelude — it is type-
installed in a future milestone but codegen-deferred pending the
heap-Str ABI work. Do not call it.
12. Content addressing
AILang has content-addressed identity: every top-level definition
has a 16-character hash derived from its canonical bytes. The
toolchain computes hashes on ail parse and ail check; the
author writes no hash literal in either form, and the JSON form
has no hash field. This is symmetric across forms and removes
hash as a form-distinguishing factor. You author code; the
toolchain owns the identity.
13. Out of scope, and closing directive
This reference does NOT teach: cross-module imports (every task is
a single self-contained module with an empty imports array),
refinement types (reserved in the schema but pass-through in this
version), point-free style (cut by Decision 6), operator
overloading (cut), syntactic sugar of any kind (cut). The four
tasks you will be given do not need any of these features.
When asked to write a module, return the complete module text and nothing else. No markdown fences. No prose explanation. No preamble. The harness parses your response verbatim; any wrapping text is treated as a parse error.