Files
AILang/docs/plans/0038-cma.1.md
T
Brummel 832375f2ac convention: counter-prefix file naming across docs/specs/, docs/plans/, design/contracts/, design/models/
All 176 files in the four accumulating directories now use a
zero-padded 4-digit counter prefix that reflects creation order
(`NNNN-slug.md`). The counter is assigned per directory in strict
git-log creation order; ties broken alphabetically by original name.
The old `YYYY-MM-DD-` prefix on docs/specs/ and docs/plans/ files is
dropped — the date is recoverable from git log and the counter
carries the ordering.

A file's counter is stable for the life of the file: never reassigned,
never reused, never compacted. Deleted files retire their counter;
subsequent files do not fill the gap. This is the property that lets
cross-references stay literal — refs use the full filename including
the counter (`design/contracts/0007-honesty-rule.md`) so they grep
cleanly and resolve directly without a glob step.

313 cross-references updated across .md/.rs/.toml/.c/.json files
(test pins, include_str! paths, design-INDEX entries, baseline notes,
runtime C comments, inter-contract markdown links incl. bare basename
and `../models/foo.md` forms).

CLAUDE.md gets a new "File-naming convention" section spelling out
the rule and rationale. skills/brainstorm/SKILL.md and
skills/planner/SKILL.md updated so new spec/plan creation produces
counter-prefixed names from the start.

The full test suite (cargo test --workspace) passes.
2026-05-28 13:31:31 +02:00

1227 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# cma.1 — Implementation Plan
> **Parent spec:** `docs/specs/0017-cross-model-authoring-form-test.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Stand up the master mini-spec source and the renderer binary
under a new top-level `experiments/2026-05-12-cross-model-authoring/`
directory, with three green test gates (example roundtrip, token
balance, spec completeness) and the two rendered mini-spec files
checked into the working tree.
**Architecture:** A new top-level dir parallels `crates/`, `bench/`,
`examples/`, `runtime/`, `docs/`. Inside it, `render/` is a standalone
Cargo crate that is **not** a member of the root workspace; it depends
on `ailang-core` and `ailang-surface` by relative path. The renderer
consumes `master/spec.md` (markdown with `{form-only: X}` blocks and
`{example: id}` markers) plus `master/examples/*.ail.json` (curated
AST fixtures) and emits `rendered/json.md` + `rendered/ailx.md`.
Examples are printed in JSON form by emitting the on-disk
canonical-key-order bytes, and in AILX form by `ailang_core::load_module`
`ailang_surface::print`.
**Tech Stack:** Rust 2021, `ailang-core::{load_module, canonical,
module_hash}`, `ailang-surface::{print, parse}`, `serde_json`,
`anyhow`. Dev-only: `tiktoken-rs` (used by `token_balance` test).
**Files this plan creates or modifies:**
- Create: `experiments/2026-05-12-cross-model-authoring/README.md` — purpose + how to run the renderer
- Create: `experiments/2026-05-12-cross-model-authoring/render/Cargo.toml` — standalone crate manifest
- Create: `experiments/2026-05-12-cross-model-authoring/render/src/main.rs` — renderer entry point
- Create: `experiments/2026-05-12-cross-model-authoring/render/src/splitter.rs` — directive parser module
- Create: `experiments/2026-05-12-cross-model-authoring/render/src/examples.rs` — example-id → form-projection
- Create: `experiments/2026-05-12-cross-model-authoring/render/tests/splitter_unit.rs` — directive-parser tests
- Create: `experiments/2026-05-12-cross-model-authoring/render/tests/example_roundtrip.rs` — mirrors `round_trip_one` for `master/examples/`
- Create: `experiments/2026-05-12-cross-model-authoring/render/tests/spec_completeness.rs` — borrowed schema-coverage visitor
- Create: `experiments/2026-05-12-cross-model-authoring/render/tests/token_balance.rs` — ±5% form-only block balance
- Create: `experiments/2026-05-12-cross-model-authoring/master/spec.md` — vollumfänglich mini-spec source
- Create: `experiments/2026-05-12-cross-model-authoring/master/examples/*.ail.json` — curated AST fixtures (one per variant cluster; final filename list emerges from Task 4)
- Create: `experiments/2026-05-12-cross-model-authoring/rendered/json.md` — generated; checked in
- Create: `experiments/2026-05-12-cross-model-authoring/rendered/ailx.md` — generated; checked in
- Modify: none. Root `Cargo.toml` is **not** touched (spec §Architecture lines 9095).
---
## Task 1: Bootstrap experiment dir + Cargo skeleton
**Files:**
- Create: `experiments/2026-05-12-cross-model-authoring/README.md`
- Create: `experiments/2026-05-12-cross-model-authoring/render/Cargo.toml`
- Create: `experiments/2026-05-12-cross-model-authoring/render/src/main.rs`
- [ ] **Step 1.1: Create the directory structure**
Run:
```
mkdir -p experiments/2026-05-12-cross-model-authoring/render/src
mkdir -p experiments/2026-05-12-cross-model-authoring/render/tests
mkdir -p experiments/2026-05-12-cross-model-authoring/master/examples
mkdir -p experiments/2026-05-12-cross-model-authoring/rendered
```
Expected: four empty directories created, no errors.
- [ ] **Step 1.2: Write README.md**
Write `experiments/2026-05-12-cross-model-authoring/README.md`:
```markdown
# Cross-model authoring-form test
Empirical measurement of whether `.ail.json` or `.ailx` is the form a
foreign LLM author reaches for and succeeds with. Single subject for
v1: Qwen3-Coder-Next via IONOS. Two blind cohorts; same four tasks.
Parent spec: `docs/specs/0017-cross-model-authoring-form-test.md`.
## Layout
- `master/spec.md` — canonical mini-spec source (form-agnostic prose +
`{form-only: X}` blocks + `{example: id}` markers).
- `master/examples/*.ail.json` — AST source-of-truth; each example
prints to either form via the existing roundtrip machinery.
- `master/tasks/*.task.json` — task definitions consumed by the
harness. **Authored in cma.2**, not cma.1.
- `render/` — standalone Cargo crate, outside the root workspace,
builds the renderer binary.
- `rendered/json.md`, `rendered/ailx.md` — projected mini-specs,
checked into the repo for review.
- `harness/`**Authored in cma.2**.
- `runs/<date>-<hash>/` — populated by `harness` during a live run.
## Running the renderer
```
cargo run --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml -- \
--master experiments/2026-05-12-cross-model-authoring/master \
--rendered experiments/2026-05-12-cross-model-authoring/rendered
```
## Running the tests
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml
```
Three integration tests: `example_roundtrip` (each example loads,
prints to AILX, reparses to the same canonical bytes), `spec_completeness`
(every AST variant in `ailang_core::ast` is exercised by at least
one example), `token_balance` (form-only blocks balanced within ±5%
across the two rendered files).
```
- [ ] **Step 1.3: Write render/Cargo.toml**
Write `experiments/2026-05-12-cross-model-authoring/render/Cargo.toml`:
```toml
[package]
name = "xmodel-render"
version = "0.0.1"
edition = "2021"
publish = false
[[bin]]
name = "xmodel-render"
path = "src/main.rs"
[dependencies]
ailang-core = { path = "../../../crates/ailang-core" }
ailang-surface = { path = "../../../crates/ailang-surface" }
serde_json = "1"
anyhow = "1"
[dev-dependencies]
tiktoken-rs = "0.6"
```
- [ ] **Step 1.4: Write skeleton src/main.rs**
Write `experiments/2026-05-12-cross-model-authoring/render/src/main.rs`:
```rust
//! xmodel-render — renders the master mini-spec into two form-specific projections.
use anyhow::Result;
mod splitter;
mod examples;
fn main() -> Result<()> {
eprintln!("xmodel-render: not yet wired (Task 7)");
Ok(())
}
```
Create the two empty `.rs` files referenced (will be filled in by
later tasks):
```
touch experiments/2026-05-12-cross-model-authoring/render/src/splitter.rs
touch experiments/2026-05-12-cross-model-authoring/render/src/examples.rs
```
- [ ] **Step 1.5: Verify the skeleton builds**
Run:
```
cargo build --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml
```
Expected: succeeds. Two warnings expected (unused `mod splitter;` and
unused `mod examples;`) — these will go once Task 2 and Task 4 wire
the modules in.
---
## Task 2: Directive splitter (TDD)
**Files:**
- Modify: `experiments/2026-05-12-cross-model-authoring/render/src/splitter.rs`
- Create: `experiments/2026-05-12-cross-model-authoring/render/tests/splitter_unit.rs`
The splitter recognises three directives in `master/spec.md`:
- `{form-only: json}``{/form-only}` — block visible only in
JSON-rendered output.
- `{form-only: ailx}``{/form-only}` — block visible only in
AILX-rendered output.
- `{example: <id>}` — single-line marker that the example renderer
resolves to a fenced code block of the matching form.
Directives appear on their own lines. The parser is line-oriented
(no nested directives, no inline directives).
- [ ] **Step 2.1: Write the splitter unit tests (RED)**
Write `experiments/2026-05-12-cross-model-authoring/render/tests/splitter_unit.rs`:
```rust
use xmodel_render::splitter::{split, Form, Segment};
#[test]
fn plain_text_passes_through_unchanged() {
let input = "hello\nworld\n";
let segments = split(input);
assert_eq!(segments, vec![Segment::Prose("hello\nworld\n".to_string())]);
}
#[test]
fn form_only_block_is_isolated() {
let input = "before\n{form-only: json}\nschema rule\n{/form-only}\nafter\n";
let segments = split(input);
assert_eq!(
segments,
vec![
Segment::Prose("before\n".to_string()),
Segment::FormOnly { form: Form::Json, body: "schema rule\n".to_string() },
Segment::Prose("after\n".to_string()),
]
);
}
#[test]
fn ailx_form_only_block_uses_ailx_form() {
let input = "{form-only: ailx}\ngrammar rule\n{/form-only}\n";
let segments = split(input);
assert_eq!(
segments,
vec![
Segment::FormOnly { form: Form::Ailx, body: "grammar rule\n".to_string() },
]
);
}
#[test]
fn example_marker_is_isolated() {
let input = "before\n{example: empty_module}\nafter\n";
let segments = split(input);
assert_eq!(
segments,
vec![
Segment::Prose("before\n".to_string()),
Segment::Example("empty_module".to_string()),
Segment::Prose("after\n".to_string()),
]
);
}
#[test]
fn multiple_directives_in_sequence() {
let input = "p1\n{example: a}\n{form-only: json}\njs\n{/form-only}\n{form-only: ailx}\nax\n{/form-only}\n{example: b}\np2\n";
let segments = split(input);
assert_eq!(
segments,
vec![
Segment::Prose("p1\n".to_string()),
Segment::Example("a".to_string()),
Segment::FormOnly { form: Form::Json, body: "js\n".to_string() },
Segment::FormOnly { form: Form::Ailx, body: "ax\n".to_string() },
Segment::Example("b".to_string()),
Segment::Prose("p2\n".to_string()),
]
);
}
#[test]
fn unknown_form_name_is_an_error() {
let input = "{form-only: yaml}\nx\n{/form-only}\n";
let result = std::panic::catch_unwind(|| split(input));
assert!(result.is_err(), "split should panic on unknown form name");
}
#[test]
fn unclosed_form_only_is_an_error() {
let input = "{form-only: json}\nopen\n";
let result = std::panic::catch_unwind(|| split(input));
assert!(result.is_err(), "split should panic on unclosed form-only block");
}
```
The test file imports `xmodel_render::splitter::{split, Form, Segment}`,
which forces `src/main.rs` to expose the splitter module via a library
target. Without a library target this fails to compile.
- [ ] **Step 2.2: Add a library target to render/Cargo.toml + lib.rs**
Add to `experiments/2026-05-12-cross-model-authoring/render/Cargo.toml`,
after the existing `[[bin]]` block:
```toml
[lib]
name = "xmodel_render"
path = "src/lib.rs"
```
Create `experiments/2026-05-12-cross-model-authoring/render/src/lib.rs`:
```rust
//! Library surface for xmodel-render's internal modules — exposed so
//! integration tests can reach them.
pub mod splitter;
pub mod examples;
```
Update `experiments/2026-05-12-cross-model-authoring/render/src/main.rs`
to use the library (replace the existing `mod splitter;` / `mod examples;`
lines with):
```rust
//! xmodel-render — renders the master mini-spec into two form-specific projections.
use anyhow::Result;
use xmodel_render::{splitter, examples};
fn main() -> Result<()> {
let _ = (&splitter::Form::Json, &examples::Form::Json); // silence unused until Task 7
eprintln!("xmodel-render: not yet wired (Task 7)");
Ok(())
}
```
(Two `Form` enums avoid silent aliasing later; the bin reconciles them
at wire-up in Task 7. For now, just silence unused-import warnings.)
- [ ] **Step 2.3: Run the tests, verify RED**
Run:
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml --test splitter_unit
```
Expected: **compilation fails** because `splitter::{split, Form, Segment}`
do not exist yet. Output contains "cannot find function `split` in module
`splitter`" or equivalent.
- [ ] **Step 2.4: Write the splitter implementation (GREEN)**
Replace `experiments/2026-05-12-cross-model-authoring/render/src/splitter.rs`
content with:
```rust
//! Directive splitter for master/spec.md.
//!
//! Recognises three directives, one per line, no nesting:
//! {form-only: json} … {/form-only}
//! {form-only: ailx} … {/form-only}
//! {example: <id>}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Form {
Json,
Ailx,
}
impl Form {
fn parse(name: &str) -> Option<Form> {
match name {
"json" => Some(Form::Json),
"ailx" => Some(Form::Ailx),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Segment {
Prose(String),
FormOnly { form: Form, body: String },
Example(String),
}
/// Split a master/spec.md text into a sequence of segments.
///
/// Panics on malformed input (unknown form name, unclosed form-only block).
/// The renderer treats these as authoring bugs that should fail the test
/// suite rather than silently produce diverging mini-specs.
pub fn split(input: &str) -> Vec<Segment> {
let mut out: Vec<Segment> = Vec::new();
let mut current_prose = String::new();
let mut current_block: Option<(Form, String)> = None;
for line in input.split_inclusive('\n') {
let trimmed = line.trim_end_matches('\n');
// Inside a form-only block: collect until the closing marker.
if let Some((form, body)) = current_block.as_mut() {
if trimmed == "{/form-only}" {
let body_owned = std::mem::take(body);
out.push(Segment::FormOnly { form: *form, body: body_owned });
current_block = None;
} else {
body.push_str(line);
}
continue;
}
// {form-only: <name>}
if let Some(rest) = trimmed.strip_prefix("{form-only: ").and_then(|r| r.strip_suffix("}")) {
flush_prose(&mut current_prose, &mut out);
let form = Form::parse(rest)
.unwrap_or_else(|| panic!("unknown form name in directive: {}", rest));
current_block = Some((form, String::new()));
continue;
}
// {example: <id>}
if let Some(id) = trimmed.strip_prefix("{example: ").and_then(|r| r.strip_suffix("}")) {
flush_prose(&mut current_prose, &mut out);
out.push(Segment::Example(id.to_string()));
continue;
}
// Stray closer with no opener.
if trimmed == "{/form-only}" {
panic!("stray {{/form-only}} without matching opener");
}
// Plain prose line.
current_prose.push_str(line);
}
if current_block.is_some() {
panic!("unclosed form-only block at end of input");
}
flush_prose(&mut current_prose, &mut out);
out
}
fn flush_prose(buf: &mut String, out: &mut Vec<Segment>) {
if !buf.is_empty() {
out.push(Segment::Prose(std::mem::take(buf)));
}
}
```
- [ ] **Step 2.5: Run the tests, verify GREEN**
Run:
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml --test splitter_unit
```
Expected: `test result: ok. 7 passed; 0 failed`.
---
## Task 3: spec_completeness test (RED on empty examples/)
**Files:**
- Create: `experiments/2026-05-12-cross-model-authoring/render/tests/spec_completeness.rs`
This task lifts the AST-variant visitor from
`crates/ailang-core/tests/schema_coverage.rs` verbatim, adjusts the
`examples_dir()` resolver to point at `experiments/.../master/examples/`,
and runs the test to confirm it fails on the empty examples directory.
Task 4 will then add fixtures to drive it green.
- [ ] **Step 3.1: Copy the visitor verbatim from schema_coverage.rs**
Read `crates/ailang-core/tests/schema_coverage.rs` lines 1399 (the
entire file).
Copy the contents into a new file
`experiments/2026-05-12-cross-model-authoring/render/tests/spec_completeness.rs`
verbatim with one adjustment: replace the body of the `examples_dir()`
function (originally at source lines 324327) with:
```rust
fn examples_dir() -> std::path::PathBuf {
// CARGO_MANIFEST_DIR for this test is
// experiments/2026-05-12-cross-model-authoring/render/
// Master examples live one parent up + master/examples/.
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest.parent().unwrap().join("master").join("examples")
}
```
Rename the driver test from `every_ast_variant_is_observed_in_the_fixture_corpus`
to `every_ast_variant_is_observed_in_master_examples` so the failure
output names the right scope.
The file ends up structurally identical to schema_coverage.rs except
for `examples_dir()` and the test name. The visitor logic, the
`VariantTag` enum, the `EXPECTED_VARIANTS` constant, and the
`visit_*` functions are unchanged.
- [ ] **Step 3.2: Run the test, verify RED**
Run:
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml --test spec_completeness
```
Expected: the test fails. Failure mode is one of:
- "no fixtures found in `experiments/.../master/examples/`" if the
visitor pre-checks fixture count (verify behaviour against
`schema_coverage.rs:329344``list_json_fixtures` returns an empty
Vec without erroring, but the variant-observation set is empty so
the assertion at lines 380+ fires with "missing variants: …").
- "missing variants: <full list of 33 variants>" — the body fires
because `observed` is empty.
Either way: the test is RED. Record the exact failure message in the
implement journal so Task 4 can confirm the change in failure shape
once examples land.
---
## Task 4: master/examples/*.ail.json fixtures
**Files:**
- Create: `experiments/2026-05-12-cross-model-authoring/master/examples/<id>.ail.json` (multiple files; see Step 4.1 for the curated list)
The goal is the smallest set of curated, didactic `.ail.json` files
that together exercise all 33 AST variants the `EXPECTED_VARIANTS`
constant lists (5 Def + 13 Term + 4 Pattern + 5 Literal + 4 Type + 3
ParamMode — note the visitor counts 33 because `Module` itself is
implicit on every file). Each fixture must be a complete, well-formed
AILang module that loads cleanly via `ailang_core::load_module`.
**Common shape constraints** (apply to every fixture):
- Top-level JSON object with `"schema": "ailang/v0"`, `"name":
"<fixture_id>_module"`, `"imports": []` (unless explicitly noted),
`"defs": [...]`.
- Canonical key order — fields appear in the order the existing
workspace fixtures use (the implementer reads any one of
`examples/*.ail.json` for the precedent).
- Every `Def::Fn` carries mandatory `params`, `param_tys` (with
ParamMode), `ret_ty`, and `effects` per DESIGN.md §"Data model".
- The fixture must load cleanly via `ailang_core::load_module` (the
spec_completeness test calls it on every file).
- The fixture must roundtrip via `ailang_surface::print` →
`ailang_surface::parse` → canonical-byte equality (Task 5 enforces
this). If a fixture cannot roundtrip, the .ail.json embeds
something the surface printer cannot faithfully reproduce — fix
the fixture, not the test.
The implementer reads `crates/ailang-core/src/ast.rs` to confirm the
exact ParamMode variant names (the planner does not pin those
because the recon report did not enumerate them); the structural
template for each fixture's shape can be lifted from a small
existing fixture in `examples/` (e.g. `box.ail.json` for the
Data/TermCtor shape; `list_map_poly.ail.json` for Forall + Match;
the workspace has 136 candidates).
- [ ] **Step 4.1: Author `fn_returns_int.ail.json`**
Single fn `f : () -> Int = 42` (or pick a non-zero integer the
implementer prefers). Exercises: Module, Def::Fn, Term::Lit, Type::Con,
Literal::Int, one ParamMode tag on the (empty) param list — verify
the empty-params shape carries a ParamMode marker; if it doesn't,
fall to `Step 4.13`'s coverage for ParamMode.
- [ ] **Step 4.2: Author `fn_calls_prelude.ail.json`**
A fn that takes two Int parameters and returns their sum by calling
a prelude function (the implementer picks the actual symbol — likely
`int_add` or its current canonical name — by reading
`crates/ailang-check/src/lib.rs` or the prelude module). Exercises:
Term::App, Term::Var, a populated ParamMode set on the params.
- [ ] **Step 4.3: Author `fn_with_lambda.ail.json`**
A fn that returns a lambda value (e.g. `make_adder : (Int) ->
((Int) -> Int) = \x. \y. int_add(x, y)`). Exercises: Term::Lam,
Type::Fn as a return type.
- [ ] **Step 4.4: Author `fn_with_do_seq.ail.json`**
A fn `main : () -> () !IO` whose body is a `do { … }`-style sequence
of two `io_print_int` calls and a unit return. Exercises: Term::Do,
Term::Seq, the IO effect on the fn's effects set, Literal::Unit (if
that variant is the discriminator the visitor recognises; otherwise
it is covered by the unit return value).
- [ ] **Step 4.5: Author `data_simple.ail.json`**
A `data Box a where MkBox a` declaration plus a fn that constructs
a `Box Int` via `MkBox(1)`. Exercises: Def::Data, Term::TermCtor,
Type::Var in the ctor's argument position.
- [ ] **Step 4.6: Author `data_with_match.ail.json`**
A `data List a where Nil | Cons a (List a)` declaration plus a fn
that pattern-matches on a value: `match xs of Nil -> 0 | Cons _ _
-> 1`. Exercises: Term::Match with two arms, Pattern::Ctor (both
nullary `Nil` and binary `Cons`), Pattern::Wildcard (on the two
Cons-field positions).
- [ ] **Step 4.7: Author `match_literal_pattern.ail.json`**
A fn that matches an Int parameter against literal patterns:
`match n of 0 -> "zero" | _ -> "other"`. Exercises: Pattern::Lit and
Pattern::Wildcard. If the visitor treats variable patterns separately
from wildcard, extend one arm to `| n2 -> …` to also cover
Pattern::Var (if not already covered by Step 4.6).
- [ ] **Step 4.8: Author `forall_polymorphic.ail.json`**
A polymorphic identity fn: `fn id : forall a. (a) -> a = \x. x`.
Exercises: Type::Forall, Type::Var in the fn signature.
- [ ] **Step 4.9: Author `class_def.ail.json`**
A `class MyShow a where show : (a) -> Str` declaration. Exercises:
Def::Class, the class method's type signature.
- [ ] **Step 4.10: Author `instance_def.ail.json`**
An instance `instance MyShow Int where show = \x. "an Int"` (or the
real `MyShow` defined in Step 4.9; the implementer ensures the two
files agree on the class name). If AILang's instance form requires
a constraint on a polymorphic instance, add a fn-type with a
Constraint in this fixture; otherwise add a separate trivial fn
`use_eq : forall a. (Eq a) => (a, a) -> Bool = \x y. eq(x, y)` to
exercise the Constraint type-form. Exercises: Def::Instance, the
Constraint variant of Type.
- [ ] **Step 4.11: Author `floats.ail.json`**
A fn returning a specific Float literal: `fn pi : () -> Float =
3.14`. Exercises: Literal::Float, Type::Con (Float). Note: the
authored decimal is what the model writes; the toolchain handles
the bit-hex encoding internally (this is what the spec.md will
teach in Section 9 — see Task 6).
- [ ] **Step 4.12: Author `bool_str.ail.json`**
Two trivial fns: `is_true : () -> Bool = true` and `greeting : ()
-> Str = "hi"`. Exercises: Literal::Bool, Literal::Str.
- [ ] **Step 4.13: Author `param_modes_all.ail.json`**
Three fns, each with one parameter exercising one of the three
ParamMode variants. The implementer reads
`crates/ailang-core/src/ast.rs` to extract the exact mode names
(planner not pinning, recon did not enumerate). Each fn is trivial
(`fn f_<mode> : (Int) -> Int = \x. x`); the load-bearing thing is
the ParamMode tag on each parameter.
- [ ] **Step 4.14: Run spec_completeness, verify GREEN**
Run:
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml --test spec_completeness
```
Expected: `test result: ok. 1 passed; 0 failed`.
If specific variants are still missing, the failure message names
them. Extend the fixture closest in scope (e.g. add a Term::Seq use
to Step 4.4's fixture, add a new pattern arm to Step 4.6's fixture),
do not weaken the test. If a variant is *structurally unreachable*
through any fixture an LLM-author would produce, that is a real
finding worth flagging in the implement journal — but the audit-rt
journal records all 34 variants are observed by the existing
schema_coverage corpus, so the curated subset should reach them
too with one fixture each.
---
## Task 5: example_roundtrip test
**Files:**
- Create: `experiments/2026-05-12-cross-model-authoring/render/tests/example_roundtrip.rs`
This test mirrors `crates/ailang-surface/tests/round_trip.rs::round_trip_one`
for the subset of fixtures under `master/examples/`. It is the local
gate ensuring every example printed by the renderer is faithful.
- [ ] **Step 5.1: Write the roundtrip test**
Write `experiments/2026-05-12-cross-model-authoring/render/tests/example_roundtrip.rs`:
```rust
//! Roundtrip gate for master/examples/.
//!
//! Mirrors crates/ailang-surface/tests/round_trip.rs but scoped to
//! the master examples directory. For each .ail.json fixture: load,
//! print via ailang_surface::print, reparse, canonicalise both sides,
//! assert byte equality.
use std::path::{Path, PathBuf};
fn examples_dir() -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest.parent().unwrap().join("master").join("examples")
}
fn list_examples() -> Vec<PathBuf> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(examples_dir())
.expect("master/examples/ should exist")
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|s| s.to_str()) == Some("json"))
.filter(|path| {
path.file_name()
.and_then(|s| s.to_str())
.map(|name| name.ends_with(".ail.json"))
.unwrap_or(false)
})
.collect();
paths.sort();
paths
}
fn round_trip_one(path: &Path) -> Result<(), String> {
let original = ailang_core::load_module(path)
.map_err(|e| format!("{}: load_module failed: {e}", path.display()))?;
let text = ailang_surface::print(&original);
let parsed = ailang_surface::parse(&text)
.map_err(|e| format!("{}: surface::parse failed: {e}", path.display()))?;
let bytes_original = ailang_core::canonical::to_bytes(&original);
let bytes_parsed = ailang_core::canonical::to_bytes(&parsed);
if bytes_original != bytes_parsed {
return Err(format!(
"{}: roundtrip diverged.\n--- printed AILX ---\n{}\n--- original bytes len {} vs parsed bytes len {} ---",
path.display(),
text,
bytes_original.len(),
bytes_parsed.len(),
));
}
Ok(())
}
#[test]
fn every_example_roundtrips_via_ailx() {
let examples = list_examples();
assert!(
!examples.is_empty(),
"master/examples/ should contain at least one .ail.json fixture"
);
let mut failures: Vec<String> = Vec::new();
for path in &examples {
if let Err(msg) = round_trip_one(path) {
failures.push(msg);
}
}
if !failures.is_empty() {
panic!(
"{} of {} examples failed roundtrip:\n\n{}",
failures.len(),
examples.len(),
failures.join("\n\n")
);
}
}
```
- [ ] **Step 5.2: Run the roundtrip test, verify GREEN**
Run:
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml --test example_roundtrip
```
Expected: `test result: ok. 1 passed; 0 failed`.
If any fixture fails the roundtrip: that's a fixture authoring bug
(the .ail.json embeds something the surface printer can't faithfully
reproduce). Fix the fixture; do not weaken the test.
---
## Task 6: master/spec.md — the mini-spec content
**Files:**
- Create: `experiments/2026-05-12-cross-model-authoring/master/spec.md`
The mini-spec is the load-bearing artifact: this is what the model
sees as its system prompt. It must be vollumfänglich (cover every
authoring construct the model could possibly need) and
form-agnostic in the prose body. Form-specific scaffolding lives in
`{form-only: X}` blocks; code samples are `{example: id}` markers
resolving to fixtures from Task 4.
Token budget guideline: total per-form rendered length ~6k10k
tokens (well below the 28k DESIGN.md baseline; the rendering will
be measured in Task 8).
**Authoring rules common to every section:**
- Each section is 200600 words of prose plus example markers as
specified. The implementer writes the prose; the planner pins the
*structure* and what each section must cover.
- Any section with a `{form-only: json}` block must also carry a
`{form-only: ailx}` block of comparable length (the token-balance
test in Task 8 enforces the aggregate ±5%; per-section symmetry is
the easiest way to land there).
- Every `{example: <id>}` marker must reference a file authored in
Task 4. If the marker resolves to a missing fixture the renderer
fails at runtime in Step 7.3.
- The spec.md is content authoring; quality gates are Task 7's
end-to-end render, Task 8's token balance, and the orchestrator's
end-to-end read before any cma.3 live run.
- [ ] **Step 6.1: Write Section 1 — Header**
One-paragraph orientation: "AILang is a small data-as-source
functional language designed for an LLM author. This document is
the complete authoring reference. Every well-formed program is a
module." No form-only blocks. No example markers.
- [ ] **Step 6.2: Write Section 2 — Modules and the on-disk form**
Explain what a module is — a top-level container of `imports` plus
`defs`. Include one `{form-only: json}` block listing schema
requirements: `schema` field equal to `"ailang/v0"`, `name` field, the
`imports` array, the `defs` array, canonical key order. Include one
`{form-only: ailx}` block listing the grammar conventions:
parenthesised tagged-head form, the `(module NAME …)` opener, the
relationship between AILX surface and the on-disk JSON. Close with
`{example: fn_returns_int}` to anchor the simplest concrete module.
- [ ] **Step 6.3: Write Section 3 — Types**
Cover Type::Con (named base types like `Int`, `Bool`, `Str`,
`Float`), Type::Var (polymorphic type variables introduced by
`forall`), Type::Fn (function types with parameter list, return
type, and effect set), Type::Forall (universal quantification at
the fn signature). State that every fn signature is mandatory at
top level — no implicit return-type inference at the binding form.
Form-only blocks: JSON shape of each type variant, AILX surface form
of each. Close with `{example: forall_polymorphic}`.
- [ ] **Step 6.4: Write Section 4 — Mode annotations**
Cover the three ParamMode variants. The implementer reads
`crates/ailang-core/src/ast.rs` to extract the exact mode names
(planner not pinning — recon did not enumerate). State that mode is
mandatory on every fn parameter; the schema rejects unannotated
parameters. Form-only blocks: JSON shape of a fn's `param_tys`
field; AILX surface syntax for mode annotations. Close with
`{example: param_modes_all}`.
- [ ] **Step 6.5: Write Section 5 — Functions**
Cover Def::Fn shape: `name`, type signature, parameter list, body.
Walk through the term forms inside a function body: Term::Var
(referencing a binding), Term::App (function application),
Term::Lam (anonymous lambda), Term::Lit (literal value). Form-only
blocks: JSON shape of each Term variant; AILX surface for each.
Examples: `{example: fn_calls_prelude}` and `{example: fn_with_lambda}`.
- [ ] **Step 6.6: Write Section 6 — Algebraic data types**
Cover Def::Data (declaring a new ADT with its ctors), Term::TermCtor
(constructing a value of that ADT). Note that ctor names are
distinct from fn names. Form-only blocks: JSON shape of `Def::Data`
with `params` (type-var list) and `ctors` (ctor name + arg-type
list); AILX surface for `(data NAME (vars …) (ctor …) …)`. Close
with `{example: data_simple}`.
- [ ] **Step 6.7: Write Section 7 — Pattern matching**
Cover Term::Match and the four Pattern variants: Pattern::Ctor (a
constructor pattern with sub-patterns), Pattern::Var (binding the
matched value to a name), Pattern::Wildcard (`_`), Pattern::Lit
(matching against a literal value). Form-only blocks: JSON shape
of a `match` term with arms; AILX surface for `(match SCRUT (case
PAT BODY) …)`. Examples: `{example: data_with_match}` and
`{example: match_literal_pattern}`.
- [ ] **Step 6.8: Write Section 8 — Effects**
Cover the effect set on a fn type. The two currently wired effects
are `IO` (required to call `io_print_int` and similar) and
`Diverge` (for non-terminating programs). Term::Do introduces an
effectful sequence; Term::Seq is the sequence node. Form-only
blocks: JSON shape of the `effects` field on a fn type; AILX
surface for `!IO` annotations and `(do …)` blocks. Close with
`{example: fn_with_do_seq}`.
- [ ] **Step 6.9: Write Section 9 — Literals**
Cover the five literal variants. Literal::Int (decimal integer
syntax). Literal::Float — note that the model writes the decimal
form (`3.14`); the toolchain encodes the IEEE-754 bit pattern as a
16-lowercase-hex string on parse, per the Roundtrip Invariant. The
model never authors the hex encoding. Literal::Bool (`true` /
`false`). Literal::Str (double-quoted, ASCII-only by Decision 6
Constraint 3). Literal::Unit (`()`). Form-only blocks: JSON shape
of `Lit` variants; AILX surface for each. Examples:
`{example: floats}` and `{example: bool_str}`.
- [ ] **Step 6.10: Write Section 10 — Typeclasses**
Cover Def::Class (declaring a class with method signatures),
Def::Instance (providing an implementation of a class for a
specific type), the Constraint variant of Type (used in
constraint-polymorphic fn signatures like `forall a. (Eq a) =>
`). Mention the four prelude classes: `Eq`, `Ord`, `Num`,
`Bounded`. State that method invocation is by name; the compiler
monomorphises class methods at the call site. Form-only blocks:
JSON shape of class / instance / constraint; AILX surface for each.
Examples: `{example: class_def}` and `{example: instance_def}`.
- [ ] **Step 6.11: Write Section 11 — The prelude**
Tabulate the names the model can call without importing anything.
The implementer extracts the canonical list at the time of
authoring from the prelude module (likely under
`crates/ailang-check/src/prelude.rs` or `lib.rs`). One row per
function: name, type signature (form-agnostic in the prose; both
forms inside form-only blocks), one-line description. Minimum
content: integer arithmetic (`int_add`, `int_sub`, `int_mul`,
`int_div` or whatever the canonical names are), integer comparison
or its typeclass-routed equivalent (`Eq Int` / `Ord Int`), and
`io_print_int` (plus `io_print_str` only if it currently ships).
State explicitly that `int_to_str` is OUT — it is type-installed
but codegen-deferred pending the heap-Str ABI milestone (roadmap
P1 entry). No example marker (the table itself is the content).
- [ ] **Step 6.12: Write Section 12 — Content addressing**
Short paragraph: AILang has content-addressed identity (Decision
2). Hashes are computed by the toolchain on `ail parse` / `ail
check` from the canonical bytes of each definition. The author
writes no hash literal in either form; the JSON form has no `hash`
field; the AILX surface has no hash production. This is symmetric
across forms and removes hash as a form-distinguishing factor. No
example marker; no form-only blocks (because there is no form
asymmetry to teach).
- [ ] **Step 6.13: Write Section 13 — Out of scope + closing**
Short list of what this mini-spec does not teach: cross-module
imports (MVP tasks live in single modules; the `imports` array is
empty), refinement types (reserved in the schema but pass-through
in MVP), point-free style, operator overloading, syntactic sugar
of any kind. Reason: the four MVP tasks do not need them, and
including them would pad the spec.
Close the file with one paragraph: "When asked to write a module,
return the complete module text and nothing else. No markdown
fences. No prose explanation." This is the directive the harness
relies on to keep response parsing trivial.
---
## Task 7: Wire render/src/main.rs end-to-end
**Files:**
- Modify: `experiments/2026-05-12-cross-model-authoring/render/src/main.rs`
- Modify: `experiments/2026-05-12-cross-model-authoring/render/src/examples.rs`
The renderer assembles the two `rendered/*.md` files from
`master/spec.md` + `master/examples/`.
- [ ] **Step 7.1: Implement the example renderer module**
Replace `experiments/2026-05-12-cross-model-authoring/render/src/examples.rs`
content with:
```rust
//! Per-form rendering of a single master example.
//!
//! - JSON form: read the on-disk canonical bytes verbatim, emit a
//! ```json``` fenced block.
//! - AILX form: load the example via ailang_core::load_module, print
//! via ailang_surface::print, emit a ```ailx``` fenced block.
use anyhow::{anyhow, Context, Result};
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Form {
Json,
Ailx,
}
pub fn render_example(examples_dir: &Path, id: &str, form: Form) -> Result<String> {
let path = examples_dir.join(format!("{id}.ail.json"));
if !path.exists() {
return Err(anyhow!(
"example id `{id}` references missing fixture at {}",
path.display()
));
}
match form {
Form::Json => {
let bytes = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
Ok(format!("```json\n{}\n```\n", bytes.trim_end()))
}
Form::Ailx => {
let module = ailang_core::load_module(&path)
.with_context(|| format!("load_module on {}", path.display()))?;
let text = ailang_surface::print(&module);
Ok(format!("```ailx\n{}\n```\n", text.trim_end()))
}
}
}
```
- [ ] **Step 7.2: Implement the renderer in main.rs**
Replace `experiments/2026-05-12-cross-model-authoring/render/src/main.rs`
content with:
```rust
//! xmodel-render — projects master/spec.md into rendered/json.md and rendered/ailx.md.
use anyhow::{anyhow, Context, Result};
use std::path::{Path, PathBuf};
use xmodel_render::splitter::{self, Segment};
use xmodel_render::examples;
fn main() -> Result<()> {
let (master_dir, rendered_dir) = parse_args()?;
let spec_path = master_dir.join("spec.md");
let examples_dir = master_dir.join("examples");
let spec_text = std::fs::read_to_string(&spec_path)
.with_context(|| format!("reading {}", spec_path.display()))?;
let segments = splitter::split(&spec_text);
let json_out = render_for(&segments, &examples_dir, examples::Form::Json, splitter::Form::Json)?;
let ailx_out = render_for(&segments, &examples_dir, examples::Form::Ailx, splitter::Form::Ailx)?;
std::fs::create_dir_all(&rendered_dir)
.with_context(|| format!("creating {}", rendered_dir.display()))?;
let json_path = rendered_dir.join("json.md");
let ailx_path = rendered_dir.join("ailx.md");
std::fs::write(&json_path, json_out)
.with_context(|| format!("writing {}", json_path.display()))?;
std::fs::write(&ailx_path, ailx_out)
.with_context(|| format!("writing {}", ailx_path.display()))?;
eprintln!("xmodel-render: wrote {} and {}", json_path.display(), ailx_path.display());
Ok(())
}
fn render_for(
segments: &[Segment],
examples_dir: &Path,
example_form: examples::Form,
keep_form: splitter::Form,
) -> Result<String> {
let mut out = String::new();
for seg in segments {
match seg {
Segment::Prose(text) => out.push_str(text),
Segment::FormOnly { form, body } if *form == keep_form => out.push_str(body),
Segment::FormOnly { .. } => {} // drop the other form's block
Segment::Example(id) => {
let rendered = examples::render_example(examples_dir, id, example_form)?;
out.push_str(&rendered);
}
}
}
Ok(out)
}
fn parse_args() -> Result<(PathBuf, PathBuf)> {
let mut master: Option<PathBuf> = None;
let mut rendered: Option<PathBuf> = None;
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--master" => master = Some(PathBuf::from(args.next().ok_or_else(|| anyhow!("--master needs a value"))?)),
"--rendered" => rendered = Some(PathBuf::from(args.next().ok_or_else(|| anyhow!("--rendered needs a value"))?)),
other => return Err(anyhow!("unknown argument: {}", other)),
}
}
let master = master.ok_or_else(|| anyhow!("--master <dir> is required"))?;
let rendered = rendered.ok_or_else(|| anyhow!("--rendered <dir> is required"))?;
Ok((master, rendered))
}
```
Note the two `Form` enums: `splitter::Form` is the marker found in
`{form-only: X}`; `examples::Form` is the renderer-side mode for an
example. They are intentionally separate types (different semantics)
and the bin's `render_for` reconciles them. The reconciliation is
trivial because there are only two forms, but keeping the types
separate avoids accidentally feeding one to the other.
- [ ] **Step 7.3: Run the renderer**
Run:
```
cargo run --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml -- \
--master experiments/2026-05-12-cross-model-authoring/master \
--rendered experiments/2026-05-12-cross-model-authoring/rendered
```
Expected:
- exit code 0,
- stderr line "xmodel-render: wrote …json.md and …ailx.md",
- `experiments/2026-05-12-cross-model-authoring/rendered/json.md` exists, non-empty,
- `experiments/2026-05-12-cross-model-authoring/rendered/ailx.md` exists, non-empty,
- spot-check: open both files; JSON file's code blocks are
```json fences; AILX file's are ```ailx fences; the prose body is
the same in both.
If the renderer errors on a missing example id: that's a bug in
master/spec.md (a marker references an id with no fixture). Fix the
spec.md or add the fixture, re-run.
---
## Task 8: token_balance test
**Files:**
- Create: `experiments/2026-05-12-cross-model-authoring/render/tests/token_balance.rs`
The test runs the splitter against `master/spec.md`, collects all
`{form-only: json}` block bodies into one buffer and all
`{form-only: ailx}` block bodies into another, tokenises both with
`tiktoken-rs::cl100k_base`, and asserts the totals are within ±5%.
- [ ] **Step 8.1: Write the token-balance test**
Write `experiments/2026-05-12-cross-model-authoring/render/tests/token_balance.rs`:
```rust
//! Token-balance gate for master/spec.md form-only blocks.
//!
//! The mini-spec is the experimental treatment; if the JSON-cohort's
//! form-only scaffolding is meaningfully longer than the AILX-cohort's
//! (or vice versa), the experiment is biased before the model ever
//! sees the prompts. This test catches gross imbalance.
use std::path::PathBuf;
use xmodel_render::splitter::{self, Form, Segment};
fn master_dir() -> PathBuf {
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest.parent().unwrap().join("master")
}
#[test]
fn form_only_block_token_totals_within_five_percent() {
let spec_text = std::fs::read_to_string(master_dir().join("spec.md"))
.expect("master/spec.md must exist");
let segments = splitter::split(&spec_text);
let mut json_buf = String::new();
let mut ailx_buf = String::new();
for seg in &segments {
if let Segment::FormOnly { form, body } = seg {
match form {
Form::Json => json_buf.push_str(body),
Form::Ailx => ailx_buf.push_str(body),
}
}
}
let bpe = tiktoken_rs::cl100k_base().expect("cl100k_base tokenizer should load");
let json_count = bpe.encode_with_special_tokens(&json_buf).len();
let ailx_count = bpe.encode_with_special_tokens(&ailx_buf).len();
assert!(json_count > 0, "JSON-form blocks produced zero tokens");
assert!(ailx_count > 0, "AILX-form blocks produced zero tokens");
let max = json_count.max(ailx_count) as f64;
let diff = (json_count as i64 - ailx_count as i64).unsigned_abs() as f64;
let ratio = diff / max;
assert!(
ratio <= 0.05,
"form-only block token imbalance: json={json_count}, ailx={ailx_count}, ratio={ratio:.4} > 0.05",
);
}
```
- [ ] **Step 8.2: Run the test**
Run:
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml --test token_balance
```
Expected: `test result: ok. 1 passed; 0 failed`.
If the test fails: revise master/spec.md to lengthen the shorter
form's blocks (typically by adding examples to the shorter-form's
rules section or factually-equivalent prose). Do not pad with
filler — the rebalancing should be substantive (one form being
shorter usually means it's underexplained). Re-run.
---
## Task 9: Run renderer + commit outputs
**Files:**
- Modify (regenerate): `experiments/2026-05-12-cross-model-authoring/rendered/json.md`
- Modify (regenerate): `experiments/2026-05-12-cross-model-authoring/rendered/ailx.md`
- [ ] **Step 9.1: Regenerate rendered outputs**
Run:
```
cargo run --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml -- \
--master experiments/2026-05-12-cross-model-authoring/master \
--rendered experiments/2026-05-12-cross-model-authoring/rendered
```
Expected: exit 0, both rendered files written.
- [ ] **Step 9.2: Full test sweep**
Run:
```
cargo test --manifest-path experiments/2026-05-12-cross-model-authoring/render/Cargo.toml
```
Expected: all four test suites green —
`splitter_unit` (7 passed), `spec_completeness` (1 passed),
`example_roundtrip` (1 passed), `token_balance` (1 passed).
Total: 10 passed; 0 failed.
- [ ] **Step 9.3: Confirm working-tree state**
Run:
```
git status -- experiments/2026-05-12-cross-model-authoring/
```
Expected: the entire `experiments/2026-05-12-cross-model-authoring/`
tree is untracked. The Boss will inspect and decide commit shape at
iter close; the implement step leaves everything unstaged.
---
## Out of scope for cma.1 (mirror of spec)
- `master/tasks/*.task.json` and their reference solutions — cma.2.
- `harness/` Cargo project, the HTTP client, the per-task loop,
the location-stripping regex, scoring — cma.2.
- Live IONOS run, raw dataset emission, `runs/<date>-<hash>/` — cma.3.
- DESIGN.md §"Decision 6" empirical addendum — cma.3.
- Journal entry under `docs/journals/` summarising the run — cma.3.
- Roadmap edits removing the P2 entry — cma.3.