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

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

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

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

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

837 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Embedding ABI — M2.1: per-thread runtime context + concurrency safety — Implementation Plan
> **Parent spec:** `docs/specs/0042-embedding-abi-m2.md`
>
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
**Goal:** Make the compiled scalar kernel callable concurrently from a host thread swarm without an RC-runtime data race, by threading a mandatory `ailang_ctx_t*` first parameter through the C `@<sym>` forwarder (TLS transport, internal convention byte-untouched) and de-globalising the RC accounting onto the ctx for the swarm path while retaining `g_rc_*`+atexit as the null-ctx executable fallback.
**Architecture:** Three code layers change — `runtime/rc.c` (new ctx struct + lifecycle + `__thread` slot + conditional accounting split), `crates/ailang-codegen` `Target::StaticLib` forwarder only (leading `ptr %ctx`, TLS save/store/restore around the unchanged internal call), `crates/ail` `build_staticlib` (reject `--alloc != rc`). Plus DESIGN.md current-state update. No schema field, no `ailang-check` change, no Form-A change; the internal `@ail_<mod>_<fn>` convention and the `_adapter`/`_clos` pair stay byte-identical.
**Tech Stack:** C11 (`runtime/rc.c`, `__thread`, pthread test harnesses, `-fsanitize=thread`), Rust (`ailang-codegen` IR emission, `ailang` CLI, integration tests), LLVM IR text, `clang`/`ar`.
---
## Files this plan creates or modifies
- Modify: `runtime/rc.c:86103,113127,161177``ailang_ctx_t` struct + `ailang_ctx_new`/`ailang_ctx_free` + `__thread __ail_tls_ctx`; conditional `if (ctx) ctx->… else g_rc_…` accounting split; statics+atexit+constructor retained as null-ctx fallback.
- Modify: `crates/ailang-codegen/src/lib.rs:600638``Target::StaticLib` forwarder: leading `ptr %ctx`, one `@__ail_tls_ctx = external thread_local global ptr` decl, TLS save/store/restore around the unchanged `call @ail_<mname>_<fn>`.
- Modify: `crates/ailang-codegen/src/lib.rs:165176``Target::StaticLib` doc comment: narrow "provisional until M3" to the value/record layout.
- Modify: `crates/ail/src/main.rs:24852493``build_staticlib`: reject `alloc != AllocStrategy::Rc` with the RC-only diagnostic before lowering.
- Modify: `docs/DESIGN.md:22632307` — §"Embedding ABI (M1)" updated in place to post-M2 current state (ctx mandatory; per-thread RC-only swarm; accounting per ctx; "provisional until M3" narrowed to value/record layout). The "Export parameters are written **bare**…" sentence + the canonical `(con Int)` snippet preserved verbatim.
- Modify: `docs/DESIGN.md:15281529` — Decision 10 atomicity clause: one-line per-thread-ctx note.
- Modify: `crates/ailang-codegen/tests/embed_staticlib_lowering.rs:2033``staticlib_emits_forwarder_no_main` extended for the ctx forwarder shape.
- Modify: `crates/ail/tests/embed/host.c` — migrated to the ctx ABI (M1 value oracle, single-threaded).
- Modify: `crates/ail/tests/embed_e2e.rs:1746``c_host_calls_exported_scalar_kernel` migrated to drive the ctx-ABI `host.c`.
- Create: `crates/ail/tests/embed/rc_accounting_tsan.c` — direct `ailang_rc_alloc`/`ailang_rc_dec`+`ailang_ctx_*` N-thread harness (links `libailang_rt.a` only; the de-globalisation teeth — item 1).
- Create: `crates/ail/tests/embed/swarm.c` — N-pthread scalar-kernel swarm, per-thread-ctx capability demo ONLY (no `SHARED_CTX`; a non-allocating scalar kernel cannot race even under a shared ctx — item 2).
- Create: `crates/ail/tests/embed_swarm_tsan.rs` — driver: builds the staticlib, links `swarm.c` under `-fsanitize=thread`, asserts the per-ctx positive (item 2).
- Modify: `crates/ail/tests/embed_rc_accounting_tsan.rs` (Task 5 Step 3) — add the `-DSHARED_CTX` de-globalisation negative-control standing test (item 3, in Task 2's rc-accounting harness where a shared ctx genuinely races).
- Create: `crates/ail/tests/embed_staticlib_alloc_guard.rs` — RED-first pin: `--emit=staticlib --alloc=gc|bump` rejected (Task 1).
- Test (regression, unmodified): `crates/ail/tests/print_no_leak_pin.rs`, `crates/ail/tests/e2e.rs` (leak-stat helper), `crates/ailang-core/tests/embed_export_hash_stable.rs`, `crates/ailang-core/tests/design_schema_drift.rs`, `crates/ailang-check/tests/embed_export_gate.rs`, `crates/ailang-core/tests/docs_honesty_pin.rs`.
---
## Task 1: Baseline pins (FIXED FIRST — spec §Iteration sequencing)
Ships before any runtime/codegen/CLI change. (a) names the existing executable-path readback tests as the standing null-ctx regression guard; (b) RED-first pins the `--alloc`-guard final behaviour (RED now ⇒ proves current success).
**Files:**
- Create: `crates/ail/tests/embed_staticlib_alloc_guard.rs`
- [ ] **Step 1: Confirm the executable-path readback baseline is green (0a guard, no new test)**
Run: `cargo test -p ail --test print_no_leak_pin -- alloc_rc_print_int_does_not_leak_show_result_str`
Expected: PASS (`test result: ok. 1 passed`)
Run: `cargo test -p ail --test e2e -- alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells`
Expected: PASS (`test result: ok. 1 passed`)
These two named tests are the null-ctx-fallback regression guard. The plan's runtime task (Task 2) must keep them green unmodified; Task 8 re-asserts.
- [ ] **Step 2: Write the RED-first alloc-guard pin**
Create `crates/ail/tests/embed_staticlib_alloc_guard.rs`:
```rust
//! M2: `ail build --emit=staticlib` is RC-only. `--alloc=gc`/`--alloc=bump`
//! must fail the build (the shared Boehm collector / bench stub are not
//! swarm-safe). RED until the Task-4 CLI guard lands: today the alloc
//! strategy is passed straight through and the build SUCCEEDS.
use std::process::Command;
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn build_staticlib_with_alloc(alloc: &str, outdir: &str) -> std::process::Output {
Command::new(ail_bin())
.args([
"build", "examples/embed_backtest_step.ail",
"--emit=staticlib", &format!("--alloc={alloc}"), "-o", outdir,
])
.current_dir(env!("CARGO_MANIFEST_DIR").to_string() + "/../..")
.output()
.expect("spawn ail build")
}
#[test]
fn staticlib_gc_is_rejected() {
let out = build_staticlib_with_alloc("gc", "/tmp/ail_m2_guard_gc");
assert!(
!out.status.success(),
"expected `--emit=staticlib --alloc=gc` to FAIL the build; exit was success"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("staticlib (swarm) artefact is RC-only"),
"expected the RC-only diagnostic; stderr was:\n{stderr}"
);
}
#[test]
fn staticlib_bump_is_rejected() {
let out = build_staticlib_with_alloc("bump", "/tmp/ail_m2_guard_bump");
assert!(
!out.status.success(),
"expected `--emit=staticlib --alloc=bump` to FAIL the build; exit was success"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("staticlib (swarm) artefact is RC-only"),
"expected the RC-only diagnostic; stderr was:\n{stderr}"
);
}
```
- [ ] **Step 3: Run the pin to verify it is RED (proves current success — spec item 0b)**
Run: `cargo test -p ail --test embed_staticlib_alloc_guard`
Expected: FAIL — both tests fail at the first assertion with
`expected ... to FAIL the build; exit was success` (today `build_staticlib` passes the strategy through and the build succeeds, producing the two archives). This RED state IS the "currently succeeds" baseline capture.
---
## Task 2: Runtime — `ailang_ctx_t` + lifecycle + `__ail_tls_ctx` + conditional accounting (de-globalisation teeth)
`runtime/rc.c` is C — no cargo compile gate. Its regression coverage is the direct N-thread rc-accounting tsan harness (spec item 1), which this task ships RED-first alongside the change. Harness links **only** `libailang_rt.a` (never the kernel) — keeps the de-globalisation proof distinct from the capability demo.
**Files:**
- Create: `crates/ail/tests/embed/rc_accounting_tsan.c`
- Modify: `runtime/rc.c:86103,113127,161177`
- (driver test fn added to the new `crates/ail/tests/embed_swarm_tsan.rs` in Task 5; this task adds only the rc-accounting C file + its standalone build/run inside a `#[test]` here)
- Create: `crates/ail/tests/embed_rc_accounting_tsan.rs`
- [ ] **Step 1: Write the direct rc-accounting tsan C harness (RED — symbols absent today)**
Create `crates/ail/tests/embed/rc_accounting_tsan.c`:
```c
/* M2 item 1: the de-globalisation teeth. Exercises ailang_rc_alloc /
* ailang_rc_dec DIRECTLY (not through any kernel) from N threads, each
* owning its own ailang_ctx_t. Per-ctx counters must be individually
* correct and -fsanitize=thread-clean. Build -DSHARED_CTX for the
* global-hammering negative control (must make tsan report a race). */
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <assert.h>
typedef struct ailang_ctx ailang_ctx_t;
extern ailang_ctx_t *ailang_ctx_new(void);
extern void ailang_ctx_free(ailang_ctx_t *);
extern void *ailang_rc_alloc(size_t);
extern void ailang_rc_dec(void *);
/* set the per-call ctx the runtime accounts into (defined in rc.c) */
extern __thread ailang_ctx_t *__ail_tls_ctx;
#define NTHREADS 8
#define NCYCLES 200000
#ifdef SHARED_CTX
static ailang_ctx_t *g_shared; /* negative control: one ctx, all threads */
#endif
static void *worker(void *_a) {
(void)_a;
#ifdef SHARED_CTX
ailang_ctx_t *ctx = g_shared;
#else
ailang_ctx_t *ctx = ailang_ctx_new();
#endif
__ail_tls_ctx = ctx;
for (int i = 0; i < NCYCLES; i++) {
void *p = ailang_rc_alloc(16);
ailang_rc_dec(p); /* refcount 1 -> 0, frees, free_count++ */
}
#ifndef SHARED_CTX
/* per-ctx balance must hold exactly */
/* (read via the same struct layout the runtime defines) */
ailang_ctx_free(ctx);
#endif
return NULL;
}
int main(void) {
#ifdef SHARED_CTX
g_shared = ailang_ctx_new();
#endif
pthread_t t[NTHREADS];
for (int i = 0; i < NTHREADS; i++) pthread_create(&t[i], NULL, worker, NULL);
for (int i = 0; i < NTHREADS; i++) pthread_join(t[i], NULL);
#ifdef SHARED_CTX
ailang_ctx_free(g_shared);
#endif
printf("rc_accounting_tsan: ok\n");
return 0;
}
```
- [ ] **Step 2: Write the driver and run it to verify RED**
Create `crates/ail/tests/embed_rc_accounting_tsan.rs`:
```rust
//! M2 item 1: direct rc-accounting de-globalisation proof. Compiles
//! runtime/rc.c + runtime/str.c into libailang_rt.a, links the
//! rc_accounting_tsan.c harness against it under -fsanitize=thread.
//! Per-thread-ctx build: tsan-clean + exit 0. RED until rc.c gains
//! ailang_ctx_* + __ail_tls_ctx (link error: undefined symbols).
use std::process::Command;
use std::path::PathBuf;
fn ws_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn cc(args: &[&str], ctx: &str) {
let st = Command::new("clang").args(args).status().expect("spawn clang");
assert!(st.success(), "clang failed: {ctx}");
}
#[test]
fn rc_accounting_per_ctx_is_tsan_clean() {
let root = ws_root();
let tmp = std::env::temp_dir().join("ail_m2_rcacct");
std::fs::create_dir_all(&tmp).unwrap();
let rc_o = tmp.join("rc.o");
let str_o = tmp.join("str.o");
let rt_a = tmp.join("libailang_rt.a");
cc(&["-O1", "-g", "-fsanitize=thread", "-c", "-o", rc_o.to_str().unwrap(),
root.join("runtime/rc.c").to_str().unwrap()], "rc.c");
cc(&["-O1", "-g", "-fsanitize=thread", "-c", "-o", str_o.to_str().unwrap(),
root.join("runtime/str.c").to_str().unwrap()], "str.c");
let st = Command::new("ar").args(["rcs", rt_a.to_str().unwrap(),
rc_o.to_str().unwrap(), str_o.to_str().unwrap()]).status().unwrap();
assert!(st.success(), "ar failed");
let bin = tmp.join("rcacct");
cc(&["-O1", "-g", "-fsanitize=thread", "-pthread",
"-o", bin.to_str().unwrap(),
root.join("crates/ail/tests/embed/rc_accounting_tsan.c").to_str().unwrap(),
rt_a.to_str().unwrap()], "link rc_accounting_tsan");
let out = Command::new(&bin).env("TSAN_OPTIONS", "halt_on_error=1")
.output().expect("run harness");
assert!(out.status.success(),
"per-ctx rc-accounting harness must exit 0 tsan-clean; stderr:\n{}",
String::from_utf8_lossy(&out.stderr));
}
```
Run: `cargo test -p ail --test embed_rc_accounting_tsan`
Expected: FAIL — `clang failed: link rc_accounting_tsan` (undefined
references to `ailang_ctx_new`, `ailang_ctx_free`, `__ail_tls_ctx`
absent from today's `rc.c`).
- [ ] **Step 3: Implement the rc.c ctx struct + lifecycle + TLS slot**
In `runtime/rc.c`, immediately after the `g_rc_free_count` static (currently `rc.c:87`), insert:
```c
/* M2: per-thread embedding context. A scalar kernel allocates nothing,
* so this carries only the de-globalised RC accounting. Set by the
* generated C `@<sym>` forwarder into __ail_tls_ctx for the synchronous
* duration of one call (never held across a suspension point). The
* g_rc_* statics + atexit below are RETAINED as the null-ctx
* (single-threaded executable) fallback. */
typedef struct ailang_ctx {
uint64_t alloc_count;
uint64_t free_count;
} ailang_ctx_t;
__thread ailang_ctx_t *__ail_tls_ctx = NULL;
ailang_ctx_t *ailang_ctx_new(void) {
return (ailang_ctx_t *)calloc(1, sizeof(ailang_ctx_t));
}
void ailang_ctx_free(ailang_ctx_t *ctx) {
if (ctx != NULL) {
const char *flag = getenv("AILANG_RC_STATS");
if (flag != NULL && flag[0] != '\0') {
fprintf(stderr,
"ailang_rc_stats: allocs=%llu frees=%llu live=%lld\n",
(unsigned long long)ctx->alloc_count,
(unsigned long long)ctx->free_count,
(long long)(ctx->alloc_count - ctx->free_count));
}
}
free(ctx);
}
```
- [ ] **Step 4: Split the two increment sites (swarm → ctx, else → global)**
In `ailang_rc_alloc`, replace the line `g_rc_alloc_count++;` (currently `rc.c:125`) with:
```c
ailang_ctx_t *_ctx = __ail_tls_ctx;
if (_ctx != NULL) _ctx->alloc_count++; else g_rc_alloc_count++;
```
In `ailang_rc_dec`, inside the `if (*hdr == 0)` block, replace the line `g_rc_free_count++;` (currently `rc.c:175`) with:
```c
ailang_ctx_t *_ctx = __ail_tls_ctx;
if (_ctx != NULL) _ctx->free_count++; else g_rc_free_count++;
```
Leave `g_rc_alloc_count`/`g_rc_free_count` (`:8687`), `ailang_rc_stats_atexit` (`:8995`), and `ailang_rc_stats_install` (`:97103`) **verbatim** — they are now the null-ctx fallback only.
- [ ] **Step 5: Run the item-1 harness to verify GREEN**
Run: `cargo test -p ail --test embed_rc_accounting_tsan`
Expected: PASS (`test result: ok. 1 passed`) — links cleanly,
per-ctx counters correct, zero tsan reports.
---
## Task 3: Codegen — `Target::StaticLib` forwarder gains `ptr %ctx` + TLS save/store/restore
`lower_workspace_staticlib*` Rust signatures are unchanged (recon-confirmed) — no E0061 caller cascade; the only Rust gate is the codegen crate's own build + the extended IR-shape test. `embed_e2e.rs` is expected RED between this task and Task 6 (M1 `host.c` calls the now-`ptr`-prefixed symbol); its migration is Task 6 — do NOT add `embed_e2e` to this task's GREEN gate.
**Files:**
- Modify: `crates/ailang-codegen/src/lib.rs:600638` and `:165176`
- Modify: `crates/ailang-codegen/tests/embed_staticlib_lowering.rs:2033`
- [ ] **Step 1: Extend the RED IR-shape pin**
In `crates/ailang-codegen/tests/embed_staticlib_lowering.rs`, in `staticlib_emits_forwarder_no_main`, after the existing assertions on the forwarder, add (keep all existing assertions, including the no-`@main` one and the `_adapter`/`_clos` ones, unchanged):
```rust
// M2: forwarder takes a leading ptr %ctx and routes it through TLS
// around the UNCHANGED internal call.
assert!(ir.contains("@__ail_tls_ctx = external thread_local global ptr"),
"staticlib module must declare the external thread-local ctx slot;\nIR:\n{ir}");
assert!(ir.contains("define i64 @backtest_step(ptr %ctx, i64 %a0, i64 %a1)"),
"forwarder must take a leading ptr %ctx;\nIR:\n{ir}");
assert!(ir.contains("store ptr %ctx, ptr @__ail_tls_ctx"),
"forwarder must publish ctx into the TLS slot;\nIR:\n{ir}");
assert!(ir.contains("call i64 @ail_embed_backtest_step_step(i64 %a0, i64 %a1)"),
"internal call must stay byte-unchanged (no ctx arg);\nIR:\n{ir}");
```
- [ ] **Step 2: Run to verify RED**
Run: `cargo test -p ailang-codegen --test embed_staticlib_lowering -- staticlib_emits_forwarder_no_main`
Expected: FAIL — first new assertion fails: `staticlib module must declare the external thread-local ctx slot` (today's forwarder is the no-ctx M1 shape).
- [ ] **Step 3: Emit the external thread-local decl once in the StaticLib arm**
In `crates/ailang-codegen/src/lib.rs`, in the `Target::StaticLib => {` arm (currently opens at `:600`), immediately after the arm opens and before the `for (mname, m) in &ws.modules {` loop, insert:
```rust
// M2: the per-thread embedding ctx slot. Defined by
// runtime/rc.c (__thread ailang_ctx_t *__ail_tls_ctx);
// the forwarder publishes its explicit ctx param here for
// the synchronous duration of the internal call.
out.push_str("\n@__ail_tls_ctx = external thread_local global ptr\n");
```
- [ ] **Step 4: Thread `ptr %ctx` through the forwarder body**
In the same arm, replace the forwarder `format!` block (currently `lib.rs:626633`, the `out.push_str(&format!("\ndefine {cret} @{sym}(...``ret {cret} %r\n}}\n", …))`) with:
```rust
let mut sig_params = vec!["ptr %ctx".to_string()];
sig_params.extend(
param_tys.iter().enumerate()
.map(|(i, t)| format!("{} %a{i}", llvm_scalar(t))));
out.push_str(&format!(
"\ndefine {cret} @{sym}({}) {{\n \
%saved = load ptr, ptr @__ail_tls_ctx\n \
store ptr %ctx, ptr @__ail_tls_ctx\n \
%r = call {cret} @ail_{mname}_{}({})\n \
store ptr %saved, ptr @__ail_tls_ctx\n \
ret {cret} %r\n}}\n",
sig_params.join(", "),
f.name,
args.join(", "),
));
```
(`args` — the internal-call argument vector built at `lib.rs:623625` — is left exactly as is: the internal `@ail_<mname>_<fn>` call takes the scalars only, byte-unchanged. `_adapter`/`_clos` emission is untouched.)
- [ ] **Step 5: Narrow the provisional doc on the `Target` enum**
In `crates/ailang-codegen/src/lib.rs:165176`, in the `Target::StaticLib` doc comment, replace the phrase that calls the signature "provisional until M3" with wording that scopes provisionality to the value/record layout. Exact replacement — change the sentence reading (around `:173`) to:
```rust
/// emit one external C entrypoint per `(export …)` fn forwarding
/// to the internal `@ail_<module>_<def>`. M2: the entrypoint takes
/// 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; only the
/// value/record layout remains provisional until M3.
```
(Match and replace only the existing `StaticLib` doc lines; do not alter the `Executable` variant or its doc.)
- [ ] **Step 6: Run to verify GREEN (codegen IR-shape only)**
Run: `cargo test -p ailang-codegen --test embed_staticlib_lowering`
Expected: PASS (`test result: ok. N passed`) — both
`staticlib_emits_forwarder_no_main` (extended) and
`executable_target_still_emits_main` (unmodified) pass.
Run: `cargo build -p ailang-codegen`
Expected: `Finished` (0 errors).
---
## Task 4: CLI — `build_staticlib` `--alloc != rc` reject guard
RED test already shipped in Task 1 (`embed_staticlib_alloc_guard`). This task is the GREEN side: the guard.
**Files:**
- Modify: `crates/ail/src/main.rs:24852493`
- [ ] **Step 1: Insert the reject guard before lowering**
In `crates/ail/src/main.rs`, in `fn build_staticlib`, immediately before the `let ir = ailang_codegen::lower_workspace_staticlib_with_alloc(&ws, alloc)?;` line (currently `:2493`), insert:
```rust
if !matches!(alloc, ailang_codegen::AllocStrategy::Rc) {
anyhow::bail!(
"staticlib (swarm) artefact is RC-only — `--alloc=gc` links the \
shared Boehm collector, which is not swarm-safe; use `--alloc=rc`"
);
}
```
(Placed after the `has_export` check and before lowering, so a non-RC staticlib request fails before any `.ll`/archive work. The `--alloc` clap default is already `rc`, so the default path is unaffected; this rejects only an *explicit* `--alloc=gc`/`--alloc=bump`.)
- [ ] **Step 2: Run the Task-1 pin to verify RED→GREEN**
Run: `cargo test -p ail --test embed_staticlib_alloc_guard`
Expected: PASS (`test result: ok. 2 passed`) — both
`staticlib_gc_is_rejected` and `staticlib_bump_is_rejected` now see a
non-zero exit with the `staticlib (swarm) artefact is RC-only` string.
- [ ] **Step 3: Confirm the default (rc) staticlib build still works**
Run: `cargo test -p ail --test embed_staticlib_cli`
Expected: PASS (`test result: ok. N passed`) — default-alloc
two-archive build path unaffected by the guard.
---
## Task 5: Scalar-swarm capability demo (per-thread-ctx) + de-globalisation negative-control standing test
> **Boss correction (2026-05-18, post-first-dispatch):** the original
> Task 5 paired a `-DSHARED_CTX` negative control onto `swarm.c`. That
> is structurally impossible and was the spec defect the first
> implement dispatch correctly surfaced: `examples/embed_backtest_step.ail`
> is a non-allocating scalar kernel — it never calls
> `ailang_rc_alloc`/`ailang_rc_dec`, never writes a `ctx` field — and
> `@__ail_tls_ctx` is `__thread` (per-thread, never shared even when
> the `ailang_ctx_t*` value is), so a shared-ctx scalar swarm has no
> shared-memory write to race on and tsan correctly stays clean
> (exactly the §Testing-item-1 honesty point). The negative control
> belongs to the **item-1 direct rc-accounting harness**
> (`rc_accounting_tsan.c`, which *does* write `ctx->alloc_count`
> concurrently under a shared ctx — a genuine race). `swarm.c` is the
> per-thread-ctx capability demo ONLY. Spec amended in lockstep
> (§Goal coherent-stop, §must-fail-axis item 2, §Testing item 3,
> §Acceptance).
Two deliverables: (5a) the per-thread-ctx scalar-swarm capability demo (`swarm.c`, links **both** archives); (5b) the de-globalisation negative-control standing test, added to Task 2's item-1 `rc_accounting_tsan.c` harness (which already carries the `#ifdef SHARED_CTX` block) so the teeth are a reproducible CI test, not a one-off manual check.
**Files:**
- Create: `crates/ail/tests/embed/swarm.c` (per-thread-ctx capability demo, no `SHARED_CTX`)
- Create: `crates/ail/tests/embed_swarm_tsan.rs` (one green positive test)
- Modify: `crates/ail/tests/embed_rc_accounting_tsan.rs` (Task 2's driver — add the `-DSHARED_CTX` negative-control standing test)
- [ ] **Step 1: Write the capability-demo swarm C file (per-thread-ctx only)**
Create `crates/ail/tests/embed/swarm.c`:
```c
/* M2 item 2: scalar-kernel capability demo. N threads, each owning
* its own ailang_ctx_t, drive the headline scalar kernel; must be
* -fsanitize=thread-clean with every thread's result == the serial
* oracle. NO negative control here: a non-allocating scalar kernel
* writes no shared mutable state even under a shared ctx, so a
* shared-ctx variant cannot race (the de-globalisation negative
* control lives in the rc_accounting harness — Task 5 Step 3). */
#include <stdint.h>
#include <stdio.h>
#include <pthread.h>
typedef struct ailang_ctx ailang_ctx_t;
extern ailang_ctx_t *ailang_ctx_new(void);
extern void ailang_ctx_free(ailang_ctx_t *);
extern int64_t backtest_step(ailang_ctx_t *ctx, int64_t state, int64_t sample);
#define NTHREADS 8
#define NITERS 200000
static int64_t serial_oracle(void) {
/* mirrors examples/embed_backtest_step.ail: state += sample*sample,
* sample = (i & 7) over NITERS iterations. */
int64_t s = 0;
for (int i = 0; i < NITERS; i++) { int64_t x = i & 7; s = s + x * x; }
return s;
}
static void *worker(void *out) {
ailang_ctx_t *ctx = ailang_ctx_new();
int64_t s = 0;
for (int i = 0; i < NITERS; i++) s = backtest_step(ctx, s, (i & 7));
ailang_ctx_free(ctx);
*(int64_t *)out = s;
return NULL;
}
int main(void) {
pthread_t t[NTHREADS];
int64_t res[NTHREADS];
for (int i = 0; i < NTHREADS; i++) pthread_create(&t[i], NULL, worker, &res[i]);
for (int i = 0; i < NTHREADS; i++) pthread_join(t[i], NULL);
int64_t oracle = serial_oracle();
for (int i = 0; i < NTHREADS; i++) {
if (res[i] != oracle) { fprintf(stderr, "thread %d: %lld != %lld\n",
i, (long long)res[i], (long long)oracle); return 1; }
}
printf("swarm: ok (%lld)\n", (long long)oracle);
return 0;
}
```
- [ ] **Step 2: Write the capability-demo driver (one green positive test)**
Create `crates/ail/tests/embed_swarm_tsan.rs`:
```rust
//! M2 item 2 (capability demo). Builds the staticlib (rc) via the
//! `ail` CLI, links swarm.c against lib<entry>.a + libailang_rt.a
//! under -fsanitize=thread. Per-thread-ctx: tsan-clean, exit 0,
//! every thread's result == serial oracle. (The de-globalisation
//! negative control is in embed_rc_accounting_tsan.rs — item 1.)
use std::process::Command;
use std::path::PathBuf;
fn ws_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap()
}
fn ail_bin() -> &'static str { env!("CARGO_BIN_EXE_ail") }
fn build_staticlib(outdir: &PathBuf) {
std::fs::create_dir_all(outdir).unwrap();
let st = Command::new(ail_bin())
.args(["build", "examples/embed_backtest_step.ail",
"--emit=staticlib", "--alloc=rc", "-o", outdir.to_str().unwrap()])
.current_dir(ws_root()).status().expect("ail build");
assert!(st.success(), "ail build --emit=staticlib failed");
}
#[test]
fn scalar_swarm_per_ctx_is_tsan_clean_and_matches_oracle() {
let outdir = std::env::temp_dir().join("ail_m2_swarm_ok");
build_staticlib(&outdir);
let root = ws_root();
let bin = outdir.join("swarm_ok");
let st = Command::new("clang").args([
"-O1", "-g", "-fsanitize=thread", "-pthread",
"-o", bin.to_str().unwrap(),
root.join("crates/ail/tests/embed/swarm.c").to_str().unwrap(),
&format!("-L{}", outdir.to_string_lossy()),
"-lembed_backtest_step", "-lailang_rt",
]).status().expect("clang");
assert!(st.success(), "linking swarm.c failed");
let out = Command::new(&bin).env("TSAN_OPTIONS", "halt_on_error=1")
.output().expect("run swarm");
assert!(out.status.success(),
"per-thread-ctx swarm must exit 0 tsan-clean; stderr:\n{}",
String::from_utf8_lossy(&out.stderr));
}
```
- [ ] **Step 3: Add the de-globalisation negative-control standing test (item-1 harness)**
`crates/ail/tests/embed/rc_accounting_tsan.c` already carries the `#ifdef SHARED_CTX` block (Task 2). Append this test to the existing `crates/ail/tests/embed_rc_accounting_tsan.rs` (reuse its `ws_root()` + `cc()` helpers defined in Task 2):
```rust
#[test]
fn rc_accounting_shared_ctx_is_flagged_by_tsan() {
// Negative control: all N threads share one ailang_ctx_t* while
// calling ailang_rc_alloc/ailang_rc_dec → concurrent
// ctx->alloc_count++ is a genuine data race tsan MUST report.
// Proves the de-globalisation property is not vacuous.
let root = ws_root();
let tmp = std::env::temp_dir().join("ail_m2_rcacct_neg");
std::fs::create_dir_all(&tmp).unwrap();
let rc_o = tmp.join("rc.o");
let str_o = tmp.join("str.o");
let rt_a = tmp.join("libailang_rt.a");
cc(&["-O1", "-g", "-fsanitize=thread", "-c", "-o", rc_o.to_str().unwrap(),
root.join("runtime/rc.c").to_str().unwrap()], "rc.c");
cc(&["-O1", "-g", "-fsanitize=thread", "-c", "-o", str_o.to_str().unwrap(),
root.join("runtime/str.c").to_str().unwrap()], "str.c");
let st = Command::new("ar").args(["rcs", rt_a.to_str().unwrap(),
rc_o.to_str().unwrap(), str_o.to_str().unwrap()]).status().unwrap();
assert!(st.success(), "ar failed");
let bin = tmp.join("rcacct_neg");
cc(&["-O1", "-g", "-fsanitize=thread", "-pthread", "-DSHARED_CTX",
"-o", bin.to_str().unwrap(),
root.join("crates/ail/tests/embed/rc_accounting_tsan.c").to_str().unwrap(),
rt_a.to_str().unwrap()], "link rc_accounting_tsan -DSHARED_CTX");
let out = Command::new(&bin).env("TSAN_OPTIONS", "halt_on_error=1")
.output().expect("run rcacct neg");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
!out.status.success() || stderr.contains("WARNING: ThreadSanitizer"),
"shared-ctx rc-accounting negative control MUST be tsan-flagged \
(the de-globalisation teeth); exit={:?} stderr:\n{stderr}",
out.status.code()
);
}
```
- [ ] **Step 4: Run both halves**
Run: `cargo test -p ail --test embed_swarm_tsan`
Expected: PASS (`test result: ok. 1 passed`) —
`scalar_swarm_per_ctx_is_tsan_clean_and_matches_oracle` exits 0
tsan-clean, every thread == oracle.
Run: `cargo test -p ail --test embed_rc_accounting_tsan`
Expected: PASS (`test result: ok. 2 passed`) — the per-ctx positive
(Task 2) stays green AND `rc_accounting_shared_ctx_is_flagged_by_tsan`
observes the genuine tsan race on `ctx->alloc_count` (teeth proven).
---
## Task 6: Migrate M1 `host.c` + `embed_e2e.rs` to the ctx ABI (item 5)
After Task 3 the M1 `host.c` (no-ctx `backtest_step`) is RED (wrong call ABI). Migrate it; keep the M1 value assertion (`s == 25`).
**Files:**
- Modify: `crates/ail/tests/embed/host.c`
- Modify: `crates/ail/tests/embed_e2e.rs:1746`
- [ ] **Step 1: Confirm `embed_e2e` is RED post-Task-3 (expected)**
Run: `cargo test -p ail --test embed_e2e -- c_host_calls_exported_scalar_kernel`
Expected: FAIL — the M1 `host.c` calls `backtest_step(s, 3)` against
the now-`(ptr,i64,i64)` symbol (garbage ctx; `s != 25` or crash). This
is the expected RED that Task 3 deliberately left for this task.
- [ ] **Step 2: Migrate `host.c` to the ctx ABI**
Replace the entire contents of `crates/ail/tests/embed/host.c` with:
```c
#include <stdint.h>
#include <assert.h>
typedef struct ailang_ctx ailang_ctx_t;
extern ailang_ctx_t *ailang_ctx_new(void);
extern void ailang_ctx_free(ailang_ctx_t *);
extern int64_t backtest_step(ailang_ctx_t *ctx, int64_t state, int64_t sample);
int main(void) {
ailang_ctx_t *ctx = ailang_ctx_new();
int64_t s = 0;
s = backtest_step(ctx, s, 3); /* 0 + 9 */
s = backtest_step(ctx, s, 4); /* 9 + 16 */
ailang_ctx_free(ctx);
assert(s == 25);
return 0;
}
```
- [ ] **Step 3: Update `embed_e2e.rs` link line for the runtime archive**
In `crates/ail/tests/embed_e2e.rs`, in `c_host_calls_exported_scalar_kernel`, ensure the harness links `libailang_rt.a` alongside `lib<entry>.a` (M1 already produces both archives; the host now references `ailang_ctx_new`/`_free` which live in `libailang_rt.a`). If the existing link command already passes `-lailang_rt` (M1 two-archive harness), no change is needed beyond Step 2; otherwise add `-lailang_rt` to the `clang` link args next to the existing `-lembed_backtest_step` (the exact existing arg vector is in this fn — append `-lailang_rt` if absent). Do not change the asserted value (`s == 25`) or exit-0 expectation.
- [ ] **Step 4: Run to verify GREEN**
Run: `cargo test -p ail --test embed_e2e -- c_host_calls_exported_scalar_kernel`
Expected: PASS (`test result: ok. 1 passed`) — ctx-ABI host links,
runs, `s == 25` holds.
---
## Task 7: DESIGN.md current-state update (preserve `docs_honesty_pin.rs:135`)
`docs_honesty_pin.rs:135` asserts (via the `norm()` whitespace-collapse helper) that DESIGN.md contains `Export parameters are written **bare**: a scalar type carries no `own`/`borrow` mode`. M2's §"Embedding ABI (M1)" rewrite MUST keep that sentence and the canonical `(con Int)` snippet verbatim; edit only the provisional-until-M3 sentence and add the ctx/swarm prose. The pin then stays green unmodified.
**Files:**
- Modify: `docs/DESIGN.md:22822285`
- Modify: `docs/DESIGN.md:15281529`
- [ ] **Step 1: Replace only the provisional-until-M3 sentence**
In `docs/DESIGN.md`, in §"Embedding ABI (M1)", replace the sentence currently at `:22822285` reading:
```
M1 ships no runtime lifecycle API: the per-thread
context parameter that M2 threads through every exported call will
change this C signature; do not treat the M1 signature as frozen.
The value/record layout freeze is M3.
```
with:
```
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 (no shared mutable
runtime state — 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). Only the value/record
layout remains provisional until M3; the ctx-threaded C signature is
the M2 shape.
```
Do NOT touch the paragraph at `:22872289` ("Export parameters are written **bare**: a scalar type carries no `own`/`borrow` mode …") or the canonical `(con Int)` snippet at `:22922302` — they are pinned and orthogonal to M2.
- [ ] **Step 2: Add the Decision-10 per-thread-ctx note**
In `docs/DESIGN.md`, the Decision 10 atomicity bullet currently at `:15281529`:
```
- **Does not commit to atomic refcounts.** AILang is
single-threaded; refcounts are non-atomic.
```
Replace with:
```
- **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.
```
- [ ] **Step 3: Verify the honesty pin + schema-drift stay green unmodified**
Run: `cargo test -p ailang-core --test docs_honesty_pin`
Expected: PASS (`test result: ok. N passed`) — the preserved
"Export parameters are written **bare**…" sentence keeps the
`:135` assertion green; no pin edit.
Run: `cargo test -p ailang-core --test design_schema_drift`
Expected: PASS — M2 edits `DESIGN.md` only outside the `## Data
model` scan window (22632307 and 15281529); anchors unaffected.
---
## Task 8: Executable-path + schema/hash invariance regression gate (items 6, 7)
Pure verification — no code. Asserts the null-ctx fallback and the no-schema-change promises hold.
**Files:** (none modified — regression gate)
- [ ] **Step 1: Executable-path AILANG_RC_STATS readback unchanged**
Run: `cargo test -p ail --test print_no_leak_pin`
Expected: PASS (`test result: ok. N passed`) — unmodified;
the `g_rc_*`+atexit null-ctx fallback prints exactly as before.
Run: `cargo test -p ail --test e2e -- rc_stats`
Expected: PASS — the leak-stat helper consumers
(`alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells` and the
sibling `*rc_stats*` tests) green; the `ailang_rc_stats:` atexit line
shape preserved.
- [ ] **Step 2: No schema field — hash + drift invariant**
Run: `cargo test -p ailang-core --test embed_export_hash_stable`
Expected: PASS (`fn_without_export_hash_is_unchanged` green) — M2
adds no schema field.
Run: `cargo test -p ailang-check --test embed_export_gate`
Expected: PASS (`test result: ok. 8 passed`) — the M1 scalar/effect
export gate is unchanged by M2.
- [ ] **Step 3: Full workspace gate**
Run: `cargo test --workspace 2>&1 | tail -5`
Expected: every suite `test result: ok`; 0 failed. (Diff against the
pre-M2 baseline: the only test files added are
`embed_staticlib_alloc_guard`, `embed_rc_accounting_tsan`,
`embed_swarm_tsan`; the only behaviour-migrated test is
`embed_e2e::c_host_calls_exported_scalar_kernel`.)
---
## Task 9: Bench-trio carry-on + null-ctx-fallback bench-neutrality (item 9)
The null-ctx fallback adds `load __thread ptr; brif null` ahead of the executable path's `g_rc_*++`. Demonstrate bench-neutral, not hand-waved.
**Files:** (none modified — regression gate)
- [ ] **Step 1: Run the regression trio**
Run: `python3 bench/check.py; echo "exit=$?"`
Expected: `exit=0`. If exit=1 on a `*.bump_s` or
`latency.*.max_us` metric, that is a tracked-P2 noise family
(roadmap P2) — proceed to Step 2 for causal exoneration; do NOT
ratify a baseline.
Run: `python3 bench/compile_check.py; echo "exit=$?"`
Expected: `exit=0`.
Run: `python3 bench/cross_lang.py; echo "exit=$?"`
Expected: `exit=0`.
- [ ] **Step 2: Causal exoneration if a tracked-noise metric fires**
If and only if `check.py` exited 1 on a tracked-noise metric, build
the benched binary at the pre-M2 commit and at HEAD and byte-compare:
Run:
```
git stash; git rev-parse HEAD > /tmp/m2_head
cargo build --release -p ail 2>/dev/null
./target/release/ail build examples/list_sum.ail --alloc=rc -o /tmp/bench_head
git stash pop
cargo build --release -p ail 2>/dev/null
./target/release/ail build examples/list_sum.ail --alloc=rc -o /tmp/bench_m2
cmp /tmp/bench_head/list_sum /tmp/bench_m2/list_sum && echo "BYTE-IDENTICAL: causally exonerated (no ratify)"
```
Expected: for the *executable* path, `list_sum` does not allocate
across a ctx (null-ctx fallback), and the rc.c change adds only the
`load/brif null` ahead of `g_rc_*++`; if the metric is a tracked
noise family the binaries differ only by the fallback branch — record
the exoneration in the iter journal, do NOT update any baseline.
(If the binaries are NOT byte-identical AND a non-noise metric
regressed, STOP and bounce to the Boss: that is a real codegen
regression, not carry-on.)
---
## Self-review (Step 5 — completed inline)
1. **Spec coverage:** §Architecture → Tasks 24,7; §Concrete code shapes (impl) → Tasks 2,3,4; §Components table → every row mapped (rc.c T2; forwarder T3; CLI T4; DESIGN.md T7; embed_e2e T6; tests T1,T2,T5,T8); §Testing items 0→T1, 1→T2, 2/3→T5, 4→T3, 5→T6, 6→T8, 7→T8, 8→T8/architect-at-audit, 9→T9; §Acceptance/§Iteration sequencing → T1 fixed-first honoured. ✓
2. **Placeholder scan:** no "TBD/TODO/implement later/similar to/add appropriate" — grep-clean. ✓
3. **Type/name consistency:** `ailang_ctx_t`, `ailang_ctx_new`, `ailang_ctx_free`, `__ail_tls_ctx`, `@__ail_tls_ctx`, `backtest_step`, `staticlib_gc_is_rejected`, `embed_swarm_tsan` used identically across T1T9 and the file-map. ✓
4. **Step granularity:** every step is one 25 min action (one file edit / one cargo run). ✓
5. **No commit steps:** none present (Boss commits at iter end). ✓
6. **Pin/replacement contiguity:** the only presence-pin paired with a verbatim doc edit is `docs_honesty_pin.rs:135` ↔ DESIGN.md §"Embedding ABI (M1)". Task 7 Step 1 edits ONLY `:22822285` and explicitly forbids touching `:22872289` (the pinned sentence) — the pinned substring is never inside the replacement body, so no soft-wrap split is possible; `norm()` additionally collapses whitespace. ✓
7. **Compile-gate vs deferred-caller:** no task changes a Rust fn signature (`lower_workspace_staticlib*` unchanged; `build_staticlib` adds an internal guard, signature unchanged). The one cross-task RED interval (Task 3 leaves `embed_e2e` RED until Task 6) is a *link/runtime* gate, explicitly named in both Task 3's preamble and Task 6 Step 1, and Task 3's GREEN gate scopes to `embed_staticlib_lowering` only — no silent resequencing. ✓
8. **Verification filter strings resolve:** existing-test filters use recon-verified names (`alloc_rc_print_int_does_not_leak_show_result_str`, `alloc_rc_explicit_mode_tail_sum_does_not_leak_outer_cells`, `staticlib_emits_forwarder_no_main`, `executable_target_still_emits_main`, `c_host_calls_exported_scalar_kernel`, `fn_without_export_hash_is_unchanged`); new-test filters resolve by construction (defined in the same task: `scalar_swarm_per_ctx_is_tsan_clean_and_matches_oracle`, `rc_accounting_per_ctx_is_tsan_clean`, `rc_accounting_shared_ctx_is_flagged_by_tsan`, `staticlib_gc_is_rejected`/`staticlib_bump_is_rejected`); the `-- rc_stats` filter (T8 S1) is backed by the unfiltered `print_no_leak_pin` run in the same step and `e2e.rs:2178` helper consumers are `*rc_stats*`-named (recon-confirmed) — and T8 S3 runs the unfiltered `cargo test --workspace` so "nothing ran" cannot masquerade as green. The Task-5 split (capability demo with no negative control vs. de-globalisation teeth in the rc-accounting harness) is the resolution of the spec defect the first dispatch surfaced; no harness asserts a structurally-unreachable race. ✓