ail: embed Form-A spec in merge-prose prompt (iter 20f)

Closes a design hole shipped in 20d: the merge-prose prompt instructed
the LLM to emit JSON-AST and gave a 12-line schema-essentials reminder.
JSON-AST is the canonical hashable artefact, not a writing surface; the
reminder was not a language spec. Foreign LLMs had no realistic shot.

20f makes two coupled changes:

1) The LLM now emits Form-A (the canonical authoring surface fixed by
   Decision 6). merge-prose loads the original via ailang_core::load_module
   and re-renders via ailang_surface::print before embedding (round-trip
   is a gating contract on the surface crate, so this is lossless). The
   user runs `ail parse foo.new.ailx` to recover JSON, then `ail check`.

2) crates/ailang-core/specs/form_a.md is the complete LLM-targeted
   Form-A specification — grammar, every term/pattern/type/def keyword,
   schema invariants, pitfall catalogue, four few-shot modules from
   examples/*.ailx. Exported as ailang_core::FORM_A_SPEC via include_str!
   and embedded verbatim in every merge-prose prompt.

Drift detection in crates/ailang-core/tests/spec_drift.rs: every variant
of Term, Pattern, Type, Def, Literal is reached via exhaustive `match`.
The arms are not the assertion — adding a new variant without updating
the match is a compile error before the test runs. Once matched, an
anchor string is asserted to appear in FORM_A_SPEC. 8 tests, all green.

The hand-written + mechanical-drift-test combo addresses the user's
"distance to code is too big" concern about a docs-only spec. Generator
overkill rejected: structural drift is mechanically caught, but prose
explanation, schema-invariant catalogue, pitfall list, and few-shot
corpus cannot be emitted from AST shape alone.

Tests:
  - ailang-core: +8 spec_drift tests; existing 12 unchanged
  - ail unit (3): rewritten in lockstep — assert (own T)/(effects IO)
    landmarks, FORM-A SPECIFICATION header, FORM_A_SPEC body verbatim
  - ail e2e merge_prose_prints_framed_prompt: rewritten — assert
    `(module foo` + `FORM-A SPECIFICATION` instead of `ailang/v0`
  - Workspace: all green

Richer integration paths (LLM tool-use, MCP server, LSP) were named
in the design discussion and deferred per "kiss". All three layer
additively on the static-prompt path; static prompt remains the
lowest-common-denominator fallback.
This commit is contained in:
2026-05-08 23:31:27 +02:00
parent 8375eb81ed
commit 72626fa94f
8 changed files with 1070 additions and 197 deletions
+28
View File
@@ -512,6 +512,34 @@ projection) and `ail merge-prose <m.ail.json> <edited.prose.txt>`
(the mediator-prompt composer); both are listed in the CLI section
below.
**Iter 20f update — Form-A spec embedding.** The 20d `merge-prose`
prompt instructed the LLM to emit JSON-AST and offered a 12-line
schema-essentials reminder; that combination did not give a
foreign LLM enough to produce valid output. 20f revises this:
- The LLM emits **Form-A** (the canonical authoring surface), not
JSON. JSON-AST stays the only hashable artefact, but it is not
a writing surface. The user runs `ail parse foo.new.ailx`
before `ail check` to produce the canonical JSON.
- `crates/ailang-core/specs/form_a.md` is the complete LLM-targeted
Form-A specification — grammar, every term / pattern / type /
def keyword, schema invariants, pitfall catalogue, four
few-shot modules drawn from `examples/*.ailx`. It is exported
as `ailang_core::FORM_A_SPEC` and embedded verbatim in every
`merge-prose` prompt.
- `crates/ailang-core/tests/spec_drift.rs` walks every variant of
`Term`, `Pattern`, `Type`, `Def`, `Literal` via exhaustive
`match` and asserts an anchor for each appears in the spec.
The exhaustive match is the load-bearing piece: adding a new
variant without updating the match fails compilation in this
test, before its assertions even run. Hand-written content,
mechanical drift detection.
The discussion of richer integration paths (LLM tool-use, MCP
server, LSP) was deferred — all three layer additively on the
static-prompt path 20f ships, which remains the
lowest-common-denominator fallback that always works.
## Decision 7: redundancy removal — `Term::If` is not a primitive
**Status: REVERTED in Iter 14g.** This decision was made on shaky grounds —
+106
View File
@@ -9535,3 +9535,109 @@ default):
handling).
- **Data model (MVP) refresh.** Cosmetic doc-tidy iter to
bring the schema snapshot current.
## 2026-05-08 — Iter 20f: Form-A spec embedding for prose round-trip
This iter closes a design hole shipped in 20d: the `merge-prose`
prompt instructed the LLM to emit JSON-AST, and gave it only a
12-line "schema essentials" reminder. JSON-AST is the canonical
hashable artefact, but it is not a writing surface, and a 12-line
hint is not a language reference. A foreign LLM with no AILang
exposure had no realistic shot at producing valid output.
20f makes two coupled changes.
### Form-A becomes the LLM's output target
The prompt now instructs the LLM to emit **Form-A** — the canonical
authoring surface fixed by Decision 6. The user's CLI cycle
becomes:
ail prose foo.ail.json > foo.prose.txt
$EDITOR foo.prose.txt
ail merge-prose foo.ail.json foo.prose.txt > prompt.txt
cat prompt.txt | <llm> > foo.new.ailx
ail parse foo.new.ailx > foo.new.ail.json
ail check foo.new.ail.json
mv foo.new.ail.json foo.ail.json # if check is clean
The original module is loaded via `ailang_core::load_module`
(schema-validating) and re-rendered via `ailang_surface::print` for
embedding. Form-A round-trip is a gating contract on the surface
crate, so this is lossless.
### Form-A spec embedded in every prompt
`crates/ailang-core/specs/form_a.md` is a hand-curated, complete
LLM-targeted specification of Form-A — grammar, every term /
pattern / type / def keyword with parenthesised syntax, schema
invariants enforced by the checker, a pitfall catalogue, and four
few-shot modules drawn from `examples/*.ailx`. It is exported as
`pub const FORM_A_SPEC: &str = include_str!("../specs/form_a.md")`
and embedded verbatim in the merge-prose prompt.
### Drift detection
The spec is hand-written, so drift was the load-bearing concern in
the design discussion (orchestrator note: user pushed back on
"docs/AIL_FORM_A_SPEC.md handgeschrieben" precisely because the
distance to the code was too big). The fix is mechanical:
`crates/ailang-core/tests/spec_drift.rs` walks every variant of
`Term`, `Pattern`, `Type`, `Def`, `Literal`, `ParamMode` via
exhaustive `match`. The arms are not the assertion — adding a new
variant without updating the match is a compile error in this
test, before the test even runs. Once the variant is matched, the
test asserts an anchor string for it appears in `FORM_A_SPEC`.
Eight tests, all green.
The exhaustive-match-as-trip-wire pattern is the same idea as the
`ailang-architect` drift review for DESIGN.md — both surfaces
encode load-bearing language semantics that must stay current —
but it runs on every `cargo test`, not just at family boundaries.
### Why hand-written and not generated
A generator (walking `syn` / proc-macro reflection on the AST,
emitting a tag table) would close the structural-drift channel
mechanically, but the spec is **not** just a tag table. It carries
prose explanation per construct, the schema-invariant catalogue,
the pitfall list, and the few-shot corpus. None of those can be
emitted from the AST shape alone. Hand-curated content + drift
test on the structural anchors is the right cost / value point;
generator overkill was rejected.
### What 20f does not address
Three larger integration paths got named in the discussion but
explicitly deferred per "kiss":
- **Tool-use schemas** — LLM calls `ail_parse` / `ail_check`
inside its turn, iterating until check is clean. Reduces
convergence from 12 rounds to ≈0 for cooperating clients.
- **MCP server** — Anthropic Model Context Protocol exposes
AILang resources (spec, examples, current module), tools
(parse, check, render, prose), prompts (canonical
round-trip templates) to any compatible client. Standard,
discoverable, vendor-neutral.
- **LSP** — editor integration; serves human authors more than
the round-trip flow. Bigger lift.
All three layer additively on the static-prompt path 20f ships.
The static prompt remains the lowest-common-denominator fallback
that always works without a tool-use-capable client.
### Test counts
- ailang-core: +8 (spec drift suite); existing 12 unchanged → 20
- ail: existing 70 e2e green after rewriting
`merge_prose_prints_framed_prompt` to assert Form-A landmarks
and `FORM-A SPECIFICATION` header instead of `ailang/v0`
- ail unit: existing 3 rewritten in lockstep
- Workspace: all green
### Family / arc state
20f closes the prose-roundtrip arc for now. JOURNAL queue is
empty again. Tidy iter for 20a / b / d / e / f together can wait
until the next family closes — the architect drift-review
discipline applies as before.
+95 -91
View File
@@ -2,8 +2,8 @@
## Why
The prose surface (`ail prose`, Iter 20a/20b) is a deliberately
**lossy** projection of a `.ail.json` module: it strips `(con T)`
The prose surface (`ail prose`, Iter 20a / 20b) is a deliberately
**lossy** projection of an `.ail.json` module: it strips `(con T)`
wrappings, drops redundant parentheses, infixes arithmetic, suppresses
schema rigor that the LLM can re-derive. That makes prose pleasant to
read and edit — but it also means there is no syntactic parser that
@@ -11,10 +11,27 @@ turns edited prose back into a `.ail.json`.
Re-integration of free-text edits therefore requires an **LLM
mediator**: a model that understands both the prose intent and the
load-bearing detail in the original `.ail.json`, and emits a new
`.ail.json` that respects both. This document describes the workflow,
the prompt template `ail merge-prose` composes for that mediator, and
the failure modes to watch for.
load-bearing detail in the original module, and emits a new module
that respects both. This document describes the workflow, the prompt
template `ail merge-prose` composes for that mediator, and the failure
modes to watch for.
## What the LLM produces
Iter 20f revised a load-bearing piece of this cycle: the LLM emits
**Form-A** (an `.ailx` document — the canonical authoring surface
fixed by Decision 6), not JSON-AST. JSON-AST is the canonical
hashable artefact, but it is not a writing surface — every type
reference wraps in `{"k": "con", ...}`, every term in
`{"t": "...", ...}`, and a single missing `param_modes` entry is a
schema error rather than a parse error with a position.
Form-A is the form `examples/*.ailx` are written in, the form
`ail render` produces, and the form `ail parse` consumes. The full
LLM-targeted specification is shipped inside the binary as
`ailang_core::FORM_A_SPEC` (sourced from
`crates/ailang-core/specs/form_a.md`) and embedded verbatim in every
`merge-prose` prompt; the LLM does not need prior AILang exposure.
## The cycle
@@ -22,124 +39,104 @@ the failure modes to watch for.
1. ail prose foo.ail.json > foo.prose.txt
2. $EDITOR foo.prose.txt # human edits freely
3. ail merge-prose foo.ail.json foo.prose.txt > prompt.txt
4. cat prompt.txt | <your-llm-cli> > foo.new.ail.json
5. ail check foo.new.ail.json
6. mv foo.new.ail.json foo.ail.json # if check is clean
4. cat prompt.txt | <your-llm-cli> > foo.new.ailx
5. ail parse foo.new.ailx > foo.new.ail.json
6. ail check foo.new.ail.json
7. mv foo.new.ail.json foo.ail.json # if check is clean
```
Step 1 is the deterministic projection (Iter 20a/20b).
Step 3 is the prompt composer this iter ships.
Step 1 is the deterministic projection (Iter 20a / 20b).
Step 3 is the prompt composer this iter ships. The prompt embeds the
Form-A spec, the original module rendered as Form-A, and the edited
prose. The original module is loaded via `ailang_core::load_module`
(which validates the schema) and rendered via
`ailang_surface::print`.
Step 4 is the user's external LLM client — Claude Code, the
Anthropic API, OpenAI's CLI, anything that takes a prompt on stdin and
emits text on stdout. AILang ships no client of its own; see *Why no
built-in API client* below.
Steps 5 and 6 reuse existing tooling: `ail check` validates schema +
typechecks, then the user accepts the new module.
Step 5 parses the LLM's Form-A back into a JSON-AST. Parser errors
are positional and can be fed back to the LLM in a follow-up prompt.
Step 6 typechecks. The full diagnostic catalogue applies — mode
violations, exhaustiveness, effect closure, etc.
Step 7 accepts the new module if check is clean.
## The prompt template
`ail merge-prose <original.ail.json> <edited.prose.txt>` reads both
files and prints the following text to stdout. Nothing more, nothing
less — no fences, no JSON envelope. The shape is exactly what a human
would compose by hand if they wanted to drive the same cycle without
the CLI helper.
`ail merge-prose <original.ail.json> <edited.prose.txt>` reads the
original (parsing it, then re-rendering as Form-A), reads the prose
file byte-for-byte, and prints the following structure to stdout —
no fences, no JSON envelope. The shape is exactly what a human would
compose by hand if they wanted to drive the same cycle without the
CLI helper.
```
You are integrating prose edits back into an AILang module.
ROLE
Your job is to produce an updated AILang JSON-AST (.ail.json) that
reflects the human's prose edits while preserving the load-bearing
semantic detail from the original .ail.json.
Your job is to produce an updated AILang module in Form-A (an .ailx
file) that reflects the human's prose edits while preserving the
load-bearing semantic detail from the original module.
CONTRACT
The prose is the source of intent: the human edited it to express
what the program should now do. The original .ail.json carries
load-bearing detail that the prose surface elides — preserve those
details unless the prose explicitly contradicts them. Specifically:
- Mode annotations on fn parameters (`own T`, `borrow T`).
These are hard contracts (memory model). The prose shows them,
but if the prose is ambiguous, default to the original.
- Effect annotations on return types (`with IO`, `with Diverge`).
Prose shows these too, but again: if uncertain, keep what the
original had.
- `tail` flags on calls. The prose prints `tail f(x)`; if the
edit moved a call, decide whether the new position is still in
tail position and flag accordingly.
- Doc strings (`///` lines).
- Type annotations on signatures and lambdas.
- Constructor names and arities (the prose `Cons(h, t)` must
round-trip to a `Term::Ctor` with the right `type` field).
The prose is the source of intent ... [list of preservation rules]
OUTPUT
Output ONLY the new .ail.json bytes. No commentary, no markdown
fences, no preamble or postscript. The output must be valid JSON
parsable by `ail parse` (i.e. by `serde_json` against the
`ailang/v0` schema). The first byte should be `{` and the last
non-whitespace byte should be `}`.
Output ONLY the new Form-A bytes. ... [no fences, no commentary]
SCHEMA ESSENTIALS
A full reference lives in docs/DESIGN.md ("Data model (MVP)"). The
shape is:
FORM-A SPECIFICATION
<<<FORM_A_SPEC
[ailang_core::FORM_A_SPEC, verbatim]
FORM_A_SPEC
- Module: { "schema": "ailang/v0", "name": ..., "imports": [...],
"defs": [...] }
- Defs are tagged via `kind` ("fn" | "type" | "const").
- Types are tagged via `k` ("con" | "fn" | "var" | "forall").
- Terms are tagged via `t` ("lit" | "var" | "app" | "let" | "if"
| "do" | "ctor" | "match" | "lam" | "seq").
- Patterns are tagged via `p` ("var" | "lit" | "ctor" | "wild").
- On `fn-type`: `param_modes` (one of "own"/"borrow" per
parameter) and `effects` are mandatory. `ret_mode` is mandatory
when the return type carries a mode.
- Constructor application uses `Term::Ctor` (`"t": "ctor"`,
fields `type` + `ctor` + `args`), NOT `Term::App`. The prose
surface elides the `type` tag (`Cons(h, t)` instead of
`(term-ctor IntList Cons h t)`) — re-introduce it.
- Base type names that appear bare in prose (`Int`, `Bool`,
`Unit`, user types like `IntList`) must be wrapped as
`{"k": "con", "name": "Int"}` etc. in the JSON.
ORIGINAL .ail.json
<<<ORIGINAL_AIL_JSON
{original_ail_json_here}
ORIGINAL_AIL_JSON
ORIGINAL MODULE (Form-A)
<<<ORIGINAL_FORM_A
[ailang_surface::print(&original_module)]
ORIGINAL_FORM_A
EDITED PROSE
<<<EDITED_PROSE
{edited_prose_here}
[edited prose, verbatim]
EDITED_PROSE
Emit the updated .ail.json now.
Emit the updated Form-A module now.
```
The two payloads (original JSON, edited prose) are inserted verbatim
between the heredoc-style markers. `merge-prose` does not strip,
re-encode, or otherwise transform either input — both go in
byte-for-byte.
The three payloads (spec, original Form-A, edited prose) are inserted
between heredoc-style markers. `merge-prose` does not strip,
re-encode, or otherwise transform the prose or the spec. The original
is parsed and re-rendered (which is lossless for any module that has
been through `ail parse` / `ail render` already; round-trip is a
gating contract on the surface crate).
## Failure modes
The mediator is an LLM, so the cycle is intentionally iterative.
Common failure modes and their fix:
- **Output wrapped in markdown fences (` ```json ... ``` `).** Strip
the fences (`sed -n '/^{/,/^}/p'`) and re-run `ail check`. If the
LLM does this consistently, paste a corrective note ("emit raw
JSON, no fences") at the top of the next prompt.
- **Output contains commentary before/after the JSON.** Same
- **Output wrapped in markdown fences (` ```ailang ... ``` `).**
Strip the fences and re-run `ail parse`. If the LLM does this
consistently, paste a corrective note ("emit raw Form-A, no
fences") at the top of the next prompt.
- **Output contains commentary before/after the module.** Same
treatment. Most modern LLMs respect "OUTPUT ONLY" but not all.
- **Invalid JSON / `serde_json` rejects.** Re-run with the parse
error pasted into the prompt as a corrective note.
- **Schema-valid but `ail check` fails.** Re-run with the
diagnostic messages pasted in. Common cases: missing
`param_modes`, `Term::App` instead of `Term::Ctor`, bare type
names instead of `{"k":"con","name":"…"}`.
- **Preserved detail dropped (mode flipped, doc string lost).**
Re-run with a corrective note naming the missing detail.
- **`ail parse` rejects the output.** The parser emits positional
errors. Re-run with the parse error pasted into the prompt as a
corrective note — "your previous output failed at byte N with
`expected X, got Y`; fix that and re-emit".
- **Parse-clean but `ail check` fails.** Re-run with the
diagnostic messages pasted in. Common cases on first-time LLM
use: missing mode annotation on a `(fn ...)` def
(`mode-required-on-fn-param`), `(app)` used where `(term-ctor)`
was needed, missing `(effects IO)` on a fn that calls `(do io/...)`.
- **Preserved detail dropped (mode flipped, doc string lost,
suppress clause stripped).** Re-run with a corrective note
naming the missing detail. The contract section calls these out
explicitly, but a long edit may push the LLM to re-derive
rather than preserve.
The cycle converges in 13 rounds for typical edits.
The cycle converges in 12 rounds for typical edits with a
modern frontier model.
## Why no built-in API client
@@ -149,5 +146,12 @@ streaming semantics, error mapping, version tracking — all carrying
zero language-level value. The user already has Claude Code, the
Anthropic SDK, the OpenAI CLI, `curl`, or any other client they
prefer; `ail merge-prose` composes the prompt and gets out of the
way. The Unix-pipe shape (`ail merge-prose ... | client | ail check`)
also makes the cycle scriptable without lock-in to one vendor.
way. The Unix-pipe shape (`ail merge-prose ... | client | ail parse |
ail check`) makes the cycle scriptable without lock-in to one vendor.
A future iter may layer a tool-use schema (LLM calls back into
`ail parse` / `ail check` from inside its turn) or an MCP server
(any MCP-compatible client discovers AILang's resources, tools, and
prompts) on top of this cycle. Both are additive — the static-prompt
fallback documented here remains the lowest-common-denominator path
that always works.