Resolves Gitea #4. Approach A (atomic single iteration). Six layers in one cohesive cut: CLI `--alloc=gc` arm removed; codegen `AllocStrategy::Gc` variant deleted with the default flipped to `Rc`; libgc link branch removed; ~3 pure-differential e2e tests plus the `gc_stress.ail` fixture deleted; ~9 RC-feature tests that used GC-stdout as backstop lose only the differential assertion (absolute fixed-stdout pin retained); M2 staticlib alloc-guard drops its gc-arm (bump-arm preserved); design/models/rc-uniqueness excises the Boehm-parity-oracle narrative; pipeline.md drops the libgc pipeline-diagram arm; docs_honesty_pin flips from Boehm-present-tense anchor to four absence-pins against Boehm-zombie strings. Three design forks were resolved with the user via brainstorm Q&A: (1) bump survives as bench-floor — `AllocStrategy::Bump`, the `--alloc=bump` CLI flag, and `runtime/bump.c` all stay; the enum keeps two variants (Rc, Bump); the codegen negative-complement test retargets from `AllocStrategy::Gc` to `AllocStrategy::Bump`. Bump's standing role is the raw-alloc bench-floor for RC-overhead measurement, not a production target. (2) the ~12 RC-vs-GC differential e2e tests are NOT all deleted wholesale — pure-differential ones (test name literally `*_matches_gc_*` or `*_same_stdout_as_gc`) are deleted; the RC-feature tests with GC as backstop keep their absolute stdout pin (`assert_eq!(stdout_rc.trim(), "<n>")`) and only lose the differential assertion. This nuance was added at spec time on top of the user's "delete the differential pattern" answer, because the differential was incidental to tests like `rc_box_drop` / `rc_list_drop_borrow` that pin drop-fn correctness under RC and would lose unrelated coverage if deleted entirely. (3) the 1.3× RC-over-bump number is retained in design/models/rc-uniqueness.md but reframed as a bench-health regression gate (not a Boehm-retirement gate); the closure-chain ±15% wider band is preserved analogously. Grounding-check (ailang-grounding-check) PASS — 7 load-bearing assumptions ratified, all spec-named paths and line numbers verified (±2 lines), all proposed-for-removal strings present at the spec-named locations. Assumption #7 (codegen default currently Gc) is structurally self-evident: removing the variant forces the default onto a surviving one by construction. Out of scope, tracked separately: - Gitea #3 "Closure-pair slab / pool" — would tighten the closure-chain ±15% band; not blocked by retirement. - Any further `AllocStrategy::Bump` rework — still a single-variant bench instrument. refs #4
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/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/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/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/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/rc-uniqueness.md — full Boehm sections excised (per "Concrete code shapes" above); 1.3× reframed as bench-health regression gate |
| Design ledger | design/models/pipeline.md — pipeline diagram + accompanying prose updated (per above) |
| Design ledger | design/contracts/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/rc-uniqueness.mdanddesign/models/pipeline.mddescribe RC + bump as the present state; the honesty rule (design/contracts/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.