Skip to content

fix: reach threw a MethodError on sum(f(x) for x in xs) instead of returning a verdict - #29

Merged
sotashimozono merged 1 commit into
mainfrom
fix/reach-threw-instead-of-answering
Sep 9, 2026
Merged

fix: reach threw a MethodError on sum(f(x) for x in xs) instead of returning a verdict#29
sotashimozono merged 1 commit into
mainfrom
fix/reach-threw-instead-of-answering

Conversation

@sotashimozono

Copy link
Copy Markdown
Member

Found while writing a worked example that actually runs — not by reading the code.

reach is specified to be three-valued: :depends, :clean, and :unknown for a call site it
could not pin to a method. It had a fourth outcome.

reach(f, Tuple{Vector{Float64}})
# ERROR: MethodError: no method matching nameof(::Base.MappingRF{…})

Cause

sum(f(x) for x in xs) lowers to a Base.MappingRF holding the generator closure and a reducing
function. Both fields are singletons, so the struct itself is a singletonisdefined(w, :instance) is true for a callable that is neither a Function nor a Type, and _callee_name
handed it straight to nameof, which has no method for that.

The value is now checked against the three things nameof accepts, falling back to the :? the
function already used for "cannot name this callee".

This is the idiom the package's own @entered docstring uses as its worked example
(driver(x, n) = sum(inner(x) for _ in 1:n)) — so the documented example function could not be
analysed by the documented analysis.

It was masking correct answers, not only crashing

caller before after
sum(unstable(x) for x in xs) threw :depends
sum(solid(x) for x in xs) threw :clean
[unstable(x) for x in xs] :unknown :depends
sum(map(unstable, xs)) :unknown :depends
a plain for loop :clean :clean

[solid(x) for x in xs] stays :unknown. That asymmetry is the design, not a remaining gap:
:depends needs one witness, :clean needs the whole graph resolved.

Verification

The new spec testset was run against the reverted fix: 4 failures with it out, 0 with it in.

187 behaviours, 1215 assertions, green.

🤖 Generated with Claude Code

…returning a verdict

Found while writing a worked example that runs: `reach` is specified to be three-valued —
`:depends`, `:clean`, `:unknown`, where the third is the honest non-answer for a call site it
could not pin down. It had a fourth outcome.

    reach(f, Tuple{Vector{Float64}})
    # ERROR: MethodError: no method matching nameof(::Base.MappingRF{…})

`sum(f(x) for x in xs)` lowers to a `Base.MappingRF` holding the generator closure and a reducing
function. Both fields are singletons, which makes the struct itself a singleton — so
`isdefined(w, :instance)` is true for a callable that is neither a `Function` nor a `Type`, and
`_callee_name` handed it to `nameof`, which has no method for that. The value is now checked
against the three things `nameof` accepts before being asked for a name, and falls back to the
`:?` the function already used for "cannot name this callee".

This is the idiom the package's own `@entered` docstring uses as its worked example
(`driver(x, n) = sum(inner(x) for _ in 1:n)`), so the documented example function could not be
analysed by the documented analysis.

The throw was also masking correct answers, not only crashing. Measured across the higher-order
shapes, before and after:

| caller | before | after |
|---|---|---|
| `sum(unstable(x) for x in xs)` | **threw** | `:depends` |
| `sum(solid(x) for x in xs)` | **threw** | `:clean` |
| `[unstable(x) for x in xs]` | `:unknown` | `:depends` |
| `sum(map(unstable, xs))` | `:unknown` | `:depends` |
| a plain `for` loop | `:clean` | `:clean` |

`[solid(x) for x in xs]` stays `:unknown`, and that asymmetry is the design rather than a
remaining gap: `:depends` needs one witness, `:clean` needs the whole graph resolved.

The new spec testset was checked against the reverted fix — 4 failures with it out, 0 with it in.

187 behaviours, 1215 assertions, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added the bug Something isn't working label Sep 9, 2026
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

📚 Docs preview: https://codes.sota-shimozono.com/ExperimentalAPI.jl/previews/PR29/

(updates on each push to this PR)

@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sotashimozono
sotashimozono merged commit 23ae0fd into main Sep 9, 2026
12 of 14 checks passed
@sotashimozono
sotashimozono deleted the fix/reach-threw-instead-of-answering branch September 9, 2026 05:28
sotashimozono added a commit that referenced this pull request Sep 9, 2026
…ing to bound it (#30)

* fix: reach did not terminate on nightly, and depth alone was never going to bound it

My own regression, and the way it reached main is worth writing down. #29 removed a `MethodError`
that `reach` threw on `sum(f(x) for x in xs)`. That throw was also, accidentally, a terminator.
Without it, on 1.14.0-DEV:

    reach(f, Tuple{Vector{Float64}})   # [f(x) for x in xs]   — did not return
    reach(g, Tuple{Vector{Float64}})   # sum(map(f, xs))      — did not return

Both answer in milliseconds on 1.12.2. The nightly leg is `continue-on-error`, so it could not
block the merge; I then cancelled the run that was sitting in `runtest` and a background job
merged #29 the moment `gh pr checks` reported nothing pending. The hang was on main for about
forty minutes.

`maxdepth` bounds how FAR the walk goes, not how much of it there is. Thirty-two levels branching
by sixteen candidates is not a finite amount of work in any useful sense, and `visited` only prunes
signatures that repeat — a higher-order call generates new ones. So the walk now also carries a
`maxwork` budget, shared with every subwalk, and spends `:unknown` with `why = :budget` when it
runs out. That is what `:unknown` is for; the alternative was a call that never comes back.

Shared, not per-branch, and the distinction is load-bearing: `visited` is deliberately reset in
`_subwalk` so a candidate reached under another branch is still walked here, which means `visited`
cannot also be the thing that bounds the total.

`maxwork` is a keyword on `reach`, `reach(::Module)` and `reach_script`, with the measurement in
the docstring — a caller whose entry point comes back `:unknown` with a `:budget` is in a
different situation from one that is genuinely dynamic, and only they can decide to pay for more.

The spec now pins the property that does not move between versions, because the verdict does:
`[unstable(x) for x in xs]` is `:depends` on 1.12.2 and `:unknown` on 1.14.0-DEV. What must hold
everywhere is that a caller which can reach a mark is never reported `:clean` — with a control
that the same shapes with nothing marked behind them still are, so the assertion is not satisfied
by an analysis that never says `:clean` at all.

Measured on both: 1237 assertions on 1.12.2, 1236 on 1.14.0-DEV, 190 behaviours, green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: truncated makes reached a lower bound, and the docstring now says so

Measured while answering "what happens with several marks behind one loop": budgets between "too
small to reach any" and the default report `:depends` with one, two, … of twelve found and the
rest never walked to. The verdict is right either way; the LIST is not complete, and
`truncated = true` is the only thing that says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant