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.
20 KiB
Boehm full retirement — Design Spec
Date: 2026-05-20 Status: Draft — awaiting user spec review Authors: Brummel (orchestrator) + Claude
Goal
Retire the transitional Boehm-Demers-Weiser GC backend from the
AILang toolchain. After this milestone, RC is the canonical
production allocator, --alloc=bump is retained as the raw-alloc
bench-floor, and there is no GC code path. The design ledger,
the CLI surface, the codegen pipeline, the test suite, and the
bench harness all reflect a single-canonical-allocator project.
Resolves Gitea issue #4 (Boehm full retirement).
Architecture
The Boehm backend was introduced as a transitional dual-allocator
during the pre-22 RC commitment window. Its standing role per
design/models/0004-rc-uniqueness.md was twofold: (a) production
fallback before RC was mature; (b) parity oracle —
differential RC-vs-GC stdout assertions to catch RC bugs cheaply.
Both roles have expired:
- (a) RC is the CLI default and has been the canonical production allocator since the memory-model commitment landed.
- (b) No recent debug iteration has used the GC oracle to triage an RC bug. The differential e2e tests have not caught any RC bug that fixed-stdout assertions would not have caught.
The retirement removes Boehm wholesale across six layers in one cohesive iteration:
- CLI surface —
--alloc=gcvalue rejected withunknown --alloc valueparser error; help text adjusted. - Codegen —
AllocStrategy::Gcvariant deleted; theruntime_alloc_fnarm returning"GC_malloc"removed; thelower_workspacedefault switches fromAllocStrategy::GctoAllocStrategy::Rc. - Build / link — the
clanginvocation incrates/ail/src/main.rsloses its libgc-link branch entirely. - Test suite — pure-differential e2e tests deleted; RC-feature
tests with GC-as-backstop converted to fixed-stdout (the GC
build + the
assert_eq!(stdout_gc, stdout_rc, ...)line drop, the absoluteassert_eq!(stdout_rc.trim(), "<n>")pin stays); the codegen-internal negative-complement test that proves "no drop fns under non-RC" retargets fromAllocStrategy::GctoAllocStrategy::Bump; the M2 staticlib alloc-guard test loses its gc-arm but keeps its bump-arm. - Design ledger —
design/models/0004-rc-uniqueness.mdexcises the "Dual allocator — RC canonical, Boehm parity oracle" section and the Boehm-Choice-block; the per-fn-alloca section's language generalises from "on top of Boehm" to "on top of the allocator".design/models/0003-pipeline.mddrops the--alloc=gc → libgcarm of the pipeline diagram and the accompanying prose. The 1.3× RC-over-bump number is retained inrc-uniqueness.mdbut reframed as a bench-health regression gate (not a retirement gate); the closure-chain ±15% wider band is preserved analogously. - Honesty pin —
crates/ailang-core/tests/docs_honesty_pin.rsremoves its present-tense assertion that requires"--alloc=gc selects the transitional Boehm backend"inpipeline.md; in its place, thedesign_md_has_no_wunschdenkenset gains absence-pins against Boehm-zombie strings ("transitional Boehm","parity oracle","GC_malloc","libgc") so the narrative cannot quietly re-emerge.
Bump survives unchanged — AllocStrategy::Bump, runtime/bump.c,
the --alloc=bump CLI flag, and bench/run.sh's RC-vs-bump
comparison all remain. Bump's standing role is bench-floor for
RC-overhead measurement, not a production target.
Concrete code shapes
This is a no-authoring-surface milestone — no new .ail
construct is added or removed. The clause-3 discriminator
(wrong code must fail) lives at the CLI layer:
Must-fail CLI fixture (clause-3 discriminator)
$ ail build --alloc=gc examples/hello.ail -o /tmp/h
error: unknown --alloc value `gc` (expected `rc` or `bump`)
exit 2
Unchanged-default north-star slice
$ cat examples/hello.ail
(module hello
(fn main
(type (fn-type (params) (ret (con Unit)) (effects IO)))
(params)
(body (do (io print_str "hello")))))
$ ail build examples/hello.ail -o /tmp/h # default --alloc=rc
$ /tmp/h
hello
After retirement, every example under examples/ continues to
build and run unchanged under the default --alloc=rc. The
--alloc=bump invocation still works for leak-tolerant bench
contexts.
Implementation shape — codegen AllocStrategy
Before:
// crates/ailang-codegen/src/lib.rs
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AllocStrategy { Gc, Bump, Rc }
impl AllocStrategy {
fn runtime_alloc_fn(&self) -> &'static str {
match self {
AllocStrategy::Gc => "GC_malloc",
AllocStrategy::Bump => "bump_malloc",
AllocStrategy::Rc => "ailang_rc_alloc",
}
}
}
pub fn lower_workspace(ws: &Workspace) -> Result<String, Error> {
lower_workspace_inner(ws, AllocStrategy::Gc, Target::Executable)
}
After:
// crates/ailang-codegen/src/lib.rs
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AllocStrategy { Bump, Rc }
impl AllocStrategy {
fn runtime_alloc_fn(&self) -> &'static str {
match self {
AllocStrategy::Bump => "bump_malloc",
AllocStrategy::Rc => "ailang_rc_alloc",
}
}
}
pub fn lower_workspace(ws: &Workspace) -> Result<String, Error> {
lower_workspace_inner(ws, AllocStrategy::Rc, Target::Executable)
}
Implementation shape — CLI parse arm
Before (crates/ail/src/main.rs:2143-2148):
match alloc.as_str() {
"gc" => Ok(ailang_codegen::AllocStrategy::Gc),
"bump" => Ok(ailang_codegen::AllocStrategy::Bump),
"rc" => Ok(ailang_codegen::AllocStrategy::Rc),
other => bail!("unknown --alloc value `{other}` (expected `gc`, `bump`, or `rc`)"),
}
After:
match alloc.as_str() {
"bump" => Ok(ailang_codegen::AllocStrategy::Bump),
"rc" => Ok(ailang_codegen::AllocStrategy::Rc),
other => bail!("unknown --alloc value `{other}` (expected `rc` or `bump`)"),
}
Implementation shape — link branch
Before (crates/ail/src/main.rs:2389ff):
match strategy {
AllocStrategy::Gc => {
// libgc link; pthread/dl transitively
cmd.arg("-lgc");
}
AllocStrategy::Bump => {
// compile runtime/bump.c, link static
...
}
AllocStrategy::Rc => { ... }
}
After: the Gc arm is gone entirely; match exhausts on Bump
and Rc. The accompanying inline doc-comment block ("Boehm
conservative GC (the transitional dual-allocator). The lowered
IR calls @GC_malloc; libgc supplies it. …") is removed; the
remaining Bump and Rc arms keep their doc-comments unchanged.
Implementation shape — staticlib alloc-guard
Before (crates/ail/tests/embed_staticlib_alloc_guard.rs):
#[test]
fn staticlib_gc_is_rejected() { ... } // expects unknown-alloc OR RC-only diag
#[test]
fn staticlib_bump_is_rejected() {
let out = build_staticlib_with_alloc("bump", "/tmp/ail_m2_guard_bump");
assert!(!out.status.success(), ...);
assert!(stderr.contains("staticlib (swarm) artefact is RC-only"), ...);
}
After: staticlib_gc_is_rejected deleted (the CLI parser now
rejects gc before the build is reached, so the staticlib-guard
no longer governs that case). staticlib_bump_is_rejected kept
unchanged. File-level doc-comment updated to drop the GC reference.
Implementation shape — codegen negative-complement test
Before (crates/ailang-codegen/src/lib.rs:3571-3576):
// Negative complement: no drop fns under `--alloc=gc`.
let ir_gc = lower_workspace_with_alloc(&ws, AllocStrategy::Gc).unwrap();
assert!(!ir_gc.contains("@drop_rclist_IntList"), ...);
After:
// Negative complement: no drop fns under `--alloc=bump`
// (only RC emits per-type drop fns; bump leaks).
let ir_bump = lower_workspace_with_alloc(&ws, AllocStrategy::Bump).unwrap();
assert!(!ir_bump.contains("@drop_rclist_IntList"), ...);
The test's semantic ("non-RC allocators emit no drop fns") is
preserved; the witness allocator shifts from Gc to Bump.
Implementation shape — honesty-pin inversion
Before (crates/ailang-core/tests/docs_honesty_pin.rs:116-117):
assert!(pipeline.contains("`--alloc=gc` selects the transitional Boehm backend"),
"models/0003-pipeline.md must describe Boehm present-tense, not as 'on the path to retirement'");
After: this assertion is deleted, and the
design_md_has_no_wunschdenken set gains four new absence-pins
in its place:
assert!(!d.contains("transitional Boehm"),
"design/: Boehm narrative is retired — must not re-emerge in the ledger");
assert!(!d.contains("parity oracle"),
"design/: Boehm-as-parity-oracle is retired narrative — git log carries the history");
assert!(!d.contains("GC_malloc"),
"design/: GC_malloc references are retired — RC + bump are the only allocators");
assert!(!d.contains("libgc"),
"design/: libgc references are retired — no GC link dependency exists anymore");
The corpus that design_md_has_no_wunschdenken scans
(design_corpus()) already includes rc-uniqueness.md and the
other ledger files, so no path-list change is needed. The
header doc-comment for the test file is updated to drop the
Boehm/Decision-9 phrase.
Implementation shape — design/models excise (rc-uniqueness.md)
The ## Dual allocator — RC canonical, Boehm parity oracle section
(lines 3-32 of the file) is deleted entirely. The ## Per-fn arena via stack alloca section is kept; its references to "Boehm" and
"@GC_malloc" generalise to "the allocator" / "@<runtime_alloc>"
respectively. The ## Memory model — RC + Uniqueness … section
keeps the RC narrative; the parenthetical
"(see the dual-allocator section above)" is dropped; the
parenthetical naming Boehm as a transitional allocator is dropped;
the 1.3× target sentence is rewritten to drop the Boehm-retirement
framing:
Before:
AILang's canonical memory model is reference counting with static uniqueness inference and explicit LLM-author annotations… Boehm becomes a transitional allocator and is retired when the RC pipeline matches the bump-allocator floor within an acceptable margin (target 1.3× on
bench/run.sh).
After:
AILang's canonical memory model is reference counting with static uniqueness inference and explicit LLM-author annotations. The RC pipeline tracks the bump-allocator raw-alloc floor: a bench-health regression gate requires RC overhead ≤ 1.3× bump on the linear/tree corpus, with a wider ±15% band on the closure-chain corpus (representational cost of the closure-pair layout). See
bench/run.shfor the active check.
Implementation shape — design/models excise (pipeline.md)
The pipeline diagram drops the libgc arm:
Before:
└─ clang -O2 *.ll -o binary
--alloc=rc → emits inc/dec (@ailang_rc_inc / _dec; canonical, default)
--alloc=gc → links libgc (@GC_malloc; parity oracle)
After:
└─ clang -O2 *.ll -o binary
--alloc=rc → emits inc/dec (@ailang_rc_inc / _dec; canonical, default)
--alloc=bump → links bump-floor (@bump_malloc; raw-alloc bench-floor)
The accompanying prose ("Two allocator backends share the same
MIR. … --alloc=gc selects the transitional Boehm backend …") is
rewritten to describe the rc/bump pair as the present state.
Components
| Component | Touchpoints (precise location-class, not byte-exact) |
|---|---|
| Codegen | crates/ailang-codegen/src/lib.rs — AllocStrategy enum variant; runtime_alloc_fn match arm; default-strategy entry-point fns (lower_workspace, lower_workspace_staticlib); doc-comment block on the enum; the inline negative-complement codegen test currently using AllocStrategy::Gc |
| Codegen | crates/ailang-codegen/src/escape.rs — module-level doc comment generalises GC-specific language to allocator-agnostic |
| CLI parse | crates/ail/src/main.rs:2143ff — --alloc match arm; error-text wording |
| CLI build | crates/ail/src/main.rs:2389ff — the AllocStrategy::Gc arm in the clang invocation; the surrounding doc-comment block; the staticlib-guard diagnostic string (the "links the shared Boehm collector" phrasing) |
| Runtime docs | runtime/bump.c header doc — drop "mirrors GC_malloc from libgc" and "default Boehm-GC build" wording; bump's signature description stays |
| Runtime docs | runtime/rc.c, runtime/str.c — comment references to Boehm / --alloc=gc |
| E2E pure-differential | crates/ail/tests/e2e.rs — gc_handles_recursive_list_construction (line 194), alloc_rc_produces_same_stdout_as_gc (1400), alloc_rc_matches_gc_on_std_list_demo (1564) — deleted |
| E2E RC-feature w/ differential backstop | crates/ail/tests/e2e.rs — reuse_as_drop_demo (1475ff), rc_box_drop (1602ff), rc_list_drop (1628ff), rc_list_drop_borrow (1674ff), pat_extract_partial_drop (1762ff), rc_own_param_drop (1858ff), rc_drop_iterative_long_list (1981ff), rc_pin_recurse_implicit (2168ff), rc_let_alias_implicit_param (2396ff) — the stdout_gc build call and the assert_eq!(stdout_gc, stdout_rc, ...) differential line dropped; the absolute assert_eq!(stdout_rc.trim(), "<n>") pin stays; doc-comments cleaned of --alloc=gc references |
| E2E staticlib guard | crates/ail/tests/embed_staticlib_alloc_guard.rs — staticlib_gc_is_rejected deleted; staticlib_bump_is_rejected unchanged; file-level doc-comment updated |
| Bench harness | bench/run.sh — bench_latency_implicit_gc arm removed; all --alloc=gc build calls removed; comment "decisive number for Decision 10's Boehm-retirement target (1.3x)" → "RC-overhead-vs-bump bench-health gate (1.3× ceiling)"; comment "Boehm-fair Implicit @ gc" arm dropped |
| Design ledger | design/models/0004-rc-uniqueness.md — full Boehm sections excised (per "Concrete code shapes" above); 1.3× reframed as bench-health regression gate |
| Design ledger | design/models/0003-pipeline.md — pipeline diagram + accompanying prose updated (per above) |
| Design ledger | design/contracts/0003-embedding-abi.md:43-44 — the present-tense sentence ail build --emit=staticlib rejects --alloc=gc/--alloc=bump (the shared Boehm collector is not swarm-safe) is rewritten to drop the gc clause (gc is now a CLI-parser-level unknown-value, not a staticlib-guard rejection) and to reframe the swarm-safety justification: only --alloc=bump is rejected by the staticlib guard, on the grounds that bump is a leak-only bench instrument, not the historical Boehm-collector justification |
| Honesty pin | crates/ailang-core/tests/docs_honesty_pin.rs — present-tense Boehm pin deleted; four absence-pins added (per above); test-file doc-comment header updated to drop the Boehm/Decision-9 phrase |
| Bencher agent | skills/audit/agents/ailang-bencher.md — the worked example "under heap pressure where Boehm GC has stop-the-world pauses" is rewritten to use an allocator-agnostic hypothesis (RC overhead under a workload that doesn't exercise the rc path is a natural replacement) |
The fixture examples/gc_stress.ail (target of the deleted
gc_handles_recursive_list_construction test) is deleted.
Verified at spec time: grep -rn 'gc_stress' --include='*.rs' --include='*.ail' --include='*.ail.json' --include='*.sh' returns
exactly two hits — the e2e test call and the fixture itself —
both of which are removed by this iteration.
Data flow
Removal-only milestone; no new data flow. The existing post-retirement data flow is:
.ail.json → check (typecheck + uniqueness) → codegen
├─ AllocStrategy::Rc → @ailang_rc_inc / _dec + rc.c linked
└─ AllocStrategy::Bump → bump.c linked (bench only)
→ clang -O2 → binary
No surviving code path branches on Boehm/GC.
Error handling
The only user-observable surface change is the CLI parser error:
| Input | Old behaviour | New behaviour |
|---|---|---|
ail build --alloc=gc foo.ail |
succeeds; links libgc; produces a Boehm-backed binary | exit 2; stderr: error: unknown --alloc value \gc` (expected `rc` or `bump`)` |
ail build foo.ail (default) |
succeeds; defaults to gc historically — but actual default in current code is rc per the memory-model commitment | unchanged: defaults to rc, succeeds |
ail build --alloc=rc foo.ail |
unchanged | unchanged |
ail build --alloc=bump foo.ail |
unchanged | unchanged |
ail build --emit=staticlib --alloc=bump foo.ail |
rejected with "staticlib artefact is RC-only" | unchanged |
The host's libgc-installation status no longer affects any
ail build/ail run outcome.
Testing strategy
The retirement RED-state is observable through several existing green tests that currently depend on Boehm. Removal breaks them; the iteration's GREEN-state is the simultaneous removal of the Boehm path AND the breaking tests.
RED→GREEN transitions in the iteration:
cargo test -p ailang-codegen— the in-source codegen test that exercisesAllocStrategy::Gcfails to compile once the enum variant is removed; the test is retargeted toBumpinside the same iteration.cargo test -p ail --test e2e— the pure-differential tests are deleted; the RC-feature tests lose theirstdout_gcbuild call (which callsbuild_and_run_with_alloc(example, "gc"), which would now panic). Each test is rewritten to drop the gc call.cargo test -p ail --test embed_staticlib_alloc_guard—staticlib_gc_is_rejectedis deleted;staticlib_bump_is_rejectedkeeps passing.cargo test -p ailang-core --test docs_honesty_pin— the present-tense Boehm-anchor assertion fails the momentpipeline.mddrops the string; the assertion is deleted inside the same iteration, and the four new absence-pins are added to replace it.
New protective assertions:
- A
cargo test -p ail --test boehm_retirement_pin(new file) pins the CLI behaviour:ail build --alloc=gc <example>must fail with exit code ≠ 0 and stderr containingunknown --alloc value. This is the milestone-protecting E2E for the retirement — guards against a future iter reintroducing the gc arm without anyone noticing.
Standing tests that must remain green:
- All
workspace-scope tests (cargo test --workspace --quiet). design_index_pin,design_schema_drift,roundtrip-invariant,effect_doc_honesty_pin,docs_honesty_pin(with the new absence-pins).- Bench harness regression check
bench/check.py— passes unchanged (no compile-side compatibility break). bench/compile_check.py,bench/cross_lang.py— pass unchanged.bench/run.shruns to completion with rc-vs-bump arms only, no gc arms.
Field test: because this is a no-authoring-surface milestone,
no .ail fieldtest is needed. The CLI must-fail fixture is the
clause-3 discriminator.
Acceptance criteria
The milestone closes CLEAN when all of the following hold:
cargo test --workspace --quietis fully green; the ~12 removed/converted tests are accounted for in the iter commit body; no new failure.grep -rn "Boehm\|libgc\|GC_malloc\|--alloc=gc\|AllocStrategy::Gc" \ crates/ design/ runtime/ bench/run.sh skills/returns matches only incrates/ailang-core/tests/docs_honesty_pin.rs(the four new absence-pins literally name the Boehm-zombie strings to scan against). Anywhere else is a residual reference and the iteration is not done.bench/check.py,bench/compile_check.py,bench/cross_lang.pyall exit 0 against the existing baseline.- The CLI must-fail fixture exits ≠ 0 with the expected stderr wording.
design/models/0004-rc-uniqueness.mdanddesign/models/0003-pipeline.mddescribe RC + bump as the present state; the honesty rule (design/contracts/0007-honesty-rule.md) is upheld — no past-tense "Boehm was kept as oracle" narrative remains; Boehm's history is recorded in the iter's git-log commit body.- Issue #4 is closed by the iter commit's
closes #4trailer.
Out of scope (deferred to separate work):
- The "Closure-pair slab / pool" optimisation (Gitea #3) that would tighten the closure-chain ±15% bench-health band. Not blocked by retirement; can ship anytime.
- Any further
AllocStrategy::Bumprework (still a single- variant bench instrument). - Any bench-harness recalibration — that just closed (bench-harness-recalibration audit, 2026-05-20); this milestone preserves the calibration.