design: 20-family tidy-iter — refresh Data model + ecosystem inventory

Architect drift review after Family 20 surfaced four items; (1) and
(2) are doc drift fixed here, (3) and (4) are recorded as acceptable.

DESIGN.md changes:

  - Data model section rewritten from "Data model (MVP)" claiming
    only fn/const exist to "Data model" reflecting all three Def
    kinds. Added the missing schema fields the architect named:
    `tail` flag on app/do, the `seq` / `clone` / `reuse-as` / `letrec`
    terms, `paramModes` / `retMode` on Type::Fn, `suppress` on FnDef,
    `drop-iterative` on TypeDef, `args` on Type::Con, `vars` on
    TypeDef. Each additive field's hash-stable serialisation is
    called out once at the top so individual mentions stay terse.
    `Suppress`, `ParamMode`, `Literal`, `Pattern` get their own
    sub-blocks.
  - Pipeline diagram now shows `--alloc=gc` (links libgc;
    transitional) and `--alloc=rc` (emits ailang_rc_inc / _dec;
    canonical) as two backends sharing the same MIR.
  - Ecosystem inventory gained a "Surface forms" bullet for
    ailang-surface (Iter 14c, lossless Form-A printer/parser) and
    ailang-prose (Family 20, lossy Form-B projection). CLI bullet
    now lists parse, render, prose, merge-prose.

JOURNAL entry records the architect findings, the resolutions for
items (1) and (2), and the acceptable-drift status of (3)
FnDef::synthetic factor-out (defer to next schema-additive
FnDef field) and (4) linearity.rs spurious warning (not
corpus-triggered).

Pure documentation iter — no code changes. 288 workspace tests
unchanged and green.
This commit is contained in:
2026-05-08 23:47:48 +02:00
parent 72626fa94f
commit c79f7e2f00
2 changed files with 288 additions and 31 deletions
+162 -28
View File
@@ -26,9 +26,17 @@ evolving in lockstep with the language:
- **Language core** (`crates/ailang-core`, `crates/ailang-check`,
`crates/ailang-codegen`): AST, type system, codegen.
- **Surface forms** (`crates/ailang-surface`, `crates/ailang-prose`):
the LLM-facing renderings of a module. `ailang-surface` (Iter 14c)
is the lossless Form-A printer/parser — the canonical authoring
surface fixed by Decision 6, with a round-trip property
`parse ∘ print = id` gating every release. `ailang-prose` (Family 20)
is the lossy Form-B projection — human-readable prose for review and
edit, with no parser; re-integration goes through the
LLM-mediator round-trip documented in `docs/PROSE_ROUNDTRIP.md`.
- **CLI** (`crates/ail`): toolchain for tooling consumers — `manifest`,
`describe`, `deps`, `check`, `build`, etc., preferably with `--json` for
machine consumption.
`describe`, `deps`, `check`, `build`, `parse`, `render`, `prose`,
`merge-prose`, etc., preferably with `--json` for machine consumption.
- **Examples** (`examples/`): canonical `.ail.json` programs. They are
specification anchors, not demos — the E2E suite hangs off them.
- **Agents** (`agents/`): specialised sub-prompts (implementer,
@@ -1382,7 +1390,15 @@ with exactly one dot in the name is a qualified reference: `<prefix>.<def>`.
Hash stability: no new AST node, no renamed fields — all previous module
hashes stay bit-identical.
## Data model (MVP)
## Data model
The on-disk JSON-AST is what the toolchain hashes, typechecks, and
lowers. Every node in this section is the schema mirror of an enum or
struct in `crates/ailang-core/src/ast.rs`; whenever the two disagree,
`ast.rs` is the source of truth. Every additive field is declared with
`skip_serializing_if` so pre-existing fixtures keep bit-identical
canonical-JSON hashes — that gating contract is what makes growing
the schema cheap.
### Module
@@ -1397,57 +1413,163 @@ hashes stay bit-identical.
### Def
`kind ∈ { "fn", "type", "effect", "const" }`. In the MVP only `fn` and `const`.
`kind ∈ { "fn", "const", "type" }`. All three are real surface forms.
```jsonc
{
"kind": "fn",
// fn (the unit that gets a content hash)
{ "kind": "fn",
"name": "<id>",
"type": Type, // typically Type::Fn, optionally wrapped in Forall
"params": ["<id>"...], // names bound in body, in type.params order
"body": Term,
"doc": "<optional string>",
"suppress": [Suppress...] // Iter 19b, omitted when empty
}
// const (top-level value; codegen emits as a global; body must be pure)
{ "kind": "const",
"name": "<id>",
"type": Type,
"params": ["<id>"...],
"body": Term,
"value": Term,
"doc": "<optional string>"
}
// type (algebraic data type; Iter 5; parameterised since Iter 13a)
{ "kind": "type",
"name": "<id>",
"vars": ["<id>"...], // type parameters; omitted when empty (pre-13a hash-stable)
"ctors": [
{ "name": "<id>", "fields": [Type...] } // nullary ctor: fields = []
...
],
"doc": "<optional string>",
"drop-iterative": true // Iter 18e opt-in; omitted when false (pre-18e hash-stable)
}
```
**`Suppress`** (Iter 19b — entry in `FnDef.suppress`):
```jsonc
{ "code": "<diagnostic-code>", // e.g. "over-strict-mode"
"because": "<author reason>" // must be non-empty;
// empty/whitespace fires `empty-suppress-reason` (Error)
}
```
### Term (expression)
```jsonc
{ "t": "lit", "lit": { "kind": "int" | "bool" | "unit", "value": ... } }
{ "t": "lit", "lit": Literal }
{ "t": "var", "name": "<id>" }
{ "t": "app", "fn": Term, "args": [Term...] }
{ "t": "let", "name": "<id>", "value": Term, "body": Term }
{ "t": "if", "cond": Term, "then": Term, "else": Term }
{ "t": "do", "op": "<eff>/<op>", "args": [Term...] }
// fn application; tail flag is Iter 14e (musttail under codegen).
// `tail` is omitted when false (pre-14e hash-stable).
{ "t": "app", "fn": Term, "args": [Term...], "tail": false }
{ "t": "let", "name": "<id>", "value": Term, "body": Term }
// Local recursive let (Iter 16b.1). Always fn-shaped. The desugar pass
// lifts most `letrec` to a synthetic top-level fn; `lift_letrecs` (16b.3)
// finishes the job after typecheck for the residue that captures
// let-bound names. Post-codegen, no `letrec` survives.
{ "t": "letrec",
"name": "<id>", "type": Type, "params": ["<id>"...],
"body": Term, "in": Term }
{ "t": "if", "cond": Term, "then": Term, "else": Term }
// Effect-op invocation. `op` is "<eff>/<op>" (e.g. "io/print_int").
// `tail` per Iter 14e (omitted when false).
{ "t": "do", "op": "<eff>/<op>", "args": [Term...], "tail": false }
// Ctor application; `args` omitted when empty.
{ "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] }
{ "t": "match", "scrutinee": Term, "arms": [Arm...] }
// Anonymous fn value (Iter 8b); free vars captured from enclosing scope.
{ "t": "lam",
"params": ["<id>"...],
"paramTypes": [Type...],
"retType": Type,
"effects": ["<id>"...],
"body": Term }
{ "t": "seq", "lhs": Term, "rhs": Term }
// Sequencing (Iter 10). Semantically `let _ = lhs in rhs`; lhs must be Unit.
{ "t": "seq", "lhs": Term, "rhs": Term }
// Iter 18c.1: explicit RC clone. Codegen lowers as
// `call void @ailang_rc_inc(ptr %v)` before returning %v under `--alloc=rc`.
{ "t": "clone", "value": Term }
// Iter 18d.1: explicit reuse-as hint. `body` must be allocating
// (typically `ctor` or `lam`); `source` must be a bare `var`. Codegen
// lowers as in-place rewrite under `--alloc=rc`.
{ "t": "reuse-as", "source": Term, "body": Term }
```
In the MVP, `do` is only a direct call to a built-in effect op (no handler).
A `lam` term constructs an anonymous function value; free variables of
its body are captured from the enclosing scope (see Iter 8 closure
conversion in JOURNAL). A `seq` term evaluates `lhs` for its effects
(its result must be Unit) and yields `rhs`'s value — equivalent to
`let _ = lhs in rhs`.
In the MVP, `do` is only a direct call to a built-in effect op (no
handler). A `lam` term constructs an anonymous function value; free
variables of its body are captured from the enclosing scope (see
Iter 8 closure conversion in JOURNAL).
**`Literal`**:
```jsonc
{ "kind": "int", "value": <i64> }
{ "kind": "bool", "value": <bool> }
{ "kind": "str", "value": "<utf-8>" }
{ "kind": "unit" }
```
**`Pattern`** (the `pat` field of an `Arm`; discriminator `p`):
```jsonc
{ "p": "wild" } // _
{ "p": "var", "name": "<id>" } // x — binds the value
{ "p": "lit", "lit": Literal }
{ "p": "ctor", "ctor": "<id>", "fields": [Pattern...] } // fields omitted when empty
```
Patterns are linear: each pattern variable may appear at most once.
### Type
```jsonc
{ "k": "con", "name": "Int" }
{ "k": "con", "name": "Bool" }
{ "k": "con", "name": "Unit" }
{ "k": "fn", "params": [Type...], "ret": Type, "effects": ["IO"...] }
{ "k": "var", "name": "a" }
{ "k": "forall", "vars": ["a"...], "body": Type }
// Type-constructor application. `args` omitted when empty
// (pre-13a hash-stable for non-parameterised cases like Int, Bool, ...).
{ "k": "con", "name": "<id>", "args": [Type...] }
// Function type. Decision 10 (Iter 18a) added paramModes/retMode as
// metadata on Type::Fn — they are NOT separate Type variants, so every
// existing match-arm in the typechecker (unify, occurs, apply) keeps
// working. `paramModes` omitted when every entry is "implicit";
// `retMode` omitted when "implicit" (pre-18a hash-stable).
{ "k": "fn",
"params": [Type...],
"paramModes": [ParamMode...],
"ret": Type,
"retMode": ParamMode,
"effects": ["<id>"...] }
{ "k": "var", "name": "<id>" }
// Top-level polymorphism only.
{ "k": "forall", "vars": ["<id>"...], "body": Type }
```
**`ParamMode`** (Iter 18a / Decision 10):
```
"implicit" — pre-18a / unannotated. Treated as `own` by the typechecker.
"own" — (own T) — caller transfers ownership; callee consumes.
"borrow" — (borrow T) — caller retains ownership; callee may not consume.
```
`implicit ≡ own` semantically; the distinction exists so pre-18a
fixtures continue to serialize without the mode wrapper and keep their
canonical-JSON hash.
## Pipeline
```
@@ -1455,13 +1577,25 @@ conversion in JOURNAL). A `seq` term evaluates `lhs` for its effects
├─ load + validate schema
├─ resolve names + assign hashes
├─ desugar (AST → AST, Iter 16a)
├─ typecheck (HM, effect rows)
├─ typecheck (HM, effect rows; mode-strict per Decision 10)
├─ lift_letrecs (post-typecheck AST → AST, Iter 16b.3)
├─ lower to MIR (SSA-like, named SSA values)
├─ emit LLVM IR (.ll)
└─ clang -O2 *.ll -o binary (links libgc for @GC_malloc)
└─ clang -O2 *.ll -o binary
--alloc=gc → links libgc (@GC_malloc; transitional)
--alloc=rc → emits inc/dec (@ailang_rc_inc / _dec; canonical)
```
Two allocator backends share the same MIR. `--alloc=gc` links libgc
and is the transitional fallback (no inc/dec instrumentation, no mode
checks at codegen time). `--alloc=rc` is the canonical backend
committed to in Decision 10: the typechecker enforces
`(own)` / `(borrow)` modes, codegen emits `ailang_rc_inc` / `_dec`
calls at the points dictated by linearity, and `Term::Clone` /
`Term::ReuseAs` materialise into actual rc-bumps and in-place
rewrites respectively. Boehm-on-`--alloc=gc` is on the path to
retirement; see the JOURNAL queue.
The **desugar** pass (`ailang-core::desugar::desugar_module`) runs
before typecheck and codegen in every entry point of `ailang-check`
and `ailang-codegen`. It is a pure AST → AST rewriter — currently