Skip to content

steins-syntax, steins-infer: a nested call's argument is lowered and resolved once, so nested calls cost linear rather than exponential time in depth (#1063) - #1064

Draft
zonuexe wants to merge 4 commits into
masterfrom
claude/nested-call-exponential
Draft

zonuexe wants to merge 4 commits into
masterfrom
claude/nested-call-exponential

Conversation

@zonuexe

@zonuexe zonuexe commented Oct 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #1063. Before this change, nested calls cost time exponential in nesting depth. Two independent roots were responsible.

Recursion structure

  • Walk (steins-infer). Cx::resolve_literal_under(c(a)) resolved the argument a from scratch up to three times:

    • in the fold (try_fold_under → fold_resolved_under);
    • in shape_builtin_return_fact's call-subject arm, when the fold declines;
    • in the string-predicate and argument-dispatch legs (transfer_arg_known).

    That made the branching factor 3 per level for builtins and 2 for project functions.

  • Lowering (steins-syntax). For method, static and new calls, lower_arg_value went through lower_argument_list. That function also lowers each argument as a guard, which lowers the inner call a second time, so these grew 2× per level.

Fix

  • cc7e344 (syntax): value-position lowering goes through lower_argument_values, which lowers values only. ArgValue never stored the guard readings. Statement-level CallExpr.arg_conds are unchanged.
  • 78cde6c (infer): the new literal_memo.rs is a thread-local memo that lives for one outermost resolution. Its key is the value's address. An entry is reused only when the value lies inside the root's own value tree, the env, folder and Cx are the root's, and no live Descent is threaded.
    • Stored findings are replayed into the caller's sink.
    • A guard clears the memo on return and on panic.
  • Tests: crates/steins-infer/tests/it/nested_call_depth.rs pins builtin, project-function, method and static calls at depth 200 under a time bound. All four fail on base, and both fixes are needed for them to pass.

Timings (release, --no-php)

shape depth base head
strlen(…) 14 0.77 s 0.05 s
strlen(…) 18 > 60 s 0.05 s
strlen(…) 200 — 1.15 s
project g(…) 22 0.70 s 0.05 s
$o->m(…) 18 1.85 s 0.06 s
new C(…) 22 16.8 s 0.06 s

Identity

  • Synthetic shapes: base and head give byte-identical output for depths 1–14 of strlen, abs, a project function, a self-recursive function, method, static, new and strlen of a variable. This holds for the plain, dumpType and strict_types variants under --no-php, and for depths 1–12 of six shapes with the PHP sidecar on, where the fold runs.
  • Public corpus: 10 packages × strict and default, all 20 byte-identical. Wall time and peak RSS are within noise.

Not covered

Polynomial residue is filed separately: lowering is about cubic in depth, and a self-recursive nested call grows about n^2.7. The private half of the fp-gate is run by the orchestrator before merge.

…ment list without guard readings, so nested method calls lower in polynomial time, not 2^n (#1063)

lower_arg_value of a method call, a static call and a new lowered its
argument list through lower_argument_list, which also reads every
positional argument as a guard. A call argument's guard reading is the
call lowered again (named_call), so each level re-entered both the value
road and the guard road: k = 2 per level, and $o->m($o->m(...)) at depth
22 lowered about 4M argument lists. ArgValue carries no guard readings, so
the value road now lowers values only; the statement-position CallExpr
keeps its arg_conds exactly as before.
…outermost resolution, so nested calls resolve in polynomial time, not k^n (#1063)

Recursion structure, measured with `sample` on strlen nested 17 deep under
--no-php. Cx::resolve_literal_under(c(a)) resolved the argument `a` along
three roads, each from scratch:

- the fold: try_fold_under -> fold_resolved_under -> resolve(a);
- when the fold declines (no runtime, off the allowlist, a project
  function), the transfer rung shape_builtin_return_fact, whose subject arm
  resolves a call subject again: resolve_literal_strat(a);
- the rung's string-predicate and argument-dispatch legs, which read a's
  fact through transfer_arg_known -> resolve(a).

Each re-entry did the same for a's own argument, so the branching factor per
level was k = 3 for a builtin (about 2.7-3x per level measured) and k = 2 for
a project function (the fold gate refuses it, leaving the two rung roads).

A thread-local memo now lives for one outermost resolution and stores an
answer, with the findings its computation emitted, keyed by the value's
address. It is reused only when every input is provably the root's: the
value lies inside the root's own value tree (registered as the resolution
reaches it, so no temporary's address is ever a key), the env, folder and
context are the root's, and no live Descent is threaded (a descent's own
memo would make a recomputation emit fewer findings than a replay). A hit
replays the stored findings into the caller's sink, which is what the
recomputation would have emitted. A guard closes the memo on return and on
unwind.

Findings are byte-identical to the base for strlen, abs, a project function,
a self-recursive function, method, static and new calls at depths 1-14 (with
and without dumpType and strict_types, with and without the PHP sidecar).
strlen nested 18 deep went from over 60 s to 0.05 s.
…ed 200 deep walk inside a deadline (#1063)

Each test runs the check on its own thread with the binary's stack and fails
on a 120 s deadline. All four fail on the base; the method and static shapes
also fail with only the literal-seam memo, since their cost was in lowering.
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.

Nested calls cost time exponential in nesting depth: strlen nested 18 deep does not finish in 60 s

1 participant