Files
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

46 KiB
Raw Permalink Blame History

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_moduleailang_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:

# 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:

[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:

//! 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:

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:

[lib]
name = "xmodel_render"
path = "src/lib.rs"

Create experiments/2026-05-12-cross-model-authoring/render/src/lib.rs:

//! 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):

//! 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:

//! 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:

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:329344list_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::printailang_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:

//! 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:

//! 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:

//! 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:

//! 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.