Skip to content

perf(extract): parallelize the Python resolution tail and memoize path work - #3214

Open
toharush wants to merge 1 commit into
Graphify-Labs:v8from
toharush:chore/perf-extract-resolution-tail-3008
Open

perf(extract): parallelize the Python resolution tail and memoize path work#3214
toharush wants to merge 1 commit into
Graphify-Labs:v8from
toharush:chore/perf-extract-resolution-tail-3008

Conversation

@toharush

@toharush toharush commented Aug 30, 2026

Copy link
Copy Markdown

What this is

Performance work on extract() and detect(). No behavior change: every
harness below digests the result and fails if it moves, and all of them report
identical output before and after.

Measured, before vs after

"Before" is 680e3ed8, this branch's base — verified byte-identical to
git show HEAD:<file> for all seven files touched here. Every number comes from
one A/B batch with the trees alternated rep by rep, each rep in a fresh
subprocess with a fresh temp cache root, so nothing inherits a warm AST cache, a
warm resolve cache, or a warm interpreter.

extract() — 501-file Python-heavy tree (this repo), 5 reps, median wall:

before after
cold run 7.61s 1.74s 4.4x
cold, per code file 15.2ms 3.5ms
warm run (watch / update path) 7.76s 1.36s 5.7x
warm, per code file 15.5ms 2.7ms

extract() — scale curve on a large C#/TypeScript monorepo, seeded sample per
size:

files before after before ms/file after ms/file
1,000 6.93s 1.32s 5.3x 6.93 1.32
4,000 15.95s 4.09s 3.9x 3.99 1.02
8,000 35.48s 8.89s 4.0x 4.44 1.11
16,000 238.1 / 229.4s 168.3 / 160.3s 1.4x 14.9 / 14.3 10.5 / 10.0

detect() — full scan of a 38,666-file monorepo emitting 48,407 paths, 3
reps, median:
33.35s to 24.74s (1.35x), 0.689ms to 0.511ms per emitted
path, identical path set.

Where the time went

Split by phase, ast being the parallel extraction phase and post the serial
cross-file resolution phase after it:

files phase before after
8,000 ast 6.89s 4.22s 1.6x
8,000 post 28.60s 4.67s 6.1x
16,000 ast 142.6 / 139.7s 152.4 / 144.7s no gain
16,000 post 95.4 / 89.7s 15.9 / 15.5s 5.9x

The serial resolution tail is where nearly all of this lands, which is also why
the warm path gains the most: on a filled cache nothing is extracted, so the run
is the tail.

The 16,000-file point underdelivers, and it is not fixed here

Read the ast column across sizes: 6.9s at 8,000 files, then ~140s at 16,000.
Twice the files, twenty times the time, in both trees. That is not a parsing
curve — it is the machine running out of RAM while holding a 303,000-node graph
plus a worker pool. Where that phase waits on memory rather than CPU, saving
each worker its module import buys nothing, and the after-tree measures a shade
slower there in both reps rather than faster.

So the honest shape: the extraction-side win is real and about 1.6x up to ~8,000
files on this hardware and gone by 16,000; the resolution-tail win holds at ~6x
at every size measured and is the entire 1.4x at 16,000. Sizes above 16,000 were
not measured and are not extrapolated. Making the AST phase scale past that is
separate work.

The changes

Extraction pool on a forkserver context, with graphify.extract
preloaded. Under spawn — the default on macOS and Windows — every worker is a
fresh interpreter re-importing the module, so that startup was paid once per
worker instead of once per run: measured worker CPU grew from 3.95s at 6 workers
to 4.65s at 16, and the pool phase got slower past 8 workers because the added
startup outran the added parallelism. A forkserver imports once and forks each
worker from that warm image. Windows, which has no forkserver, keeps the default
context and the existing BrokenProcessPool fallback.

The two Python resolution hotspots moved out of the serial parent, where
they were 1.47s of a 2.01s tail. Both are one parse plus one tree walk per file,
over files the workers had already parsed, and only the cross-file merge
genuinely needs the global node table. _collect_python_symbol_resolution_facts
and the identifier walk inside _resolve_cross_file_imports are now per-file
functions returning plain str/int payloads that a pool produces and the
parent stitches back together in input order. That order is load-bearing — the
fact lists and the per-statement import order flow into emitted edge order — and
the parent keeps what only it can hold: the module-stem index, the local-symbol
name maps (node ids exist only there, and only after the id remap), and the edge
emission.

These two only pay off together. Isolated in the same batch, the forkserver
alone is 3.59s to 2.79s and the pass split alone is 3.59s to 4.23s — under
spawn the two extra pools re-import the module per worker and cost more than the
walks they parallelize. So the split is gated on a forkserver actually being
available and on a corpus of at least 60 Python files, and a spawn-only platform
keeps both passes in the parent. A pool that fails for any reason discards the
whole pass and the parent redoes it in-process, where the payloads are
identical, so a broken pool costs time and nothing else.

Path resolution memoized for the life of one run. Path.resolve() follows
symlinks component by component, one lstat per segment, and cross-file
resolution asked the same question about the same few thousand paths millions of
times: a profile showed 3.9M realpath calls issuing 59M lstat syscalls, with
_source_key alone 36% of the run. The caches key on paths with no mtime
component, so extract() clears them per run exactly as it already did for the
tsconfig caches, and detect() now clears them at the start of a scan for the
same reason — otherwise a long-lived watch process would carry the previous
cycle's symlink targets into this scan.

One parse per Python file per run instead of three. The same file was parsed
in the worker's _extract_python_rationale and then again in each of the two
parent-side phases. The parent-side phases now share one parser and one tree per
file; that cache treats a file's contents as fixed for one run, so extract()
clears it on entry under the same contract as clear_resolve_cache() and clears
it again as soon as the last consumer finishes, so the trees are not held past
the peak.

Node-type scans pushed into tree-sitter's C layer. The import_from_statement
and call scans are compiled queries rather than full Python-side walks —
measured 3.6x and 3.1x faster, where one walk visits 1.44M nodes to reach a few
thousand of interest. Captures arrive in match-completion order, so they are
re-sorted by start byte ascending then end byte descending, which reproduces
pre-order exactly (a parent always starts no later than its child and ends no
earlier); that order is observable, since it flows into emitted edge order. A
cursor that hits its match limit falls back to the full walk rather than acting
on a silently truncated capture set.

C# type-reference arbitration indexed instead of scanned. It minted a
dangling stub by scanning every node in the graph for a matching sourceless one,
once per unresolved reference — 194M dictionary lookups from 102,650 calls on a
32,000-file corpus. Sourceless nodes are now indexed by label once, and the
index registers the stubs it mints.

Ignore matching compiled per scan rather than parsed per path. Per-pattern
derivations — negation, directory-only, anchored, literal, pre-split segments —
are compiled once per scan, with the list extended in place as nested ignore
files are appended during the walk. Anchor membership becomes a normcased string
prefix test needing no parts tuple. Two shapes that dominate real ignore files
skip the general fnmatch cascade: a literal name with no wildcard collapses to
one set membership test, and an anchored pattern with no ** matches strictly
positionally. The ** matcher is a segment automaton rather than a memoized
recursion whose key carried the path, so consecutive paths never shared an entry.

Smaller repeated work removed: the workspace-root walk (which reads and
JSON-parses every ancestor package.json) is memoized per starting directory;
JS/TS declaration, alias, and export collection share one depth-first pass
instead of three, 18M fewer node visits; Path(...).suffix in two language-family
predicates becomes a memoized splitext, 1.7M calls; module-specifier resolution
is memoized per directory, halving its 76,317 filesystem probes; normalize_id
is memoized (448,282 calls); _module_stem_key is memoized on the node id; the
proximity tie-breaker's PurePosixPath parse is memoized on the normalized
string, still through pathlib so .parent's handling of //, . components and
bare filenames is unchanged; the cache-write path answers os.path.relpath once
per distinct value instead of once per item (61,964 calls across 438 payloads),
and copy.deepcopy of a payload bound for json.dumps becomes a three-branch
recursive copy over dicts, lists, and immutable scalars.

Two candidates measured and rejected rather than shipped

  • Replacing the recursive walkers with explicit stacks is 1.85x slower in
    CPython (0.299s vs 0.555s over the same corpus). The earlier generator win came
    from yield from frame resumption, which plain recursive functions do not pay.
  • Rewriting the identifier walk in _resolve_cross_file_imports as a query plus
    a byte-order scope sweep is 1.3x slower (0.44s vs 0.56s) — the C-side query win
    is more than consumed by the Python-side scope bookkeeping it needs.

A note on CPU time

Forkserver workers are forked from the server process, which makes them
grandchildren that the parent's RUSAGE_CHILDREN cannot see. Measured CPU for
the 501-file corpus collapses from 11.44s to 1.01s across this branch, and
essentially all of that is the measurement losing sight of the workers, not work
disappearing. Wall time is the only figure that means the same thing on both
sides of this change, so wall time is the only figure reported.

Deliberately not in this PR

Tests, benchmark harnesses, changelog entries, and the measurement write-up are
held back at the author's request and will land separately. Worth flagging what
that leaves uncovered until they do:

  • the two ignore fast paths have no regression pin here; the held-back test
    checks them against a reference implementation of the previous semantics, and
    without it a fast path that diverges from the general cascade fails silently
  • the worker payloads have no pin on picklability or on pooled-equals-in-process
    equivalence; both properties are invisible from graph output, and a Path or
    dataclass creeping back into a payload breaks the pool at runtime, not at
    import

…h work (Graphify-Labs#3008)

extract() is 4.4x faster cold and 5.7x faster warm on a 501-file Python-heavy
corpus, and 4.0x faster at 8,000 files on a large C#/TypeScript monorepo, with
byte-identical output. detect() is 1.35x faster on a 38,666-file scan emitting
an identical path set.

The extraction pool now runs on a forkserver context with graphify.extract
preloaded, so per-worker interpreter startup is paid once per run instead of
once per worker; Windows, which has no forkserver, keeps the default context
and the existing BrokenProcessPool fallback. The two Python resolution
hotspots -- fact collection and the identifier walk in
_resolve_cross_file_imports -- were 1.47s of a 2.01s serial tail and are now
per-file functions returning plain str/int payloads that a pool produces and
the parent stitches back together in input order. That order is load-bearing,
since the fact lists and the per-statement import order flow into emitted edge
order, and the parent keeps what only it can hold: the module-stem index, the
local-symbol name maps, and the edge emission. The two changes only pay off
together -- under spawn the extra pools re-import the module per worker and
cost more than the walks they parallelize -- so the split is gated on a
forkserver being available and on at least 60 Python files, and a pool that
fails for any reason discards the whole pass and the parent redoes it
in-process, where the payloads are identical.

Path resolution and one Python parse per file are now memoized for the life of
one run, cleared per run by both extract() and detect() so a long-lived watch
process cannot carry one cycle's symlink targets or source into the next. The
import_from_statement and call scans are compiled tree-sitter queries with
captures re-sorted to pre-order and a full-walk fallback when a cursor hits its
match limit. C# type-reference arbitration indexes sourceless nodes by label
once instead of scanning the graph per unresolved reference. Ignore matching
compiles its per-pattern derivations once per scan instead of parsing them once
per path, with literal and anchored patterns skipping the general fnmatch
cascade.

At 16,000 files the AST phase becomes memory-bound -- 6.9s at 8,000 files to
~140s at 16,000, in both the before and after trees -- so the extraction-side
win is gone there and only the ~6x resolution-tail win survives. Sizes above
16,000 were not measured.
@toharush toharush changed the title perf(extract): parallelize the Python resolution tail and memoize path work (#3008) perf(extract): parallelize the Python resolution tail and memoize path work Aug 30, 2026

@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. 1 change(s) tested, no difference found (not proven).


Graphify review — findings

Speeds up cache save and directory scans by replacing per-item and per-pattern recomputation with memoized work. _relativize_source_files_in now resolves each distinct source_file string once per payload instead of once per node, and save_cached deep-copies via _json_deepcopy (dict/list/scalar only) rather than copy.deepcopy, since the payload is JSON-shaped and about to be serialized. Cache path resolution across cache.py switches from Path(...).resolve() to the shared resolve_cached, and detect.py drops the hand-rolled pattern-parse cache in favor of lru_cache-backed _match_anchored_ignore_pattern that matches anchored ignore patterns by slicing normcased strings instead of building Path objects per entry.

Worth a look

  • _py_fact_payloads indexed by Python-only slots but merge receives full paths listgraphify/extract.py:6387 · Escalate · high
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • _json_deepcopy leaves tuple-contained dicts sharedgraphify/cache.py:842 · Escalate · medium
    • agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
  • Compiled ignore cache reuses stale rules after in-place pattern replacementgraphify/detect.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
  • Compiled-pattern cache keyed on id() may reuse stale entry after list mutationgraphify/detect.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
  • Compiled ignore cache ignores in-place pattern mutationsgraphify/detect.py:1536 · 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 — 3856 functions depend on the 659 functions this change touches.

Health — this change adds coupling hotspots:

  • new: extract() — 517 callers, 48 callees
  • new: _rebuild_code() — 108 callers, 50 callees
  • new: build_from_json() — 192 callers, 18 callees
  • new: detect() — 108 callers, 16 callees
  • new: build_merge() — 53 callers, 13 callees
  • new: save_semantic_cache() — 58 callers, 9 callees
  • new: to_obsidian() — 36 callers, 13 callees
  • new: _extract_generic() — 18 callers, 25 callees
  • …and 145 more — each is listed as a finding

Verification — 3856 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: 3440 function(s) in the blast radius were not formally verified this run

Formal verification

Could not verify: Could not verify cache\_dir.

The verifier did not have enough to check cache\_dir, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify cached\_word\_count.

The verifier did not have enough to check cached\_word\_count, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify file\_hash.

The verifier did not have enough to check file\_hash, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify prune\_semantic\_cache.

The verifier did not have enough to check prune\_semantic\_cache, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_relativize\_source\_files\_in.

The verifier did not have enough to check \_relativize\_source\_files\_in, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify save\_cached.

The verifier did not have enough to check save\_cached, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_stat\_index\_file.

The verifier did not have enough to check \_stat\_index\_file, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify detect.

The verifier did not have enough to check detect, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_is\_ignored.

The verifier did not have enough to check \_is\_ignored, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_lexical\_relative.

The verifier did not have enough to check \_lexical\_relative, 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: parameter `target` is annotated `Path` — outside the synthesizable primitive/collection set

No difference found (not proven): No behavior difference found in \_match\_anchored\_ignore\_pattern (not a proof).

The verifier ran both versions of \_match\_anchored\_ignore\_pattern on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

Could not verify: Could not verify \_resolves\_under\_root.

The verifier did not have enough to check \_resolves\_under\_root, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

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: parameter `cache_root` is annotated `Path | None` — outside the synthesizable primitive/collection set

Could not verify: Could not verify extract\_csproj.

The verifier did not have enough to check extract\_csproj, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_extract\_parallel.

The verifier did not have enough to check \_extract\_parallel, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify extract\_slnx.

The verifier did not have enough to check extract\_slnx, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_lang\_family.

The verifier did not have enough to check \_lang\_family, 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: parameter `source_file` is annotated `object` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_lang\_is\_case\_insensitive.

The verifier did not have enough to check \_lang\_is\_case\_insensitive, 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: parameter `source_file` is annotated `object` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_merge\_csharp\_partial\_class\_nodes.

The verifier did not have enough to check \_merge\_csharp\_partial\_class\_nodes, 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: parameter `root` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_repoint\_python\_package\_imports.

The verifier did not have enough to check \_repoint\_python\_package\_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: not verifiable: all 51 sampled inputs raised on both versions — the function never executed, so 'no divergence' would be vacuous (mostly TypeError — names the real obstacle, not a sampling gap)

Could not verify: Could not verify \_rescue\_js\_dynamic\_imports.

The verifier did not have enough to check \_rescue\_js\_dynamic\_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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_resolve\_python\_member\_calls.

The verifier did not have enough to check \_resolve\_python\_member\_calls, 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: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

Could not verify: Could not verify \_xaml\_csharp\_class\_nodes.

The verifier did not have enough to check \_xaml\_csharp\_class\_nodes, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_xaml\_project\_root.

The verifier did not have enough to check \_xaml\_project\_root, 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: parameter `path` is annotated `Path` — outside the synthesizable primitive/collection set

Could not verify: Could not verify \_resolve\_csharp\_type\_references.

The verifier did not have enough to check \_resolve\_csharp\_type\_references, 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: non-vacuity: domain too small (only 1 distinct inputs exercised, need 3) — 'no divergence' would be near-vacuous

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

Comment thread graphify/detect.py
return True


def detect(root: Path, *, follow_symlinks: bool | None = None, google_workspace: bool | None = None, extra_excludes: list[str] | None = None, cache_root: Path | None = None, gitignore: bool = True) -> 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 regressiondetect()

fans out to 16 callees (efferent coupling); 108 callers depend on it (afferent coupling).

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

Comment thread graphify/extract.py
@@ -4965,7 +5006,7 @@ def _xaml_project_root(path: Path) -> Path:
def _xaml_csharp_class_nodes(path: Path) -> dict[str, list[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_xaml_csharp_class_nodes()

fans out to 7 callees (efferent coupling).

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

Comment thread graphify/extract.py
return max(max_workers, 1)


def _extract_parallel(

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_parallel()

high coupling complexity (Ca·Ce = 12).

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


trees: dict[Path, tuple[bytes, object]] = {}

def _collect_export_facts(node, source: bytes, path: Path) -> None:

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_collect_export_facts()

fans out to 12 callees (efferent coupling).

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

return _read_text(function_node, source)
return None

def _collect_python_file_facts(path: Path, root: Path) -> "dict | None":

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_collect_python_file_facts()

fans out to 8 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.

1 participant