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:
+103
-103
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user