File size: 7,005 Bytes
036a2db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
"""
scoring.py β€” Impact scoring for prioritizing symbols in context.

Algorithm: Bidirectional decay propagation from changed symbols.

Forward  (callees): score decays by CALLEE_DECAY per hop
Backward (callers): score decays by CALLER_DECAY per hop

Structural bonuses:
  - Sibling bonus: shares a caller with a changed symbol (co-change signal),
    weighted by 1/caller_outdegree so hub callers don’t flood the pool,
    and log-scaled before capping to dampen compounding across many hubs.
  - Structural bonus: log2(1 + indegree)*3 + log2(1 + outdegree),
    hard-capped at STRUCT_MAX so mega-hub nodes (outdegree=400) don’t
    accumulate +800 and crowd out genuinely co-changed code.

Changes vs v1:
  1. Structural bonus capped (STRUCT_MAX=15). Uncapped bonus turned hubs
     into permanent top-scorers regardless of actual co-change signal.
  2. BFS propagation cutoff raised 5.0 β†’ 8.0. Large repos (transformers)
     have long paths; 5.0 prematurely cut valid propagation chains.
  3. Sibling bonus log-scaled before accumulation to dampen compounding.
"""

import math
from collections import deque
from typing import Dict, List, Optional, Set


# Tunable constants
CHANGED_SCORE   = 100.0
CALLEE_DECAY    = 0.65   # lowered: callees are less likely to co-change
CALLER_DECAY    = 0.85   # raised: callers co-change more than callees
CALLEE_BASE     = 90.0   # direct callee of changed symbol
CALLER_BASE     = 85.0   # direct caller
SIBLING_BASE    = 60.0   # base for sibling contribution (divided by caller_outdegree)
SIBLING_MAX     = 80.0   # cap: a symbol can’t accumulate more than this from siblings
BLAST_BASE      = 30.0   # in blast_radii but not reached by BFS
MAX_HOPS        = 8      # propagation depth limit
BFS_CUTOFF      = 8.0    # stop propagating a path when score drops below this
STRUCT_MAX      = 15.0   # hard cap on structural bonus (log-scaled)


def compute_impact_scores(
    graph: Dict[str, List[str]],
    changed_symbols: List[str],
    blast_radii: Dict[str, List[str]],
    expanded_deps: List[str] = None,
    reverse: Optional[Dict[str, Set[str]]] = None,
) -> Dict[str, float]:
    """
    Score every symbol's relevance to understanding the change.

    Args:
        graph:           Forward call graph.
        changed_symbols: Symbols that were modified.
        blast_radii:     Pre-computed blast radii per changed symbol.
        expanded_deps:   Symbols reachable by forward dependency expansion.
        reverse:         Pre-built reverse graph (built internally if None).

    Returns dict of symbol_id -> score (higher = more important).
    """
    # Build reverse graph once (or reuse caller's)
    if reverse is None:
        reverse = {}
        for caller, callees in graph.items():
            for callee in callees:
                reverse.setdefault(callee, set()).add(caller)

    changed_set = set(changed_symbols)
    scores: Dict[str, float] = {}

    # ── 1. Changed symbols = 100 ──────────────────────────────────────────
    for sym in changed_symbols:
        scores[sym] = CHANGED_SCORE

    # ── 2. Forward BFS (callees) with decay ──────────────────────────────
    queue: deque = deque()
    for sym in changed_symbols:
        for callee in graph.get(sym, []):
            if callee not in changed_set:
                queue.append((callee, CALLEE_BASE, 1))

    visited_fwd: Set[str] = set(changed_symbols)
    while queue:
        node, score, hop = queue.popleft()
        if node in visited_fwd or hop > MAX_HOPS:
            continue
        visited_fwd.add(node)
        scores[node] = max(scores.get(node, 0.0), score)
        next_score = score * CALLEE_DECAY
        if next_score >= BFS_CUTOFF:
            for callee in graph.get(node, []):
                if callee not in visited_fwd:
                    queue.append((callee, next_score, hop + 1))

    # ── 3. Backward BFS (callers) with decay ─────────────────────────────
    queue2: deque = deque()
    for sym in changed_symbols:
        for caller in reverse.get(sym, set()):
            if caller not in changed_set:
                queue2.append((caller, CALLER_BASE, 1))

    visited_bwd: Set[str] = set(changed_symbols)
    while queue2:
        node, score, hop = queue2.popleft()
        if node in visited_bwd or hop > MAX_HOPS:
            continue
        visited_bwd.add(node)
        scores[node] = max(scores.get(node, 0.0), score)
        next_score = score * CALLER_DECAY
        if next_score >= BFS_CUTOFF:
            for caller in reverse.get(node, set()):
                if caller not in visited_bwd:
                    queue2.append((caller, next_score, hop + 1))

    # ── 4. Specificity-weighted sibling bonus ────────────────────────────
    sibling_accumulator: Dict[str, float] = {}

    for sym in changed_symbols:
        for caller in reverse.get(sym, set()):
            caller_callees = graph.get(caller, [])
            caller_outdegree = len(caller_callees)
            if caller_outdegree <= 1:
                continue
            raw_contribution = SIBLING_BASE / caller_outdegree
            contribution = math.log2(1.0 + raw_contribution)
            for sibling in caller_callees:
                if sibling not in changed_set and sibling != sym:
                    sibling_accumulator[sibling] = (
                        sibling_accumulator.get(sibling, 0.0) + contribution
                    )

    for sibling, bonus in sibling_accumulator.items():
        capped_bonus = min(bonus, SIBLING_MAX)
        scores[sibling] = scores.get(sibling, 0.0) + capped_bonus

    # ── 5. Expanded deps: give them a meaningful score ────────────────────
    if expanded_deps:
        for sym in expanded_deps:
            if sym not in scores:
                scores[sym] = BLAST_BASE

    # ── 6. Remaining blast radius symbols not yet scored ─────────────────
    for sym, radius in blast_radii.items():
        for affected in radius:
            if affected not in scores:
                scores[affected] = BLAST_BASE

    # ── 7. Structural bonus: log-scaled, hard-capped ──────────────────────────
    # Previous formula (indegree*2 + outdegree) was unbounded: a hub with
    # indegree=50 got +100 structural bonus, drowning all co-change signal.
    # log2(1+degree) grows slowly and the STRUCT_MAX cap prevents run-away.
    for sym in scores:
        indegree  = len(reverse.get(sym, set()))
        outdegree = len(graph.get(sym, []))
        struct_bonus = math.log2(1 + indegree) * 3 + math.log2(1 + outdegree)
        scores[sym] += min(struct_bonus, STRUCT_MAX)

    return scores