iter design-md-rolesplit.1 (DONE 9/9): DESIGN.md -> design/ ledger role-split

The 3020-line docs/DESIGN.md is replaced by the design/ ledger:
design/INDEX.md (sole addressable spine, typed Contracts+Models tables,
polymorphic links — prose file OR authoritative source //!), 14
design/contracts/*.md test-linked invariants + 3 source-link-only
contracts (mangling/env-construction/qualified-xref, no prose file —
code is SoT), 5 design/models/*.md whitepapers, and
docs/journals/2026-05-19-design-decision-records.md (the
relitigation-guard archive — every why/rejected/does-not-do/rollback/
empirical ### moved out at ###-granularity). Clean cut: git rm
docs/DESIGN.md, no stub.

RED-first crates/ailang-core/tests/design_index_pin.rs — the 4-clause
anti-regrowth spine (DESIGN.md-gone / every-INDEX-link-resolves /
every-contract-names-a-resolvable-ratifier /
contracts-carry-no-decision-record-prose) — demonstrably RED before,
GREEN after. Build-atomic by task ordering: design_schema_drift.rs's
include_str! (the only compile-time consumer) retargeted to
design/contracts/data-model.md BEFORE the deletion; its
## Data model/## Pipeline slicer dropped (a simplification the split
enables). 2 NoInstance diagnostics + 2 lockstep E2Es retargeted to
design/contracts/{float-semantics,typeclasses}.md. ~12 agent reading
lists + 5 SKILL bodies + CLAUDE.md + skills/README.md + ~25
code/C/.ail/spec comment xrefs retargeted; OQ7 dangling 'Iter 13b'
cite deleted (no forward target — a pointer would be fiction).
honesty-rule.md rewritten so the rule names the new home
(rationale->journals), resolving the recon-found internal
contradiction; the two docs_honesty_pin.rs:70,72 pinned phrases kept
verbatim+contiguous.

Boss-verified independently: cargo test --workspace 646 passed /
0 failed; design_index_pin 4/4; acceptance grep CLEAN of live
DESIGN.md refs (residuals = only the spec-mandated clause-4
deletion-enforcer). 2 DONE_WITH_CONCERNS routed to the mandatory
milestone-close audit: (a) str-abi.md:23 '(iter str-concat,
2026-05-13)' provenance stamp trips advisory architect_sweeps Sweep-1
— Boss-confirmed byte-identical to DESIGN.md@deeffb1:2062-2065, a
faithfully-migrated PRE-EXISTING anchor (regexes verbatim, only path
retargeted), NOT split-introduced — RATIFY-or-tidy at audit; (b) a
now stale-direction intra-prose 'see Str ABI below' cross-ref in
float-semantics.md — audit-adjudication candidate. Plan defect noted:
Task 9 Step 4's verbatim acceptance grep used a ^./ anchor not
matching the system's grep -rIn output; substance re-verified CLEAN.

Spec grounding-check PASS x2. Journals INDEX + decision-records
pointer appended (Boss-only).
This commit is contained in:
2026-05-19 13:04:22 +02:00
parent deeffb1872
commit 176821c2e7
76 changed files with 3707 additions and 3340 deletions
+25 -17
View File
@@ -42,7 +42,8 @@ it measurably improves correctness or removes redundancy.
| `runtime/` | C glue around the RC runtime |
| `bench/` | Regression harnesses (`check.py`, `compile_check.py`, `cross_lang.py`) and the throughput-and-latency runner (`run.sh`); `bench/reference/` holds the hand-C corpus for cross-language ratios |
| `examples/` | AILang fixtures used by tests and benches |
| `docs/` | Canonical specs and decisions log — `docs/DESIGN.md`, `docs/journals/` (per-iter journals + `INDEX.md`), `docs/journal-archive.md` (pre-2026-05-11 history), `docs/specs/` (per-milestone design specs), `docs/plans/` (per-iteration plans), `PROSE_ROUNDTRIP.md` |
| `design/` | The canonical contract ledger — `design/INDEX.md` (sole addressable spine: a typed Contracts + Models table), `design/contracts/` (prose-authoritative test-linked invariants), `design/models/` (onboarding whitepapers) |
| `docs/` | Specs and decisions log — `docs/journals/` (per-iter journals + `INDEX.md`; incl. the migrated design decision-records), `docs/journal-archive.md` (pre-2026-05-11 history), `docs/specs/` (per-milestone design specs), `docs/plans/` (per-iteration plans), `PROSE_ROUNDTRIP.md` |
| `skills/` | Project-local skill definitions and their agents. See `skills/README.md` for the skill table, agent roster, and discovery layout. |
## Skill system
@@ -74,8 +75,8 @@ See @skills/README.md for the skill + agent roster.
### What this means in practice
- **Plan, design, decide** — myself. Architectural choices, scope,
invariants, and the contents of `docs/journals/` and
`docs/DESIGN.md` are my work product.
invariants, and the contents of `docs/journals/` and the
`design/` ledger are my work product.
- **Implement, refactor, write tests, diagnose bugs** — by default,
delegated. `ailang-implementer` for code changes that follow a
fixed design, `ailang-tester` for E2E coverage, `ailang-debugger`
@@ -175,7 +176,7 @@ The test for whether a feature ships is whether an LLM author
naturally produces code that uses it AND whether the feature
measurably improves correctness or removes redundancy. Aesthetic
appeal does not count; neither does human ergonomics. Full criterion
lives in `docs/DESIGN.md` ("Feature-acceptance criterion") and is
lives in `design/contracts/feature-acceptance.md` and is
applied as a gate by `skills/brainstorm/SKILL.md` during spec writing.
## Bug fixes — TDD, always
@@ -196,20 +197,27 @@ Vocabulary note: legacy JOURNAL entries (pre-2026-05-09) use
"iter" / "family"; new entries use "iteration" / "milestone".
Existing entries are not retroactively renamed.
## Roles of `docs/DESIGN.md`, `docs/journals/`, `docs/journal-archive.md`, `docs/roadmap.md`, `docs/specs/`, `docs/plans/`
## Roles of the `design/` ledger, `docs/journals/`, `docs/journal-archive.md`, `docs/roadmap.md`, `docs/specs/`, `docs/plans/`
- **`docs/DESIGN.md`** is the canonical specification. It describes
what AILang *is*: schema, semantics, invariants, runtime contracts.
Every new feature must justify itself against `docs/DESIGN.md`
before it can ship; if the feature requires changes to
`docs/DESIGN.md`, those changes are part of the same iteration.
`docs/DESIGN.md` is also the artefact `ailang-architect` checks
the code against during drift review.
- **The `design/` ledger** is the canonical specification. It
describes what AILang *is*: schema, semantics, invariants, runtime
contracts. `design/INDEX.md` is the sole addressable spine — a
typed two-table ledger; `design/contracts/` holds the
prose-authoritative, test-linked invariants; `design/models/`
holds the onboarding whitepapers. Every new feature must justify
itself against the relevant contract before it can ship; if the
feature requires changes to a contract, those changes are part of
the same iteration. The `design/` ledger is also the artefact
`ailang-architect` checks the code against during drift review.
The current-state-mirror discipline is unchanged, just re-homed:
a contract describes only the actual present state; forward intent
goes to `docs/roadmap.md`, history and rationale to
`docs/journals/` (see `design/contracts/honesty-rule.md`).
- **`docs/journals/<YYYY-MM-DD>-iter-<id>.md`** is the decisions
log, one file per iter. Each file records *why* the iter moved
the way it did — alternatives considered and rejected, lessons,
rationale that does not belong in `docs/DESIGN.md`. Append-only
rationale that does not belong in the `design/` ledger. Append-only
per file; new files are appended via `docs/journals/INDEX.md`.
`docs/journals/INDEX.md` is the chronological pointer list,
orchestrator-maintained, one line per iter.
@@ -242,7 +250,7 @@ Existing entries are not retroactively renamed.
rules (in particular: no internals, lead with the change).
Together these answer three questions: "what is the language right
now?" (DESIGN), "how did we get here?" (per-iter journals, specs,
plans), and "what's next?" (roadmap). `WhatsNew.md` is the
user-facing mirror of the journals — the same history, told without
internals.
now?" (the `design/` ledger), "how did we get here?" (per-iter
journals, specs, plans), and "what's next?" (roadmap).
`WhatsNew.md` is the user-facing mirror of the journals — the same
history, told without internals.
+3 -3
View File
@@ -1,7 +1,7 @@
//! Lean embedding of an AILang M3-frozen staticlib kernel into a
//! Rust host. The Rust analogue of the audited C host
//! `crates/ail/tests/embed/tick_roundtrip.c`: same frozen value
//! layout (DESIGN.md §"Frozen value layout", :2322-2360), same
//! layout (design/contracts/frozen-value-layout.md), same
//! `own`-mode contract. ZERO `data_server`/finance knowledge.
use std::ffi::c_void;
@@ -32,7 +32,7 @@ impl Ctx {
Self(unsafe { ailang_ctx_new() })
}
/// `State` box, frozen layout (DESIGN.md :2328-2336):
/// `State` box, frozen layout (design/contracts/frozen-value-layout.md):
/// payload `8 + 2*8` = 24 bytes — tag:i64@0, acc:f64@8, n:i64@16.
/// `ailang_rc_alloc` sets the `p-8` rc header to 1 and zeroes the
/// payload, so the tag write of 0 is the single-ctor tag.
@@ -88,7 +88,7 @@ impl Kernel {
}
/// Folds `prices` via the kernel and returns `(Σ px, count)`.
/// `own` discipline (DESIGN.md :2346-2352): each `Tick` is freshly
/// `own` discipline (design/contracts/frozen-value-layout.md): each `Tick` is freshly
/// `ailang_rc_alloc`'d and consumed by the kernel; `st` is
/// consumed and replaced by the return each step; only the final
/// return is host-`dec`'d.
+13 -10
View File
@@ -1,14 +1,16 @@
#!/usr/bin/env bash
# bench/architect_sweeps.sh
#
# Run the five DESIGN.md honesty/anchor sweep-invariant greps against
# docs/DESIGN.md. Output: matched lines, prefixed by sweep name. Exit 0
# if all five sweeps are clean, 1 if any anchor was found. Sweeps 1-4
# are the design-md-consolidation history-anchor invariants; Sweep 5
# is the docs-honesty-lint Wunschdenken / post-mortem invariant.
# Run the five honesty/anchor sweep-invariant greps against the
# design/ prose set (design/contracts + design/models); the spine is
# design/INDEX.md. Output: matched lines, prefixed by sweep name.
# Exit 0 if all five sweeps are clean, 1 if any anchor was found,
# 2 if the spine (design/INDEX.md) is not found. Sweeps 1-4 are the
# history-anchor invariants; Sweep 5 is the docs-honesty-lint
# Wunschdenken / post-mortem invariant.
#
# Companion to skills/audit/agents/ailang-architect.md "What you check
# / DESIGN.md history-anchor regrowth". See JOURNAL 2026-05-10
# / design/ history-anchor regrowth". See JOURNAL 2026-05-10
# ("Milestone close: design-md-consolidation") for the rationale of
# each sweep regex; the regexes here are copied verbatim from JOURNAL
# entries 12487 (Sweep 1), 12619 (Sweep 2), 12718-12720 (Sweep 3),
@@ -19,11 +21,12 @@
# (e.g. a JOURNAL citation inside a block-quote) or fresh drift.
set -u
DESIGN="docs/DESIGN.md"
INDEX="design/INDEX.md"
DESIGN_GLOB="design/contracts design/models"
HITS=0
if [[ ! -f "$DESIGN" ]]; then
echo "architect_sweeps: $DESIGN not found (run from repo root)" >&2
if [[ ! -f "$INDEX" ]]; then
echo "architect_sweeps: $INDEX not found (run from repo root)" >&2
exit 2
fi
@@ -31,7 +34,7 @@ run_sweep() {
local name="$1"
local pattern="$2"
local matches
matches=$(grep -nE "$pattern" "$DESIGN" || true)
matches=$(grep -rnE "$pattern" $DESIGN_GLOB || true)
if [[ -n "$matches" ]]; then
echo "=== Sweep: $name ==="
echo "$matches"
@@ -0,0 +1,24 @@
{
"iter_id": "design-md-rolesplit.1",
"date": "2026-05-19",
"mode": "standard",
"outcome": "DONE",
"tasks_total": 9,
"tasks_completed": 9,
"reloops_per_task": { "1": 0, "2": 1, "3": 0, "4": 0, "5": 0, "6": 0, "7": 1, "8": 0, "9": 1 },
"review_loops_spec": 0,
"review_loops_quality": 3,
"blocked_reason": null,
"notes": {
"task_2_reloop": "plan/spec internal contradiction repaired (honesty-rule.md plan-verbatim text tripped the plan-verbatim clause-3 marker 'an earlier draft'); + INDEX link-cell path form corrected for the authoritative pin",
"task_7_concern": "architect_sweeps exits 1 not 0 on one pre-existing advisory Sweep-1 date-stamp faithfully migrated verbatim (DONE_WITH_CONCERNS, audit-adjudicated)",
"task_9_concern": "acceptance-grep plan-defect: ^\\./ anchor doesn't match grep output format; spec-criterion-8-faithful filter is CLEAN; 3 residuals are the spec-mandated clause-4 pin",
"boss_action_required": [
"append journals INDEX pointer for the decision-records file (Task 4 Step 2 — Boss-only INDEX rule overrides plan instruction)",
"append this iter's own journals INDEX pointer per standard procedure"
],
"design_index_pin_red_to_green": true,
"build_atomic_verified": true,
"whole_suite_failures": 0
}
}
+3 -2
View File
@@ -1390,7 +1390,8 @@ fn load_workspace_human(
///
/// Cross-module references (`<prefix>.<def>`) are always kept — a local
/// binding can never shadow a dotted name (the typechecker rejects defs
/// with a dot, see DESIGN.md "qualified cross-module references").
/// with a dot; qualified cross-module references are code-SoT — see
/// the `qualified-xref` row in design/INDEX.md).
fn collect_refs(def: &ailang_core::Def) -> std::collections::BTreeSet<String> {
let mut out = std::collections::BTreeSet::new();
let builtins: std::collections::HashSet<&'static str> =
@@ -1569,7 +1570,7 @@ fn walk_term(
/// Adds the variable bindings introduced by a pattern to `scope` and
/// returns the names that were freshly inserted (so the caller can roll
/// them back). MVP-restricted: nested ctor patterns only contain
/// `Var`/`Wild` — see DESIGN.md.
/// `Var`/`Wild` — see design/contracts/data-model.md.
fn bind_pattern(
p: &ailang_core::ast::Pattern,
scope: &mut std::collections::HashSet<String>,
@@ -1,6 +1,6 @@
//! Pin for the iter-24.3 codegen `import_map`-fallback path
//! (DESIGN.md §"Cross-module references in synthesised bodies"
//! invariant 2).
//! (design/contracts/typeclasses.md, "Cross-module references in
//! synthesised bodies" invariant 2).
//!
//! Property protected: post-mono synthesised body cross-module
//! references resolve at codegen via the fallback to
+3 -3
View File
@@ -2840,9 +2840,9 @@ fn str_clone_drop_balances_rc_stats() {
/// static-Str input (literal `"abc"`). Both clones print their
/// input bytes; RC stats account for three heap-Str slabs (one
/// from int_to_str, two from str_clone) and three matching frees.
/// Pins the "uniform consumer ABI" claim from DESIGN.md §"Str
/// ABI" — str_clone only reads len + bytes + NUL, never the
/// rc_header.
/// Pins the "uniform consumer ABI" claim from
/// design/contracts/str-abi.md — str_clone only reads len + bytes +
/// NUL, never the rc_header.
#[test]
fn str_clone_cross_realisation_uniform_abi() {
let (stdout, allocs, frees, live) =
+2 -2
View File
@@ -1,6 +1,6 @@
/* Embedding-ABI M3 record round-trip C host (own + borrow via a
* compile-time -DBORROW switch). Frozen value layout (DESIGN.md
* §"Embedding ABI" > "Frozen value layout"):
* compile-time -DBORROW switch). Frozen value layout
* (design/contracts/frozen-value-layout.md):
* p - 8 .. p uint64_t refcount header (set to 1 by ailang_rc_alloc)
* p + 0 int64_t constructor tag (single ctor → 0)
* p + 8 IEEE-754 double field 0 = Float acc
+1 -1
View File
@@ -3,7 +3,7 @@
* only ever push a *scalar* per-tick sample; this host pushes a
* single-ctor all-scalar *record* `Tick` as the per-call payload —
* the actual minimal data-server binding shape. Frozen value layout
* (DESIGN.md §"Embedding ABI" > "Frozen value layout"):
* (design/contracts/frozen-value-layout.md):
*
* State (24-byte payload):
* p - 8 .. p uint64_t refcount header (set to 1 by ailang_rc_alloc)
+6 -6
View File
@@ -2,8 +2,8 @@
//!
//! Property protected: a polymorphic Eq/Ord helper invoked at Float
//! fires `no-instance` (since Float has neither Eq nor Ord instance
//! per DESIGN.md §"Float semantics") AND the diagnostic message
//! cross-references the canonical Float-semantics section so the
//! per design/contracts/float-semantics.md) AND the diagnostic message
//! cross-references the canonical float-semantics contract so the
//! LLM author immediately learns the partial-Float story rather
//! than seeing a bare "no instance" message.
@@ -35,11 +35,11 @@ fn eq_at_float_fires_float_aware_noinstance() {
no_inst.message
);
// Cross-ref to DESIGN.md §Float semantics — the LLM author should be
// pointed at the canonical explanation of partial Float orderability.
// Cross-ref to the float-semantics contract — the LLM author should
// be pointed at the canonical explanation of partial Float orderability.
assert!(
no_inst.message.contains("Float semantics") || no_inst.message.contains("DESIGN"),
"expected NoInstance message to cross-reference DESIGN.md §Float semantics, got: {:?}",
no_inst.message.contains("design/contracts/float-semantics.md"),
"expected NoInstance message to cross-reference the float-semantics contract, got: {:?}",
no_inst.message
);
}
+7 -4
View File
@@ -86,8 +86,10 @@ fn eq_ord_user_adt_eq_intbox_hash_stable() {
let mono = monomorphise_workspace(&ws).expect("mono");
// The mono symbol for `eq` at a user-defined ADT uses the
// `<method>__<8-hex-prefix>` mangling per DESIGN.md
// §"Mangling scheme" (primitives use the bare type name; compound
// `<method>__<8-hex-prefix>` mangling (mangling is code-SoT —
// see the `mangling` row in design/INDEX.md / the
// crates/ailang-codegen/src/lib.rs //! header) (primitives use
// the bare type name; compound
// / user-defined types use the 8-hex BLAKE3 prefix of the
// canonical type bytes). We look up the single `eq__` symbol
// synthesised at `IntBox` — there is only one user instance,
@@ -107,8 +109,9 @@ fn eq_ord_user_adt_eq_intbox_hash_stable() {
.expect("eq__<IntBox-hash> not synthesised");
// The 8-hex-prefix in the mono-symbol name is the BLAKE3 prefix
// of the canonical IntBox type bytes (DESIGN.md §"Mangling
// scheme"). Drift here means either the canonical-form encoding
// of the canonical IntBox type bytes (mangling is code-SoT — see
// the `mangling` row in design/INDEX.md). Drift here means either
// the canonical-form encoding
// changed or the type's canonical bytes changed — both warrant
// investigation before re-pinning.
assert_eq!(
@@ -1,6 +1,6 @@
//! Pin for the iter-24.3 FreeFnCall constraint-residual-push
//! invariant (DESIGN.md §"Cross-module references in synthesised
//! bodies" invariant 3).
//! invariant (design/contracts/typeclasses.md, "Cross-module
//! references in synthesised bodies" invariant 3).
//!
//! Property protected: bare-name references to polymorphic free fns
//! that resolve through the auto-injected-prelude path AT THE
+5 -6
View File
@@ -2,7 +2,7 @@
//!
//! Property protected: calling `print` on a function type fires the
//! `no-instance` diagnostic with a Show-aware message that
//! cross-references DESIGN.md §"Prelude (built-in) classes", so the
//! cross-references design/contracts/typeclasses.md, so the
//! LLM author immediately learns which types ship with built-in Show
//! and how to declare their own instance for a user type.
@@ -40,12 +40,11 @@ fn print_on_fn_type_fires_show_aware_no_instance() {
no_inst.message
);
// Must cross-reference DESIGN.md §"Prelude (built-in) classes"
// verbatim — that's the canonical anchor naming which types
// ship with Show.
// Must cross-reference design/contracts/typeclasses.md — that's
// the canonical contract naming which types ship with Show.
assert!(
no_inst.message.contains("Prelude (built-in) classes"),
"expected DESIGN.md §Prelude (built-in) classes cross-reference, got message: {:?}",
no_inst.message.contains("design/contracts/typeclasses.md"),
"expected design/contracts/typeclasses.md cross-reference, got message: {:?}",
no_inst.message
);
}
+11 -11
View File
@@ -446,7 +446,7 @@ pub enum CheckError {
/// An `(export …)` fn has a parameter or return type that is not
/// a permitted embedding-ABI type. The embedding ABI is frozen as
/// of M3 (DESIGN.md §"Embedding ABI" > "Frozen value layout"):
/// of M3 (design/contracts/embedding-abi.md):
/// `Int`/`Float` or a single-constructor record of those; every
/// other shape (multi-ctor sums, `Str`/`List`/nested-record
/// fields) is rejected at typecheck (the feature-acceptance
@@ -858,7 +858,7 @@ impl CheckError {
}
// Iter 23.5: Float-aware addendum on `NoInstance`. When the
// unresolved class is `Eq` or `Ord` AND the type is `Float`,
// append a cross-reference to DESIGN.md §"Float semantics" so
// append a cross-reference to design/contracts/float-semantics.md so
// the LLM author learns the partial-Float story immediately
// rather than seeing a bare "no instance" message. Float has
// neither Eq nor Ord by design (partial orderability per
@@ -871,7 +871,7 @@ impl CheckError {
if (class == "prelude.Eq" || class == "prelude.Ord") && at_type == "Float" {
d.message = format!(
"{} — Float has no Eq/Ord instance by design (partial \
orderability per IEEE-754); see DESIGN.md §\"Float semantics\".",
orderability per IEEE-754); see design/contracts/float-semantics.md.",
d.message
);
} else if class == "prelude.Show" {
@@ -879,14 +879,14 @@ impl CheckError {
// for any type lacking a Show instance — most commonly
// a function type (`print f` where f is bare-fn-typed)
// or a user type the LLM-author forgot to give an
// instance for. Cross-references DESIGN.md §"Prelude
// (built-in) classes" so the author learns which types
// ship with built-in Show immediately.
// instance for. Cross-references
// design/contracts/typeclasses.md so the author learns
// which types ship with built-in Show immediately.
d.message = format!(
"{} — `print` and `show` require a Show instance. \
Built-in Show ships for Int, Bool, Str, Float in \
the prelude; see DESIGN.md §\"Prelude (built-in) \
classes\". User types declare their own \
the prelude; see design/contracts/typeclasses.md. \
User types declare their own \
`instance prelude.Show <T>` in the type's defining \
module per Decision 11 coherence.",
d.message
@@ -2644,7 +2644,7 @@ fn constraint_type_matches(declared: &Type, residual: &Type) -> bool {
/// Decision 8.
///
/// `is_tail` is the tail-context flag for the term currently being
/// visited. The propagation rules (from DESIGN.md Decision 8):
/// visited. The propagation rules (from design/contracts/tail-calls.md):
///
/// - The body of a fn / Lam is visited with `is_tail = true`.
/// - `Match { scrutinee, arms }`: the scrutinee is **not** in tail
@@ -6333,8 +6333,8 @@ mod tests {
/// Floats milestone post-fieldtest B1 (RED): pattern-matching on a
/// `Literal::Float` literal must be hard-rejected at typecheck via
/// `CheckError::FloatPatternNotAllowed` per DESIGN.md §"Float
/// semantics" — even when the match arm reaches `check` through
/// `CheckError::FloatPatternNotAllowed` per
/// design/contracts/float-semantics.md — even when the match arm reaches `check` through
/// the *full* check pipeline (which runs `desugar_module` first).
///
/// The existing `reject_float_pattern_in_match` test in
@@ -1,8 +1,11 @@
//! Pin for `CheckError::DuplicateCtor` — same-named ctors in two
//! different ADTs within one module. Ratifies the load-bearing
//! consumer of the per-module `env.ctor_index` rebuild in
//! `check_in_workspace` that DESIGN.md §"Env construction" rests
//! on as the asymmetry-with-mono-side argument. If the
//! `check_in_workspace`. Env construction is code-SoT (the
//! `env-construction` ledger row in design/INDEX.md is source-link
//! only); the why-two-overlays rationale this test rests on as the
//! asymmetry-with-mono-side argument lives in
//! docs/journals/2026-05-19-design-decision-records.md. If the
//! per-module rebuild is ever removed without an equivalent
//! replacement path, this test goes red.
+2 -2
View File
@@ -85,7 +85,7 @@ impl<'a> Emitter<'a> {
out.push_str(" %is_null = icmp eq ptr %p, null\n");
out.push_str(" br i1 %is_null, label %ret, label %live\n");
out.push_str("live:\n");
// FROZEN ABI (embedding boundary) — see DESIGN.md §"Embedding ABI".
// FROZEN ABI (embedding boundary) — see design/contracts/embedding-abi.md.
out.push_str(" %tag = load i64, ptr %p, align 8\n");
let n_ctors = td.ctors.len();
@@ -111,7 +111,7 @@ impl<'a> Emitter<'a> {
if lty != "ptr" {
continue;
}
// FROZEN ABI (embedding boundary) — see DESIGN.md §"Embedding ABI".
// FROZEN ABI (embedding boundary) — see design/contracts/embedding-abi.md.
let off = 8 + (j as i64) * 8;
let addr_id = local;
local += 1;
+8 -7
View File
@@ -99,9 +99,10 @@ pub enum CodegenError {
/// `llvm_type` was asked to lower an AILang [`Type`] it does not
/// know how to represent. In the MVP this fires for an unresolved
/// rigid `Type::Var` reaching codegen (a substitution bug; see
/// Iter 13b notes in `DESIGN.md`) or for any non-`Con`/`Fn`/`Var`
/// shape that has not yet been wired through.
/// rigid `Type::Var` reaching codegen (a substitution bug: a
/// parameterised-ADT type argument that monomorphisation should
/// have substituted to a concrete type before codegen) or for any
/// non-`Con`/`Fn`/`Var` shape that has not yet been wired through.
#[error("unsupported type: {0}")]
UnsupportedType(String),
@@ -175,8 +176,8 @@ pub enum Target {
/// a mandatory leading `ptr %ctx` (per-thread embedding context)
/// published via `@__ail_tls_ctx` around the unchanged internal
/// call. The ctx-threaded C signature is the M2 shape; the
/// value/record layout is frozen as of M3 (DESIGN.md §"Embedding
/// ABI" > "Frozen value layout").
/// value/record layout is frozen as of M3
/// (design/contracts/frozen-value-layout.md).
StaticLib,
}
@@ -682,8 +683,8 @@ fn llvm_scalar(t: &Type) -> &'static str {
Type::Con { name, .. } if name == "Int" => "i64",
// M3: any other Type::Con reaching an (export) signature is,
// by the Task-2 export gate, a single-ctor scalar record;
// it crosses the C ABI as a bare `ptr` (DESIGN.md §"Embedding
// ABI" frozen layout — payload pointer).
// it crosses the C ABI as a bare `ptr`
// (design/contracts/frozen-value-layout.md — payload pointer).
Type::Con { .. } => "ptr",
_ => "i64",
}
+2 -2
View File
@@ -104,8 +104,8 @@ impl<'a> Emitter<'a> {
}
compiled.push((v, vty));
}
// FROZEN ABI (embedding boundary) — DESIGN.md §"Embedding ABI"
// > "Frozen value layout". size = 8 + n*8, tag@0, fields@8+i*8.
// FROZEN ABI (embedding boundary) —
// design/contracts/frozen-value-layout.md. size = 8 + n*8, tag@0, fields@8+i*8.
let size_bytes = 8 + (compiled.len() * 8) as i64;
let p = self.fresh_ssa();
// Iter 17a: pick allocator based on escape analysis. `alloca`
@@ -1,4 +1,4 @@
//! FROZEN ABI byte-pin — see DESIGN.md §"Embedding ABI" frozen layout.
//! FROZEN ABI byte-pin — see design/contracts/frozen-value-layout.md.
//! Pins the heap (@ailang_rc_alloc) box layout a boundary-crossing
//! single-ctor scalar record takes: size = 8 + n*8, tag i64 @ offset
//! 0, fields i64-strided from offset 8. Goes RED if codegen moves an
@@ -44,8 +44,8 @@ fn staticlib_emits_forwarder_no_main() {
}
/// M3: a single-ctor scalar record param/ret crosses the embedding
/// C boundary as a bare `ptr` (the frozen payload pointer — DESIGN.md
/// §"Embedding ABI" > "Frozen value layout"). The M2 forwarder body
/// C boundary as a bare `ptr` (the frozen payload pointer —
/// design/contracts/frozen-value-layout.md). The M2 forwarder body
/// is byte-unchanged: leading `ptr %ctx`, TLS save/store/restore, the
/// internal `@ail_<module>_<fn>` call carrying no ctx arg and no
/// aggregate calling convention (no `sret`/`byval` — a record is a
+1 -1
View File
@@ -86,7 +86,7 @@ externally-callable entrypoint (Embedding ABI M1). The symbol is
author-chosen and decoupled from the internal `ail_<module>_<def>`
mangling. M1 requires the fn's parameter and return types to be
scalar (`Int`/`Float`) and its effect set to be empty — see
DESIGN.md §"Embedding ABI".
design/contracts/embedding-abi.md.
`suppress` clauses silence advisory diagnostics for this def. The
`code` MUST be one of the registered codes (today: `over-strict-mode`).
+3 -3
View File
@@ -1,6 +1,6 @@
//! AST nodes for the AILang language.
//!
//! **The canonical schema lives in `docs/DESIGN.md` §"Data model"**;
//! **The canonical schema lives in `design/contracts/data-model.md`**;
//! this module is the Rust-side projection of it. When the two drift,
//! `crates/ailang-core/tests/design_schema_drift.rs` fires.
//!
@@ -208,8 +208,8 @@ pub struct FnDef {
/// Iter embedding-abi-m1: when `Some(sym)`, this fn is the
/// embedding boundary. Codegen's `Target::StaticLib` mode
/// additionally emits an externally-visible C entrypoint
/// `@<sym>` (signature frozen as of M3 — DESIGN.md §"Embedding
/// ABI" > "Frozen value layout") forwarding
/// `@<sym>` (signature frozen as of M3 —
/// design/contracts/embedding-abi.md) forwarding
/// to `@ail_<module>_<fn>`. The symbol is author-chosen and
/// decoupled from the `ail_<module>_<def>` mangling so the
/// M3-frozen ABI survives module/fn refactors.
@@ -0,0 +1,157 @@
//! Structural pin for the design/ ledger. Sibling of
//! docs_honesty_pin.rs. Fails RED the instant the design/ split
//! re-conflates contract + rationale + narrative, an INDEX row
//! dangles, a contract loses its ratifying test, or docs/DESIGN.md
//! is resurrected. Spec: docs/specs/2026-05-19-design-md-rolesplit.md.
use std::fs;
use std::path::PathBuf;
fn root() -> PathBuf {
PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../"))
}
fn read(rel: &str) -> String {
fs::read_to_string(root().join(rel))
.unwrap_or_else(|e| panic!("read {rel}: {e}"))
}
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Parse the two pipe-tables out of design/INDEX.md.
/// Returns (contracts, models) as rows of trimmed cells.
fn index_tables() -> (Vec<Vec<String>>, Vec<Vec<String>>) {
let idx = read("design/INDEX.md");
let mut contracts = Vec::new();
let mut models = Vec::new();
let mut section = "";
for line in idx.lines() {
let t = line.trim();
if t.starts_with("## Contracts") {
section = "c";
continue;
}
if t.starts_with("## Models") {
section = "m";
continue;
}
if !t.starts_with('|') {
continue;
}
let cells: Vec<String> = t
.trim_matches('|')
.split('|')
.map(|c| c.trim().to_string())
.collect();
// skip header + separator rows
if cells.iter().any(|c| c.starts_with("---")) {
continue;
}
if cells.first().map(|c| c.as_str()) == Some("id") {
continue;
}
match section {
"c" => contracts.push(cells),
"m" => models.push(cells),
_ => {}
}
}
(contracts, models)
}
/// A link cell may be a design/ path, a source path, a dual-link
/// "A + B", or carry a trailing "(in-source ...)" / "§..." note.
/// Resolve to the first concrete path token and check it exists.
fn link_target_exists(cell: &str) -> bool {
let first = cell.split(" + ").next().unwrap_or(cell).trim();
// strip a trailing parenthetical or §-note
let path = first
.split(" (")
.next()
.unwrap_or(first)
.split(" §")
.next()
.unwrap_or(first)
.trim()
.trim_end_matches("//!")
.trim();
!path.is_empty() && root().join(path).exists()
}
#[test]
fn design_md_is_gone() {
// clause 4 — clean-cut pin
assert!(
!root().join("docs/DESIGN.md").exists(),
"docs/DESIGN.md was resurrected; the split is clean-cut"
);
}
#[test]
fn every_index_link_resolves() {
// clause 1
let (contracts, models) = index_tables();
assert!(contracts.len() >= 15, "expected >=15 contract rows, got {}", contracts.len());
assert!(models.len() >= 5, "expected >=5 model rows, got {}", models.len());
for row in contracts.iter().chain(models.iter()) {
let link = row.last().expect("row has a link cell");
assert!(
link_target_exists(link),
"INDEX link does not resolve: {:?} (row {:?})",
link,
row
);
}
}
#[test]
fn every_contract_names_a_resolvable_ratifying_test() {
// clause 2 — ratifying-test token resolves to a real file under
// crates/**/tests, crates/**/src (in-source #[cfg(test)] mod
// tests are first-class ratifiers — spec OQ1/OQ2), bench/, or
// skills/**/SKILL.md.
let (contracts, _) = index_tables();
for row in &contracts {
// columns: id | consumer/lifetime | ratifying-test | link
let rt = &row[2];
// take the path token (before any " (" note)
let path = rt.split(" (").next().unwrap_or(rt).trim();
assert!(
root().join(path).exists(),
"ratifying-test does not resolve to a real file: {:?} (contract {:?})",
path,
row[0]
);
}
}
#[test]
fn contracts_carry_no_decision_record_prose() {
// clause 3 — the conflation tripwire.
let dir = root().join("design/contracts");
let markers = [
"we rejected",
"an earlier draft",
"Why not other",
"was retired in iter",
"rollback plan",
"previously all",
];
for entry in fs::read_dir(&dir).expect("design/contracts/ exists") {
let p = entry.unwrap().path();
if p.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let body = norm(&fs::read_to_string(&p).unwrap());
for m in &markers {
assert!(
!body.contains(m),
"decision-record marker {:?} found in contract {:?} — re-conflation",
m,
p.file_name().unwrap()
);
}
}
}
+33 -74
View File
@@ -1,43 +1,28 @@
//! Drift detection between ast.rs and `docs/DESIGN.md` §"Data model".
//! Drift detection between ast.rs and `design/contracts/data-model.md`.
//!
//! DESIGN.md is the canonical schema source-of-truth. Every AST enum
//! (`Term`, `Pattern`, `Type`, `Def`, `Literal`, `ParamMode`) must have a
//! JSON-schema anchor (e.g. `"t": "lit"`, `"k": "fn"`) present in that
//! document. These tests enforce the property.
//! `design/contracts/data-model.md` is the canonical schema
//! source-of-truth. Every AST enum (`Term`, `Pattern`, `Type`, `Def`,
//! `Literal`, `ParamMode`) must have a JSON-schema anchor (e.g.
//! `"t": "lit"`, `"k": "fn"`) present in that document. These tests
//! enforce the property.
//!
//! The exhaustive `match` per enum is the load-bearing mechanism: adding a
//! new variant without a matching arm fails compilation before the test runs.
//! Once the variant is matched, the test asserts the anchor is present in
//! §"Data model" specifically — not anywhere in DESIGN.md. The narrow scope
//! is load-bearing: before this tightening, the test scanned the whole
//! document, so an anchor that appeared only in §"Decision 11" (or any
//! other discussion section) was treated as "present" even though
//! §"Data model" — the canonical schema reference — was missing it.
//! `design/contracts/data-model.md`. The whole file is the data-model
//! contract, so the file boundary now bounds the section — the former
//! `## Data model` … `## Pipeline` slicer is no longer needed (the split
//! gave the section its own file).
use ailang_core::ast::{
ClassDef, ClassMethod, Constraint, ConstDef, Ctor, Def, FnDef, InstanceDef,
InstanceMethod, Literal, Pattern, ParamMode, Suppress, Term, Type, TypeDef,
};
const DESIGN_MD: &str = include_str!("../../../docs/DESIGN.md");
/// Slice of `DESIGN_MD` covering only §"Data model": from the `## Data model`
/// header to the next top-level `## ` header. Returned as a `&'static str`
/// because `DESIGN_MD` is itself static. Panics if §"Data model" is missing
/// — that itself would be drift.
fn data_model_section() -> &'static str {
let start = DESIGN_MD
.find("## Data model")
.expect("DESIGN.md must contain `## Data model` header");
let from_start = &DESIGN_MD[start..];
match from_start.find("\n## ") {
Some(end) => &from_start[..end],
None => from_start,
}
}
const DATA_MODEL: &str = include_str!("../../../design/contracts/data-model.md");
/// Every `Term` variant must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". An LLM author cannot produce a term variant
/// design/contracts/data-model.md. An LLM author cannot produce a term variant
/// whose `"t"` tag is absent from the canonical schema document.
#[test]
fn design_md_anchors_every_term_variant() {
@@ -171,15 +156,15 @@ fn design_md_anchors_every_term_variant() {
Term::Recur { .. } => "recur",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Term variant — \
add it to docs/DESIGN.md"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Term variant — \
add it to design/contracts/data-model.md"
);
}
}
/// Every `Pattern` variant must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". Missing anchors mean an LLM cannot produce
/// design/contracts/data-model.md. Missing anchors mean an LLM cannot produce
/// the corresponding pattern form.
#[test]
fn design_md_anchors_every_pattern_variant() {
@@ -201,14 +186,14 @@ fn design_md_anchors_every_pattern_variant() {
Pattern::Ctor { .. } => "ctor",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Pattern variant"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Pattern variant"
);
}
}
/// Every `Type` variant must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". The `"k"` discriminator is load-bearing for
/// design/contracts/data-model.md. The `"k"` discriminator is load-bearing for
/// the codegen and typechecker; an LLM must know all four forms.
#[test]
fn design_md_anchors_every_type_variant() {
@@ -234,14 +219,14 @@ fn design_md_anchors_every_type_variant() {
Type::Forall { .. } => "forall",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Type variant"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Type variant"
);
}
}
/// Every `Literal` variant must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". The `"kind"` discriminator identifies the
/// design/contracts/data-model.md. The `"kind"` discriminator identifies the
/// literal type; an LLM cannot produce a literal it hasn't seen in the
/// schema.
#[test]
@@ -263,14 +248,14 @@ fn design_md_anchors_every_literal_variant() {
Literal::Float { .. } => "float",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Literal variant"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Literal variant"
);
}
}
/// Every `Def` kind must have its JSON-schema anchor present in
/// DESIGN.md §"Data model". All five kinds (`fn`, `const`, `type`,
/// design/contracts/data-model.md. All five kinds (`fn`, `const`, `type`,
/// `class`, `instance`) must be documented so an LLM can write
/// any kind of top-level definition.
#[test]
@@ -335,14 +320,14 @@ fn design_md_anchors_every_def_kind() {
Def::Instance(_) => "instance",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a Def kind"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a Def kind"
);
}
}
/// Every `ParamMode` variant must have its serialized string form present
/// in DESIGN.md §"Data model". The mode annotations are load-bearing for
/// in design/contracts/data-model.md. The mode annotations are load-bearing for
/// ownership checking; an LLM author must know all three forms.
#[test]
fn design_md_anchors_every_parammode_variant() {
@@ -359,13 +344,13 @@ fn design_md_anchors_every_parammode_variant() {
ParamMode::Borrow => "borrow",
};
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing anchor `{anchor}` for a ParamMode variant"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing anchor `{anchor}` for a ParamMode variant"
);
}
}
/// Nested struct key anchors must be present in DESIGN.md §"Data model".
/// Nested struct key anchors must be present in design/contracts/data-model.md.
/// These keys appear inside `Suppress`, `ClassMethod`, `InstanceMethod`,
/// and `Type::Forall` — they are not discriminators but they ARE part
/// of the canonical JSON schema and must remain documented.
@@ -396,36 +381,10 @@ fn design_md_anchors_nested_struct_keys() {
for anchor in anchors {
assert!(
data_model_section().contains(anchor),
"DESIGN.md §Data-model is missing nested-struct-key anchor `{anchor}`"
DATA_MODEL.contains(anchor),
"design/contracts/data-model.md is missing nested-struct-key anchor `{anchor}`"
);
}
}
/// Pin the §"Data model" section extractor itself: it must return a
/// non-empty slice starting with `## Data model`, and it must NOT bleed
/// into the next top-level section (`## Pipeline` today). A bug in the
/// extractor would otherwise silently widen every drift test's scope
/// back toward full-document scanning — the failure mode this file's
/// tightening exists to prevent.
#[test]
fn data_model_section_is_bounded() {
let section = data_model_section();
assert!(
section.starts_with("## Data model"),
"section must start at the §Data model header; got first 40 bytes: {:?}",
&section[..40.min(section.len())]
);
assert!(
!section.contains("\n## Pipeline"),
"section must stop before the next top-level header (\\n## Pipeline); \
extractor regressed to whole-document scanning"
);
assert!(
section.len() > 1000,
"§Data model is the canonical schema reference; a slice shorter than \
1 KB suggests the extractor truncated; got {} bytes",
section.len()
);
}
+80 -43
View File
@@ -1,6 +1,6 @@
//! Regression pin: the canonical docs must stay present-tense-honest.
//!
//! `docs/DESIGN.md` (and `docs/PROSE_ROUNDTRIP.md`) must not assert
//! The `design/` ledger (and `docs/PROSE_ROUNDTRIP.md`) must not assert
//! something exists/works/changed that does not. This pin fails if any
//! swept-and-corrected Wunschdenken / post-mortem string reappears, or
//! if any present-tense corrected anchor (incl. the protected
@@ -25,70 +25,107 @@ fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// The `design/` prose set the absent-pins span after the role-split
/// (these pins formerly scanned the single canonical design doc): the
/// Boehm/Decision-9 narrative
/// (`models/rc-uniqueness.md`), float semantics
/// (`contracts/float-semantics.md`), the typeclass contract incl.
/// prelude classes & "does NOT commit to"
/// (`contracts/typeclasses.md`), the effects model
/// (`models/effects.md`), and the authoring-surface notation model
/// (`models/authoring-surface.md`). A swept Wunschdenken /
/// doc-archaeology string must be absent from **all** of them, so the
/// reads are joined and a single absence assertion covers the set.
fn design_corpus() -> String {
[
read("design/models/rc-uniqueness.md"),
read("design/contracts/float-semantics.md"),
read("design/contracts/typeclasses.md"),
read("design/models/effects.md"),
read("design/models/authoring-surface.md"),
]
.join("\n")
}
#[test]
fn design_md_has_no_wunschdenken() {
let d = norm(&read("docs/DESIGN.md"));
let d = norm(&design_corpus());
assert!(!d.contains("is on the path to retirement"),
"DESIGN.md: Boehm retirement is forward intent — it lives in docs/roadmap.md, not DESIGN.md");
"design/: Boehm retirement is forward intent — it lives in docs/roadmap.md, not the ledger");
assert!(!d.contains("Will back `Show Float`"),
"DESIGN.md: 'Will back Show Float' asserts a future capability as fact");
"design/: 'Will back Show Float' asserts a future capability as fact");
assert!(!d.contains("codegen ships in a future iter"),
"DESIGN.md: float_to_str codegen is reserved/not-yet-shipped, not a promised future ship");
"design/: float_to_str codegen is reserved/not-yet-shipped, not a promised future ship");
assert!(!d.contains("may land in a future iteration"),
"DESIGN.md: auto-derivation 'may land in a future iteration' is Wunschdenken");
"design/: auto-derivation 'may land in a future iteration' is Wunschdenken");
assert!(!d.contains("If/when concurrency arrives"),
"DESIGN.md: 'if/when concurrency arrives … will be' is forward conditional");
"design/: 'if/when concurrency arrives … will be' is forward conditional");
assert!(!d.contains("was deferred — all three layer additively"),
"DESIGN.md: deferred future integration paths are roadmap intent, not DESIGN.md");
"design/: deferred future integration paths are roadmap intent, not the ledger");
assert!(!d.contains("was deferred from milestone 22 entirely"),
"DESIGN.md: 'was deferred from milestone 22 entirely … A future iter ships' is history+Wunschdenken (Task 4 Step 14 catch-all)");
"design/: 'was deferred from milestone 22 entirely … A future iter ships' is history+Wunschdenken");
assert!(!d.contains("A future iter ships the full prose projection"),
"DESIGN.md: Form-B class/instance prose 'a future iter ships' is forward intent → roadmap");
"design/: Form-B class/instance prose 'a future iter ships' is forward intent → roadmap");
}
#[test]
fn design_md_has_no_doc_archaeology() {
let d = norm(&read("docs/DESIGN.md"));
let d = norm(&design_corpus());
assert!(!d.contains("An earlier draft of this Decision said"),
"DESIGN.md: doc-archaeology ('an earlier draft said … a follow-up replaced it') lives in docs/journals/");
"design/: doc-archaeology ('an earlier draft said … a follow-up replaced it') lives in docs/journals/");
assert!(!d.contains("previously all diagnostics were `Error`"),
"DESIGN.md: 'previously all diagnostics were Error' is project history → docs/journals/");
"design/: 'previously all diagnostics were Error' is project history → docs/journals/");
assert!(!d.contains("What did change in 2026-05-13 (this milestone) is"),
"DESIGN.md: 'what did change in <date> (this milestone)' is post-mortem narrative");
"design/: 'what did change in <date> (this milestone)' is post-mortem narrative");
assert!(!d.contains("primitives were retired 2026-05-14 in iter rpe.1"),
"DESIGN.md: 'X were retired <date> in iter Y' is history → docs/journals/");
"design/: 'X were retired <date> in iter Y' is history → docs/journals/");
assert!(!d.contains("were retired in iter rpe.1 (2026-05-14)"),
"DESIGN.md: the second per-type-print-op retirement-narrative sentence is history");
"design/: the second per-type-print-op retirement-narrative sentence is history");
assert!(!d.contains("originally listed here as the eighth fixture, was retired"),
"DESIGN.md: 'originally listed … was retired by milestone X' is doc-archaeology");
"design/: 'originally listed … was retired by milestone X' is doc-archaeology");
}
#[test]
fn design_md_present_tense_anchors_present() {
let d = norm(&read("docs/DESIGN.md"));
// the discriminator meta-subsection (Task 3)
assert!(d.contains("the honesty rule it holds itself to"),
"DESIGN.md must carry the discriminator meta-subsection heading");
assert!(d.contains("whether the document asserts something exists, works, or changed that does not"),
"DESIGN.md discriminator must state the operational test");
// Each anchor is read from its post-split home (spec Appendix
// relocation map). Three honest-reserved rationale anchors
// ("Regions were considered and rejected", the "does not infer
// everything" bullet, the Form-B prose-placeholder state) sat in
// `### What this Decision deliberately does not do` / `### What
// this decision does NOT commit to`, which the Appendix routes to
// the decision-record journal — so the journal is part of the
// canonical anchor corpus, not a contract surface.
let honesty = norm(&read("design/contracts/honesty-rule.md"));
let scope = norm(&read("design/contracts/scope-boundaries.md"));
let memory = norm(&read("design/contracts/memory-model.md"));
let pipeline = norm(&read("design/models/pipeline.md"));
let str_abi = norm(&read("design/contracts/str-abi.md"));
let typeclasses = norm(&read("design/contracts/typeclasses.md"));
let records = norm(&read("docs/journals/2026-05-19-design-decision-records.md"));
// the discriminator meta-subsection -> contracts/honesty-rule.md
assert!(honesty.contains("the honesty rule it holds itself to"),
"honesty-rule.md must carry the discriminator meta-subsection heading");
assert!(honesty.contains("whether the document asserts something exists, works, or changed that does not"),
"honesty-rule.md discriminator must state the operational test");
// protected honest-reserved exceptions — over-correction guard
assert!(d.contains("`Diverge` is a reserved effect name with no op and no codegen"),
"the gold-form honest-reserved anchor must remain (do not over-strip)");
assert!(d.contains("Regions were considered and rejected"),
"the present-tense design-rationale exclusion must remain (do not over-strip)");
assert!(d.contains("a tiebreaker, not a rationale"),
"the self-labelled tiebreaker is honest and stays (do not over-strip)");
// corrected present-tense anchors (Task 4)
assert!(d.contains("`--alloc=gc` selects the transitional Boehm backend"),
"DESIGN.md must describe Boehm present-tense, not as 'on the path to retirement'");
assert!(d.contains("type-installed; codegen is reserved and not yet shipped"),
"float_to_str must be present-tense honest-reserved");
assert!(d.contains("AILang demands annotations *because* the LLM author can produce them effortlessly"),
"the 'does not infer everything' bullet must be present-tense (no doc-archaeology)");
assert!(d.contains("`io/print_str` is the only built-in direct-output effect-op"),
"the print-op set must be stated present-tense, not as a retirement narrative");
assert!(d.contains("the render in `crates/ailang-prose/src/lib.rs` is a placeholder and informational only"),
"the Form-B class/instance prose-projection state must be present-tense (Task 4 Step 14 catch-all)");
assert!(scope.contains("`Diverge` is a reserved effect name with no op and no codegen"),
"the gold-form honest-reserved anchor must remain in scope-boundaries.md (do not over-strip)");
assert!(records.contains("Regions were considered and rejected"),
"the present-tense design-rationale exclusion must remain in the decision-records (do not over-strip)");
assert!(memory.contains("a tiebreaker, not a rationale"),
"the self-labelled tiebreaker is honest and stays in memory-model.md (do not over-strip)");
// corrected present-tense anchors
assert!(pipeline.contains("`--alloc=gc` selects the transitional Boehm backend"),
"models/pipeline.md must describe Boehm present-tense, not as 'on the path to retirement'");
assert!(str_abi.contains("type-installed; codegen is reserved and not yet shipped"),
"float_to_str must be present-tense honest-reserved in str-abi.md");
assert!(records.contains("AILang demands annotations *because* the LLM author can produce them effortlessly"),
"the 'does not infer everything' bullet must be present-tense in the decision-records (no doc-archaeology)");
assert!(typeclasses.contains("`io/print_str` is the only built-in direct-output effect-op"),
"the print-op set must be stated present-tense in typeclasses.md, not as a retirement narrative");
assert!(records.contains("the render in `crates/ailang-prose/src/lib.rs` is a placeholder and informational only"),
"the Form-B class/instance prose-projection state must be present-tense in the decision-records");
}
#[test]
@@ -109,7 +146,7 @@ fn form_a_scalar_param_carveout_present_and_old_rule_absent() {
// (planner Step-5 item 6: line-wrap is structurally discharged,
// the substrings need not be contiguous in the .md source).
let f = norm(FORM_A_SPEC);
let d = norm(&read("docs/DESIGN.md"));
let d = norm(&read("design/contracts/embedding-abi.md"));
// --- ABSENT: the four old unconditional phrasings must be gone ---
assert!(!f.contains("Mode annotations on every `(fn ...)` parameter."),
@@ -131,7 +168,7 @@ fn form_a_scalar_param_carveout_present_and_old_rule_absent() {
assert!(f.contains("Putting a mode on a scalar parameter."),
"form_a.md Pitfalls must carry the inverse pitfall — over-wrapping a scalar (site 4)");
// --- PRESENT: the DESIGN.md §\"Embedding ABI\" mirror ---
// --- PRESENT: the design/contracts/embedding-abi.md mirror ---
assert!(d.contains("Export parameters are written **bare**: a scalar type carries no `own`/`borrow` mode"),
"DESIGN.md §Embedding ABI must mirror the canonical bare-scalar export-param rule");
"design/contracts/embedding-abi.md must mirror the canonical bare-scalar export-param rule");
}
@@ -16,21 +16,32 @@ fn read(rel: &str) -> String {
.unwrap_or_else(|e| panic!("read {rel}: {e}"))
}
/// Collapse every whitespace run to a single space so a pinned phrase
/// split across the design/ files' hard line-wrap (or across the
/// join seam) still matches a single-line assertion substring.
fn norm(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[test]
fn design_md_effect_prose_is_true() {
let d = read("docs/DESIGN.md");
// Decision 3 effect prose -> design/models/effects.md; the
// "the built-in IO and Diverge ops" absent-pin's home
// `## What is not (yet) supported` -> design/contracts/scope-boundaries.md.
let d = norm(&[read("design/models/effects.md"),
read("design/contracts/scope-boundaries.md")].join("\n"));
// fiction absent
assert!(!d.contains("row-polymorphic"),
"DESIGN.md: the effect row is NOT row-polymorphic (no EffectRow / row var exists)");
"design/: the effect row is NOT row-polymorphic (no EffectRow / row var exists)");
assert!(!d.contains("`IO` and `Diverge` (for infinite"),
"DESIGN.md: `Diverge` is not a wired-up MVP effect");
"design/: `Diverge` is not a wired-up MVP effect");
assert!(!d.contains("the built-in IO and Diverge ops"),
"DESIGN.md §What-is-not-supported: there is no built-in Diverge op");
"design/contracts/scope-boundaries.md: there is no built-in Diverge op");
// corrected anchors present
assert!(d.contains("flat, unordered, closed set of effect names"),
"DESIGN.md Decision 3 must describe the real (flat set-equality) effect model");
"design/models/effects.md Decision 3 must describe the real (flat set-equality) effect model");
assert!(d.contains("`Diverge` (for non-termination) is reserved"),
"DESIGN.md Decision 3 must state Diverge is reserved/unimplemented");
"design/models/effects.md Decision 3 must state Diverge is reserved/unimplemented");
}
#[test]
+1 -1
View File
@@ -4,7 +4,7 @@
//! [`ailang_core::ast::Module`] values. The JSON-AST in `ailang-core`
//! remains the canonical, hashable, content-addressed source of truth;
//! form (A) is the AI-authoring projection optimised for token-efficient
//! production by LLMs (see `docs/DESIGN.md` Decision 6).
//! production by LLMs (see `design/contracts/authoring-surface.md`).
//!
//! ## Modules
//!
+4 -2
View File
@@ -75,10 +75,12 @@
//! lit-form ::= integer | "true" | "false" | string
//! ```
//!
//! Notes on the form (deviations from the spec in DESIGN.md Decision 6):
//! Notes on the form (the binding constraints are in
//! design/contracts/authoring-surface.md; the form-refinement
//! rationale is in docs/journals/2026-05-19-design-decision-records.md):
//!
//! - The `lam` form carries `paramTypes`, a `ret` type, and an
//! optional `effects` clause. The original DESIGN.md sketch left
//! optional `effects` clause. The early form sketch left
//! these out; the AST stores them per-lambda and so the form must
//! round-trip them.
//! - The `import` form admits an optional `as` alias to round-trip
+1 -1
View File
@@ -125,7 +125,7 @@ fn write_type_def(out: &mut String, td: &TypeDef, level: usize) {
}
// Iter 18e: `(drop-iterative)` annotation. Printed last (after
// every ctor) so the canonical form lands consistent with the
// DESIGN.md example. Omitted when `drop_iterative == false`.
// design/contracts/data-model.md example. Omitted when `drop_iterative == false`.
if td.drop_iterative {
out.push('\n');
indent(out, level + 1);
+2 -2
View File
@@ -7,7 +7,7 @@
//!
//! 1. `parse_is_deterministic_over_every_ail_fixture` — pins
//! **parse-determinism** (property 1 of the post-form-a Roundtrip
//! Invariant, DESIGN.md §"Roundtrip Invariant"). For every
//! Invariant, design/contracts/roundtrip-invariant.md). For every
//! well-formed `.ail` text `t`, `parse(t)` produces the same
//! canonical bytes every invocation. The parser is a pure
//! function of input — no randomness, no time dependence, no
@@ -69,7 +69,7 @@ fn list_ail_fixtures() -> Vec<PathBuf> {
}
/// Pins **idempotency-under-print** (property 2 of the post-form-a
/// Roundtrip Invariant, DESIGN.md §"Roundtrip Invariant"): for every
/// Roundtrip Invariant, design/contracts/roundtrip-invariant.md): for every
/// well-formed `.ail` text `t`, the composition `parse → print → parse`
/// is idempotent on the AST.
///
+107
View File
@@ -0,0 +1,107 @@
# AILang Design — Index
The sole addressable entry point. Every contract and model is
reached from here. A contract is a prescriptive, test-linked
invariant; a model is a whitepaper narrative. `ratifying-test` names
the green test that proves a contract still holds. `link` is
polymorphic: a `design/` file, or the authoritative source `//!`
header when the code is the single source of truth.
## Project framing
### Goal
AILang is a programming language for LLM authors. It compiles to LLVM IR.
Performance: native, no GC for the MVP.
Optimised for:
- **Machine readability** over human ergonomics. The source is structured.
- **Local reasoning.** Every definition carries its full type and effects.
- **Provability.** Pure core language, explicit effects, optional refinements.
- **Robustness against hallucinations.** Symbols are hashable; tools can verify
existence without spending context window.
### Project ecosystem
AILang is not just a language but an ecosystem. The language on its own is
only valuable when its surroundings make it usable, checkable, and
extensible for its target user (LLM authors). The repo therefore contains
several equally important components — none of them optional, all of them
evolving in lockstep with the language:
- **Language core** (`crates/ailang-core`, `crates/ailang-check`,
`crates/ailang-codegen`): AST, type system, codegen.
- **Surface forms** (`crates/ailang-surface`, `crates/ailang-prose`):
the LLM-facing renderings of a module. `ailang-surface`
is the lossless Form-A printer/parser — the canonical authoring
surface fixed by Decision 6, with a round-trip property
`parse ∘ print = id` gating every release. `ailang-prose`
is the lossy Form-B projection — human-readable prose for review and
edit, with no parser; re-integration goes through the
LLM-mediator round-trip documented in `docs/PROSE_ROUNDTRIP.md`.
- **CLI** (`crates/ail`): toolchain for tooling consumers — `manifest`,
`describe`, `deps`, `check`, `build`, `parse`, `render`, `prose`,
`merge-prose`, etc., preferably with `--json` for machine consumption.
- **Examples** (`examples/`): canonical `.ail.json` programs. They are
specification anchors, not demos — the E2E suite hangs off them.
- **Skills** (`skills/`): specialised disciplines
(`brainstorm`, `planner`, `implement`, `audit`, `debug`,
`fieldtest`) plus the agent rosters they dispatch
(`skills/<name>/agents/`). They form the project's own
development methodology and are versioned with the codebase.
See `skills/README.md`.
- **Design ledger** (`design/`): `design/INDEX.md` (this file — the
sole addressable spine for canonical state), `design/contracts/`
(test-linked invariants), `design/models/` (onboarding
whitepapers).
- **Docs** (`docs/`): `docs/journals/` (per-iter decisions log; see
`INDEX.md`), `docs/journal-archive.md` (archived monolith for
pre-2026-05-11 history), `roadmap.md` (forward queue), `specs/`
(per-milestone design specs), `plans/` (per-iteration
implementation plans).
- **Tests**: unit tests per crate plus E2E in `crates/ail/tests/e2e.rs`. Every
new compiler path needs a test, otherwise the feature does not count as done.
### Project language: English
All in-tree content is written in English: source code (identifiers,
comments, string literals, CLI help), design documents, the journal, agent
prompts, READMEs, commit messages, examples, and `CLAUDE.md`. The live
conversation between user and me stays German for ergonomic reasons;
everything that lands in git is English. This keeps diffs and tooling output
uniform and matches the audience for AILang (LLM authors), for whom English
is the default.
## Contracts
| id | consumer / lifetime | ratifying-test | link |
|----|---------------------|----------------|------|
| feature-acceptance | brainstorm-gate / stable | skills/brainstorm/SKILL.md | design/contracts/feature-acceptance.md |
| authoring-surface | LLM author / stable | crates/ailang-surface/tests/round_trip.rs | design/contracts/authoring-surface.md |
| roundtrip-invariant | every release / stable | crates/ailang-surface/tests/round_trip.rs | design/contracts/roundtrip-invariant.md |
| memory-model | LLM author / stable | crates/ailang-check/src/uniqueness.rs (in-source mod tests) | design/contracts/memory-model.md |
| data-model | LLM author / stable | crates/ailang-core/tests/design_schema_drift.rs | design/contracts/data-model.md |
| mangling | codegen / stable | crates/ail/tests/eq_ord_e2e.rs | crates/ailang-codegen/src/lib.rs //! |
| env-construction | codegen / stable | crates/ailang-check/tests/duplicate_ctor_pin.rs | crates/ailang-codegen/src/lib.rs //! |
| qualified-xref | codegen / stable | crates/ail/tests/codegen_import_map_fallback_pin.rs | crates/ail/src/main.rs //! |
| frozen-value-layout | embedding ABI / one-way-frozen| crates/ailang-codegen/tests/embed_record_layout_pin.rs | design/contracts/frozen-value-layout.md + runtime/rc.c §layout |
| float-semantics | LLM author / stable | crates/ail/tests/eq_float_noinstance.rs | design/contracts/float-semantics.md |
| typeclasses | LLM author / stable | crates/ail/tests/show_no_instance_e2e.rs | design/contracts/typeclasses.md |
| str-abi | runtime ABI / stable | crates/ail/tests/e2e.rs (Str path) | design/contracts/str-abi.md + runtime/str.c §heap-Str |
| tail-calls | codegen / stable | crates/ailang-check/src/lib.rs (in-source tail_call_in_non_tail_position_is_rejected) | design/contracts/tail-calls.md |
| honesty-rule | architect+grounding / stable | crates/ailang-core/tests/docs_honesty_pin.rs | design/contracts/honesty-rule.md |
| embedding-abi | embedding host / stable | crates/ailang-codegen/tests/embed_record_layout_pin.rs | design/contracts/embedding-abi.md |
| scope-boundaries | architect+author / stable | crates/ailang-core/tests/effect_doc_honesty_pin.rs | design/contracts/scope-boundaries.md |
| verification | architect / stable | bench/architect_sweeps.sh | design/contracts/verification.md |
## Models
| id | consumer / lifetime | link |
|----|---------------------|------|
| rc-uniqueness | onboarding / evolves | design/models/rc-uniqueness.md |
| typeclasses | onboarding / evolves | design/models/typeclasses.md |
| effects | onboarding / evolves | design/models/effects.md |
| authoring-surface | onboarding / evolves | design/models/authoring-surface.md |
| pipeline | onboarding / evolves | design/models/pipeline.md |
+80
View File
@@ -0,0 +1,80 @@
# Authoring surface
## Decision 6: authoring surface
Form (A) is implemented as
the `ailang-surface` crate (parser + printer). Form-A is
gated against drift by `ailang-surface/tests/round_trip.rs`, which
parses every `.ail` fixture, prints it back, re-parses, and demands
canonical-byte equality. `ail render` and both branches
of `ail describe` were rewired to use `ailang_surface::print`, making
form (A) the **sole** text projection of a module — the legacy
non-round-tripping pretty-printer code in `pretty.rs` was deleted at
the same time, leaving only diagnostic helpers (`type_to_string`,
`pattern_to_string`, `manifest`) public.
## Architectural pin: data structure is the source of truth
The textual surface is **not** a replacement for the JSON-AST. It is
one projection among potentially many. Concretely:
- The JSON-AST keeps its role as the canonical, hashable, content-
addressed representation of a module. All hashing, content-
addressing, cross-module references, and typecheck/codegen input
flow through the JSON-AST. **No new hashable form is introduced.**
- The textual surface (form A, this Decision) is the **AI authoring
projection**: optimised for me producing programs token-efficiently
and for foreign LLMs producing programs from a spec. It is not
optimised for human authors and does not need to be human-pleasant.
- Future projections are explicitly anticipated: a visual / graphical
front-end is a plausible second projection for human review and
inspection (display being the one case where non-AI eyes matter).
The architecture leaves room: any producer of well-formed
`ailang-core::ast::Module` values is a valid front-end.
- **No human is expected to author AILang seriously.** Authoring is
AI work. Display and verification, by contrast, are concerns where
human-facing alternatives may be useful — and which can therefore
layer their own projections on top of the same AST without
touching the surface or the core.
In code terms: `ailang-core` owns the AST. `ailang-surface` is one
producer/consumer pair: text-form-A → AST → text-form-A. A hypothetical
`ailang-visual` would be a different producer
of the same AST. `ailang-check` and `ailang-codegen` consume only
the AST and remain projection-agnostic.
## Constraints (hard, in priority order)
1. **Formalizable for a foreign LLM.** The grammar must fit in an
EBNF/PEG spec of ≤ 30 productions. A model that has never seen
AILang must be able to read the spec and produce conforming source
zero-shot. Rules out: precedence between binary operators,
semantic indentation, maximal-munch lexing, context-sensitive
reductions.
2. **AST-isomorphic.** Every surface form maps to exactly one AST
shape. The full bijection between `.ail.json` and `.ail` (both
directions, BLAKE3-stable hashing, Float-bits-hex encoding,
workspace-CI enforcement points) is anchored as the top-level
§"Roundtrip Invariant" — this constraint records that Decision
6's surface-design choice must satisfy that invariant; the
invariant itself lives at top level because the property is
load-bearing on the language identity, not on this Decision's
surface-design rationale.
3. **No external symbols.** ASCII only. No Greek (`∀`), no arrows
(`→`), no subscripts. Reasoning: I substitute mojibake for
non-ASCII characters under context pressure; foreign LLMs vary
in how they tokenize Unicode.
4. **No precedence.** Either everything is parenthesized, or there
are no infix operators. Prefer the latter — `add(x, 1)` over
`x + 1`. Removes a fail mode for both me and foreign LLMs.
5. **No semantic indentation.** Block structure expressed by paired
delimiters or terminator tokens. Indentation is informational
only; the parser ignores it.
6. **One construct per token-list.** Every AST node corresponds to
exactly one parenthesized form (or atom). No "sometimes you can
omit the parens" rules.
7. **AST surface stays frozen.** The surface adapts to the AST, not
the other way around. We do not change the JSON schema or
invalidate hashes to make the surface prettier.
Ratified by: `crates/ailang-surface/tests/round_trip.rs`.
+242
View File
@@ -0,0 +1,242 @@
# Data model
## Data model
The on-disk JSON-AST is what the toolchain hashes, typechecks, and
lowers. **This section is the canonical schema.** The Rust types in
`crates/ailang-core/src/ast.rs` are the in-memory projection of it;
when the two disagree, this section wins, and the drift test
`crates/ailang-core/tests/design_schema_drift.rs` fires. Every
additive field is declared with `skip_serializing_if` so pre-existing
fixtures keep bit-identical canonical-JSON hashes — that gating
contract is what makes growing the schema cheap.
### Module
```jsonc
{
"schema": "ailang/v0",
"name": "<id>",
"imports": [{ "module": "<id>", "as": "<id>" }],
"defs": [Def...]
}
```
### Def
`kind ∈ { "fn", "const", "type", "class", "instance" }`. All five
are real surface forms.
```jsonc
// fn (the unit that gets a content hash)
{ "kind": "fn",
"name": "<id>",
"type": Type, // typically Type::Fn, optionally wrapped in Forall
"params": ["<id>"...], // names bound in body, in type.params order
"body": Term,
"doc": "<optional string>",
"export": "<optional C symbol>", // omitted when absent (hash-stable when omitted); see §"Embedding ABI"
"suppress": [Suppress...] // omitted when empty
}
// const (top-level value; codegen emits as a global; body must be pure)
{ "kind": "const",
"name": "<id>",
"type": Type,
"value": Term,
"doc": "<optional string>"
}
// type (algebraic data type; parameterised)
{ "kind": "type",
"name": "<id>",
"vars": ["<id>"...], // type parameters; omitted when empty (hash-stable when omitted)
"ctors": [
{ "name": "<id>", "fields": [Type...] } // nullary ctor: fields = []
...
],
"doc": "<optional string>",
"drop-iterative": true // opt-in; omitted when false (hash-stable when omitted)
}
// class (typeclass declaration; see Decision 11)
{ "kind": "class",
"name": "<id>", // class name (e.g. "Show")
"param": "<id>", // single class parameter, kind *
"superclass": null, // or { "class": "<id>", "type": "<param>" } — "class": canonical form (bare for same-module, "<module>.<Class>" for cross-module; see §"Class names" / mq.1)
"methods": [
{ "name": "<id>",
"type": Type, // FnSig over the class param
"default": Term // optional fallback body; null = abstract-required
}
...
],
"doc": "<optional string>"
}
// instance (typeclass instance; see Decision 11)
{ "kind": "instance",
"class": "<id>", // class being instantiated; canonical form (bare for same-module, "<module>.<Class>" for cross-module; see §"Class names" / mq.1)
"type": Type, // concrete type expression (never the class param)
"methods": [
{ "name": "<id>", "body": Term }
...
],
"doc": "<optional string>"
}
```
**`Suppress`** (entry in `FnDef.suppress`):
```jsonc
{ "code": "<diagnostic-code>", // e.g. "over-strict-mode"
"because": "<author reason>" // must be non-empty;
// empty/whitespace fires `empty-suppress-reason` (Error)
}
```
### Term (expression)
```jsonc
{ "t": "lit", "lit": Literal }
{ "t": "var", "name": "<id>" }
// fn application; tail flag triggers musttail under codegen.
// `tail` is omitted when false (hash-stable when omitted).
{ "t": "app", "fn": Term, "args": [Term...], "tail": false }
{ "t": "let", "name": "<id>", "value": Term, "body": Term }
// Local recursive let. Always fn-shaped. The desugar pass
// lifts most `letrec` to a synthetic top-level fn; `lift_letrecs`
// finishes the job after typecheck for the residue that captures
// let-bound names. Post-codegen, no `letrec` survives.
{ "t": "letrec",
"name": "<id>", "type": Type, "params": ["<id>"...],
"body": Term, "in": Term }
{ "t": "if", "cond": Term, "then": Term, "else": Term }
// Effect-op invocation. `op` is "<eff>/<op>" (e.g. "io/print_str").
// `tail` triggers musttail (omitted when false).
{ "t": "do", "op": "<eff>/<op>", "args": [Term...], "tail": false }
// Ctor application; `args` omitted when empty.
{ "t": "ctor", "type": "<id>", "ctor": "<id>", "args": [Term...] }
{ "t": "match", "scrutinee": Term, "arms": [Arm...] }
// Anonymous fn value; free vars captured from enclosing scope.
{ "t": "lam",
"params": ["<id>"...],
"paramTypes": [Type...],
"retType": Type,
"effects": ["<id>"...],
"body": Term }
// Sequencing. Semantically `let _ = lhs in rhs`; lhs must be Unit.
{ "t": "seq", "lhs": Term, "rhs": Term }
// Explicit RC clone. Codegen lowers as
// `call void @ailang_rc_inc(ptr %v)` before returning %v under `--alloc=rc`.
{ "t": "clone", "value": Term }
// Explicit reuse-as hint. `body` must be allocating
// (typically `ctor` or `lam`); `source` must be a bare `var`. Codegen
// lowers as in-place rewrite under `--alloc=rc`.
{ "t": "reuse-as", "source": Term, "body": Term }
// loop-recur iter 1: strict iteration block. `binders` declares
// one or more loop parameters (name, type, init), evaluated in
// order on loop entry; `body` is in scope of all binders. The
// loop's value is `body`'s value on the iteration that exits via a
// non-`recur` branch. Strictly additive (no `skip_serializing_if`;
// pre-existing fixtures hash bit-identically — none carry the tag).
// No totality claim — an infinite loop is legal. See
// `docs/specs/2026-05-17-loop-recur.md`.
{ "t": "loop",
"binders": [ { "name": "<id>", "type": Type, "init": Term }, ... ],
"body": Term }
// loop-recur iter 1: re-enter the lexically innermost enclosing
// `loop`, rebinding its binders positionally to `args`. Transfers
// control (no fall-through); valid only in tail position of its
// enclosing loop (enforced at typecheck, `recur-not-in-tail-position`).
{ "t": "recur",
"args": [ Term, ... ] }
```
In the MVP, `do` is only a direct call to a built-in effect op (no
handler). A `lam` term constructs an anonymous function value; free
variables of its body are captured from the enclosing scope.
Loop binders are alloca-resident: typecheck binds them in the
ordinary local scope plus a positional `loop_stack`, and codegen
lowers them as entry-block allocas. Capturing a `loop` binder into a
lambda body is rejected at typecheck via
`CheckError::LoopBinderCapturedByLambda`. See
`docs/specs/2026-05-17-loop-recur.md`.
**`Literal`**:
```jsonc
{ "kind": "int", "value": <i64> }
{ "kind": "bool", "value": <bool> }
{ "kind": "str", "value": "<utf-8>" }
{ "kind": "unit" }
{ "kind": "float", "bits": "<16-lowercase-hex>" }
```
**`Pattern`** (the `pat` field of an `Arm`; discriminator `p`):
```jsonc
{ "p": "wild" } // _
{ "p": "var", "name": "<id>" } // x — binds the value
{ "p": "lit", "lit": Literal }
{ "p": "ctor", "ctor": "<id>", "fields": [Pattern...] } // fields omitted when empty
```
Patterns are linear: each pattern variable may appear at most once.
### Type
```jsonc
// Type-constructor application. `args` omitted when empty
// (hash-stable when omitted, for non-parameterised cases like Int, Bool, ...).
{ "k": "con", "name": "<id>", "args": [Type...] } // "name": canonical form (bare for same-module / primitives, "<module>.<TypeName>" for cross-module; see §"Type::Con name scoping" / ct.1)
// Function type. Decision 10 added paramModes/retMode as
// metadata on Type::Fn — they are NOT separate Type variants, so every
// existing match-arm in the typechecker (unify, occurs, apply) keeps
// working. `paramModes` omitted when every entry is "implicit";
// `retMode` omitted when "implicit" (hash-stable when omitted).
{ "k": "fn",
"params": [Type...],
"paramModes": [ParamMode...],
"ret": Type,
"retMode": ParamMode,
"effects": ["<id>"...] }
{ "k": "var", "name": "<id>" }
// Top-level polymorphism only. `constraints` carries class
// constraints (Decision 11); omitted when empty (hash-stable when omitted).
{ "k": "forall",
"vars": ["<id>"...],
"constraints": [{ "class": "<id>", "type": "<id>" }, ...], // "class": canonical form (bare for same-module, "<module>.<Class>" for cross-module; see §"Class names" / mq.1)
"body": Type }
```
**`ParamMode`** (Decision 10):
```
"implicit" — unannotated / back-compat. Treated as `own` by the typechecker.
"own" — (own T) — caller transfers ownership; callee consumes.
"borrow" — (borrow T) — caller retains ownership; callee may not consume.
```
`implicit ≡ own` semantically; the distinction exists so existing
unannotated fixtures continue to serialize without the mode wrapper and keep their
canonical-JSON hash.
Ratified by: `crates/ailang-core/tests/design_schema_drift.rs`.
+71
View File
@@ -0,0 +1,71 @@
# Embedding ABI
## Embedding ABI
`ail build --emit=staticlib` compiles a module to a relocatable
`lib<entry>.a` (program objects only) plus a separate
`libailang_rt.a` (the RC runtime: `rc.c` + `str.c`), with **no**
`@main` trampoline and **no** `MissingEntryMain` requirement — a
kernel module is a library.
Each `fn` carrying `(export "<sym>")` (schema: `FnDef.export`) is
emitted as an externally-visible C entrypoint `@<sym>` forwarding to
the internal `@ail_<module>_<fn>`. The symbol is author-chosen and
decoupled from the `ail_<module>_<def>` mangling so a module/fn
rename does not move the C symbol.
The set that can cross the boundary is exactly: `Int` (lowered
`i64`), `Float` (lowered `double`), or a single-constructor record
whose every field is one of those (crossing as a bare `ptr` to the
box layout below); the fn's effect set must be empty. There is no
general value-marshalling layer — the host hand-constructs and
hand-reads that box layout directly, so the enumerated set *is* the
whole contract, narrow by construction, not a subset of a wider
embedding ABI. Because the box layout is the host's contract
surface with no accessor indirection, it is a **one-way commitment
frozen as of M3**: a compiler change MUST NOT move the box offsets
below for an exported type, nor invert the host-free rule.
These are enforced at `ail check` (`export-non-scalar-signature`,
`export-has-effects`) — an effectful or non-scalar export *fails to
typecheck*. Every exported entrypoint takes a mandatory leading `ailang_ctx_t*`
(M2): a per-thread embedding context created by `ailang_ctx_new()`
and released by `ailang_ctx_free()`, owned by the calling thread for
its lifetime. The host links one `ailang_ctx_t` per OS worker thread
and the runtime accounts RC alloc/free into it: the per-allocation
hot path (the per-ctx counters and the per-object refcount header)
is non-atomic by design and never shared — a box never crosses a
thread (`Ctx: !Send`) and each `ailang_ctx_t` is
single-thread-per-ctx. The one datum a multi-threaded host shares
is the global RC-stats fallback counter (used when no ctx is
bound); it is atomic-relaxed so the swarm's leak accounting is
exact. The swarm artefact is data-race-free, sanitiser-verified.
The staticlib swarm artefact is **RC-only**:
`ail build --emit=staticlib` rejects `--alloc=gc`/`--alloc=bump`
(the shared Boehm collector is not swarm-safe). The value/record
layout is **frozen as of M3** (see "Frozen value layout" below); the
ctx-threaded C signature is the M2 shape.
Export parameters are written **bare**: a scalar type carries no
`own`/`borrow` mode (a single-constructor record export parameter,
by contrast, carries `own`/`borrow` — the ownership contract the
frozen value layout below specifies). The
canonical M1 export shape:
```
(fn step
(export "backtest_step")
(type
(fn-type
(params (con Int) (con Int))
(ret (con Int))))
(params state sample)
(body
(app + state (app * sample sample))))
```
`ail emit-ir <module> --emit=staticlib` prints this kernel's LLVM
IR (the external `@<sym>` forwarders, no `@main`) instead of the
executable-path `main`-required rejection — the Decision-5
IR-readability affordance for a `main`-free kernel.
Ratified by: `crates/ailang-codegen/tests/embed_record_layout_pin.rs`.
+75
View File
@@ -0,0 +1,75 @@
# Feature-acceptance criterion
## Feature-acceptance criterion
A proposed feature ships only if all three hold:
1. **An LLM author naturally produces code that uses it.** Without
prompting toward the feature, the LLM reaches for it as the clean
way to express the situation. If the feature is only used when
explicitly mentioned, it isn't earning its keep — the LLM is the
only author, and what the LLM doesn't reach for naturally is dead
surface area.
2. **The feature measurably improves correctness or removes
redundancy.** Either it eliminates a class of bugs structurally
(the schema forbids the wrong code), or it lets the LLM express the
same logic in fewer sites that have to stay consistent across
edits. Aesthetic appeal — "feels elegant", "is idiomatic" — does
not count.
3. **The feature reintroduces no bug class the core constraint exists
to eliminate.** Criterion 1 is necessary but does *not*
discriminate: an LLM reaches for *every* construct native to its
imperative training distribution, so "the LLM reaches for it" is
satisfied by exactly the constructs AILang most deliberately
refuses. A feature can pass 1 and 2 — LLMs reach for it unprompted,
and it removes redundancy — and still be a regression, because it
reinstates the error surface the pure core, local-reasoning, and
RC-acyclicity guarantees were built to remove. The decisive
question is whether the construct re-opens a class of mistakes
(iterated-mutable-state reasoning, silently-unbounded recursion,
reference cycles) that a foundational invariant closes. If it does,
it is cut even when 1 and 2 hold — *or* it must be reshaped until
the bug class is structurally impossible rather than merely
discouraged. A documentation note is not a reshape; the
discriminator is whether the wrong code fails to typecheck, not
whether a guideline advises against it. Worked example: a bare
`while` over mutable state would pass clauses 1 and 2 yet fail
clause 3 (it reinstates iterated-mutable-state reasoning the pure
core exists to remove); a hypothetical "all repetition is either
structurally-decreasing recursion over an acyclic ADT or an
explicit named loop" iteration story would, *if it could be built
without a documented-unenforced precondition*, pass all three —
and the fact that the 2026-05 attempt could not (it forced a
silent-divergence precondition; see
`docs/specs/2026-05-16-iteration-discipline-revert.md`) is itself
the clause-3 mechanism working as intended.
This is the positive complement to the CLAUDE.md rule that
implementation effort is not a rationale: cost is not a reason *for* a
feature, and neither is human aesthetic preference. LLM-author utility
(1, 2) is necessary; criterion 3 is the discriminator that keeps
utility from laundering the imperative paradigm back in one construct
at a time.
Two corollaries:
- **Human-attractive but LLM-neutral features are cut.** Point-free
style, operator overloading, implicit conversions, syntactic
shortcuts that hide structure. They reward human authors who enjoy
compression; they cost the LLM the explicit form it relies on to
keep RC, uniqueness, and effects locally legible.
- **Human-hostile but LLM-friendly features are kept.** JSON as
canonical authoring surface; mandatory mode annotations on every
fn parameter; mandatory top-level type signatures; explicit `clone`
for shared values. These cost a human author keystrokes; they let
the LLM reason locally without spending context window on
cross-references.
Empirically: if a feature is proposed and the LLM does not produce it
in unprompted code samples, the feature is proposed for the wrong
reason. The orchestrator's job is to notice that and cut.
Ratified by: `skills/brainstorm/SKILL.md`.
+105
View File
@@ -0,0 +1,105 @@
# Float semantics
## Float semantics
`Float` is IEEE-754 binary64 (LLVM `double`). One float type ships;
no `f32` variant. The runtime / codegen contract:
**Guaranteed:**
- Every individual builtin (`+`/`-`/`*`/`/`/`neg`/`<`/`==`/...) lowers
to a single LLVM IR instruction on the Float arm:
`fadd/fsub/fmul/fdiv double`, `fneg double`, `fcmp olt/ole/ogt/oge
double`, `fcmp oeq double`, `fcmp une double` (for `!=`). On a
fixed `(target triple, LLVM version)` pair, the bit pattern of
the result of any single op is reproducible.
- NaN and ±Inf propagate per IEEE 754 — no silent collapse to zero,
no trap. Arithmetic on a NaN operand produces NaN; division by
zero produces ±Inf; `0.0 / 0.0` produces NaN.
- `-0.0` and `+0.0` are distinct bit patterns at the canonical-JSON
hash level (`{"bits":"0000000000000000",...}` vs
`{"bits":"8000000000000000",...}` — distinct `def_hash`s) but
compare equal via `==` per IEEE (`fcmp oeq double` returns true
for `+0 == -0`). This asymmetry is the correct IEEE behaviour;
it does mean `def_hash`-equality is finer than `==`-equality on
Float.
- `==` returns `false` whenever either operand is NaN
(`fcmp oeq` is the ordered-equal predicate; ordered = both
operands non-NaN).
- `!=` returns `true` whenever either operand is NaN (`fcmp une`
is the unordered-or-not-equal predicate). This matches Rust
`f64::ne` and IEEE-`!=` exactly.
- `is_nan` (`fcmp uno double %x, %x`) returns `true` iff `x` is
NaN. Bit-pattern-based NaN detection without dependence on the
payload bits.
- `int_to_float` (`sitofp`) is exact for `|n| < 2^53`,
round-to-nearest-even otherwise.
- `float_to_int_truncate` (`@llvm.fptosi.sat.i64.f64`) is total:
NaN → 0, +Inf → i64::MAX, -Inf → i64::MIN, finite-out-of-range
saturates, finite-in-range truncates toward zero. Matches Rust
`as i64` semantics (since 1.45).
**Unspecified:**
- FMA contraction. LLVM may fold `fadd (fmul a b) c` into
`fma a b c`. Bit results may differ between an op-emitted-in-
isolation pattern and an op-folded-into-FMA pattern.
- Reassociation. The compiler may reorder a chain like
`(a + b) + c` into `a + (b + c)`, producing a bit-different
result on numerically sensitive inputs.
- Subnormal flushing modes. If the target enables FTZ (flush-to-
zero) or DAZ (denormals-are-zero), subnormal results round to
zero; AILang does not enable these flags but does not forbid the
target from doing so.
- The exact NaN bit pattern produced by an op. Any quiet NaN bit
pattern is conformant; `0.0 / 0.0` may produce
`0x7ff8000000000000` on one target and a different qNaN on
another.
- The textual rendering of NaN through `float_to_str` (the runtime
C helper that backs `instance Show Float` and, post-iter-rpe.1,
every Float-typed `print` call). The libc `printf("%g", nan)`
glue used by `float_to_str` is permitted to emit `nan` / `-nan`
/ `NaN` etc. depending on libc version and the NaN's sign bit;
AILang does not normalise this, since the prose / surface-print
paths render NaN as the explicit `"NaN"` spelling and Float
rendering is for human-readable output, not round-trip.
The same libc-`%g` rendering applies to `show 1.5` / `show nan` /
`show inf` via `instance Show Float` (which calls `float_to_str`
internally — see §"Prelude (built-in) classes" for the Show ship).
The NaN-spelling caveat above is observable via `do print x` for
Float-typed `x`; the rendering is libc-version-dependent and
target-libc-specific. AILang does NOT canonicalise Float textual
representation; the LLM-author who needs deterministic Float
rendering for cross-platform test fixtures should bypass `show` /
`print` and emit a custom formatter.
These are the Rust / Swift / standard-LLVM defaults — not
research-grade reproducibility guarantees. The stronger guarantee
(e.g. Pythonic `float.fromhex`-level bit reproducibility across
ops) would require `-ffp-contract=off` plus per-op intrinsic
selection — out of scope for the milestone; revisit only if a real
use case appears.
**Form-A serialisation:** Float literals carry the IEEE-754
bit pattern as a 16-character lowercase hex string in the canonical
JSON: `{"kind":"float","bits":"<16-hex>"}`. Routing through the
JSON *string* path (not `serde_json::Number`) preserves bit
stability across `serde_json` versions and lets NaN / ±Inf
round-trip through Form-A — JSON numbers cannot represent them.
**Pattern matching:** `Pattern::Lit` on `Literal::Float` is hard-
rejected at typecheck (`CheckError::FloatPatternNotAllowed`). IEEE
semantics make Float patterns semantically dubious — NaN never
matches via IEEE-`==`, and bit-exact equality is rarely what an
LLM-author wants. Use ordering operators (`<`, `>`, ...) and
`is_nan` to discriminate Floats.
`float_to_str` (Float → Str) and `int_to_str` (Int → Str) are
fully wired through checker, codegen, and runtime. Both allocate
a fresh heap-Str slab at the call site (see "Str ABI" below for
the dual realisation) and carry `ret_mode: Own` so the let-binder
for the call result is RC-tracked and the slab is freed at scope
close.
Ratified by: `crates/ail/tests/eq_float_noinstance.rs`.
+46
View File
@@ -0,0 +1,46 @@
# Frozen value layout (M3 — one-way commitment)
## Frozen value layout (M3 — one-way commitment)
For a single-constructor `data T` whose `n` fields are each `Int`
or `Float`, a value of `T` crossing the embedding C boundary is a
bare payload pointer `p`:
| bytes | content |
|---|---|
| `p - 8 .. p` | `uint64_t` refcount header (`HEADER_SIZE = 8`) |
| `p + 0 .. p + 8` | `int64_t` constructor tag (written; `0` for the single ctor — no elision) |
| `p + 8 + i*8` | field `i`, declaration order: `int64_t` for `Int`, IEEE-754 `double` bit-pattern for `Float` |
Total box payload size = `8 + n*8`. This is the same layout
`lower_ctor` (`match_lower.rs`) emits internally; for an exported
type the host encodes and decodes these exact offsets itself, so
they are **frozen** — a compiler change MUST NOT move them for an
exported type, which permanently constrains codegen's freedom to
repack exported records.
**Construction (host → kernel input).** The host MUST obtain an
input record's storage from `ailang_rc_alloc(8 + n*8)` (returns the
payload pointer with the refcount header pre-set to `1`, payload
zeroed), then write the tag (`0`) and the scalar fields at the
offsets above. A raw `malloc` is a contract violation:
`own`-consume and `ailang_rc_dec` both require the runtime's 8-byte
header at `p - 8` initialised to `1`.
**Ownership follows the declared mode** (the §"Mode metadata is
load-bearing for codegen" contract, as the C ABI): a `(own (con
T))` parameter transfers ownership in — the kernel consumes it
(Iter-B drop-at-return); the host MUST NOT touch or `dec` it after
the call. A `(borrow (con T))` parameter is retained by the host —
the kernel does not consume it; the host frees it. The return value
is always owned by the host.
**Free (host side).** `ailang_rc_dec(payload)`. Leak-free for an M3
record because every field is a scalar — `ailang_rc_dec` is
header-only and an M3 record has no boxed children. A record with
boxed fields (`Str`/`List`/nested record) is **not** an M3
embedding type — the export gate rejects it, so the boundary
never crosses a value that would need a recursive typed-free. The
freeze covers exactly the all-scalar single-constructor record.
Ratified by: `crates/ailang-codegen/tests/embed_record_layout_pin.rs`.
+21
View File
@@ -0,0 +1,21 @@
# The honesty rule this ledger holds itself to
`design/` describes what AILang **is now**: schema, semantics,
invariants, runtime contracts. It is present-tense by construction.
Two things never belong in a contract or model file:
- **Forward intent** (anything stated as planned, intended, or
expected of a future iteration rather than true today) — that
lives in `docs/roadmap.md`.
- **History and rationale** (how a prior draft read, what changed
since, why one option was chosen, why another was rejected, what
was dropped in some iteration) — that lives in `docs/journals/`.
Decision-records are journal content, not ledger content; this is
the honesty rule it holds itself to.
The single legitimate exception is a present-tense reserved or
deliberately-excluded claim that is explicitly and correctly
labelled. The discriminator is not whether a sentence mentions past
or future, but whether the document asserts something exists, works, or changed that does not.
Ratified by: `crates/ailang-core/tests/docs_honesty_pin.rs`.
+348
View File
@@ -0,0 +1,348 @@
# Memory model — language-design constraints and codegen contract
## Language-design constraints (binding)
The four constraints below are necessary preconditions for RC to
be sound and complete *without* a cycle-collector backstop. They
are not new — AILang already satisfies all four — but Decision 10
makes them load-bearing rather than incidental:
1. **Strict evaluation.** Every `Term::App` argument is fully
evaluated before the call. No laziness, no thunks. (Already
true.)
2. **No recursive value bindings.** `(let x EXPR ...)` evaluates
`EXPR` in a scope where `x` is *not* bound. Recursion is
exclusively via `Term::LetRec` (which binds a fn, not a value)
and module-level fn defs. (Already true.)
3. **No shared mutable refs.** Values are immutable once
constructed. There is no `ref`, `IORef`, `Mutex`, or any
primitive that allows a value to be mutated from a position
outside its allocation. (Already true.)
4. **ADTs are acyclic by construction.** Strict evaluation +
no-recursive-value-bindings + no-shared-mutable-refs together
guarantee that any value graph reachable from a binding is a
DAG. The reference graph has no cycles. (Follows from 13.)
Laziness, recursive value bindings, shared mutable state, or any
feature that creates cycles is rejected at design time unless the
proposal proves the cycle is collectible by an extension (e.g.
linear ownership).
## Schema additions
**Parameter modes on `Type::Fn`.**
The form-A surface for fn signatures gains mode wrappers:
```
(fn-type (params (borrow (List Int))) (ret (con Int)))
(fn-type (params (own (List Int))) (ret (own (List Int))))
```
Internally, this is *not* a new `Type` variant. Modes are
metadata on `Type::Fn``paramModes` and `retMode` fields run
parallel to `params` and `ret` (see §"Data model" for the JSON
schema). The substantive reasons for per-position metadata over a
`Type::Borrow` / `Type::Own` variant approach:
- **Semantic locality.** Modes are properties of fn-signature
parameter positions, not of types in general. `Int` does not
have a mode; a fn-parameter slot does. Embedding modes in
`Type` would let the schema express forms like
`(con List (borrow Int))` — syntactically possible, semantically
meaningless (you cannot separately own/borrow a list element
from the list it lives in). Decision 1 is "schema = data,
schema permits exactly what is meaningful"; per-position
metadata is the option that holds that line.
- **Compositional clarity.** A `Type` value's identity should
depend only on the type. Two functions with the same param /
ret types but different calling conventions share `Type::Fn.params`
and differ only in `param_modes`. That is the right factoring:
"what data does this carry" is one axis, "how is it transferred"
is another. Mixing them under a single hierarchy conflates the
two and makes both harder to reason about.
- **Future-proof against more position metadata.** If later iters
add other per-position properties (streaming receiver, captured-
by-closure, lifetime witness), they generalise as additional
metadata fields on `Type::Fn` — one consistent hierarchy. The
variant approach would force every new dimension into its own
`Type::*` variant (`Type::Streamed`, `Type::Captured`, ...) and
combinatorics blow up: `Type::Borrow(Type::Streamed(T))` versus
`Type::Streamed(Type::Borrow(T))` raise questions of canonical
ordering that don't exist when modes live in a flat metadata
vector.
`Implicit` is the legacy / back-compat state — semantically
equivalent to `Own` but printed bare (`(con T)`, no wrapper).
`Own` and `Borrow` are explicitly annotated.
JSON canonical hash for every existing fixture stays bit-
identical: `param_modes` is skipped when every entry is
`Implicit`, `ret_mode` is skipped when `Implicit`. Existing
modules emit the same bytes as before.
**Type::Con name scoping (canonical form, since ct.1).** Within a
`.ail.json`, a `Type::Con.name` is interpreted relative to the
file's top-level `"name"` field (the owning module). Bare names
(no `.`) refer to a TypeDef in the owning module's own `defs`.
Cross-module references MUST be qualified `<owning_module>.<TypeName>`
where `<owning_module>` is a known module in the workspace.
Primitives (`Int`, `Bool`, `Str`, `Unit`, `Float`) are bare and
have no module qualifier. Bare cross-module references are a
schema violation (`WorkspaceLoadError::BareCrossModuleTypeRef`);
qualified references whose owner is unknown are also a violation
(`WorkspaceLoadError::BadCrossModuleTypeRef`). The same rule
applies to `Term::Ctor.type_name`.
Class names follow the canonical-form rule (mq.1): bare for
same-module references, `<module>.<Class>` for cross-module
references — symmetric to `Type::Con.name`'s rule from ct.1.
Three schema fields carry class references in this form:
`InstanceDef.class`, `Constraint.class`, and `SuperclassRef.class`.
`ClassDef.name` itself stays bare (defining-site context, like
`TypeDef.name`).
Method dispatch is type-driven post-mq.3 (see §"Method dispatch"
below): synth resolves a `Term::Var { name: "show" }` by consulting
the workspace's method-to-candidate-class index, filtering by
argument type (concrete) or by declared constraint (rigid-var), and
routing the residual through the registry at fn-body-end discharge.
Method-name collisions across classes are now structurally legal —
they resolve at the call site via type-driven dispatch with explicit
qualifier (`<module>.<Class>.<method>`) as the LLM-author's
disambiguation tool.
The legacy `(con T)` form is treated as `(own T)` semantically.
(An incidental observation, not a design reason: keeping `Type`
itself unchanged also avoids touching ~250 sites across the
typechecker / desugar / codegen that match on `Type` variants.
This is a tiebreaker, not a rationale — the substantive reasons
above are what justify the choice.)
**New `Term` variants.**
```
Term::Clone { value: Box<Term> } ; `(clone X)` — explicit RC inc
Term::ReuseAs { source: Box<Term>, body: Box<Term> } ; `(reuse-as SRC NEW-CTOR)`
```
`Term::ReuseAs` is structured as a *wrapper* around a `body`
term rather than as a `reuse_from: Option<String>` modifier on
`Term::Ctor`. Two substantive reasons:
1. **Compositional flexibility.** Reuse-as is conceptually a
wrapper that says "this expression's allocation comes from
`<source>`'s slot". The wrapper form generalises naturally
if future iters introduce other allocating constructs
(record literals, opaque box wrappers, capability cells) —
they all become valid `body` positions. A modifier on
`Term::Ctor` would have to be replicated on every
constructible Term variant the language grows.
2. **Source-locality at the head.** `(reuse-as SRC NEW-CTOR)`
reads as a single sentence with the source-binder named at
the head. The modifier form would scatter the reuse intent
across a child position of the constructor's argument
syntax, separating the `source` from the rest of the
reuse-as semantics.
The trade-off this accepts: the schema permits `Term::ReuseAs
{ body }` where `body` is not an allocating form (e.g. a
literal, a var). Such terms are caught at typecheck via a
`reuse-as-non-allocating-body` diagnostic — structural rejection
in the typechecker, not the schema. The principle: prefer
composability over schema-level rejection where the typecheck
rule is unambiguous.
**`TypeDef` attribute.**
```
TypeDef.drop_iterative: bool ; `(drop-iterative)`
```
All four are skipped during serialisation when absent / false /
None so canonical-JSON hashes of every fixture remain stable
until the fixture intentionally adopts the feature.
**`FnDef.suppress`.** The `suppress` field on `FnDef` carries a
list of advisory-diagnostic suppress entries; each entry has a
`code` (the diagnostic being suppressed) and a `because` (a
mandatory non-empty reason). See §"Data model" for the canonical
schema.
Form-A surface: `(suppress (code "...") (because "..."))` clause
between fn name and `(type ...)`. Multiple clauses allowed; one
per entry. Form-B (prose) renders one
`// @suppress <code>: <because>` line per entry above the doc
string — lossless, contract metadata.
Skipped from serialisation when empty so existing fixtures keep
bit-identical canonical-JSON hashes (regression-pinned by
`iter19b_empty_suppress_preserves_pre_19b_hashes` and
`iter19b_schema_extension_preserves_pre_19b_hashes`).
The canonical-form tightening in ct.1 shifted the hashes of two
cross-module fixtures (`ordering_match.ail.json` and
`test_22b1_dup_a.ail.json`); all intra-module fixtures, including
the regression-pinned `sum.ail.json` and `list.ail.json`, remain
bit-identical. The new pins are
`ct4_migrated_fixtures_have_canonical_form_hashes` (locks the
post-migration hashes) and
`ct4_unmigrated_fixtures_remain_bit_identical` (re-asserts the
existing 13a/19b/22b.1 hashes still hold).
## Advisory diagnostics
The advisory-diagnostics arc introduces the language's first
**advisory** typechecker diagnostic and the suppression mechanism
that goes with it. Decision 10's mandatory-annotation rule is
unchanged: `param_modes` and `ret_mode` remain author-required;
the typechecker does not infer them. What's new is feedback when
an authored annotation is *stricter than necessary*.
**The lint: `over-strict-mode`.** Fires on a
fn-param `p` annotated `(own T)` when:
1. `p`'s `consume_count == 0` (uniqueness pass: the body
never consumes `p` as a whole).
2. For every match arm whose scrutinee is `p`, no
**heap-typed** pattern-binder has `consume_count > 0`.
The heap-type filter is load-bearing for soundness:
`match xs { Cons(h, t) => h }` records `consume_count(h) == 1`,
but `h: Int` is read by-value — no RC traffic, no heap data
moved out of `xs`'s allocation. Filtering primitive-typed
binders is what lets the lint correctly identify `head_or_zero`
as over-strict (could be `borrow`) while staying silent on
`sum_list` where `t: List` *is* moved out.
Severity: `Warning`. `ail check`, `ail build`, `ail emit-ir` exit 1
only on at least one `Error`; warnings print but do not abort.
**The suppression: `mode-strict-because`.** Authors
who want to keep an over-strict annotation deliberately (e.g.
RC codegen-test fixtures, fns reserved for planned in-place
mutation) attach a `Suppress` entry naming the diagnostic code
and a non-empty reason. The typechecker drops matching
diagnostics from the output. Empty `because` is a hard error
(`empty-suppress-reason`); wrong-code suppresses are silent
no-ops (open-set diagnostic registry — a suppress for a code
that doesn't fire today may exist defensively for a code that
might fire after a future edit).
## Codegen contract
Memory layout:
- Every heap allocation has an 8-byte refcount header, followed
by the payload. `ailang_rc_alloc(size)` returns a pointer to
the *payload*; the header is at `ptr - 8`.
- `ailang_rc_inc(ptr)`: load `ptr - 8`, +1, store. Non-atomic
(single-threaded).
- `ailang_rc_dec(ptr)`: load, -1, store; if zero, recurse-dec
child references and `free(ptr - 8)`. For `(drop-iterative)`
types, the recursion is replaced by a worklist loop (via `drop-iterative`).
Codegen for `Term::Ctor` / `Term::Lam` env / closure pair under
`--alloc=rc` calls `ailang_rc_alloc(SIZE)`. The initial RC plumbing
stops there — inc/dec instrumentation is added once the
inference is wired up. Until then, `--alloc=rc` deliberately
leaks like the pre-Boehm era; this is purely about plumbing.
## Mode metadata is load-bearing for codegen
`param_modes` and `ret_mode` on `Type::Fn` are not merely
typechecker metadata — codegen consults both to decide where to
emit drop calls. They were promoted from
"annotation that the typechecker enforces" to "annotation that
codegen reads to keep RC correct". Recorded here so the schema
metadata's role is explicit:
**`param_modes` — drop-emission gates.**
- **Iter B: Own-param dec at fn return.** When a fn body
fall-throughs to a `ret` (no tail-call), every parameter with
`param_modes[i] == Own` is dec'd before the `ret` iff its
uniqueness `consume_count == 0` and the ret value is not the
param itself. `Borrow` and `Implicit` parameters are skipped:
`Borrow` retains the caller's ownership by contract;
`Implicit` carries no static caller-handed-off-ownership
signal (it's the back-compat lane).
- **Iter A: arm-close pattern-binder dec.** When a match-arm's
body terminates without a tail-call, every ptr-typed
pattern-bound binder pushed by the arm is dec'd at arm close
iff its `consume_count == 0` and it is not the arm's tail
value, **gated on the scrutinee's static ownership**. If the
scrutinee is a fn-param, only `Own`-mode scrutinees enable
the dec — `Borrow` and `Implicit` scrutinees would let the
arm dec memory the caller still references.
- **Pre-tail-call shallow-dec.** When a match-arm's
body IS a tail call, both Iter A and Iter B are skipped (the
block is terminated). A separate seam in `lower_match` emits
a shallow `ailang_rc_dec` on the scrutinee outer cell BEFORE
the tail call, gated identically on the scrutinee mode plus
the requirement that every ptr-typed slot in the active
ctor's pattern is in `moved_slots[scrutinee]`.
**`ret_mode` — let-binder trackability.**
- **`Term::App` drop at let-scope close.** A
let-binder whose value is `Term::App { callee, .. }` is
trackable for scope-close drop iff the callee's
`ret_mode == Own`. The signal is the callee's static
contract that ownership of the freshly heap-allocated cell
flows to the caller. `Borrow`-returning calls remain
non-trackable (the callee retains ownership; the caller
holds a view, not an own ref). `Implicit`-returning calls
remain non-trackable (back-compat lane).
The drop fn's symbol resolution for an Own-returning App:
synthesise the call's return type, resolve `Type::Con { name }`
to `drop_<owner>_<T>` (with cross-module qualification through
the import map). Falls back to shallow `ailang_rc_dec` for
returns that are not `Type::Con` (e.g. unresolved type vars on
a polymorphic call's pre-monomorphisation site; the
monomorphised copies resolve to concrete drop fns).
#### Arg-position policy for compound AST nodes
The uniqueness and linearity passes walk arguments of compound
nodes with a fixed `Position` policy. For ownership-bearing nodes:
| Node | Arg position | Reason |
|----------------------|--------------|---------------------------------------------------------------------------------------|
| `Term::Ctor.args[*]` | Consume | constructor packs values into the cell; the cell owns them afterwards |
| `Term::Do.args[*]` | Borrow | effect-op observes its arguments; the caller still owns whatever pointer it passed in |
The two policies are language rules, not per-op annotations. They
do not appear as fields on `EffectOpSig` or `Ctor`; the AST node
kind itself carries the default. The walkers that read this policy
live at `crates/ailang-check/src/uniqueness.rs` and
`crates/ailang-check/src/linearity.rs` (matched arms in both).
The Do = Borrow rule pairs with the `ret_mode == Own` letbinder-
trackability rule above: when a built-in such as `int_to_str` is
declared `ret_mode: Own` and its result is fed into an effect-op
(`io/print_str s`), the let-binder is RC-tracked for scope-close
drop *and* the effect-op does not consume it — the slab is freed
exactly once at scope close, never zero-times (RC leak under the
old Consume rule, which silenced the scope-close drop) and never
twice (double-free under a hypothetical Consume + scope-close).
**What this widening does NOT do.**
- Does not change the canonical hash. `param_modes` /
`ret_mode` were already hash-load-bearing when introduced;
subsequent work added codegen consumers, not new schema fields.
- Does not introduce a new `Type` variant. Mode metadata stays
flat on `Type::Fn` (see "Schema additions" above on why).
- Does not cover let-aliases of borrowed values. A let-binder
whose value is `Term::Var` referencing a `Borrow`-mode
param is not yet propagated through; the param-mode gates
treat such a binder as "owned" (its `current_param_modes`
lookup misses, default = owned). This is a known carve-out
shared by Iter A and the pre-tail-call shallow-dec arm; closing it is a propagation pass
through let-bindings that has not shipped yet.
Ratified by: `crates/ailang-check/src/uniqueness.rs`.
+99
View File
@@ -0,0 +1,99 @@
# Roundtrip Invariant
## Roundtrip Invariant
Every well-formed AILang module has a canonical `.ail.json`
representation (the hashable, content-addressed JSON-AST) and a
hand-authored `.ail` representation (Form A, the authoring
projection). The JSON-AST is derived from `.ail` in-process by
consumers that need it; the round-trip is now the *property* of
that derivation, not the byte-level agreement of two on-disk forms.
Concretely:
1. **Parse-determinism.** For every well-formed `.ail` text `t`,
`ailang_surface::parse(t)` produces a unique AST. The parser is
a pure function of input — no randomness, no time dependence,
no environment leak. Hashing `canonical::to_bytes(parse(t))` is
therefore well-defined for any `.ail` source.
2. **Idempotency under print.** For every well-formed `.ail` text
`t`, `canonical(parse(t)) == canonical(parse(print(parse(t))))`.
The printer is a left-inverse of the parser modulo canonical
form: print-then-parse is a no-op on the canonical bytes.
3. **CLI-pipeline idempotency.** For every `.ail` fixture, the
public CLI pipeline `ail parse | ail render | ail parse` is
byte-identical to direct `ail parse` of the source `.ail`.
Pins drift that crate-internal tests cannot see.
4. **Carve-out anchor.** Seven `.ail.json`-only fixtures
(subject-matter rejection tests) survive in the corpus by
structural necessity. They participate in their own dedicated
rejection-shape tests, not in the round-trip gate. (The prelude is
embedded as `examples/prelude.ail` in `ailang-surface` and parsed
at compile time via `ailang_surface::parse_prelude`.)
Hashing is the consequence the language depends on: BLAKE3 of the
canonical bytes is well-defined for any `.ail` source via parse-
determinism. An LLM author writes `.ail`; the build derives the
canonical hash without ambiguity.
### Float literals are inside the invariant
Float literals carry an IEEE-754 bit pattern, not a decimal
approximation. The canonical encoding is
`{"kind":"float","bits":"<16-lowercase-hex>"}` and the surface
emits the same bits-hex string. NaN, ±Inf, signed zero, and subnormals all
round-trip exactly because the JSON-number path is bypassed (see
§"Float semantics", "Form-A serialisation"). Floats are not an
exception to the invariant — the bits-hex encoding is the
mechanism that keeps them *inside* it.
### Enforcement
The invariant is workspace-CI-enforced by five tests, each
operating on the `examples/` corpus via dynamic `read_dir`
collection (no hardcoded fixture list, so newly added fixtures
inherit the gate automatically):
- `crates/ailang-surface/tests/round_trip.rs::parse_is_deterministic_over_every_ail_fixture`
— for every `.ail`, parse twice and assert canonical-byte
equality between the two ASTs. Direction 1 above.
- `crates/ailang-surface/tests/round_trip.rs::parse_then_print_then_parse_is_idempotent_on_every_ail_fixture`
— for every `.ail` text `t`, `parse(t)` and `parse(print(parse(t)))`
produce canonical-byte-equal AST. Direction 2 above.
- `crates/ail/tests/roundtrip_cli.rs::cli_parse_then_render_then_parse_is_idempotent`
— for every `.ail`, the public CLI pipeline `ail parse | ail render | ail parse`
reproduces canonical bytes byte-identical to direct `ail parse`.
Pins drift internal tests cannot see.
- `crates/ailang-core/tests/schema_coverage.rs::every_ast_variant_is_observed_in_the_fixture_corpus`
— every variant of `Def`, `Term`, `Pattern`, `Literal`, `Type`,
and `ParamMode` appears in at least one `.ail` fixture (corpus
flipped from `.ail.json` to `.ail` at iter form-a.1). New AST
variants fail compile until the visitor and corpus are extended
in lockstep.
- `crates/ailang-core/tests/carve_out_inventory.rs::examples_ail_json_inventory_matches_carve_outs`
— exactly the seven named carve-out files exist under
`examples/*.ail.json` at any commit. A new `.ail.json` or a
missing carve-out fails the test.
A new fixture or a new AST variant that violates the invariant
fails one of these tests; the fix is in render or parse code,
never by relaxing the test.
### Why this is anchored at top level
The invariant is a property of the language identity, not of any
one surface-design Decision. Decision 6 introduces the `.ail`
surface and lists round-trip-as-property as one of its
constraints, but the property is load-bearing for every
downstream concern that treats the two forms as exchangeable:
content-addressed hashing, the LLM-author's choice of authoring
form, the integrity of fixture cross-references, and the
prerequisite for empirical cross-model authoring-form studies.
Lifting it out of Decision 6 makes the property quotable on its
own and reviewable by the architect agent at every milestone
close.
Ratified by: `crates/ailang-surface/tests/round_trip.rs`.
+186
View File
@@ -0,0 +1,186 @@
# What is not (yet) supported
## What is not (yet) supported
Snapshot of the current boundary. Items move out of this list
as iterations land; the JOURNAL records when.
- No effect handlers — only the built-in `IO` op (`io/print_str`).
`Diverge` is a reserved effect name with no op and no codegen.
- No refinements / SMT escalation.
- No HM inference inside bodies. Top-level def types are explicit;
polymorphism is opt-in via `Type::Forall { vars, body }`. Inside
a body, lambdas check monomorphically against their declared type.
- Polymorphic fns must be **directly called** at the use site.
Passing a polymorphic fn as a value (`let f = id in f(42)`) is
not yet supported — it would need one closure-pair global per
instantiation, deferred.
- No higher-rank polymorphism. Passing a polymorphic fn to another
polymorphic fn (`apply(id, 42)`) is not supported.
- No recursive `let` for non-fn values. Plain `let x = … in …` only
sees `x` inside the body, not inside its own RHS — recursive value
bindings would break Decision 10's acyclicity invariant. Recursive
*fn* bindings are supported via `Term::LetRec` (`{ "t": "letrec",
... }`); the desugar pass lifts most occurrences to a synthetic
top-level fn, with `lift_letrecs` finishing the residue after
typecheck.
- No visibility rules in imports. Every top-level def of an imported module
is reachable; there is no `pub` / `priv`.
What **is** supported (and used as the smoke test for the pipeline):
- Int, Bool, Unit, **Str**, **Float** as primitive types.
- `if`, `let`, function calls, recursion.
- Effects on function signatures, with `do op(args)` for direct effect
ops (`io/print_str`). The per-type print ops
`io/print_int`, `io/print_bool`, `io/print_float` were retired in
iter rpe.1; the polymorphic `print` (§"Polymorphic print") is the
canonical output path for non-Str values.
- **Builtins.** Arithmetic operators (`+`, `-`, `*`, `/`) of type
`forall a. (a, a) -> a` (codegen-restricted to `{Int, Float}`);
`%` of type `(Int, Int) -> Int` (Int-only — `fmod` semantics for
Float deferred); ordering operators and `!=` (`!=`, `<`, `<=`,
`>`, `>=`) of type `forall a. (a, a) -> Bool` (codegen-restricted
to `{Int, Float}`); polymorphic `neg : forall a. (a) -> a`
(codegen-restricted to `{Int, Float}`; Float arm uses LLVM
`fneg double` for correct `-0.0` handling); logical
`not : (Bool) -> Bool`; conversions
`int_to_float : (Int) -> Float`,
`float_to_int_truncate : (Float) -> Int` (saturating, NaN → 0),
`float_to_str : (Float) -> Str`,
`int_to_str : (Int) -> Str` (both allocate a heap-Str slab at
call time and return it with `ret_mode: Own`; see "Str ABI" for
the dual heap-/static-Str realisation); inspection
`is_nan : (Float) -> Bool` (LLVM
`fcmp uno`); Float bit-pattern constants `nan : Float`,
`inf : Float`, `neg_inf : Float` (resolved as bare values, lower
to direct hex-float `double` SSA constants at use site); the IO
effect op `io/print_str`; **`==` : forall a.
(a, a) -> Bool**; and **`__unreachable__ : forall a. a`**.
- **`==` is polymorphic.** The typechecker accepts
`==` at any type whose two sides agree (the rigid `a` of the
`Forall` is unified by HM at the use site). Codegen
monomorphises and dispatches on the resolved AIL arg type:
`Int``icmp eq i64`; `Bool``icmp eq i1`;
`Str``call @strcmp(ptr, ptr)` then `icmp eq i32 0`
(`@strcmp` is declared in the LLVM IR header alongside
`@printf` / `@GC_malloc`); `Unit` → constant `i1 true`
(Unit has a single inhabitant; both sides are still
evaluated for any side effects); `Float``fcmp oeq double`.
ADT and `Fn` arg types are rejected at codegen with a
`CodegenError::Internal` mentioning `==` and the offending
type — neither has a canonical structural-equality scheme
yet, and the language deliberately does not silently elide
the check. **`!=` for Float uses `fcmp UNE double` (NOT
`one`)** — `one` is "ordered and not equal" and would return
false for `nan != nan`, violating IEEE-`!=`.
- **`__unreachable__`** is a polymorphic bottom value: a use of
`__unreachable__` typechecks against any expected type at
the use site and codegens to the LLVM `unreachable`
instruction (UB if ever executed). It is the chain
machinery's deepest fall-through for matches that the
typechecker proved exhaustive, and it is available to user
code as an explicit panic primitive
(`(if cond __unreachable__ ...)` for assertions or
impossible branches). Reference site is
`Term::Var { name = "__unreachable__" }` / form-A bare
`__unreachable__`.
- **ADTs + pattern matching.** Sub-patterns of a Ctor pattern may be `Var`, `Wild`,
another `Ctor`, or a literal. The desugar pass flattens
nested Ctor patterns into a chain of let + match and rewrites every
`Pattern::Lit` (top-level or sub-) to a `Term::If` on `==` before
typecheck/codegen — see `ailang-core::desugar` and Pipeline above.
- Literal patterns at top level and inside Ctor sub-patterns (via desugar).
`(pat-lit 0)` and `(pat-ctor Cons (pat-lit 0) _)` both
parse and lower; the rewrite is to `Term::If { cond = (== sv lit) }`,
so any literal kind whose `==` is supported is authorable. With `==`
polymorphic over `Int`/`Bool`/`Str`/`Unit`, that
covers every lit kind the AST ships — including `(pat-lit "hi")`
over a `Str` scrutinee, exercised by `examples/eq_demo.ail.json`.
- **Imports + qualified cross-module references** via dotted names.
Extends to **types and constructors**: a foreign
module's ADT is referenced as `(con std_pair.Pair a b)`, its ctors as
`(term-ctor std_pair.Pair MkPair x y)` and `(pat-ctor MkPair x y)`
inside that scrutinee. Std-library demos (`examples/std_*_demo.ail.json`)
exercise this end-to-end.
- **AI-authoring text surface, form (A)** (Decision 6). The `ailang-surface` crate parses `.ail` form-A
text into a canonical `ailang-core::ast::Module` and prints any module
back as form-A text. `ail render` and `ail describe` use it as the
sole text projection; `ail parse` is the inverse direction. Round-trip
identity (text → AST → JSON → AST → text) is gated by
`ailang-surface/tests/round_trip.rs` over every shipped fixture.
- **Memory management via Boehm conservative GC** (Decision 9),
with **per-fn arena via stack `alloca` for non-escaping allocations**
layered on top. Every ADT box, lambda env, and closure
pair allocates either via `@GC_malloc` (escaping; Boehm-managed) or
via LLVM `alloca` (non-escaping; freed at fn return). The decision
is made by an escape-analysis pre-pass over the fn body — see
Decision 9's "Per-fn arena via stack `alloca`" subsection.
Boehm-only soak tests are unchanged: `examples/gc_stress.ail.json`
and `examples/std_list_stress.ail.json` still allocate via
`@GC_malloc` because their boxes flow into other fns and escape.
The per-fn-arena path is exercised end-to-end by
`examples/escape_local_demo.ail.json`.
- **First-class function references.** A top-level fn name (or
qualified `prefix.def`) used as a `Term::Var` is a fn-value.
- **Anonymous lambdas with capture.** `Term::Lam` constructs a
closure that captures any free variables of its body from the
enclosing scope. All fn-values share a single ABI: a `ptr` to a
closure pair `{ thunk_ptr, env_ptr }`. Top-level fns get an auto-
generated adapter and a static closure pair (env = null) so they
remain passable as values without heap overhead.
- **Polymorphism via `Type::Forall`** at top-level def types.
Use sites instantiate fresh metavars; unification pins them against
the concrete types of the call args. Codegen monomorphises on
demand: each unique instantiation emits a specialised LLVM fn
mangled `@ail_<m>_<def>__<descriptor>` (e.g. `id__I` for `id` at
`Int`, `apply__I_I` for `apply` at `(Int, Int)`).
- **Parameterised ADTs.** `TypeDef.vars: Vec<String>`
declares type parameters; `Type::Con.args: Vec<Type>` carries the
type arguments at use sites. Both fields default to empty and are
skipped during serialization, so canonical-JSON hashes of every
existing definition stay bit-identical (regression test in
`crates/ailang-core/src/hash.rs`). Ctor and match codegen stay
inline at every use site — there is no specialised ADT symbol —
but LLVM field types are derived per use site by substituting
through `cdef.ail_fields`. The substitution is read off the call's
arg types (ctor) or the scrutinee's `Type::Con.args` (match). An
unresolved `Type::Var` reaching `llvm_type` is a hard error
rather than a silent fallback to `ptr`.
Pipeline regression smoke tests:
- `examples/sum.ail.json` → prints 55 (recursion, arithmetic).
- `examples/list.ail.json` → prints 42 (ADTs + match).
- `examples/hof.ail.json` → prints 42 (first-class fn-refs, indirect call).
- `examples/closure.ail.json` → prints 42 (lambda capturing a let-bound var).
- `examples/list_map.ail.json` → prints 2/4/6 (ADTs + closure + recursive
HOF + IO; the dogfood smoke test).
- `examples/sort.ail.json` → prints sorted [3,1,4,1,5,9,2,6,5,3,5]
one-per-line (insertion sort over an 11-element list).
- `examples/poly_id.ail.json` → prints 42 then "true" (polymorphic
identity at `Int` and `Bool`; two specialised fns emitted).
- `examples/poly_apply.ail.json` → prints 42 (polymorphic `apply`
with a fn-typed parameter; `apply(succ, 41)`).
- `examples/box.ail.json` → prints 42 (parameterised ADT round-
trip: `MkBox(42)` constructed, then projected by a polymorphic
`unbox : forall a. (Box<a>) -> a` and printed).
- `examples/maybe_int.ail.json` → prints 7 then 99 (pattern match
over `Maybe<Int>`: `or_else(Some(7), 99)` then
`or_else(None, 99)`).
- `examples/std_list_demo.ail.json` → exercises
`std_list`'s combinators (length, sum, reverse, take/drop-style
uses) end-to-end against `std_list`'s `List<a>`.
- `examples/std_maybe_demo.ail.json` → exercises `std_maybe`
combinators over `Maybe<Int>`, including `from_maybe` and `map`.
- `examples/std_either_demo.ail.json` → first program with
three distinct type variables in a single fn (the `either`
eliminator), monomorphised six different ways in the IR.
- `examples/std_pair_demo.ail.json` → drives every
`std_pair` combinator (fst, snd, swap, map_first, map_second);
expected output 7, 9, 9, 7, 8, 18.
- `examples/nested_pat.ail.json` → first program to use a
nested `(pat-ctor Cons a (pat-ctor Cons b _))`; the desugar pass
flattens it into a chain that the existing flat-match codegen
consumes. Prints 30 for a 3-element input list.
Ratified by: `crates/ailang-core/tests/effect_doc_honesty_pin.rs`.
+86
View File
@@ -0,0 +1,86 @@
# Str ABI
## Heap-Str primitives
The runtime ships a small family of operations that produce or
transform heap-allocated `Str` values uniformly across static-Str
and heap-Str inputs (the consumer ABI is identical between
realisations — see §"Str ABI"). All take their input(s) by `borrow`
and return an owned `Str`. Each is registered as a builtin in
`crates/ailang-check/src/builtins.rs`, lowered inline in
`crates/ailang-codegen/src/lib.rs::lower_app` to a `call ptr @ailang_<name>`,
and backed by a `runtime/str.c` C helper.
- `int_to_str : (borrow Int) -> Str` (iter 24.1) — decimal rendering
of an `Int`. Backs `Show Int` in the prelude.
- `bool_to_str : (borrow Bool) -> Str` (iter 24.1) — `"true"`/`"false"`.
Backs `Show Bool` in the prelude.
- `float_to_str : (borrow Float) -> Str` (iter 24.1) —
type-installed; codegen is reserved and not yet shipped.
- `str_clone : (borrow Str) -> Str` (iter 24.1) — allocates a fresh
heap-Str copy of the input's bytes. Backs `Show Str` in the prelude.
- `str_concat : (borrow Str, borrow Str) -> Str` (iter str-concat,
2026-05-13) — combines two `Str` values into a single owned `Str`.
General-purpose; commonly used in Show bodies for labelled output
(`(app str_concat "label=" (app int_to_str x))`).
The four Show-backers above are not directly observable to the
LLM-author writing a `Show <T>` instance — the prelude's instance
bodies dispatch into them. `str_concat` IS directly observable
because the LLM-author calls it explicitly when authoring an
instance body that wants to combine fragments.
Primitive output for `Str` values goes through `io/print_str`
directly; values of other primitive types route through the
polymorphic `print` helper (§"Polymorphic print"), which feeds
the heap-Str result of `show x` into `io/print_str`.
`==`, `<`, `<=`, `>`, `>=` REMAIN primitive operators (unchanged
from the original draft). Class methods are accessed by name (`eq x y`,
`lt x y`, …), not via these operators. Routing operators through
classes is deliberately deferred — it would require migrating every
existing fixture and would re-baseline the bench corpus, which is a
new-baseline decision rather than an iter detail.
`Num` is NOT in milestone 22. Arithmetic operators (`+`, `-`, `*`,
`/`) stay primitive and per-type. Class-based numeric overloading
would invoke literal-defaulting which axis-7 already excluded.
**Str ABI.** A `Str` is a pointer to a structure with `i64 len` at
offset 0 followed by `len` bytes plus a trailing `NUL` at offset 8.
Two realisations share this consumer ABI:
| Realisation | Origin | rc_header | Memory |
|-------------|-------------------------------------------------|-----------|-----------------------------------------|
| static-Str | string literals (`@.str_*` LLVM globals) | none | `.rodata`, packed-struct `<{ i64, [N+1 x i8] }>` |
| heap-Str | runtime allocations (`int_to_str`, `float_to_str`, ...) | yes, at `payload - 8` | `malloc`'d via `ailang_rc_alloc(8 + len + 1)` |
Every consumer (`@puts`, `@strcmp`, `@ail_str_eq`,
`@ail_str_compare`) GEPs `+8` from the Str pointer to reach the
bytes, regardless of realisation. The byte-comparison semantics
are inherited from libc `strcmp` — locale-independent, NUL-
terminated.
The heap-Str realisation participates in standard RC: the
`rc_header` slot eight bytes before the `len` field is managed
by `ailang_rc_alloc` / `ailang_rc_inc` / `ailang_rc_dec` exactly
like any other RC-allocated cell. The static-Str realisation has
no `rc_header` slot at all; the bytes at `payload - 8` belong to
the previous global in `.rodata` and reading them is undefined.
**The static-Str non-RC invariant is enforced at codegen.** Two
mechanisms keep static-Str pointers out of `ailang_rc_dec` along
every shipping execution path: (1) the non-escape lowering pass
(iter 18b) and the move-tracking partial-drop logic (iter 18d.3)
prevent let-binders or pattern-binders for static-Str literals
from reaching scope-close drop emission; (2) the `Type::Con { name: "Str" }`
carve-outs in `field_drop_call` and in the `Term::App` arm of
`drop_symbol_for_binder` (both in `crates/ailang-codegen/src/drop.rs`)
route the rare case that *does* reach drop emission through
`ailang_rc_dec`, which itself only fires for heap-Str at runtime
(static-Str pointers never carry a live rc_header; if codegen ever
let one through, the runtime would corrupt `.rodata`-adjacent
memory). No runtime guard backs the invariant up; the codegen
proof is the protection.
Ratified by: `crates/ail/tests/e2e.rs`.
+60
View File
@@ -0,0 +1,60 @@
# Tail calls
## Decision 8: explicit, verified tail calls
For an LLM author, recursion is the natural iteration form
(`\n. if n == 0 then () else loop(n-1)` is what I reach for, not
a `for`-loop). Without a tail-call guarantee, every recursive
program has a silent stack-depth ceiling that no compile-time
diagnostic warns about. That is exactly the class of correctness
hazard the language exists to eliminate.
Solution: explicit, verified tail calls.
- **AST.** `Term::App { fn, args, tail: bool }` and
`Term::Do { op, args, tail: bool }` gain a `tail` flag,
serde-defaulting to `false` so existing fixtures load with
`tail: false` and their hashes stay bit-identical.
- **Typecheck.** A new pass `verify_tail_positions(fn_body)`
runs after the main type-check. It walks the body with an
`is_tail_context: bool` threaded down. The flag is `true` at
the start, `true` for the body of every `Term::Match` arm,
`true` for the right operand of `Term::Seq`, `true` for the
body of `Term::Let`, `true` for the body of `Term::Lam` (each
Lam opens its own tail scope). The flag is `false` for: args
of any `App`/`Do`/`Ctor`, scrutinee of `Match`, left of
`Seq`, condition of `Let`-bound expression. When the walker
visits an `App { tail: true }` or `Do { tail: true }`, the
flag must be `true` at that visit; otherwise emit diagnostic
`tail-call-not-in-tail-position`.
- **Codegen.** Emit `musttail call` (LLVM IR) for marked calls
instead of plain `call`. LLVM rejects at IR-verification
time if the call cannot physically be a tail call (calling
convention mismatch, signature divergence, etc.). The reject
surfaces as a hard build error, not a silent runtime
surprise.
- **Form (A).** Two new keywords: `tail-app`, `tail-do`.
Productions are positional analogues of `app`/`do` with
`tail: true` set on the resulting term. EBNF gains 2 lines;
total production count goes from ~28 to ~30, still inside
the constraint-1 budget.
**What this does NOT promise.** Per the tail-call survey of existing fixtures,
many existing recursive calls are *not* in tail position
because they are arguments to constructor calls (e.g.
`Cons (f h) (map f t)`). The current pipeline adds annotation + verification;
it does **not** add a CPS transform or accumulator-form
rewrite. Programs whose recursion is constructor-blocked
will continue to be stack-bounded by recursion depth. The
canonical authoring pattern in such cases is to write the
accumulator-form variant (`map_acc`, `fold_left`, etc.)
explicitly. The stdlib ships both forms where
relevant.
Migration of existing fixtures is partial: only
`print_list`-style terminal recursions get marked. The
constructor-blocked recursions in `map`, `sort`, `insert`
remain unmarked — they cannot benefit from `musttail`
without a source-level rewrite.
Ratified by: `crates/ailang-check/src/lib.rs`.
+319
View File
@@ -0,0 +1,319 @@
# Typeclasses
## Form-A schema (the JSON authoring surface)
Three additive schema extensions; no existing module becomes invalid.
**`ClassDef`** — top-level definition kind, declares a class:
```
{ "kind": "class",
"name": "Show",
"param": "a",
"superclass": null,
"methods": [
{ "name": "show",
"type": { /* full FnSig over `a`, with mode annotations */ },
"default": null
}
]
}
```
`superclass` is either `null` or `{ "class": <name>, "type": "a" }`
where `"a"` MUST be the same identifier as the class's own `param`.
`default` is either `null` (method is abstract-required at instance
sites) or an AST body (method is optional with the body as fallback).
**`InstanceDef`** — top-level definition kind, declares an instance:
```
{ "kind": "instance",
"class": "Show",
"type": "Int",
"methods": [
{ "name": "show", "body": <AST> }
]
}
```
`type` is a concrete type expression, never the class param. `methods`
contains the bodies for every required method plus optional overrides
of default-bearing methods.
**`FnDef.type` extension** — the existing FnSig schema gains a
`constraints` sibling field next to `forall`:
```
"type": {
"forall": ["a"],
"constraints": [{ "class": "Show", "type": "a" }],
"type": ["Fn", [...], "String"]
}
```
When `forall` is empty, `constraints` is also empty (and may be
omitted for hash stability of older definitions). Each constraint's
`type` field MUST reference a type variable bound by the surrounding
`forall`.
**Method invocation sites do NOT get a new node.** A call to a class
method is a normal `Call` node; resolution against a class is the
typechecker's job, not the parser's. The LLM author writes `show x`
exactly as for any free function.
## Cross-module references in synthesised bodies
The unified mono pass (per Decision 11's milestone-23.4 reorganisation)
synthesises mono symbols for polymorphic free fns and class-method
instances in the symbol's owner module — e.g. `print__Int` lives in
`prelude` (because `print` is defined in the prelude), but a
user-ADT call site `print (MkIntBox 7)` causes synthesis of
`prelude.print__IntBox` whose body references
`show_user_adt.show__IntBox` (the user instance lives in the
user-defining module per Decision 11 coherence). The synthesised body
crosses a module boundary the source template did not.
Three invariants make this work, all installed in milestone 24's iter
24.3 and worth keeping load-bearing:
1. **`MonoTarget::FreeFn::type_args` carries canonical types
post-collection.** At every site where `subst.apply(m)` produces a
concrete substitution that enters `MonoTarget::FreeFn::type_args`
(`crates/ailang-check/src/mono.rs::collect_mono_targets` and
`::collect_residuals_ordered`), the resolved `Type` must be passed
through `Registry::normalize_type_for_lookup(caller_module, &t)`
before being pushed. The downstream synthesised body's Phase 3
rewrite cursor (which runs in the OWNER module's context, not the
caller's) keys lookups in `Registry::entries` by the canonical
qualified form; a bare type-con reference at the type_args layer
silently drops the cross-module mono-symbol synthesis and leaves a
bare `Var "show"` post-mono that codegen later rejects with
`unknown variable`.
2. **Post-mono synthesised body cross-module references may bypass
the source template's `import_map`.** `prelude` does not import
user modules (the auto-injection runs one-way: user workspaces
import prelude, never the inverse). But a synthesised body for
`prelude.print__<UserType>` references `<user_module>.show__<UserType>`,
created by mono — not by the prelude source. Codegen's
cross-module name-resolution must accept this: at
`crates/ailang-codegen/src/lib.rs::resolve_top_level_fn` (Var-
resolution), `::lower_app`'s cross-module call arm, and
`::synth_with_extras`'s Var arm, the resolution first tries the
current module's `import_map`, then falls back to a direct
`module_user_fns` / `module_def_ail_types` lookup against the
prefix. Both ends were independently typechecked under their own
module contexts before mono ran; the cross-module reference is a
post-mono construct, not a source-language one.
3. **FreeFnCall synth pushes one residual per declared forall-
constraint.** At `crates/ailang-check/src/lib.rs`'s synth Var arm
for the `prefix.suffix` free-fn path, when the type is a
`Type::Forall { vars, constraints, body }`, instantiation produces
fresh metavars for the `vars` AND pushes one `ResidualConstraint`
per entry in `constraints` (with rigid vars substituted by the
freshly-generated metavars). The downstream discharge loop at
`check_fn`'s post-synth phase resolves each residual against the
workspace registry; if no instance satisfies the residual at the
unified concrete type, the `NoInstance` diagnostic fires at
typecheck (correctly), not at codegen (confusingly). Without the
residual push, milestone-23-shape negative cases (e.g. `print f`
where `f : Int -> Int`) silently typecheck and surface as
`unknown variable: show` from codegen instead of the right
typecheck-phase NoInstance Show.
The three invariants are lockstep partners: invariant (1) creates the
cross-module reference, invariant (2) makes codegen able to resolve
it, invariant (3) makes the typecheck-time discharge fire correctly
when no instance exists. A future refactor that loosens any one of
the three breaks the user-ADT trajectory; the test pins at
`crates/ail/tests/codegen_import_map_fallback_pin.rs` (invariant 2),
`crates/ail/tests/polyfn_dot_qualified_branch_pin.rs` (invariant 3
+ lockstep), and the existing `crates/ail/tests/show_user_adt`
fixture (full trajectory) collectively protect the contract.
## Defaults and superclasses
**Defaults.** A `ClassDef.methods[i].default` is either `null` (the
method is required at instance level) or an AST body. When an
`InstanceDef` does not specify a method that has a default, the
typechecker uses the default body for that instance, with the class
param substituted to the instance type. Default bodies may call
other methods of the same class (the canonical example is
`default ne x y = not (eq x y)`); the called methods resolve at
monomorphisation time against the same instance.
**Superclasses.** A `ClassDef.superclass` of `{ "class": "Eq",
"type": "a" }` declares that any `instance C T` requires a
corresponding `instance Eq T` to exist. The check fires at
workspace-load time: for each `InstanceDef` whose class declares a
superclass, the registry is queried for the matching superclass
instance. Missing → `MissingSuperclassInstance`.
**Superclass auto-expansion in constraint contexts.** When a function
declares `Ord a` as a constraint, the typechecker treats the constraint
context as `{ Ord a, Eq a }` for the purposes of `MissingConstraint`
checking. This is the one deviation from "alles sichtbar": the
extension is anchored in the class's own `superclass` field, so the
relation is schema-visible at the class declaration even when the
constraint at the function site reads `Ord a` alone.
Superclass chains are linear (single-superclass relation per class)
and auto-expansion closes transitively across the chain.
**No `deriving`.** AILang does not auto-derive instances. `instance Eq
MyType` must be written by hand.
## Diagnostic categories
The typeclass layer introduces three families of diagnostics. Exact
wording is fixed; the categories and their triggers are:
**Workspace-load (registry-build) diagnostics:**
- `OrphanInstance` — instance is not in class's or type's module.
- `DuplicateInstance` — two instances match the same key.
- `MissingSuperclassInstance` — superclass instance absent.
- `MissingMethod` — instance omits a required method.
- `OverridingNonExistentMethod` — instance specifies a method not in
the class.
(`MethodNameCollision` was retired at iter mq.3. Cross-class method
sharing is now structurally legal; ambiguity surfaces at the call
site via `AmbiguousMethodResolution` or, for class-fn name overlap,
via the `class-method-shadowed-by-fn` warning. See §"Method
dispatch" below.)
**Class-schema diagnostics** (validation of class declarations):
- `InvalidSuperclassParam` — superclass `type` differs from the
class's own `param`.
- `ConstraintReferencesUnboundTypeVar` — a constraint mentions a
type variable not bound by the surrounding `forall`.
A class param appearing in applied position (e.g., `f a` where `f`
is the class param) is rejected earlier by the canonical-form
validator as `BareCrossModuleTypeRef``f` is a bare,
non-primitive name not declared as a `TypeDef` in the owning
module — so the dedicated `KindMismatch` diagnostic was retired
at iter ctt.3.
**Typecheck diagnostics** (per function body):
- `MissingConstraint` — body's residual constraint is not covered
by declared (and superclass-expanded) constraints.
- `NoInstance` — fully concrete constraint has no registry entry.
mq.2 adds an optional `candidate_classes` field surfacing the
multi-candidate set when the bare-method dispatch path's filter
collapses to zero registry survivors.
- `AmbiguousMethodResolution` (mq.2) — a monomorphic `Term::Var`
call site survives both type-driven and constraint-driven filters
with more than one candidate class. LLM-author writes the explicit
qualifier form `<module>.<Class>.<method>` to disambiguate.
- `UnknownClass` (mq.2) — an explicit class qualifier in
`Term::Var.name` names a qualified class that is not in the
workspace's candidate-class index for the method.
- `class-method-shadowed-by-fn` (mq.3, warning) — a `Term::Var`
resolved via fn lookup precedence (locals → caller-module-fn →
imported-fn) while a class method of the same name also exists
in the workspace. Fn resolution proceeds; the warning surfaces
the shadow so the LLM-author can disambiguate via explicit
class-qualified call if the shadow was unintentional.
There is no `AmbiguousInstance` diagnostic at the registry level —
coherence (`DuplicateInstance` at registry build, W2) makes
per-`(class, type)` ambiguity structurally impossible. Cross-class
method ambiguity is a separate concern resolved at the call site
via `AmbiguousMethodResolution` (see §"Method dispatch" below).
## Method dispatch
Post-mq.3, dispatch is two-mode:
**Polymorphic call sites** (inside a fn body with `forall` +
constraint set): the constraint names the class via the qualified
`Constraint.class` field (canonical-form per mq.1). Synth's
residual carries the class name directly; constraint-discharge at
fn-body-end matches against the workspace registry by
`(class, type_hash)` key.
**Monomorphic call sites**: synth consults the workspace-flat
`Env.method_to_candidate_classes: BTreeMap<MethodName,
BTreeSet<QualifiedClassName>>` index, then runs the 5-step
dispatch rule:
1. Parse the `Term::Var.name` for an optional class qualifier
(last-dot-segment is the method name; everything before is the
qualified class).
2. If `method_to_candidate_classes` has no entry for the method
name, fall through to the existing Var-arm branches (free fn
lookup, dot-qualified cross-module).
3. Qualifier present: filter candidates to the named class. Empty
result fires `UnknownClass`. Singleton survivor proceeds.
4. Qualifier empty (bare-method form): singleton candidate proceeds
directly; multiple candidates yield a multi-candidate residual
for discharge-time refinement.
5. At discharge, refinement runs: concrete `type_` filters
candidates via the workspace registry; rigid-var `type_` filters
via the active fn's declared constraints
(`env.active_declared_constraints`). Single survivor discharges;
multiple survivors fire `AmbiguousMethodResolution` (concrete) or
`MissingConstraint` (rigid-var); zero survivors fire `NoInstance`
(concrete) or `MissingConstraint` (rigid-var).
The `method_to_candidate_classes` index is the load-bearing data
structure for this routing — its construction in `build_check_env`
inverts the per-module `class_methods` maps (themselves tuple-keyed
by `(qualified-class, method)` post-mq.3) to a workspace-flat
method-name-to-class-set map.
Class-fn collisions resolve at the call site, not at workspace load
time: the fn lookup precedence (locals → caller-module-fn →
imported-fn) runs ahead of the class-method branch. When both
sides have a match, the fn wins and a `class-method-shadowed-by-fn`
warning surfaces the shadow.
## Prelude (built-in) classes
Milestone 22 ships **no built-in Prelude classes**.
An earlier draft committed to a fixed Prelude (Show / Eq / Ord on the
primitives), but the implementation work to wire `int_to_str` as a
heap-allocated-string runtime primitive proved substantively
separable from the typeclass machinery itself, and the LLM-utility
case for primitive `Show` is weak (LLM-natural form is `int_to_str
x`, not `show x` through a single-instance class). The user-class
end-to-end path is the milestone's typeclass acceptance gate
(the user-defined-class fixture: `class Foo a` + `data IntBox` +
`instance Foo IntBox`); see `docs/specs/2026-05-09-22-typeclasses.md`
"Amendments" for the substantive rationale.
Milestone 23 amends the above: the prelude now ships the `Ordering`
ADT, the `Eq` and `Ord` classes, primitive `Eq Int/Bool/Str` and
`Ord Int/Bool/Str` instances, and the five polymorphic free-fn helpers
`ne` / `lt` / `le` / `gt` / `ge`. Operator routing through `Eq`/`Ord`
and `print`-rewire remain out of scope per their original substantive
reasons (bench rebaseline, milestone-24 dependency on the post-mq
dispatcher); `Show` itself ships in milestone 24. Float has neither
`Eq` nor `Ord` instance per §"Float semantics"; a polymorphic helper
invoked at Float fires `NoInstance` at typecheck with a Float-aware
diagnostic cross-referencing this section.
Milestone 24 amends the above further: the prelude ships `class Show
a where show : (a borrow) -> Str` and primitive `Show Int`,
`Show Bool`, `Show Str`, `Show Float` instances (iter 24.2). Float
**is** included in Show (unlike Eq/Ord) — IEEE-754 makes structural
equality and total ordering semantically dubious, but textual
representation of a Float is well-defined modulo the NaN-spelling
caveat in §"Float semantics". Each `Show <T>` instance body is a
single-application lambda invoking the corresponding runtime primitive
(`int_to_str`, `bool_to_str`, `str_clone`, `float_to_str`); no codegen
intercept is required. The polymorphic helper `print : forall a. Show
a => a -> () !IO` shipped in iter 24.3 with body
`\x -> let s = show x in do io/print_str s` (explicit let-binder for
heap-Str RC discipline per eob.1 Str carve-out). The let-binder is
structurally pinned by `crates/ail/tests/print_mono_body_shape.rs`.
Routing through `print` is the path for non-`Str` primitives;
`io/print_str` is the only built-in direct-output effect-op.
Ratified by: `crates/ail/tests/show_no_instance_e2e.rs`.
+16
View File
@@ -0,0 +1,16 @@
# Verification and correctness (across cycles)
## Verification and correctness (across cycles)
1. **Snapshot tests** for the pretty-printer and IR emit. The diff makes
regressions visible immediately.
2. **Property tests** for the JSON ↔ pretty-print roundtrip.
3. **End-to-end tests** for `examples/` with expected program output.
4. **Hash stability**: a test ensures the same def always produces the same
hash.
5. **CI pin** of the outputs in `tests/expected/`.
6. **Rustdoc cleanliness**: `cargo doc --no-deps` runs warning-free.
Fixing a rustdoc warning is part of the iteration that
introduced it, not a follow-up.
Ratified by: `bench/architect_sweeps.sh`.
+226
View File
@@ -0,0 +1,226 @@
# Authoring surface — notation rationale whitepaper
## Candidate notations (same `map` encoded in each)
The reference target — the polymorphic `map` from `examples/list_map_poly.ail.json`:
```
data List a where Nil | Cons a (List a)
fn map : forall a b. ((a) -> b, List a) -> List b
= \f xs. match xs of Nil -> Nil
| Cons h t -> Cons(f(h), map(f, t))
```
#### (A) S-expression with fully-tagged AST nodes
```
(module list_map_poly
(data List (vars a)
(ctor Nil)
(ctor Cons a (con List a)))
(fn inc
(type (fn-type (params (con Int)) (ret (con Int))))
(params x)
(body (app + x 1)))
(fn map
(type
(forall (vars a b)
(fn-type
(params (fn-type (params a) (ret b)) (con List a))
(ret (con List b)))))
(params f xs)
(body
(match xs
(case (pat-ctor Nil) (term-ctor List Nil))
(case (pat-ctor Cons h t)
(term-ctor List Cons
(app f h)
(app map f t)))))))
```
Grammar core (3-rule lexical layer + ~25 named-form productions):
```
sexpr ::= atom | "(" sexpr* ")"
atom ::= integer | string | ident
ident ::= any maximal non-whitespace, non-paren run that is not
a recognised integer or string literal.
```
The lexer recognises one delimiter (`(` / `)`) and whitespace.
Every other maximal token is classified post-hoc:
- All-digit run with optional leading `-` → integer atom.
- `"`-delimited run → string atom.
- Otherwise → ident.
Consequence: operators like `+`, `==`, `<=`, `**`, qualified
names like `io/print_str`, and cross-module references like
`std_list.map` are all single ident tokens with no special lex
rule. The only reserved tokens are `(`, `)`, and whitespace.
Bool literals (`true`, `false`) and unit (`(lit-unit)`) are
disambiguated by parser context, not by lex.
Every AST node form has a unique head keyword (`module`, `data`,
`fn`, `forall`, `fn-type`, `con`, `var`, `app`, `lam`, `match`,
`case`, `pat-ctor`, `term-ctor`, `do`, `seq`, ...). A bare atom in
a positional slot (e.g. inside `(con List a)` second position) is
a name reference whose **sort** is determined by the parent slot:
- inside `(con NAME args...)` second-and-later positions → type
expression. Bare atom there ⇒ `Type::Var { name }`.
- inside `(app HEAD args...)` first position ⇒ `Term::Var`.
- inside `(pat-ctor CTOR fields...)` field positions ⇒
`Pattern::Var`.
- inside `(case PAT BODY)` second position ⇒ term.
There is **no lexical case rule**. To construct a value with a
ctor, write `(term-ctor TypeName CtorName args...)`. To match
against one, write `(pat-ctor CtorName fields...)`. Capitalised
identifiers carry no special meaning to the parser. This rules
out a class of silent errors ("I forgot to capitalise `Cons` and
it parsed as a function call").
**Pros:** smallest formal grammar of any candidate (the lexical
core is 3 rules; the named-form productions are uniform — every
node a tagged list). Foreign-LLM bar lowest. Round-trip with the
existing pretty-printer is a refactor of `pretty.rs` to emit this
tagged form, plus a new parser.
**Cons:** paren density is high. `(forall (vars a b) (fn-type
...))` has more visual nesting than the current pretty-printer's
`forall a. (...) -> ...`. Verbosity is ~2× JSON for the same node
when measured in characters, but ~8× shorter in lines (the
existing JSON `box.ail.json` of 160 lines becomes ~20 lines in
this form).
#### (B) Indented record-style with explicit terminators
```
module std_list
data List(a):
Nil
Cons(a, List(a))
end
fn map:
type: forall a b. fn(fn(a) -> b, List(a)) -> List(b)
params: f, xs
body:
match xs:
Nil => Nil
Cons(h, t) => Cons(f(h), map(f, t))
end
end
```
Grammar core (~2030 productions): module-level (def/data/end), type
sub-grammar (forall, fn, con, var), term sub-grammar (lam, match,
ctor, app, lit, var, seq), pattern sub-grammar.
**Pros:** higher information density per line, closer to mainstream
ML/Haskell shape. **Cons:** four sub-grammars instead of one.
`forall a b. fn(...)` keeps a pseudo-precedence (`->` binds tighter
than the outer `fn(...)` wrapper). Foreign-LLM bar higher.
#### (C) Pretty-printer-as-source
Use exactly the format `pretty::module` already emits, plus a parser
that accepts it. The existing pretty-printer's quirks (`::` for
type-of, `[params]` for fn-params, `<a>` for type-args, `forall a. ...`,
`!IO`, `()` ambiguous between unit-arg-list and empty-form) become
the spec.
**Pros:** zero churn — the existing pretty-printer is already the
spec; only the inverse is missing. Round-trip is the identity by
construction. **Cons:** the existing format mixes four mini-dialects
(s-expr at term level, ML-shape at type level, square brackets for
params, `<>` for type args). Formalising it crisply is harder than
designing a uniform form from scratch.
## Form (B) — human prose projection
AILang ships a
second textual projection of the AST: `ailang-prose`, a one-way
projection from `Module → human-readable text`. It is **not** an
authoring surface; it is the "display" projection that Decision 6's
architectural pin (line 167176) explicitly anticipated:
> *"Future projections are explicitly anticipated: a visual /
> graphical front-end is a plausible second projection for human
> review and inspection (display being the one case where non-AI
> eyes matter). The architecture leaves room: any producer of
> well-formed `ailang-core::ast::Module` values is a valid
> front-end."*
Form (B) targets the specific failure mode where a human reviewer
needs to read an AILang module quickly. Form (A) was designed to
fit a 30-production EBNF spec and to be parsed zero-shot by foreign
LLMs; that prioritisation makes it dense and visually noisy for
human readers. Form (B) inverts the trade-offs:
- **Rust-flavoured surface.** Braces and `=>` for match arms,
Rust-aligned 4-level operator precedence, infix arithmetic
(`a + b`, not `+(a, b)`), unary `!` for `not`.
- **Lossy by design.** Projection elides machinery the LLM can
re-derive: `(con T)` wrappers (`(con Int)``Int`), the
`(fn-type (params ...) (ret ...))` wrap, `(term-ctor T C ...)`
collapses to `C(...)`, redundant parens. Only the AST machinery
whose information is recoverable from typecheck context.
- **Lossless on load-bearing detail.** Mode annotations
(`own T`, `borrow T`), effects (`with IO`), explicit `clone`,
`reuse-as`, doc strings, type annotations on signatures and
lambdas, the `tail` flag — all preserved verbatim.
Critically, **form (B) has no parser**. Form (A) is round-trippable
by construction (Decision 6 constraint 2); form (B) deliberately is
not. Re-integrating prose edits requires an external LLM mediator,
not a compiler pass — see `docs/PROSE_ROUNDTRIP.md` for the
six-step cycle and the prompt template `ail merge-prose` composes.
Form (B) does not weaken any Decision 6 invariant:
- The JSON-AST remains the only hashable artefact. Prose is not
hashed, not content-addressed, not load-bearing for any
cross-module reference.
- Form (A) remains the canonical authoring surface. Foreign LLMs
still author against form (A); humans review and edit through
form (B).
- The 30-production grammar of form (A) is unchanged.
- `ailang-check` and `ailang-codegen` remain projection-agnostic;
`ailang-prose` is a downstream consumer of `ailang-core::ast`,
parallel to `ailang-surface` but in the rendering direction only.
The CLI gains `ail prose <m.ail.json>` (the deterministic
projection) and `ail merge-prose <m.ail.json> <edited.prose.txt>`
(the mediator-prompt composer); both are listed in the CLI section
below.
**Form-A spec embedding.** An earlier `merge-prose`
prompt instructed the LLM to emit JSON-AST and offered a 12-line
schema-essentials reminder; that combination did not give a
foreign LLM enough to produce valid output. The current prompt revises this:
- The LLM emits **Form-A** (the canonical authoring surface), not
JSON. JSON-AST stays the only hashable artefact, but it is not
a writing surface. The user runs `ail parse foo.new.ail`
before `ail check` to produce the canonical JSON.
- `crates/ailang-core/specs/form_a.md` is the complete LLM-targeted
Form-A specification — grammar, every term / pattern / type /
def keyword, schema invariants, pitfall catalogue, four
few-shot modules drawn from `examples/*.ail`. It is exported
as `ailang_core::FORM_A_SPEC` and embedded verbatim in every
`merge-prose` prompt.
- `crates/ailang-core/tests/spec_drift.rs` walks every variant of
`Term`, `Pattern`, `Type`, `Def`, `Literal` via exhaustive
`match` and asserts an anchor for each appears in the spec.
The exhaustive match is the load-bearing piece: adding a new
variant without updating the match fails compilation in this
test, before its assertions even run. Hand-written content,
mechanical drift detection.
The cycle's lowest-common-denominator path is the static prompt
(`ail merge-prose | client | ail parse | ail check`), which works
with any client.
+16
View File
@@ -0,0 +1,16 @@
# Effects — pure core + algebraic effects
## Decision 3: pure core language + algebraic effects
The default is total, pure functions. Effects are declared as a set in the
function type: `(Int) -> Int ![IO]`. The effect set is a
flat, unordered, closed set of effect names, unified by set-equality —
there is no effect row variable; a signature lists exactly the effects
its body may perform.
In the MVP only the effect `IO` is wired up (its sole op is `io/print_str`).
`Diverge` (for non-termination) is reserved as an effect name but is
unimplemented — no op, no codegen, no checker injection — in the same
sense Decision 4 reserves refinements.
This is the most important LLM property: when I read a function, I can trust
its signature without reading the body.
+72
View File
@@ -0,0 +1,72 @@
# Pipeline and CLI
## Pipeline
```
.ail.json ─┐
├─ load + validate schema
├─ resolve names + assign hashes
├─ desugar (AST → AST)
├─ typecheck (HM, effect rows; mode-strict per Decision 10)
├─ lift_letrecs (post-typecheck AST → AST)
├─ lower to MIR (SSA-like, named SSA values)
├─ emit LLVM IR (.ll)
└─ clang -O2 *.ll -o binary
--alloc=rc → emits inc/dec (@ailang_rc_inc / _dec; canonical, default)
--alloc=gc → links libgc (@GC_malloc; parity oracle)
```
Two allocator backends share the same MIR. `--alloc=rc` is the
canonical backend committed to in Decision 10 and the CLI default.
The typechecker enforces
`(own)` / `(borrow)` modes, codegen emits `ailang_rc_inc` / `_dec`
calls at the points dictated by linearity, and `Term::Clone` /
`Term::ReuseAs` materialise into actual rc-bumps and in-place
rewrites respectively. `--alloc=gc` selects the transitional Boehm
backend; `--alloc=rc` is the canonical backend (Decision 10) and the
CLI default.
The **desugar** pass (`ailang-core::desugar::desugar_module`) runs
before typecheck and codegen in every entry point of `ailang-check`
and `ailang-codegen`. It is a pure AST → AST rewriter — currently
only flattens nested constructor patterns, but is the chosen
home for any future surface-smoothing rewrites that should not bloat
the core AST or the backends. **Critical invariant:** `CheckedModule.symbols`
in the `check` entry point continues to hash from the *original*
on-disk module, not the desugared one, so `ail diff` and `ail manifest`
report identities that match the canonical JSON the user is editing.
The **lift_letrecs** pass (`ailang-check::lift_letrecs`)
runs **after** typecheck and **before** codegen, but only on the
`build` / `run` paths — the `check` subcommand stops at typecheck
and never sees a lifted module. It eliminates every `Term::LetRec`
that the desugar pass left in place (the case where at least one
capture is `Term::Let`-bound, so its type is only knowable after
inference). The output is a module with synthetic `<hint>$lr_N`
top-level fns appended, ready for codegen. Synthetic FnDefs added
by this pass do **not** appear in `CheckedModule.symbols` — same
invariant as the desugar-pass lifts.
## CLI
```
ail check <module.ail.json> — loads, validates, typechecks
ail manifest <module.ail.json> — table: name :: type !effects [hash]
ail describe <module> <name> — detail of a definition (form-A body)
ail render <module.ail.json> — JSON-AST → form-A text (exact inverse of `parse`)
ail parse <module.ail> — form-A text → canonical JSON-AST
ail prose <module.ail.json> — JSON-AST → form-B (lossy human prose, no parser)
ail merge-prose <m.ail.json> <m.prose.txt>
— compose the LLM-mediator prompt for the prose round-trip
(see docs/PROSE_ROUNDTRIP.md)
ail deps <module.ail.json> — list cross-module references
ail diff <a.ail.json> <b.ail.json> — content-addressed def-level diff
ail workspace <entry.ail.json> — list all modules transitively reachable from entry
(`--json` for machine output;
`manifest --workspace` and `diff --workspace`
extend single-module subcommands to workspaces)
ail builtins — list built-in fns and effect ops
ail emit-ir <module> [--emit=staticlib] — writes .ll (staticlib: a main-free kernel's IR, no @main)
ail build <module> [--emit=staticlib] — full pipeline → binary (staticlib: lib<entry>.a + libailang_rt.a)
ail run <module> — build + execute (tempdir), passthrough exit code
```
+315
View File
@@ -0,0 +1,315 @@
# RC + Uniqueness — memory model whitepaper
## Decision 9: dual allocator — RC canonical, Boehm parity oracle
AILang ships two allocator backends with an asymmetric role:
- **RC is canonical.** `--alloc=rc` is the CLI default for
`ail build` and `ail run`. The runtime AILang's memory model
(RC + uniqueness inference) is designed for. New examples,
benches, and corpus tests run under RC unless they explicitly
pin GC.
- **Boehm stays as a parity oracle.** `--alloc=gc` remains
reachable. Its load-bearing job is differential diagnosis: when
RC produces a segfault, refcount underflow, or wrong stdout, the
GC build of the same module is the cheap "memory bug or logic
bug?" probe. The end-to-end suite includes per-example
parity tests that run both backends and assert byte-identical
stdout — those tests are what make the oracle real.
- **`--alloc=bump`** is unchanged: a leak-only bench instrument,
not a production target.
Full Boehm retirement (drop libgc, remove the gc backend) reopens
when the parity oracle stops paying its keep — concretely, when a
few iter families ship without the gc arm catching anything that
the rc arm did not already catch. Until then, the cost of
keeping libgc as a build dependency is accepted in exchange for
diagnostic leverage. Decision 10 (RC + uniqueness) holds as the
specification of the canonical runtime; the rest of this section
documents the Boehm half, retained as the oracle.
**Choice: Boehm-Demers-Weiser conservative GC.** The simplest
working option:
- Replace `malloc(...)` with `GC_malloc(...)` in every IR site
(currently `lower_ctor`'s ADT box, `lower_lambda`'s env block
and closure pair).
- Replace the IR-level `declare ptr @malloc(i64)` with
`declare ptr @GC_malloc(i64)`.
- Add `-lgc` to the `clang` link command (in
`crates/ail/src/main.rs`'s `Build` / `Run` paths).
- No language-level change. No AST change. No schema change.
Rationale:
- **Mature.** Boehm has been the default conservative GC for
decades. Linux distros ship it as `libgc` / `libgc-dev` /
`gc` (Arch).
- **No language work.** Conservative scan of the C stack handles
AILang's stack frames without LLVM stack-map infrastructure
(which is its own multi-iter design).
- **Single-iter integration.** Lift-and-shift of the four
allocation sites; all existing tests must still pass with
identical output.
Trade-offs accepted:
- **Conservative over-retention.** A user-supplied `Int` field
whose value happens to coincide with a heap address will pin
that allocation. In practice, vanishingly rare for typical
values; survivable.
- **Pause time non-deterministic.** Boehm uses stop-the-world
mark-sweep. For LLM-author-written stdlib code at MVP scale,
pause times are not the bottleneck.
- **Build-time dependency.** `libgc` must be installed on the
build host. Users without it get a link-time error from
clang, not a silent failure.
## Per-fn arena via stack `alloca`
This optimisation is layered on top of Boehm in its simplest form.
`ailang-codegen` runs an escape-analysis pre-pass over every fn
body (and every lifted lambda thunk body); allocations the pass
proves do not outlive the fn frame are lowered to LLVM `alloca`
instead of `@GC_malloc`. Allocations that may escape continue to
use `@GC_malloc`. The Boehm collector is still linked and
unchanged; this is purely an optimisation above the floor.
**Allocation mechanism: LLVM `alloca`** (not a heap arena). Stack
allocation matches the "freed at fn return" lifetime exactly,
needs no malloc/free pair, and integrates with LLVM's existing
optimiser (mem2reg / SROA may further promote the alloca'd box
to registers if the box is small and its uses are simple). No
new runtime is introduced; no language-level change; no AST or
schema change.
**Escape rule (conservative).** A `Term::Ctor` or `Term::Lam`
allocation is non-escaping iff (1) it is the value of a
`Term::Let { name = X, value = ALLOC, body = B }`, and (2) the
body `B` does not let any value derived from `X` flow past the
fn frame. "Derived from" follows two propagation rules:
- A `Term::Match` whose scrutinee is a `Var` referring to a
tainted name propagates taint to every pattern-bound name in
every arm. (Pattern bindings hold field projections of the
scrutinee, which live inside the same allocation.)
- A `Term::Let { name = Y, value = Var(t), ... }` where `t` is
tainted makes `Y` tainted in the let's body.
A tainted name "escapes" if it appears in any of: the tail
position of `B`, the arg list of any `Term::App` / `Term::Do`,
the field list of a `Term::Ctor`, or the free-var capture set of
a `Term::Lam`. The closure-pair-callee position of `Term::App`
where the callee is a bare `Var` to the tainted name is NOT an
escape (calling locally is fine).
**What this is not.** Not a region-inference system. Not
flow-sensitive within an arm. Not field-sensitive (pattern
bindings are tainted wholesale). Precision can be improved later;
correctness is the priority for this iter. A pessimistic answer
(claiming an allocation escapes when it does not) only loses
optimisation, never correctness.
**Codegen integration.** Three sites in
`ailang-codegen/src/lib.rs`:
- `lower_ctor` — ADT box.
- `lower_lambda` env block (when there are captures).
- `lower_lambda` closure pair (always 16 bytes).
Each site queries the per-fn `non_escape: BTreeSet<usize>` (raw
pointer addresses of `Term::Ctor` / `Term::Lam` AST nodes flagged
as non-escaping). On a hit the emitter writes
`alloca i8, i64 <size>, align 8`; on a miss it writes
`call ptr @GC_malloc(i64 <size>)`. The rest of the lowering (tag
store, field stores, closure-pair packing) is identical.
The closure-pair and its env share an escape verdict — they have
parallel lifetimes. If the closure pair is non-escaping, the env
is too.
## Decision 10: memory model — RC + Uniqueness with LLM-author annotations
**The GC bench (`bench/run.sh`) showed
Boehm contributing a substantial fraction of runtime on allocation-heavy workloads
that hold the heap fully live (bench notes in JOURNAL). The
mainstream "RC + inference" position is extended with mandatory
LLM-author mode annotations (`borrow` / `own`), explicit `clone`,
first-class `reuse-as`, and `drop-iterative` data attrs.**
The cost of GC is structurally in the allocate path — Boehm's
`GC_malloc` is structurally slower than a bump pointer, and the bench
workloads exercised allocate cost without collection cost.
Tracing GC's irreducible variability cannot be tuned away; RC's
costs are bounded and analysable per program point. A corpus
committed to one memory model is expensive to switch — pre-
stdlib is the cheapest moment to commit.
**Choice.** AILang's canonical memory model is reference counting
with static uniqueness inference **and explicit LLM-author
annotations on fn signatures**, in the lineage of Lean 4 / Roc /
Koka. Boehm becomes a transitional allocator (Decision 9) and is
retired when the RC pipeline matches the bump-allocator floor
within an acceptable margin (target 1.3× on `bench/run.sh`).
**Workload scope of the 1.3× target.** The 1.3× target was
calibrated on the original `bench/run.sh` corpus: linear list
sum (`bench_list_sum`) and tree walk (`bench_tree_walk`) — uniform
single-allocation-per-step workloads where one inc/dec pair
amortises against one allocation. The corpus was later extended
with `bench_closure_chain` (closure-pair allocation: each step
allocates *two* heap objects, the closure cell and its captured
env struct) and `bench_hof_pipeline` (poly-ADT + indirect
dispatch). The closure-chain fixture measures wider than the 1.3×
linear/tree target: each step pays two allocs and two decs against
one bump-pointer bump, doubling the allocation tax on closure
construction (current ratio recorded in JOURNAL bench entries).
This is a representational cost of the closure-pair layout,
not a defect in the RC implementation; a future slab/pool
allocator for fixed-shape pair cells (Decision 9 retirement
follow-up) would compress this ratio without changing semantics.
The 1.3× retirement target therefore applies to the linear /
tree / poly-ADT subset of the corpus. Closure-heavy workloads are
tracked under a wider band (the closure-chain baseline records its rc/bump ratio as the `rc_over_bump` reference value with ±15% tolerance) and
are explicitly excluded from the Boehm-retirement gate until a
slab/pool answer ships. Decision-10's RC commitment is unchanged;
what is scoped is the *quantitative* retirement criterion, not
the choice of memory model.
The architecture has two layers:
1. **Inference.** A post-typecheck pass produces a per-node
uniqueness side table. Codegen uses it to elide inc/dec
wherever provably redundant.
2. **LLM-author annotations.** Fn signatures carry mandatory
`(borrow T)` / `(own T)` mode markers. Authors mark
sharing-vs-consumption explicitly. The compiler verifies
rather than guesses.
The combination plays to what LLMs are good at (writing slightly
more annotation per definition) and avoids what compilers are
bad at (proving sharing absent in the face of recursion +
closures + match).
## The LLM-aware sharpening
A mainstream RC implementation (think Lean 4 in default mode)
infers everything from naked AST plus a few optional hints. The
inference is conservative; whatever it can't prove unique becomes
shared and pays runtime inc/dec. AILang exploits its target
audience to push that conservative ceiling higher.
Five mechanisms.
**(1) Mandatory `(borrow T)` / `(own T)` on fn signatures.**
```
(fn list_length
(type (fn-type (params (borrow (List Int))) (ret (con Int))))
...)
(fn sum_list_consume
(type (fn-type (params (own (List Int))) (ret (con Int))))
...)
```
`(borrow T)` declares the parameter is read-only and lives at
most until the call returns; the caller still owns it; the
callee performs no inc/dec on it. `(own T)` declares ownership
transfer; the callee consumes the value and is responsible for
its end-of-life. The declaration is structural (visible in JSON)
and binding (the typechecker rejects bodies that contradict it).
For a Lean 4 / Roc author this is all *optional* and inferred
when omitted. AILang makes it mandatory because the LLM author
can carry the cognitive cost trivially, and the compiler gains a
precise contract at every call site instead of a probabilistic
guess.
**(2) Linear-by-default consumption with explicit `(clone X)`.**
In bodies, every binder is consumed by exactly one `own`-mode
use. If the LLM writes:
```
(let p (expensive_fn x)
(let r1 (consume_a p) ; consume_a takes (own); consumes p
(let r2 (consume_b p) ; ERROR: p already consumed
...)))
```
the compiler emits a structured non-linear-use diagnostic with
concrete `suggested_rewrites`:
- "make consume_a borrow": refactor consume_a's signature, no
body change at the call site;
- "explicit clone": insert `(clone p)` at the first use;
- "fuse traversal": replace the two separate calls with a fused fn.
The LLM picks one. There is no implicit clone — sharing always
costs visible source.
**(3) Reuse hints as first-class.**
```
(fn map_inc
(type (fn-type (params (own (List Int))) (ret (own (List Int)))))
(params xs)
(body
(match xs
(case Nil Nil)
(case (Cons h t)
(reuse-as xs (term-ctor List Cons (app + h 1) (app map_inc t)))))))
```
`(reuse-as SRC NEW-CTOR)` asks the codegen to allocate `NEW-CTOR`
in `SRC`'s memory slot. Compiler verifies: `SRC` is owned, this
is its last use, sizes match. On a hit: no malloc, no free — the
box is overwritten in place. On a miss: structured diagnostic
explains which precondition failed; the LLM either adjusts the
surrounding code or removes the hint.
This matches Lean 4 / Roc reuse analysis but lifts it from
"compiler-inferred when possible" to "author-asserted, compiler-
verified". The LLM applies it everywhere it expects to fire and
lets the compiler bounce the request when it can't.
**(4) `(drop-iterative)` annotation on data declarations.**
```
(data Tree (vars a)
(ctor Leaf)
(ctor Node a (Tree a) (Tree a))
(drop-iterative))
```
When the refcount of a `Tree` value reaches zero, the synthesised
dec-on-zero traversal is iterative (worklist + heap-allocated
stack) instead of recursive. Avoids stack overflow on deep
structures. The LLM adds the annotation where appropriate; the
compiler refuses to emit recursive dec-cascade on annotated types.
**(5) Structured compiler diagnostics with `suggested_rewrites`.**
Every RC-mode error (use-after-consume, mode-mismatch,
reuse-as-fail, drop-cascade-too-deep) emits a JSON object
containing the failure kind, the source span, and a list of
concrete rewrite suggestions in form-A AILang. The LLM consumes
these without prose-parsing. This is the missing half of the
LLM-as-author story: the language spec defines not only what
compiles, but what the compiler tells the author when it doesn't.
## Inference algorithm
Post-typecheck, post-`lift_letrecs`, pre-codegen pass over the
elaborated module. For each `Term` node that produces or binds a
boxed value, the pass computes a uniqueness flag:
- **Unique:** at this program point, the reference is the only
outstanding reference to its referent.
- **Shared:** there may be multiple outstanding references.
A reference is *unique* if every path from its allocation to the
current program point passes through exactly one binding. The
inference is a forward dataflow over the AST. The annotations
(`borrow` / `own`) provide the inter-fn contract; the
inference fills in intra-fn detail.
+156
View File
@@ -0,0 +1,156 @@
# Typeclasses — resolution and monomorphisation whitepaper
## Decision 11: typeclasses — Haskell-lite, monomorphised, coherent
**The design pass for typeclasses. Codified after the Feature-acceptance
criterion (this document, above) was committed; the criterion is
the explicit basis for the choices below.**
AILang ships typeclasses to compress a real LLM-author redundancy:
without them, every comparable function must be written per-type
(`int_eq`, `string_eq`, `bool_eq`, `int_show`, `string_show`, …). With
typeclasses behind a monomorphising compiler, the LLM author writes
one signature with a class constraint and one method per concrete
type, and the compiler emits the same machine code as the per-type
version. No runtime cost, no dictionary passing, no vtables.
**Choice.** A deliberately narrow typeclass design — narrower than
Haskell, narrower than Rust traits — calibrated to what an LLM
author naturally produces. Five semantic axes are committed:
1. **Scope.** Multi-method, single-parameter, optional defaults,
single-superclass relation. No multi-param classes, no
functional dependencies, no associated types.
2. **Constraints in signatures.** Explicit and mandatory. A
function that calls a class method must declare the constraint
in its `forall` block. No constraint inference.
3. **Resolution.** Orphan-free coherence. An `instance C T` may be
declared only in the module of `C` or in the module of `T`.
Resolution is global type-directed against a workspace-built
registry; coherence makes the lookup unambiguous.
4. **Defaults.** Opt-in via an explicit `default` keyword in the
class body. Methods without `default` are abstract-required;
methods with `default` may be overridden or inherited per
instance.
5. **Class-parameter kind.** Concrete types only (kind `*`). No
higher-kinded class params; `Functor`/`Monad`-style abstractions
over type constructors are not expressible. The LLM-natural
pattern is `List.map` / `Tree.map` as separate functions per
type, which monomorphisation handles directly.
The five axes follow from the Feature-acceptance criterion: each
rejected mechanism (multi-param, higher-kinded, FunDeps, assoc
types) is one an LLM author does not unprompted produce.
## Resolution and monomorphisation
**Constraint collection (per function body).** During typechecking
of a body, each method call generates a residual constraint of shape
`<Class> <Type>` where `<Type>` may still contain type variables.
After local typechecking, residual constraints are checked against
the function's declared constraints (modulo α-conversion and modulo
auto-expansion through superclasses; see below). Any residual not
covered by declared constraints fires `MissingConstraint`.
**Instance registry (workspace-global).** At workspace load (see
`crates/ailang-core/src/workspace.rs`), all `InstanceDef` nodes
across all reachable modules are collected into a registry keyed by
`(class-name, canonical-hash-of-instance-type)`. Registry build
performs three checks:
- **Coherence.** Each instance's module must be either the class's
defining module or the instance type's defining module. Otherwise
`OrphanInstance`.
- **Uniqueness.** No two entries share a key. Otherwise →
`DuplicateInstance`.
- **Method completeness.** Each instance specifies every required
(non-default) method of its class. Otherwise → `MissingMethod`.
Registry build is a one-time-per-build pass that fires before any
typechecking. Its errors are workspace-load errors, not per-call-site
errors.
**Resolution at call sites with concrete types.** When the typechecker
sees a method call where every type variable in the constraint is
substituted to a concrete type, it queries the registry. Hit →
resolved. Miss → `NoInstance`.
**Resolution at polymorphic call sites.** When type variables are
still free, the constraint propagates into the surrounding function's
constraint context — which the user MUST have declared explicitly
(per axis 2). No constraint is implicitly hoisted.
**Monomorphisation (post-typecheck, pre-codegen).** A pass between
typechecking and codegen replaces every call to a
`Type::Forall`-quantified `Def::Fn` with a call to a synthesised
monomorphic `FnDef`. Two source-body entry points share the same
mechanics in one fixpoint:
1. **Class-method entry.** For each unique `(method, concrete-type)`
pair produced by a class-constraint residual, the pass looks up
the resolved instance body via `Registry::entries[(class,
type-hash)]`, substitutes the class parameter to the concrete
type, and synthesises a top-level `FnDef` named
`<method>__<type-surface-name>`.
2. **Free-fn entry.** For each call site to a polymorphic free
`Def::Fn` with a fully-concrete substitution, the pass takes the
source body directly from the polymorphic `Def::Fn`, applies
rigid-var substitution on both the type AND the body (the body
may contain inner `Term::Lam`s whose `param_tys` reference the
outer Forall vars), and synthesises a top-level `FnDef` named
`<name>__<type-surface-name-1>__<type-surface-name-2>__…`
(concatenated in `Type::Forall.vars` declaration order; the
N-ary case extends the single-type-var class-method shape
bit-stably).
Both arms share:
- A fixpoint loop that keeps collecting targets until a round adds
nothing new (a synthesised free-fn body may invoke class methods
at concrete types, scheduling new class-method targets; a
class-method body may invoke polymorphic free fns at concrete
types, scheduling new free-fn targets).
- A dedup cache keyed by `(kind, base-name,
type-hash-or-joined-hashes)` where the first component
(`"class"` / `"free"`) guarantees disjoint keying across the
two kinds.
- A call-site rewrite walker that rewrites bare polymorphic call
sites — class-method-named OR poly-free-fn-named — to their
mono symbols before codegen runs. The walker advances a single
cursor over interleaved class-method and free-fn slots emitted
in synth's traversal order.
After this pass, the IR contains no polymorphism, no class
machinery, no polymorphic call sites — only ordinary monomorphic
functions and direct calls. Codegen sees no difference between a
hand-written `show_int` and a synthesised `show__Int`.
**Why mono, not virtual dispatch.** Monomorphisation makes the call
target visible to the optimiser, unlocking inlining and downstream
loop transformations that virtual dispatch prevents in principle.
On a saturating branch predictor with a monomorphic indirect
target, the indirect call itself is comparable in cost to a
non-inlined direct call — the win is in what the optimiser can do
with the visible target, not in the call instruction. The
end-to-end gain shrinks toward zero on larger callee bodies and
cold call sites, but the architectural claim — "mono enables
optimisations vdisp forbids" — holds across the spectrum
(`bench/mono_dispatch.py` and the corresponding JOURNAL bench-notes
entry record the measured ratios).
The separator is `__` rather than `#` or `@` because `#` and `@`
are invalid in LLVM IR global identifiers (the IR verifier rejects
them inside `@ail_<module>_<def>` mangled names). `__` is legal in
both LLVM IR and the C ABI used by the runtime glue, and parses
unambiguously into `<method>__<type-surface-name>` because neither
component contains `__` by project convention.
**No runtime dispatch, no dictionary passing.** The monomorphisation
pass is the ONLY specialiser. Codegen sees only monomorphic
`Def::Fn`s and direct calls; the pre-iter-23.4 codegen-time
specialiser (`lower_polymorphic_call` + `module_polymorphic_fns` +
`mono_queue`) was removed in iter 23.4. A call that cannot be
monomorphised — for instance, because a constraint remains
unresolved at the entry point — is a static error, not a runtime
one. This is the LLVM-friendly form and is consistent with
Decision 10's performance commitment.
-3020
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,431 @@
# Design decision-records — relitigation guard (migrated 2026-05-19)
Why-X-chosen / why-Y-rejected / deliberately-does-not-do / rollback
/ empirical-addendum prose, migrated out of the former
`docs/DESIGN.md` by the design-md-rolesplit milestone. A future
brainstorm reads this so it does not re-propose a settled-and-
rejected idea. Order preserved from the source. This file is
append-only history; it is NOT a contract surface.
## Decision 1: source = data, not text
A module is a JSON object with a fixed schema. There is no parser for
free-form text. Typos in identifiers turn into hash-lookup errors that the
compiler proposes a fix for directly.
A textual form exists (`.ail`, S-expression-like), but only as a
bidirectional projection of the JSON form. It is intended for human reviews
and diffs.
**Canonical format:** `.ail.json` with deterministic key order.
## Decision 2: content-addressed definitions
Every top-level definition has a `hash` value (BLAKE3 over canonical JSON
without the `hash` field itself). References between definitions go primarily
by name — names are for readability. The hash is the canonical identity.
Advantages:
- Refactoring by adding new defs, not by in-place change. Old versions stay
callable until manually removed.
- Caching of typecheck results and codegen per hash.
- Diffs show exactly which def has changed.
## Decision 4: Hindley-Milner + optional refinements
MVP: HM with let-polymorphism. All types are inferable, but at the top level
they must always be explicitly annotated (for local reasoning).
Later: refinement annotations that escalate to SMT. `(i: Int | i >= 0)`. They
are reserved in the AST from the start, but in the MVP they are simply passed
through as opaque strings.
## Decision 5: emit LLVM IR as text
Instead of `inkwell` or `llvm-sys`: AILang produces `.ll` files as strings
and hands them to `clang` for linking.
Rationale:
- The LLVM IR text syntax is largely stable across versions.
- No build dependency on a specific libllvm version.
- Generated code is trivially inspectable, which makes debugging much easier.
- An LLM can read the generated IR directly, which is harder with opaque
library calls.
Trade-off: no inline optimisations through the LLVM API. We rely on
`clang -O2` as the standard pipeline.
The rest of this section
records the *why* of Decision 6 for the audit trail; the constraints
listed below describe the surface as shipped.
## Why this is opening up
Early development authored everything as raw `*.ail.json`. That worked
for 17 fixture files (each ≤ 60 LOC of JSON) but does not scale. Two
breaking signals:
1. The token-economy cost of the JSON-AST is massive: a single integer
literal `1` is encoded as `{"t":"lit","lit":{"kind":"int","value":1}}`
— ~38 tokens of structural overhead per bit of semantics. For a stdlib
in the 200500 def range this displaces real attention budget.
2. JSON-AST authoring exposes a class of errors (wrong field names,
silently-accepted extra fields under `#[serde(default)]`,
inconsistent ctor casing) that surface only at load time. The
schema is correct-by-construction in storage but **error-prone in
authoring**.
Decision 1 anticipated this: it says a textual form exists "as a
bidirectional projection of the JSON form." The pretty-printer already
emits S-expression-style text (see `crates/ailang-core/src/pretty.rs`).
What is missing is the inverse direction — text → AST. Decision 6
adds that inverse as **one** authoring projection alongside the
existing pretty-printer; the JSON-AST remains the source of truth.
## First choice and rollback plan
**Try (A) first.** Reasoning: constraint 1 (formalizable) outweighs
constraint readability. (A) has a 3-rule core grammar with one
lexical disambiguation rule. (B) doubles the rule count and
re-introduces a soft form of precedence (`->` inside `forall`).
(C) is tempting because it is zero-design but the resulting spec is
visibly heterogeneous, which is exactly what constraint 1 was meant
to rule out.
If implementing (A) reveals that paren density actively hurts my
authoring (measurable: I make more wrong-paren errors than the
JSON-AST shape produced before), roll back and try (C). (B) stays
on the shelf for a future iter only if both fail.
## Implementation outline
- `crates/ailang-surface` — new crate. **Strictly additive.** PEG
parser produces existing `ailang-core::ast` types. No new AST
nodes, no schema changes, no new hashable form. Pretty-printer
for form (A) lives here too (the round-trip is the contract).
- `ailang-check`, `ailang-codegen` are **not modified**. They
continue to consume `ailang-core::ast::Module` values regardless
of which projection produced them.
- Round-trip test: for every `examples/*.ail.json`, parse the
corresponding hand-written `*.ail`, canonicalise, and assert
hash-equivalence to the original. Hash equivalence is the truth
check; the surface ships only if every fixture round-trips
identically.
- CLI: `ail parse <file.ail> -o <file.ail.json>`. Symmetric to
existing `ail render`. **`.ail.json` remains a first-class input**
to every existing subcommand; the parser is a producer, not a
gatekeeper.
**Both extensions accepted.** Every path-taking CLI subcommand
accepts either `.ail` (Form A)
or `.ail.json` (Form B) as input. For `.ail` paths the subcommand
parses through `ailang_surface::parse` in-line and then proceeds
with the same loaded `Module` value that `.ail.json` would have
produced. `ail parse` remains the explicit converter — it does
nothing the implicit dispatch in the other subcommands does not,
but it is the supported way to materialise a stable `.ail.json`
snapshot from a `.ail` source for diff / hash / cache purposes.
- Stdlib (`std_list`, `std_maybe`, ...) authored in
form (A) from day one **because that is the AI authoring
projection**, not because JSON authoring is forbidden. The
resulting `.ail.json` is what tests and downstream tools see.
## What this Decision deliberately does not do
- It does not change which form is canonical: the JSON-AST remains
the hashable, content-addressed representation, and all hashing,
content-addressing, and cross-module references flow through it
unchanged. The authored form is Form A; the JSON-AST is
materialised in-process by callers that need it. The two
forms are byte-isomorphic by the round-trip invariant. Form (A)
is one projection; the JSON-AST stays canonical.
- It does not foreclose visual or graphical front-ends. The crate
layout (`core` owns AST; `surface`/`visual`/... are siblings)
reserves that lane.
- It does not remove `.ail.json` as input. Every existing
CLI subcommand (`check`, `render`, `describe`, `emit-ir`,
`build`, `run`, `manifest`, `deps`, `diff`, `workspace`,
`builtins`) keeps its current `.ail.json` interface.
## Form refinements during implementation
Two productions in the original sketch had to be widened
during implementation to round-trip the existing AST faithfully.
Captured here for the spec record:
1. **`lam-term` carries types and effects.** The AST's `Term::Lam`
stores parallel `params`, `param_tys`, `ret_ty`, and `effects`
fields. The original sketch had only names. The implemented form is
```
lam-term ::= "(" "lam" "(" "params" typed-param* ")"
"(" "ret" type ")"
effects-clause? body-attr ")"
typed-param ::= "(" "typed" ident type ")"
```
This keeps the no-precedence / one-construct-per-token-list
invariants and adds no new lexical rules.
2. **`import-clause` admits an optional alias.** The AST's
`Import.alias` is `Option<String>`; the original sketch only
supported the `None` case. The implemented form is
```
import-clause ::= "(" "import" ident ("as" ident)? ")"
```
`as` is a bare ident token in this position; no special lexical
rule is needed.
Neither change extends the grammar's rule budget meaningfully:
the 30-production ceiling of constraint 1 is intact (the parser
implements ~28 named productions). All 17 `examples/*.ail.json`
fixtures round-trip identically through `print → parse → canonical
JSON`; the three hand-written `.ail` exhibits parse to canonical
JSON identical to their corresponding `.ail.json` files.
3. **Tail-call surface.** Decision 8 ships two new
productions, both positional analogues of their non-tail
counterparts. Their result terms set `Term::App.tail = true` /
`Term::Do.tail = true`; the typechecker's `verify_tail_positions`
pass enforces that the marker is only used in tail position.
```
tail-app-term ::= "(" "tail-app" term term+ ")"
tail-do-term ::= "(" "tail-do" ident term* ")"
```
Production count: ~30, still inside the 30-rule
constraint-1 budget. No new lexical rule (`tail-app` / `tail-do`
are bare ident tokens; no special casing).
## Empirical addendum — cross-model authoring measurement
Cross-model measurement against two foreign LLMs via IONOS,
temperature=0, top_p=1, max-turns=5. Two blind cohorts on the same
four MVP tasks (`t1_add_three`, `t2_length`, `t3_main_prints`,
`t4_count_zeros`); each cohort sees only its own form's mini-spec.
Subjects (both runs use the pipeline-error-formatting fix from ms.1
so JSON-cohort feedback carries the full anyhow cause chain):
- `Qwen/Qwen3-Coder-Next`
`experiments/2026-05-12-cross-model-authoring/runs/2026-05-12-080864/`
- `meta-llama/CodeLlama-13b-Instruct-hf`
`experiments/2026-05-12-cross-model-authoring/runs/2026-05-12-9197fd/`
| metric | Qwen / JSON | Qwen / AIL | CodeLlama / JSON | CodeLlama / AIL |
|---|---|---|---|---|
| reached green | 1/4 | 1/4 | 0/4 | 2/4 |
| first-attempt green | 1/4 | 1/4 | 0/4 | 2/4 |
| mean turns-to-green (green only) | 1.0 | 1.0 | — | 1.0 |
| total prompt tokens | 182,378 | 110,575 | 116,015 | 93,017 |
| total completion tokens | 14,972 | 3,474 | 2,711 | 2,234 |
| top error class | check (×3) | parse (×3) | check (×2) | parse (×2) |
Both subjects show the same direction on every metric the table
tracks. AIL is cheaper than JSON on prompt tokens (Qwen 61%,
CodeLlama 80% of the JSON cohort's spend) and cheaper on completion
tokens (Qwen 23%, CodeLlama 82%). AIL reached-green is greater than
or equal to JSON reached-green for each subject (Qwen ties at 1/4;
CodeLlama strictly dominates, 2/4 vs 0/4). The failure-class
symmetry observed on the original Qwen baseline holds for both
subjects: JSON cohorts fail at the typecheck stage, AIL cohorts at
the parse stage — each form's front-of-pipeline check. Three of the
four first-attempt-green cells observed across the two subjects are
AIL (Qwen-AIL t3, CodeLlama-AIL t1, CodeLlama-AIL t3); the
fourth is Qwen-JSON t3, the same task the original baseline already
flagged as the JSON-form's only first-attempt success. Two CodeLlama
JSON-cohort cells (`t2_length`, `t4_count_zeros`) terminated as
`api_failure` on turn 1 with zero tokens consumed — a transient
IONOS-side terminal error, not a model-output failure; CodeLlama
JSON-cohort numbers therefore average over 2 informative cells, not
4. The Qwen re-run also shifted by one cell against the cma.3
baseline (AIL 2/4 → 1/4, JSON 1/4 → 1/4) despite identical
temperature=0 inputs; this is IONOS-side state-of-day noise on a
single deterministic re-run, not an effect of the ms.1
feedback-formatting fix (which only changes JSON-cohort prompt
content).
**Scope of this addendum:** two subjects, n=1 each, deterministic.
This is a second data point pointing in the same direction as the
first, not a verdict. The universal claim of this Decision (".ail
is the AI authoring projection") would need ≥3 subjects with
statistical robustness to ratify; both current points point the
same way (AIL cohort cheaper and at-least-as-green) but neither
the sample size nor the cross-call noise observed in the Qwen
re-run is enough to close out the Decision.
## Why not other memory models
**Tracing GC.** Open-ended. Boehm's bench overhead is structurally on the allocate path (JOURNAL bench notes); tuning Boehm cannot move that
needle. A precise tracing GC would need read/write barriers,
root maps, generational machinery — months of work, with
irreducible pause-time variability at the end. The user's
framing was decisive: "Am GC kannst du ewig rumschrauben (und
bekommst trotzdem auch in 100 Jahren keine berechnbare
Performance)."
**Region inference (Tofte/Talpin / MLton-style).** Considered
seriously; rejected. Regions tie lifetimes to dynamic scope —
every value lives in some region, regions stack on entry/exit,
deallocation is bulk-by-region. The reduction: a region is an
explicit allocator with sugar plus a static check that nothing
escapes its lifetime. The deeper problem: regions assume
**stack-shaped lifetimes**. Real programs have non-stack-shaped
lifetimes — caches, memo tables, registries, lookup structures
whose lifetimes are not nested. Regions would either force these
into a `letregion` at the top of `main` (everyone's allocator
becomes the root region — useless), or require a region per
cache variant (combinatorial). RC has no lifetime-shape
assumption; it works for any DAG.
**Linear / ownership types as primary mechanism (Rust-style).**
Considered as a general-purpose alternative; rejected as primary.
Rust's borrow-checker is a great mechanism but requires the
author to thread lifetimes through every signature and accept
that some programs cannot be expressed without `unsafe`. For an
LLM-targeted language with recursion + closures everywhere, that
cost is too high — most of the source surface would be lifetime
annotations rather than logic. AILang uses a *subset* of these
ideas (mode annotations, linear consumption discipline)
selectively, layered on top of RC, where the annotations buy
concrete optimisations and never have to thread lifetimes
through callees.
## Why advisory + suppress instead of inference
Three reasons, all anchored in Decision 10's framing:
1. **Annotation states intent; inference picks
weakest-supporting.** These often coincide today but are
conceptually different. The annotation captures what the
author committed to (e.g. `(own T)` reserved for a planned
mutation that hasn't landed); inference would silently
relax it.
2. **Annotation is a drift-bremse.** Body change that flips
the inferred mode produces caller-side breakage at remote
sites. Annotation enforces the local-conflict-error pattern
instead.
3. **Forcing function for LLM authoring.** Without mandatory
annotation, the LLM never has to commit to ownership intent
before writing the body. The advisory lint plus suppress
lets us flag accidental over-strictness without weakening
the contract.
The suppress mechanism with mandatory-reason mirrors Rust's
`#[allow(...)]`-style escape hatch but sharpens it: the reason
is required (not optional), and it becomes part of the contract
the next reader sees. CLAUDE.md's "preserve correctness across
development cycles" is what this directly serves.
## Adjacent extensions for mutability (out of Decision 10's scope)
If future workloads need mutable arrays, hash tables, or other
inherently mutable primitives, the answer is **not** a tracing GC
backstop. The answer is a separate ownership/linear extension
that gates mutability behind static single-owner discipline. RC
+ uniqueness is the universal floor; ownership extends it for
specific high-performance primitives without rebreaking the
acyclicity invariant.
## What this Decision deliberately does not do
- **Does not infer everything.** AILang demands annotations
*because* the LLM author can produce them effortlessly. The
compiler does inference *plus* verification of contracts.
- **Does not require existing fixtures to migrate immediately.**
`(con T)` is treated as `(own T)`. Existing JSON hashes
stay bit-identical until the fixture is intentionally updated.
- **Does not commit to atomic refcounts.** AILang is
single-threaded; refcounts are non-atomic. The embedding ABI's
per-thread `ailang_ctx_t` keeps this correct under a host swarm:
each thread's allocations are private to its ctx, no RC cell
crosses a thread, so non-atomic refcounts stay sound.
- **Does not introduce regions.** Regions were considered and
rejected; see "Why not other memory models" above.
## What the typeclass design explicitly does NOT support
Each of the following is rejected by either schema (parser cannot
express the construct) or by an enumerated diagnostic. The
combination of axis-1 (single-param, no FunDeps, no assoc) and
axis-5 (kind `*` only) covers the space.
- **Multi-parameter classes** (`class Foo a b where ...`). Schema
rejects: `ClassDef.param` is a string, not a list.
- **Higher-kinded class params** (`class Functor f`). Rejected at
workspace-load time: `BareCrossModuleTypeRef` from canonical-form
validation fires before class-schema validation if any method
body uses the param as a `Type::Con` head — bare non-primitive
names must be declared as a `TypeDef` in the owning module, and
a class param is not.
- **Higher-rank polymorphism** (`forall a. (forall b. b -> b) -> a`).
Already rejected at parse time per the typeclass-conversation
rationale recorded in JOURNAL; constraint-bearing
signatures inherit that prohibition.
- **Existential / dyn dispatch** (`exists a. Show a => a`,
heterogeneous lists like `[Show]`). Schema does not express
existentials. The LLM-natural alternative is sum types.
- **Associated types** (`class Container c where type Element c`).
Schema does not express type-level methods.
- **Functional dependencies** (`class Convert a b | a -> b`). Required
only for multi-param classes; entails by axis 1.
- **`deriving` / auto-derivation** (`data Foo = ... deriving (Eq)`).
Future iteration, gated separately on Feature-acceptance.
- **Numeric literal defaulting.** `1` does not auto-resolve to `Int`
under an ambiguous `Num a` constraint. Bare polymorphic literals
with multiple satisfying instances fire `NoInstance` with a hint
to annotate. The LLM-author writes `1 : Int` or `1 : Float`.
- **Orphan-with-warning mode.** Coherence is hard. `OrphanInstance`
is always an error, never a warning. The corollary `--allow-orphans`
flag is not provided.
## What this decision does NOT commit to
- **Operator routing.** `==`, `<`, etc. stay primitive in milestone
22. Class-routing operators is a future-iteration option, not a
Decision-11 commitment.
- **Form-B (prose) projection of class/instance.** The prose
renderer for the class/instance schema nodes is one-way (no
parser by design); the render in
`crates/ailang-prose/src/lib.rs` is a placeholder and
informational only.
- **Specific monomorphised-symbol naming format.** Milestone 22
uses `<method>__<type-surface-name>` for primitive type targets
(e.g. `show__Int`) and `<method>__<8-hex-prefix>` for compound
types (where `<8-hex-prefix>` is the BLAKE3 hash of the canonical
type bytes). The `__` separator was chosen over `#` and `@` for
LLVM IR identifier legality.
- **Mode annotations on class methods.** Class method signatures
ARE full FnSigs and DO carry mode annotations per Decision 10;
the convention is `borrow` for read-only methods. User-defined
classes pick modes per method.
- **Number of Prelude classes.** Milestone 22 ships zero (no
Prelude). A future Prelude milestone gates class additions on
the Feature-acceptance criterion at the time of proposal.
## Env construction — why two parallel overlays (rationale of the code-SoT §"Env construction")
Behaviour of the two-overlay check environment is code-authoritative
(the `env-construction` ledger row is source-link only); only the
*why* moves here so a future brainstorm does not re-propose
collapsing the two maps:
The split is not a refactor wart. The two maps have distinct
semantic roles: an owning data index, and a derived lookup
accelerator over the same data. Collapsing them into one
variant-keyed map (`Map<Name, EntryKind>`) would force every
type-iterator and every ctor-lookup site to discriminate the variant
tag, reversing the optimisation the reverse index exists to
provide.
The mono-side overlay was narrowed to types-only at iter ct.3.2 because its
consumer is the runtime ctor lookup, which became type-driven
post-ct.2.2 — a different consumer story, hence a different
overlay shape. The asymmetry between the check side and the mono
side is by design and is pinned by
`crates/ailang-check/tests/duplicate_ctor_pin.rs`.
@@ -0,0 +1,91 @@
# iter design-md-rolesplit.1 — DESIGN.md → design/ ledger role-split (whole milestone)
**Date:** 2026-05-19
**Started from:** deeffb1872cd13a9fed2a0812d69e7273f49671b
**Status:** DONE
**Tasks completed:** 9 of 9
## Summary
The 3020-line `docs/DESIGN.md` is replaced by the `design/` ledger:
`design/INDEX.md` (sole addressable spine, a typed Contracts+Models
table), 14 `design/contracts/*.md` (test-linked invariants), 5
`design/models/*.md` (onboarding whitepapers), plus
`docs/journals/2026-05-19-design-decision-records.md` (the
relitigation-guard archive of every why/rejected/does-not-do/rollback
`###`). Content was sliced from DESIGN.md by the spec Appendix line
ranges with a deterministic Python slicer (`/tmp/ail-iter/
design-md-rolesplit.1/slice.py`, not committed) — byte-faithful
except (a) heading-level re-level, (b) the rewritten
`honesty-rule.md`, (c) the OQ7 cite deletion. The RED-first
`design_index_pin.rs` anti-regrowth spine (4 clauses) was
demonstrably RED before and is GREEN after. Every live `DESIGN.md`
reference in code, scripts, agents, SKILL bodies, CLAUDE.md, README,
runtime C, `.ail` fixtures, and `form_a.md` was retargeted to the
Appendix destination (or to a code-SoT note for the source-link
contracts). `docs/DESIGN.md` deleted via `git rm` (staged only).
Build-atomic by task ordering; whole `cargo test --workspace` GREEN,
zero failures.
## Per-task notes
- design-md-rolesplit.1.1: RED-first `crates/ailang-core/tests/design_index_pin.rs` — 4-clause structural pin written; verified RED (DESIGN.md present + design/ absent). Dropped one unused `Path` import (plan-verbatim code had an unused-import warning) — recorded as a concern.
- design-md-rolesplit.1.2: `design/INDEX.md` (spec-verbatim 2-table ledger + "## Project framing" preamble absorbing DESIGN.md:6-18/19-55/83-92) + 14 `design/contracts/*.md` sliced from the Appendix ranges + the rewritten `honesty-rule.md` (two `docs_honesty_pin.rs:70,72` phrases each contiguous on one physical line). INDEX link cells made workspace-root-relative (`design/contracts/...`) so the authoritative pin resolves them.
- design-md-rolesplit.1.3: 5 `design/models/*.md` sliced from the Appendix model-tagged ranges; `every_index_link_resolves` PASS for all 20 link rows.
- design-md-rolesplit.1.4: `docs/journals/2026-05-19-design-decision-records.md` — every Appendix `D` range concatenated in source order + the genuine Env-construction "why" rationale (source-link sections' behaviour dropped as code-SoT). Acceptance-criterion-2 completeness verified programmatically: every one of 66 `##`/`###` headings mapped exactly once, no overlap (only uncovered lines are DESIGN.md's own `#`-preamble :1-5 and one blank separator :244). Journals-INDEX pointer line (Step 2) **deferred to Boss** — see Concerns.
- design-md-rolesplit.1.5: retargeted `design_schema_drift.rs` (const `DESIGN_MD``DATA_MODEL` include_str! → data-model.md; deleted `data_model_section()` + `data_model_section_is_bounded()`; all anchor tests use `DATA_MODEL`), `docs_honesty_pin.rs` (per-test reads point at the design/ homes; three present-anchors found to live in the decision-records journal — read from there), `effect_doc_honesty_pin.rs:21` (normalised join of effects.md + scope-boundaries.md; added `norm()`). All three GREEN; build green with DESIGN.md still present.
- design-md-rolesplit.1.6: retargeted the Float-branch (`design/contracts/float-semantics.md`) and Show-branch (`design/contracts/typeclasses.md`, contiguous across the `\`-continuation) diagnostics in `ailang-check/src/lib.rs` + the 2 lockstep E2E assertions + module rustdocs; both E2Es GREEN.
- design-md-rolesplit.1.7: `bench/architect_sweeps.sh` repointed (`INDEX`/`DESIGN_GLOB`, recursive grep over design/contracts+models, regexes unchanged, exit-code contract preserved, exit-2 verified). Sweep-5 honesty-rule self-quote reworded; one pre-existing advisory Sweep-1 match remains (see Concerns).
- design-md-rolesplit.1.8: retargeted buckets (b) 12 agent files, (c) 5 SKILL bodies + skills/README.md, (d) CLAUDE.md (layout table + the "Roles of …" section, current-state-mirror discipline preserved & re-homed), (e) ~25 code/C/.ail/spec comment xrefs to the Appendix destinations (source-link contracts → code-SoT/INDEX note); OQ7 "Iter 13b" cite deleted (behaviour stated directly, no pointer). Build green.
- design-md-rolesplit.1.9: `git rm docs/DESIGN.md` (staged); workspace build green (build-atomicity proven — the only compile-time consumer was retargeted in Task 5); `cargo test --workspace` ALL GREEN incl. `design_index_pin`'s 4 clauses (RED→GREEN); acceptance grep CLEAN of live refs (residuals = the spec-mandated clause-4 pin only); architect_sweeps contract preserved.
## Concerns
- iter .1: dropped an unused `use std::path::Path` from the plan-verbatim `design_index_pin.rs` (it produced an unused-import warning). Minimal quality fix; the RED-first property is unchanged. Deviation from plan-verbatim text, recorded.
- iter .2: **Plan/spec internal contradiction (repaired).** Task 1's clause-3 marker list (spec-verbatim) includes `"an earlier draft"`; Task 2 Step 4's plan-verbatim `honesty-rule.md` text quoted `"an earlier draft said"` as a *forbidden-pattern example*, which `contracts_carry_no_decision_record_prose` flags forever. The spec's own sample honesty-rule.md (spec §"Concrete code shapes") does NOT contain that phrase. Repaired by rewording honesty-rule.md's History bullet to name the category without the tripwire substring, preserving the two `docs_honesty_pin.rs:70,72` pinned phrases verbatim+contiguous. Spec-faithful to §Architecture-4 ("rewritten to resolve the internal contradiction").
- iter .2: INDEX link cells changed from spec-verbatim `contracts/…` to workspace-root-relative `design/contracts/…` so the authoritative `design_index_pin.rs` (resolves `root().join(link)`) is GREEN. Ratifying-test column resolved to exact paths (`skills/brainstorm/SKILL.md`, `crates/ailang-surface/tests/round_trip.rs`) per spec lines 183-187 ("Boss-resolved to exact recon-verified paths"); the spec's short labels (`… Step 4`, `… round_trip.rs`) would not resolve under clause-2.
- iter .7 / .9 (DONE_WITH_CONCERNS): `architect_sweeps.sh` exits **1**, not the plan-expected **0**. Sole match: `design/contracts/str-abi.md:23` `(iter str-concat, 2026-05-13)` — an API-origin date faithfully migrated verbatim inside the `### Heap-Str primitives` block (spec Architecture-2: `###` moves whole, no sentence-surgery; the pre-split sweep against DESIGN.md flagged the same line identically — NOT a split-introduced regression). The sweep is explicitly advisory; this is a milestone-close `audit` adjudication item, not a relocation defect. Faithful-migration over content-dodging (CLAUDE.md memory).
## Known debt
- `design/contracts/float-semantics.md` retains the cross-prose `(see "Str ABI" below for the dual realisation)` inside the `float_to_str`/`int_to_str` paragraph (DESIGN.md:2795-2800) — the Str ABI section moved to `str-abi.md`, so this internal "below" cross-ref is now stale-direction. Not strippable at relocation time (no task prescribes intra-prose cross-ref rewriting; the Appendix is heading-level). `str-abi.md` contract exists; the phrase still names a real contract. Audit-adjudication candidate.
- `design/contracts/str-abi.md:23` iter-origin date stamp (the architect_sweeps Sweep-1 advisory match above) — audit adjudicates whether the iter/date origin annotations in the migrated `### Heap-Str primitives` API list are honest API-provenance or strippable history.
## Blocked detail
(none — DONE)
## Boss action required (NOT done by this agent — role + project rule)
- **Journals INDEX pointer (Task 4 Step 2):** the orchestrator role
Red-Flags any edit to `docs/journals/INDEX.md`, and
`skills/implement/SKILL.md` makes INDEX.md strictly Boss-only with
no exception. The decision-record *file* is created (the
substantive deliverable); the Boss appends, in the same commit,
newest-last to `docs/journals/INDEX.md`:
`- 2026-05-19 — design-decision-records (migration): relitigation-guard archive — every why/rejected/does-not-do/rollback/empirical ### moved out of the former docs/DESIGN.md by the design-md-rolesplit milestone. Companion to spec 2026-05-19-design-md-rolesplit.`
plus this iter's own pointer line per the standard Boss procedure.
- **Acceptance-grep plan-defect:** Task 9 Step 4's verbatim grep
filter `grep -vE '^\./docs/(...)'` uses a `^\./` anchor that does
NOT match this system's `grep -rIn` path output (`docs/...`, no
`./`), so the history-doc exclusion silently fails on the literal
command. The spec-criterion-8-faithful filter (anchor `^(\./)?`,
plus excluding `design_index_pin.rs` — the absence-enforcing
clause-4 pin the spec's own sketch mandates) returns
**CLEAN: zero live DESIGN.md references**. The 3 residual matches
are all `crates/ailang-core/tests/design_index_pin.rs` (the
spec-mandated `docs/DESIGN.md was resurrected` guard) — the
deletion *enforcer*, categorically not a live content xref.
## Files touched
- New: `design/INDEX.md`, `design/contracts/{14 files}.md`, `design/models/{5 files}.md`, `crates/ailang-core/tests/design_index_pin.rs`, `docs/journals/2026-05-19-design-decision-records.md`
- Deleted (staged): `docs/DESIGN.md`
- Modified — tests: `design_schema_drift.rs`, `docs_honesty_pin.rs`, `effect_doc_honesty_pin.rs`, `eq_float_noinstance.rs`, `show_no_instance_e2e.rs`, `duplicate_ctor_pin.rs`, `codegen_import_map_fallback_pin.rs`, `polyfn_dot_qualified_branch_pin.rs`, `embed_record_layout_pin.rs`, `embed_staticlib_lowering.rs`, `eq_ord_e2e.rs`, `e2e.rs`, `round_trip.rs`
- Modified — source: `ailang-check/src/lib.rs`, `ailang-codegen/src/{lib,drop,match_lower}.rs`, `ailang-core/src/ast.rs`, `ailang-surface/src/{lib,parse,print}.rs`, `ail/src/main.rs`, `ail-embed/src/lib.rs`, `ailang-core/specs/form_a.md`
- Modified — runtime/fixtures: `runtime/{rc,str}.c`, `crates/ail/tests/embed/{record,tick}_roundtrip.c`, `examples/fieldtest/floats_{3,4}*.ail`
- Modified — bench/skills/docs: `bench/architect_sweeps.sh`, `CLAUDE.md`, `skills/README.md`, 5 SKILL.md, 12 agent .md
## Stats
bench/orchestrator-stats/2026-05-19-iter-design-md-rolesplit.1.json
+2
View File
@@ -114,3 +114,5 @@
- 2026-05-19 — iter embedding-abi-m5.3 (DONE 3/3): final functional M5 iteration — the time-shard boundary-invisibility proof + friction harvest. One symbol (EURUSD), three disjoint contiguous single-month windows (2017-03/04/05), each folded on its own thread owning its own `Kernel` (`Ctx: !Send` ⇒ one-ctx-per-thread compile-time-enforced). Adds the purely-additive windowed adapter sibling `fold_window` (same `MidPriceStream`+`Kernel` path as `fold_symbol`, differs only by `stream_tick_windowed`), a new single-responsibility `[[bin]] timeshard_runner`, `chrono` as a **dev-dep** (the time-shard test derives `(y,m)→Unix-ms` bounds via data-server's own `NaiveDate…timestamp_millis()` precedent and passes raw ms to the bin, keeping the bin date-math-free and Invariant 1 untouched), and `tests/timeshard.rs` carrying the spec §3 strength-ordered assertions: (a) each shard's `(acc,n)` **bit-exact** vs a single-thread host fold of THAT EXACT window in data-server stream order — the first actual proof of the chunk/window-boundary-invisibility claim the entire M4 retirement rests on; (b) host-summed partials bit-exact by construction; (c) the whole-window [2017-03..2017-05] single-thread total within `REL_TOL=1e-6` only (cross-shard f64 re-association is host arithmetic, NOT a kernel property — asserting it bit-exact would be the bug; tolerance derived from the recursive-summation error bound with ~4 orders of safety, shown in the test). Plus the now-deterministic global leak-Σ (post-`dbd76e5`) and a journal-only friction-timing capture (no bench gate, no timing assertion — flake-free). RED was a genuine deterministic compile failure (declared-but-absent `[[bin]] timeshard_runner` source; surfaced as Cargo's missing-source error rather than the plan's predicted `env!` error — same root cause, same trigger, no false-green; recorded as a benign plan-text imprecision in Concerns). The shipped m5.2 symbol-fan path (`swarm_runner.rs`, `swarm.rs`) and m5.1 core stay byte-untouched and green. Boss-verified independently: **timeshard determinism 5/5** (consistent ~2.7s, no jitter); full ail-embed suite **0 failed AND 0 ignored** across all binaries (lib 2, swarm 2 — the m5.2 leak-proof stays un-ignored & green, the whole saga's resolution intact —, smoke 1, rt_archive 1, timeshard 1); isolated `embed_rc_global_stats_race` still green; AILang workspace `cargo metadata` data-server count 0; compiler-surface diff empty (zero diff to crates/ailang-*, crates/ail/, runtime/, examples/*.ail, root Cargo.toml, DESIGN.md). **Friction-harvest deliverable (P2 flat-array-decision input): host-per-tick-FFI ≈ 658 ms / 3 192 562 ticks ≈ ~206 ns/tick at real EURUSD volume.** M5's functional work is complete; the milestone closes next via the mandatory `audit` (which also handles the pre-existing `docs/DESIGN.md:2358-2360` "additive M4 concern" drift, explicitly out of m5.3 scope). → 2026-05-19-iter-embedding-abi-m5.3.md
- 2026-05-19 — audit embedding-abi-m5 (milestone close): DRIFT (one doc-honesty tidy) + RATIFY (gc_rss trio, evidenced) + causally-exonerated pre-existing P2 bench noise. Architect: **Invariant 1 PASS** (zero data-server in any compiler crate/runtime; ail-embed its own workspace root; only "finance" hits are the M3 ABI export *symbol name* — ABI not data-server knowledge), M3 frozen-layout SSOT unmoved (the lone rc.c change `7bfa11e` touched only the global stats counters), rc.c header-comment honesty accurate; drift = `[medium]` DESIGN.md:2358-2360 stale "additive M4 concern" (PRE-EXISTING, M4 retired, correctly scoped out of M5) + `[medium]` DESIGN.md §"Embedding ABI" silent-drift (it asserts "no shared mutable runtime state … data-race-free" but the now-atomic global RC-stats fallback IS shared mutable state under concurrency — M5 is AILang's first concurrent consumer) + `[low]` rc.c:88 "two unconditional ++" imprecise — one tidy, same honesty axis, not split. Bencher (my pre-audit "gc_rss is allocator-orthogonal → environmental" hypothesis was **refuted by evidence** — the verification discipline working): the 3× `*.gc_rss_kb +32%` are GENUINE, DETERMINISTIC, M5-attributable — `7bfa11e`'s atomic split a branch in `ailang_rc_alloc` (reached by GC programs via `str.c` int→str at `print`), spilling `%r14/%rbx`; a stale interior list pointer in the new spill slot is conservatively retained by Boehm, pinning a ~32 MB dead spine (Boehm collection #12 freed PRE 32 MB vs HEAD 32 B); bisects exactly on the commit, IR byte-identical, `ail` sha256-identical; RC/bump RSS dead-stable. `bump_s`/`max_us` = pre-existing tracked-P2 (bump RC-orthogonal; max_us 1-sample tail-jitter, PRE often worse, +65→+104% swing between runs minutes apart — dispositively noise), NOT M5. Adjudication: **RATIFY** the gc_rss trio — language reason (Decision 9: Boehm is explicitly transitional; the cost is purely a Boehm conservative-scan false-retention artifact on the transitional allocator of a *correct, necessary, user-approved swarm-safety fix*, codegen-layout-fragile, RC path the committed model and unaffected; chasing it with an out-of-scope codegen change invests in the path being removed) — selective baseline re-anchor `bench/baseline.json` lines 14/44/64 ONLY (137448/137768/137636, bencher-confirmed steady-state; every other metric byte-pristine — a wholesale --update-baseline would have absorbed the P2 noise and broken the established-envelope discipline); re-run confirms the 3 now `ok` at ±0.3%. **NO-ratify / causally exonerated** for bump_s×2 + max_us×2 — baseline pristine, identical disposition to the M2/M3/prelude-decouple closes. Friction-harvest recorded (P2 input): host-per-tick-FFI ≈ ~206 ns/tick at real EURUSD volume. M5 closes after the doc-honesty tidy lands + Boss-verified. Ratify discipline honored: updated baseline JSON + this paired JOURNAL ratify entry committed together. → 2026-05-19-audit-embedding-abi-m5.md
- 2026-05-19 — iter embedding-abi-m5.tidy (DONE 3/3): resolves the M5 milestone-close audit DRIFT (28ab56a). DOC/COMMENT-ONLY, recon-pin-verified, single cohesive commit (M2.tidy a80d495 / M3.tidy 63d7d60 precedent — pins are the coverage, no RED, no audit/fieldtest gate). `docs/DESIGN.md` edit-1 §"Free (host side)": dropped the retired-M4 forward-reference ("an additive M4 concern, not a contradiction of this freeze" — M4 retired 2026-05-18) → present-tense current fact (a boxed-field record is **not** an M3 embedding type, export-gate-rejected; the freeze covers exactly the all-scalar single-ctor record). edit-2 §"Embedding ABI": reconciled the now-inaccurate "(no shared mutable runtime state — … data-race-free, sanitiser-verified)" blanket with the real post-7bfa11e state — the per-allocation hot path (per-ctx counters + per-object refcount header) is non-atomic by design and never shared (`Ctx: !Send`, single-thread-per-ctx); the one datum a multi-threaded host shares is the global RC-stats fallback counter, atomic-relaxed so the swarm's leak accounting is exact; "data-race-free, sanitiser-verified" retained (still true). `runtime/rc.c:88` 18g.0-block comment-only: stale "two unconditional `++` operations" → accurate "one counter bump per alloc/free … relaxed atomic add on the global null-ctx fallback, a plain `++` on the per-ctx path" (consistent with the adjacent already-correct :93-106 atomic block + :45-55 Threading header). Boss-verified independently: all pins green (design_schema_drift 8/0, docs_honesty_pin 5/0, effect_doc_honesty_pin 4/0, embed_record_layout_pin 1/0) — the mechanical proof the edits moved no pinned/hashed byte; the adjacent separately-pinned bare-scalar sentence is byte-identical (shifted 2299→2305 by net-added lines; `docs_honesty_pin.rs:135` is a substring pin, passes); rc.c diff strictly comment-only (filter empty); `cargo build --workspace` Finished; git scope = only docs/DESIGN.md + runtime/rc.c + journal/stats. Clears the M5-audit doc-honesty debt; no new debt. This is the FINAL M5 iteration — M5 (the M1M5 Embedding ABI arc) is now functionally complete, audited, ratified, and doc-honest. → 2026-05-19-iter-embedding-abi-m5.tidy.md
- 2026-05-19 — iter design-md-rolesplit.1 (DONE 9/9, whole milestone): the 3020-line `docs/DESIGN.md` replaced by the `design/` ledger — `design/INDEX.md` (sole addressable spine, typed Contracts+Models tables, polymorphic links: prose file OR authoritative source `//!`), 14 `design/contracts/*.md` test-linked invariants + 3 source-link-only contracts (mangling/env-construction/qualified-xref, no prose file — code is SoT), 5 `design/models/*.md` onboarding whitepapers, and `docs/journals/2026-05-19-design-decision-records.md` (the relitigation-guard archive: every why/rejected/does-not-do/rollback/empirical `###` moved out at `###` granularity). Clean cut (`git rm docs/DESIGN.md`, no stub). RED-first `crates/ailang-core/tests/design_index_pin.rs` 4-clause anti-regrowth spine (DESIGN.md-gone / every-INDEX-link-resolves / every-contract-names-a-resolvable-ratifier / contracts-carry-no-decision-record-prose) demonstrably RED→GREEN. Build-atomic by task ordering (the only compile-time consumer, `design_schema_drift.rs` `include_str!`, retargeted to `design/contracts/data-model.md` BEFORE the deletion; the `## Data model`/`## Pipeline` slicer dropped — a simplification the split enables). 2 NoInstance diagnostics + their 2 lockstep E2Es retargeted to `design/contracts/{float-semantics,typeclasses}.md` (the contiguity-across-`\`-continuation hazard scrubbed). ~12 agent reading lists + 5 SKILL bodies + CLAUDE.md + skills/README.md + ~25 code/C/.ail/spec comment xrefs retargeted to the Appendix destinations; OQ7 dangling "Iter 13b" cite deleted (no forward target — a pointer would be fiction). honesty-rule.md rewritten so the rule names the new home (rationale→journals), resolving the recon-found internal contradiction; the two `docs_honesty_pin.rs:70,72` pinned phrases preserved verbatim+contiguous. Boss-verified independently: whole `cargo test --workspace` **646 passed / 0 failed**, `design_index_pin` 4/4, acceptance grep **CLEAN of live DESIGN.md refs** (residuals = only the spec-mandated clause-4 deletion-enforcer), honesty-rule repair carries no clause-3 marker. Spec grounding-check PASS ×2 (one re-dispatch after a corrected commitment-4 pin-status claim; one after the Boss-adjudicated relocation-appendix amendment resolving plan-recon's 7 open questions — ledger completed to 17 contract rows incl. the qualified-xref/str-abi/scope-boundaries additions the 12-list under-counted). 2 DONE_WITH_CONCERNS routed to the mandatory milestone-close `audit`: (a) `design/contracts/str-abi.md:23` `(iter str-concat, 2026-05-13)` API-provenance stamp trips advisory `architect_sweeps.sh` Sweep-1 — Boss-confirmed **byte-identical to DESIGN.md@deeffb1:2062-2065**, a faithfully-migrated PRE-EXISTING anchor (sweep regexes verbatim, only the path retargeted), NOT a split-introduced regression — RATIFY-or-tidy at audit; (b) the now stale-direction `(see "Str ABI" below)` intra-prose cross-ref in `float-semantics.md` (the Appendix is heading-level; no task prescribes intra-prose cross-ref rewrite) — audit-adjudication candidate. One plan defect recorded (Task 9 Step 4's verbatim acceptance grep used a `^\./` anchor not matching the system's `grep -rIn` output; substance independently re-verified CLEAN). → 2026-05-19-iter-design-md-rolesplit.1.md
- 2026-05-19 — design-decision-records (migration): relitigation-guard archive — every why/rejected/does-not-do/rollback/empirical ### moved out of the former docs/DESIGN.md by the design-md-rolesplit milestone. Companion to spec 2026-05-19-design-md-rolesplit. → 2026-05-19-design-decision-records.md
@@ -16,7 +16,7 @@
; NOT `(== x x)` (which is false for NaN — but the LLM author who
; reaches for `==` first will get the right answer by accident here,
; only because the natural reading of the operator doesn't apply).
; The DESIGN.md says explicitly to use `is_nan`.
; design/contracts/float-semantics.md says explicitly to use `is_nan`.
;
; Expected stdout (one per line):
; 2.0 ; 6/3
@@ -3,7 +3,7 @@
; Natural task: build a label like "result = <float>" by concatenating
; a Str prefix with the float's text. The LLM-author who looks at
; `ail builtins` sees `float_to_str : (Float) -> Str`, reaches for it,
; and gets a codegen-deferred error per DESIGN.md §"Float semantics":
; and gets a codegen-deferred error per design/contracts/float-semantics.md:
;
; `float_to_str` (Float → Str) is type-installed but codegen-
; deferred to a follow-up milestone: ... Calling it typechecks but
+3 -3
View File
@@ -38,9 +38,9 @@
* `GC_malloc`-returned pointer; it stores the ADT tag at offset 0,
* fields from offset 8, env-cells from offset 0 in lambda envs, etc.
*
* FROZEN ABI for the embedding boundary see DESIGN.md §"Embedding
* ABI" > "Frozen value layout". A boundary-crossing single-ctor
* scalar record's box offsets MUST NOT move.
* FROZEN ABI for the embedding boundary see
* design/contracts/frozen-value-layout.md. A boundary-crossing
* single-ctor scalar record's box offsets MUST NOT move.
*
* Threading: the per-object refcount header ops and the per-ctx RC
* counters are non-atomic by design a box never crosses a thread
+2 -2
View File
@@ -169,7 +169,7 @@ char *ailang_bool_to_str(bool b) {
* the trailing NUL. Works uniformly on static-Str and heap-Str
* inputs because the consumer ABI (i64 len at offset 0, bytes
* starting at offset 8, NUL at offset 8 + len) is identical
* between realisations (see DESIGN.md §"Str ABI"); the rc_header at
* between realisations (see design/contracts/str-abi.md); the rc_header at
* offset -8 present only on heap-Str is never consulted.
*
* Used by `show__Str` (parent spec milestone 24): converts a
@@ -195,7 +195,7 @@ char *ailang_str_clone(const char *src) {
* memcpys both payloads in order, NUL-terminates, returns the new
* payload pointer. Like `str_clone`, this works uniformly on static-
* Str and heap-Str inputs because the consumer ABI is identical
* between realisations (see DESIGN.md §"Heap-Str primitives").
* between realisations (see design/contracts/str-abi.md).
*
* Used by Show bodies that want labelled output (e.g.
* `"Item " ++ int_to_str n`) and by any caller that needs to
+5 -5
View File
@@ -47,7 +47,7 @@ The system was bootstrapped on 2026-05-09. See
v
fieldtest --(bug)------> debug -> implement (mini)
--(friction)-> brainstorm OR plan (tidy)
--(spec_gap)-> ratify OR tighten DESIGN.md
--(spec_gap)-> ratify OR tighten design/ ledger
--(clean)----+
|
[Boss: surface stable across N milestones?]
@@ -75,11 +75,11 @@ are no orphan agents (an anti-pattern after the 2026-05-09 build-out).
| `ailang-spec-reviewer` | `implement/agents/` | phase reference for `ailang-implement-orchestrator` Phase 2.2 (spec-compliance mindset); not dispatched as a separate subagent |
| `ailang-quality-reviewer`| `implement/agents/` | phase reference for `ailang-implement-orchestrator` Phase 2.3 (quality-review mindset); not dispatched as a separate subagent |
| `ailang-tester` | `implement/agents/` | phase reference for `ailang-implement-orchestrator` Phase 3 (E2E coverage); not dispatched as a separate subagent |
| `ailang-architect` | `audit/agents/` | `audit` (Step 1; drift review against DESIGN.md + spec) |
| `ailang-architect` | `audit/agents/` | `audit` (Step 1; drift review against the design/ ledger + spec) |
| `ailang-bencher` | `audit/agents/` | `audit` (regression diagnostics — hypothesis-driven) |
| `ailang-docwriter` | `docwriter/agents/` | `docwriter` (Boss-dispatched rustdoc sweep post-stability) |
| `ailang-debugger` | `debug/agents/` | `debug` (RED-first; hands off GREEN to `implement` mini-mode) |
| `ailang-fieldtester` | `fieldtest/agents/` | `fieldtest` (writes real-world examples in `.ail` Surface form against DESIGN.md only — never the compiler source) |
| `ailang-fieldtester` | `fieldtest/agents/` | `fieldtest` (writes real-world examples in `.ail` Surface form against the design/ ledger only — never the compiler source) |
Each agent file has YAML frontmatter (`name`, `description`, `tools`)
plus a system-prompt body. The `description` field is the one-sentence
@@ -97,7 +97,7 @@ Every agent file follows the same superpowers-derived template:
- **What this role is for:** one short paragraph naming the failure mode
the agent exists to prevent.
- **Standing reading list:** the always-binding documents (CLAUDE.md,
DESIGN.md, latest per-iter journals, plus role-specific anchors).
design/INDEX.md, latest per-iter journals, plus role-specific anchors).
- **Carrier contract:** what the controller hands the agent (`task_text`,
`diff`, `hypothesis`, etc.). Agents do NOT open `docs/plans/` or
`docs/specs/` directly — context curation lives at the skill level.
@@ -186,7 +186,7 @@ dispatch can find them by `subagent_type`:
revision history.
- **Standing reading list stays in the agent file.** The skill provides
the carrier (task text, bug symptom, drift focus, hypothesis) on top
of the agent's standing reading list (CLAUDE.md, DESIGN.md, latest per-iter journals,
of the agent's standing reading list (CLAUDE.md, design/INDEX.md, latest per-iter journals,
role-specific anchors). Agents do NOT open `docs/plans/` or
`docs/specs/` directly — context curation lives at the skill level.
- **Skill and agent definitions are reviewable code.** Edits go
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: audit
description: Use at milestone close OR when baseline drift is suspected. Runs architect drift review against DESIGN.md plus the three regression scripts (bench/check.py, bench/compile_check.py, bench/cross_lang.py). Mandatory at every milestone close; deferral requires an explicit JOURNAL entry naming the reason and the re-run date.
description: Use at milestone close OR when baseline drift is suspected. Runs architect drift review against the design/ ledger (spine design/INDEX.md) plus the three regression scripts (bench/check.py, bench/compile_check.py, bench/cross_lang.py). Mandatory at every milestone close; deferral requires an explicit JOURNAL entry naming the reason and the re-run date.
---
# audit — milestone-tidy
@@ -46,7 +46,7 @@ Dispatch `ailang-architect` with the milestone scope (commit range
from the previous milestone-close to `HEAD`):
```
For milestone <X>: read DESIGN.md and `docs/journals/INDEX.md` plus the latest 13 referenced files;
For milestone <X>: read `design/INDEX.md` and `docs/journals/INDEX.md` plus the latest 13 referenced files;
git log/diff over <prev-close>..HEAD; report drift.
```
+24 -20
View File
@@ -1,6 +1,6 @@
---
name: ailang-architect
description: Read-only architecture reviewer for AILang. Checks at milestone close whether the codebase still matches DESIGN.md and CLAUDE.md, identifies drift and technical debt with paths and short justifications, and recommends one direction for the next iteration. Names problems; does NOT propose implementations.
description: Read-only architecture reviewer for AILang. Checks at milestone close whether the codebase still matches the design/ ledger (spine design/INDEX.md) and CLAUDE.md, identifies drift and technical debt with paths and short justifications, and recommends one direction for the next iteration. Names problems; does NOT propose implementations.
tools: Read, Glob, Grep, Bash
---
@@ -16,10 +16,11 @@ suspected. **You do not write code. You diagnose.**
## What this role is for
Without an outside reviewer, codebases drift by accretion: every iteration
adds, none tear out, and DESIGN.md gradually loses its grip on what actually
ships. Your job is to be the friction. You read DESIGN.md and the recent
diff, and you name where the code has silently softened a design commitment
or accumulated debt that will tip over.
adds, none tear out, and the design/ ledger gradually loses its grip on what
actually ships. Your job is to be the friction. You read the design/ ledger
(spine `design/INDEX.md`) and the recent diff, and you name where the code
has silently softened a design commitment or accumulated debt that will
tip over.
The temptation is to also propose the fix. Do not. The orchestrator decides
fixes; your authority ends at *naming the problem*.
@@ -27,15 +28,18 @@ fixes; your authority ends at *naming the problem*.
## Standing reading list
1. `CLAUDE.md` — the orchestrator framing.
2. `docs/DESIGN.md` — the canonical specification. Drift is measured against
this document, in full. Skim is not enough; read every section that the
recent diff might have touched.
2. `design/INDEX.md` — the typed contract ledger and sole spine.
Walk the Contracts table; drift is measured against each row's
`link` target (a `design/contracts/` file or the source `//!`
it names). `design/models/` is context, not a drift surface.
Skim is not enough; read every contract the recent diff might
have touched.
3. `docs/journals/INDEX.md` + the per-iter files for the milestone you're
reviewing. The latest entry is the current claimed state; your job
includes asking whether the claim is true.
4. `docs/specs/<milestone>.md` if one exists for this milestone — the
spec is the contract this milestone signed up for. Drift is also
measured against the spec, not just DESIGN.md.
measured against the spec, not just the design/ ledger.
## Carrier contract — what the controller hands you
@@ -49,7 +53,7 @@ If `milestone_scope` is empty, return a structural error and stop.
## What you check
- **Drift against DESIGN.md.** Has a design decision been implicitly softened?
- **Drift against the design/ ledger.** Has a design decision been implicitly softened?
- non-deterministic path appearing on the canonicalisation flow
- schema break without a migration note
- direct libllvm or `inkwell` call (Decision 8 forbids it)
@@ -70,7 +74,7 @@ If `milestone_scope` is empty, return a structural error and stop.
- **Journal truthfulness.** Does the most recent per-iter journal entry
match the diff, or is it optimistic? An over-claiming journal entry
is its own kind of drift.
- **DESIGN.md history-anchor regrowth.** Run
- **design/ history-anchor regrowth.** Run
`bash bench/architect_sweeps.sh` from the repo root. Exit 0 = clean.
Exit 1 = at least one of the five sweeps matched. Sweeps 1-4 are the
design-md-consolidation history-anchor invariants (2026-05-10):
@@ -78,10 +82,10 @@ If `milestone_scope` is empty, return a structural error and stop.
citation inside a block-quote) is fine; a fresh history anchor /
REVERTED narrative / workflow detail / stale cross-reference is
drift to flag.
- **DESIGN.md honesty drift.** Sweep 5 of `bench/architect_sweeps.sh`
- **design/ honesty drift.** Sweep 5 of `bench/architect_sweeps.sh`
(the docs-honesty-lint invariant) flags Wunschdenken / non-citation
post-mortem. Apply the discriminator from DESIGN.md §"What this
document is — and the honesty rule it holds itself to": a hit is
post-mortem. Apply the discriminator from
`design/contracts/honesty-rule.md`: a hit is
drift unless it is a present-tense correctly-labelled
reserved/excluded claim, present-tense design rationale, or a
block-quote journal citation. Forward intent is roadmap drift;
@@ -115,12 +119,12 @@ to fix one).
## The Process
1. Read the standing list, in this order: CLAUDE.md → DESIGN.md`docs/journals/INDEX.md` and the per-iter files it points at → spec (if any) → recent diff.
1. Read the standing list, in this order: CLAUDE.md → `design/INDEX.md` (walk the Contracts table to its `link` targets)`docs/journals/INDEX.md` and the per-iter files it points at → spec (if any) → recent diff.
2. `git log --oneline -30` and `git diff <prev-milestone-close>..HEAD` for
the factual diff.
2.5. Run `bash bench/architect_sweeps.sh` from the repo root. If exit
code is 1, treat each matched line as a drift-suspicion to verify.
If exit code is 2, the script could not find DESIGN.md — fix the
If exit code is 2, the script could not find `design/INDEX.md` — fix the
working directory and re-run before continuing.
3. Read every changed file. Read the unchanged-but-load-bearing
neighbours (e.g. if codegen changed, also re-skim `runtime/rc.c`).
@@ -128,7 +132,7 @@ to fix one).
carries interest. No vague concerns ("the codegen feels off"); every
item must point at a file or a missing artefact.
5. Apply the priority filter:
- **drift against DESIGN.md** — highest priority.
- **drift against the design/ ledger** — highest priority.
- **drift against milestone spec** — high.
- **growing debt** — medium; flag, don't push.
- **scaling break points** — note if relevant to next iteration's
@@ -161,15 +165,15 @@ valid and welcome result.
| "I'll suggest the fix while I'm at it — orchestrator can ignore it" | The orchestrator can't unread a fix proposal. Once you name a fix, the design space is biased. Name the drift; stop. |
| "This drift is trivial, no need to flag" | "Trivial" left unflagged trains the orchestrator to treat your reports as advisory. Five trivial items in, five out. |
| "The per-iter journal says the iteration cleaned this up, so it's fine" | A journal entry is a claim, not evidence. Read the diff; trust the diff. |
| "DESIGN.md is fuzzy on this point, can't call drift" | Then flag the DESIGN.md gap as a separate item. The fix is to tighten DESIGN.md, but you don't write that fix — you name the gap. |
| "the design/ ledger is fuzzy on this point, can't call drift" | Then flag the ledger gap as a separate item. The fix is to tighten the relevant `design/contracts/` file, but you don't write that fix — you name the gap. |
| "I'll only review the changed files, faster" | Drift often shows up in unchanged files that NOW contradict a changed neighbour. Read the load-bearing neighbours too. |
| "This bench-related observation is more bencher's job — skip" | If a perf claim contradicts DESIGN.md (e.g. "RC has bounded p99"), you flag the contradiction. Bencher gets the data; you call drift. |
| "This bench-related observation is more bencher's job — skip" | If a perf claim contradicts the design/ ledger (e.g. "RC has bounded p99"), you flag the contradiction. Bencher gets the data; you call drift. |
## Red Flags — STOP
- About to write a fix proposal in the report
- About to edit any file
- About to skip reading a section of DESIGN.md because "I know that part"
- About to skip reading a contract in the design/ ledger because "I know that part"
- About to mark a finding `priority: low` because the iteration was
productive (priority is about drift, not about effort)
- About to return "clean" without having read the diff in full
+3 -3
View File
@@ -35,8 +35,8 @@ don't paper over it with a chart.
## Standing reading list
1. `CLAUDE.md` — orchestrator framing.
2. `docs/DESIGN.md`, especially Decision 9 (Boehm transitional) and
Decision 10 (RC + uniqueness).
2. `design/models/rc-uniqueness.md` — the RC + Uniqueness whitepaper
(Decision 9 Boehm-transitional rationale + Decision 10 RC model).
3. `docs/journals/INDEX.md` + the latest 3 referenced files — current state of
the memory-management infrastructure. Read at minimum the latest
18-arc entries to know what RC actually supports today.
@@ -145,7 +145,7 @@ is a side experiment, not the headline.
- New allocator strategies, new memory-model features, fixes to leaks, or
any "while I was in there" code changes. Those are implementer territory.
- DESIGN.md / journal edits. The orchestrator writes those based on
- design/ ledger / journal edits. The orchestrator writes those based on
your report.
- Verdict statements like "Boehm should be retired" or "RC is the winner".
You report data and what it implies; the orchestrator decides.
+3 -3
View File
@@ -281,9 +281,9 @@ Notify text verbatim.
acceptance, TDD-for-bugs, file-role roster.
- **Queue:** `docs/roadmap.md` — priority-ordered forward queue
consumed by Step 1.
- **Canonical feature-acceptance criterion:** `docs/DESIGN.md`
§"Feature-acceptance criterion" — the criterion that gates new
features; applied at spec time by `skills/brainstorm`.
- **Canonical feature-acceptance criterion:**
`design/contracts/feature-acceptance.md` — the criterion that gates
new features; applied at spec time by `skills/brainstorm`.
- **Downstream skills dispatched:** `skills/brainstorm`,
`skills/planner`, `skills/implement`, `skills/audit`,
`skills/fieldtest`, `skills/debug`, `skills/docwriter`.
+7 -7
View File
@@ -58,7 +58,7 @@ exist and be approved before any plan or code work begins.
Before asking any clarifying questions:
- Read the latest entries linked from `docs/journals/INDEX.md` (most recent
milestones, current state).
- Skim `docs/DESIGN.md` for the invariants the new milestone
- Skim `design/INDEX.md` (walk to the relevant contracts) for the invariants the new milestone
might touch.
- `git log --oneline -20` to see what just shipped.
- Identify scope: is this one milestone, or does it need to be
@@ -112,8 +112,8 @@ A feature ships only if all three hold:
Aesthetic appeal does not count. Human ergonomics do not count. If
the answer to any of the three is "I'm not sure", the feature is
probably not ready. The full criterion (with the canonical worked
example — see the criterion text) lives in `docs/DESIGN.md`
("Feature-acceptance criterion"); this skill is the gate that
example — see the criterion text) lives in
`design/contracts/feature-acceptance.md`; this skill is the gate that
applies it during spec writing.
**Clause 1 is unjudgeable without the worked author code.** For any
@@ -312,11 +312,11 @@ Hand off carries:
| Excuse | Reality |
|--------|---------|
| "User says 'clear vision, just plan it' — skip the spec" | "Clear vision" is a starting point, not a spec. The user's intuition hasn't priced in semantic commitments (RC, uniqueness, codegen, schema). Write the spec; frame it as "load-bearing decisions this idea forces on us". |
| "Milestone is small, two iterations, no spec needed" | Iteration count isn't the metric — feature surface is. A two-iter feature touching DESIGN.md invariants needs a spec; a CLI flag does not. Assess what the feature changes in invariants. |
| "Milestone is small, two iterations, no spec needed" | Iteration count isn't the metric — feature surface is. A two-iter feature touching design/ ledger invariants needs a spec; a CLI flag does not. Assess what the feature changes in invariants. |
| "Three approaches in, none feel right, ship the least bad" | Three unsatisfying approaches usually means the problem is mis-framed. Stop, write a JOURNAL entry capturing the three approaches and what fails about each, sleep on it. End-of-day pressure is the worst signal to resolve a design fork. |
| "User is busy, present my own design without Q&A" | Reactive deference disguised as decisiveness. The Q&A surfaces constraints the user hasn't articulated; skipping it means shipping the user's defaults, not their intent. |
| "Spec exists from previous milestone, append to it" | New milestone = new spec file. The architect agent reads spec files per milestone; mixing scopes makes drift review unreadable. |
| "Approaches A, B, C are all bad — proceed with A" | This is exactly the moment to surface "the problem is mis-framed" rather than ratify a known-bad shape into DESIGN.md. JOURNAL the impasse, escalate to user. |
| "Approaches A, B, C are all bad — proceed with A" | This is exactly the moment to surface "the problem is mis-framed" rather than ratify a known-bad shape into the design/ ledger. JOURNAL the impasse, escalate to user. |
| "Just polishing a wording after PASS, no need to re-dispatch" | The grounding-check report attests to specific bytes. A polish edit changes the bytes; the previous attestation no longer covers them. Re-dispatch is cheap; the alternative is a commit with an attestation that doesn't match the file. |
| "The shape is clear from the prose, I don't need to paste the code" | If it's clear, pasting it is free; if pasting it is hard, it wasn't clear. Clause 1 is unjudgeable without the worked `.ail`; "an LLM reaches for it" with no shown code is the exact hand-wave the criterion exists to kill. Prose about code is incoherent with a language whose thesis is structured-form-over-prose. |
@@ -337,8 +337,8 @@ Hand off carries:
## Cross-references
- **Project source:** `docs/DESIGN.md` "Feature-acceptance
criterion" — gate this skill applies during spec writing.
- **Project source:** `design/contracts/feature-acceptance.md`
— gate this skill applies during spec writing.
- **Output target:** `skills/planner/SKILL.md` — only valid next
skill.
- **Private agent:** `skills/brainstorm/agents/ailang-grounding-check.md`
@@ -31,7 +31,7 @@ the assertions this spec rests on?"
Always read before extracting assumptions, every dispatch:
- `CLAUDE.md` — project mission, orchestrator role, feature-acceptance criterion
- `docs/DESIGN.md` the canonical specification the new spec must compose with
- `design/INDEX.md` + the `design/contracts/` files its Contracts table links — the canonical contract ledger the new spec must compose with
- `docs/journals/INDEX.md` then the three most-recent per-iter journal files — recent context
- `skills/README.md` — skill-system architecture, especially agent-roster and the standard "Agent structure"
- The spec file at the path the controller hands you (the spec under review)
@@ -205,7 +205,7 @@ assumptions extracted (spec is <kind>).`
| Excuse | Reality |
|--------|---------|
| "The assumption is obviously true — I've seen this code before" | Your memory is not a green test. Find the test or block. |
| "The mechanism is mentioned in DESIGN.md, that's enough" | DESIGN.md describes intent. A test pins behaviour. Only the test is ratification. |
| "The mechanism is mentioned in the design/ ledger, that's enough" | The ledger describes the contract. A test pins behaviour. Only the test is ratification. |
| "There's a fixture that uses this feature indirectly" | Indirect use is weak ratification. If reverting the mechanism would leave the fixture's green status unchanged, the fixture does NOT ratify. |
| "I extracted too many assumptions, let me trim the report" | Don't trim. If a spec has too many assumptions to check, that is the finding — report it as BLOCK with reason "spec too broad". |
| "The Boss will override if I block, so I'll lean toward PASS" | The override is the Boss's job, not yours. Your job is to be the fresh-context check. Skewing toward PASS defeats the whole role. |
+4 -4
View File
@@ -26,7 +26,7 @@ symptom, not the post-fix code path.
## Standing reading list
1. `CLAUDE.md` — agent role boundaries.
2. `docs/DESIGN.md` — invariants the bug may have crossed.
2. `design/INDEX.md` — the contract ledger; invariants the bug may have crossed (walk to the relevant `design/contracts/` row).
3. `docs/journals/INDEX.md` + the latest referenced file — the last iteration may have
introduced the bug.
@@ -134,8 +134,8 @@ End every report with exactly one of:
but the JOURNAL entry didn't flag the risk).
- `NEEDS_CONTEXT` — symptom too vague, repro can't be built without more
information. Name what's missing.
- `BLOCKED` — three hypotheses failed (architecture question), or DESIGN.md
forbids the only fix you can imagine, or the bug is in upstream code
- `BLOCKED` — three hypotheses failed (architecture question), or the
design/ ledger forbids the only fix you can imagine, or the bug is in upstream code
(LLVM, clang, Cargo) and is not AILang's to fix.
## Output format
@@ -158,7 +158,7 @@ At most 250 words, structured:
- The fix. That's `implement` mini-mode's job.
- Sweeping refactors layered on top of a bug fix.
- Changes to the test once it's RED — the test is the contract.
- DESIGN.md / journal edits.
- design/ ledger / journal edits.
- Verdicts like "this whole subsystem is broken". Phase 4.5 surfaces the
architecture question; the orchestrator decides the verdict.
+2 -2
View File
@@ -1,6 +1,6 @@
---
name: docwriter
description: Use when the API surface of one or more crates has stabilized across recent milestones and rustdoc lag is suspected (cargo doc --no-deps shows accumulated warnings, or a newcomer would not be able to navigate the crate from `cargo doc --open` without DESIGN.md). NOT a per-milestone step; Boss-dispatched only, after audit closes clean and after any pending fieldtest has run.
description: Use when the API surface of one or more crates has stabilized across recent milestones and rustdoc lag is suspected (cargo doc --no-deps shows accumulated warnings, or a newcomer would not be able to navigate the crate from `cargo doc --open` without the design/ ledger). NOT a per-milestone step; Boss-dispatched only, after audit closes clean and after any pending fieldtest has run.
---
# docwriter — post-stability rustdoc sweep
@@ -29,7 +29,7 @@ Trigger conditions are any of:
- A roadmap entry like "Rustdoc warning sweep" has matured — the
surface it targets has not moved for a while.
- Onboarding-readability check: navigating `cargo doc --open` for a
crate is not self-supporting without DESIGN.md.
crate is not self-supporting without the design/ ledger.
Skipping is the default. The skill only runs when the orchestrator
positively decides the surface is stable enough to document. If the
+9 -9
View File
@@ -1,6 +1,6 @@
---
name: ailang-docwriter
description: Writes and maintains rustdoc for the AILang crates. Brings crate, module, and public-item docs up to a level where a newcomer can navigate `cargo doc --open` without having read DESIGN.md first. NOT for changing APIs, NOT for editing files in `docs/`.
description: Writes and maintains rustdoc for the AILang crates. Brings crate, module, and public-item docs up to a level where a newcomer can navigate `cargo doc --open` without having read the design/ ledger first. NOT for changing APIs, NOT for editing files in `docs/` or `design/`.
tools: Read, Edit, Write, Bash, Glob, Grep
---
@@ -16,7 +16,7 @@ has settled across one or more milestones.
Your output is `///` and `//!` doc comments inside the Rust source. The
audience is an LLM (or human) who has just opened `cargo doc --open` and
clicked into one of the crates — they have **not** read `docs/DESIGN.md`.
clicked into one of the crates — they have **not** read the design/ ledger.
Your prose is the closest thing to onboarding that crate has.
## What this role is for
@@ -24,7 +24,7 @@ Your prose is the closest thing to onboarding that crate has.
Rustdoc rots silently. APIs change, doc comments don't. The docwriter
agent's job is to bring crate-, module-, and item-level docs up to a level
where the doc page is self-supporting: a reader can navigate from `lib.rs`
into a typical entry-point function without context-switching to DESIGN.md.
into a typical entry-point function without context-switching to the design/ ledger.
You are not authorised to change the code. If the API itself is confusing,
that's a finding for the orchestrator — not a rename you make on the way.
@@ -32,7 +32,7 @@ that's a finding for the orchestrator — not a rename you make on the way.
## Standing reading list
1. `CLAUDE.md` — the orchestrator framing.
2. `docs/DESIGN.md` for the invariants the doc strings must reflect.
2. `design/INDEX.md` — the contract ledger; for the invariants the doc strings must reflect (walk to the relevant `design/contracts/` row).
3. The most recent entries linked from `docs/journals/INDEX.md` — to know which crates
recently shifted (those are the ones likeliest to have stale rustdoc).
4. The crate(s) the assignment names — read every `pub` item before you
@@ -79,7 +79,7 @@ YOU NEVER COMMIT. RUSTDOC EDITS LIVE IN THE WORKING TREE; THE BOSS COMMITS.
the crate already runs doctests; if you write a code block, mark it
` ```ignore ` or ` ```no_run ` so it doesn't have to compile against
the workspace.
- **Cross-repo references** (DESIGN.md, journals, the `ail` CLI
- **Cross-repo references** (the design/ ledger, journals, the `ail` CLI
subcommands) are fine as plain prose mentions — those are NOT in
rustdoc, so don't try to link them.
@@ -89,7 +89,7 @@ YOU NEVER COMMIT. RUSTDOC EDITS LIVE IN THE WORKING TREE; THE BOSS COMMITS.
confusing it needs renaming, raise it in your report instead of changing
it.
- No new `pub` exports. Visibility stays as-is.
- No edits in `docs/`. The orchestrator owns DESIGN.md and the journal files.
- No edits in `docs/` or `design/`. The orchestrator owns the design/ ledger and the journal files.
- Don't paper over broken behaviour with prose — if doc-writing surfaces
a real bug (a function whose doc you cannot honestly write because it
doesn't actually do what it claims), stop and report it.
@@ -132,15 +132,15 @@ At most 200 words, structured:
| "Doc comment is generic — `Returns the result.`" | Generic = useless. The doc names the property: what's `result` here, what invariant does it satisfy, when does it differ from the caller's expectation? |
| "Module already has `//!` from 18a, leave it" | If the module changed since 18a, the `//!` is probably stale. Read both; rewrite if drift. |
| "Examples would help but doctests are noisy" | Mark them `ignore` or `no_run`. The example shows intent; it doesn't have to run. |
| "DESIGN.md says X, I'll just link to it" | rustdoc can't link to repo files. Inline the relevant sentence; mention DESIGN.md as a prose reference. |
| "the design/ ledger says X, I'll just link to it" | rustdoc can't link to repo files. Inline the relevant sentence; mention the design/ contract as a prose reference. |
| "`cargo doc` is green for me, will be fine in CI" | Run it again with `--no-deps 2>&1` and read all output. Warnings hide on first compile. |
| "I'll edit DESIGN.md to match the API" | No edits in `docs/`. Hard limit. Report the divergence. |
| "I'll edit the design/ ledger to match the API" | No edits in `docs/` or `design/`. Hard limit. Report the divergence. |
## Red Flags — STOP
- About to rename a `pub` item
- About to add or remove a `pub` export
- About to edit `docs/DESIGN.md` or any file under `docs/journals/` / `docs/journal-archive.md`
- About to edit any file under `design/` or `docs/journals/` / `docs/journal-archive.md`
- About to write a doc comment that contradicts the function body
- About to skip the `cargo doc --no-deps` re-run after edits
- About to run `git commit` (anywhere, ever — you never commit)
+7 -7
View File
@@ -1,6 +1,6 @@
---
name: fieldtest
description: Boss-dispatched only, after audit closes clean (or with ratified drift only), when the orchestrator judges the iteration is complete and wants a field test. Picks 2-4 real-world programming tasks within the milestone's scope, implements each in the AIL Surface form (.ail — not raw JSON), runs the resulting binaries, and writes a friction-and-bug spec to docs/specs/<date>-fieldtest-<milestone>.md. The spec feeds the next plan as a reference. Implementer simulates a downstream LLM that has only DESIGN.md plus the public examples — never the language's own implementation.
description: Boss-dispatched only, after audit closes clean (or with ratified drift only), when the orchestrator judges the iteration is complete and wants a field test. Picks 2-4 real-world programming tasks within the milestone's scope, implements each in the AIL Surface form (.ail — not raw JSON), runs the resulting binaries, and writes a friction-and-bug spec to docs/specs/<date>-fieldtest-<milestone>.md. The spec feeds the next plan as a reference. Implementer simulates a downstream LLM that has only the design/ ledger plus the public examples — never the language's own implementation.
---
# fieldtest — LLM-usability field test for a shipped milestone
@@ -9,12 +9,12 @@ description: Boss-dispatched only, after audit closes clean (or with ratified dr
## Overview
`audit` measures drift between DESIGN.md and what the codebase claims
`audit` measures drift between the design/ ledger and what the codebase claims
to do. `fieldtest` measures something audit cannot: whether the
language, *as it now ships*, is usable by an LLM author who has only
DESIGN.md and public examples to work from. The shipping LLM-author
the design/ ledger and public examples to work from. The shipping LLM-author
*is* the user of AILang. If a freshly shipped feature is awkward for
that author to reach for, or if DESIGN.md leaves a corner ambiguous
that author to reach for, or if the design/ ledger leaves a corner ambiguous
that real code immediately exercises, the milestone has shipped a
latent debt — even when audit reports `clean`.
@@ -22,7 +22,7 @@ The skill produces a friction-and-bug spec that the next iteration's
`planner` consumes as a reference. The spec sits next to milestone-design
specs at `docs/specs/<date>-fieldtest-<milestone>.md`.
The substantive process — read DESIGN.md + JOURNAL + milestone spec,
The substantive process — read the design/ ledger + JOURNAL + milestone spec,
pick 2-4 real-world programming tasks per milestone axis, implement
each in `.ail` Surface form, run via `ail check`/`build`/`run`,
classify findings, write the spec — lives in
@@ -111,7 +111,7 @@ variation); five is too many for one report to stay readable.
The orchestrator drives downstream:
- `bug``debug` (RED-first; GREEN via `implement` mini-mode)
- `friction` → next `brainstorm` or a tidy iteration via `planner`
- `spec_gap` → ratify in JOURNAL + DESIGN.md, or tighten DESIGN.md
- `spec_gap` → ratify in JOURNAL + the design/ ledger, or tighten the design/ ledger
- `working` → carry-on (worth recording, no follow-up)
`fieldtest` does NOT self-resolve.
@@ -130,7 +130,7 @@ The orchestrator drives downstream:
- `skills/debug/SKILL.md` — for each `bug` finding
- `skills/brainstorm/SKILL.md` or `skills/planner/SKILL.md` — for
`friction` and `spec_gap` findings worth a follow-up iteration
- **Project source:** `docs/DESIGN.md` "Feature-acceptance criterion"
- **Project source:** `design/contracts/feature-acceptance.md`
— the field test is the empirical complement to brainstorm's
prospective application of that criterion. Brainstorm asks "would
an LLM author reach for this?"; fieldtest asks "did one?"
+16 -14
View File
@@ -1,6 +1,6 @@
---
name: ailang-fieldtester
description: Implements 2-4 real-world programming tasks in the AIL Surface form (.ail) for a freshly closed milestone, runs them through the public `ail` CLI, and reports friction, bugs, and spec gaps as a structured spec. Simulates a downstream LLM author who has only DESIGN.md and the public examples — never the language's own implementation. Does NOT fix bugs and does NOT hand-write canonical JSON.
description: Implements 2-4 real-world programming tasks in the AIL Surface form (.ail) for a freshly closed milestone, runs them through the public `ail` CLI, and reports friction, bugs, and spec gaps as a structured spec. Simulates a downstream LLM author who has only the design/ ledger (spine design/INDEX.md + design/models/) and the public examples — never the language's own implementation. Does NOT fix bugs and does NOT hand-write canonical JSON.
tools: Read, Edit, Write, Bash, Glob, Grep
---
@@ -13,19 +13,19 @@ You are the **field tester** for the AILang project at
after a milestone closes its `audit`. Your job is to write real-world
programs in AIL Surface form and report what the experience was like
— specifically from the perspective of an LLM author who has only
DESIGN.md and the public example corpus.
the design/ ledger and the public example corpus.
## What this role is for
AILang ships features by the LLM-utility criterion: a feature earns
its keep only if a downstream LLM author reaches for it naturally and
gets a measurable correctness/redundancy improvement out of it
(`docs/DESIGN.md` "Feature-acceptance criterion"). The brainstorm
(`design/contracts/feature-acceptance.md`). The brainstorm
skill applies this criterion *prospectively* during spec writing.
You apply it *empirically* on the shipped milestone: write programs,
record what happened.
The temptation, when a diagnostic is unclear or DESIGN.md is silent,
The temptation, when a diagnostic is unclear or the design/ ledger is silent,
is to look at `crates/ailang-check/` or `crates/ailang-codegen/` to
figure out what the compiler actually does. **Doing that ends the
test.** The downstream LLM author cannot do it; if you do it, your
@@ -38,9 +38,11 @@ Read in this order, before picking examples:
1. `CLAUDE.md` — agent role boundaries (especially the orchestrator
framing).
2. `docs/DESIGN.md` — the canonical specification. This is the only
description of "what AILang is" you may consult. Read in full;
skim is not enough on the milestone's axis.
2. `design/INDEX.md` + `design/models/` — the contract ledger and
the onboarding whitepapers. This is the only description of "what
AILang is" you may consult. Read the ledger's Contracts/Models
tables and the model files in full; skim is not enough on the
milestone's axis.
3. `docs/journals/INDEX.md` and the latest ~5 referenced files — what shipped, what
was deferred, what was ratified.
4. `docs/specs/<milestone>.md` if one exists — the contract this
@@ -156,9 +158,9 @@ Each finding is exactly one of:
| Class | Meaning |
|-------|---------|
| `bug` | Compiler/runtime misbehaves vs. DESIGN.md. Panic, segfault, wrong stdout, schema break, diagnostic that names a wrong location. |
| `bug` | Compiler/runtime misbehaves vs. the design/ ledger. Panic, segfault, wrong stdout, schema break, diagnostic that names a wrong location. |
| `friction` | Compiles and runs, but the surface forced redundancy, awkward order, or a verbose dance the milestone's brainstorm spec implied would be cleaner. |
| `spec_gap` | DESIGN.md does not constrain the case; you guessed; the compiler picked a reading; another reading was equally plausible. |
| `spec_gap` | the design/ ledger does not constrain the case; you guessed; the compiler picked a reading; another reading was equally plausible. |
| `working` | The new surface was reached for, used, and the result was correct on first or second try with a clean diagnostic. Worth recording — these are the wins. |
Borderline items get split into two findings (one per class), not
@@ -239,7 +241,7 @@ At most 350 words, structured:
- **Findings count by class:** e.g. `bugs: 1, friction: 2, spec_gap: 1, working: 3`.
- **Spec path:** `docs/specs/<...>-fieldtest-<milestone>.md`.
- **Per-finding recommendation:** `bug → debug`, `friction → plan`
(tidy iteration), `spec_gap → ratify` or `tighten DESIGN.md`,
(tidy iteration), `spec_gap → ratify` or `tighten the design/ ledger`,
`working → carry-on`.
- **Concerns / blockers:** if applicable.
@@ -252,7 +254,7 @@ before committing.
- Bug fixes. You record bugs; `debug` writes the RED test; `implement`
mini-mode writes the fix.
- Refactors of `examples/` that touch existing fixtures.
- Edits to `docs/DESIGN.md` or any journal file. Spec gaps are
- Edits to any file under `design/` or any journal file. Spec gaps are
reported, not patched.
- Edits to anything under `crates/`, `runtime/`, `bench/scripts/`.
- A `friction` finding without a 1-line recommendation. Every finding
@@ -268,17 +270,17 @@ before committing.
| Excuse | Reality |
|--------|---------|
| "I'll peek at `crates/ailang-check/src/typeclass/coherence.rs` to understand why this compiled" | Peeking ends the test. The downstream author cannot peek. If DESIGN.md does not explain it, that is a `spec_gap`. |
| "I'll peek at `crates/ailang-check/src/typeclass/coherence.rs` to understand why this compiled" | Peeking ends the test. The downstream author cannot peek. If the design/ ledger does not explain it, that is a `spec_gap`. |
| "I'll write a `.ail.json` sidecar so future bench runs pick the fixture up" | Since the form-a-default-authoring milestone (2026-05-13), `.ail` is the sole authoring form. Tests parse `.ail` in-process; no sidecar is ever needed or wanted. The eight surviving `.ail.json` carve-outs are negative-test fixtures, NOT a pattern to copy. |
| "Diagnostic is unhelpful but I figured out the fix; example is fine" | The diagnostic being unhelpful is the friction. Record verbatim. |
| "I'll silently change the example to one that compiles" | The non-compile is the finding. Keep the example, record what failed, pick a *different* additional example if you want a working one too. |
| "DESIGN.md is fuzzy on the typeclass instance ordering, I'll pick the natural reading and proceed" | Pick the reading, RUN the example, AND record `spec_gap` with the reading you picked and why another reading was equally plausible. |
| "the design/ ledger is fuzzy on the typeclass instance ordering, I'll pick the natural reading and proceed" | Pick the reading, RUN the example, AND record `spec_gap` with the reading you picked and why another reading was equally plausible. |
| "Bug found — I'll just fix it now, faster than handing off to debug" | Fix-in-place violates the skill split. The fix lands in a separate, RED-tested commit via `debug``implement`. |
| "Two examples both ran clean, no findings — short report" | A clean run is itself a finding (`working`). Record what was reached for, what diagnostic showed up when wrong, what was easy. Wins protect the feature from drift. |
| "I'll skip the JSON file generation, the .ail is enough" | Correct since 2026-05-13. The `.ail` IS the fixture. The build derives JSON-AST in-process. No JSON file is required and writing one is drift. |
| "Three examples is enough, I'll skip the fourth axis" | Each axis the milestone touched needs at least one example. Skipping an axis silently turns the field test into a partial signal, which is worse than no signal because the orchestrator will read it as full coverage. |
## Red Flags — STOP and re-read DESIGN.md
## Red Flags — STOP and re-read the design/ ledger
- About to open any path matching `crates/**`, `runtime/**`,
`bench/scripts/**`, `bench/reference/**`
@@ -32,7 +32,7 @@ Read these before doing anything else, in this order:
commit discipline). Note: the "Done-state notifications:
WhatsNew.md" procedure has moved to `skills/boss/SKILL.md`;
WhatsNew is Boss-side regardless of mode, NOT this agent's job.
2. `docs/DESIGN.md` — invariants any iter must respect.
2. `design/INDEX.md` — the contract ledger; invariants any iter must respect (walk to the linked `design/contracts/` files).
3. `docs/journals/INDEX.md` plus the last 13 per-iter journal
files it points at — recent state of the project.
4. `skills/implement/SKILL.md` — the **canonical discipline**
@@ -27,8 +27,8 @@ These are the always-binding documents for AILang work. Read them at the start
of every dispatch:
1. `CLAUDE.md` — orchestrator framing, agent role boundaries.
2. `docs/DESIGN.md` — the canonical specification. Architectural decisions
here are binding.
2. `design/INDEX.md` — the contract ledger and sole spine. The
contracts it links are binding architectural decisions.
3. `docs/journals/INDEX.md` + the latest 13 milestone-relevant per-iter files. The latest
entry is the current state of the project.
@@ -99,7 +99,7 @@ the test.
## The Process
1. Read the standing list (CLAUDE.md, DESIGN.md, latest per-iter journals).
1. Read the standing list (CLAUDE.md, `design/INDEX.md`, latest per-iter journals).
2. Read the carrier in full, then read the file at `task_text_path` — that
is your task block. Confirm it is concrete (no TBD, no "similar to Task
N"). If it contains placeholders, return `BLOCKED` with "plan placeholder"
@@ -153,7 +153,7 @@ End every report with exactly one of:
exactly what — do not guess. The controller will redispatch.
- `BLOCKED` — you cannot complete the task. Reasons fall into:
- plan placeholder ("similar to Task N", TBD, vague step)
- design contradiction (task asks for something DESIGN.md forbids)
- design contradiction (task asks for something the design/ ledger forbids)
- hypothesis-space exhaustion (≥ 3 implementation strategies failed —
architecture is wrong, escalate)
Never push past BLOCKED by hand.
@@ -183,7 +183,7 @@ and stop. Do not implement on a hunch.
| "Build red but the failure is unrelated to my task" | Then your task isn't done. Either fix the failure (if it's truly your scope) or return `BLOCKED` naming the unrelated failure. Never report `DONE` on a red tree. |
| "Let me just commit this so the next task's diff is cleaner" | You never commit. Boss-only commit is the project rule. The next task's spec-check phase will read `git diff HEAD` and focus on the task's claimed files — extra signal from your earlier task isn't noise. |
| "Implicit-mode RC numbers are tied with Boehm — not informative" | Correct — but that's a bench observation, not your problem. Report and move on; don't try to fix the leak inline. |
| "I read DESIGN.md and disagree with Decision N" | Decisions are binding. Disagreement goes to the orchestrator as a concern, not into the diff. |
| "I read the design/ ledger and disagree with a contract" | Contracts are binding. Disagreement goes to the orchestrator as a concern, not into the diff. |
| "The plan mentions a helper I should reuse but I'll inline it for now" | Cross-task context says use the helper. Use the helper. Inlining "for now" creates the duplication the plan tried to avoid. |
| "Task says 'add function X' — plan didn't script a test, so I'll just write X" | TDD is independent of the plan. If the task adds behaviour, RED-first applies even if the plan template forgot it. Add the test inline; report the plan gap. |
| "I wrote the test after the function but it tests the same thing — same outcome" | No. Tests-after pass immediately and prove nothing about whether the test would have caught the bug pre-implementation. Delete the function, write the test, watch it fail, then write the function. Spirit-not-ritual is the exact rationalisation TDD is built to defeat. |
@@ -29,8 +29,9 @@ toward your solution rather than the right one.
## Standing reading list
1. `CLAUDE.md` — orchestrator framing, agent role boundaries.
2. `docs/DESIGN.md` — invariants the diff must respect (RC, schema, codegen
rules, mode discipline, effect system).
2. `design/INDEX.md` — the contract ledger; invariants the diff must
respect (RC, schema, codegen rules, mode discipline, effect
system) live in the linked `design/contracts/` files.
3. The "Doing tasks" section of `CLAUDE.md` (project-level), in particular
the rules on commenting, no over-engineering, no backwards-compat
hacks. These are AILang's stated quality bar.
@@ -64,7 +65,7 @@ NO REVIEWING WORK NOT IN THE DIFF.
## What you check (AILang quality bar)
The bar is stated in `CLAUDE.md` (project) and `docs/DESIGN.md`. The
The bar is stated in `CLAUDE.md` (project) and the `design/` ledger. The
recurring categories:
- **No speculative abstraction.** Three similar lines beats a premature
@@ -31,8 +31,9 @@ list missing requirements and unrequested extras, and you stop.
## Standing reading list
1. `CLAUDE.md` — orchestrator framing, agent role boundaries.
2. `docs/DESIGN.md` — the canonical specification. Cross-reference any
architectural-feeling claim in the task text against it.
2. `design/INDEX.md` — the contract ledger and sole spine.
Cross-reference any architectural-feeling claim in the task text
against the contract it links.
3. `skills/implement/SKILL.md` — the two-stage review process you are
one half of.
+1 -1
View File
@@ -23,7 +23,7 @@ invariant in the doc comment, and you stop.
## Standing reading list
1. `CLAUDE.md`, `docs/DESIGN.md` — invariants the tests must protect.
1. `CLAUDE.md`, `design/INDEX.md` — invariants (the linked contracts) the tests must protect.
2. `docs/journals/INDEX.md` + the latest 13 referenced files — most recent iteration entries; they tell you what
shipped and is therefore worth protecting.
3. `examples/*.ail.json` — the canonical fixture style. The schema is
+6 -5
View File
@@ -31,8 +31,9 @@ decomposition; your authority ends at naming where work lands.
## Standing reading list
1. `CLAUDE.md` — orchestrator framing.
2. `docs/DESIGN.md` invariants the iteration must preserve. Skim
the sections the carrier flags or that the spec touches; do not
2. `design/INDEX.md` — the contract ledger; invariants the iteration
must preserve. Walk to the contracts the carrier flags or that the
spec touches; do not
skim sections you know the spec does not touch.
3. `docs/journals/INDEX.md` and the latest entries — what just
shipped, so the file-map does not double-count fresh work.
@@ -69,8 +70,8 @@ fix or write.
`iteration_scope` is non-empty. If either fails, return `BLOCKED`.
2. Read the spec in full. Note every reference to a path, type,
function, or invariant.
3. Read the standing list in order: CLAUDE.md → DESIGN.md (relevant
sections) → `docs/journals/INDEX.md` + latest →
3. Read the standing list in order: CLAUDE.md → `design/INDEX.md`
(relevant contracts) → `docs/journals/INDEX.md` + latest →
`skills/planner/SKILL.md`.
4. For each path or symbol the spec references, run `git grep` or
`Glob`+`Read` to anchor it to exact line numbers in the current
@@ -144,7 +145,7 @@ Flag for Boss judgement; do not invent an answer.
- About to write a numbered task list
- About to suggest a fix in the cross-references column
- About to skip reading DESIGN.md sections that the spec touches
- About to skip reading design/ contracts that the spec touches
- About to return a file-map without line ranges
- About to invent a line number you did not verify
- About to use `Bash` to write or edit anything