Skip to content

perf(resolution): eliminate the warm-run Python re-parse via a content-hash fact cache - #3649

Open
abhay-codes07 wants to merge 2 commits into
Graphify-Labs:v8from
abhay-codes07:perf/python-resolution-facts-cache
Open

abhay-codes07 wants to merge 2 commits into
Graphify-Labs:v8from
abhay-codes07:perf/python-resolution-facts-cache

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

What

On a warm graphify update, Python symbol resolution re-reads and re-parses every .py file — twice — even when the file is unchanged and its AST is already a cache hit:

  • _collect_python_symbol_resolution_facts walks each file to gather the import/use facts the resolver needs, and
  • _resolve_cross_file_imports (a separate, later pass) parses each file again to turn from .module import Name into direct class-level uses edges.

Both walks are pure waste on a warm run: the bytes haven't changed. This PR caches the per-file facts by content hash so neither pass parses an unchanged file.

How — a content-only / filesystem-dependent split

Caching resolution facts is only safe if you cache the half that depends solely on the file's own bytes and re-run the half that depends on other files. So each file's extraction is split along exactly that line:

  • _extract_python_raw_facts(tree) — content-only. The from … import … statements as written (relative level, module string, imported/local name pairs, line); the bare call sites inside each top-level function keyed by the function's own name; and, for the cross-file pass, each from-import's module reference plus the chain of enclosing class/function names (outermost first) for every identifier a from-import binds. No filesystem access, no baked paths — a pure function of the file's bytes.
  • _apply_* / the resolver body — filesystem-dependent, always run fresh: resolving a module string to a file on disk (is_file probes, _resolve_python_module_path), mapping a module to the global node index, and deriving each caller's node id from the current path.

Because the raw facts are content-only, they cache under the same content hash the AST cache already uses (load/save_derived_facts, namespaced by version+schema under cache/pyfacts/). A single shared _python_raw_facts_for helper backs both passes, so a file is parsed at most once on a cold run and not at all on a warm one.

The subtle bit: the cross-file pass attributes a reference to the first enclosing symbol whose name resolves to a node in this file. That resolution depends on the current node index, so it must stay fresh — which is why the cache stores the whole enclosing-name chain and the first-mapped choice is made at apply time, rather than caching a single resolved id that could diverge in the unmapped-outer / mapped-inner case.

Measured

Warm extract of graphify's own 364-file corpus, second run with caches populated, parallel=False, best-of-3, all three revisions measured back-to-back under the same machine load:

revision warm extract vs v8
v8 (no caching) 5.4s
facts-cache only 4.4s −18%
+ parse elimination (this branch) 4.0s −26%

The machine-independent metric: _parse_python_tree calls on a warm run drop from 364 to 0 — the parse is fully eliminated for unchanged files across both resolution passes.

Correctness

Edges are byte-identical to the pre-change output, verified both cold (which populates the cache) and warm (which reads it): both produce the exact same sorted source|relation|target|context|confidence edge set as v8 — 11972 nodes, 25726 edges. The two-phase split preserves the original two-pass order (imports for all files, then uses), and the cross-file refactor preserves the original walk's ordering and first-writer/first-line semantics, so resolution is unchanged.

Tests

The Python resolution / cross-file / incremental / cache suites pass. The full suite matches a fresh v8 (0.9.63) baseline: the same 22 pre-existing failures on this Windows machine (readonly-atomic-write, fifo/socket/symlink rejection, install/skill destinations, a terraform test, a timing-flaky mtime test, two pre-existing unicode-id tests) fail identically on clean v8 — zero regressions from this change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q

abhay-codes07 and others added 2 commits September 17, 2026 23:37
…ntent hash

_collect_python_symbol_resolution_facts re-read and re-parsed every .py file
on every run — including AST-cache hits — to gather import/use facts. Split it
into a content-only raw-facts extraction (import statements as written, and
call sites keyed by their function name — no filesystem, no baked paths) and a
fresh filesystem apply (module->file resolution, caller-id derivation). The
raw facts are a pure function of the file's bytes, so they cache by the same
content hash the AST cache uses; a warm `graphify update` loads them and skips
the walk for unchanged files, while the filesystem-dependent half always runs
fresh so a file added/removed elsewhere can never be served a stale target.
Warm extract of the 364-file self-corpus: 4.0s -> 3.2s. Edges are byte-
identical (verified cold and warm against the pre-change output).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
…esolution

The first-half facts cache (7f8c84d) removed the re-parse from the
symbol-resolution facts pass, but `_resolve_cross_file_imports` — a
separate, later pass — still re-read and re-parsed every .py file on a
warm `graphify update`, so the parse was merely relocated, not removed.

Extend the content-hash-cached raw-facts payload (schema 1 -> 2) with the
two things this pass derives from the tree: `xfile_imports` (each
from-import's module reference in the pass's own parse shape, plus its
imported/local name pairs) and `xfile_refs` (for each identifier bound by
a from-import, the chain of enclosing class/function names, outermost
first, and the line). Both are pure functions of the file's bytes, so
they cache by content hash exactly like the existing facts.

The filesystem-dependent half stays fresh: the module->stem resolution
and the caller-id mapping (chain -> first enclosing name that maps to a
node in this file) run against the current node index at apply time, so a
file added or removed elsewhere can never be served a stale target. The
enclosing-name CHAIN is cached rather than a single resolved id precisely
so that outermost-mapped attribution is decided fresh, matching the
original module-scope walk in the unmapped-outer/mapped-inner case.

A shared `_python_raw_facts_for` helper backs both passes, so each file is
parsed at most once across them on a cold run and not at all on a warm
run: warm `_parse_python_tree` calls drop from 364 to 0 on the 364-file
corpus. Cold and warm edge dumps are byte-identical to the pre-change
baseline (11972 nodes, 25726 edges).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJbLfztSxm2tH5cJwBbe9q
Copilot AI lite review requested due to automatic review settings September 17, 2026 20:50

Copilot AI 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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@graphify-labs graphify-labs 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.

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 5 advisory finding(s) below merit a look before merge.

Formal verification. No changes could be formally verified in this run.


Graphify review — findings

Adds a per-file derived-facts cache (load_derived_facts/save_derived_facts in a version+schema-namespaced dir keyed by the same content hash as the AST cache) and rewires the Python symbol-resolution passes to use it. _extract_python_raw_facts now derives only path-free, content-pure facts (imports, call uses, and cross-file import/ref data) that are cached and reused when a file is unchanged, while path/filesystem resolution (_apply_python_raw_imports, cross-file import resolution) always runs fresh; the schema version orphans stale entries on shape changes. _augment_symbol_resolution_edges and _resolve_cross_file_imports now take root/cache_root so they can hit the cache.

Worth a look

  • Removing the recursive body leaves an empty for-loop and makes resolution.py unparsablegraphify/extractors/resolution.py · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Derived-facts cache namespace permits absolute-path/path-traversal writesgraphify/cache.py:1151 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Partial os.write publishes truncated derived-facts cachegraphify/cache.py:1179 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • xfile_refs collected but never appliedgraphify/extractors/resolution.py · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Attribute names are cached as imported-name referencesgraphify/extractors/resolution.py:2404 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 2616 functions depend on the 493 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 645 callers, 45 callees
  • new: _rebuild_code() — 129 callers, 54 callees
  • new: detect() — 112 callers, 15 callees
  • new: save_semantic_cache() — 63 callers, 9 callees
  • new: _extract_generic() — 18 callers, 29 callees
  • new: load_cached() — 53 callers, 7 callees
  • new: extract_js() — 85 callers, 4 callees
  • new: extract_xaml() — 19 callers, 17 callees
  • …and 60 more — each is listed as a finding

Verification — 2616 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 2443 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

137 of 286 test file(s) selected (48%) via static blast radius.

  • tests/test_astro_extraction.py — impact
  • tests/test_astro_import_ids.py — impact
  • tests/test_build.py — impact
  • tests/test_builtin_global_type_refs.py — impact
  • tests/test_cache.py — impact
  • tests/test_case_sensitive_resolution.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_cjs_module_extension.py — impact
  • tests/test_cpp_nested_and_cli.py — impact
  • tests/test_cpp_objc_cross_file_calls.py — impact
  • tests/test_cross_extension_reexport_self_cycle.py — impact
  • tests/test_cross_language_call_resolution.py — impact
  • tests/test_cross_repo_external_call_guards.py — impact
  • tests/test_cross_repo_member_calls.py — impact
  • tests/test_csharp_call_site_generic_args.py — impact
  • tests/test_csharp_enum_members.py — impact
  • tests/test_csharp_field_generic_args.py — impact
  • tests/test_csharp_generic_callsites.py — impact
  • tests/test_csharp_interface_dispatch.py — impact
  • tests/test_csharp_member_calls.py — impact
  • tests/test_csharp_member_nodes.py — impact
  • tests/test_csharp_object_creation.py — impact
  • tests/test_csharp_partial_classes.py — impact
  • tests/test_csharp_type_resolution.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_dotnet.py — impact
  • tests/test_duplicate_annotation_edges.py — impact
  • tests/test_elixir_import_resolution.py — impact
  • tests/test_extract.py — impact
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_file_label_disambiguation.py — impact
  • tests/test_file_node_id_spec.py — impact
  • tests/test_forwarding_review_findings.py — impact
  • tests/test_go_builtin_call_targets.py — impact
  • tests/test_go_qualified_resolution.py — impact
  • tests/test_ignore_file_encoding.py — impact
  • tests/test_import_extension_resolution.py — impact
  • tests/test_import_self_loops.py — impact
  • tests/test_imported_export_forwarding.py — impact
  • tests/test_incremental.py — impact
  • tests/test_incremental_mtime_collision.py — impact
  • tests/test_indirect_call_arrow_single_param_shadow.py — impact
  • tests/test_indirect_call_catch_binding_shadow.py — impact
  • tests/test_indirect_call_external_import_shadow.py — impact
  • tests/test_indirect_call_for_of_binding_shadow.py — impact
  • tests/test_indirect_call_function_expression_shadow.py — impact
  • tests/test_indirect_call_nested_closure_shadow.py — impact
  • … and 87 more

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

Could not verify: Could not verify extract.

The verifier did not have enough to check extract, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_augment\_symbol\_resolution\_edges.

The verifier did not have enough to check \_augment\_symbol\_resolution\_edges, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_collect\_python\_symbol\_resolution\_facts.

The verifier did not have enough to check \_collect\_python\_symbol\_resolution\_facts, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_resolve\_cross\_file\_imports.

The verifier did not have enough to check \_resolve\_cross\_file\_imports, so it is saying so rather than guessing. No false assurance is the whole point.

Guarantee: No guarantee either way, this is an honest abstention, not a pass.

Note: Reason: no capturable inputs from the test suite; property tier: parameter `root` is annotated `Path | None` — outside the synthesizable primitive/collection set

· 1 grounded finding(s) anchored inline below; 67 more finding(s) on lines outside this diff (see the check run).

return None


def _extract_python_raw_facts(root_node, source: bytes) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Health regression_extract_python_raw_facts()

fans out to 7 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

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