Skip to content

Call graph: reference edges (Python) — functions used as values; nested definitions win over the file's first - #3712

Merged
squid-protocol merged 7 commits into
mainfrom
claude/python-reference-edges
Sep 26, 2026
Merged

squid-protocol merged 7 commits into
mainfrom
claude/python-reference-edges

Conversation

@squid-protocol

Copy link
Copy Markdown
Owner

Summary

Part of the Level 2 call-graph work (epic #3265). Stacked on #3708 (decorator edges): this PR's base is that branch, so the diff shows only the reference work. Merge #3708 first, then retarget this to main.

Problem. A function name used as a value (Depends(get_db), key=sort_key, callback=self.on_done, return wrapper) is a function the framework or caller invokes later. It's how frameworks wire programs together, and it was recorded nowhere. In the classification of pyan3's unlinked Python edges, these were ~330 edges (~9%).

Fix.

  • Detector (_python_references; python and embedded_python via a top-level reference_edges flag, per orphaned_logic reports 100% dead code for five languages whose invocation model never names the callee — the family #2727 closed without covering #2806):
    • Records candidates in argument, keyword, assignment, return / yield / lambda and collection positions as references_to / references_qualifiers (self.on_done becomes on_done via self).
    • Variables are excluded, because a bare name is far more often a variable than a function:
      • the function's parameters and locals, including tuple and starred targets and with / except / walrus;
      • every nested def's parameters;
      • the bindings of every enclosing function (closure variables);
      • the module's own variables (app = FastAPI());
      • in a signature, anything but a default or a call argument, so Sequence[Depends] isn't a reference but Depends(get_db) is;
      • names the function also invokes: those are already call edges. A nested def f( header is not an invocation, so return f is a reference.
  • Resolver: a reference becomes a row only when it resolves to a function through a scoped step (class, qualified, typed, file, import). unique isn't enough: a local variable named like the repo's one function of that name is still a variable.
    • Rows are fcall_data kind 'reference', counted in references_by_step and never in the call rates.
    • They reach fan-in and PageRank, like decorators.
  • Nested definitions win: a bare call or reference to a name the caller defines nested inside itself now resolves to that nested definition, not the first one in the file. This is the every-decorator-has-its-own-wrapper case.
    • It also fixed the long-standing iter_data wrong-target examples for plain calls.
    • The rehydrator now restores loc, so the caller's span survives a delta scan.
  • Persistence: function_data.references_to / references_qualifiers (healed), restored by the state rehydrator.
  • The module unit carries no references yet: synthetic_unit_data doesn't persist them, and a delta scan must resolve exactly like a fresh one. That's a follow-up.
  • Tool: call_graph_resolution.py scores reference edges separately and adds reference_precision_pct, reference_judged and recall_all_kinds_pct to the baseline, reported but not gated.

Type of change

  • Bug fix
  • Parsing or engine logic (detector.py, call_resolver.py, record_keeper.py, state_rehydrator.py, python profiles)

Measured: call_graph_resolution.py python (pyan3 reference, language-crucible)

metric before (#3708) after
confident precision (calls, gated) 99.4% (1,806 judged) 99.8% (1,806 judged)
edge recall (calls, gated) 51.4% 51.6%
resolution recall (calls, gated) 97.0% 97.5%
reference precision (not gated) n/a 99.7% (322 agree / 1 wrong)
edge recall, calls + decorators + references 67.9% 77.3%

How the precision got there, since these rules read the real source:

  • The first cut was 99.6% on fewer links, with 122 unconfirmed rows (pyan has no edge at all). Sampling them against source found module-level variables (TestClient(app) resolving to an inner app in routing.py), tuple targets, closure variables, signature annotations and a truncated 2,000-char signature. All are fixed, which brought unconfirmed down to 65.
  • Adding return <nested def> then dropped precision to 90.5% (31 wrong: the file's first wrapper). The nested-definition rule brought it back to 99.7%.
  • The remaining 69 unconfirmed, sampled:
    • mostly @property reads (self._data, self.code, self.timeOut; reading them runs a function, and pyan doesn't count that);
    • fastapi's app.dependency_overrides[common_parameters] = ..., where the key is the function.
  • Known imperfection: for a property with both a getter and a setter, a read can land on the setter's definition.

Golden masters

crucible_check.py --mode both --update --yes: no drift. The changes are function-level and within a file.

Tests

  • New tests/extraction/languages/test_python_references.py:
    • value positions;
    • variables of every kind excluded;
    • nested parameters and closure variables;
    • called names and the module unit carry no references;
    • languages without the flag record nothing.
  • tests/core_engine/test_call_resolver.py: a reference is kept only when it reaches an in-scope function (not a unique variable, not a class); a nested definition wins over the file's first, for calls and references.
  • tests/tools_recorders/test_fcall_data.py: the reference columns and loc persist and rehydrate.
  • test_calls_out_nested_decl_3360.py::test_no_false_fcall_edge_from_outer_to_nested_declaration: the C5 claim (a nested header isn't a call) now checks call-kind rows. return helper draws the new reference edge, which is asserted explicitly.
  • test_call_graph_resolution_gate.py: covers the new baseline fields.

Verification

$ pytest tests/                                               -- 11479 passed; the 1 failure (the C5 test above) fixed in 34cdc58d
$ PATH=.venv/bin:$PATH python tests/tools/audit_check.py      -- all clear (after 34cdc58d)
$ python tests/tools/call_graph_accuracy.py --ci              -- OK
$ python tests/tools/call_graph_resolution.py python --regenerate
$ python tests/tools/crucible_check.py --mode both --update --yes   -- no drift

🤖 Generated with Claude Code

https://claude.ai/code/session_01GCpfMCUMpco98QGbsuv2sr


Generated by Claude Code

…unit

Python sliced only defs, so the code a module runs at import -- app =
FastAPI(), @app.post("/items"), setup_logging(), the __main__ guard -- was
recorded nowhere, and a web app's route registration with it. The Mode C
slicer now emits Mode D's synthetic __global_context__ bucket for Python and
embedded_python (top-level module_level_unit flag): every sliced unit's span
and each `class Name` header's name are blanked, the rest is scanned for
calls (qualifiers and receiver types included, so app.post is typed
FastAPI.post).

It is calls-only: magnitude/impact 0 and flagged calls_only, which the file
mass, function-analysis report, function-impact forensics, file-archetype
function count, named-structure retention and folder function count skip, so
no file's size or classification moves because of it.

Synthetic units have no function_data row (#2691), so their calls now ride
on a synthetic_unit_data child table (FK cascade) that the state rehydrator
restores; a delta scan resolves them like a fresh scan. This also covers
Mode D's existing buckets, which were lost on delta scans.

Golden masters (both, 42 differences each): fastapi applications.py direct
downstream 44 -> 191, routing.py 13 -> 30, param_functions.py 92 -> 103
(module-level app/router construction and calls now link to the defining
file), and what those move: Composition Fit z-scores, within-repo file
clusters, network macros, three forensic top lists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCpfMCUMpco98QGbsuv2sr
… audit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCpfMCUMpco98QGbsuv2sr
…decorator

A decorator's wrapper runs around every call to the function it decorates,
and neither the call graph nor the module unit recorded that link. The
detector now captures, per def, the contiguous decorator block directly above
it (argument lines included, shielded text, bounded look-back) as
decorated_by / decorated_by_qualifiers (`@app.post(...)` -> post via app);
python and embedded_python opt in with a top-level decorator_edges flag.

The resolver links them through the usual ladder as fcall_data kind
'decorator' -- counted in decorators_by_step, never in the call-resolution
rates -- and they reach function fan-in/PageRank like COBOL transfers do.
A function now also falls back to its module's receiver types (`app =
FastAPI()` at top level) for a receiver it does not bind itself; a name the
function binds with no known class is recorded as "" and hides the module
type. Both columns persist in function_data and rehydrate.

call_graph_resolution.py python (pyan3): calls-only precision 99.4%
(1806 judged), recall 51.3 -> 51.4, resolution recall 96.9 -> 97.0; new,
not gated: decorator edges 100% precision (575 judged, 0 wrong), recall with
decorators 67.9%. Golden masters: no drift.

Stacked on #3704 (module-level unit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCpfMCUMpco98QGbsuv2sr
…or-edges

# Conflicts:
#	gitgalaxy/core/detector.py
#	gitgalaxy/standards/language_standards/languages/embedded_python.py
#	gitgalaxy/standards/language_standards/languages/python.py
#	tests/tools_recorders/test_fcall_data.py
… the file's first

A function name used as a value -- Depends(get_db), key=sort_key,
callback=self.on_done, return wrapper -- is a link the framework or caller
invokes later, and was recorded nowhere. The detector (python and
embedded_python, top-level reference_edges flag) records candidates in
argument, keyword, assignment, return/yield/lambda and collection positions
as references_to / references_qualifiers. Variables are excluded: the
function's parameters and locals (tuple and starred targets included), every
nested def's parameters, the bindings of every enclosing function (closure
variables), the module's own variables (`app = FastAPI()`), and in a
signature anything but a default or a call argument (so `Sequence[Depends]`
is not one). Names the function also invokes are already edges.

The resolver keeps a reference only when it reaches a function confidently
through a scoped step (class, qualified, typed, file, import) -- `unique`
is not enough for a bare name -- as fcall_data kind 'reference', counted in
references_by_step and never in the call rates. A bare call or reference to a
name the caller defines nested inside itself now resolves to that nested
definition, not the file's first of that name (every decorator's own
`wrapper`); the rehydrator restores loc for the caller's span. The module
unit carries no references yet (synthetic_unit_data does not persist them).

call_graph_resolution.py python (pyan3): call precision 99.4 -> 99.8 (1806
judged), recall 51.4 -> 51.6, resolution recall 97.0 -> 97.5; new, not
gated: reference precision 99.7% (323 judged), recall across calls,
decorators and references 77.3%. Golden masters: no drift.

Stacked on #3708 (decorator edges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCpfMCUMpco98QGbsuv2sr
…coped to calls

`return helper` now draws a reference edge o2 -> helper, which is the
point of this PR; the C5 test's claim is that the nested header is not a
call, so it checks call-kind rows and asserts the reference separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GCpfMCUMpco98QGbsuv2sr
@github-actions

Copy link
Copy Markdown
Contributor

🐦‍⬛ Muninn Security Scan

✅ No security issues found.

🐦‍⬛ Powered by Muninn · Skald Lab

Base automatically changed from claude/python-decorator-edges to main September 26, 2026 10:29
…ce-edges

# Conflicts:
#	gitgalaxy/core/call_resolver.py
#	gitgalaxy/core/detector.py
#	gitgalaxy/core/state_rehydrator.py
#	gitgalaxy/recorders/record_keeper.py
#	gitgalaxy/standards/language_standards/languages/embedded_python.py
#	gitgalaxy/standards/language_standards/languages/python.py
#	tests/call_graph_resolution_baseline.json
#	tests/core_engine/test_call_resolver.py
#	tests/tools/call_graph_resolution.py
#	tests/tools/test_call_graph_resolution_gate.py
#	tests/tools_recorders/test_fcall_data.py
@squid-protocol
squid-protocol merged commit 66acf5d into main Sep 26, 2026
36 checks passed
@squid-protocol
squid-protocol deleted the claude/python-reference-edges branch September 26, 2026 10:45
@squid-protocol squid-protocol added this to the Compiler call-graph comparison milestone Sep 26, 2026 — with Claude
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