feat: rank impact radius by weighted best-path score#606
Conversation
get_impact_radius treated every edge and depth equally: a depth-2 IMPORTS_FROM hop counted the same as a depth-1 CALLS hop, and when the result exceeded max_nodes, truncation kept arbitrary scan-order nodes. On a ~4.8k-node production graph a single-module change returned 500 flat nodes whose "key entities" were alphabetical shell scripts. Each reached node now gets a score: the best path from any seed, where every hop multiplies by an edge-kind weight (IMPACT_EDGE_WEIGHTS: CALLS 1.0 down to CONTAINS 0.3, unknown kinds 0.5) and a per-hop decay (0.6). Paths whose score falls below IMPACT_SCORE_FLOOR (0.05) stop expanding. impacted_nodes come back ordered best-first, truncation keeps the highest-signal nodes, and a new additive impact_scores map (plus impact_score on each node dict at the tool layer) exposes the ranking. SQLite cannot aggregate inside a recursive CTE, so the recursion may revisit a node once per distinct path score; the depth guard plus the score floor bound the expansion and the outer GROUP BY keeps MAX(score). The legacy NetworkX engine implements the same scoring (with better-path revisits, since a deep CALLS chain can outscore a shallow CONTAINS hop) and stays set- and score-aligned with the SQL engine. Weights follow the precedent of communities.EDGE_WEIGHTS but model review-risk propagation rather than clustering affinity, so the values intentionally differ.
|
Maintainer disposition: ranked impact results are useful, but this branch will not be merged directly. I am porting the safe behavior onto current main with Stefan Hudici attribution after correcting boundedness and truncation semantics.\n\nTwo concrete merge blockers in the current patch:\n1. CRG_IMPACT_DEPTH_DECAY and CRG_IMPACT_SCORE_FLOOR are accepted as arbitrary floats without finite/range validation. A decay greater than or equal to 1, a negative value, infinity, or NaN breaks the claimed score decay/floor bound; in a cyclic dense graph it can cause the recursive SQL path set to grow explosively.\n2. The SQL query fetches only the capped result set and then marks truncated when the count is greater than or equal to max_nodes. At the exact boundary this reports truncation even when no node was dropped. Fetching one sentinel row is enough to make the flag exact.\n\nThe replacement will retain edge-kind weights, best-path ranking, SQL/NetworkX parity, ghost-endpoint filtering, and additive impact_score fields, with validated configuration, exact max_nodes-plus-one truncation detection, dense-cycle performance tests, and current-main compatibility.\n\nI will post the replacement PR, full benchmark/CI evidence, merge commit, and attribution here when complete. |
Safe replacement for #606. Ranks impact traversal by bounded best weighted path with SQLite/NetworkX parity, exact truncation, and validated configuration.\n\nCo-authored-by: Stefan Hudici <shudici@gmail.com>
|
Final maintainer disposition: the safe version has now merged as #646 at 31feb78, so this source PR is superseded and can be closed. Adopted from this contribution:
Changes required before merge:
Evidence on the final replacement:
Stefan Hudici is explicitly credited on #646 and on its reviewed source commit. Thank you for the core weighted-impact design. |
What
get_impact_radiustreats every edge and every depth equally: a depth-2IMPORTS_FROMhop counts the same as a depth-1CALLShop, and when the reachable set exceedsmax_nodes,LIMITtruncates in arbitrary scan order. On a real ~4.8k-node / 39k-edge graph, a single-module change returned 500 flat nodes whose top "key entities" were alphabetically-first shell scripts — the most relevant nodes had no priority over the least.This PR ranks the blast radius by a weighted best-path score so truncation keeps the highest-signal nodes and consumers can see what is most at risk, not just everything within N hops.
How
Scoring. Each reached node gets
score = max over paths of Π(edge_weight × 0.6 decay)per hop. Edge-kind weights live inconstants.IMPACT_EDGE_WEIGHTS(CALLS 1.0, INHERITS/OVERRIDES/IMPLEMENTS 0.9, TESTED_BY 0.7, REFERENCES/DEPENDS_ON 0.6, IMPORTS_FROM 0.5, CONTAINS 0.3; unknown kinds 0.5). Paths whose score falls belowIMPACT_SCORE_FLOOR(0.05) stop expanding. Decay and floor are env-overridable (CRG_IMPACT_DEPTH_DECAY,CRG_IMPACT_SCORE_FLOOR). These weights follow the precedent ofcommunities.EDGE_WEIGHTSbut model review-risk propagation rather than clustering affinity, so the values intentionally differ.SQL engine. The recursive CTE carries
score, joins a small_impact_weightstemp table per hop, and applies the floor in the recursion guard. SQLite cannot aggregate inside the recursive term, so a node may be revisited once per distinct path score; the depth guard + floor bound the expansion and the outerGROUP BYkeepsMAX(score). The final select isORDER BY impact_score DESCbeforeLIMIT, so truncation is best-first. Two related fixes fell out of measuring this on the real graph:nodesrow stay in the recursion as traversal bridges but are excluded from the final selection. Before, they consumed up to ~8% of the LIMIT and were then silently dropped by_batch_get_nodes.truncatedis now honest under LIMIT saturation. Previously the flag compared post-LIMIT counts and could never fire on the SQL path; it now reports saturation (>= max_nodes, i.e. "there may be more beyond the cutoff").NetworkX engine (legacy,
CRG_BFS_ENGINE=networkx). Implements the same scoring with better-path revisits — a node re-enters the frontier when a better-scoring path reaches it, because a deep CALLS chain can outscore a shallow CONTAINS hop._build_networkx_graphnow keeps the strongest kind when collapsing parallel edges between the same pair (DiGraph holds one edge per pair; previously "last row wins" arbitrarily). The only other consumer of that graph, betweenness centrality inanalysis.py, ignoreskindentirely. On the production graph both engines return identical node sets and identical scores (max diff 0.0).Surface (additive). The store result gains
impact_scores(qualified_name → best-path score);impacted_nodescome back best-first; theget_impact_radiustool attachesimpact_scoreto each node dict, and minimal-detailkey_entitiesbecome the top-scored nodes instead of the first five in scan order. No existing keys change shape.Measured on a real graph (4,757 nodes / 39,386 edges)
.claude/hooks/*.shTesting
TestWeightedImpactScoring(9 tests): kind weighting, per-hop decay, best-first truncation, deeper-strong-path-beats-shallow-weak-path, floor cutoff, SQL↔NetworkX score parity, parallel-edge-kind collapse regression, unknown-kind default, empty-input shape.TestImpactRadiusScoring(tool layer, 2 tests):impact_scoreattached and sorted best-first; minimalkey_entitieslead with the top-scored node.test_sql_matches_networkx, truncation, and empty-input tests pass unchanged.Notes / non-goals
detect_changesandget_review_contextcould consumeimpact_scorefor better review priorities — deliberately left for a follow-up so this PR stays reviewable.GROUP BYcollapses it. The depth guard + score floor bound this; on the 39k-edge measurement graph the full query is ~50 ms. A pathological dense cyclic subgraph with a raisedCRG_MAX_IMPACT_DEPTHwould pay more — the floor is the safety valve.truncatedwith the same saturation semantics (>= max_nodes, meaning "the result is full; more may exist"). At the exact boundary this can reportTruewith nothing actually dropped — chosen over the previous behavior where the SQL flag could never fire at all._impact_seeds/_impact_weightsTEMP tables are per-connection state; the tool layer opens a fresh store per call, matching the pre-existing_impact_seedspattern.