Files
AILang/docs/journals/2026-05-13-audit-24.md
T
Brummel 71dec143d9 audit-24: milestone close (Show + print rewire) drift report + 24.tidy queue
5 actionable drift items route to 24.tidy (3× [high] + 2× [medium]).
1× [medium] (negative-test coverage breadth) defers to roadmap P3.
1× [low] (bench sweep noise on pre-mq24 DESIGN.md lines) is carry-on.

Bench: 9th consecutive observation of metric-identity-migrating noise
envelope on latency.*_at_rc.* + bench_list_sum.bump_s + check_ms.*
clusters; baseline pristine per conservative-call convention. The
right ratification path is the queued P3 latency-histogram
methodology rework, not --update-baseline.
2026-05-13 04:13:16 +02:00

12 KiB

audit-24 — milestone close (Show + print rewire)

Date: 2026-05-13 Milestone: Show + print rewire (number-24 family) Iters covered: 24.1 (f38bad8, shipped 2026-05-12) → 24.2 (3286117) → 24.3 (246b5c7) Range from previous close: 0dcdaab..0cfb3f6 (audit-ct-tidy → iter-24.3 INDEX) Status: drift_found — 24.tidy queued

Summary

The Show + print milestone shipped in three iters: 24.1 installed bool_to_str + str_clone runtime + codegen primitives at heap-Str ABI level (pre-mq audit lineage); 24.2 added class Show + four primitive instances (Int/Bool/Str/Float) to the prelude and migrated the 22b user-Show fixture corpus to TShow/tshow to eliminate post-prelude.Show ambiguity; 24.3 added fn print with explicit-let body, three E2E fixtures (positive 4-prim, user-ADT, negative NoInstance), an IR-shape pin on print__Int.body, a Show-aware NoInstance diagnostic, plus three compiler-path repairs surfaced by the user-ADT E2E (canonical-form normalisation of MonoTarget::FreeFn::type_args, codegen cross-module reference fallback, FreeFnCall constraint-residual push).

Architect drift review surfaces 7 items: three [high] (missing DESIGN.md documentation of the three iter-24.3 strengthenings: canonical-form type_args, post-mono cross-module-ref invariant, FreeFnCall constraint- residual push; missing codegen-level pin for the import_map-fallback path; constraint-residual push currently fires only in the dot-qualified FreeFnCall branch), three [medium] (unwrap_or_default in the method-name lookup masks class-index drift; normalize_type_for_lookup duplicated across two mono.rs sites with manual-lockstep invariant; negative-test coverage is single-shape — only f : Int -> Int, not user-type-without-instance), and one [low] (bench architect_sweeps.sh noise on three pre-milestone-24 DESIGN.md lines — not new drift).

Bench: bench/check.py exit 1 with 2 regressed / 1 improved metrics (metric identities migrate between runs — first run hit latency.implicit_at_rc.max_us +209%, second run hit latency.explicit_at_rc.max_us +66% + bench_list_sum.bump_s +11.9%). bench/compile_check.py exit 1 with 3-5 regressed metrics across runs (metric identities migrate: first run hit build_O0_ms.* cluster within tolerance, second run hit check_ms.* cluster with several +25-34% deltas). bench/cross_lang.py exit 0, 25/25 stable. 9th consecutive audit observation of the documented noise envelope covering latency.implicit_at_rc.* / latency.explicit_at_rc.* / bench_list_sum.bump_s / check_ms.* metric-cluster migration across consecutive runs. Conservative-call convention has held the baseline pristine across 8 prior audits since audit-cma; this audit extends the lineage to 9 with the same conservative call — the metric-migration-between-runs is itself attribution evidence that the signal is variance, not regression.

Resolution path: 24.tidy iteration covers 5 actionable drift items (three [high] + two [medium] documentable/refactorable). The [medium] "negative-test coverage single-shape" item moves to the roadmap P3 backlog. The [low] bench_sweep noise is carry-on.

Architect drift items

[high-1] Three iter-24.3 strengthenings undocumented in DESIGN.md

Paths: crates/ailang-check/src/mono.rs:687-696 + :1287-1294, crates/ailang-codegen/src/lib.rs:1980-2000 + :2200-2244 + :2776-2816, crates/ailang-check/src/lib.rs:2820-2865, docs/DESIGN.md §"Monomorphisation" or new subsection.

Iter 24.3 silently strengthened three load-bearing mono / codegen / synth invariants without documenting them in DESIGN.md:

  • (a) MonoTarget::FreeFn::type_args carries canonical (<owner>.<bare>) types post-collection via normalize_type_for_lookup. Pre-iter invariant was "subst.apply result"; new invariant is "subst.apply result normalised to registry-lookup form".
  • (b) Post-mono synthesised bodies may carry cross-module references to modules their source template didn't import. Codegen falls back to direct module_user_fns / module_def_ail_types lookup when the prefix isn't in the current module's import_map.
  • (c) FreeFnCall synth pushes one ResidualConstraint per declared forall-constraint with rigid vars substituted by fresh metavars. Pre-iter, the arm had constraints: _ (constraints ignored); latent for milestone-23 fixtures because all primitive instances discharged silently.

Future planner / implementer work that touches these surfaces will not see the strengthenings without a doc anchor.

Outcome: fix in 24.tidy (doc-only).

[high-2] Codegen import_map fallback path has no unit-level pin

Path: crates/ailang-codegen/src/lib.rs:2200-2244 + :1980-2000 + :2776-2816.

The three cross-module-fallback arms (resolve_top_level_fn, lower_app cross-module arm, synth_with_extras Var arm) introduce a new dispatch path: try import_map, fall back to direct module_user_fns / module_def_ail_types lookup. The fallback is only justified by post-mono synthesised cross-module references. No codegen-level unit test pins the failure mode; the only test that would catch a regression is the E2E show_user_adt.ail.json. If a future refactor tightens resolve_top_level_fn back to import_map-only, the regression surfaces only at E2E (slow to bisect).

Outcome: fix in 24.tidy — add a unit pin asserting the fallback resolves for a synthesised prelude.print__T<user_module>.show__T pair without going through import_map.

[high-3] FreeFnCall constraint-residual push covers only dot-qualified branch

Path: crates/ailang-check/src/lib.rs:2820-2865.

The constraint-residual push happens only in the dot-qualified prefix.suffix synth branch (the only branch that populates free_fn_owner = Some(...)). Bare-name references to polymorphic free fns that resolve through other branches (locals shadow, env.module_globals direct hit at the current module, etc.) do not push residuals for declared constraints. For prelude poly fns this is fine because the auto-injected prelude call-path always lands in the dot-qualified branch — but the invariant is implicit and undocumented. The same "silent unknown variable from codegen" failure mode existed pre-iter-24.3 for any milestone-23 negative case (ne on a type without Eq instance, etc.); the iter-24.3 fix covers Show only because that's what the negative E2E exercised.

Outcome: fix in 24.tidy — generalise the constraint-residual push to all FreeFnCall synth branches, OR document the invariant "poly free fns always reach synth via the dot-qualified branch" and pin it with a unit test.

[medium-1] unwrap_or_default in method-name lookup masks class-index drift

Path: crates/ailang-check/src/lib.rs:2852-2858.

Method-name lookup in the FreeFnCall constraint-residual push uses unwrap_or_default() (empty string fallback) when class_methods has no entry for the residual class. A user constraint referencing a class that does not exist would silently render NoInstance with an empty method: field. The iter-22 MissingClass pre-pass plausibly rejects this earlier, but the fallback masks the dependency.

Outcome: fix in 24.tidy — replace with explicit Internal or expect(...) to surface registry/index drift on regression.

[medium-2] normalize_type_for_lookup duplicated across two sites

Path: crates/ailang-check/src/mono.rs:687-696 + :1287-1294.

The two normalize_type_for_lookup call sites use a literal copy of the body comment rather than extracting a helper. The two sites must agree byte-identically (per iter-24.3 journal: "Must agree byte- identically with that site or Phase 3 rewrite cursor produces a mono- symbol name that differs from the Phase 2 synthesis name"). A normalize_free_fn_type_args(env, module_name, metas, subst) helper would make the lockstep enforceable by construction.

Outcome: fix in 24.tidy — extract helper.

[medium-3] Negative-test coverage single-shape (deferred)

Paths: examples/show_no_instance.ail.json and the broader NoInstance Show test surface.

The negative E2E covers f : Int -> Int (function type without Show instance). Other NoInstance shapes — user type without instance, partial-application residual carrying both a missing constraint AND a NoInstance, polymorphic call with two unsatisfied constraints — are not pinned. The Show-aware NoInstance arm fires for any non-prelude-Show type by construction, so the single fixture is structurally representative, but coverage breadth is thin.

Outcome: roadmap backlog P3 (not in 24.tidy). Single-fixture coverage is acceptable for the architecture-shipping milestone; broader coverage waits for the corpus-migration milestone (the queued P2 retirement of io/print_int|bool|float) when more downstream fixtures naturally exercise these shapes.

[low-1] bench/architect_sweeps.sh noise on pre-milestone-24 DESIGN.md lines

Path: bench/architect_sweeps.sh matches on docs/DESIGN.md:50, :449, :614.

All three matched lines predate milestone 24 (text not touched in the milestone-24 diff). Legitimate references or empirical-addendum headings, not new history anchors. Sweep noise from prior eras.

Outcome: carry-on (not a milestone-24 drift item).

Bench

  • bench/check.py: exit 1 with metric identities migrating between consecutive runs:
    • Run 1: 2 regressed (latency.implicit_at_rc.max_us +209%, bench_list_sum.bump_s +11.9%), 1 improved (bench_list_sum.gc_over_bump).
    • Run 2: 2 regressed (latency.explicit_at_rc.max_us +66%, bench_list_sum.bump_s +11.9% again), 1 improved (different metric).
    • Pattern: the latency.*_at_rc.max_us metric cluster shifts identity between runs; bench_list_sum.bump_s is the 5th consecutive sighting since audit-mq.tidy. Same envelope the iter-24.3 journal documented as the 8th consecutive observation; this audit makes it 9.
  • bench/compile_check.py: exit 1 with 3-5 regressed metrics across runs; metric identity migrates between build_O0_ms.* cluster (run
    1. and check_ms.* cluster (run 2). All deltas are +5% to +34% across runs; tolerance is 20-25% so multiple check_ms.* deltas cross threshold sporadically. Same migration pattern.
  • bench/cross_lang.py: exit 0; 25/25 stable.

Conservative call: baseline pristine for the 9th consecutive audit. The metric-identity-migration is itself attribution evidence that the signal is variance, not regression. If signal were present, the metric would not migrate between runs at this rate. Ratifying via --update-baseline would pin a snapshot that does not characterise the actual distribution (since the noise migrates, any single snapshot is wrong). The right ratification path is the queued P3 "Latency methodology rework — switch from per-run timing to a histogram-based approach"; until that ships, conservative-call is the correct response.

No --update-baseline invocation this audit.

Resolution

  • 24.tidy iter to land 5 drift fixes: [high-1] (DESIGN.md documentation of the three iter-24.3 strengthenings), [high-2] (codegen import_map-fallback pin), [high-3] (FreeFnCall constraint-residual push generalisation OR documented invariant), [medium-1] (replace unwrap_or_default with explicit error), [medium-2] (extract normalize_free_fn_type_args helper). Routes through plannerimplement.
  • [medium-3] (negative-test coverage single-shape): roadmap backlog P3.
  • [low-1] (bench sweep noise): carry-on, not actionable.
  • Bench: extend lineage to 9th consecutive observation. Pristine baseline. Document for future histogram-methodology landing.

Files touched

  • docs/journals/2026-05-13-audit-24.md (new, this file)
  • docs/journals/INDEX.md (one new line)

No production code or test edits in this audit; all recommendations land in 24.tidy.