iter cma.1: master mini-spec + render binary for cross-model authoring experiment

New top-level experiments/2026-05-12-cross-model-authoring/ (out of
root Cargo workspace; nested-crate standalone via empty [workspace]
table per Cargo idiom). The renderer projects one canonical
master/spec.md into two form-specific files (rendered/json.md and
rendered/ailx.md) by walking three directive forms ({form-only:
json}, {form-only: ailx}, {example: <id>}) and emitting per-example
fenced blocks sourced from 13 curated .ail.json fixtures.

Four test gates green (10/10 total):
- splitter_unit (7) — directive parser, malformed inputs panic
- spec_completeness (1) — AST-variant visitor lifted verbatim from
  schema_coverage.rs; 34/34 variants covered by the curated subset
- example_roundtrip (1) — every fixture roundtrips through
  ailang_surface::{print, parse} to identical canonical bytes
- token_balance (1) — form-only block totals balanced to 3.22% under
  tiktoken-rs::cl100k_base (gate is ±5%)

Six of the 34 variants did not have a dedicated fixture topic in the
plan and were absorbed into the closest existing fixture per the
plan's Step 4.14 escape hatch (Let/Clone → fn_calls_prelude; LetRec
→ data_with_match; If → match_literal_pattern; ReuseAs →
data_simple; Const → floats). Surfaced as a journal note, not a
plan revision.

Two ancillary additions vs. plan, both structural Cargo necessities,
spec-compatible: render/Cargo.toml carries an empty [workspace] table
so the nested crate refuses parent-workspace discovery, and
render/.gitignore drops /target + Cargo.lock. Both surfaced inline
in the per-iter journal's Concerns section.

cma.2 (harness) and cma.3 (live IONOS run + DESIGN.md addendum +
journal + roadmap edits) remain out of scope per the spec.
This commit is contained in:
2026-05-12 11:43:10 +02:00
parent fd6efdbcf6
commit a8c29d130b
30 changed files with 3764 additions and 0 deletions
@@ -0,0 +1,23 @@
{
"iter_id": "cma.1",
"date": "2026-05-12",
"mode": "standard",
"outcome": "DONE",
"tasks_total": 9,
"tasks_completed": 9,
"reloops_per_task": {
"1": 0,
"2": 0,
"3": 0,
"4": 0,
"5": 0,
"6": 0,
"7": 0,
"8": 1,
"9": 0
},
"review_loops_spec": 0,
"review_loops_quality": 0,
"blocked_reason": null,
"notes": "Task 8 token_balance failed on first run (json=1800, ailx=1692, ratio 6.00%); one substantive AILX-side spec.md edit brought it to 3.22%. Counts as 1 re-loop on Task 8 (implementer-phase repair, not a review-loop)."
}
+128
View File
@@ -0,0 +1,128 @@
# iter cma.1 — Master mini-spec + renderer for the cross-model authoring experiment
**Date:** 2026-05-12
**Started from:** fd6efdbc
**Status:** DONE
**Tasks completed:** 9 of 9
## Summary
Stands up the master mini-spec source and the renderer binary under a
new top-level `experiments/2026-05-12-cross-model-authoring/` tree
that lives outside the root Cargo workspace. The renderer projects
one form-agnostic `master/spec.md` into two form-specific files
(`rendered/json.md` and `rendered/ailx.md`) by walking three directive
forms (`{form-only: json}`, `{form-only: ailx}`, `{example: <id>}`)
and emitting per-example fenced blocks from 13 curated `.ail.json`
fixtures. Four test gates land green: a 7-test splitter unit suite,
a `spec_completeness` test borrowing the AST-variant visitor verbatim
from `crates/ailang-core/tests/schema_coverage.rs` (34/34 variants
observed across the curated subset), an `example_roundtrip` test
mirroring the surface roundtrip contract, and a `token_balance` test
that gates form-only block tokens to ±5% under `tiktoken-rs::cl100k_base`
(final ratio: json=1800, ailx=1742, ratio 3.22%). Two follow-on iters
(cma.2: harness, cma.3: live IONOS run + DESIGN.md addendum) are out
of scope per the spec.
## Per-task notes
- cma.1.1: Bootstrap experiment dir + Cargo skeleton — created
`experiments/.../{README.md,render/Cargo.toml,render/src/main.rs}`
plus the four target directories. Spec-pinned dependency paths
resolve `ailang-core` and `ailang-surface` via relative
`../../../crates/...` references.
- cma.1.2: Directive splitter (TDD) — 7 unit tests RED first, then
implementation; added a `[lib]` target so the integration tests
can reach `xmodel_render::splitter`. GREEN on first impl run.
- cma.1.3: spec_completeness test — visitor + EXPECTED_VARIANTS
list lifted verbatim from `crates/ailang-core/tests/schema_coverage.rs`;
only `examples_dir()` body and the test name changed. Confirmed RED
on the empty master/examples/ directory (failure mode: "no .ail.json
fixtures found").
- cma.1.4: 13 curated `.ail.json` fixtures (one per file as planned).
The plan's 13 fixture topics naturally covered 28 of the 34 AST
variants; the remaining 6 (Def::Const, Term::Let, Term::LetRec,
Term::If, Term::Clone, Term::ReuseAs) were folded into existing
fixtures per the plan's Step 4.14 instruction ("Extend the fixture
closest in scope … do not weaken the test"). Concrete absorptions:
Let + Clone into `fn_calls_prelude`; LetRec into `data_with_match`
(as a recursive `count_via_letrec` helper); If into
`match_literal_pattern` (as a `sign_if` helper); ReuseAs into
`data_simple` (the `wrap_one` body uses `(reuse-as src ...)`);
Const into `floats` (a `const pi : Float = 3.14`). All 34 variants
green on first run.
- cma.1.5: example_roundtrip test — verbatim from plan; all 13
fixtures roundtrip through `ailang_surface::print` and
`ailang_surface::parse` to the same canonical bytes on first
implementation run.
- cma.1.6: master/spec.md — 13 sections of form-agnostic prose with
parallel JSON/AILX form-only blocks in §2-5, §7, §9, §10 plus the
prelude table in §11 (no form-only there; the table itself is the
content). Section §11's prelude tabulation reflects the live
`crates/ailang-check/src/builtins.rs` symbol set: `+`, `-`, `*`,
`/`, `%`, `==`, `!=`, `<`, `<=`, `>`, `>=`, `not`, `neg`,
`int_to_float`, `float_to_int_truncate`, `float_to_str`, `is_nan`,
`nan`/`inf`/`neg_inf`, `__unreachable__`, plus the effect ops
`io/print_{int,bool,str,float}`. `int_to_str` is explicitly named
as OUT (heap-Str ABI milestone).
- cma.1.7: Wire renderer end-to-end — examples.rs and main.rs filled
in verbatim from the plan. Renderer ran clean on first invocation;
json.md (33409 bytes) and ailx.md (23964 bytes) emit with the
expected 13 fenced blocks each (and zero of the wrong fence type).
- cma.1.8: token_balance test — first run reported json=1800,
ailx=1692, ratio 6.00% (above the 5% threshold). One substantive
AILX-side extension to §4 (mode annotations) added authoring
guidance on `over-strict-mode` and hash stability under future
default shifts; final ratio json=1800, ailx=1742, 3.22%. The
rebalancing is content, not filler.
- cma.1.9: Final render + full sweep — `cargo test --manifest-path
experiments/.../render/Cargo.toml` ends with 10 passed; 0 failed
across four test suites; `git status` shows the entire experiment
tree as untracked.
## Concerns
- Task 1: Cargo's automatic workspace discovery hits the root
manifest, so the experiment crate refuses to build on its own. The
fix is the standard Cargo idiom for a nested-but-out-of-workspace
crate: add an empty `[workspace]` table to the experiment's
`Cargo.toml`. The plan did not enumerate this; I added it with a
comment pointing at the spec authority (§Architecture lines 90-95)
for the standalone constraint. Boss should confirm this is the
intended mechanism vs. e.g. adding the experiment to a
`workspace.exclude` list in the root manifest (the spec is explicit
that the root Cargo.toml is not touched, which the chosen mechanism
respects).
- Task 1 ancillary: a `.gitignore` was added under
`experiments/.../render/` ignoring `/target` and `Cargo.lock`. Not
enumerated in the plan but a structural necessity to keep cargo's
build output and lockfile out of the repo. Boss can keep, replace
with a top-level rule, or move to the root `.gitignore`.
## Known debt
- None surfaced inside the iter. Two named deferrals remain out of
scope (cma.2 harness, cma.3 live run + DESIGN.md addendum), per
the spec.
## Files touched
27 new files, all under `experiments/2026-05-12-cross-model-authoring/`:
- `README.md`
- `master/spec.md`
- `master/examples/*.ail.json` × 13
- `render/Cargo.toml`
- `render/.gitignore`
- `render/src/{main,lib,splitter,examples}.rs`
- `render/tests/{splitter_unit,spec_completeness,example_roundtrip,token_balance}.rs`
- `rendered/json.md`
- `rendered/ailx.md`
`git diff --name-only HEAD | wc -l`: 0 (untracked tree; use
`git status --porcelain --untracked-files=all experiments/...` to
enumerate).
## Stats
`bench/orchestrator-stats/2026-05-12-iter-cma.1.json`
+1
View File
@@ -20,3 +20,4 @@
- 2026-05-12 — iter rt.2: DESIGN.md anchor for Roundtrip Invariant (top-level section + Decision 6 Constraint 2 upward cross-reference) → 2026-05-12-iter-rt.2.md
- 2026-05-12 — audit-rt: milestone close (Roundtrip Invariant) — architect drift fixed in rt.tidy (3 items: Direction-2 5th test, roadmap P1 removed, wording sync); bench all-green → 2026-05-12-audit-rt.md
- 2026-05-12 — iter boss: `/boss` skill — autonomous-mode discipline (Direction freedom, Notifications, WhatsNew procedure) extracted from CLAUDE.md into a user-invoked skill → 2026-05-12-iter-boss.md
- 2026-05-12 — iter cma.1: master mini-spec + render binary for cross-model authoring experiment (13 fixtures cover 34/34 AST variants, 4 test gates green, rendered/{json,ailx}.md checked in; out-of-workspace nested crate) → 2026-05-12-iter-cma.1.md
@@ -0,0 +1,42 @@
# Cross-model authoring-form test
Empirical measurement of whether `.ail.json` or `.ailx` is the form a
foreign LLM author reaches for and succeeds with. Single subject for
v1: Qwen3-Coder-Next via IONOS. Two blind cohorts; same four tasks.
Parent spec: `docs/specs/2026-05-12-cross-model-authoring-form-test.md`.
## Layout
- `master/spec.md` — canonical mini-spec source (form-agnostic prose +
`{form-only: X}` blocks + `{example: id}` markers).
- `master/examples/*.ail.json` — AST source-of-truth; each example
prints to either form via the existing roundtrip machinery.
- `master/tasks/*.task.json` — task definitions consumed by the
harness. **Authored in cma.2**, not cma.1.
- `render/` — standalone Cargo crate, outside the root workspace,
builds the renderer binary.
- `rendered/json.md`, `rendered/ailx.md` — projected mini-specs,
checked into the repo for review.
- `harness/`**Authored in cma.2**.
- `runs/<date>-<hash>/` — populated by `harness` during a live run.
## Running the renderer
```
cargo run --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml -- \
--master experiments/2026-05-12-cross-model-authoring/master \
--rendered experiments/2026-05-12-cross-model-authoring/rendered
```
## Running the tests
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml
```
Three integration tests: `example_roundtrip` (each example loads,
prints to AILX, reparses to the same canonical bytes), `spec_completeness`
(every AST variant in `ailang_core::ast` is exercised by at least
one example), `token_balance` (form-only blocks balanced within ±5%
across the two rendered files).
@@ -0,0 +1,46 @@
{
"schema": "ailang/v0",
"name": "bool_str",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "is_true",
"doc": "Trivial Bool literal. Exercises Literal::Bool.",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Bool" },
"effects": []
},
"params": [],
"body": { "t": "lit", "lit": { "kind": "bool", "value": true } }
},
{
"kind": "fn",
"name": "greeting",
"doc": "Trivial Str literal. Exercises Literal::Str.",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Str" },
"effects": []
},
"params": [],
"body": { "t": "lit", "lit": { "kind": "str", "value": "hi" } }
},
{
"kind": "fn",
"name": "unit_value",
"doc": "Trivial Unit literal — needed so Literal::Unit appears at least once in the corpus.",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Unit" },
"effects": []
},
"params": [],
"body": { "t": "lit", "lit": { "kind": "unit" } }
}
]
}
@@ -0,0 +1,24 @@
{
"schema": "ailang/v0",
"name": "class_def",
"imports": [],
"defs": [
{
"kind": "class",
"name": "MyShow",
"param": "a",
"doc": "A toy single-method class. The instance in instance_def.ail.json provides a body for `show` at type Int.",
"methods": [
{
"name": "show",
"type": {
"k": "fn",
"params": [ { "k": "var", "name": "a" } ],
"ret": { "k": "con", "name": "Str" },
"effects": []
}
}
]
}
]
}
@@ -0,0 +1,45 @@
{
"schema": "ailang/v0",
"name": "data_simple",
"imports": [],
"defs": [
{
"kind": "type",
"name": "Box",
"vars": ["a"],
"doc": "A unary box around a polymorphic value.",
"ctors": [
{
"name": "MkBox",
"fields": [ { "k": "var", "name": "a" } ]
}
]
},
{
"kind": "fn",
"name": "wrap_one",
"doc": "Construct a Box Int via MkBox. The body uses (reuse-as src body) — a hint that the freshly-allocated Box should reuse the source's memory slot under --alloc=rc. Exercises TermCtor, ReuseAs, and Type::Var inside a ctor field position.",
"type": {
"k": "fn",
"params": [
{ "k": "con", "name": "Box", "args": [ { "k": "con", "name": "Int" } ] }
],
"param_modes": ["own"],
"ret": { "k": "con", "name": "Box", "args": [ { "k": "con", "name": "Int" } ] },
"ret_mode": "own",
"effects": []
},
"params": ["src"],
"body": {
"t": "reuse-as",
"source": { "t": "var", "name": "src" },
"body": {
"t": "ctor",
"type": "Box",
"ctor": "MkBox",
"args": [ { "t": "lit", "lit": { "kind": "int", "value": 1 } } ]
}
}
}
]
}
@@ -0,0 +1,118 @@
{
"schema": "ailang/v0",
"name": "data_with_match",
"imports": [],
"defs": [
{
"kind": "type",
"name": "List",
"doc": "Monomorphic singly-linked Int list — boxed, recursive.",
"ctors": [
{ "name": "Nil", "fields": [] },
{
"name": "Cons",
"fields": [
{ "k": "con", "name": "Int" },
{ "k": "con", "name": "List" }
]
}
]
},
{
"kind": "fn",
"name": "head_or_zero",
"doc": "Return the head of xs, or 0 if empty. Exercises TermMatch with two arms, PatternCtor (both nullary Nil and binary Cons), and PatternWild on the tail field.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "List" } ],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["xs"],
"body": {
"t": "match",
"scrutinee": { "t": "var", "name": "xs" },
"arms": [
{
"pat": { "p": "ctor", "ctor": "Nil", "fields": [] },
"body": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
},
{
"pat": {
"p": "ctor",
"ctor": "Cons",
"fields": [
{ "p": "var", "name": "h" },
{ "p": "wild" }
]
},
"body": { "t": "var", "name": "h" }
}
]
}
},
{
"kind": "fn",
"name": "count_via_letrec",
"doc": "Walk xs counting nodes using a local recursive let. Exercises TermLetRec and TermVar; the recursive `go` is bound locally.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "List" } ],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["xs"],
"body": {
"t": "letrec",
"name": "go",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "List" } ],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["ys"],
"body": {
"t": "match",
"scrutinee": { "t": "var", "name": "ys" },
"arms": [
{
"pat": { "p": "ctor", "ctor": "Nil", "fields": [] },
"body": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
},
{
"pat": {
"p": "ctor",
"ctor": "Cons",
"fields": [
{ "p": "wild" },
{ "p": "var", "name": "t" }
]
},
"body": {
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 1 } },
{
"t": "app",
"fn": { "t": "var", "name": "go" },
"args": [ { "t": "var", "name": "t" } ]
}
]
}
}
]
},
"in": {
"t": "app",
"fn": { "t": "var", "name": "go" },
"args": [ { "t": "var", "name": "xs" } ]
}
}
}
]
}
@@ -0,0 +1,14 @@
{
"schema": "ailang/v0",
"name": "floats",
"imports": [],
"defs": [
{
"kind": "const",
"name": "pi",
"doc": "Pi as a Float constant. Exercises Def::Const and Literal::Float — the bit pattern below is f64::to_bits(3.14).",
"type": { "k": "con", "name": "Float" },
"value": { "t": "lit", "lit": { "kind": "float", "bits": "40091eb851eb851f" } }
}
]
}
@@ -0,0 +1,35 @@
{
"schema": "ailang/v0",
"name": "fn_calls_prelude",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "add",
"doc": "Add two Ints via the polymorphic prelude `+`. Also exercises TermLet by binding the sum to a local name before returning it, and TermClone by re-using the let-bound value.",
"type": {
"k": "fn",
"params": [
{ "k": "con", "name": "Int" },
{ "k": "con", "name": "Int" }
],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["x", "y"],
"body": {
"t": "let",
"name": "s",
"value": {
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "var", "name": "x" },
{ "t": "var", "name": "y" }
]
},
"body": { "t": "clone", "value": { "t": "var", "name": "s" } }
}
}
]
}
@@ -0,0 +1,20 @@
{
"schema": "ailang/v0",
"name": "fn_returns_int",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "answer",
"doc": "The simplest possible fn — no params, returns a fixed Int.",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": [],
"body": { "t": "lit", "lit": { "kind": "int", "value": 42 } }
}
]
}
@@ -0,0 +1,32 @@
{
"schema": "ailang/v0",
"name": "fn_with_do_seq",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"doc": "Print two Ints in sequence. Exercises TermDo, TermSeq, and the IO effect on a fn type. The trailing unit return is implicit in the last Do (op returns Unit).",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Unit" },
"effects": ["IO"]
},
"params": [],
"body": {
"t": "seq",
"lhs": {
"t": "do",
"op": "io/print_int",
"args": [ { "t": "lit", "lit": { "kind": "int", "value": 1 } } ]
},
"rhs": {
"t": "do",
"op": "io/print_int",
"args": [ { "t": "lit", "lit": { "kind": "int", "value": 2 } } ]
}
}
}
]
}
@@ -0,0 +1,39 @@
{
"schema": "ailang/v0",
"name": "fn_with_lambda",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "make_adder",
"doc": "Curried adder: takes an Int and returns an Int -> Int closure. Exercises TermLam and Type::Fn appearing as a return type.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"effects": []
},
"params": ["x"],
"body": {
"t": "lam",
"params": ["y"],
"paramTypes": [ { "k": "con", "name": "Int" } ],
"retType": { "k": "con", "name": "Int" },
"effects": [],
"body": {
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "var", "name": "x" },
{ "t": "var", "name": "y" }
]
}
}
}
]
}
@@ -0,0 +1,24 @@
{
"schema": "ailang/v0",
"name": "forall_polymorphic",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "id",
"doc": "The polymorphic identity function. Exercises Type::Forall and Type::Var.",
"type": {
"k": "forall",
"vars": ["a"],
"body": {
"k": "fn",
"params": [ { "k": "var", "name": "a" } ],
"ret": { "k": "var", "name": "a" },
"effects": []
}
},
"params": ["x"],
"body": { "t": "var", "name": "x" }
}
]
}
@@ -0,0 +1,36 @@
{
"schema": "ailang/v0",
"name": "instance_def",
"imports": [],
"defs": [
{
"kind": "class",
"name": "MyShow",
"param": "a",
"doc": "Re-declared here so this fixture is self-contained; class_def.ail.json declares the same class verbatim.",
"methods": [
{
"name": "show",
"type": {
"k": "fn",
"params": [ { "k": "var", "name": "a" } ],
"ret": { "k": "con", "name": "Str" },
"effects": []
}
}
]
},
{
"kind": "instance",
"class": "MyShow",
"type": { "k": "con", "name": "Int" },
"doc": "Trivial MyShow Int — show always returns the fixed string \"int\".",
"methods": [
{
"name": "show",
"body": { "t": "lit", "lit": { "kind": "str", "value": "int" } }
}
]
}
]
}
@@ -0,0 +1,70 @@
{
"schema": "ailang/v0",
"name": "match_literal_pattern",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "classify",
"doc": "Classify an Int via literal patterns plus a wildcard fallback. Exercises PatternLit and PatternWild.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["n"],
"body": {
"t": "match",
"scrutinee": { "t": "var", "name": "n" },
"arms": [
{
"pat": { "p": "lit", "lit": { "kind": "int", "value": 0 } },
"body": { "t": "lit", "lit": { "kind": "int", "value": 100 } }
},
{
"pat": { "p": "wild" },
"body": { "t": "lit", "lit": { "kind": "int", "value": 200 } }
}
]
}
},
{
"kind": "fn",
"name": "sign_if",
"doc": "Return -1 / 0 / 1 for the sign of n using nested TermIf. Exercises TermIf in both then/else positions plus TermApp on the polymorphic comparison.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["n"],
"body": {
"t": "if",
"cond": {
"t": "app",
"fn": { "t": "var", "name": "<" },
"args": [
{ "t": "var", "name": "n" },
{ "t": "lit", "lit": { "kind": "int", "value": 0 } }
]
},
"then": { "t": "lit", "lit": { "kind": "int", "value": -1 } },
"else": {
"t": "if",
"cond": {
"t": "app",
"fn": { "t": "var", "name": "==" },
"args": [
{ "t": "var", "name": "n" },
{ "t": "lit", "lit": { "kind": "int", "value": 0 } }
]
},
"then": { "t": "lit", "lit": { "kind": "int", "value": 0 } },
"else": { "t": "lit", "lit": { "kind": "int", "value": 1 } }
}
}
}
]
}
@@ -0,0 +1,48 @@
{
"schema": "ailang/v0",
"name": "param_modes_all",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "f_implicit",
"doc": "Implicit mode is the legacy default — `param_modes` is omitted from canonical JSON when every entry is Implicit, which is the case here. The visitor still observes ParamMode::Implicit via the (defaulted) ret_mode field on Type::Fn.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["x"],
"body": { "t": "var", "name": "x" }
},
{
"kind": "fn",
"name": "f_own",
"doc": "(own T) — caller transfers ownership; callee consumes.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"param_modes": ["own"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["x"],
"body": { "t": "var", "name": "x" }
},
{
"kind": "fn",
"name": "f_borrow",
"doc": "(borrow T) — caller retains ownership; callee may not consume.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["x"],
"body": { "t": "var", "name": "x" }
}
]
}
@@ -0,0 +1,613 @@
# 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: ailx}
The AILX 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 AILX form is the printed dual of the JSON form: `ail parse
file.ailx` produces the same canonical bytes as the JSON form would,
and `ail render file.ail.json` produces the AILX 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: ailx}
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 as `params`. 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 is `own` or
`borrow`.
- `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: ailx}
Modes are surface wrappers around the type in parameter and return
position. Inside a `(fn-type ...)`:
- `(params (own T1) (borrow T2))``T1` is owned, `T2` is 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: ailx}
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. `x` or `+`.
- Application: `(app CALLEE ARG*)` — head is `app`, then the callee
term, then positional arguments.
- Lambda: `(lam (params NAME*) (paramTypes TYPE*) (ret TYPE)
(effects EFFECT*) BODY)`.
- Let: `(let (NAME VALUE) BODY)` — binds `NAME` to `VALUE` in
`BODY`.
- Letrec: `(letrec NAME (type FN-TYPE) (params NAME*) BODY IN-BODY)`
— `NAME` is recursively visible inside `BODY`.
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: ailx}
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 to
`h`.
- Literal: `{"p": "lit", "lit": LITERAL}` — same `lit` shape 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: ailx}
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 no `type` slot
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: ailx}
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 AILX 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 AILX 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"}` — the
`bits` field 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 across `serde_json` versions.
Float bit patterns are computed by the toolchain on `ail parse`,
not by the author. When you write `3.14` in AILX, the parser emits
`"bits": "40091eb851eb851f"` in the JSON form.
{/form-only}
{form-only: ailx}
Literal surface forms:
- Int: bare signed decimal, e.g. `42`, `-7`, `0`.
- Bool: `true` or `false` as bare keywords.
- Str: double-quoted with `\"`, `\\`, `\n`, `\t` escapes, 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: ailx}
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.
@@ -0,0 +1,2 @@
/target
Cargo.lock
@@ -0,0 +1,30 @@
# Empty `[workspace]` table declares this crate as a standalone
# Cargo workspace of its own, so it is NOT auto-discovered as a member
# of the root workspace at /home/brummel/dev/ailang/Cargo.toml. The
# spec (`docs/specs/2026-05-12-cross-model-authoring-form-test.md`
# §Architecture lines 90-95) requires this experiment to live outside
# the root workspace; the root Cargo.toml is intentionally not modified.
[workspace]
[package]
name = "xmodel-render"
version = "0.0.1"
edition = "2021"
publish = false
[[bin]]
name = "xmodel-render"
path = "src/main.rs"
[lib]
name = "xmodel_render"
path = "src/lib.rs"
[dependencies]
ailang-core = { path = "../../../crates/ailang-core" }
ailang-surface = { path = "../../../crates/ailang-surface" }
serde_json = "1"
anyhow = "1"
[dev-dependencies]
tiktoken-rs = "0.6"
@@ -0,0 +1,38 @@
//! Per-form rendering of a single master example.
//!
//! - JSON form: read the on-disk canonical bytes verbatim, emit a
//! ```json``` fenced block.
//! - AILX form: load the example via ailang_core::load_module, print
//! via ailang_surface::print, emit a ```ailx``` fenced block.
use anyhow::{anyhow, Context, Result};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Form {
Json,
Ailx,
}
pub fn render_example(examples_dir: &Path, id: &str, form: Form) -> Result<String> {
let path = examples_dir.join(format!("{id}.ail.json"));
if !path.exists() {
return Err(anyhow!(
"example id `{id}` references missing fixture at {}",
path.display()
));
}
match form {
Form::Json => {
let bytes = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
Ok(format!("```json\n{}\n```\n", bytes.trim_end()))
}
Form::Ailx => {
let module = ailang_core::load_module(&path)
.with_context(|| format!("load_module on {}", path.display()))?;
let text = ailang_surface::print(&module);
Ok(format!("```ailx\n{}\n```\n", text.trim_end()))
}
}
}
@@ -0,0 +1,5 @@
//! Library surface for xmodel-render's internal modules — exposed so
//! integration tests can reach them.
pub mod splitter;
pub mod examples;
@@ -0,0 +1,67 @@
//! xmodel-render — projects master/spec.md into rendered/json.md and rendered/ailx.md.
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use xmodel_render::splitter::{self, Segment};
use xmodel_render::examples;
fn main() -> Result<()> {
let (master_dir, rendered_dir) = parse_args()?;
let spec_path = master_dir.join("spec.md");
let examples_dir = master_dir.join("examples");
let spec_text = std::fs::read_to_string(&spec_path)
.with_context(|| format!("reading {}", spec_path.display()))?;
let segments = splitter::split(&spec_text);
let json_out = render_for(&segments, &examples_dir, examples::Form::Json, splitter::Form::Json)?;
let ailx_out = render_for(&segments, &examples_dir, examples::Form::Ailx, splitter::Form::Ailx)?;
std::fs::create_dir_all(&rendered_dir)
.with_context(|| format!("creating {}", rendered_dir.display()))?;
let json_path = rendered_dir.join("json.md");
let ailx_path = rendered_dir.join("ailx.md");
std::fs::write(&json_path, json_out)
.with_context(|| format!("writing {}", json_path.display()))?;
std::fs::write(&ailx_path, ailx_out)
.with_context(|| format!("writing {}", ailx_path.display()))?;
eprintln!("xmodel-render: wrote {} and {}", json_path.display(), ailx_path.display());
Ok(())
}
fn render_for(
segments: &[Segment],
examples_dir: &Path,
example_form: examples::Form,
keep_form: splitter::Form,
) -> Result<String> {
let mut out = String::new();
for seg in segments {
match seg {
Segment::Prose(text) => out.push_str(text),
Segment::FormOnly { form, body } if *form == keep_form => out.push_str(body),
Segment::FormOnly { .. } => {} // drop the other form's block
Segment::Example(id) => {
let rendered = examples::render_example(examples_dir, id, example_form)?;
out.push_str(&rendered);
}
}
}
Ok(out)
}
fn parse_args() -> Result<(PathBuf, PathBuf)> {
let mut master: Option<PathBuf> = None;
let mut rendered: Option<PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--master" => master = Some(PathBuf::from(args.next().ok_or_else(|| anyhow!("--master needs a value"))?)),
"--rendered" => rendered = Some(PathBuf::from(args.next().ok_or_else(|| anyhow!("--rendered needs a value"))?)),
other => return Err(anyhow!("unknown argument: {}", other)),
}
}
let master = master.ok_or_else(|| anyhow!("--master <dir> is required"))?;
let rendered = rendered.ok_or_else(|| anyhow!("--rendered <dir> is required"))?;
Ok((master, rendered))
}
@@ -0,0 +1,92 @@
//! Directive splitter for master/spec.md.
//!
//! Recognises three directives, one per line, no nesting:
//! {form-only: json} … {/form-only}
//! {form-only: ailx} … {/form-only}
//! {example: <id>}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Form {
Json,
Ailx,
}
impl Form {
fn parse(name: &str) -> Option<Form> {
match name {
"json" => Some(Form::Json),
"ailx" => Some(Form::Ailx),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Segment {
Prose(String),
FormOnly { form: Form, body: String },
Example(String),
}
/// Split a master/spec.md text into a sequence of segments.
///
/// Panics on malformed input (unknown form name, unclosed form-only block).
/// The renderer treats these as authoring bugs that should fail the test
/// suite rather than silently produce diverging mini-specs.
pub fn split(input: &str) -> Vec<Segment> {
let mut out: Vec<Segment> = Vec::new();
let mut current_prose = String::new();
let mut current_block: Option<(Form, String)> = None;
for line in input.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
// Inside a form-only block: collect until the closing marker.
if let Some((form, body)) = current_block.as_mut() {
if trimmed == "{/form-only}" {
let body_owned = std::mem::take(body);
out.push(Segment::FormOnly { form: *form, body: body_owned });
current_block = None;
} else {
body.push_str(line);
}
continue;
}
// {form-only: <name>}
if let Some(rest) = trimmed.strip_prefix("{form-only: ").and_then(|r| r.strip_suffix("}")) {
flush_prose(&mut current_prose, &mut out);
let form = Form::parse(rest)
.unwrap_or_else(|| panic!("unknown form name in directive: {}", rest));
current_block = Some((form, String::new()));
continue;
}
// {example: <id>}
if let Some(id) = trimmed.strip_prefix("{example: ").and_then(|r| r.strip_suffix("}")) {
flush_prose(&mut current_prose, &mut out);
out.push(Segment::Example(id.to_string()));
continue;
}
// Stray closer with no opener.
if trimmed == "{/form-only}" {
panic!("stray {{/form-only}} without matching opener");
}
// Plain prose line.
current_prose.push_str(line);
}
if current_block.is_some() {
panic!("unclosed form-only block at end of input");
}
flush_prose(&mut current_prose, &mut out);
out
}
fn flush_prose(buf: &mut String, out: &mut Vec<Segment>) {
if !buf.is_empty() {
out.push(Segment::Prose(std::mem::take(buf)));
}
}
@@ -0,0 +1,73 @@
//! Roundtrip gate for master/examples/.
//!
//! Mirrors crates/ailang-surface/tests/round_trip.rs but scoped to
//! the master examples directory. For each .ail.json fixture: load,
//! print via ailang_surface::print, reparse, canonicalise both sides,
//! assert byte equality.
use std::path::{Path, PathBuf};
fn examples_dir() -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest.parent().unwrap().join("master").join("examples")
}
fn list_examples() -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(examples_dir())
.expect("master/examples/ should exist")
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
.filter(|path| {
path.file_name()
.and_then(|s| s.to_str())
.map(|name| name.ends_with(".ail.json"))
.unwrap_or(false)
})
.collect();
paths.sort();
paths
}
fn round_trip_one(path: &Path) -> Result<(), String> {
let original = ailang_core::load_module(path)
.map_err(|e| format!("{}: load_module failed: {e}", path.display()))?;
let text = ailang_surface::print(&original);
let parsed = ailang_surface::parse(&text)
.map_err(|e| format!("{}: surface::parse failed: {e}", path.display()))?;
let bytes_original = ailang_core::canonical::to_bytes(&original);
let bytes_parsed = ailang_core::canonical::to_bytes(&parsed);
if bytes_original != bytes_parsed {
return Err(format!(
"{}: roundtrip diverged.\n--- printed AILX ---\n{}\n--- original bytes len {} vs parsed bytes len {} ---",
path.display(),
text,
bytes_original.len(),
bytes_parsed.len(),
));
}
Ok(())
}
#[test]
fn every_example_roundtrips_via_ailx() {
let examples = list_examples();
assert!(
!examples.is_empty(),
"master/examples/ should contain at least one .ail.json fixture"
);
let mut failures: Vec<String> = Vec::new();
for path in &examples {
if let Err(msg) = round_trip_one(path) {
failures.push(msg);
}
}
if !failures.is_empty() {
panic!(
"{} of {} examples failed roundtrip:\n\n{}",
failures.len(),
examples.len(),
failures.join("\n\n")
);
}
}
@@ -0,0 +1,396 @@
//! Schema-coverage audit (scoped to master/examples/): every AST enum
//! variant of `Def`, `Term`, `Pattern`, `Literal`, `Type`, `ParamMode`
//! is exercised by at least one master example.
//!
//! Mirrors `crates/ailang-core/tests/schema_coverage.rs` — the visitor
//! and the expected-variants list are copied verbatim. The only
//! adjustment is the `examples_dir()` resolver (one parent up rather
//! than two, because this crate's manifest sits at
//! `experiments/.../render/`).
//!
//! Pure reader: this test loads fixtures but does not modify any
//! committed content. A failure means a master example must be added
//! (in a separate iteration); the test must not be relaxed.
use std::collections::HashSet;
use std::path::PathBuf;
use ailang_core::ast::{
Def, InstanceMethod, Literal, Module, ParamMode, Pattern, Term, Type,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum VariantTag {
// Def
DefFn,
DefConst,
DefType,
DefClass,
DefInstance,
// Term
TermLit,
TermVar,
TermApp,
TermLet,
TermLetRec,
TermIf,
TermDo,
TermCtor,
TermMatch,
TermLam,
TermSeq,
TermClone,
TermReuseAs,
// Pattern
PatternWild,
PatternVar,
PatternLit,
PatternCtor,
// Literal
LiteralInt,
LiteralBool,
LiteralStr,
LiteralUnit,
LiteralFloat,
// Type
TypeCon,
TypeFn,
TypeVar,
TypeForall,
// ParamMode
ParamModeImplicit,
ParamModeOwn,
ParamModeBorrow,
}
/// Lists every expected variant. Stored as a const array so that
/// adding a new `VariantTag` here and forgetting to add the
/// corresponding match arm in the visitor is a Rust compile error
/// (and vice versa).
const EXPECTED_VARIANTS: &[VariantTag] = &[
VariantTag::DefFn,
VariantTag::DefConst,
VariantTag::DefType,
VariantTag::DefClass,
VariantTag::DefInstance,
VariantTag::TermLit,
VariantTag::TermVar,
VariantTag::TermApp,
VariantTag::TermLet,
VariantTag::TermLetRec,
VariantTag::TermIf,
VariantTag::TermDo,
VariantTag::TermCtor,
VariantTag::TermMatch,
VariantTag::TermLam,
VariantTag::TermSeq,
VariantTag::TermClone,
VariantTag::TermReuseAs,
VariantTag::PatternWild,
VariantTag::PatternVar,
VariantTag::PatternLit,
VariantTag::PatternCtor,
VariantTag::LiteralInt,
VariantTag::LiteralBool,
VariantTag::LiteralStr,
VariantTag::LiteralUnit,
VariantTag::LiteralFloat,
VariantTag::TypeCon,
VariantTag::TypeFn,
VariantTag::TypeVar,
VariantTag::TypeForall,
VariantTag::ParamModeImplicit,
VariantTag::ParamModeOwn,
VariantTag::ParamModeBorrow,
];
fn visit_def(def: &Def, observed: &mut HashSet<VariantTag>) {
match def {
Def::Fn(fd) => {
observed.insert(VariantTag::DefFn);
visit_type(&fd.ty, observed);
visit_term(&fd.body, observed);
}
Def::Const(cd) => {
observed.insert(VariantTag::DefConst);
visit_type(&cd.ty, observed);
visit_term(&cd.value, observed);
}
Def::Type(td) => {
observed.insert(VariantTag::DefType);
for ctor in &td.ctors {
for field_ty in &ctor.fields {
visit_type(field_ty, observed);
}
}
}
Def::Class(cd) => {
observed.insert(VariantTag::DefClass);
for m in &cd.methods {
visit_type(&m.ty, observed);
if let Some(body) = &m.default {
visit_term(body, observed);
}
}
}
Def::Instance(id) => {
observed.insert(VariantTag::DefInstance);
visit_type(&id.type_, observed);
for m in &id.methods {
visit_instance_method(m, observed);
}
}
}
}
fn visit_instance_method(m: &InstanceMethod, observed: &mut HashSet<VariantTag>) {
visit_term(&m.body, observed);
}
fn visit_term(t: &Term, observed: &mut HashSet<VariantTag>) {
match t {
Term::Lit { lit } => {
observed.insert(VariantTag::TermLit);
visit_literal(lit, observed);
}
Term::Var { .. } => {
observed.insert(VariantTag::TermVar);
}
Term::App { callee, args, .. } => {
observed.insert(VariantTag::TermApp);
visit_term(callee, observed);
for a in args {
visit_term(a, observed);
}
}
Term::Let { value, body, .. } => {
observed.insert(VariantTag::TermLet);
visit_term(value, observed);
visit_term(body, observed);
}
Term::LetRec { ty, body, in_term, .. } => {
observed.insert(VariantTag::TermLetRec);
visit_type(ty, observed);
visit_term(body, observed);
visit_term(in_term, observed);
}
Term::If { cond, then, else_ } => {
observed.insert(VariantTag::TermIf);
visit_term(cond, observed);
visit_term(then, observed);
visit_term(else_, observed);
}
Term::Do { args, .. } => {
observed.insert(VariantTag::TermDo);
for a in args {
visit_term(a, observed);
}
}
Term::Ctor { args, .. } => {
observed.insert(VariantTag::TermCtor);
for a in args {
visit_term(a, observed);
}
}
Term::Match { scrutinee, arms } => {
observed.insert(VariantTag::TermMatch);
visit_term(scrutinee, observed);
for arm in arms {
visit_pattern(&arm.pat, observed);
visit_term(&arm.body, observed);
}
}
Term::Lam { param_tys, ret_ty, body, .. } => {
observed.insert(VariantTag::TermLam);
for ty in param_tys {
visit_type(ty, observed);
}
visit_type(ret_ty, observed);
visit_term(body, observed);
}
Term::Seq { lhs, rhs } => {
observed.insert(VariantTag::TermSeq);
visit_term(lhs, observed);
visit_term(rhs, observed);
}
Term::Clone { value } => {
observed.insert(VariantTag::TermClone);
visit_term(value, observed);
}
Term::ReuseAs { source, body } => {
observed.insert(VariantTag::TermReuseAs);
visit_term(source, observed);
visit_term(body, observed);
}
}
}
fn visit_pattern(p: &Pattern, observed: &mut HashSet<VariantTag>) {
match p {
Pattern::Wild => {
observed.insert(VariantTag::PatternWild);
}
Pattern::Var { .. } => {
observed.insert(VariantTag::PatternVar);
}
Pattern::Lit { lit } => {
observed.insert(VariantTag::PatternLit);
visit_literal(lit, observed);
}
Pattern::Ctor { fields, .. } => {
observed.insert(VariantTag::PatternCtor);
for f in fields {
visit_pattern(f, observed);
}
}
}
}
fn visit_literal(l: &Literal, observed: &mut HashSet<VariantTag>) {
match l {
Literal::Int { .. } => {
observed.insert(VariantTag::LiteralInt);
}
Literal::Bool { .. } => {
observed.insert(VariantTag::LiteralBool);
}
Literal::Str { .. } => {
observed.insert(VariantTag::LiteralStr);
}
Literal::Unit => {
observed.insert(VariantTag::LiteralUnit);
}
Literal::Float { .. } => {
observed.insert(VariantTag::LiteralFloat);
}
}
}
fn visit_type(t: &Type, observed: &mut HashSet<VariantTag>) {
match t {
Type::Con { args, .. } => {
observed.insert(VariantTag::TypeCon);
for a in args {
visit_type(a, observed);
}
}
Type::Fn { params, param_modes, ret, ret_mode, .. } => {
observed.insert(VariantTag::TypeFn);
for p in params {
visit_type(p, observed);
}
for m in param_modes {
visit_param_mode(m, observed);
}
visit_type(ret, observed);
visit_param_mode(ret_mode, observed);
}
Type::Var { .. } => {
observed.insert(VariantTag::TypeVar);
}
Type::Forall { body, .. } => {
observed.insert(VariantTag::TypeForall);
visit_type(body, observed);
}
}
}
fn visit_param_mode(m: &ParamMode, observed: &mut HashSet<VariantTag>) {
match m {
ParamMode::Implicit => {
observed.insert(VariantTag::ParamModeImplicit);
}
ParamMode::Own => {
observed.insert(VariantTag::ParamModeOwn);
}
ParamMode::Borrow => {
observed.insert(VariantTag::ParamModeBorrow);
}
}
}
fn visit_module(m: &Module, observed: &mut HashSet<VariantTag>) {
for def in &m.defs {
visit_def(def, observed);
}
}
fn examples_dir() -> std::path::PathBuf {
// CARGO_MANIFEST_DIR for this test is
// experiments/2026-05-12-cross-model-authoring/render/
// Master examples live one parent up + master/examples/.
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest.parent().unwrap().join("master").join("examples")
}
fn list_json_fixtures() -> Vec<PathBuf> {
let dir = examples_dir();
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.unwrap_or_else(|e| panic!("read_dir({}): {e}", dir.display()))
.filter_map(|entry| entry.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.ends_with(".ail.json"))
.unwrap_or(false)
})
.collect();
paths.sort();
paths
}
#[test]
fn every_ast_variant_is_observed_in_master_examples() {
let fixtures = list_json_fixtures();
assert!(
!fixtures.is_empty(),
"no .ail.json fixtures found under {}",
examples_dir().display()
);
let mut observed: HashSet<VariantTag> = HashSet::new();
let mut load_failures = Vec::<String>::new();
for path in &fixtures {
match ailang_core::load_module(path) {
Ok(m) => visit_module(&m, &mut observed),
Err(e) => load_failures.push(format!("{}: {e}", path.display())),
}
}
// Load failures fail the test on their own — a fixture that
// does not load is a corpus bug, surface it loud.
if !load_failures.is_empty() {
panic!(
"{} fixture(s) failed to load during coverage scan:\n{}",
load_failures.len(),
load_failures.join("\n")
);
}
let missing: Vec<VariantTag> = EXPECTED_VARIANTS
.iter()
.copied()
.filter(|v| !observed.contains(v))
.collect();
if !missing.is_empty() {
let names: Vec<String> = missing.iter().map(|v| format!("{v:?}")).collect();
panic!(
"{} AST variant(s) have NO fixture coverage in {}:\n {}\n\n\
every variant must be exercised by at least one fixture; \
add a fixture that uses each missing variant (do NOT \
weaken this test).",
missing.len(),
examples_dir().display(),
names.join("\n ")
);
}
eprintln!(
"schema coverage ok: {} variants observed across {} fixtures",
observed.len(),
fixtures.len()
);
}
@@ -0,0 +1,79 @@
use xmodel_render::splitter::{split, Form, Segment};
#[test]
fn plain_text_passes_through_unchanged() {
let input = "hello\nworld\n";
let segments = split(input);
assert_eq!(segments, vec![Segment::Prose("hello\nworld\n".to_string())]);
}
#[test]
fn form_only_block_is_isolated() {
let input = "before\n{form-only: json}\nschema rule\n{/form-only}\nafter\n";
let segments = split(input);
assert_eq!(
segments,
vec![
Segment::Prose("before\n".to_string()),
Segment::FormOnly { form: Form::Json, body: "schema rule\n".to_string() },
Segment::Prose("after\n".to_string()),
]
);
}
#[test]
fn ailx_form_only_block_uses_ailx_form() {
let input = "{form-only: ailx}\ngrammar rule\n{/form-only}\n";
let segments = split(input);
assert_eq!(
segments,
vec![
Segment::FormOnly { form: Form::Ailx, body: "grammar rule\n".to_string() },
]
);
}
#[test]
fn example_marker_is_isolated() {
let input = "before\n{example: empty_module}\nafter\n";
let segments = split(input);
assert_eq!(
segments,
vec![
Segment::Prose("before\n".to_string()),
Segment::Example("empty_module".to_string()),
Segment::Prose("after\n".to_string()),
]
);
}
#[test]
fn multiple_directives_in_sequence() {
let input = "p1\n{example: a}\n{form-only: json}\njs\n{/form-only}\n{form-only: ailx}\nax\n{/form-only}\n{example: b}\np2\n";
let segments = split(input);
assert_eq!(
segments,
vec![
Segment::Prose("p1\n".to_string()),
Segment::Example("a".to_string()),
Segment::FormOnly { form: Form::Json, body: "js\n".to_string() },
Segment::FormOnly { form: Form::Ailx, body: "ax\n".to_string() },
Segment::Example("b".to_string()),
Segment::Prose("p2\n".to_string()),
]
);
}
#[test]
fn unknown_form_name_is_an_error() {
let input = "{form-only: yaml}\nx\n{/form-only}\n";
let result = std::panic::catch_unwind(|| split(input));
assert!(result.is_err(), "split should panic on unknown form name");
}
#[test]
fn unclosed_form_only_is_an_error() {
let input = "{form-only: json}\nopen\n";
let result = std::panic::catch_unwind(|| split(input));
assert!(result.is_err(), "split should panic on unclosed form-only block");
}
@@ -0,0 +1,46 @@
//! Token-balance gate for master/spec.md form-only blocks.
//!
//! The mini-spec is the experimental treatment; if the JSON-cohort's
//! form-only scaffolding is meaningfully longer than the AILX-cohort's
//! (or vice versa), the experiment is biased before the model ever
//! sees the prompts. This test catches gross imbalance.
use std::path::PathBuf;
use xmodel_render::splitter::{self, Form, Segment};
fn master_dir() -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest.parent().unwrap().join("master")
}
#[test]
fn form_only_block_token_totals_within_five_percent() {
let spec_text = std::fs::read_to_string(master_dir().join("spec.md"))
.expect("master/spec.md must exist");
let segments = splitter::split(&spec_text);
let mut json_buf = String::new();
let mut ailx_buf = String::new();
for seg in &segments {
if let Segment::FormOnly { form, body } = seg {
match form {
Form::Json => json_buf.push_str(body),
Form::Ailx => ailx_buf.push_str(body),
}
}
}
let bpe = tiktoken_rs::cl100k_base().expect("cl100k_base tokenizer should load");
let json_count = bpe.encode_with_special_tokens(&json_buf).len();
let ailx_count = bpe.encode_with_special_tokens(&ailx_buf).len();
assert!(json_count > 0, "JSON-form blocks produced zero tokens");
assert!(ailx_count > 0, "AILX-form blocks produced zero tokens");
let max = json_count.max(ailx_count) as f64;
let diff = (json_count as i64 - ailx_count as i64).unsigned_abs() as f64;
let ratio = diff / max;
assert!(
ratio <= 0.05,
"form-only block token imbalance: json={json_count}, ailx={ailx_count}, ratio={ratio:.4} > 0.05",
);
}
@@ -0,0 +1,581 @@
# 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.
The AILX 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 AILX form is the printed dual of the JSON form: `ail parse
file.ailx` produces the same canonical bytes as the JSON form would,
and `ail render file.ail.json` produces the AILX text. Round-trip
through this pair is gated by an integration test.
```ailx
(module fn_returns_int
(fn answer
(doc "The simplest possible fn — no params, returns a fixed Int.")
(type (fn-type (params) (ret (con Int))))
(params)
(body 42)))
```
## 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.
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)`.
```ailx
(module forall_polymorphic
(fn id
(doc "The polymorphic identity function. Exercises Type::Forall and Type::Var.")
(type (forall (vars a) (fn-type (params a) (ret a))))
(params x)
(body x)))
```
## 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.
Modes are surface wrappers around the type in parameter and return
position. Inside a `(fn-type ...)`:
- `(params (own T1) (borrow T2))``T1` is owned, `T2` is 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.
```ailx
(module param_modes_all
(fn f_implicit
(doc "Implicit mode is the legacy default — `param_modes` is omitted from canonical JSON when every entry is Implicit, which is the case here. The visitor still observes ParamMode::Implicit via the (defaulted) ret_mode field on Type::Fn.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params x)
(body x))
(fn f_own
(doc "(own T) — caller transfers ownership; callee consumes.")
(type (fn-type (params (own (con Int))) (ret (con Int))))
(params x)
(body x))
(fn f_borrow
(doc "(borrow T) — caller retains ownership; callee may not consume.")
(type (fn-type (params (borrow (con Int))) (ret (con Int))))
(params x)
(body x)))
```
## 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.
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. `x` or `+`.
- Application: `(app CALLEE ARG*)` — head is `app`, then the callee
term, then positional arguments.
- Lambda: `(lam (params NAME*) (paramTypes TYPE*) (ret TYPE)
(effects EFFECT*) BODY)`.
- Let: `(let (NAME VALUE) BODY)` — binds `NAME` to `VALUE` in
`BODY`.
- Letrec: `(letrec NAME (type FN-TYPE) (params NAME*) BODY IN-BODY)`
— `NAME` is recursively visible inside `BODY`.
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)`.
```ailx
(module fn_calls_prelude
(fn add
(doc "Add two Ints via the polymorphic prelude `+`. Also exercises TermLet by binding the sum to a local name before returning it, and TermClone by re-using the let-bound value.")
(type (fn-type (params (con Int) (con Int)) (ret (con Int))))
(params x y)
(body (let s (app + x y) (clone s)))))
```
```ailx
(module fn_with_lambda
(fn make_adder
(doc "Curried adder: takes an Int and returns an Int -> Int closure. Exercises TermLam and Type::Fn appearing as a return type.")
(type (fn-type (params (con Int)) (ret (fn-type (params (con Int)) (ret (con Int))))))
(params x)
(body (lam (params (typed y (con Int))) (ret (con Int)) (body (app + x y))))))
```
## 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.
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.
```ailx
(module data_simple
(data Box (vars a)
(doc "A unary box around a polymorphic value.")
(ctor MkBox a))
(fn wrap_one
(doc "Construct a Box Int via MkBox. The body uses (reuse-as src body) — a hint that the freshly-allocated Box should reuse the source's memory slot under --alloc=rc. Exercises TermCtor, ReuseAs, and Type::Var inside a ctor field position.")
(type (fn-type (params (own (con Box (con Int)))) (ret (own (con Box (con Int))))))
(params src)
(body (reuse-as src (term-ctor Box MkBox 1)))))
```
## 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.
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 no `type` slot
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`.
```ailx
(module data_with_match
(data List
(doc "Monomorphic singly-linked Int list — boxed, recursive.")
(ctor Nil)
(ctor Cons (con Int) (con List)))
(fn head_or_zero
(doc "Return the head of xs, or 0 if empty. Exercises TermMatch with two arms, PatternCtor (both nullary Nil and binary Cons), and PatternWild on the tail field.")
(type (fn-type (params (borrow (con List))) (ret (con Int))))
(params xs)
(body (match xs
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons h _) h))))
(fn count_via_letrec
(doc "Walk xs counting nodes using a local recursive let. Exercises TermLetRec and TermVar; the recursive `go` is bound locally.")
(type (fn-type (params (borrow (con List))) (ret (con Int))))
(params xs)
(body (let-rec go (params ys) (type (fn-type (params (borrow (con List))) (ret (con Int)))) (body (match ys
(case (pat-ctor Nil) 0)
(case (pat-ctor Cons _ t) (app + 1 (app go t))))) (in (app go xs))))))
```
```ailx
(module match_literal_pattern
(fn classify
(doc "Classify an Int via literal patterns plus a wildcard fallback. Exercises PatternLit and PatternWild.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body (match n
(case (pat-lit 0) 100)
(case _ 200))))
(fn sign_if
(doc "Return -1 / 0 / 1 for the sign of n using nested TermIf. Exercises TermIf in both then/else positions plus TermApp on the polymorphic comparison.")
(type (fn-type (params (con Int)) (ret (con Int))))
(params n)
(body (if (app < n 0) -1 (if (app == n 0) 0 1)))))
```
## 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.
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.
```ailx
(module fn_with_do_seq
(fn main
(doc "Print two Ints in sequence. Exercises TermDo, TermSeq, and the IO effect on a fn type. The trailing unit return is implicit in the last Do (op returns Unit).")
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (seq (do io/print_int 1) (do io/print_int 2)))))
```
## 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 AILX 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 AILX 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.
Literal surface forms:
- Int: bare signed decimal, e.g. `42`, `-7`, `0`.
- Bool: `true` or `false` as bare keywords.
- Str: double-quoted with `\"`, `\\`, `\n`, `\t` escapes, 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`.
```ailx
(module floats
(const pi
(doc "Pi as a Float constant. Exercises Def::Const and Literal::Float — the bit pattern below is f64::to_bits(3.14).")
(type (con Float))
(body 3.14)))
```
```ailx
(module bool_str
(fn is_true
(doc "Trivial Bool literal. Exercises Literal::Bool.")
(type (fn-type (params) (ret (con Bool))))
(params)
(body true))
(fn greeting
(doc "Trivial Str literal. Exercises Literal::Str.")
(type (fn-type (params) (ret (con Str))))
(params)
(body "hi"))
(fn unit_value
(doc "Trivial Unit literal — needed so Literal::Unit appears at least once in the corpus.")
(type (fn-type (params) (ret (con Unit))))
(params)
(body (lit-unit))))
```
## 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.
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).
```ailx
(module class_def
(class MyShow
(param a)
(doc "A toy single-method class. The instance in instance_def.ail.json provides a body for `show` at type Int.")
(method show
(type (fn-type (params a) (ret (con Str)))))))
```
```ailx
(module instance_def
(class MyShow
(param a)
(doc "Re-declared here so this fixture is self-contained; class_def.ail.json declares the same class verbatim.")
(method show
(type (fn-type (params a) (ret (con Str))))))
(instance
(class MyShow)
(type (con Int))
(doc "Trivial MyShow Int — show always returns the fixed string \"int\".")
(method show
(body "int"))))
```
## 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.
@@ -0,0 +1,997 @@
# 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.
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.
```json
{
"schema": "ailang/v0",
"name": "fn_returns_int",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "answer",
"doc": "The simplest possible fn — no params, returns a fixed Int.",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": [],
"body": { "t": "lit", "lit": { "kind": "int", "value": 42 } }
}
]
}
```
## 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.
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).
```json
{
"schema": "ailang/v0",
"name": "forall_polymorphic",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "id",
"doc": "The polymorphic identity function. Exercises Type::Forall and Type::Var.",
"type": {
"k": "forall",
"vars": ["a"],
"body": {
"k": "fn",
"params": [ { "k": "var", "name": "a" } ],
"ret": { "k": "var", "name": "a" },
"effects": []
}
},
"params": ["x"],
"body": { "t": "var", "name": "x" }
}
]
}
```
## 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.
On `Type::Fn`, two optional fields carry modes:
- `param_modes`: array of strings `"implicit"`, `"own"`, or
`"borrow"`, one per parameter, in the same order as `params`. 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 is `own` or
`borrow`.
- `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"`.
```json
{
"schema": "ailang/v0",
"name": "param_modes_all",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "f_implicit",
"doc": "Implicit mode is the legacy default — `param_modes` is omitted from canonical JSON when every entry is Implicit, which is the case here. The visitor still observes ParamMode::Implicit via the (defaulted) ret_mode field on Type::Fn.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["x"],
"body": { "t": "var", "name": "x" }
},
{
"kind": "fn",
"name": "f_own",
"doc": "(own T) — caller transfers ownership; callee consumes.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"param_modes": ["own"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["x"],
"body": { "t": "var", "name": "x" }
},
{
"kind": "fn",
"name": "f_borrow",
"doc": "(borrow T) — caller retains ownership; callee may not consume.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["x"],
"body": { "t": "var", "name": "x" }
}
]
}
```
## 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.
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.
```json
{
"schema": "ailang/v0",
"name": "fn_calls_prelude",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "add",
"doc": "Add two Ints via the polymorphic prelude `+`. Also exercises TermLet by binding the sum to a local name before returning it, and TermClone by re-using the let-bound value.",
"type": {
"k": "fn",
"params": [
{ "k": "con", "name": "Int" },
{ "k": "con", "name": "Int" }
],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["x", "y"],
"body": {
"t": "let",
"name": "s",
"value": {
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "var", "name": "x" },
{ "t": "var", "name": "y" }
]
},
"body": { "t": "clone", "value": { "t": "var", "name": "s" } }
}
}
]
}
```
```json
{
"schema": "ailang/v0",
"name": "fn_with_lambda",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "make_adder",
"doc": "Curried adder: takes an Int and returns an Int -> Int closure. Exercises TermLam and Type::Fn appearing as a return type.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"effects": []
},
"params": ["x"],
"body": {
"t": "lam",
"params": ["y"],
"paramTypes": [ { "k": "con", "name": "Int" } ],
"retType": { "k": "con", "name": "Int" },
"effects": [],
"body": {
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "var", "name": "x" },
{ "t": "var", "name": "y" }
]
}
}
}
]
}
```
## 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.
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": []`.
```json
{
"schema": "ailang/v0",
"name": "data_simple",
"imports": [],
"defs": [
{
"kind": "type",
"name": "Box",
"vars": ["a"],
"doc": "A unary box around a polymorphic value.",
"ctors": [
{
"name": "MkBox",
"fields": [ { "k": "var", "name": "a" } ]
}
]
},
{
"kind": "fn",
"name": "wrap_one",
"doc": "Construct a Box Int via MkBox. The body uses (reuse-as src body) — a hint that the freshly-allocated Box should reuse the source's memory slot under --alloc=rc. Exercises TermCtor, ReuseAs, and Type::Var inside a ctor field position.",
"type": {
"k": "fn",
"params": [
{ "k": "con", "name": "Box", "args": [ { "k": "con", "name": "Int" } ] }
],
"param_modes": ["own"],
"ret": { "k": "con", "name": "Box", "args": [ { "k": "con", "name": "Int" } ] },
"ret_mode": "own",
"effects": []
},
"params": ["src"],
"body": {
"t": "reuse-as",
"source": { "t": "var", "name": "src" },
"body": {
"t": "ctor",
"type": "Box",
"ctor": "MkBox",
"args": [ { "t": "lit", "lit": { "kind": "int", "value": 1 } } ]
}
}
}
]
}
```
## 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.
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 to
`h`.
- Literal: `{"p": "lit", "lit": LITERAL}` — same `lit` shape 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.
```json
{
"schema": "ailang/v0",
"name": "data_with_match",
"imports": [],
"defs": [
{
"kind": "type",
"name": "List",
"doc": "Monomorphic singly-linked Int list — boxed, recursive.",
"ctors": [
{ "name": "Nil", "fields": [] },
{
"name": "Cons",
"fields": [
{ "k": "con", "name": "Int" },
{ "k": "con", "name": "List" }
]
}
]
},
{
"kind": "fn",
"name": "head_or_zero",
"doc": "Return the head of xs, or 0 if empty. Exercises TermMatch with two arms, PatternCtor (both nullary Nil and binary Cons), and PatternWild on the tail field.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "List" } ],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["xs"],
"body": {
"t": "match",
"scrutinee": { "t": "var", "name": "xs" },
"arms": [
{
"pat": { "p": "ctor", "ctor": "Nil", "fields": [] },
"body": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
},
{
"pat": {
"p": "ctor",
"ctor": "Cons",
"fields": [
{ "p": "var", "name": "h" },
{ "p": "wild" }
]
},
"body": { "t": "var", "name": "h" }
}
]
}
},
{
"kind": "fn",
"name": "count_via_letrec",
"doc": "Walk xs counting nodes using a local recursive let. Exercises TermLetRec and TermVar; the recursive `go` is bound locally.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "List" } ],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["xs"],
"body": {
"t": "letrec",
"name": "go",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "List" } ],
"param_modes": ["borrow"],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["ys"],
"body": {
"t": "match",
"scrutinee": { "t": "var", "name": "ys" },
"arms": [
{
"pat": { "p": "ctor", "ctor": "Nil", "fields": [] },
"body": { "t": "lit", "lit": { "kind": "int", "value": 0 } }
},
{
"pat": {
"p": "ctor",
"ctor": "Cons",
"fields": [
{ "p": "wild" },
{ "p": "var", "name": "t" }
]
},
"body": {
"t": "app",
"fn": { "t": "var", "name": "+" },
"args": [
{ "t": "lit", "lit": { "kind": "int", "value": 1 } },
{
"t": "app",
"fn": { "t": "var", "name": "go" },
"args": [ { "t": "var", "name": "t" } ]
}
]
}
}
]
},
"in": {
"t": "app",
"fn": { "t": "var", "name": "go" },
"args": [ { "t": "var", "name": "xs" } ]
}
}
}
]
}
```
```json
{
"schema": "ailang/v0",
"name": "match_literal_pattern",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "classify",
"doc": "Classify an Int via literal patterns plus a wildcard fallback. Exercises PatternLit and PatternWild.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["n"],
"body": {
"t": "match",
"scrutinee": { "t": "var", "name": "n" },
"arms": [
{
"pat": { "p": "lit", "lit": { "kind": "int", "value": 0 } },
"body": { "t": "lit", "lit": { "kind": "int", "value": 100 } }
},
{
"pat": { "p": "wild" },
"body": { "t": "lit", "lit": { "kind": "int", "value": 200 } }
}
]
}
},
{
"kind": "fn",
"name": "sign_if",
"doc": "Return -1 / 0 / 1 for the sign of n using nested TermIf. Exercises TermIf in both then/else positions plus TermApp on the polymorphic comparison.",
"type": {
"k": "fn",
"params": [ { "k": "con", "name": "Int" } ],
"ret": { "k": "con", "name": "Int" },
"effects": []
},
"params": ["n"],
"body": {
"t": "if",
"cond": {
"t": "app",
"fn": { "t": "var", "name": "<" },
"args": [
{ "t": "var", "name": "n" },
{ "t": "lit", "lit": { "kind": "int", "value": 0 } }
]
},
"then": { "t": "lit", "lit": { "kind": "int", "value": -1 } },
"else": {
"t": "if",
"cond": {
"t": "app",
"fn": { "t": "var", "name": "==" },
"args": [
{ "t": "var", "name": "n" },
{ "t": "lit", "lit": { "kind": "int", "value": 0 } }
]
},
"then": { "t": "lit", "lit": { "kind": "int", "value": 0 } },
"else": { "t": "lit", "lit": { "kind": "int", "value": 1 } }
}
}
}
]
}
```
## 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.
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.
```json
{
"schema": "ailang/v0",
"name": "fn_with_do_seq",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "main",
"doc": "Print two Ints in sequence. Exercises TermDo, TermSeq, and the IO effect on a fn type. The trailing unit return is implicit in the last Do (op returns Unit).",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Unit" },
"effects": ["IO"]
},
"params": [],
"body": {
"t": "seq",
"lhs": {
"t": "do",
"op": "io/print_int",
"args": [ { "t": "lit", "lit": { "kind": "int", "value": 1 } } ]
},
"rhs": {
"t": "do",
"op": "io/print_int",
"args": [ { "t": "lit", "lit": { "kind": "int", "value": 2 } } ]
}
}
}
]
}
```
## 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 AILX 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 AILX 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.
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"}` — the
`bits` field 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 across `serde_json` versions.
Float bit patterns are computed by the toolchain on `ail parse`,
not by the author. When you write `3.14` in AILX, the parser emits
`"bits": "40091eb851eb851f"` in the JSON form.
```json
{
"schema": "ailang/v0",
"name": "floats",
"imports": [],
"defs": [
{
"kind": "const",
"name": "pi",
"doc": "Pi as a Float constant. Exercises Def::Const and Literal::Float — the bit pattern below is f64::to_bits(3.14).",
"type": { "k": "con", "name": "Float" },
"value": { "t": "lit", "lit": { "kind": "float", "bits": "40091eb851eb851f" } }
}
]
}
```
```json
{
"schema": "ailang/v0",
"name": "bool_str",
"imports": [],
"defs": [
{
"kind": "fn",
"name": "is_true",
"doc": "Trivial Bool literal. Exercises Literal::Bool.",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Bool" },
"effects": []
},
"params": [],
"body": { "t": "lit", "lit": { "kind": "bool", "value": true } }
},
{
"kind": "fn",
"name": "greeting",
"doc": "Trivial Str literal. Exercises Literal::Str.",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Str" },
"effects": []
},
"params": [],
"body": { "t": "lit", "lit": { "kind": "str", "value": "hi" } }
},
{
"kind": "fn",
"name": "unit_value",
"doc": "Trivial Unit literal — needed so Literal::Unit appears at least once in the corpus.",
"type": {
"k": "fn",
"params": [],
"ret": { "k": "con", "name": "Unit" },
"effects": []
},
"params": [],
"body": { "t": "lit", "lit": { "kind": "unit" } }
}
]
}
```
## 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.
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.
```json
{
"schema": "ailang/v0",
"name": "class_def",
"imports": [],
"defs": [
{
"kind": "class",
"name": "MyShow",
"param": "a",
"doc": "A toy single-method class. The instance in instance_def.ail.json provides a body for `show` at type Int.",
"methods": [
{
"name": "show",
"type": {
"k": "fn",
"params": [ { "k": "var", "name": "a" } ],
"ret": { "k": "con", "name": "Str" },
"effects": []
}
}
]
}
]
}
```
```json
{
"schema": "ailang/v0",
"name": "instance_def",
"imports": [],
"defs": [
{
"kind": "class",
"name": "MyShow",
"param": "a",
"doc": "Re-declared here so this fixture is self-contained; class_def.ail.json declares the same class verbatim.",
"methods": [
{
"name": "show",
"type": {
"k": "fn",
"params": [ { "k": "var", "name": "a" } ],
"ret": { "k": "con", "name": "Str" },
"effects": []
}
}
]
},
{
"kind": "instance",
"class": "MyShow",
"type": { "k": "con", "name": "Int" },
"doc": "Trivial MyShow Int — show always returns the fixed string \"int\".",
"methods": [
{
"name": "show",
"body": { "t": "lit", "lit": { "kind": "str", "value": "int" } }
}
]
}
]
}
```
## 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.