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
+103 -103
View File
@@ -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
<<<ORIGINAL_AIL_JSON
{orig}
ORIGINAL_AIL_JSON
EDITED PROSE
<<<EDITED_PROSE
{edited}
EDITED_PROSE
Emit the updated .ail.json now.
",
orig = orig_ail_json,
edited = edited_prose,
)
);
out.push_str("FORM-A SPECIFICATION\n");
out.push_str("<<<FORM_A_SPEC\n");
out.push_str(ailang_core::FORM_A_SPEC);
if !ailang_core::FORM_A_SPEC.ends_with('\n') {
out.push('\n');
}
out.push_str("FORM_A_SPEC\n\n");
out.push_str("ORIGINAL MODULE (Form-A)\n");
out.push_str("<<<ORIGINAL_FORM_A\n");
out.push_str(original_form_a);
if !original_form_a.ends_with('\n') {
out.push('\n');
}
out.push_str("ORIGINAL_FORM_A\n\n");
out.push_str("EDITED PROSE\n");
out.push_str("<<<EDITED_PROSE\n");
out.push_str(edited_prose);
if !edited_prose.ends_with('\n') {
out.push('\n');
}
out.push_str("EDITED_PROSE\n\n");
out.push_str("Emit the updated Form-A module now.\n");
out
}
fn main() -> 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("<<<ORIGINAL_AIL_JSON")
&& out.contains("ORIGINAL_AIL_JSON\n")
out.contains("<<<FORM_A_SPEC")
&& out.contains("FORM_A_SPEC\n")
&& out.contains("<<<ORIGINAL_FORM_A")
&& out.contains("ORIGINAL_FORM_A\n")
&& out.contains("<<<EDITED_PROSE")
&& out.contains("EDITED_PROSE\n"),
"prompt missing payload markers"
@@ -1982,7 +1982,7 @@ mod tests {
/// output.
#[test]
fn merge_prose_prompt_is_deterministic() {
let orig = "{\"x\":1}";
let orig = "(module y)";
let prose = "fn f() {}";
let a = compose_merge_prose_prompt(orig, prose);
let b = compose_merge_prose_prompt(orig, prose);