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

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

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

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

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

20 KiB
Raw Permalink Blame History

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:

  1. CLI surface--alloc=gc value rejected with unknown --alloc value parser error; help text adjusted.
  2. CodegenAllocStrategy::Gc variant deleted; the runtime_alloc_fn arm returning "GC_malloc" removed; the lower_workspace default switches from AllocStrategy::Gc to AllocStrategy::Rc.
  3. Build / link — the clang invocation in crates/ail/src/main.rs loses its libgc-link branch entirely.
  4. 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 absolute assert_eq!(stdout_rc.trim(), "<n>") pin stays); the codegen-internal negative-complement test that proves "no drop fns under non-RC" retargets from AllocStrategy::Gc to AllocStrategy::Bump; the M2 staticlib alloc-guard test loses its gc-arm but keeps its bump-arm.
  5. Design ledgerdesign/models/0004-rc-uniqueness.md excises 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.md drops the --alloc=gc → libgc arm of the pipeline diagram and the accompanying prose. The 1.3× RC-over-bump number is retained in rc-uniqueness.md but reframed as a bench-health regression gate (not a retirement gate); the closure-chain ±15% wider band is preserved analogously.
  6. Honesty pincrates/ailang-core/tests/docs_honesty_pin.rs removes its present-tense assertion that requires "--alloc=gc selects the transitional Boehm backend" in pipeline.md; in its place, the design_md_has_no_wunschdenken set 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`)"),
}

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.sh for 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.rsAllocStrategy 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.rsgc_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.rsreuse_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.rsstaticlib_gc_is_rejected deleted; staticlib_bump_is_rejected unchanged; file-level doc-comment updated
Bench harness bench/run.shbench_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:

  1. cargo test -p ailang-codegen — the in-source codegen test that exercises AllocStrategy::Gc fails to compile once the enum variant is removed; the test is retargeted to Bump inside the same iteration.
  2. cargo test -p ail --test e2e — the pure-differential tests are deleted; the RC-feature tests lose their stdout_gc build call (which calls build_and_run_with_alloc(example, "gc"), which would now panic). Each test is rewritten to drop the gc call.
  3. cargo test -p ail --test embed_staticlib_alloc_guardstaticlib_gc_is_rejected is deleted; staticlib_bump_is_rejected keeps passing.
  4. cargo test -p ailang-core --test docs_honesty_pin — the present-tense Boehm-anchor assertion fails the moment pipeline.md drops 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 containing unknown --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.sh runs 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:

  1. cargo test --workspace --quiet is fully green; the ~12 removed/converted tests are accounted for in the iter commit body; no new failure.
  2. grep -rn "Boehm\|libgc\|GC_malloc\|--alloc=gc\|AllocStrategy::Gc" \ crates/ design/ runtime/ bench/run.sh skills/ returns matches only in crates/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.
  3. bench/check.py, bench/compile_check.py, bench/cross_lang.py all exit 0 against the existing baseline.
  4. The CLI must-fail fixture exits ≠ 0 with the expected stderr wording.
  5. design/models/0004-rc-uniqueness.md and design/models/0003-pipeline.md describe 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.
  6. Issue #4 is closed by the iter commit's closes #4 trailer.

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::Bump rework (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.