From 72626fa94fe7d3a58c1abd8a4aea40f9843cb892 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 8 May 2026 23:31:27 +0200 Subject: [PATCH] ail: embed Form-A spec in merge-prose prompt (iter 20f) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/ail/src/main.rs | 206 ++++++------- crates/ail/tests/e2e.rs | 16 +- crates/ailang-core/specs/form_a.md | 397 +++++++++++++++++++++++++ crates/ailang-core/src/lib.rs | 12 + crates/ailang-core/tests/spec_drift.rs | 316 ++++++++++++++++++++ docs/DESIGN.md | 28 ++ docs/JOURNAL.md | 106 +++++++ docs/PROSE_ROUNDTRIP.md | 186 ++++++------ 8 files changed, 1070 insertions(+), 197 deletions(-) create mode 100644 crates/ailang-core/specs/form_a.md create mode 100644 crates/ailang-core/tests/spec_drift.rs diff --git a/crates/ail/src/main.rs b/crates/ail/src/main.rs index ae4f1a8..342de8e 100644 --- a/crates/ail/src/main.rs +++ b/crates/ail/src/main.rs @@ -229,89 +229,89 @@ enum Cmd { /// Composes the prose-round-trip prompt described in /// `docs/PROSE_ROUNDTRIP.md`. /// -/// The two payloads — the original `.ail.json` and the edited prose — -/// are inserted verbatim between heredoc-style markers; the rest of -/// the returned string is the role-statement, contract, output spec, -/// and schema-essentials reminder that frame the LLM's task. +/// The three payloads — the AILang Form-A specification, the original +/// module rendered as Form-A, and the edited prose — are inserted +/// verbatim between heredoc-style markers. The rest of the returned +/// string is the role-statement, contract, and output spec that frame +/// the LLM's task. +/// +/// Iter 20f rewrote this function: the prompt now embeds +/// [`ailang_core::FORM_A_SPEC`] (the language reference an LLM needs +/// to generate AILang from scratch) and the original module as Form-A +/// (the canonical authoring surface, Decision 6) instead of JSON-AST. +/// JSON-AST stays the canonical hashable artefact, but no human or +/// LLM should write it directly — `ail parse` converts the LLM's +/// Form-A output to JSON. /// /// Pure: same inputs always yield the same bytes. The CLI wrapper -/// (`Cmd::MergeProse`) is just a file-reader + `print!`. -fn compose_merge_prose_prompt(orig_ail_json: &str, edited_prose: &str) -> String { - // Keep this template in lockstep with `docs/PROSE_ROUNDTRIP.md` — - // the doc reproduces the literal text so a human can assemble the - // same prompt by hand. - format!( +/// (`Cmd::MergeProse`) is just file-reading + Form-A render + `print!`. +fn compose_merge_prose_prompt(original_form_a: &str, edited_prose: &str) -> String { + // Built with String::push_str rather than format!() because + // `FORM_A_SPEC` contains literal `{` and `}` from embedded JSON + // examples, which would break format!'s placeholder escaping. + let mut out = String::new(); + out.push_str( "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 +what the program should now do. The original Form-A 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. + - Mode annotations on fn parameters (`(own T)`, `(borrow T)`). + These are hard contracts (memory model). The prose shows them + as `own T` / `borrow T`; the Form-A wraps them. If the prose is + ambiguous, default to the original. + - Effect annotations on return types (`(effects IO)`, + `(effects Diverge)`). Prose shows these as `with IO` etc.; if + uncertain, keep what the original had. + - `tail` flags on calls. The prose prints `tail f(x)`; the Form-A + keyword is `(tail-app f x)`. If the edit moved a call, decide + whether the new position is still in tail position. + - Doc strings (`(doc \"...\")` clauses; prose shows them as `///`). + - `(suppress (code ...) (because ...))` clauses (prose shows them + as `// @suppress code: reason`). The `because` MUST be non-empty. - Constructor names and arities (the prose `Cons(h, t)` must - round-trip to a `Term::Ctor` with the right `type` field). + round-trip to `(term-ctor TYPE Cons h t)` with the right TYPE). 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 commentary, no markdown fences, +no preamble or postscript. The output must be parseable by +`ail parse` (it will be piped to that command immediately). The first +non-whitespace byte should be `(` (the opening of `(module ...)`) +and the last non-whitespace byte should be `)`. -SCHEMA ESSENTIALS -A full reference lives in docs/DESIGN.md (\"Data model (MVP)\"). The -shape is: - - - 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 -<< Result<()> { @@ -418,15 +418,18 @@ fn main() -> Result<()> { print!("{}", ailang_prose::module_to_prose(&m)); } Cmd::MergeProse { original, edited } => { - // Iter 20d: read both inputs verbatim, compose the prompt, - // print to stdout. No schema validation here — `ail check` - // exists for that purpose, and merge-prose is intentionally - // a pure prompt-shaper. - let orig = std::fs::read_to_string(&original) - .with_context(|| format!("reading {}", original.display()))?; + // Iter 20f: load the original .ail.json, render it as + // Form-A (the canonical authoring surface, Decision 6), + // and embed *that* in the prompt — not the raw JSON-AST. + // Form-A is the form an LLM should produce; JSON is the + // hashable artefact, not the writing surface. The prose + // file is embedded byte-for-byte. + let module = ailang_core::load_module(&original) + .with_context(|| format!("loading {}", original.display()))?; + let original_form_a = ailang_surface::print(&module); let prose = std::fs::read_to_string(&edited) .with_context(|| format!("reading {}", edited.display()))?; - print!("{}", compose_merge_prose_prompt(&orig, &prose)); + print!("{}", compose_merge_prose_prompt(&original_form_a, &prose)); } Cmd::Describe { path, name, json, workspace } => { if workspace { @@ -1911,22 +1914,28 @@ fn build_to( mod tests { use super::compose_merge_prose_prompt; - /// The composed prompt must contain both inputs byte-for-byte — - /// the LLM has to see exactly what the user wrote, with no - /// re-encoding or stripping. + /// The composed prompt must contain all three payloads + /// byte-for-byte — the LLM has to see exactly what the user wrote, + /// the original module's Form-A bytes, and the embedded spec, with + /// no re-encoding or stripping. #[test] - fn merge_prose_prompt_contains_both_inputs_verbatim() { - let orig = "{\"schema\":\"ailang/v0\",\"name\":\"foo\",\"defs\":[]}"; - let prose = "// module foo\n\nfn bar() -> Int { 42 }\n"; + fn merge_prose_prompt_contains_all_payloads_verbatim() { + let orig = "(module foo\n (fn main\n (type (fn-type (params) (ret (con Unit))))\n (params)\n (body (lit-unit))))\n"; + let prose = "// module foo\n\nfn main() -> Unit { () }\n"; let out = compose_merge_prose_prompt(orig, prose); assert!( out.contains(orig), - "prompt missing original .ail.json verbatim; got:\n{out}" + "prompt missing original Form-A verbatim; got:\n{out}" ); assert!( out.contains(prose), "prompt missing edited prose verbatim; got:\n{out}" ); + // The full FORM_A_SPEC must appear in the prompt. + assert!( + out.contains(ailang_core::FORM_A_SPEC), + "prompt missing the FORM_A_SPEC body verbatim" + ); } /// The framing sections must all be present so the LLM has the @@ -1934,44 +1943,35 @@ mod tests { /// landmark from each section. #[test] fn merge_prose_prompt_has_all_framing_sections() { - let out = compose_merge_prose_prompt("{}", ""); - // Role statement. + let out = compose_merge_prose_prompt("(module x)", ""); assert!( - out.contains("ROLE") - && out.contains("integrating prose edits"), + out.contains("ROLE") && out.contains("Form-A"), "prompt missing ROLE section" ); - // Contract about preserving load-bearing detail. assert!( out.contains("CONTRACT") && out.contains("preserve those") - && out.contains("`own T`") - && out.contains("`with IO`"), + && out.contains("(own T)") + && out.contains("(effects IO)"), "prompt missing CONTRACT section" ); - // Output spec — JSON only, no fences, no commentary. assert!( out.contains("OUTPUT") && out.contains("ONLY") && out.contains("no markdown"), "prompt missing OUTPUT section" ); - // Schema-essentials reminder + DESIGN.md pointer. + // The embedded language spec, by section header. assert!( - out.contains("SCHEMA ESSENTIALS") - && out.contains("docs/DESIGN.md") - && out.contains("`kind`") - && out.contains("`k`") - && out.contains("`t`") - && out.contains("`p`") - && out.contains("param_modes") - && out.contains("Term::Ctor"), - "prompt missing SCHEMA ESSENTIALS section" + out.contains("FORM-A SPECIFICATION"), + "prompt missing FORM-A SPECIFICATION header" ); - // Heredoc-style markers around both payloads. + // Heredoc-style markers around all three payloads. assert!( - out.contains("<< Int { 42 }\n"; @@ -2410,13 +2415,18 @@ fn merge_prose_prints_framed_prompt() { stdout.contains("integrating prose edits"), "prompt missing role-statement landmark; got:\n{stdout}" ); - // Both inputs must round-trip into the prompt verbatim. + // Original module is now embedded as Form-A, not JSON. assert!( - stdout.contains("ailang/v0"), - "prompt missing original .ail.json content" + stdout.contains("(module foo"), + "prompt missing original Form-A content; got:\n{stdout}" ); assert!( stdout.contains("fn bar() -> Int { 42 }"), "prompt missing edited prose content" ); + // The embedded spec must be present. + assert!( + stdout.contains("FORM-A SPECIFICATION"), + "prompt missing FORM-A SPECIFICATION section" + ); } diff --git a/crates/ailang-core/specs/form_a.md b/crates/ailang-core/specs/form_a.md new file mode 100644 index 0000000..569b016 --- /dev/null +++ b/crates/ailang-core/specs/form_a.md @@ -0,0 +1,397 @@ +# AILang Form-A — LLM authoring specification + +Form-A is the canonical textual surface of AILang. It is the form that +LLMs generate when asked to produce or edit AILang code, and the form +that `ail parse .ailx` reads. The inverse direction — printing +JSON-AST as Form-A — is `ail render .ail.json`. Round-trip +through this pair is the gating contract: `parse(render(m)) == m` +for every well-formed module. + +This document is the **complete LLM-targeted specification**. If you +are an LLM and this is in your context, you have everything you need +to produce valid Form-A. The file lives at +`crates/ailang-core/specs/form_a.md` next to the AST definitions, and +a unit test (`tests/spec_drift.rs`) walks every AST enum variant and +asserts its serde tag appears here — so this document cannot silently +fall behind the language. + +## Why Form-A and not JSON + +The hashable artefact is `.ail.json`, but no human or LLM should write +that directly. JSON-AST is a mechanical serialization with high +boilerplate (`{"k": "con", "name": "Int"}` per type reference, mandatory +field tags, every term wrapped). Form-A is the same information in a +Lisp-style S-expression dress that: + +- omits structural noise (no field tags; positions carry meaning) +- has a real parser with positional error messages +- round-trips through `ail render` ↔ `ail parse` losslessly +- is the form every existing `examples/*.ailx` is written in + +LLMs generate Form-A; the toolchain converts to JSON. + +## Conventions + +- `NAME` is a bare identifier: letters, digits, `_`, `-`, `+`, `*`, + `/`, `<`, `>`, `=`, `!`, `?`, `%`. Cannot start with a digit. +- `STRING` is a double-quoted UTF-8 literal: `"hello"`. Backslash + escapes: `\"`, `\\`, `\n`, `\t`. +- `INT` is a signed decimal integer; leading `-` is allowed. +- Whitespace and `;`-prefixed line comments are insignificant. +- `?` after a clause means optional. `*` means zero or more. + +## Module structure + +``` +(module NAME + IMPORT* + DEF*) +``` + +The module name MUST equal the file stem (`bench_list_sum.ailx` → +`(module bench_list_sum ...)`). + +## Imports + +``` +(import MODULE-NAME) +(import MODULE-NAME (as ALIAS)) +``` + +The alias clause is optional. Imported modules are resolved relative +to the entry file's directory. + +## Definitions + +Three kinds, matched on the leading keyword: + +### Function — `(fn ...)` + +``` +(fn NAME + (doc STRING)? + (suppress (code STRING) (because STRING))* + (type FN-TYPE) + (params NAME*) + (body TERM)) +``` + +`doc` is optional but recommended — it appears in the prose projection +and helps downstream readers (human and LLM). + +`suppress` clauses silence advisory diagnostics for this def. The +`code` MUST be one of the registered codes (today: `over-strict-mode`). +The `because` MUST be a non-empty justification — empty / whitespace- +only `because` is itself an error (`empty-suppress-reason`). + +`type` is a `(fn-type ...)` (possibly wrapped in `(forall ...)` for +polymorphic defs). All parameters of a `(fn ...)` MUST carry a mode +annotation — see *Modes* below. + +`params` is a list of bare names that bind the parameters in `body`. +The list length must match the number of params in `type`. + +### Data type — `(data ...)` + +``` +(data NAME + (vars TYVAR+)? + (doc STRING)? + (ctor CTOR-NAME ARG-TYPE*)*) +``` + +`vars` makes the type polymorphic; absent means monomorphic. Each +`ctor` clause is one variant. `ARG-TYPE` is a `TYPE` — see below. + +### Constant — `(const ...)` + +``` +(const NAME + (doc STRING)? + (type TYPE) + (body TERM)) +``` + +## Types + +Four shapes, all parenthesised except a bare type variable: + +``` +TYVAR-NAME ; type variable (e.g. `a`, `T`) +(con NAME TYPE-ARG*) ; type-constructor application +(fn-type (params PARAM*) + (ret RETURN-PARAM) + (effects EFFECT-NAME*)?) ; function type +(forall (vars TYVAR+) BODY-TYPE) ; polymorphic schema +``` + +`PARAM` and `RETURN-PARAM` are types, optionally wrapped in a mode +annotation: + +``` +TYPE ; implicit mode (DO NOT USE in new (fn ...) defs) +(own TYPE) ; caller transfers ownership; callee consumes +(borrow TYPE) ; caller retains ownership; callee must not consume +``` + +`EFFECT-NAME` is a bare identifier — currently `IO` and `Diverge`. +Effects are a set; order is irrelevant. + +Built-in type-constructors: `Int`, `Bool`, `Str`, `Unit`. User ADTs +use the name from the `(data ...)` def. + +Examples: + +``` +(con Int) +(con List (con Int)) +(fn-type (params (own (con List)) (borrow (con Int))) (ret (con Int))) +(forall (vars a) (fn-type (params (con List a)) (ret (con Int)))) +``` + +## Terms + +Atom forms (no parens): + +- `INT` — integer literal +- `STRING` — string literal +- `true`, `false` — bool literals +- `NAME` — variable reference (parameter, local, top-level def, or import alias) + +Parenthesised forms: + +``` +(lit-unit) ; the unit value () +(app FN ARG+) ; function application (≥1 arg) +(tail-app FN ARG+) ; tail-position application (Decision 8) +(do OP-NAME ARG*) ; effect operation +(tail-do OP-NAME ARG*) ; tail-position effect +(let NAME VALUE-TERM BODY-TERM) ; binding +(let-rec NAME (params NAME*) (type FN-TYPE) (body TERM) + (in BODY-TERM)) ; recursive let (fn-shaped) +(if COND-TERM THEN-TERM ELSE-TERM) ; conditional +(match SCRUTINEE-TERM (case PAT BODY)+) ; pattern match (≥1 arm) +(term-ctor TYPE-NAME CTOR-NAME ARG*) ; constructor application +(lam (params (typed NAME TYPE)*) + (ret RETURN-TYPE) + (effects EFFECT-NAME*)? + (body TERM)) ; anonymous function (Iter 8b) +(seq EFFECTFUL-TERM RESULT-TERM) ; sequence; lhs evaluated for effect +(clone TERM) ; explicit RC clone (Iter 18c.1) +(reuse-as SOURCE-TERM BODY-TERM) ; explicit reuse hint (Iter 18d.1) +``` + +Notes: + +- `app` and `do` REQUIRE the right tag for the right thing. + Constructors are NEVER called with `app`; always use `term-ctor`. +- `tail-app` / `tail-do` mark the call as occurring in tail position + per Decision 8. Codegen lowers them to `musttail call`. Use the + tail variant whenever a recursive call is the final action of an + arm — it converts unbounded recursion into iteration. Non-tail + variants are otherwise indistinguishable in semantics. +- `seq` is `(seq A B)` — A is evaluated for its effects and result + discarded; B is the value of the whole expression. For pure A, + prefer `(let _ A B)` or just drop A. +- `reuse-as` requires `SOURCE-TERM` to be a bare variable reference + (a `NAME` in the term grammar). Anything else fails the linearity + check with `reuse-as-source-not-bare-var`. + +## Patterns + +``` +_ ; wildcard; matches anything, binds nothing +NAME ; variable; binds the value to NAME +(pat-lit LIT-FORM) ; literal match: integer, true/false, string +(pat-ctor CTOR-NAME FIELD*) ; constructor; FIELD is itself a pattern +``` + +`LIT-FORM` is `INT`, `true`, `false`, or `STRING` — the same atoms used +as terms. + +A pattern variable may bind at most once per arm. Pattern-binders are +in scope inside the arm body. + +## Schema invariants enforced by `ail check` + +The parser will accept syntactically valid Form-A that violates these; +the typechecker will not. Producing Form-A that obeys them yields +checked code on the first try. + +1. **Mode annotations on every `(fn ...)` parameter.** Every type in + the `(params ...)` clause of a `(fn ...)` definition's + `(fn-type ...)` MUST be wrapped in `(own T)` or `(borrow T)`. The + return type MUST also carry a mode whenever the type is heap-shaped + (i.e. anything other than `(con Int)`, `(con Bool)`, `(con Unit)`, + `(con Str)`). Implicit mode on a `(fn ...)` def is rejected. +2. **Constructors via `term-ctor`.** `Cons(1, Nil)` becomes + `(term-ctor List Cons 1 (term-ctor List Nil))`, never + `(app Cons 1 (app Nil))`. +3. **Effects on side-effecting fns.** A function whose body uses `(do ...)` + MUST list every effect operation's effect in its `(effects ...)` + clause. `IO` for `io/print_*`; `Diverge` for `diverge/*`. +4. **Tail correctness.** `(tail-app f x)` MUST appear in tail position + — i.e. as the body of a fn, the last expression of a `(seq ...)`, + the chosen arm of an `(if ...)` or `(match ...)`, or the body of + a `(let ...)`. A `tail-app` outside a tail position is rejected. +5. **Linearity for own/borrow.** A parameter declared `(own T)` must + be consumed exactly once on every reachable path; a `(borrow T)` + must never be consumed. The diagnostic catalog has named codes + for the typical violations. + +## Pitfalls + +LLMs without prior AILang exposure tend to make the following errors. +Reading these once before generating Form-A reduces re-roll cost +significantly. + +- **Bare type names instead of `(con T)`.** Writing `Int` where a type + is expected does NOT work — types live inside `(con ...)`. Only + `TYVAR-NAME` (a single ident) parses as a type without parens, and + it is interpreted as a type variable. So `(fn-type (params Int) ...)` + parses as "function with one type-variable parameter named Int", + which is almost certainly not what was meant. +- **Forgetting mode annotations.** `(fn-type (params (con List)) ...)` + is accepted by the parser but rejected by the checker. Wrap every + `(fn ...)` parameter in `(own ...)` or `(borrow ...)`. +- **Using `app` for constructors.** Constructors are NOT first-class + functions. `(app Cons 1 Nil)` is interpreted as "apply variable + `Cons` to ...", which then fails because `Cons` is not a fn. +- **Forgetting `tail-`.** A non-tail call in tail position works, but + three million stack frames will overflow. For recursive fns where + the recursive call is the final action, use `tail-app`. +- **Wrong arity in `(case (pat-ctor C f1 f2 ...) ...)`.** The number + of pattern fields must equal the constructor's declared arity. The + checker catches this but the message is clearer if you do too. +- **Strings inside `(suppress (because ...))` must be non-empty.** Empty + is an error. Whitespace-only is an error. Write a real reason. + +## Few-shot corpus + +These four modules are real `examples/*.ailx` content. Each one is +parseable and typechecks clean. Pattern-match against them when +generating new code. + +### 1 — `hello.ailx`: minimal IO program + +``` +(module hello + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body (do io/print_str "Hello, AILang.")))) +``` + +### 2 — `borrow_own_demo.ailx`: mode annotations on a recursive list + +``` +(module borrow_own_demo + + (data List + (doc "Monomorphic singly-linked Int list — boxed, recursive.") + (ctor Nil) + (ctor Cons (con Int) (con List))) + + (fn list_length + (doc "Borrow xs, count its elements.") + (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 t) + (app + 1 (app list_length t)))))) + + (fn sum_list + (doc "Consume xs, sum its elements.") + (type + (fn-type + (params (own (con List))) + (ret (con Int)))) + (params xs) + (body + (match xs + (case (pat-ctor Nil) 0) + (case (pat-ctor Cons h t) + (app + h (app sum_list t)))))) + + (fn main + (type (fn-type (params) (ret (con Unit)) (effects IO))) + (params) + (body + (let xs + (term-ctor List Cons 1 + (term-ctor List Cons 2 + (term-ctor List Cons 3 + (term-ctor List Nil)))) + (seq + (do io/print_int (app list_length xs)) + (do io/print_int (app sum_list xs))))))) +``` + +### 3 — `lit_pat.ailx`: literal patterns and nested ctor patterns + +``` +(module lit_pat + + (data IntList + (ctor Nil) + (ctor Cons (con Int) (con IntList))) + + (fn classify + (type (fn-type (params (con Int)) (ret (con Int)))) + (params n) + (body + (match n + (case (pat-lit 0) 100) + (case (pat-lit 1) 200) + (case _ 999)))) + + (fn categorize_first + (type (fn-type (params (own (con IntList))) (ret (con Int)))) + (params xs) + (body + (match xs + (case (pat-ctor Nil) -1) + (case (pat-ctor Cons (pat-lit 0) _) 0) + (case (pat-ctor Cons h _) h))))) +``` + +### 4 — Tail-recursive sum (the canonical big-N pattern) + +``` +(module sum_demo + (data IntList + (ctor INil) + (ctor ICons (con Int) (con IntList))) + + (fn sum_acc + (doc "Tail-recursive accumulator.") + (type + (fn-type + (params (own (con IntList)) (con Int)) + (ret (con Int)))) + (params xs acc) + (body + (match xs + (case (pat-ctor INil) acc) + (case (pat-ctor ICons h t) + (tail-app sum_acc t (app + acc h)))))) + + (fn sum_list + (type + (fn-type + (params (own (con IntList))) + (ret (con Int)))) + (params xs) + (body + (app sum_acc xs 0)))) +``` + +Notice in (4): the recursive `sum_acc` call is `tail-app`, the addition +is plain `app`. The accumulator parameter is `(con Int)` (no mode — +`Int` is a primitive value type, not heap-shaped, so modes do not +apply to it). diff --git a/crates/ailang-core/src/lib.rs b/crates/ailang-core/src/lib.rs index 0953276..b366c6e 100644 --- a/crates/ailang-core/src/lib.rs +++ b/crates/ailang-core/src/lib.rs @@ -96,6 +96,18 @@ pub type Result = std::result::Result; /// loading fails with [`Error::SchemaMismatch`]. pub const SCHEMA: &str = "ailang/v0"; +/// Iter 20f: complete LLM-targeted specification of Form-A — the +/// canonical authoring surface (Decision 6). Embedded verbatim into +/// any prompt that asks an LLM to produce or edit AILang code; in +/// particular, `ail merge-prose` includes it in the round-trip +/// prompt template. The string is the raw bytes of +/// `specs/form_a.md`, co-located with this crate so the spec sits +/// next to the AST it describes; a drift test +/// (`tests/spec_drift.rs`) walks every AST enum variant and asserts +/// its serde tag appears in this string. Adding a new variant +/// without updating the spec fails the test suite. +pub const FORM_A_SPEC: &str = include_str!("../specs/form_a.md"); + /// Load a single module file, validate its schema, and deserialize it /// into a [`Module`]. /// diff --git a/crates/ailang-core/tests/spec_drift.rs b/crates/ailang-core/tests/spec_drift.rs new file mode 100644 index 0000000..cc8f258 --- /dev/null +++ b/crates/ailang-core/tests/spec_drift.rs @@ -0,0 +1,316 @@ +//! Iter 20f: drift detection between the AST and `specs/form_a.md`. +//! +//! The spec is hand-curated, but it cannot silently fall behind the +//! language. Every AST enum (`Term`, `Pattern`, `Type`, `Def`, `Literal`, +//! `ParamMode`) discriminates on a `#[serde(rename = "...")]` tag. +//! These tests construct a sample of every variant, then check that the +//! corresponding tag string (or its parenthesised Form-A keyword) appears +//! in the spec. +//! +//! The exhaustive `match` is the load-bearing piece: adding a new variant +//! without a spec entry fails compilation here long before the test runs. +//! Once the variant is matched, the test asserts the spec mentions it. + +use ailang_core::ast::{ + ConstDef, Ctor, Def, FnDef, Literal, Pattern, Suppress, Term, Type, TypeDef, +}; +use ailang_core::FORM_A_SPEC; + +/// Every `Term` variant must be reachable from the spec. The Form-A +/// keyword for each variant is what the spec is supposed to teach an +/// LLM; if it is missing here, the LLM cannot produce that term. +#[test] +fn spec_mentions_every_term_variant() { + let exemplars: Vec<(&str, Term)> = vec![ + ("(lit-unit", Term::Lit { lit: Literal::Unit }), + // The Var form has no parenthesised keyword (a bare ident is a + // var-ref). The spec calls it out under "Atom forms"; we look for + // that anchor. + ("Atom forms", Term::Var { name: "x".into() }), + ( + "(app", + Term::App { + callee: Box::new(Term::Var { name: "f".into() }), + args: vec![Term::Var { name: "x".into() }], + tail: false, + }, + ), + ( + "(let ", + Term::Let { + name: "x".into(), + value: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }), + body: Box::new(Term::Var { name: "x".into() }), + }, + ), + ( + "(let-rec", + Term::LetRec { + name: "f".into(), + ty: Type::fn_implicit(vec![], Type::int(), vec![]), + params: vec![], + body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + in_term: Box::new(Term::Var { name: "f".into() }), + }, + ), + ( + "(if", + Term::If { + cond: Box::new(Term::Lit { lit: Literal::Bool { value: true } }), + then: Box::new(Term::Lit { lit: Literal::Int { value: 1 } }), + else_: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + }, + ), + ( + "(do ", + Term::Do { + op: "io/print_int".into(), + args: vec![], + tail: false, + }, + ), + ( + "(term-ctor", + Term::Ctor { + type_name: "List".into(), + ctor: "Nil".into(), + args: vec![], + }, + ), + ( + "(match", + Term::Match { + scrutinee: Box::new(Term::Var { name: "x".into() }), + arms: vec![], + }, + ), + ( + "(lam", + Term::Lam { + params: vec![], + param_tys: vec![], + ret_ty: Box::new(Type::int()), + effects: vec![], + body: Box::new(Term::Lit { lit: Literal::Int { value: 0 } }), + }, + ), + ( + "(seq", + Term::Seq { + lhs: Box::new(Term::Var { name: "a".into() }), + rhs: Box::new(Term::Var { name: "b".into() }), + }, + ), + ( + "(clone", + Term::Clone { + value: Box::new(Term::Var { name: "x".into() }), + }, + ), + ( + "(reuse-as", + Term::ReuseAs { + source: Box::new(Term::Var { name: "x".into() }), + body: Box::new(Term::Var { name: "y".into() }), + }, + ), + ]; + + for (anchor, term) in exemplars { + // Force the exhaustive match: the body is just a tag string that + // we will not actually use, but the compiler will refuse to + // compile this file once a new Term variant is added without a + // matching arm. + let _: &'static str = match term { + Term::Lit { .. } => "lit", + Term::Var { .. } => "var", + Term::App { .. } => "app", + Term::Let { .. } => "let", + Term::LetRec { .. } => "letrec", + Term::If { .. } => "if", + Term::Do { .. } => "do", + Term::Ctor { .. } => "ctor", + Term::Match { .. } => "match", + Term::Lam { .. } => "lam", + Term::Seq { .. } => "seq", + Term::Clone { .. } => "clone", + Term::ReuseAs { .. } => "reuse-as", + }; + assert!( + FORM_A_SPEC.contains(anchor), + "spec is missing anchor `{anchor}` for a Term variant — \ + update crates/ailang-core/specs/form_a.md" + ); + } +} + +/// Every `Pattern` variant must appear in the spec. +#[test] +fn spec_mentions_every_pattern_variant() { + let exemplars: Vec<(&str, Pattern)> = vec![ + ("_", Pattern::Wild), + // pat-var is again the bare-ident form. The spec discusses it + // under "Patterns". Use the heading as the anchor. + ("## Patterns", Pattern::Var { name: "x".into() }), + ("(pat-lit", Pattern::Lit { lit: Literal::Int { value: 0 } }), + ( + "(pat-ctor", + Pattern::Ctor { ctor: "Nil".into(), fields: vec![] }, + ), + ]; + + for (anchor, pat) in exemplars { + let _: &'static str = match pat { + Pattern::Wild => "wild", + Pattern::Var { .. } => "var", + Pattern::Lit { .. } => "lit", + Pattern::Ctor { .. } => "ctor", + }; + assert!( + FORM_A_SPEC.contains(anchor), + "spec is missing anchor `{anchor}` for a Pattern variant" + ); + } +} + +/// Every `Type` variant must appear in the spec. +#[test] +fn spec_mentions_every_type_variant() { + let exemplars: Vec<(&str, Type)> = vec![ + ("(con ", Type::int()), + ( + "(fn-type", + Type::fn_implicit(vec![], Type::unit(), vec![]), + ), + ( + "TYVAR-NAME", + Type::Var { name: "a".into() }, + ), + ( + "(forall", + Type::Forall { + vars: vec!["a".into()], + body: Box::new(Type::Var { name: "a".into() }), + }, + ), + ]; + + for (anchor, ty) in exemplars { + let _: &'static str = match ty { + Type::Con { .. } => "con", + Type::Fn { .. } => "fn", + Type::Var { .. } => "var", + Type::Forall { .. } => "forall", + }; + assert!( + FORM_A_SPEC.contains(anchor), + "spec is missing anchor `{anchor}` for a Type variant" + ); + } +} + +/// Every `Literal` variant must appear in the spec. +#[test] +fn spec_mentions_every_literal_variant() { + // Anchors describe how the literal renders in Form-A. + let exemplars: Vec<(&str, Literal)> = vec![ + ("`INT`", Literal::Int { value: 0 }), + ("`true`, `false`", Literal::Bool { value: true }), + ("`STRING`", Literal::Str { value: "x".into() }), + ("(lit-unit)", Literal::Unit), + ]; + + for (anchor, lit) in exemplars { + let _: &'static str = match lit { + Literal::Int { .. } => "int", + Literal::Bool { .. } => "bool", + Literal::Str { .. } => "str", + Literal::Unit => "unit", + }; + assert!( + FORM_A_SPEC.contains(anchor), + "spec is missing anchor `{anchor}` for a Literal variant" + ); + } +} + +/// Every `Def` kind must appear in the spec. +#[test] +fn spec_mentions_every_def_kind() { + let fn_def = FnDef { + name: "f".into(), + doc: None, + suppress: vec![], + ty: Type::fn_implicit(vec![], Type::int(), vec![]), + params: vec![], + body: Term::Lit { lit: Literal::Int { value: 0 } }, + }; + let const_def = ConstDef { + name: "k".into(), + doc: None, + ty: Type::int(), + value: Term::Lit { lit: Literal::Int { value: 0 } }, + }; + let type_def = TypeDef { + name: "T".into(), + doc: None, + vars: vec![], + ctors: vec![Ctor { + name: "C".into(), + fields: vec![], + }], + drop_iterative: false, + }; + let exemplars: Vec<(&str, Def)> = vec![ + ("(fn ", Def::Fn(fn_def)), + ("(const ", Def::Const(const_def)), + ("(data ", Def::Type(type_def)), + ]; + + for (anchor, def) in exemplars { + let _: &'static str = match def { + Def::Fn(_) => "fn", + Def::Const(_) => "const", + Def::Type(_) => "type", + }; + assert!( + FORM_A_SPEC.contains(anchor), + "spec is missing anchor `{anchor}` for a Def kind" + ); + } +} + +/// The mode keywords (Decision 10) must appear so an LLM knows the +/// Form-A wrapper syntax. +#[test] +fn spec_mentions_mode_keywords() { + assert!(FORM_A_SPEC.contains("(own"), "spec missing `(own ...)`"); + assert!(FORM_A_SPEC.contains("(borrow"), "spec missing `(borrow ...)`"); +} + +/// `tail-app` and `tail-do` are distinct keywords from `app`/`do`. The +/// spec must mention both, otherwise an LLM cannot produce tail-correct +/// code at scale. +#[test] +fn spec_mentions_tail_variants() { + assert!(FORM_A_SPEC.contains("tail-app"), "spec missing `tail-app`"); + assert!(FORM_A_SPEC.contains("tail-do"), "spec missing `tail-do`"); +} + +/// `suppress` is part of the surface and the LLM must know how to +/// preserve it. Empty-because is itself a diagnostic; the spec calls +/// it out so the LLM does not produce empty justifications. +#[test] +fn spec_mentions_suppress_clause() { + assert!(FORM_A_SPEC.contains("(suppress"), "spec missing `(suppress ...)`"); + assert!( + FORM_A_SPEC.contains("empty-suppress-reason"), + "spec missing the empty-suppress-reason diagnostic" + ); + // Make sure `Suppress` in the AST can still be constructed — the + // exhaustive-match property carries through to the surface. + let _ = Suppress { + code: "x".into(), + because: "y".into(), + }; +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index f336af1..eb2bed8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -512,6 +512,34 @@ projection) and `ail merge-prose ` (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 — diff --git a/docs/JOURNAL.md b/docs/JOURNAL.md index 84a7e85..ade5be1 100644 --- a/docs/JOURNAL.md +++ b/docs/JOURNAL.md @@ -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 | > 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 1–2 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. diff --git a/docs/PROSE_ROUNDTRIP.md b/docs/PROSE_ROUNDTRIP.md index 67e9f37..3412ea3 100644 --- a/docs/PROSE_ROUNDTRIP.md +++ b/docs/PROSE_ROUNDTRIP.md @@ -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 | > 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 | > 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 ` 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 ` 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 +<<