ADR 0088 Phase 0a Audit: Codegen Shrinkage Measurement

Date: 2026-05-28 Issue: BT-2314 (part of epic BT-2313) Evaluator: 3-function rewrite + projection across 58 codegen files (49,351 non-test LOC) Status: Recommendation — Qualified: in-between zone (≈10–12% projected shrinkage), neither clear-proceed nor clear-withdraw.

Executive Summary

The audit rewrote 3 representative Document-based codegen functions on a throwaway cerl::* Rust AST mirror and measured the size delta. Aggregate shrinkage lands at ~11% by non-whitespace character count and 12–30% by non-blank LOC (LOC has higher variance because rustfmt collapses multi-line constructor calls):

TierFunctionBody LOCChar count
Leaf utilitybeamtalk_class_attribute17 → 15 (−12%)275 → 279 (+1.5%)
MediumLogger metadata-map + log-call25 → 20 (−20%)460 → 411 (−11%)
High-complexityThreadingPlan::generate_pack_prefix33 → 23 (−30%)876 → 776 (−11%)

By character count (the more honest of the two metrics — see Methodology), all three rewrites cluster near 11%. The leaf utility is a wash (the ceremony shifts from atom-quote punctuation to tuple(vec![atom(...), ...]) Rust verbosity); the medium and high-complexity rewrites both deliver ~11% shrinkage. The high-complexity LOC win of 30% reflects rustfmt's ability to collapse short cerl::* constructor chains onto single lines that the original's multi-element docvec! calls could not — real but partly cosmetic.

This sits below the ADR's 15% "proceed unconditionally" gate but above the 5% "withdraw" gate.

Recommendation: This is the qualified middle. The audit does not support proceeding with Phase 1 on simplification grounds alone; the per-line savings exist but are modest and converge to ~11% regardless of function complexity — not the "bigger functions benefit more" curve the ADR's most optimistic scenario predicts. Before committing to Phase 1, gather Phase 0b data (ETF encode/decode cost) and explicitly compare against the typed-Document-leaves alternative on the same three functions. See §"What would shift the recommendation" below.

Methodology

What was rewritten

Three functions, one per size tier mandated by the ADR's "real audit" requirement (Appendix A used a biased 2-file sample of util.rs-like code; the audit had to include the 4.2K-LOC control_flow/mod.rs hot spot):

  1. Leaf utility (util.rs): beamtalk_class_attribute — builds the 'beamtalk_class' = [{Name, Super}, ...] module attribute fragment. Pure leaf, no generator state, almost all atom-quote ceremony.

  2. Medium (intrinsics.rs): the metadata-map + logger:log/3 call fragments inside try_generate_logger_intrinsic. Pulled the two map/call fragments out of the 115-LOC parent function — the rest of that function (selector parsing, arg hoisting, dispatch fall-through) is orthogonal to the Document/cerl choice and would look identical in both designs.

  3. High-complexity (control_flow/mod.rs): ThreadingPlan::generate_pack_prefix, the function that emits the maps:put-chain prefix that loads a StateAcc map for stateful loops. Body has 3 control-flow branches (early-return, ValueType vs. actor context, threaded-locals loop), each emitting let-chains. Pulled out of control_flow/mod.rs (4,237 LOC, the hot spot Appendix A explicitly flagged as missing).

Rewrite location

All rewrites + a minimal cerl::* mirror + the original functions (verbatim copies, isolated from CoreErlangGenerator so they need no compiler instance) live in crates/beamtalk-core/src/codegen/core_erlang/cerl_audit.rs. The file is #[cfg(test)]-gated so it ships no production code, and is to be deleted when the gate decision lands. The minimal cerl::* mirror has 12 node kinds (Atom, Var, Int, Map, Tuple, List, Cons, Call, Let, LetOpen, Seq, Raw) — a small fraction of the 20–30 the real ADR-0088 Phase 1 would need.

Semantic equivalence check

Each rewrite is paired with the verbatim original; tests render both to text via to_pretty_string() and assert byte-equality on a representative input matrix:

All 9 tests pass:

test codegen::core_erlang::cerl_audit::tests::beamtalk_class_attribute_empty_matches ... ok
test codegen::core_erlang::cerl_audit::tests::beamtalk_class_attribute_single_class_matches ... ok
test codegen::core_erlang::cerl_audit::tests::beamtalk_class_attribute_multi_class_matches ... ok
test codegen::core_erlang::cerl_audit::tests::logger_log_call_matches ... ok
test codegen::core_erlang::cerl_audit::tests::logger_log_call_various_levels_match ... ok
test codegen::core_erlang::cerl_audit::tests::pack_prefix_empty_locals_matches ... ok
test codegen::core_erlang::cerl_audit::tests::pack_prefix_actor_context_matches ... ok
test codegen::core_erlang::cerl_audit::tests::pack_prefix_value_type_context_matches ... ok
test codegen::core_erlang::cerl_audit::tests::pack_prefix_direct_params_returns_nil ... ok

Measurement rules

Per-function comparisons

Rewrite 1: Leaf utility — beamtalk_class_attribute

Original (verbatim, in cerl_audit.rs as beamtalk_class_attribute_original):

pub fn beamtalk_class_attribute_original(classes: &[(String, String)]) -> Document<'static> {
    if classes.is_empty() {
        return Document::Nil;
    }
    let entries = classes.iter().map(|(name, superclass)| {
        docvec![
            "{'",
            Document::String(name.clone()),
            "', '",
            Document::String(superclass.clone()),
            "'}"
        ]
    });
    docvec![
        ",\n     'beamtalk_class' = [",
        join(entries, &Document::Str(", ")),
        "]"
    ]
}

Rewrite (beamtalk_class_attribute_cerl):

pub fn beamtalk_class_attribute_cerl(classes: &[(String, String)]) -> Document<'static> {
    use cerl::*;
    if classes.is_empty() {
        return Document::Nil;
    }
    let entries: Vec<_> = classes
        .iter()
        .map(|(name, superclass)| tuple(vec![atom(name.clone()), atom(superclass.clone())]).to_doc())
        .collect();
    docvec![
        ",\n     'beamtalk_class' = [",
        join(entries, &Document::Str(", ")),
        "]",
    ]
}
MetricOriginalRewriteΔ
Body LOC (non-blank)1715−12%
Char count (no whitespace)275279+1.5%
Branching (cyclomatic)220
Helper calls56 (+1 tuple)+1

Reading: The LOC win is real (the "{'", name, "', '", superclass, "'}" five-part atom dance collapses to tuple(vec![atom(name), atom(superclass)])). But the character count is slightly worse — the Rust verbosity (tuple, vec!, .iter().map().collect::<Vec<_>>() plus .to_doc() because the surrounding attribute glue is still text) eats the savings. The leading ",\n " and "'beamtalk_class' = [..." glue is not Core Erlang — it's attribute-list plumbing one level up — so cerl-direct cannot remove it. In a function this small, ceremony shifts; it doesn't shrink.

Rewrite 2: Medium — Logger metadata-map + log-call fragments

Original (verbatim, no-metadata branch of try_generate_logger_intrinsic):

let metadata_map_doc = docvec![
    "~{",
    "'domain' => ['beamtalk' | ['user']], ",
    "'beamtalk_class' => '",
    Document::String(ctx_class.to_string()),
    "', ",
    "'beamtalk_selector' => '",
    Document::String(ctx_selector.to_string()),
    "'",
    "}~",
];
docvec![
    "call 'logger':'log'('",
    Document::String(level.to_string()),
    "', ",
    Document::String(msg_doc_text.to_string()),
    ", ",
    metadata_map_doc,
    ")"
]

Rewrite (logger_log_call_cerl):

pub fn logger_log_call_cerl(level: &str, msg_doc_text: &str, ctx_class: &str, ctx_selector: &str)
    -> Document<'static>
{
    use cerl::*;
    let metadata = map(vec![
        (atom("domain"), cons(atom("beamtalk"), Expr::List(vec![atom("user")]))),
        (atom("beamtalk_class"), atom(ctx_class)),
        (atom("beamtalk_selector"), atom(ctx_selector)),
    ]);
    let log_call = call(
        "logger", "log",
        vec![atom(level), Expr::Raw(msg_doc_text.to_string()), metadata],
    );
    log_call.to_doc()
}
MetricOriginalRewriteΔ
Body LOC (non-blank)2520−20%
Char count (no whitespace)460411−10.7%
Branching (cyclomatic)110
Helper calls614+8

Reading: This is closer to the ADR's optimistic prediction. The metadata map (a 9-element punctuation tower in the original) collapses to a 4-element Vec<(Expr, Expr)>. The call 'logger':'log'('debug', ..., ...) ceremony collapses to call("logger", "log", vec![atom("debug"), ..., ...]). Char count drops ~11% even after accounting for cerl::* import boilerplate. The helper-call count increases because cerl-direct expresses structure with many small constructors — but those constructors carry zero punctuation, so the net per-character density is much lower.

Rewrite 3: High-complexity — ThreadingPlan::generate_pack_prefix

Original (excerpt — the per-iteration maps:put let):

pack_docs.push(docvec![
    "let ",
    Document::String(packed_var.clone()),
    " = call 'maps':'put'('",
    Document::String(key),
    "', ",
    Document::String(core_var),
    ", ",
    Document::String(current),
    ") in ",
]);

Rewrite (generate_pack_prefix_cerl — same iteration):

pack_exprs.push(let_open(
    packed_var.clone(),
    call("maps", "put", vec![atom(key), var(core_var), var(current)]),
));

Full-function metrics:

MetricOriginalRewriteΔ
Body LOC (non-blank)3323−30%
Char count (no whitespace)876776−11.4%
Branching (cyclomatic)4 (if/match/for + early-return)40
Helper calls17 (2 docvec + 14 Doc::String + 1 Doc::Vec)9−8

Reading — the most important data point of the audit: Per maps:put emission, cerl-direct saves 6 of 9 lines (the punctuation lines) inside the loop body — a 67% per-call shrinkage. But that loop is only 9 of the 33 original-function lines; the surrounding control flow (early-return, ValueType branch, fresh-temp generation, vector building, threading state through current) is unchanged.

The 30% LOC headline overstates the picture because rustfmt collapses the call("maps", "put", vec![atom(key), var(core_var), var(current)]) chain onto a single line where the original docvec![ "let ", Document::String(...), " = call 'maps':'put'('", Document::String(key), ..., ") in ", ] ran 9 multi-line lines. The honest measurement is the −11% character count, which is consistent across the medium and high-complexity rewrites.

The audit's key finding: per-line savings are real but ceiling-bound by the proportion of a function that is pure-emission vs. control-flow. generate_pack_prefix is roughly 50/50, and it shrinks ~11% by chars. Functions dominated by emission (logger fragment) shrink in the same ballpark. Functions dominated by control flow (most of control_flow/mod.rs, which is largely analysis + threading-plan construction, not emission) will shrink less.

Projection across the codegen

The codegen layer is 49,351 non-test LOC across 58 files. Projection uses the character-count basis (~11% per emission-heavy fragment) rather than the LOC basis, because raw LOC mixes real shrinkage with rustfmt-collapse noise. Naive per-file shrinkage by Document::* / docvec! ref density:

FileLOCDoc refsRefs/LOCEst. tier
control_flow/mod.rs4,2373147.4%high-complex
gen_server/methods.rs3,8613559.2%medium-high
dispatch_codegen.rs3,3262647.9%medium-high
expressions.rs3,09734811.2%medium
value_type_codegen.rs3,0632909.5%medium-high
intrinsics.rs2,3651817.7%medium
control_flow/list_ops/transform_ops.rs2,20551023.1%emission-heavy
gen_server/callbacks.rs1,60816810.4%medium
Other 50 files25,589~1,559~6.1%mostly low-medium
Total non-test49,3513,9898.1%

Linking these density buckets to the per-function shrinkage tiers measured above:

Projected total codegen shrinkage: ~3,100–4,500 LOC, or 6–9% of 49K LOC.

This is below the 15% "proceed unconditionally" threshold the ADR sets. It is above the 5% "withdraw" threshold.

Caveats and bias correction

Recommendation against the ADR's gates

The ADR 0088 (proposed) Status section sets three gates:

  • Phase 0a ≥15% projected shrinkage → migration justified on simplification grounds alone
  • Phase 0a ≤5% shrinkage → typed-Document-leaves alternative likely wins; ADR may be withdrawn
  • Between 5% and 15% → qualified recommendation explaining what additional data would decide

The audit lands in the between zone:

What would shift the recommendation

Toward proceed (Phase 0b critical): The ADR's strongest remaining argument after this audit is the wire-cost argument (Phase 0b). If ETF encode/decode is a small fraction of per-compile cost (the ADR's hopeful case), cerl-direct unlocks compile-time wins independent of LOC shrinkage. If Phase 0b lands favourably, the modest codegen shrinkage measured here is sufficient justification to proceed — because the simplification is a side benefit, not the primary motivation.

Toward withdraw (typed-Document-leaves alternative): The same three rewrites should be done with typed-Document-leaves (Atom | VarName | StringLit | … replacing Document::String for known kinds, keeping the rest of Document intact). If typed-leaves achieves ≥60% of the cerl-direct shrinkage measured here at ~1/30th the LOC cost (the ADR's own counter-argument estimate), withdrawal in favour of the cheaper refactor is justified — modest codegen shrinkage does not buy the 58K-LOC migration the ADR proposes. This is the single highest-value follow-up experiment.

Toward pivot (Alternative 7, Port+NIF): If Phase 0b shows ETF dominates per-compile cost, the ADR should pivot to NIF-for-conversion before any Phase 1 work — the codegen shrinkage measured here is too small to justify the migration on its own.

Net recommendation

Qualified: do not proceed with Phase 1 yet.

  1. Run Phase 0b (wire-cost napkin) — this remains in the epic and is now load-bearing for the decision.
  2. Add a Phase 0c: typed-Document-leaves rewrite of the same three audit subjects. The ADR's own typed-leaves counter-argument is untested — this audit's data makes it more important, not less, to know how the alternative measures up against cerl-direct at the same three functions.
  3. Decide Phase 1 only after both Phase 0b and Phase 0c data are in.

The audit specifically does not support the ADR's Phase 1 plan on the simplification dimension alone. Shrinkage is real but plateaus at ~11% by characters regardless of function complexity — it does not grow proportionally with the emission-heaviness of the file, so the largest files (where migration cost is highest) gain proportionally no more than small leaf utilities. The cheaper typed-leaves alternative is now a serious contender and must be measured before commitment.

No regressions

Files added/modified

References