From a8c29d130b99381ad031c4134dcfb53efef34b97 Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 12 May 2026 11:43:10 +0200 Subject: [PATCH] iter cma.1: master mini-spec + render binary for cross-model authoring experiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: }) 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. --- .../2026-05-12-iter-cma.1.json | 23 + docs/journals/2026-05-12-iter-cma.1.md | 128 +++ docs/journals/INDEX.md | 1 + .../README.md | 42 + .../master/examples/bool_str.ail.json | 46 + .../master/examples/class_def.ail.json | 24 + .../master/examples/data_simple.ail.json | 45 + .../master/examples/data_with_match.ail.json | 118 +++ .../master/examples/floats.ail.json | 14 + .../master/examples/fn_calls_prelude.ail.json | 35 + .../master/examples/fn_returns_int.ail.json | 20 + .../master/examples/fn_with_do_seq.ail.json | 32 + .../master/examples/fn_with_lambda.ail.json | 39 + .../examples/forall_polymorphic.ail.json | 24 + .../master/examples/instance_def.ail.json | 36 + .../examples/match_literal_pattern.ail.json | 70 ++ .../master/examples/param_modes_all.ail.json | 48 + .../master/spec.md | 613 +++++++++++ .../render/.gitignore | 2 + .../render/Cargo.toml | 30 + .../render/src/examples.rs | 38 + .../render/src/lib.rs | 5 + .../render/src/main.rs | 67 ++ .../render/src/splitter.rs | 92 ++ .../render/tests/example_roundtrip.rs | 73 ++ .../render/tests/spec_completeness.rs | 396 +++++++ .../render/tests/splitter_unit.rs | 79 ++ .../render/tests/token_balance.rs | 46 + .../rendered/ailx.md | 581 ++++++++++ .../rendered/json.md | 997 ++++++++++++++++++ 30 files changed, 3764 insertions(+) create mode 100644 bench/orchestrator-stats/2026-05-12-iter-cma.1.json create mode 100644 docs/journals/2026-05-12-iter-cma.1.md create mode 100644 experiments/2026-05-12-cross-model-authoring/README.md create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/bool_str.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/class_def.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/data_simple.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/data_with_match.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/floats.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/fn_calls_prelude.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/fn_returns_int.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_do_seq.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/forall_polymorphic.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/instance_def.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/match_literal_pattern.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/examples/param_modes_all.ail.json create mode 100644 experiments/2026-05-12-cross-model-authoring/master/spec.md create mode 100644 experiments/2026-05-12-cross-model-authoring/render/.gitignore create mode 100644 experiments/2026-05-12-cross-model-authoring/render/Cargo.toml create mode 100644 experiments/2026-05-12-cross-model-authoring/render/src/examples.rs create mode 100644 experiments/2026-05-12-cross-model-authoring/render/src/lib.rs create mode 100644 experiments/2026-05-12-cross-model-authoring/render/src/main.rs create mode 100644 experiments/2026-05-12-cross-model-authoring/render/src/splitter.rs create mode 100644 experiments/2026-05-12-cross-model-authoring/render/tests/example_roundtrip.rs create mode 100644 experiments/2026-05-12-cross-model-authoring/render/tests/spec_completeness.rs create mode 100644 experiments/2026-05-12-cross-model-authoring/render/tests/splitter_unit.rs create mode 100644 experiments/2026-05-12-cross-model-authoring/render/tests/token_balance.rs create mode 100644 experiments/2026-05-12-cross-model-authoring/rendered/ailx.md create mode 100644 experiments/2026-05-12-cross-model-authoring/rendered/json.md diff --git a/bench/orchestrator-stats/2026-05-12-iter-cma.1.json b/bench/orchestrator-stats/2026-05-12-iter-cma.1.json new file mode 100644 index 0000000..45c694d --- /dev/null +++ b/bench/orchestrator-stats/2026-05-12-iter-cma.1.json @@ -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)." +} diff --git a/docs/journals/2026-05-12-iter-cma.1.md b/docs/journals/2026-05-12-iter-cma.1.md new file mode 100644 index 0000000..ed55add --- /dev/null +++ b/docs/journals/2026-05-12-iter-cma.1.md @@ -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: }`) +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` diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index 5608bd6..77abfc8 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -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 diff --git a/experiments/2026-05-12-cross-model-authoring/README.md b/experiments/2026-05-12-cross-model-authoring/README.md new file mode 100644 index 0000000..a57c70b --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/README.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/-/` — 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). diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/bool_str.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/bool_str.ail.json new file mode 100644 index 0000000..f0f42a7 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/bool_str.ail.json @@ -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" } } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/class_def.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/class_def.ail.json new file mode 100644 index 0000000..7a0c4a5 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/class_def.ail.json @@ -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": [] + } + } + ] + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/data_simple.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/data_simple.ail.json new file mode 100644 index 0000000..d861c33 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/data_simple.ail.json @@ -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 } } ] + } + } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/data_with_match.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/data_with_match.ail.json new file mode 100644 index 0000000..8c01fcd --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/data_with_match.ail.json @@ -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" } ] + } + } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/floats.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/floats.ail.json new file mode 100644 index 0000000..aac758b --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/floats.ail.json @@ -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" } } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/fn_calls_prelude.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/fn_calls_prelude.ail.json new file mode 100644 index 0000000..5d21ab7 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/fn_calls_prelude.ail.json @@ -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" } } + } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/fn_returns_int.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/fn_returns_int.ail.json new file mode 100644 index 0000000..56bf081 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/fn_returns_int.ail.json @@ -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 } } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_do_seq.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_do_seq.ail.json new file mode 100644 index 0000000..1dd26e8 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_do_seq.ail.json @@ -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 } } ] + } + } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json new file mode 100644 index 0000000..ff2e6d9 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json @@ -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" } + ] + } + } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/forall_polymorphic.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/forall_polymorphic.ail.json new file mode 100644 index 0000000..2124887 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/forall_polymorphic.ail.json @@ -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" } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/instance_def.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/instance_def.ail.json new file mode 100644 index 0000000..55b4350 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/instance_def.ail.json @@ -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" } } + } + ] + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/match_literal_pattern.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/match_literal_pattern.ail.json new file mode 100644 index 0000000..de1da64 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/match_literal_pattern.ail.json @@ -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 } } + } + } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/examples/param_modes_all.ail.json b/experiments/2026-05-12-cross-model-authoring/master/examples/param_modes_all.ail.json new file mode 100644 index 0000000..d366f05 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/examples/param_modes_all.ail.json @@ -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" } + } + ] +} diff --git a/experiments/2026-05-12-cross-model-authoring/master/spec.md b/experiments/2026-05-12-cross-model-authoring/master/spec.md new file mode 100644 index 0000000..c60182c --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/master/spec.md @@ -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. diff --git a/experiments/2026-05-12-cross-model-authoring/render/.gitignore b/experiments/2026-05-12-cross-model-authoring/render/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/experiments/2026-05-12-cross-model-authoring/render/Cargo.toml b/experiments/2026-05-12-cross-model-authoring/render/Cargo.toml new file mode 100644 index 0000000..763fc35 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/Cargo.toml @@ -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" diff --git a/experiments/2026-05-12-cross-model-authoring/render/src/examples.rs b/experiments/2026-05-12-cross-model-authoring/render/src/examples.rs new file mode 100644 index 0000000..9b135bc --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/src/examples.rs @@ -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 { + 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())) + } + } +} diff --git a/experiments/2026-05-12-cross-model-authoring/render/src/lib.rs b/experiments/2026-05-12-cross-model-authoring/render/src/lib.rs new file mode 100644 index 0000000..dd98b5b --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/src/lib.rs @@ -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; diff --git a/experiments/2026-05-12-cross-model-authoring/render/src/main.rs b/experiments/2026-05-12-cross-model-authoring/render/src/main.rs new file mode 100644 index 0000000..7f10984 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/src/main.rs @@ -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 { + 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 = None; + let mut rendered: Option = 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 is required"))?; + let rendered = rendered.ok_or_else(|| anyhow!("--rendered is required"))?; + Ok((master, rendered)) +} diff --git a/experiments/2026-05-12-cross-model-authoring/render/src/splitter.rs b/experiments/2026-05-12-cross-model-authoring/render/src/splitter.rs new file mode 100644 index 0000000..07823d6 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/src/splitter.rs @@ -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: } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Form { + Json, + Ailx, +} + +impl Form { + fn parse(name: &str) -> Option
{ + 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 { + let mut out: Vec = 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: } + 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: } + 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) { + if !buf.is_empty() { + out.push(Segment::Prose(std::mem::take(buf))); + } +} diff --git a/experiments/2026-05-12-cross-model-authoring/render/tests/example_roundtrip.rs b/experiments/2026-05-12-cross-model-authoring/render/tests/example_roundtrip.rs new file mode 100644 index 0000000..f621dea --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/tests/example_roundtrip.rs @@ -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 { + let mut paths: Vec = 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 = 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") + ); + } +} diff --git a/experiments/2026-05-12-cross-model-authoring/render/tests/spec_completeness.rs b/experiments/2026-05-12-cross-model-authoring/render/tests/spec_completeness.rs new file mode 100644 index 0000000..8c85472 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/tests/spec_completeness.rs @@ -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) { + 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) { + visit_term(&m.body, observed); +} + +fn visit_term(t: &Term, observed: &mut HashSet) { + 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) { + 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) { + 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) { + 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) { + 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) { + 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 { + let dir = examples_dir(); + let mut paths: Vec = 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 = HashSet::new(); + let mut load_failures = Vec::::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 = EXPECTED_VARIANTS + .iter() + .copied() + .filter(|v| !observed.contains(v)) + .collect(); + + if !missing.is_empty() { + let names: Vec = 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() + ); +} diff --git a/experiments/2026-05-12-cross-model-authoring/render/tests/splitter_unit.rs b/experiments/2026-05-12-cross-model-authoring/render/tests/splitter_unit.rs new file mode 100644 index 0000000..b084751 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/tests/splitter_unit.rs @@ -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"); +} diff --git a/experiments/2026-05-12-cross-model-authoring/render/tests/token_balance.rs b/experiments/2026-05-12-cross-model-authoring/render/tests/token_balance.rs new file mode 100644 index 0000000..a23aa91 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/render/tests/token_balance.rs @@ -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", + ); +} diff --git a/experiments/2026-05-12-cross-model-authoring/rendered/ailx.md b/experiments/2026-05-12-cross-model-authoring/rendered/ailx.md new file mode 100644 index 0000000..be03eb5 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/rendered/ailx.md @@ -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. diff --git a/experiments/2026-05-12-cross-model-authoring/rendered/json.md b/experiments/2026-05-12-cross-model-authoring/rendered/json.md new file mode 100644 index 0000000..b6ed548 --- /dev/null +++ b/experiments/2026-05-12-cross-model-authoring/rendered/json.md @@ -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.