ADR 0088: Direct Core Erlang AST Emission via ETF

Status

Phases 1–4: Rejected (2026-05-28). Phase 0c measured the typed-Document-leaves alternative against the cerl-direct rewrite on the same three functions used in Phase 0a — typed-leaves came in at −8.7% aggregate char shrinkage vs cerl's −9.5% (within 0.8% on the aggregate; typed-leaves wins on 2 of 3 function tiers). With typed-leaves capturing essentially the same shrinkage at ~30× the migration cost ratio, the 58K-LOC cerl-direct migration does not pay back. The recommendation in Phase 0c memo is withdraw ADR 0088 in favour of the typed-leaves refactor as the long-term fix for the BT-875 recurrence vector.

The wire-as-ETF question is Deferred, not rejected. Phase 0b (memo) found ETF encode+decode is only ~2.4–3.4% of per-compile cost — small enough that ETF is viable — and the cerl wire beat the text wire end-to-end by ~7.7–10% on the same fixtures because core_scan + core_parse cost ~4–4.5× more than binary_to_term. If compile time becomes a real bottleneck on larger projects, this win can be reclaimed by a much smaller independent ADR (wire-only, no codegen restructure) — by then the codegen will already be typed-leaves clean and only the wire question remains. Do not re-open ADR 0088 itself; the bundled scope (typed codegen + wire change + 58K LOC migration) is what's rejected.

Phase 0 audit history (all merged):

PhaseMemoPRFinding
0a — Codegen shrinkage audit0088-phase-0a-audit.html#2348~9.5% projected shrinkage by char count — in-between the 5% / 15% gates; qualified
0b — Wire-mechanism napkin0088-phase-0b-napkin.html#2350ETF cost = 2.4–3.4% of per-compile time; pivot-to-Alternative-7 gate does not fire
0c — Typed-leaves comparison0088-phase-0c-typed-leaves.html#2352Typed-leaves shrinkage within 0.8% of cerl on the aggregate; decisive against Phase 1

Decision criteria that fired and why:

What is left intact for downstream work:

Downstream replacement ADR: ADR 0089 turns the Phase 0c recommendation into a buildable plan — the typed-Document-leaves refactor that closes the BT-875 recurrence vector structurally without re-opening the cerl-wire question.


Original Status (now superseded — Proposed, 2026-05-26): Phase 0a + 0b only. Phases 1–4 are explicitly contingent on two decision gates:

BT-aware stack traces are now a committed downstream consumer (see Downstream Consumers), so the annotation-fidelity leg of the justification is no longer hypothetical. The full source-level debugger remains aspirational.

Approval of this ADR authorises Phases 0a + 0b and the data-gathering they enable; it does not pre-authorise the 58K LOC codegen migration.

Context

ADR 0022 established the OTP Port architecture for the embedded compiler: the Rust beamtalk-core runs as a long-running Port subprocess, returns Core Erlang as a binary in an ETF response, and the Erlang side compiles it to BEAM bytecode fully in-memory. The current implementation lives in runtime/apps/beamtalk_compiler/src/beamtalk_compiler_server.erl:

%% beamtalk_compiler_server:compile_core_erlang/1
compile_core_erlang(CoreErlangBin) ->
    CoreErlangStr = binary_to_list(CoreErlangBin),
    case core_scan:string(CoreErlangStr) of
        {ok, Tokens, _} ->
            case core_parse:parse(Tokens) of
                {ok, CoreModule} ->
                    case compile:forms(CoreModule, [from_core, binary, return_errors]) of
                        {ok, ModuleName, Binary} -> {ok, ModuleName, Binary};
                        {ok, ModuleName, Binary, _Warnings} -> {ok, ModuleName, Binary};
                        {error, Errors, _Warnings} -> {error, {core_compile_error, Errors}}
                    end;
                {error, ParseError} -> {error, {core_parse_error, ParseError}}
            end;
        {error, ScanError, _Loc} -> {error, {core_scan_error, ScanError}}
    end.

ADR 0018 established the Wadler-Lindig Document tree as the only sanctioned way to construct Core Erlang output (BT-875 cleanup enforces this — no format!() or string concatenation in codegen). The Document tree is rendered to a Core Erlang text binary that is sent across the Port to the Erlang side.

The serialization tax

The current pipeline encodes the same structural information three times:

  1. Rust codegen builds a Document tree (~58K LOC of typed combinator calls across ~55 source files in crates/beamtalk-core/src/codegen/core_erlang/).
  2. Document renderer flattens the tree to a Core Erlang text binary — pretty-printer machinery (Group, Break, Nest) exists to format text readably, but no human reads the output before it crosses the Port.
  3. Erlang compiler server calls core_scan:string/1 and core_parse:parse/1 to rebuild a structured representation (cerl records) from the text, then passes those records to compile:forms/2 for the rest of the lowering pipeline (kernel → asm → BEAM).

Each compile pays for steps 2 and 3 — building text from structure, then rebuilding structure from text — for no semantic gain. compile:forms/2 accepts cerl records directly (the from_core option). If the Rust side produced cerl-shaped data, encoded it as ETF, and the Erlang side called binary_to_term/1 followed by compile:forms/2, steps 2 and 3 disappear.

What's already in place

Constraints

Decision

Replace text-rendered Core Erlang on the Port wire with ETF-encoded cerl AST terms.

Concretely:

  1. Add a Rust mirror of the cerl record types (c_module, c_fun, c_var, c_literal, c_cons, c_tuple, c_map, c_let, c_letrec, c_case, c_clause, c_apply, c_call, c_primop, c_try, c_catch, c_receive, c_binary, plus annotation wrappers — ~20–30 node types).
  2. Each Rust cerl::* node has an ETF encoder that produces the byte-for-byte equivalent of what term_to_binary/1 would produce on the corresponding Erlang record.
  3. Migrate codegen function return types from Document to cerl::Expr (or the appropriate cerl node type). The Document API itself is a Wadler-Lindig pretty-printer tree — its layout combinators (nest, group, break, line) only make sense when producing text and have no meaning on a typed AST. So the long-term shape is not "Document over cerl leaves" but cerl values composed directly: let_expr(...), case_expr(...), apply(...). A small adapter cerl_to_doc :: cerl::Expr -> Document exists during the transition so a migrated function can be called from a still-text-emitting parent. ADR 0018's discipline (build structured data, never strings; per-fragment unit testability) carries forward; its API surface does not — the Document API is deleted in Phase 4 along with the text wire.
  4. The Port message format gains a cerl response variant alongside the existing core_erlang text variant. The Erlang side decodes ETF, calls compile:forms(Forms, [from_core, binary, return_errors]), and code:load_binary/3 as today.
  5. Migrate codegen functions opportunistically (per ADR 0018's organic-migration model) — each function's return type changes from Document to the corresponding cerl node type. The full behavioural test suite (just test-stdlib, just test-bunit, just test-repl-protocol) verifies behaviour parity at every step; the just codegen-diff BEAM-diff harness runs as a diagnostic, but byte-for-byte BEAM parity is not enforced (see Consequences).
  6. After all codegen has migrated, remove the text path: delete the Document type and its API, delete the cerl_to_doc transitional adapter, and delete the core_scan/core_parse calls in beamtalk_compiler_server and beamtalk_build_worker.

Why not just constrain Document leaves?

The Option B "compiler architect" steelman raises the sharpest counter to this ADR: ADR 0018 already gave us typed codegen, and the BT-875 recurrence vector is the one typed-leaf escape hatch — Document::String(...) accepts arbitrary text. That escape hatch is closable in ~1–2K LOC by replacing Document::String with a sum of typed leaves (Atom, VarName, StringLit, IntLit, ...) and forbidding raw-string construction at module boundaries. If BT-875 elimination is the primary motivation, that change is roughly 30× cheaper than the 58K LOC migration this ADR proposes, and it preserves the Document combinator surface and all 245 unit tests unchanged.

We do not refute that argument — we scope around it. The typed-leaf refactor closes BT-875 but captures none of the other benefits this ADR targets: it does not remove the core_scan/core_parse round-trip, does not enable cerl-node annotation propagation to BEAM bytecode, does not give us the same idiomatic interface LFE uses, and does not eliminate the text/AST boundary at which source positions are lost today. BT-aware stack traces (see Downstream Consumers below) are now a committed near-term feature whose enabling infrastructure requires the cerl wire — they cannot be delivered by the typed-leaf refactor alone. So the contingency in the Status section narrows: the typed-leaf alternative wins only if both (a) the Phase 0a audit shows cerl-direct does not materially shrink codegen, and (b) Phase 0b's napkin timing data shows ETF is dominant cost (pushing toward Alternative 7 anyway). With at least one downstream consumer (stack traces) committed, the annotation-fidelity leg of the justification is no longer hypothetical.

Downstream Consumers

The annotation-fidelity benefit only counts as a real argument for this ADR if scheduled work consumes those annotations. Today:

ConsumerStatusWhat it needs from the cerl wire
BT-aware stack tracesScheduled near-termCerl nodes carrying {file, line} annotations (Phase 1 covers this), debug_info chunks in the compiled BEAM (already true on beamtalk_build_worker; the compiler-server path would need it added), and Beamtalk's AST reliably propagating positions to every codegen call site (separate upstream work, partially complete today). Estimated weeks of work after Phase 1 lands. Payoff hits every runtime error a user sees.
Source-level debuggerAspirationalCerl annotations as above, plus breakpoint resolution (.bt line → BEAM instruction), variable-name remapping (cerl c_var ↔ Beamtalk identifier), step semantics that respect Beamtalk block/closure boundaries, and DAP/int/dbg integration. Multi-month effort; OTP's debugger ecosystem is thin. The cerl wire is necessary but a long way from sufficient.
Dialyzer integrationNot scheduledCerl annotations + Beamtalk-typed specs surfaced through the codegen. Listed as a possibility, not a roadmap item.

The presence of at least one scheduled consumer (stack traces) is what flips the annotation-fidelity argument from hypothetical to material. Both Phase 0a (audit) and Phase 0b (napkin) decisions should weigh stack-trace delivery as a real downstream constraint, not a "nice if it works out" benefit. If the stack-traces roadmap item is descoped, this section should be revisited — the ADR's justification would meaningfully weaken.

What this looks like

Today — codegen produces text, BEAM re-parses it:

// crates/beamtalk-core/src/codegen/core_erlang/expressions.rs (illustrative)
fn gen_call(module: &str, fun: &str, args: Vec<Document>) -> Document {
    docvec![
        "call '", module, "':'", fun, "'(",
        join(args, ", "),
        ")"
    ]
}
%% compile_core_erlang/1 (simplified, error cases elided)
{ok, Tokens, _}        = core_scan:string(binary_to_list(CoreErlangBin)),
{ok, CoreModule}       = core_parse:parse(Tokens),
{ok, Mod, Bin}         = compile:forms(CoreModule, [from_core, binary, return_errors]).

After — codegen produces typed nodes, BEAM consumes them directly:

// crates/beamtalk-core/src/codegen/core_erlang/expressions.rs
fn gen_call(module: Atom, fun: Atom, args: Vec<cerl::Expr>) -> cerl::Expr {
    cerl::Expr::Call {
        module: Box::new(cerl::Expr::Literal(cerl::Literal::Atom(module))),
        name:   Box::new(cerl::Expr::Literal(cerl::Literal::Atom(fun))),
        args,
    }
}
%% compile_core_erlang/1 — new cerl variant (simplified, [safe] decode)
CoreModule     = binary_to_term(CerlEtf, [safe]),
{ok, Mod, Bin} = compile:forms(CoreModule, [from_core, binary, return_errors]).

The Rust-side change is the larger one (return types of every codegen function over time, plus the new cerl::* module). The Erlang-side change is small in code size but is a contract change between beamtalk_compiler_server / beamtalk_build_worker and the Rust compiler — both Erlang call sites migrate in lockstep.

What stays the same

What changes

Prior Art

CompilerApproachNotes
Pharo / Squeak (Smalltalk)Compiler lives entirely inside the image; parsing, codegen, and bytecode installation are all in-process method calls operating on objects. There is no wire format.The "Smalltalk way" (ADR 0022) — no transport at all, because compiler and runtime share an address space. We can't match this directly (our compiler is Rust, our runtime is BEAM), but the principle that the compiler should communicate in structured data, never text, is the same principle.
ElixirBuilds Erlang AST (erl_parse:abstract_form()) directly via quoted forms; never goes through text.Closest precedent for "skip the text serialization entirely." Elixir compiles quoted forms to BEAM in-process via the same compile-module entry points we'd use.
LFECompiles Lisp s-expressions to cerl records directly, calls compile:forms/2.Demonstrates that targeting cerl (rather than Erlang abstract forms) is viable for non-Erlang-syntax languages.
Erlang itselfThe compile module's own pipeline preserves annotations through cerl → kernel → asm → BEAM. Source positions, file/line info, and custom annotations on cerl nodes survive to the final bytecode.Gives us a path to better diagnostics: nodes carrying {file, Line, Col} annotations enable downstream warnings (e.g., from sys_core_fold) to map back to .bt source.
GleamRenders Erlang source text to .erl files, then shells out to erlc.Chose readability/portability over wire optimization. Gleam targets the Erlang source level for end-user readability; we target Core Erlang for codegen simplicity (ADR 0003), so the same trade-off doesn't apply.
rustcBuilds typed HIR/MIR in-memory throughout; never renders to text between phases.Confirms that "structured all the way down" is the modern compiler default. Text emission is a debug/inspection feature, not a transport.

Pattern: Compilers that share a host runtime with their target VM (Pharo in-image, Elixir on BEAM, LFE on BEAM, rustc to its own backends) keep structured data in-memory. Compilers that render text (Gleam, our current pipeline) do so when there's a strong reason — user readability or process isolation. Once ADR 0022 collapsed the daemon/erlc subprocess into an in-VM compile:forms/2 call, the readability rationale stopped applying to us — no human reads the wire bytes. Our process-isolation reason (Port boundary) is independent of the wire format and is preserved.

User Impact

PersonaImpact
NewcomerInvisible. Same language, same REPL output, same .beam artifacts. Marginally faster compile (skips core_scan/core_parse).
Smalltalk developerInvisible. No semantic changes.
Erlang/BEAM developerMildly positive. The compiler now uses the same idiomatic interface (cerl + compile:forms) that LFE and other BEAM languages do. .core debug dumps can still be produced on demand by calling core_pp:format/1 on the cerl term — same output, just opt-in.
Production operatorMarginally positive. Lower per-compile CPU (no scan+parse pass), lower per-compile memory (no intermediate text binary). No new failure modes — the Port boundary and supervision are unchanged.
Compiler contributorNet positive but with adjustment cost. Codegen produces typed nodes instead of text fragments: type errors catch mis-shaped cerl at compile time instead of as core_parse errors at runtime. No string-escape bugs (cerl atoms and strings are values, not text needing escaping). Source-position annotations attach to cerl nodes uniformly. Per-fragment unit testing remains as ADR 0018 enabled it. Adjustments: Contributors must learn the cerl record shapes (well documented in OTP, but a new vocabulary), can no longer grep-debug generated source files directly (need --emit-core to dump pretty-printed text on demand), and unit-test assertions move from assert_eq!(doc.to_pretty_string(), "...") to structural matches on cerl AST values.
Tooling developerPositive. Annotations on cerl nodes flow through to sys_core_fold warnings, dialyzer, and any future source-mapped debugger. Today's text path loses annotations at the core_scan/core_parse boundary unless we re-emit -file/-line directives manually.

Discoverability

No user-facing change. Contributors writing new codegen (post Phase 2) return cerl::Expr values directly — composed using cerl constructors (let_expr(...), case_expr(...), apply(...)) rather than the pretty-printer combinators (docvec!, join, nest) the text path uses today. The cerl::* types are new vocabulary but map 1:1 to OTP's existing cerl module (well-documented, stable across recent OTP versions). During Phase 3, the cerl_to_doc adapter lets migrated functions still embed into unmigrated parents; that adapter is deleted in Phase 4 along with the rest of the Document API.

Steelman Analysis

Option A: Direct cerl Emission via ETF (Recommended)

Option B: Keep Text Emission (Status Quo)

Option C: NIF-based cerl Construction (Embed ERTS into Rust)

Tension Points

Alternatives Considered

1. Keep Text Emission (Status Quo)

Continue rendering Document trees to Core Erlang text, ship text across the Port, decode with core_scan + core_parse on the BEAM side.

Rejected because:

2. Direct Erlang Abstract Forms (erl_parse:abstract_form()) Instead of cerl

Construct Erlang surface-syntax AST in Rust, encode via ETF, call compile:forms/2 without from_core.

Rejected because:

3. Full NIF-based Compiler (Embed Rust Compiler into BEAM)

Use Rustler or hand-rolled NIFs to host the entire Rust compiler in the BEAM VM, constructing cerl terms in the BEAM heap directly and calling compile:forms/2 from inside the NIF or from the calling Erlang process.

Rejected because:

A narrower NIF variant — Port for the compiler, NIF only for cerl-term conversion — is a distinct alternative (see Alternative 7 below) with a meaningfully different risk profile.

4. Direct BEAM Bytecode Emission (Skip cerl Too)

Bypass compile:forms/2 entirely and have Rust produce .beam binaries directly.

Rejected because:

5. Text Wire + Annotation Side-Channel

Keep the text wire format. Add a parallel ETF map shipped alongside the Core Erlang text, mapping {module, function, line} triples in the generated text to source-level {file, line, column} spans. On the Erlang side, after core_parse:parse/1 produces cerl forms, walk the AST and re-attach annotations from the side-channel map.

Rejected because:

6. Hybrid Wire — Text by Default, cerl as an Opt-In

Keep the text path as the default; add a --cerl-wire flag that opts into the cerl-ETF path.

Rejected because:

7. Hybrid Port + NIF-for-cerl-Conversion

Keep the OTP Port boundary for the compiler itself (preserving ADR 0022's crash-isolation guarantee for the long-running compile:forms/2 call), but replace the ETF encode/decode step alone with a NIF that constructs BEAM-heap cerl terms directly from the Rust cerl representation. The NIF does pure data conversion (no compilation work, no scheduler blocking, no side effects); the compiler itself stays on its supervised Port.

Rejected (for now) because:

Consequences

Positive

Negative

Neutral

Implementation

Phase 0a: Codegen Shrinkage Audit (Decision Gate — XS)

Before any wire-format work, measure how much of today's ~38.5K LOC of codegen (excluding tests; ~58K total) is Document pretty-printer ceremony vs. irreducible language-lowering complexity. The hypothesis behind ADR 0088 is that a meaningful fraction of current codegen exists to thread parens, commas, atom-escaping, and indentation through a text-rendering pipeline — work that disappears entirely under typed cerl construction.

Why this gates Phase 0b: if the audit shows cerl-direct shrinks codegen materially (say, ≥15%), the migration's net cost story changes from "58K LOC of churn" to "the migration is itself a simplification that pays back in LOC". If the audit shows ≤5% shrinkage, the typed-Document-leaves alternative (see Why not just constrain Document leaves? above) becomes the right call for the BT-875 vector, and the cerl migration's remaining justification rests entirely on annotation-consumer roadmap (see Downstream Consumers below). Either outcome is decision-useful; the napkin's timing data is necessary but not sufficient on its own.

Phase 0b: Napkin — Empty Module End-to-End (S)

The minimum proof that the wire contract works. Goal: compile a hand-constructed cerl c_module for module 'bt_napkin' [] [] attributes [] end from Rust → ETF → BEAM → loaded module that responds to module_info/0. No codegen migration yet.

This phase is the wire-check AND the timing-contingency check. If it works and ETF cost is a small share of total, Phase 1 proceeds as planned. If it works but ETF cost dominates for large modules, pivot to Alternative 7 (Port + NIF-for-conversion) before committing to the full ETF-direct migration. The commitment to ETF over NIF in this ADR is contingent on the napkin's timing data.

Phase 1: Cerl Rust Mirror — Full Node Set + ETF Encoders (S)

Extend cerl.rs to cover every node used by current codegen:

Unit tests verify each node's ETF output round-trips through binary_to_term/1 + cerl:c_*/n constructors on the Erlang side and equals what core_parse:parse would produce from equivalent text.

Phase 2: Cerl Adapter + Codegen Generator Dispatch (M)

The Document API is text-rendering-shaped and is not generalised over a leaf type — the layout combinators have no meaning on cerl. Instead, this phase adds the seams that let codegen functions migrate one-at-a-time:

Type-system reality check: this is a type-driven migration, not a write!-by-write! migration like ADR 0018's. When a codegen function changes its return type from Document to cerl::Expr, every call site that composes it must either (a) also migrate, or (b) wrap the result in cerl_to_doc(...). The adapter exists specifically to enable (b) during the transition. Without the adapter, Phase 3 would be all-or-nothing.

Phase 3: Per-Function Codegen Migration (Ongoing)

When touching a codegen function for a feature, migrate its return type from Document to the corresponding cerl node type. Behavioural suites verify no regression per PR.

Migration is type-driven, not purely organic. Unlike ADR 0018 (which incrementally replaced write! calls without changing function signatures), this phase changes return types — a Phase 3 migration of a single function touches every caller. Two valid strategies for each PR:

  1. Vertical slice: migrate a leaf function + all its callers that don't compose with non-migrated peers (rare for highly-shared utilities, common for feature-specific codegen).
  2. Adapter-bridged: migrate the leaf function, wrap its result in cerl_to_doc(...) at every caller; clean up the wrappers in a later PR when surrounding code migrates.

Strategy (1) is preferable when feasible — it leaves no cerl_to_doc callbacks. Strategy (2) is the fallback for utility functions used across the codebase.

Approximate order (following the ADR 0018 phase ordering):

SubsystemFilesTriggerStrategy
Leaf utilities (erlang_types.rs, selector_mangler.rs, util.rs)3First — foundationalVertical slice; small caller fan-out
Expressions + intrinsics + operators3Stdlib feature workVertical slice
Control flow (control_flow/)7Block-semantics workVertical slice + adapter at exception boundaries
Primitives (primitives/)17Stdlib class-by-classAdapter-bridged initially; vertical slice once neighbours migrate
Gen-server / actor / supervisor codegen (gen_server/, actor_codegen.rs, supervisor_codegen.rs)~10Actor runtime workAdapter-bridged
Module assembly (class_builder_source.rs, dispatch_codegen.rs, spec_codegen.rs, state_codegen.rs, value_type_codegen.rs)5Metaclass / module-structure workLast (consumes everyone else)
Mod / glueremainingFinal cleanup PRAll-at-once

Phase 4: Text Path Removal (S)

When ≤50 text-leaf Document call sites remain (same threshold ADR 0018 used), schedule a dedicated cleanup PR:

Affected Components

Migration Health Checks

Verification

Migration Path

This is an internal refactor — no user-facing migration needed. Beamtalk source code (.bt files) is unaffected. Generated .beam artifacts are behaviourally identical (verified by the full behavioural test suite with both wire formats during transition); their bytes may differ in annotation chunks and chunk ordering, per the Consequences section. The just codegen-diff harness surfaces unexpected divergence as a diagnostic, not as a parity invariant.

For compiler contributors:

References

Appendix A: Smoke-Test Audit Data

A two-file informal audit was performed during ADR review to sanity-check the Phase 0a hypothesis that cerl-direct shrinks codegen materially. This is not a substitute for Phase 0a — it's a lower-bound signal on whether running the real audit is worthwhile. Phase 0a must still rewrite three representative functions (including one from control_flow/mod.rs) and project across all ~55 files before any Phase 1 commitment.

Files sampled

FileTotal LOCNon-test LOCWhy chosen
util.rs449~250Leaf utility — escape helpers, attribute fragment builders
operators.rs201~150Small medium-complexity — binary ops, power, concat with runtime type dispatch

util.rs findings

Lines that evaporate entirely under cerl-direct (not just simplify):

ConstructLOC affectedWhy it disappears
escape_core_erlang_string + its 4 unit tests~25ETF encodes strings as length+bytes; no \" escaping needed
escape_atom_chars + its 3 unit tests~35ETF encodes atoms via ATOM_EXT; no \' escaping
Text plumbing in beamtalk_class_attribute~12 of 18Becomes a structured c_module.attrs.push(c_tuple([c_atom(name), c_atom(super)]))
Text plumbing in file_attr and source_path_attr~20 of 26Same — structured attribute constructors
capture_expression (renders to string for interpolation)~5Exists only because some callers want text; mostly deletable

Estimated shrinkage: ~95 LOC of 449 ≈ 21% (or ~38% of non-test code).

operators.rs findings

Estimated shrinkage: ~12 LOC of 201 ≈ 6%.

Combined and caveats

Combined: ~107 LOC of 650 ≈ 16% — right at the ADR's 15% threshold.

Sample biases to be aware of when reading this number:

What the smoke test does and does not say

Says: A meaningful fraction (≈15–20%) of codegen LOC is text-rendering ceremony, not language-lowering logic. The hypothesis underlying Phase 0a is plausible and the real audit is worth running.

Does not say: That the migration is justified. The smoke-test sample is small and skewed toward a leaf-utility file. A real Phase 0a must include control_flow/mod.rs (or similar high-complexity file) before any Phase 1 decision — that's where the strongest evidence either way will come from.