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);
|
||||
|
||||
+13
-3
@@ -2387,6 +2387,11 @@ fn merge_prose_prints_framed_prompt() {
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
let orig_path = tmp.join("orig.ail.json");
|
||||
let prose_path = tmp.join("edited.prose.txt");
|
||||
// Iter 20f: merge-prose now loads the module via ailang_core,
|
||||
// renders to Form-A, and embeds *that* in the prompt — so the
|
||||
// input must be a well-formed ailang/v0 JSON-AST that
|
||||
// load_module accepts. The schema, name, and an empty defs list
|
||||
// are the minimum.
|
||||
let orig_bytes =
|
||||
b"{\"schema\":\"ailang/v0\",\"name\":\"foo\",\"imports\":[],\"defs\":[]}";
|
||||
let prose_bytes = b"// module foo\n\nfn bar() -> 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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <file>.ailx` reads. The inverse direction — printing
|
||||
JSON-AST as Form-A — is `ail render <file>.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).
|
||||
@@ -96,6 +96,18 @@ pub type Result<T> = std::result::Result<T, Error>;
|
||||
/// 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`].
|
||||
///
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 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.
|
||||
|
||||
+95
-91
@@ -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 1–3 rounds for typical edits.
|
||||
The cycle converges in 1–2 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.
|
||||
|
||||
Reference in New Issue
Block a user