Skip to content

fix(auto): stop false-positive generators in effect summarization - #79

Open
MilesCranmerBot wants to merge 11 commits into
MilesCranmer:mainfrom
MilesCranmerBot:pr/auto-fp-generators
Open

MilesCranmerBot wants to merge 11 commits into
MilesCranmer:mainfrom
MilesCranmerBot:pr/auto-fp-generators

Conversation

@MilesCranmerBot

Copy link
Copy Markdown
Contributor

Summary

Dogfooding @safe on real packages (DynamicExpressions, DifferentiationInterface + ForwardDiff) surfaced three false-positive generators in the effect summarizer. Every entry point of DynamicExpressions' public API tripped the checker; after these fixes the same dogfood drivers run clean.

  • Recursive callees poisoned every caller. A re-entrant summary lookup returned nothing, so the conservative "unknown call consumes its owned arguments" fallback fired at the recursion edge, and the poisoned summary was cached. Any recursive function taking a tracked argument (all of DynamicExpressions' tree walks) became a violation generator. Re-entry now contributes no effects (optimistic fixed point); a cycle is also no longer treated as budget exhaustion.
  • Depth exhaustion poisoned too. When summarization hit max_summary_depth, the same fallback fired and cached. Budget-limited calls now fall back to writes instead of consumes: writes require aliasing evidence to violate, so unrelated code stops being flagged while genuine mutations through unanalyzable calls are still caught.
  • Default max_summary_depth 12 → 24. Base's broadcast chain needs ~20 hops to resolve into precise write effects; at 12, detection of broadcast mutation depended on where the budget ran out.

Also updates the DynamicExpressions integration test: the known-broken copy(::Expression) case passes for real now, and the bat() expectation is unchanged (still correctly throws).

Test plan

  • BORROWCHECKER_ONLY_AUTO=1 julia --project=. -e 'using Pkg; Pkg.test()': 224 pass, 0 broken (was 221 pass / 2 broken on main).
  • New regression testset: recursive callee with use-after-call.
  • Threads-plumbing case promoted from @test_broken to @test.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b31d74222c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/auto/summaries.jl Outdated
Comment thread src/auto/summaries.jl Outdated
Comment thread src/auto/checker.jl Outdated
Comment thread src/safe/defs.jl
@MilesCranmerBot

Copy link
Copy Markdown
Contributor Author

Responses to all four comments (dd37a8e):

Fixed point for recursive summaries — implemented. Re-entrant lookups now read the provisional summary published by the previous pass, and _summary_for_tt/_summary_for_mi iterate until stable (cap: 5 passes; effects only grow between passes, so this converges). Your f(x, y, n) permutation example is now a regression testset (recursive summary reaches fixed point across permuted args) and throws as expected. Iteration is gated on an atomic cycle-hit counter so non-recursive functions pay zero extra passes.

Per-callsite budget isolation — implemented. Each statement's effect resolution gets a fresh tracker; one deep chain no longer changes how unrelated statements are treated.

Documented default — updated in both the @safe docstring and the README options table.

Consumes on budget exhaustion — kept as writes, deliberately. Reverting to consume-on-budget is what made every DynamicExpressions entry point unusable before this PR: consume violations do not require aliasing or later-use evidence at the call site (bind barriers create phantom aliases), so unresolved calls deep inside third-party packages flagged unrelated user code. The write fallback preserves mutation detection (e.g. broadcast mutation through views still throws) while requiring alias evidence. The tradeoff you describe \u2014 a genuine escape of an otherwise-unique argument past the depth limit goes undiagnosed \u2014 is real and accepted for now; if it matters in practice, a follow-up could attribute consumes only when the callee summary was cached from a prior full analysis rather than truncated.

@MilesCranmerBot

Copy link
Copy Markdown
Contributor Author

Update on the two open threads (fe98bc7):

1. Consumes on budget exhaustion — resolved via a budget_fallback option (default consume).
The default is now sound exactly as requested: calls whose summary computation hits the depth budget conservatively assume they may move their owned arguments. The budget_fallback = :write setting remains available for linting third-party-heavy code, where unconditional consumes on unresolved calls flag unrelated user code (measured: every DynamicExpressions public entry point tripped under unconditional consumes before the recursion fix; a couple still do at default depth). Documented in the Config docstring, the @safe option list, and the README table.

2. Documented default — already updated in dd37a8e: the @safe docstring (src/auto/frontend.jl) and the README options table both say 24.

Also in this push: fixed-point refinement passes are gated on an atomic cycle-hit counter (non-recursive functions pay zero extra passes) and skip when a pass produces no summary, which resolves the JET SummaryCacheEntry(::Nothing) finding from CI.

@MilesCranmerBot

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fe98bc73d8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/safe/summaries.jl
Comment thread src/safe/summaries.jl
Comment thread src/safe/defs.jl
Remove the 1.13 ceiling from the version gate in src/BorrowChecker.jl and the auto-test gate in test/runtests.jl. Verified on 1.13.0-rc3: full suite passes (227 pass, 2 pre-existing broken) and 1.12.7 remains green.

Link BorrowChecker into test/Project.toml as a relative path source so the test environment resolves on any Julia version.
Post-1.13-rc nightly moved local inference lookup to get_indices(cache, mi) over an InferenceCache. Our BCInterp uses a plain Vector{InferenceResult}, so provide a matching get_indices overload when the compiler defines the function.
Nightly wraps cached edge results in LocalInferenceResult before pushing into the interpreter's local cache, so the cache element type must accept both InferenceResult and LocalInferenceResult.
- Nightly narrows constprop_cache_lookup to InferenceCache; provide a
  mirror implementation for our plain-vector local cache.
- Nightly requires a LineNumberNode argument to
  generated_body_to_codeinfo; pick the applicable method at runtime.
- get_indices and constprop_cache_lookup unwrap LocalInferenceResult
  before reading linfo.
- Use Compiler.proof_worlds for wrapped entries and a real
  LineNumberNode for generated_body_to_codeinfo on nightly.
Nightly indexes cache.results, which only exists on InferenceCache. Provide a BCInterp-specific implementation that scans our plain vector directly.
Port of bless-lockables onto the dissolved-module layout. Locks serialize access without consuming or writing through their arguments: register Base.lock/unlock/trylock/islocked in the builtin effect registry, and model the callback-taking lock(f, l) form as a transparent higher-order call that propagates callback effects (capture writes surface as functor writes) while granting payload writes. Co-authored-by: Miles Cranmer <miles.cranmer@gmail.com>
MilesCranmerBot and others added 4 commits August 23, 2026 13:50
Port of pr/string-tracking. On 1.12+, ismutabletype(String) is true
(memory-based layout), so the generic mutable-type rule misclassifies
strings as owned and flags harmless string forwarding/escapes. Exempt
exactly String and SubString; AbstractString stays tracked so
user-defined mutable string subtypes keep their diagnostics.

Co-authored-by: Miles Cranmer <miles.cranmer@gmail.com>
Restrict compiler-IR checking to Julia 1.12 and 1.13, matching the documented support range. Julia 1.14 and newer now load the warn-and-pass-through stubs, with a regression test covering that contract.

Co-authored-by: Miles Cranmer <miles.cranmer@gmail.com>
…ment

Port of pr/auto-fp-generators onto the dissolved-module layout:
- generator calls no longer poison callers with spurious consumes
- recursive effect summaries refine to a fixed point (bounded passes)
- per-callsite depth budgets are isolated so one deep chain cannot
  change how unrelated statements are treated
- new budget_fallback config (:consume default, :write to soften)

Suite green on 1.13.0-rc3 and 1.12.7.

Co-authored-by: Miles Cranmer <miles.cranmer@gmail.com>

This branch has not been deployed

No deployments
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.

1 participant