Skip to content

Make type check expr loop not recursive#9872

Draft
jaredramirez wants to merge 41 commits into
mainfrom
iterative-checker-prologue-epilogue
Draft

Make type check expr loop not recursive#9872
jaredramirez wants to merge 41 commits into
mainfrom
iterative-checker-prologue-epilogue

Conversation

@jaredramirez

@jaredramirez jaredramirez commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

Converts checkExpr from native recursion to an explicit iterative work-stack driver (checkExprIter), so the common deeply-nested spine can no longer overflow the native stack. Every CIR.Expr kind is now migrated — checkExprRecursive is retained only as the differential-harness baseline (selected by force_recursive).

How it works

  • checkEnterPrologue / checkExitEpilogue extract the per-expression prologue/epilogue into one shared seam (ExprPrologue), used by both drivers.
  • The iterative driver reifies each checkExpr frame onto a check_frame_stack with .enter → resume-step → .exit phases. The block final_expr spine — the common deep case — is walked on this stack with O(1) native depth.
  • Helper-delegating kinds (e_binop, the call family, e_match, …) and interleaving kinds (e_call, e_if, e_for, e_lambda, e_record, e_str, e_interpolation, e_list) carry their cross-child state in per-kind resume steps / CheckKindState.
  • A two-pass differential harness type-checks every test module twice (recursive vs iterative) and asserts identical inferred types and an unchanged diagnostic count.

Migration (bottom → top)

Foundation (driver skeleton, differential harness, deep-nesting stress test, prologue/epilogue extraction) → e_tuple_accesse_block (flatten the block spine) → 22 leaf kinds → single-child kinds → multi-child kinds (list/tuple/tag/call-family) → e_calle_interpolatione_fore_ife_matche_lambdae_recorde_str, then the code-review fixes below.

Latest commit — code-review fixes

  • e_list now interleaves its per-element elem_var unifies (list_after_elem resume step) so element-mismatch diagnostics are emitted in element order, matching the recursive arm.
  • Restored the per-expression tracy span (one per checkExprIter call).
  • Fetch each node's CIR.Expr once per visit (threaded into checkEnterPrologue, reused in .exit) instead of three times.
  • Extracted checkLeafExpr: the literal/leaf bodies live once and both drivers delegate to it (~430 lines of verbatim duplication removed).
  • Hoisted the call-arg / immediate-callee flag re-assertion to one shared site.
  • Removed the dead MAX_CHECK_RECURSION_DEPTH guard (see Known limitation).

Known limitation — deep O(depth) spines

The block final_expr spine is O(1), but the remaining O(depth) spines — helper-delegating kinds and statement-nested blocks — re-enter checkExpr → checkExprIter per level and overflow the native stack at pathological depth (empirically low hundreds of levels). There is intentionally no checker depth guard: a guard can't return cleanly mid-iteration (the driver's .exit state restores are skipped on an error unwind), and parse + canonicalization recurse and SIGSEGV on the same shapes unguarded anyway. Real crash-safety on deep input needs an iterative front-end too; until then this is a documented limit, consistent across the front-end. See deep_nesting_test.zig and the note at the top of Check.zig.

Verification

  • zig build run-test-zig-module-check (incl. the differential harness) → green.
  • Snapshots regenerated (run-snapshot-tool) → zero diffs.
  • run-check-zig-format → clean.

🤖 Generated with Claude Code

@jaredramirez jaredramirez changed the title check: non-recursive type checker — driver skeleton + prologue/epilogue extraction (Task 4a) check: non-recursive type checker Jun 29, 2026
@jaredramirez jaredramirez force-pushed the iterative-checker-prologue-epilogue branch 5 times, most recently from 1eca417 to 9cc8cc5 Compare June 30, 2026 23:17
@jaredramirez jaredramirez self-assigned this Jul 1, 2026
@jaredramirez jaredramirez force-pushed the iterative-checker-prologue-epilogue branch from 9cc8cc5 to c266da9 Compare July 1, 2026 13:42
@jaredramirez jaredramirez reopened this Jul 1, 2026
@jaredramirez jaredramirez force-pushed the iterative-checker-prologue-epilogue branch 2 times, most recently from cadfaa7 to 2862473 Compare July 2, 2026 02:15
jaredramirez and others added 18 commits July 2, 2026 12:53
Reify the checkExpr stack frame into an explicit Self-owned work stack
(allocated at Check init), splitting each node into enter/resume/exit
around the shared prologue/epilogue. Incremental escape-hatch migration
(Approach B), e_block first with a manual review gate before scaling out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation (frame stack + passthrough driver + depth guard), required
two-pass differential harness, deep-nesting stress test, e_tuple_access
warm-up migration, then full e_block + checkBlockStatements flattening.
Stops at the manual review gate before scaling to other expr kinds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate all leaf expression kinds (literals, lookups, crash, ellipsis,
break, anno_only, hosted_lambda, runtime_error, zero_argument_tag) to the
iterative checkExprIter work-stack driver. Each leaf runs its recursive
arm body verbatim in the .exit step (prologue in .enter sets step=.exit
with no children scheduled), reusing the shared epilogue+finish tail.
Recursive arms kept intact as source of truth. Adds focused differential
tests covering each kind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrates e_binop, e_closure, e_dbg, e_expect, e_expect_err, e_field_access,
e_method_eq, e_nominal, e_nominal_external, e_return, e_structural_eq,
e_structural_hash, e_unary_minus, e_unary_not to checkExprIter. Direct-child
kinds schedule their children as work-stack frames; helper-delegating and
per-child-state kinds run their recursive body verbatim under the driver.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… iterative driver

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cumulators)

Convert the e_call apply/record_builder/range path from the escape-hatch
recursive body to a true interleaving migration on the work stack: .enter
schedules the func child, a call_after_func resume step instantiates a
generalized func var and schedules all arg children, and .exit runs the
post-child body verbatim. Adds a per-frame call_arg flag so each func/arg
child re-asserts checking_call_arg at its own prologue. did_err and the
effectful-func does_fx accumulators are reconstructed in the resume/exit
steps. Adds a focused differential test exercising e_call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the recursive e_interpolation arm to the iterative work-stack
driver using the e_block interleave template: add interp_after_first,
interp_after_part_value, and interp_after_part_segment resume steps plus
an interpolation CheckKindState carrying str_var/item_var/first_var/
did_err/part_cursor. Preserves the exact unify order and the per-child
checking_call_arg=true set before each scheduled child. Adds a focused
differential test exercising string interpolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inline checkIteratorForLoop into the iterative work-stack driver as an
interleaving kind: .enter marks the hoist observable effect, checks the
pattern inline, and schedules the iterable child; the for_after_iterable
resume step builds the iterator/next dispatch constraints and records the
for-loop dispatch plan, then schedules the body child; .exit unifies the
loop expr with empty_record. The recursive arm is kept as the source of
truth. Adds a focused differential test for a for-loop over a list.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flatten checkIfElseExpr's recursion spine: schedule each branch cond
and body (and the final else) as work-stack frames with resume steps
(if_after_cond / if_after_body), preserving per-branch hoist-selection
suppression, the branch_acc accumulator, exact unifyInContext contexts
and branch indices, warn_unused_branches warnings, and the pairwise
error-recovery break semantics. The recursive checkIfElseExpr arm stays
intact as the escape-hatch source of truth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Treat checkMatchExpr as a single unit (like checkBlockStatements/
checkMethodCallExpr): add e_match to isMigratedKind, advance to .exit in
the driver's .enter switch, and run the recursive arm's body verbatim in
.exit. checkMatchExpr's child checkExpr calls re-enter the iterative
driver (each its own frame_base), so no native recursion through the
statement-nesting spine is added. Inference results unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the recursive .e_lambda arm to the iterative work-stack driver via
enterLambdaExpr/.exit helpers: arg-pattern checks + annotation rigid/per-arg
unifies run in .enter before scheduling the single body child; the post-body
closeAbsentConstructedPayloadVars + annotation-return unify + return-constraint
processing + function-type construction run in exitLambdaExpr. The
empirical_exhaustiveness_depth guard is saved/zeroed in .enter and restored in
.exit (the recursive defer). The lambda's own does_fx is reset to false (a
lambda value performs no effects; the body's effectfulness is captured in the
function type via mkFuncEffectful).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
jaredramirez and others added 4 commits July 2, 2026 12:53
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Also carries post-main-merge housekeeping that couldn't be cleanly split
without interactive hunk tooling: dev-history comment cleanup across the
check module, and two differential guards for the is_immediate_callee
feature (immediately-invoked vs argument lambda).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the e_str migration, applying fixes from a code review of the non-recursive checker branch:

- e_list: interleave each element's elem_var unify (new list_after_elem resume step) instead of deferring all unifies to .exit, so element-mismatch diagnostics are emitted in element order, matching the recursive arm (mirrors the existing e_str/e_record interleaving).
- Restore the per-expression tracy span (one per checkExprIter call).
- Fetch each node's CIR.Expr once per visit (thread it into checkEnterPrologue; reuse prologue.expr in .exit) instead of three times.
- Extract checkLeafExpr: the literal/leaf bodies now live once and both drivers delegate to it, removing ~430 lines of verbatim duplication and the cross-driver drift risk.
- Hoist the call-arg/immediate-callee flag re-assertion to one shared site.
- Remove the dead MAX_CHECK_RECURSION_DEPTH guard: it never ran in production (every kind is migrated), could not return cleanly mid-iteration (the driver's .exit state restores are skipped on an error unwind), and only covered the checker while parse/canon recurse unguarded. Replaced with a note documenting the deep-nesting limit.

Tests: differential coverage for list mismatch + nested error; a bounded-depth binop stress test.
@jaredramirez jaredramirez force-pushed the iterative-checker-prologue-epilogue branch from 2862473 to 95e26ac Compare July 2, 2026 17:14
@jaredramirez jaredramirez changed the title check: non-recursive type checker Make type check expr loop not recursive Jul 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants