d5c565d48d
Tasks 1-5 GREEN. T1 baseline pins (re-point annotation + @ailang_rc_alloc heap-box byte-pin: size=8+n*8, tag@0, fields@8/16). T2 export gate widened (is_c_scalar -> two-level is_c_abi_type: single-ctor all-Int/Float record; multi-ctor/Str/List/nested still RED; gate suite 10/10; M1 adt-ret must-fail re-pointed to multi-ctor+Str Reading). T3 codegen forwarder widened (llvm_scalar record Type::Con -> ptr; M2 forwarder body byte-unchanged; 3/3 staticlib pins). T4/T5 E2E record round-trip own+borrow, global leak-freedom. Boss spec-consistency repair (M2.1-precedent class): orchestrator correctly BLOCKED Task 5 on a genuine spec defect -- the single-ctx-readback allocs==frees proof model is unsatisfiable for borrow (and only coincidentally passes for own) because M2's TLS-ctx is bound only during the synchronous forwarder call, so host-side decs land on g_rc_*, not ctx. Boss-verified globally leak-free + value-correct both modes. Spec + plan + harness amended to the stronger global model (sum all ailang_rc_stats: lines; the M2-TLS cross-attribution documented as correct behaviour). No fresh grounding-check (removes an over-strong measurement assumption). Tasks 6 (DESIGN.md frozen-layout SSOT + lockstep pointers + freeze wording + enforceability demo) and 7 (workspace-green gate) re-dispatched on the amended plan. Bench/architect milestone-close is audit-owned. iter embedding-abi-m3.1 (PARTIAL); INDEX.md line deferred to the DONE commit
1125 lines
48 KiB
Markdown
1125 lines
48 KiB
Markdown
# Embedding ABI — M3.1 — Implementation Plan
|
||
|
||
> **Parent spec:** `docs/specs/2026-05-18-embedding-abi-m3.md`
|
||
>
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement`
|
||
> to run this plan. Steps use `- [ ]` checkboxes for tracking.
|
||
|
||
**Goal:** Let a single-constructor record of `Int`/`Float` fields
|
||
cross the embedding C boundary in and out, ownership following the
|
||
declared `own`/`borrow` mode, with the record memory layout frozen
|
||
(byte-pinned) and the alloc==free contract proven for both modes.
|
||
|
||
**Architecture:** Three layers change. (1) `ailang-check` — the
|
||
nested export-gate predicate `is_c_scalar` becomes `is_c_abi_type`:
|
||
accept `Int`/`Float` **or** a `Type::Con` resolving (via the in-scope
|
||
`env.types`) to a single-constructor `data` whose every field is a
|
||
bare `Int`/`Float` (two-level, NOT deep — a record-typed field stays
|
||
rejected = M4). (2) `ailang-codegen` — `fn_scalar_sig`/`llvm_scalar`
|
||
widen so a record type maps to `ptr`; the M2 `Target::StaticLib`
|
||
forwarder body is byte-unchanged (a record is a bare `ptr`; all
|
||
mode-driven RC stays in the byte-unchanged internal
|
||
`@ail_<mod>_<fn>`). (3) docs+pins — DESIGN.md §"Embedding ABI" gains
|
||
a frozen value-layout SSOT, "provisional until M3" becomes one-way
|
||
freeze, and the heap box layout codegen emits is byte-pinned with
|
||
lockstep comment pointers from the three encoding sites.
|
||
|
||
**Tech Stack:** `crates/ailang-check` (gate), `crates/ailang-codegen`
|
||
(forwarder + box-layout sites), `runtime/rc.c` (layout comment),
|
||
`docs/DESIGN.md` (frozen SSOT), `examples/*.ail` (fixtures),
|
||
`crates/ail/tests` (E2E C-host harness), `crates/ailang-codegen/tests`
|
||
(byte-pin + forwarder-IR pin).
|
||
|
||
---
|
||
|
||
## Files this plan creates or modifies
|
||
|
||
- Test: `crates/ailang-check/tests/embed_export_gate.rs:31-34` — `adt_ret_export_rejected` (the deliberate re-point target; baseline-confirmed Task 1, re-pointed Task 2)
|
||
- Create: `crates/ailang-codegen/tests/embed_record_layout_pin.rs` — heap-box byte-pin (`@ailang_rc_alloc(i64 24)` + tag@0 + field offsets) for a single-ctor 2-scalar-field record (Task 1)
|
||
- Create: `examples/embed_record_layout_carrier.ail` — minimal today-valid non-export program that heap-allocates a single-ctor `(Int,Int)` record, the byte-pin carrier (Task 1)
|
||
- Modify: `crates/ailang-check/src/lib.rs:1921-1924` — `is_c_scalar` → `is_c_abi_type` (Task 2)
|
||
- Modify: `crates/ailang-check/src/lib.rs:1925-1940` — gate param/ret loop calls `is_c_abi_type(ty, env)` (Task 2)
|
||
- Modify: `crates/ailang-check/src/lib.rs:452` / `:458` — widen the two `#[error(...)]` message strings (Task 2)
|
||
- Modify: `examples/embed_export_adt_ret_rejected.ail` — content replacement: single-ctor `Pair` → multi-ctor + `Str`-field `Reading` (Task 2)
|
||
- Create: `examples/embed_export_str_field_record_rejected.ail` — single-ctor record, one `Str` field (Task 2)
|
||
- Create: `examples/embed_export_list_field_record_rejected.ail` — single-ctor record, one `List` field (Task 2)
|
||
- Create: `examples/embed_export_multictor_rejected.ail` — 2-ctor all-scalar sum (Task 2)
|
||
- Create: `examples/embed_export_record_ok.ail` — single-ctor `(Float,Int)` record export, the positive (Task 2)
|
||
- Test: `crates/ailang-check/tests/embed_export_gate.rs` — add 4 RED + 1 positive test fns (Task 2)
|
||
- Modify: `crates/ailang-codegen/src/lib.rs:659-669` — `fn_scalar_sig` accepts a record param/ret (Task 3)
|
||
- Modify: `crates/ailang-codegen/src/lib.rs:673-678` — `llvm_scalar` maps a record `Type::Con` → `"ptr"` (Task 3)
|
||
- Create: `examples/embed_backtest_step_record.ail` — north-star `(own (con State))` `(State,Float)->State` (Task 3)
|
||
- Create: `examples/embed_backtest_step_record_borrow.ail` — `(borrow (con State))` variant (Task 3)
|
||
- Test: `crates/ailang-codegen/tests/embed_staticlib_lowering.rs` — add `staticlib_record_forwarder_is_ptr_passthrough` (Task 3)
|
||
- Create: `crates/ail/tests/embed/record_roundtrip.c` — the C host (own + borrow, compile-time `MODE` switch) (Task 4/5)
|
||
- Create: `crates/ail/tests/embed_record_e2e.rs` — builds libs, links the C host, runs, asserts `allocs==frees` + value (Task 4/5)
|
||
- Modify: `docs/DESIGN.md:2280-2282` / `:2293-2295` — "provisional until M3" → one-way-freeze wording (Task 6)
|
||
- Modify: `docs/DESIGN.md:2317` — insert the frozen value-layout SSOT subsection before `## Data model` (`:2319`) (Task 6)
|
||
- Modify: `runtime/rc.c:28-44` — add the lockstep-pointer line to the layout comment (Task 6)
|
||
- Modify: `crates/ailang-codegen/src/match_lower.rs:107` / `:123-126` / `:128-136` — add `// FROZEN ABI` lockstep comment at the size/tag/field encoders (Task 6)
|
||
- Modify: `crates/ailang-codegen/src/drop.rs:88` / `:113` — add `// FROZEN ABI` lockstep comment at the tag-load / field-offset encoders (Task 6)
|
||
- Modify: `crates/ailang-codegen/src/lib.rs:178` — rustdoc "provisional until M3" → frozen (Task 6)
|
||
- Modify: `crates/ailang-core/src/ast.rs:211` — rustdoc "provisional until M3" → frozen (Task 6)
|
||
|
||
---
|
||
|
||
## Task 1: Prerequisite baseline pins (FIXED FIRST — spec §"Iteration sequencing")
|
||
|
||
Both load-bearing M3 changes (the gate widen and the layout freeze)
|
||
must rest on a baseline pinned at their own layer *before* the change
|
||
(spec §"Iteration sequencing"; 2026-05-11 failure class pre-empted).
|
||
This task introduces **no** behaviour change — only baselines.
|
||
|
||
**Files:**
|
||
- Test: `crates/ailang-check/tests/embed_export_gate.rs:31-34`
|
||
- Create: `examples/embed_record_layout_carrier.ail`
|
||
- Create: `crates/ailang-codegen/tests/embed_record_layout_pin.rs`
|
||
|
||
- [ ] **Step 1: Confirm the re-point baseline pin is green TODAY**
|
||
|
||
The pin M3 deliberately re-points already exists
|
||
(`adt_ret_export_rejected`, `crates/ailang-check/tests/embed_export_gate.rs:31-34`,
|
||
asserting `examples/embed_export_adt_ret_rejected.ail` yields code
|
||
`"export-non-scalar-signature"`). Confirm it is green on the current
|
||
tree as the baseline.
|
||
|
||
Run: `cargo test -p ailang-check --test embed_export_gate adt_ret_export_rejected -- --exact`
|
||
Expected: PASS, `1 passed` (it asserts the single-ctor `Pair` ADT
|
||
return is rejected *today* — the M1 "any ADT rejected" meaning).
|
||
|
||
- [ ] **Step 2: Annotate the pin as the deliberate re-point target**
|
||
|
||
In `crates/ailang-check/tests/embed_export_gate.rs`, immediately
|
||
above the `adt_ret_export_rejected` test fn (`:31`), add this comment
|
||
so Task 2's change to it is reviewed-explicit, not a silent inversion
|
||
(spec §"Testing strategy" item 0(a)):
|
||
|
||
```rust
|
||
// M3 RE-POINT (deliberate, reviewed — spec 2026-05-18-embedding-abi-m3
|
||
// §"must-fail axis"): the fixture this asserts on
|
||
// (embed_export_adt_ret_rejected.ail) is single-ctor two-Int = M3-VALID.
|
||
// Task 2 re-points the fixture to a genuinely-still-rejected ADT
|
||
// (multi-ctor + Str field). This pin's *meaning* changes from
|
||
// "any ADT rejected" to "non-M3-shaped ADT rejected"; it is NOT
|
||
// silently inverted — the rejection (export-non-scalar-signature)
|
||
// stays asserted, on a fixture that still genuinely fails it.
|
||
```
|
||
|
||
- [ ] **Step 3: Run the gate suite to confirm the comment is inert**
|
||
|
||
Run: `cargo test -p ailang-check --test embed_export_gate`
|
||
Expected: PASS, `6 passed` (comment-only change; the existing six
|
||
tests `str_param_export_rejected`, `adt_ret_export_rejected`,
|
||
`io_export_rejected`, `combined_effectful_export_rejected_on_signature_first`,
|
||
`scalar_pure_export_passes`, `headline_int_export_passes` all green).
|
||
|
||
- [ ] **Step 4: Write the byte-pin carrier fixture**
|
||
|
||
Create `examples/embed_record_layout_carrier.ail` — a today-valid
|
||
(pre-M3, no `(export)`) program whose `main` heap-allocates a
|
||
single-ctor two-`Int` record so the standard escaping `lower_ctor`
|
||
heap path (`@ailang_rc_alloc`, the path a crossing M3 record takes)
|
||
emits its box. Grounded against `examples/borrow_own_demo.ail`
|
||
(`(data … (ctor …))`, `(match … (case (pat-ctor …) …))`, `print`):
|
||
|
||
```
|
||
(module embed_record_layout_carrier
|
||
|
||
(data Pt
|
||
(ctor Pt (con Int) (con Int)))
|
||
|
||
(fn mk
|
||
(type
|
||
(fn-type
|
||
(params (con Int) (con Int))
|
||
(ret (con Pt))))
|
||
(params a b)
|
||
(body (app Pt a b)))
|
||
|
||
(fn sum_pt
|
||
(type
|
||
(fn-type
|
||
(params (own (con Pt)))
|
||
(ret (con Int))))
|
||
(params p)
|
||
(body
|
||
(match p
|
||
(case (pat-ctor Pt x y)
|
||
(app + x y)))))
|
||
|
||
(fn main
|
||
(type (fn-type (params) (ret (con Unit)) (effects IO)))
|
||
(params)
|
||
(body
|
||
(app print (app sum_pt (app mk 3 4))))))
|
||
```
|
||
|
||
- [ ] **Step 5: Verify the carrier compiles and emits the heap box TODAY**
|
||
|
||
Run: `cargo run -q --bin ail -- check examples/embed_record_layout_carrier.ail`
|
||
Expected: exit 0, no diagnostics.
|
||
|
||
Run: `cargo run -q --bin ail -- emit-ir examples/embed_record_layout_carrier.ail -o /tmp/m3_carrier.ll && grep -nE 'ailang_rc_alloc\(i64 24\)|store i64 0, ptr|getelementptr inbounds i8, ptr .* i64 8|getelementptr inbounds i8, ptr .* i64 16' /tmp/m3_carrier.ll`
|
||
Expected: matches present — `call ptr @ailang_rc_alloc(i64 24)` (size
|
||
= 8 header-excluded payload? — confirm: `match_lower.rs:107`
|
||
`size_bytes = 8 + n*8`; for n=2 that is `8 + 16 = 24`), a
|
||
`store i64 0, ptr` (tag at offset 0), and the field GEPs at i64 8 /
|
||
i64 16. (If the heap path is not taken — escape analysis kept it in
|
||
`alloca` — adjust the carrier so `Pt` provably escapes: returning it
|
||
from `mk` and consuming via `own`-param `sum_pt` forces the heap
|
||
box; this is why `mk`/`sum_pt` are split. Confirm the `@ailang_rc_alloc`
|
||
form is what appears; the byte-pin asserts that exact form.)
|
||
|
||
- [ ] **Step 6: Write the heap-box byte-pin test**
|
||
|
||
Create `crates/ailang-codegen/tests/embed_record_layout_pin.rs`,
|
||
modelled exactly on the harness shape of
|
||
`crates/ailang-codegen/tests/embed_staticlib_lowering.rs`
|
||
(`lower_workspace`/`emit-ir` → `ir.contains(...)`; open that file and
|
||
mirror its workspace-load + lower incantation verbatim, substituting
|
||
the carrier fixture). Assert the **frozen heap layout as codegen
|
||
emits it today**:
|
||
|
||
```rust
|
||
//! FROZEN ABI byte-pin — see DESIGN.md §"Embedding ABI" frozen layout.
|
||
//! 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
|
||
//! offset — the M3 freeze made enforceable, not aspirational.
|
||
|
||
// <mirror embed_staticlib_lowering.rs's load+lower helper here:
|
||
// load examples/embed_record_layout_carrier.ail, lower to IR string `ir`>
|
||
|
||
#[test]
|
||
fn heap_box_layout_is_frozen_8_plus_n_times_8_tag_at_0_fields_from_8() {
|
||
let ir = lower_carrier_ir(); // the mirrored helper
|
||
// Pt has 2 Int fields → payload size 8 (tag) + 2*8 (fields) = 24
|
||
assert!(ir.contains("@ailang_rc_alloc(i64 24)"),
|
||
"frozen: heap box payload size = 8 + n*8 (n=2 → 24); IR:\n{ir}");
|
||
// constructor tag written at offset 0 (single-ctor → 0; no elision)
|
||
assert!(ir.contains("store i64 0, ptr"),
|
||
"frozen: ctor tag i64 at offset 0; IR:\n{ir}");
|
||
// field 0 at offset 8, field 1 at offset 16 (i64-strided from 8)
|
||
assert!(ir.contains("getelementptr inbounds i8, ptr") && ir.contains("i64 8"),
|
||
"frozen: field 0 at payload offset 8; IR:\n{ir}");
|
||
assert!(ir.contains("i64 16"),
|
||
"frozen: field 1 at payload offset 16 (8 + 1*8); IR:\n{ir}");
|
||
}
|
||
```
|
||
|
||
(Exact `contains` substrings must be tightened to what
|
||
`/tmp/m3_carrier.ll` actually shows in Step 5 — copy the literal
|
||
emitted forms; do not assert a guessed shape. The four asserted
|
||
facts — size 24, tag@0, field@8, field@16 — are the frozen layout.)
|
||
|
||
- [ ] **Step 7: Run the byte-pin GREEN on the current (pre-M3) tree**
|
||
|
||
Run: `cargo test -p ailang-codegen --test embed_record_layout_pin -- --exact heap_box_layout_is_frozen_8_plus_n_times_8_tag_at_0_fields_from_8`
|
||
Expected: PASS, `1 passed` — the layout baseline is pinned *before*
|
||
any M3 change attaches the freeze lockstep (spec §"Testing strategy"
|
||
item 0(b)).
|
||
|
||
---
|
||
|
||
## Task 2: Widen the export gate to a single-ctor scalar record
|
||
|
||
`is_c_scalar` (a nested local fn in `check_fn`, one caller — the gate
|
||
itself; recon-confirmed no other caller, fully contained) becomes
|
||
`is_c_abi_type`, **two-level**: a bare `Int`/`Float`, OR a
|
||
`Type::Con` resolving via `env.types` to a single-constructor `data`
|
||
whose every field type is a bare `Int`/`Float`. A record-typed field
|
||
stays rejected (M4 — spec §"Out of scope"). No new `CheckError`
|
||
variant; the two `#[error]` message strings widen (pin-safe — the
|
||
gate test asserts the code string, not the message).
|
||
|
||
**Files:**
|
||
- Modify: `crates/ailang-check/src/lib.rs:1921-1924` (predicate)
|
||
- Modify: `crates/ailang-check/src/lib.rs:1925-1940` (gate loop call site)
|
||
- Modify: `crates/ailang-check/src/lib.rs:452` / `:458` (messages)
|
||
- Modify: `examples/embed_export_adt_ret_rejected.ail` (re-point)
|
||
- Create: 4 must-fail fixtures + 1 positive fixture (see file list)
|
||
- Test: `crates/ailang-check/tests/embed_export_gate.rs`
|
||
|
||
- [ ] **Step 1: Write the failing tests (RED) — gate fixtures**
|
||
|
||
Create the five fixtures. `examples/embed_export_record_ok.ail`
|
||
(positive — must PASS check):
|
||
|
||
```
|
||
(module embed_export_record_ok
|
||
(data State
|
||
(ctor State (con Float) (con Int)))
|
||
(fn step
|
||
(export "step")
|
||
(type
|
||
(fn-type
|
||
(params (own (con State)) (con Float))
|
||
(ret (con State))))
|
||
(params st sample)
|
||
(body
|
||
(match st
|
||
(case (pat-ctor State acc n)
|
||
(app State (app + acc sample) (app + n 1)))))))
|
||
```
|
||
|
||
`examples/embed_export_str_field_record_rejected.ail` (single-ctor,
|
||
one `Str` field — must FAIL):
|
||
|
||
```
|
||
(module embed_export_str_field_record_rejected
|
||
(data Tagged
|
||
(ctor Tagged (con Int) (con Str)))
|
||
(fn f
|
||
(export "f")
|
||
(type (fn-type (params (con Int)) (ret (con Tagged))))
|
||
(params n)
|
||
(body (app Tagged n "x"))))
|
||
```
|
||
|
||
`examples/embed_export_list_field_record_rejected.ail` (single-ctor,
|
||
one `List` field — must FAIL; `List` def grounded on
|
||
`examples/borrow_own_demo.ail`):
|
||
|
||
```
|
||
(module embed_export_list_field_record_rejected
|
||
(data List
|
||
(ctor Nil)
|
||
(ctor Cons (con Int) (con List)))
|
||
(data Boxed
|
||
(ctor Boxed (con Int) (con List)))
|
||
(fn f
|
||
(export "f")
|
||
(type (fn-type (params (con Int)) (ret (con Boxed))))
|
||
(params n)
|
||
(body (app Boxed n (term-ctor List Nil)))))
|
||
```
|
||
|
||
(Note: `List` here is itself multi-ctor, so the `Boxed` rejection
|
||
reason that bites is the **non-scalar field** rule — `Boxed` is
|
||
single-ctor but its field `List` is not a bare `Int`/`Float`. That
|
||
is exactly the M4-boundary case the spec wants pinned.)
|
||
|
||
`examples/embed_export_multictor_rejected.ail` (2-ctor all-scalar
|
||
sum — must FAIL on the single-ctor rule):
|
||
|
||
```
|
||
(module embed_export_multictor_rejected
|
||
(data Either2
|
||
(ctor L (con Int))
|
||
(ctor R (con Float)))
|
||
(fn f
|
||
(export "f")
|
||
(type (fn-type (params (con Int)) (ret (con Either2))))
|
||
(params n)
|
||
(body (app L n))))
|
||
```
|
||
|
||
Re-point `examples/embed_export_adt_ret_rejected.ail` — **replace
|
||
its entire content** (was single-ctor `Pair`, now M3-valid) with the
|
||
multi-ctor + `Str`-field `Reading` from spec §"must-fail axis"
|
||
(module name stays `embed_export_adt_ret_rejected` = file-stem, the
|
||
loader invariant):
|
||
|
||
```
|
||
(module embed_export_adt_ret_rejected
|
||
(data Reading
|
||
(ctor Missing)
|
||
(ctor Sample (con Int) (con Str)))
|
||
(fn f
|
||
(export "f")
|
||
(type
|
||
(fn-type
|
||
(params (con Int))
|
||
(ret (con Reading))))
|
||
(params n)
|
||
(body (app Sample n "tick"))))
|
||
```
|
||
|
||
In `crates/ailang-check/tests/embed_export_gate.rs`, add (mirroring
|
||
the existing `check_codes("…")`/assert pattern at `:25-63` — open
|
||
the file and copy that helper's exact form):
|
||
|
||
```rust
|
||
#[test]
|
||
fn record_export_passes() {
|
||
// single-ctor (Float,Int) record export — M3-valid
|
||
assert!(check_codes("embed_export_record_ok.ail").is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn str_field_record_export_rejected() {
|
||
assert!(check_codes("embed_export_str_field_record_rejected.ail")
|
||
.contains(&"export-non-scalar-signature".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn list_field_record_export_rejected() {
|
||
assert!(check_codes("embed_export_list_field_record_rejected.ail")
|
||
.contains(&"export-non-scalar-signature".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn multictor_export_rejected() {
|
||
assert!(check_codes("embed_export_multictor_rejected.ail")
|
||
.contains(&"export-non-scalar-signature".to_string()));
|
||
}
|
||
```
|
||
|
||
(`adt_ret_export_rejected` at `:31-34` is **kept as-is in assertion**
|
||
— it still asserts `"export-non-scalar-signature"` on
|
||
`embed_export_adt_ret_rejected.ail`; only the fixture content changed
|
||
in this task. Its Task-1 re-point comment now describes a completed,
|
||
reviewed change.)
|
||
|
||
- [ ] **Step 2: Run the new tests to verify RED**
|
||
|
||
Run: `cargo test -p ailang-check --test embed_export_gate record_export_passes str_field_record_export_rejected list_field_record_export_rejected multictor_export_rejected`
|
||
Expected: FAIL — `record_export_passes` fails (today's `is_c_scalar`
|
||
rejects the single-ctor `State` return, so `check_codes` is
|
||
non-empty); the three `_rejected` tests *may* already pass (today
|
||
everything ADT is rejected) — that is acceptable RED-state for the
|
||
positive; the discriminating RED is `record_export_passes` failing
|
||
and `adt_ret_export_rejected` will (after re-point) still pass.
|
||
|
||
Run: `cargo test -p ailang-check --test embed_export_gate adt_ret_export_rejected -- --exact`
|
||
Expected: PASS — the re-pointed `Reading` (2 ctors + `Str` field)
|
||
still yields `export-non-scalar-signature` under *today's* gate
|
||
(every non-scalar ADT is rejected pre-M3), so the re-point keeps the
|
||
pin green through the transition (its meaning sharpens in Step 3).
|
||
|
||
- [ ] **Step 3: Widen the predicate (`is_c_scalar` → `is_c_abi_type`)**
|
||
|
||
In `crates/ailang-check/src/lib.rs`, replace the nested fn at
|
||
`:1921-1924`:
|
||
|
||
```rust
|
||
fn is_c_scalar(t: &Type) -> bool {
|
||
matches!(t, Type::Con { name, args, .. }
|
||
if args.is_empty() && (name == "Int" || name == "Float"))
|
||
}
|
||
```
|
||
|
||
with the two-level M3 predicate (NOT deep — a record-typed field is
|
||
rejected; M4 owns nesting):
|
||
|
||
```rust
|
||
fn is_c_scalar(t: &Type) -> bool {
|
||
matches!(t, Type::Con { name, args, .. }
|
||
if args.is_empty() && (name == "Int" || name == "Float"))
|
||
}
|
||
// M3 (spec 2026-05-18-embedding-abi-m3 §Architecture L85-102):
|
||
// a C scalar, OR a single-constructor `data` whose every field
|
||
// is a *bare* C scalar. Two-level by design — a record-typed
|
||
// field stays rejected (M4 owns nested-record recursion). Uses
|
||
// the in-scope `env.types: String -> TypeDef` (read pattern as
|
||
// `env.types.get(name)`, cf. lib.rs:1826).
|
||
fn is_c_abi_type(t: &Type, env: &Env) -> bool {
|
||
if is_c_scalar(t) {
|
||
return true;
|
||
}
|
||
if let Type::Con { name, args, .. } = t {
|
||
if args.is_empty() {
|
||
if let Some(td) = env.types.get(name) {
|
||
if td.ctors.len() == 1 {
|
||
return td.ctors[0]
|
||
.fields
|
||
.iter()
|
||
.all(is_c_scalar);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
```
|
||
|
||
(`env`, `Type`, `Env`, `TypeDef.ctors[i].fields` are recon-confirmed
|
||
in scope at the gate site `:1920` inside `check_fn`. If the field
|
||
accessor differs — `ctor.fields` vs `ctor.field_types` — use the
|
||
exact name the `drop.rs:100-102` walker uses; open `drop.rs` to
|
||
confirm the accessor before writing this, then match it.)
|
||
|
||
- [ ] **Step 4: Point the gate loop at `is_c_abi_type`**
|
||
|
||
In `crates/ailang-check/src/lib.rs:1925-1940`, the param loop and ret
|
||
check currently call `is_c_scalar(ty)`. Change each call site to
|
||
`is_c_abi_type(ty, env)` (the `env` binding is the same one
|
||
`is_c_abi_type` borrows; recon-confirmed in scope). Do **not** change
|
||
the loop structure, the position `&'static str`, or the
|
||
`ExportNonScalarSignature(...)` construction shape — only the
|
||
predicate call. The effect check (`:1941-1946`) is untouched.
|
||
|
||
- [ ] **Step 5: Widen the two `#[error]` message strings**
|
||
|
||
`crates/ailang-check/src/lib.rs:452` currently:
|
||
|
||
```rust
|
||
#[error("export `{0}`: M1 embedding ABI is scalar-only — {1} type `{2}` is not `Int`/`Float`")]
|
||
```
|
||
|
||
Replace with (single contiguous line — pin-safe; no test asserts
|
||
this message text, only the `code()` string; recon-confirmed):
|
||
|
||
```rust
|
||
#[error("export `{0}`: embedding ABI accepts `Int`/`Float` or a single-constructor record of those — {1} type `{2}` is not permitted (multi-constructor sums, and `Str`/`List`/nested-record fields, are a future M4 layer)")]
|
||
```
|
||
|
||
`crates/ailang-check/src/lib.rs:458` (the `ExportHasEffects`
|
||
message) — only widen if it says "M1"; if it reads
|
||
`export \`{0}\` must be pure …` leave it byte-unchanged (effects
|
||
rule did not change). Open `:455-459`, and if `:458` contains the
|
||
literal `M1`, drop the `M1 ` token only; otherwise no edit.
|
||
|
||
- [ ] **Step 6: Run the gate suite to verify GREEN**
|
||
|
||
Run: `cargo test -p ailang-check --test embed_export_gate`
|
||
Expected: PASS, `10 passed` — the original 6 (incl.
|
||
`adt_ret_export_rejected`, now sharpened: `Reading` fails on
|
||
multi-ctor AND Str-field) + the 4 new
|
||
(`record_export_passes`, `str_field_record_export_rejected`,
|
||
`list_field_record_export_rejected`, `multictor_export_rejected`).
|
||
|
||
- [ ] **Step 7: Confirm no `ailang-check` regression**
|
||
|
||
Run: `cargo test -p ailang-check`
|
||
Expected: PASS — workspace `ailang-check` suite green; the only
|
||
behaviour change is the export gate widening (single-ctor scalar
|
||
records now accepted; everything else byte-identical).
|
||
|
||
---
|
||
|
||
## Task 3: Widen the codegen forwarder to pass a record as `ptr`
|
||
|
||
`fn_scalar_sig`/`llvm_scalar` widen so a record `Type::Con` maps to
|
||
`ptr`; the M2 forwarder body (`:604-651`) is **byte-unchanged** (it
|
||
already templates `define {cret} @sym(ptr %ctx, …)`; the change is
|
||
only what `{cret}`/param types resolve to). All RC stays in the
|
||
byte-unchanged internal `@ail_<mod>_<fn>`.
|
||
|
||
**Files:**
|
||
- Modify: `crates/ailang-codegen/src/lib.rs:659-669` (`fn_scalar_sig`)
|
||
- Modify: `crates/ailang-codegen/src/lib.rs:673-678` (`llvm_scalar`)
|
||
- Create: `examples/embed_backtest_step_record.ail`
|
||
- Create: `examples/embed_backtest_step_record_borrow.ail`
|
||
- Test: `crates/ailang-codegen/tests/embed_staticlib_lowering.rs`
|
||
|
||
- [ ] **Step 1: Confirm the caller set of `fn_scalar_sig`/`llvm_scalar`**
|
||
|
||
Run: `grep -rn 'fn_scalar_sig\|llvm_scalar' crates/ailang-codegen/src/`
|
||
Expected: callers are only inside the `Target::StaticLib` forwarder
|
||
block (`lib.rs:622`/`:628` per recon). Record every call site found;
|
||
all must be threaded inside THIS task (no signature change is
|
||
introduced — these fns keep their signatures — so no compile-gate /
|
||
deferred-caller hazard; this step is the explicit confirmation that
|
||
the change is contained).
|
||
|
||
- [ ] **Step 2: Write the north-star + borrow fixtures**
|
||
|
||
Create `examples/embed_backtest_step_record.ail` (spec headline,
|
||
verbatim):
|
||
|
||
```
|
||
(module backtest
|
||
|
||
(data State
|
||
(ctor State (con Float) (con Int)))
|
||
|
||
(fn step
|
||
(export "backtest_step")
|
||
(type
|
||
(fn-type
|
||
(params (own (con State)) (con Float))
|
||
(ret (con State))))
|
||
(params st sample)
|
||
(body
|
||
(match st
|
||
(case (pat-ctor State acc n)
|
||
(app State (app + acc sample) (app + n 1)))))))
|
||
```
|
||
|
||
Create `examples/embed_backtest_step_record_borrow.ail` — identical
|
||
except the param mode:
|
||
|
||
```
|
||
(module backtest
|
||
|
||
(data State
|
||
(ctor State (con Float) (con Int)))
|
||
|
||
(fn step
|
||
(export "backtest_step")
|
||
(type
|
||
(fn-type
|
||
(params (borrow (con State)) (con Float))
|
||
(ret (con State))))
|
||
(params st sample)
|
||
(body
|
||
(match st
|
||
(case (pat-ctor State acc n)
|
||
(app State (app + acc sample) (app + n 1)))))))
|
||
```
|
||
|
||
- [ ] **Step 3: Write the failing forwarder-IR pin (RED)**
|
||
|
||
In `crates/ailang-codegen/tests/embed_staticlib_lowering.rs`, add
|
||
(mirror the exact load+lower helper `staticlib_emits_forwarder_no_main`
|
||
at `:20-44` uses — open the file, copy its incantation, substitute
|
||
the record fixture):
|
||
|
||
```rust
|
||
#[test]
|
||
fn staticlib_record_forwarder_is_ptr_passthrough() {
|
||
let ir = lower_staticlib_ir("embed_backtest_step_record.ail"); // mirror :20
|
||
// record param + ret cross as bare ptr; ctx leading; M2 TLS shape
|
||
assert!(ir.contains("define ptr @backtest_step(ptr %ctx, ptr %a0, double %a1)"),
|
||
"record fwd: ptr ret, leading ptr %ctx, ptr record param, double sample; IR:\n{ir}");
|
||
assert!(ir.contains("store ptr %ctx, ptr @__ail_tls_ctx"),
|
||
"M2 TLS store byte-unchanged; IR:\n{ir}");
|
||
assert!(ir.contains("call ptr @ail_backtest_step(ptr %a0, double %a1)"),
|
||
"internal call byte-unchanged (no RC at forwarder); IR:\n{ir}");
|
||
assert!(!ir.contains("sret") && !ir.contains("byval"),
|
||
"no aggregate convention introduced — record is a bare ptr; IR:\n{ir}");
|
||
assert!(!ir.contains("define i32 @main()"),
|
||
"staticlib: no @main; IR:\n{ir}");
|
||
}
|
||
```
|
||
|
||
(The exact param SSA names — `%a0`/`%a1` — and the `double` vs `i64`
|
||
for the `(con Float) sample` must match what the forwarder template
|
||
at `lib.rs:632-646` actually emits; after Step 4, read the emitted
|
||
IR and tighten these `contains` to the literal emitted forms.
|
||
`llvm_scalar` already maps `(con Float)` → `double`, recon-confirmed.)
|
||
|
||
- [ ] **Step 4: Run the pin to verify RED**
|
||
|
||
Run: `cargo build -p ailang-codegen 2>&1 | tail -3 ; cargo test -p ailang-codegen --test embed_staticlib_lowering staticlib_record_forwarder_is_ptr_passthrough -- --exact`
|
||
Expected: FAIL — today `fn_scalar_sig` only matches scalar
|
||
`Type::Fn`; `llvm_scalar` maps any non-`Float` `Type::Con` to `i64`,
|
||
so a `(con State)` param/ret is mis-lowered to `i64` (or the
|
||
workspace fails to lower the record export at all). The assertion
|
||
`define ptr @backtest_step(ptr %ctx, ptr %a0, double %a1)` is unmet.
|
||
|
||
- [ ] **Step 5: Widen `llvm_scalar`**
|
||
|
||
`crates/ailang-codegen/src/lib.rs:673-678` currently:
|
||
|
||
```rust
|
||
fn llvm_scalar(t: &Type) -> &'static str {
|
||
match t {
|
||
Type::Con { name, .. } if name == "Float" => "double",
|
||
_ => "i64",
|
||
}
|
||
}
|
||
```
|
||
|
||
The forwarder must map a record (`Type::Con` resolving to a
|
||
single-ctor `data`) to `"ptr"`, `Float` → `"double"`, `Int`/scalar →
|
||
`"i64"`. `llvm_scalar` has no `env` param today. Rather than thread
|
||
`env` through (signature change → caller ripple), distinguish
|
||
structurally: at the forwarder, a non-`Int`/`Float` `Type::Con` that
|
||
reached codegen on an `(export)` fn is — by Task-2's gate — a
|
||
guaranteed single-ctor scalar record. So:
|
||
|
||
```rust
|
||
fn llvm_scalar(t: &Type) -> &'static str {
|
||
match t {
|
||
Type::Con { name, .. } if name == "Float" => "double",
|
||
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).
|
||
Type::Con { .. } => "ptr",
|
||
_ => "i64",
|
||
}
|
||
}
|
||
```
|
||
|
||
(The original `_ => "i64"` default for `Int` is preserved by the
|
||
explicit `name == "Int"` arm; the new `Type::Con { .. } => "ptr"`
|
||
only catches named-type records — exactly the gate-guaranteed M3
|
||
case. Confirm no scalar path relies on a non-`Con` type hitting
|
||
`i64` via the old catch-all in a way the reorder breaks: the only
|
||
caller is the forwarder over export-gated scalar/record types.)
|
||
|
||
- [ ] **Step 6: Confirm `fn_scalar_sig` already handles a record**
|
||
|
||
`crates/ailang-codegen/src/lib.rs:659-669` `fn_scalar_sig` returns
|
||
`Some((params.clone(), (**ret).clone()))` for a `Type::Fn`
|
||
(recon-quoted). It clones whole `Type`s — a `(con State)` param/ret
|
||
is already carried through unchanged; the scalar-ness was only
|
||
imposed downstream by `llvm_scalar`. No `fn_scalar_sig` edit is
|
||
needed *iff* it does not itself filter on scalar-ness. Open
|
||
`:659-669`; if the body is exactly the recon-quoted
|
||
`Forall→inner; Type::Fn{params,ret}→Some((params,ret)); _→None`,
|
||
add only a clarifying comment:
|
||
|
||
```rust
|
||
// M3: params/ret may include a single-ctor scalar record type;
|
||
// llvm_scalar maps it to `ptr`. fn_scalar_sig is type-shape only.
|
||
```
|
||
|
||
If instead it rejects non-scalar params (a `is_c_scalar`-like
|
||
guard inside), remove that guard so a record param/ret survives
|
||
extraction (the gate already guarantees only M3-valid records
|
||
reach here).
|
||
|
||
- [ ] **Step 7: Run the forwarder pin to verify GREEN**
|
||
|
||
Run: `cargo test -p ailang-codegen --test embed_staticlib_lowering`
|
||
Expected: PASS — `staticlib_record_forwarder_is_ptr_passthrough`
|
||
green plus `staticlib_emits_forwarder_no_main` and
|
||
`executable_target_still_emits_main` **byte-unchanged green** (the
|
||
scalar forwarder IR is unaffected: `Int`→`i64`, `Float`→`double`
|
||
arms preserved).
|
||
|
||
- [ ] **Step 8: Confirm no `ailang-codegen` regression**
|
||
|
||
Run: `cargo test -p ailang-codegen`
|
||
Expected: PASS — full codegen suite green incl. Task-1
|
||
`embed_record_layout_pin` (unchanged: the box layout did not move)
|
||
and the in-source `mod tests`.
|
||
|
||
---
|
||
|
||
## Task 4: E2E record round-trip — `own` (coherent-stop proof)
|
||
|
||
Build `libbacktest.a` + `libailang_rt.a` from
|
||
`embed_backtest_step_record.ail`, link a C host that constructs the
|
||
input `State` via `ailang_rc_alloc`, calls `backtest_step` N times
|
||
(kernel consumes each `own` input), frees the final return via
|
||
`ailang_rc_dec`, and asserts **global leak-freedom** — Σallocs ==
|
||
Σfrees summed across *every* `ailang_rc_stats:` line the run emits
|
||
(the `ailang_ctx_free` ctx readback AND the `g_rc_*` atexit line) —
|
||
plus exit 0.
|
||
|
||
> **Boss spec-consistency repair (2026-05-18 — M3.1 BLOCKED
|
||
> adjudication, see the spec's repair note).** The original
|
||
> single-ctx-readback `allocs == frees` model is unsatisfiable for
|
||
> borrow (and only coincidentally passes for own): by M2's TLS-ctx
|
||
> design the host's `make_state`/`dec` run outside the forwarder
|
||
> call and land on `g_rc_*`, not ctx. The corrected, stronger
|
||
> invariant is **global**: the harness sums *all* `ailang_rc_stats:`
|
||
> lines; Σallocs == Σfrees (Σ`live` = 0) + exit 0, for both modes.
|
||
> Verified empirically: own ctx `live=0`/g_rc `live=0`; borrow ctx
|
||
> `live=+N`/g_rc `live=−N` (Σ=0) — the split is correct M2-TLS
|
||
> behaviour, not a leak.
|
||
|
||
**Files:**
|
||
- Create: `crates/ail/tests/embed/record_roundtrip.c`
|
||
- Create: `crates/ail/tests/embed_record_e2e.rs`
|
||
|
||
- [ ] **Step 1: Write the C host (own + borrow via a compile-time switch)**
|
||
|
||
Create `crates/ail/tests/embed/record_roundtrip.c` (the spec
|
||
§"changed C host" verbatim for the `own` arm, plus a `-DBORROW` arm
|
||
for Task 5; the frozen layout `{tag@0, Float acc@8, Int n@16}`,
|
||
`make_state` via `ailang_rc_alloc(8 + 2*8)`):
|
||
|
||
```c
|
||
#include <stdint.h>
|
||
#include <assert.h>
|
||
#include <string.h>
|
||
#include <stdlib.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 *);
|
||
extern void *backtest_step(ailang_ctx_t *ctx, void *st, double sample);
|
||
|
||
static void *make_state(double acc, int64_t n) {
|
||
void *p = ailang_rc_alloc(8 + 2 * 8); /* header=1 set by runtime */
|
||
*(int64_t *)((char *)p + 0) = 0; /* single-ctor tag = 0 */
|
||
memcpy((char *)p + 8, &acc, 8); /* Float acc @ 8 */
|
||
*(int64_t *)((char *)p + 16) = n; /* Int n @ 16 */
|
||
return p;
|
||
}
|
||
|
||
int main(void) {
|
||
ailang_ctx_t *ctx = ailang_ctx_new();
|
||
void *st = make_state(0.0, 0);
|
||
for (int i = 0; i < 1000000; i++) {
|
||
void *next = backtest_step(ctx, st, (double)(i & 7));
|
||
#ifdef BORROW
|
||
ailang_rc_dec(st); /* borrow: host retained input, frees it */
|
||
#endif
|
||
/* own: kernel consumed `st`; do NOT touch/dec it here. */
|
||
st = next; /* return is host-owned */
|
||
}
|
||
double acc; memcpy(&acc, (char *)st + 8, 8);
|
||
int64_t n = *(int64_t *)((char *)st + 16);
|
||
assert(n == 1000000);
|
||
ailang_rc_dec(st); /* host frees the final return */
|
||
ailang_ctx_free(ctx); /* AILANG_RC_STATS readback fires here */
|
||
return 0;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write the failing `own` E2E test (RED)**
|
||
|
||
Create `crates/ail/tests/embed_record_e2e.rs`. Open
|
||
`crates/ail/tests/embed_e2e.rs` and **mirror its exact** build/link/
|
||
run incantation (the `ail build --emit=staticlib … -o`, the `cc`
|
||
link of the C host against `lib<module>.a` + `libailang_rt.a`, the
|
||
`AILANG_RC_STATS=1` env). **The stats parse must sum ALL
|
||
`ailang_rc_stats:` lines, not just the first** (Boss repair — the
|
||
run emits two: the ctx readback and the `g_rc_*` atexit line; global
|
||
leak-freedom is the invariant). The helper:
|
||
|
||
```rust
|
||
#[derive(Debug)]
|
||
struct RcStats { allocs: u64, frees: u64, exit_code: i32 }
|
||
|
||
fn build_link_run_embed(fixture: &str, host_rel: &str, extra_cc: &[&str]) -> RcStats {
|
||
// <build --emit=staticlib + cc link + run AILANG_RC_STATS=1,
|
||
// mirrored verbatim from embed_e2e.rs's real incantation>
|
||
// SUM every "ailang_rc_stats:" line (ctx readback + g_rc atexit):
|
||
let (mut allocs, mut frees) = (0u64, 0u64);
|
||
for l in stderr.lines().filter(|l| l.starts_with("ailang_rc_stats:")) {
|
||
for tok in l.split_whitespace() {
|
||
if let Some(v) = tok.strip_prefix("allocs=") { allocs += v.parse::<u64>().unwrap(); }
|
||
else if let Some(v) = tok.strip_prefix("frees=") { frees += v.parse::<u64>().unwrap(); }
|
||
}
|
||
}
|
||
RcStats { allocs, frees, exit_code: run.status.code().unwrap_or(-1) }
|
||
}
|
||
|
||
#[test]
|
||
fn record_roundtrip_own_alloc_eq_free() {
|
||
let stats = build_link_run_embed(
|
||
"embed_backtest_step_record.ail",
|
||
"embed/record_roundtrip.c",
|
||
&[/* no extra clang defines */],
|
||
);
|
||
// GLOBAL leak-freedom: Σallocs == Σfrees across ctx + g_rc lines.
|
||
assert_eq!(stats.allocs, stats.frees,
|
||
"own: globally leak-free — every State box freed exactly once \
|
||
(kernel-consumed `own` inputs on ctx; host make_state/final \
|
||
dec on g_rc); {stats:?}");
|
||
assert_eq!(stats.exit_code, 0, "C host assert(n==1000000) held");
|
||
}
|
||
```
|
||
|
||
(Mirror `embed_e2e.rs`'s real build/link/run; only the **multi-line
|
||
sum** is the Boss-repair delta over a single-line parse. Do not
|
||
invent a new harness; reuse `embed_e2e.rs`'s incantation verbatim.)
|
||
|
||
- [ ] **Step 3: Run the `own` E2E to verify RED**
|
||
|
||
Run: `cargo test -p ail --test embed_record_e2e record_roundtrip_own_alloc_eq_free -- --exact --nocapture`
|
||
Expected: FAIL — before Task 2+3 are both in the tree this fails to
|
||
build the staticlib (gate rejects the record export) or mis-lowers
|
||
the record; with Task 2+3 present it builds, and this step's RED is
|
||
only if a real alloc/free imbalance exists. (Run order: this task
|
||
executes after Tasks 2+3; the RED here pins the *alloc==free*
|
||
property specifically, not the gate/forwarder which Tasks 2/3 pinned.)
|
||
|
||
- [ ] **Step 4: Verify GREEN (own coherent-stop)**
|
||
|
||
The `own` consume path is existing Iter-B machinery (spec §"Why the
|
||
ownership contract is mechanically almost free"; ratified by
|
||
`crates/ail/tests/e2e.rs::alloc_rc_own_param_dec_at_fn_return:1855`
|
||
— **not** `borrow_own_demo_modes_are_metadata_only`, which is an
|
||
Iter-18a pre-enforcement pin and must not be cited). No production
|
||
code change is expected in this task — if the **global**
|
||
Σallocs==Σfrees is RED for a real imbalance, that is a genuine bug:
|
||
hand off to `debug` (RED test already exists). If it is GREEN once
|
||
Tasks 2+3 are in the tree, that *is* the spec's "mechanically almost
|
||
free" claim substantiated.
|
||
|
||
Run: `cargo test -p ail --test embed_record_e2e record_roundtrip_own_alloc_eq_free -- --exact`
|
||
Expected: PASS — Σallocs == Σfrees (own: ctx `live=0` + g_rc
|
||
`live=0`; the two stat lines summed), exit 0.
|
||
|
||
---
|
||
|
||
## Task 5: E2E record round-trip — `borrow`
|
||
|
||
Same harness, the `-DBORROW` arm: the kernel does **not** consume the
|
||
input; the host retains and `ailang_rc_dec`s it itself. **Global**
|
||
Σallocs == Σfrees again (Boss repair — *not* per-ctx-line: in borrow
|
||
the ctx line is `live=+N` and the g_rc line `live=−N`, summing to 0;
|
||
the host's decs land on g_rc because they run outside the forwarder's
|
||
TLS-ctx window — correct M2 behaviour, not a leak). Tasks 4+5
|
||
together prove "ownership follows the declared mode" both directions,
|
||
globally leak-free.
|
||
|
||
**Files:**
|
||
- Modify: `crates/ail/tests/embed_record_e2e.rs` (add the borrow test)
|
||
|
||
- [ ] **Step 1: Write the failing `borrow` E2E test (RED)**
|
||
|
||
In `crates/ail/tests/embed_record_e2e.rs` add (same multi-line-sum
|
||
`build_link_run_embed` as Task 4 — global invariant):
|
||
|
||
```rust
|
||
#[test]
|
||
fn record_roundtrip_borrow_alloc_eq_free() {
|
||
// -DBORROW: kernel (borrow param) does NOT consume the input;
|
||
// host frees the retained input each iter + the final return.
|
||
let stats = build_link_run_embed(
|
||
"embed_backtest_step_record_borrow.ail",
|
||
"embed/record_roundtrip.c",
|
||
&["-DBORROW"],
|
||
);
|
||
// GLOBAL: ctx allocs (kernel returns) + g_rc frees (host decs)
|
||
// sum to a balanced total — Σallocs == Σfrees, Σlive == 0.
|
||
assert_eq!(stats.allocs, stats.frees,
|
||
"borrow: globally leak-free — kernel allocs returns on ctx, \
|
||
host frees inputs+return on g_rc; the two summed balance; {stats:?}");
|
||
assert_eq!(stats.exit_code, 0, "C host assert(n==1000000) held");
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Run the `borrow` E2E to verify RED then GREEN**
|
||
|
||
Run: `cargo test -p ail --test embed_record_e2e record_roundtrip_borrow_alloc_eq_free -- --exact --nocapture`
|
||
Expected: FAIL first if the borrow fixture is not yet built/lowered
|
||
or the harness still parses only the first stat line; then PASS once
|
||
the multi-line-sum harness is in place and
|
||
`embed_backtest_step_record_borrow.ail` builds via the Task-2/3
|
||
path. A `borrow` param is **not** dropped by the kernel (ratified by
|
||
`crates/ail/tests/e2e.rs::alloc_rc_borrow_only_recursive_list_drop:1671`);
|
||
the host's per-iter `ailang_rc_dec(st)` on g_rc is what balances the
|
||
**global** total. A real *global* imbalance ⇒ genuine bug ⇒ `debug`.
|
||
|
||
- [ ] **Step 3: Both modes green together**
|
||
|
||
Run: `cargo test -p ail --test embed_record_e2e`
|
||
Expected: PASS, `2 passed` — `record_roundtrip_own_alloc_eq_free`
|
||
and `record_roundtrip_borrow_alloc_eq_free`, both at global
|
||
Σallocs==Σfrees. The frozen ownership contract is **proven both
|
||
ways**, not asserted.
|
||
|
||
---
|
||
|
||
## Task 6: Freeze the layout — DESIGN.md SSOT + lockstep pointers
|
||
|
||
Convert "provisional until M3" into one-way-freeze wording, add the
|
||
frozen value-layout SSOT subsection, and point the three independent
|
||
layout-encoding sites at it. The Task-1 byte-pin becomes the
|
||
enforcing guard. Pin-safety: `docs_honesty_pin.rs:135` asserts
|
||
DESIGN.md `:2297` ("Export parameters are written **bare**…"), which
|
||
is **outside** both rewrite zones — it must stay byte-verbatim and
|
||
contiguous (planner Step-5 item 6).
|
||
|
||
**Files:**
|
||
- Modify: `docs/DESIGN.md:2280-2282`, `:2293-2295`, insert before `:2319`
|
||
- Modify: `runtime/rc.c:28-44`
|
||
- Modify: `crates/ailang-codegen/src/match_lower.rs:107`/`:123-126`/`:128-136`
|
||
- Modify: `crates/ailang-codegen/src/drop.rs:88`/`:113`
|
||
- Modify: `crates/ailang-codegen/src/lib.rs:178`, `crates/ailang-core/src/ast.rs:211`
|
||
|
||
- [ ] **Step 1: Confirm the docs-honesty pin baseline + the exact pinned line**
|
||
|
||
Run: `cargo test -p ailang-core --test docs_honesty_pin`
|
||
Expected: PASS (baseline before any DESIGN.md edit).
|
||
|
||
Run: `grep -n 'Export parameters are written' docs/DESIGN.md`
|
||
Expected: one hit at `:2297`. Record its exact byte content — Step 4
|
||
must NOT touch this line and must keep it contiguous.
|
||
|
||
- [ ] **Step 2: Rewrite the two "provisional until M3" sentences**
|
||
|
||
`docs/DESIGN.md:2280-2282` currently (recon-verbatim):
|
||
|
||
```
|
||
The M1 ABI is **scalar-only and provisional until M3**: every parameter and the return type must be `Int` (lowered `i64`) or `Float` (lowered `double`), and the fn's effect set must be empty.
|
||
```
|
||
|
||
Replace with (the scalar rule still holds for *scalar* exports; the
|
||
record extension + freeze are stated; single contiguous sentences):
|
||
|
||
```
|
||
The embedding ABI accepts `Int` (lowered `i64`), `Float` (lowered `double`), or a single-constructor record of those (crossing as a bare `ptr` to the frozen box layout below); the fn's effect set must be empty. **Frozen as of M3**: a future compiler change MUST NOT move the box offsets below or invert the host-free rule.
|
||
```
|
||
|
||
`docs/DESIGN.md:2293-2295` currently (recon-verbatim):
|
||
|
||
```
|
||
Only the value/record layout remains provisional until M3; the ctx-threaded C signature is the M2 shape.
|
||
```
|
||
|
||
Replace with:
|
||
|
||
```
|
||
The value/record layout is **frozen as of M3** (see "Frozen value layout" below); the ctx-threaded C signature is the M2 shape.
|
||
```
|
||
|
||
- [ ] **Step 3: Run the docs-honesty pin to confirm Step 2 is pin-safe**
|
||
|
||
Run: `cargo test -p ailang-core --test docs_honesty_pin`
|
||
Expected: PASS — recon-confirmed no pin asserts any "provisional
|
||
until M3" substring; `:2297` untouched. If RED, a pinned substring
|
||
was in the rewrite zone — STOP, restore, and re-scope the edit to
|
||
exclude the pinned bytes (planner Step-5 item 6).
|
||
|
||
- [ ] **Step 4: Insert the frozen value-layout SSOT subsection**
|
||
|
||
In `docs/DESIGN.md`, immediately before `## Data model` (recon: blank
|
||
`:2318`, `## Data model` at `:2319`; insert at end of §"Embedding
|
||
ABI", after `:2317`):
|
||
|
||
```markdown
|
||
### 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 layout codegen
|
||
emits today (`match_lower.rs` `lower_ctor`); it is **frozen** — a
|
||
future compiler change MUST NOT move these offsets.
|
||
|
||
**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 type
|
||
(rejected by the export gate); a recursive typed-free for that
|
||
shape is an additive M4 concern, not a contradiction of this
|
||
freeze.
|
||
```
|
||
|
||
- [ ] **Step 5: Add the lockstep-pointer comment at the three encoders**
|
||
|
||
`runtime/rc.c:28-44` — at the end of the layout comment block
|
||
(after the "fields from offset 8, env-cells …" sentence, before the
|
||
"Single-threaded:" paragraph), add one line:
|
||
|
||
```
|
||
* 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.
|
||
```
|
||
|
||
`crates/ailang-codegen/src/match_lower.rs` — one line immediately
|
||
above the `size_bytes = 8 + ...` computation (`:107`):
|
||
|
||
```rust
|
||
// FROZEN ABI (embedding boundary) — DESIGN.md §"Embedding ABI"
|
||
// > "Frozen value layout". size = 8 + n*8, tag@0, fields@8+i*8.
|
||
```
|
||
|
||
`crates/ailang-codegen/src/drop.rs` — one line above the tag load
|
||
(`:88`) and above the `off = 8 + j*8` field-offset computation
|
||
(`:113`):
|
||
|
||
```rust
|
||
// FROZEN ABI (embedding boundary) — see DESIGN.md §"Embedding ABI".
|
||
```
|
||
|
||
- [ ] **Step 6: Fix the two stale rustdoc "provisional until M3" lines**
|
||
|
||
`crates/ailang-codegen/src/lib.rs:178` and
|
||
`crates/ailang-core/src/ast.rs:211` each carry a rustdoc fragment
|
||
saying the layout/signature is "provisional until M3" (recon-named;
|
||
DESIGN.md-current-state-mirror rule — these are now stale). Open
|
||
each, replace the "provisional until M3" clause with "frozen as of
|
||
M3 (DESIGN.md §\"Embedding ABI\" > \"Frozen value layout\")",
|
||
preserving the rest of the doc line byte-for-byte.
|
||
|
||
- [ ] **Step 7: Confirm the byte-pin is now the enforcing guard (spec item 5)**
|
||
|
||
Run: `cargo test -p ailang-codegen --test embed_record_layout_pin`
|
||
Expected: PASS (still — Task 6 changed no codegen offset, only
|
||
comments+docs).
|
||
|
||
Demonstrate enforceability (local, reverted): temporarily change
|
||
`match_lower.rs:107` `8 + ...` to `16 + ...`, run the pin, observe
|
||
RED, revert.
|
||
|
||
Run: `sed -i 's/let size_bytes = 8 +/let size_bytes = 16 +/' crates/ailang-codegen/src/match_lower.rs ; cargo test -p ailang-codegen --test embed_record_layout_pin -- --exact heap_box_layout_is_frozen_8_plus_n_times_8_tag_at_0_fields_from_8 ; git checkout -- crates/ailang-codegen/src/match_lower.rs`
|
||
Expected: the test run in the middle FAILS (`@ailang_rc_alloc(i64 24)`
|
||
absent — emits `i64 32`), then `git checkout` restores the file.
|
||
Re-run: `cargo test -p ailang-codegen --test embed_record_layout_pin`
|
||
Expected: PASS — proves the freeze is enforced, not aspirational.
|
||
(Use the exact `let size_bytes = 8 +` literal as it appears at
|
||
`:107`; if the binding name differs, adjust the `sed` to the actual
|
||
literal — confirm via `grep -n 'size_bytes' crates/ailang-codegen/src/match_lower.rs` first.)
|
||
|
||
---
|
||
|
||
## Task 7: Milestone-close verification gate
|
||
|
||
Not a code change — the consolidated GREEN gate proving the
|
||
"unmodified set stays green" + the full workspace is clean. Spec
|
||
Testing items 6/7 (regression-green-unmodified), 8 (clean core), 9
|
||
(bench) — items 8/9 are the milestone-close `audit` skill's job
|
||
(architect Invariant 1, bench trio); this task asserts only what an
|
||
implement run can: the test-suite GREEN state.
|
||
|
||
**Files:** none (verification only).
|
||
|
||
- [ ] **Step 1: The "stays green unmodified" regression set**
|
||
|
||
Run: `cargo test -p ail --test embed_e2e --test embed_rc_accounting_tsan --test embed_swarm_tsan --test embed_staticlib_cli --test embed_staticlib_alloc_guard --test print_no_leak_pin`
|
||
Expected: PASS — every M1/M2 embed test + the executable-path
|
||
leak pin green **unmodified** (M3 is additive: the scalar path is
|
||
byte-preserved; recon named this exact set).
|
||
|
||
Run: `cargo test -p ailang-core --test design_schema_drift ; cargo test -p ailang-check --test embed_export_hash_stable`
|
||
Expected: PASS — no schema field added (explicit M2-style contrast;
|
||
hashes/drift byte-unchanged).
|
||
|
||
- [ ] **Step 2: Full workspace**
|
||
|
||
Run: `cargo test --workspace 2>&1 | tail -15`
|
||
Expected: PASS — every crate green. Record the total passed count;
|
||
it must be the pre-M3 baseline + the net-new M3 tests
|
||
(`embed_record_layout_pin` 1, `embed_export_gate` +4,
|
||
`embed_staticlib_lowering` +1, `embed_record_e2e` 2) and no
|
||
pre-existing test removed or its assertion weakened.
|
||
|
||
- [ ] **Step 3: Round-trip invariant (no surface change)**
|
||
|
||
Run: `cargo test -p ailang-surface --test round_trip`
|
||
Expected: PASS — the new `.ail` fixtures round-trip; no Form-A
|
||
surface was added (existing `data`/`ctor`/`own`/`borrow`/`match`/
|
||
`pat-ctor`/`app` only).
|
||
|
||
---
|
||
|
||
## Notes for the implement orchestrator
|
||
|
||
- **Bench / architect are NOT this plan's job.** Spec Testing items
|
||
8 (clean-core Invariant 1) and 9 (bench trio carry-on) are the
|
||
milestone-close `audit` skill (architect + bencher). Do not run
|
||
`bench/check.py` in implement; record in the per-iter journal that
|
||
audit owns the bench/architect close.
|
||
- **Grounding-check carry-forward (do not violate):**
|
||
(1) the own/borrow contract is ratified by
|
||
`crates/ail/tests/e2e.rs::alloc_rc_own_param_dec_at_fn_return:1855`
|
||
+ `…::alloc_rc_borrow_only_recursive_list_drop:1671` — **never**
|
||
cite `borrow_own_demo_modes_are_metadata_only:1330` (Iter-18a
|
||
pre-enforcement pin) as the contract ratifier.
|
||
(2) Task 1's byte-pin is a genuine RED-first new pin on the
|
||
`@ailang_rc_alloc` heap path; the pre-existing
|
||
`iter17a_local_box_alloca` pins the *alloca* path and is NOT a
|
||
substitute.
|
||
- **Boss-only-commits.** Leave all work uncommitted in the working
|
||
tree; the Boss inspects `git diff` and commits the iter.
|