ail: merge-prose subcommand + PROSE_ROUNDTRIP.md (iter 20d)

Closes family 20: prose-edit cycle is end-to-end. ail merge-prose
<original.ail.json> <edited.prose.txt> composes a shaped LLM prompt
to stdout. User pipes to their LLM (Claude/etc.), gets new .ail.json,
runs ail check, saves.

No built-in API client — AILang stays a compiler + tooling, the LLM
mediator is supplied externally. PROSE_ROUNDTRIP.md documents the
6-step cycle, the prompt contract (preserve modes/effects/tail
flags from original), failure modes, and the design choice to omit
an API client.

Family 20 now closes: 20a renderer + 20b polish + 20d mediator
shipped; 20c rolled into 20a. Three deferred polishes (let-inlining,
print sugar, doc-wrap widow control) wait for corpus signal.
This commit is contained in:
2026-05-08 18:22:46 +02:00
parent 9cf0e3e81c
commit 9c09dfbc8d
4 changed files with 510 additions and 0 deletions
+199
View File
@@ -207,6 +207,111 @@ enum Cmd {
/// surface remains form (A) (`render` / `parse`) and the canonical
/// hashable artefact remains the JSON-AST.
Prose { path: PathBuf },
/// Iter 20d: composes a prompt for the prose-edit round-trip.
///
/// Given the original `.ail.json` and the edited `.prose.txt`,
/// prints a prompt that asks an external LLM to emit an updated
/// `.ail.json` integrating the prose edits. AILang ships no LLM
/// client of its own; the user pipes the prompt to their tool of
/// choice (Claude Code, Anthropic API, OpenAI CLI, etc.) and runs
/// `ail check` on the result.
///
/// See `docs/PROSE_ROUNDTRIP.md` for the full cycle and the
/// rationale for keeping the API client out of scope.
MergeProse {
/// Original `.ail.json` (carries load-bearing detail the prose elides).
original: PathBuf,
/// Edited `.prose.txt` (carries the human's intent).
edited: PathBuf,
},
}
/// 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.
///
/// 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!(
"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.
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).
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 `}}`.
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,
)
}
fn main() -> Result<()> {
@@ -312,6 +417,17 @@ fn main() -> Result<()> {
let m = ailang_core::load_module(&path)?;
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()))?;
let prose = std::fs::read_to_string(&edited)
.with_context(|| format!("reading {}", edited.display()))?;
print!("{}", compose_merge_prose_prompt(&orig, &prose));
}
Cmd::Describe { path, name, json, workspace } => {
if workspace {
let ws = ailang_core::load_workspace(&path)?;
@@ -1790,3 +1906,86 @@ fn build_to(
}
Ok(out_bin)
}
#[cfg(test)]
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.
#[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";
let out = compose_merge_prose_prompt(orig, prose);
assert!(
out.contains(orig),
"prompt missing original .ail.json verbatim; got:\n{out}"
);
assert!(
out.contains(prose),
"prompt missing edited prose verbatim; got:\n{out}"
);
}
/// The framing sections must all be present so the LLM has the
/// full task definition. Asserted by substring on a representative
/// landmark from each section.
#[test]
fn merge_prose_prompt_has_all_framing_sections() {
let out = compose_merge_prose_prompt("{}", "");
// Role statement.
assert!(
out.contains("ROLE")
&& out.contains("integrating prose edits"),
"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`"),
"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.
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"
);
// Heredoc-style markers around both payloads.
assert!(
out.contains("<<<ORIGINAL_AIL_JSON")
&& out.contains("ORIGINAL_AIL_JSON\n")
&& out.contains("<<<EDITED_PROSE")
&& out.contains("EDITED_PROSE\n"),
"prompt missing payload markers"
);
}
/// Pure / deterministic: same inputs must yield byte-identical
/// output.
#[test]
fn merge_prose_prompt_is_deterministic() {
let orig = "{\"x\":1}";
let prose = "fn f() {}";
let a = compose_merge_prose_prompt(orig, prose);
let b = compose_merge_prose_prompt(orig, prose);
assert_eq!(a, b);
}
}