diffcontext 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diffcontext/__init__.py +233 -0
- diffcontext/_warn_once.py +112 -0
- diffcontext/cache.py +216 -0
- diffcontext/cli/__init__.py +655 -0
- diffcontext/context/__init__.py +1 -0
- diffcontext/context/compiler.py +643 -0
- diffcontext/context/selector.py +258 -0
- diffcontext/diff/__init__.py +1 -0
- diffcontext/diff/git_diff.py +298 -0
- diffcontext/diff/state_manager.py +75 -0
- diffcontext/graph_builder.py +1026 -0
- diffcontext/history.py +154 -0
- diffcontext/impact/__init__.py +1 -0
- diffcontext/impact/blast_radius.py +58 -0
- diffcontext/impact/scoring.py +223 -0
- diffcontext/impact/traversal.py +58 -0
- diffcontext/impact/visualizer.py +338 -0
- diffcontext/languages/__init__.py +80 -0
- diffcontext/languages/typescript.py +960 -0
- diffcontext/lexical.py +108 -0
- diffcontext/models.py +180 -0
- diffcontext/parser.py +183 -0
- diffcontext/pipeline.py +887 -0
- diffcontext/py.typed +0 -0
- diffcontext/rerank/__init__.py +17 -0
- diffcontext/rerank/features.py +356 -0
- diffcontext/rerank/model.py +175 -0
- diffcontext/resolver.py +288 -0
- diffcontext/scanner.py +153 -0
- diffcontext/symbols.py +254 -0
- diffcontext/verify/__init__.py +68 -0
- diffcontext/verify/cases.py +631 -0
- diffcontext/verify/history.py +396 -0
- diffcontext/verify/sufficiency.py +324 -0
- diffcontext-0.5.1.dist-info/METADATA +219 -0
- diffcontext-0.5.1.dist-info/RECORD +40 -0
- diffcontext-0.5.1.dist-info/WHEEL +5 -0
- diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
- diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
- diffcontext-0.5.1.dist-info/top_level.txt +1 -0
diffcontext/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""
|
|
2
|
+
rerank — stage-2 precision reranking over the stage-1 hybrid candidate pool.
|
|
3
|
+
|
|
4
|
+
Stage 1 (``pipeline._blend_hybrid``) optimizes recall: it unions the graph
|
|
5
|
+
blast radius, the BM25 hits and every symbol in a changed file, which puts
|
|
6
|
+
r@100 at ~0.83 but leaves precision at 0.05-0.10 because the pool is
|
|
7
|
+
effectively the whole repository (see docs/RERANK.md §1).
|
|
8
|
+
|
|
9
|
+
Stage 2 rescores the top-N of that pool with a learned linear model over 22
|
|
10
|
+
features. Inference is **pure Python stdlib** (``json`` + ``math``) so the
|
|
11
|
+
shipped package keeps ``dependencies = []``; training lives benchmark-side in
|
|
12
|
+
``benchmarks/rerank/train.py`` and may use numpy/scipy because it runs offline.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .features import FEATURE_NAMES, QueryContext, extract_features
|
|
16
|
+
|
|
17
|
+
__all__ = ["FEATURE_NAMES", "QueryContext", "extract_features"]
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
features.py — the stage-2 feature extractor.
|
|
3
|
+
|
|
4
|
+
Given the symbol table, the call graph + reverse graph, the changed symbols
|
|
5
|
+
and one candidate, emit a fixed-order ``List[float]``. Everything here is
|
|
6
|
+
derivable from structures the pipeline already builds: no new dependencies,
|
|
7
|
+
no new parsing passes, no file re-reads.
|
|
8
|
+
|
|
9
|
+
Two signals are *optional inputs* rather than hard requirements, because the
|
|
10
|
+
pipeline only has them in some configurations:
|
|
11
|
+
|
|
12
|
+
* ``import_overlap`` needs ``index._import_maps`` (present after
|
|
13
|
+
``index_repository``; ``None`` on a graph-cache warm start)
|
|
14
|
+
* ``cochange_assoc`` needs a ``CoChangeIndex`` (only with ``--with-history``)
|
|
15
|
+
|
|
16
|
+
Both degrade to ``0.0`` when absent. That is deliberate: a missing signal must
|
|
17
|
+
look like "no evidence", never like a different scale, or a model trained with
|
|
18
|
+
history would silently mis-score a run without it.
|
|
19
|
+
|
|
20
|
+
FEATURE_ORDER IS PART OF THE MODEL CONTRACT. ``weights.json`` stores the names
|
|
21
|
+
and ``model.py`` asserts them on load. Appending a feature requires retraining
|
|
22
|
+
and a version bump; reordering silently corrupts every score.
|
|
23
|
+
|
|
24
|
+
Cost: the per-query precompute (three bounded BFS passes + a BM25 rank sort)
|
|
25
|
+
dominates; per-candidate extraction is dict lookups and two set intersections.
|
|
26
|
+
Measured budget in docs/RERANK.md.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
from collections import deque
|
|
33
|
+
from typing import Dict, Iterable, List, Mapping, Optional, Sequence, Set
|
|
34
|
+
|
|
35
|
+
from ..lexical import tokenize
|
|
36
|
+
|
|
37
|
+
# Fixed feature order — see module docstring. Changing this is a breaking
|
|
38
|
+
# change to weights.json.
|
|
39
|
+
FEATURE_NAMES: Sequence[str] = (
|
|
40
|
+
"inv_hop_fwd", # 1
|
|
41
|
+
"inv_hop_bwd", # 2
|
|
42
|
+
"inv_hop_undirected", # 3
|
|
43
|
+
"is_direct_callee", # 4
|
|
44
|
+
"is_direct_caller", # 5
|
|
45
|
+
"is_sibling", # 6
|
|
46
|
+
"is_same_file", # 7
|
|
47
|
+
"is_same_class", # 8
|
|
48
|
+
"dir_distance", # 9
|
|
49
|
+
"log_file_n_symbols", # 10
|
|
50
|
+
"bm25_score", # 11
|
|
51
|
+
"inv_bm25_rank", # 12
|
|
52
|
+
"log_cand_indegree", # 13
|
|
53
|
+
"log_cand_outdegree", # 14
|
|
54
|
+
"log_cand_tokens", # 15
|
|
55
|
+
"name_jaccard", # 16
|
|
56
|
+
"body_token_overlap", # 17
|
|
57
|
+
"import_overlap", # 18
|
|
58
|
+
"is_test", # 19
|
|
59
|
+
"is_private", # 20
|
|
60
|
+
"is_dunder", # 21
|
|
61
|
+
"cochange_assoc", # 22
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
N_FEATURES = len(FEATURE_NAMES)
|
|
65
|
+
|
|
66
|
+
# Hops beyond this are indistinguishable from unreachable for ranking
|
|
67
|
+
# purposes, and bounding the frontier keeps BFS linear on hub-heavy graphs.
|
|
68
|
+
MAX_HOPS = 8
|
|
69
|
+
|
|
70
|
+
# Hard ceiling on nodes visited per BFS direction. django's graph is ~40k
|
|
71
|
+
# edges; without a cap one pathological query could dominate the budget.
|
|
72
|
+
BFS_NODE_CAP = 30000
|
|
73
|
+
|
|
74
|
+
_CAMEL_RE = re.compile(r"[A-Z]+(?![a-z])|[A-Z][a-z]*|[a-z]+|[0-9]+")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def split_identifier(name: str) -> Set[str]:
|
|
78
|
+
"""Lowercased sub-tokens of a symbol name.
|
|
79
|
+
|
|
80
|
+
``"HTTPAdapter.send_request"`` -> ``{"http", "adapter", "send", "request"}``.
|
|
81
|
+
Splits on ``.``/``_`` and camelCase, so ``sendRequest`` and ``send_request``
|
|
82
|
+
produce the same tokens. Single characters are dropped, matching
|
|
83
|
+
``lexical.tokenize``.
|
|
84
|
+
"""
|
|
85
|
+
out: Set[str] = set()
|
|
86
|
+
for part in re.split(r"[._]+", name):
|
|
87
|
+
for tok in _CAMEL_RE.findall(part):
|
|
88
|
+
if len(tok) > 1:
|
|
89
|
+
out.add(tok.lower())
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _bare_name(symbol_id: str) -> str:
|
|
94
|
+
"""``"./a/b.py:Cls.method"`` -> ``"method"``."""
|
|
95
|
+
name = symbol_id.split(":", 1)[1] if ":" in symbol_id else symbol_id
|
|
96
|
+
return name.rsplit(".", 1)[-1]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _class_of(symbol_id: str) -> str:
|
|
100
|
+
"""``"./a.py:Cls.method"`` -> ``"Cls"``; ``""`` for module-level funcs."""
|
|
101
|
+
name = symbol_id.split(":", 1)[1] if ":" in symbol_id else symbol_id
|
|
102
|
+
return name.rsplit(".", 1)[0] if "." in name else ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _file_of(symbol_id: str) -> str:
|
|
106
|
+
return symbol_id.split(":", 1)[0] if ":" in symbol_id else ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _dir_distance(file_a: str, file_b: str) -> float:
|
|
110
|
+
"""Path-component distance between two files.
|
|
111
|
+
|
|
112
|
+
``0`` for the same directory; otherwise the number of directory levels
|
|
113
|
+
you must walk up from one and back down to the other.
|
|
114
|
+
"""
|
|
115
|
+
if file_a == file_b:
|
|
116
|
+
return 0.0
|
|
117
|
+
a = [p for p in os.path.dirname(file_a).split("/") if p not in ("", ".")]
|
|
118
|
+
b = [p for p in os.path.dirname(file_b).split("/") if p not in ("", ".")]
|
|
119
|
+
common = 0
|
|
120
|
+
for x, y in zip(a, b):
|
|
121
|
+
if x != y:
|
|
122
|
+
break
|
|
123
|
+
common += 1
|
|
124
|
+
return float((len(a) - common) + (len(b) - common))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _bfs_hops(
|
|
128
|
+
sources: Iterable[str],
|
|
129
|
+
adjacency: Mapping[str, Iterable[str]],
|
|
130
|
+
max_hops: int = MAX_HOPS,
|
|
131
|
+
node_cap: int = BFS_NODE_CAP,
|
|
132
|
+
) -> Dict[str, int]:
|
|
133
|
+
"""Multi-source BFS returning ``node -> min hops from any source``.
|
|
134
|
+
|
|
135
|
+
Sources themselves are recorded at hop 0. ``adjacency`` may map to any
|
|
136
|
+
iterable of ids (``graph`` uses lists, ``reverse_graph`` uses sets), so
|
|
137
|
+
it is typed as a ``Mapping`` -- covariant in its value type, unlike
|
|
138
|
+
``Dict`` -- to accept both without a cast at each call site.
|
|
139
|
+
"""
|
|
140
|
+
dist: Dict[str, int] = {s: 0 for s in sources}
|
|
141
|
+
frontier = deque(dist)
|
|
142
|
+
while frontier:
|
|
143
|
+
node = frontier.popleft()
|
|
144
|
+
d = dist[node]
|
|
145
|
+
if d >= max_hops or len(dist) >= node_cap:
|
|
146
|
+
continue
|
|
147
|
+
for nbr in adjacency.get(node, ()) or ():
|
|
148
|
+
if nbr not in dist:
|
|
149
|
+
dist[nbr] = d + 1
|
|
150
|
+
frontier.append(nbr)
|
|
151
|
+
return dist
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _inv_hop(dist: Dict[str, int], sid: str) -> float:
|
|
155
|
+
"""``1/(1+hops)``, or 0.0 when unreachable within MAX_HOPS."""
|
|
156
|
+
d = dist.get(sid)
|
|
157
|
+
return 0.0 if d is None else 1.0 / (1.0 + d)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class QueryContext:
|
|
161
|
+
"""Everything derivable once per query, so per-candidate work stays O(1)-ish.
|
|
162
|
+
|
|
163
|
+
Build one of these per changed-symbol query, then call
|
|
164
|
+
:func:`extract_features` for each candidate.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
symbols: id -> Symbol (needs ``.code``; ``.name`` unused here).
|
|
168
|
+
graph: id -> [callee ids].
|
|
169
|
+
reverse_graph: id -> {caller ids}.
|
|
170
|
+
changed: the changed symbol ids for this query. Only ids
|
|
171
|
+
present in ``symbols`` contribute.
|
|
172
|
+
bm25_scores: candidate id -> BM25 score, already maxed over the
|
|
173
|
+
changed symbols by the caller (both the pipeline and
|
|
174
|
+
the benchmark harness compute this anyway).
|
|
175
|
+
import_maps: relative file -> {alias: module}, or None.
|
|
176
|
+
cochange: file -> association in [0, 1], or None.
|
|
177
|
+
file_counts: file -> number of symbols in it. Computed from
|
|
178
|
+
``symbols`` when omitted; pass it in to share across
|
|
179
|
+
queries on the same index.
|
|
180
|
+
token_cache: id -> frozenset of body tokens, shared across queries.
|
|
181
|
+
Strongly recommended: body tokenization is the single
|
|
182
|
+
most expensive per-candidate operation, and candidate
|
|
183
|
+
pools overlap heavily between queries in a repo.
|
|
184
|
+
"""
|
|
185
|
+
|
|
186
|
+
__slots__ = (
|
|
187
|
+
"symbols", "graph", "reverse_graph", "changed", "bm25_scores",
|
|
188
|
+
"import_maps", "cochange", "file_counts", "token_cache",
|
|
189
|
+
"hop_fwd", "hop_bwd", "hop_und", "direct_callees", "direct_callers",
|
|
190
|
+
"siblings", "changed_files", "changed_classes", "changed_name_tokens",
|
|
191
|
+
"changed_body_tokens", "changed_imports", "bm25_rank",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def __init__(
|
|
195
|
+
self,
|
|
196
|
+
symbols: Dict[str, object],
|
|
197
|
+
graph: Dict[str, List[str]],
|
|
198
|
+
reverse_graph: Dict[str, Set[str]],
|
|
199
|
+
changed: Sequence[str],
|
|
200
|
+
bm25_scores: Optional[Dict[str, float]] = None,
|
|
201
|
+
import_maps: Optional[Dict[str, Dict[str, str]]] = None,
|
|
202
|
+
cochange: Optional[Dict[str, float]] = None,
|
|
203
|
+
file_counts: Optional[Dict[str, int]] = None,
|
|
204
|
+
token_cache: Optional[Dict[str, frozenset]] = None,
|
|
205
|
+
):
|
|
206
|
+
self.symbols = symbols
|
|
207
|
+
self.graph = graph
|
|
208
|
+
self.reverse_graph = reverse_graph
|
|
209
|
+
self.bm25_scores = bm25_scores or {}
|
|
210
|
+
self.import_maps = import_maps
|
|
211
|
+
self.cochange = cochange or {}
|
|
212
|
+
self.token_cache = token_cache if token_cache is not None else {}
|
|
213
|
+
|
|
214
|
+
live = [c for c in changed if c in symbols]
|
|
215
|
+
self.changed = live
|
|
216
|
+
|
|
217
|
+
# --- graph geometry -------------------------------------------------
|
|
218
|
+
# Forward = along callee edges (what the change depends on).
|
|
219
|
+
# Backward = along caller edges (what depends on the change).
|
|
220
|
+
self.hop_fwd = _bfs_hops(live, graph)
|
|
221
|
+
self.hop_bwd = _bfs_hops(live, reverse_graph)
|
|
222
|
+
|
|
223
|
+
undirected: Dict[str, Set[str]] = {}
|
|
224
|
+
for node in set(self.hop_fwd) | set(self.hop_bwd):
|
|
225
|
+
nbrs = set(graph.get(node, ()) or ())
|
|
226
|
+
nbrs |= set(reverse_graph.get(node, ()) or ())
|
|
227
|
+
if nbrs:
|
|
228
|
+
undirected[node] = nbrs
|
|
229
|
+
self.hop_und = _bfs_hops(live, undirected)
|
|
230
|
+
|
|
231
|
+
self.direct_callees: Set[str] = set()
|
|
232
|
+
self.direct_callers: Set[str] = set()
|
|
233
|
+
for c in live:
|
|
234
|
+
self.direct_callees.update(graph.get(c, ()) or ())
|
|
235
|
+
self.direct_callers.update(reverse_graph.get(c, ()) or ())
|
|
236
|
+
|
|
237
|
+
# Siblings: called by something that also calls a changed symbol.
|
|
238
|
+
self.siblings: Set[str] = set()
|
|
239
|
+
for parent in self.direct_callers:
|
|
240
|
+
self.siblings.update(graph.get(parent, ()) or ())
|
|
241
|
+
self.siblings.difference_update(live)
|
|
242
|
+
|
|
243
|
+
# --- location + lexical ---------------------------------------------
|
|
244
|
+
self.changed_files = {_file_of(c) for c in live}
|
|
245
|
+
self.changed_classes = {(_file_of(c), _class_of(c)) for c in live}
|
|
246
|
+
|
|
247
|
+
self.changed_name_tokens: Set[str] = set()
|
|
248
|
+
for c in live:
|
|
249
|
+
self.changed_name_tokens |= split_identifier(
|
|
250
|
+
c.split(":", 1)[1] if ":" in c else c
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
self.changed_body_tokens: Set[str] = set()
|
|
254
|
+
for c in live:
|
|
255
|
+
self.changed_body_tokens |= self._tokens(c)
|
|
256
|
+
|
|
257
|
+
self.changed_imports: Set[str] = set()
|
|
258
|
+
if import_maps:
|
|
259
|
+
for f in self.changed_files:
|
|
260
|
+
self.changed_imports.update(
|
|
261
|
+
(import_maps.get(f) or {}).values()
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# --- BM25 rank (query-comparable, unlike the raw score) -------------
|
|
265
|
+
ranked = sorted(
|
|
266
|
+
self.bm25_scores.items(), key=lambda kv: kv[1], reverse=True
|
|
267
|
+
)
|
|
268
|
+
self.bm25_rank = {sid: i for i, (sid, _) in enumerate(ranked)}
|
|
269
|
+
|
|
270
|
+
if file_counts is None:
|
|
271
|
+
file_counts = {}
|
|
272
|
+
for sid in symbols:
|
|
273
|
+
f = _file_of(sid)
|
|
274
|
+
file_counts[f] = file_counts.get(f, 0) + 1
|
|
275
|
+
self.file_counts = file_counts
|
|
276
|
+
|
|
277
|
+
def _tokens(self, sid: str) -> frozenset:
|
|
278
|
+
"""Body identifier tokens for `sid`, memoized in the shared cache."""
|
|
279
|
+
cached = self.token_cache.get(sid)
|
|
280
|
+
if cached is None:
|
|
281
|
+
sym = self.symbols.get(sid)
|
|
282
|
+
cached = frozenset(tokenize(getattr(sym, "code", "") or ""))
|
|
283
|
+
self.token_cache[sid] = cached
|
|
284
|
+
return cached
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def extract_features(ctx: QueryContext, candidate: str) -> List[float]:
|
|
288
|
+
"""The 22 features for one candidate, in ``FEATURE_NAMES`` order."""
|
|
289
|
+
cand_file = _file_of(candidate)
|
|
290
|
+
cand_name = candidate.split(":", 1)[1] if ":" in candidate else candidate
|
|
291
|
+
bare = _bare_name(candidate)
|
|
292
|
+
|
|
293
|
+
is_same_file = 1.0 if cand_file in ctx.changed_files else 0.0
|
|
294
|
+
is_same_class = 1.0 if (cand_file, _class_of(candidate)) in ctx.changed_classes else 0.0
|
|
295
|
+
|
|
296
|
+
# Distance to the *nearest* changed file, so a multi-file change is not
|
|
297
|
+
# penalized by whichever changed file happens to sort first.
|
|
298
|
+
dir_dist = min(
|
|
299
|
+
(_dir_distance(cand_file, f) for f in ctx.changed_files), default=0.0
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
cand_tokens = ctx._tokens(candidate)
|
|
303
|
+
body_overlap = 0.0
|
|
304
|
+
if cand_tokens and ctx.changed_body_tokens:
|
|
305
|
+
inter = len(cand_tokens & ctx.changed_body_tokens)
|
|
306
|
+
if inter:
|
|
307
|
+
body_overlap = inter / len(cand_tokens | ctx.changed_body_tokens)
|
|
308
|
+
|
|
309
|
+
name_tokens = split_identifier(cand_name)
|
|
310
|
+
name_jaccard = 0.0
|
|
311
|
+
if name_tokens and ctx.changed_name_tokens:
|
|
312
|
+
inter = len(name_tokens & ctx.changed_name_tokens)
|
|
313
|
+
if inter:
|
|
314
|
+
name_jaccard = inter / len(name_tokens | ctx.changed_name_tokens)
|
|
315
|
+
|
|
316
|
+
import_overlap = 0.0
|
|
317
|
+
if ctx.changed_imports and ctx.import_maps is not None:
|
|
318
|
+
cand_imports = set((ctx.import_maps.get(cand_file) or {}).values())
|
|
319
|
+
if cand_imports:
|
|
320
|
+
inter = len(cand_imports & ctx.changed_imports)
|
|
321
|
+
if inter:
|
|
322
|
+
import_overlap = inter / len(cand_imports | ctx.changed_imports)
|
|
323
|
+
|
|
324
|
+
base = os.path.basename(cand_file)
|
|
325
|
+
is_test = 1.0 if (
|
|
326
|
+
base.startswith("test_") or "/tests/" in cand_file or "/test/" in cand_file
|
|
327
|
+
) else 0.0
|
|
328
|
+
is_dunder = 1.0 if (bare.startswith("__") and bare.endswith("__")) else 0.0
|
|
329
|
+
is_private = 1.0 if (bare.startswith("_") and not is_dunder) else 0.0
|
|
330
|
+
|
|
331
|
+
rank = ctx.bm25_rank.get(candidate)
|
|
332
|
+
|
|
333
|
+
return [
|
|
334
|
+
_inv_hop(ctx.hop_fwd, candidate), # 1
|
|
335
|
+
_inv_hop(ctx.hop_bwd, candidate), # 2
|
|
336
|
+
_inv_hop(ctx.hop_und, candidate), # 3
|
|
337
|
+
1.0 if candidate in ctx.direct_callees else 0.0, # 4
|
|
338
|
+
1.0 if candidate in ctx.direct_callers else 0.0, # 5
|
|
339
|
+
1.0 if candidate in ctx.siblings else 0.0, # 6
|
|
340
|
+
is_same_file, # 7
|
|
341
|
+
is_same_class, # 8
|
|
342
|
+
dir_dist, # 9
|
|
343
|
+
math.log1p(ctx.file_counts.get(cand_file, 0)), # 10
|
|
344
|
+
ctx.bm25_scores.get(candidate, 0.0), # 11
|
|
345
|
+
0.0 if rank is None else 1.0 / (1.0 + rank), # 12
|
|
346
|
+
math.log1p(len(ctx.reverse_graph.get(candidate, ()) or ())), # 13
|
|
347
|
+
math.log1p(len(ctx.graph.get(candidate, ()) or ())), # 14
|
|
348
|
+
math.log1p(len(cand_tokens)), # 15
|
|
349
|
+
name_jaccard, # 16
|
|
350
|
+
body_overlap, # 17
|
|
351
|
+
import_overlap, # 18
|
|
352
|
+
is_test, # 19
|
|
353
|
+
is_private, # 20
|
|
354
|
+
is_dunder, # 21
|
|
355
|
+
float(ctx.cochange.get(cand_file, 0.0)), # 22
|
|
356
|
+
]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
model.py — stage-2 inference. Pure Python stdlib, by contract.
|
|
3
|
+
|
|
4
|
+
The shipped package declares ``dependencies = []`` and that is a product
|
|
5
|
+
promise, not an accident. Training (``benchmarks/rerank/train.py``) may use
|
|
6
|
+
numpy and scipy because it runs offline; **this module may import nothing but
|
|
7
|
+
the standard library**, and ``tests/test_rerank.py`` asserts that scoring a
|
|
8
|
+
candidate never pulls numpy into ``sys.modules``.
|
|
9
|
+
|
|
10
|
+
The model is a standardized L2-regularized logistic regression:
|
|
11
|
+
|
|
12
|
+
p = sigmoid( sum_i coef[i] * (x[i] - mean[i]) / scale[i] + intercept )
|
|
13
|
+
|
|
14
|
+
`p` is a calibrated estimate of "this candidate is part of the same logical
|
|
15
|
+
change", which is what makes the probability cutoff in ``context.selector``
|
|
16
|
+
possible — the stage-1 blend's min-max normalized score never could be one.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
from typing import Dict, List, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
from .features import FEATURE_NAMES, QueryContext, extract_features
|
|
25
|
+
|
|
26
|
+
WEIGHTS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "weights.json")
|
|
27
|
+
|
|
28
|
+
# Bump when the feature set or the scoring form changes in a way that makes
|
|
29
|
+
# an older weights.json wrong rather than merely stale.
|
|
30
|
+
SUPPORTED_VERSION = 1
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RerankModelError(RuntimeError):
|
|
34
|
+
"""Raised when weights are missing, malformed, or trained on a
|
|
35
|
+
different feature contract than this code implements."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sigmoid(z: float) -> float:
|
|
39
|
+
# Branch to avoid overflow in exp() for large-magnitude logits.
|
|
40
|
+
if z >= 0.0:
|
|
41
|
+
return 1.0 / (1.0 + math.exp(-z))
|
|
42
|
+
e = math.exp(z)
|
|
43
|
+
return e / (1.0 + e)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RerankModel:
|
|
47
|
+
"""A loaded reranker. Construct via :meth:`load`."""
|
|
48
|
+
|
|
49
|
+
__slots__ = ("feature_names", "mean", "scale", "coef", "intercept", "meta")
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
feature_names: Sequence[str],
|
|
54
|
+
mean: Sequence[float],
|
|
55
|
+
scale: Sequence[float],
|
|
56
|
+
coef: Sequence[float],
|
|
57
|
+
intercept: float,
|
|
58
|
+
meta: Optional[Dict] = None,
|
|
59
|
+
):
|
|
60
|
+
n = len(feature_names)
|
|
61
|
+
if not (len(mean) == len(scale) == len(coef) == n):
|
|
62
|
+
raise RerankModelError(
|
|
63
|
+
f"weights arrays disagree: {n} names, {len(mean)} mean, "
|
|
64
|
+
f"{len(scale)} scale, {len(coef)} coef"
|
|
65
|
+
)
|
|
66
|
+
# Feature order is the model contract. A silent mismatch here would
|
|
67
|
+
# score every candidate against the wrong coefficient, which looks
|
|
68
|
+
# like a merely mediocre model rather than a bug — so fail loudly.
|
|
69
|
+
if tuple(feature_names) != tuple(FEATURE_NAMES):
|
|
70
|
+
raise RerankModelError(
|
|
71
|
+
"feature contract mismatch between weights.json and "
|
|
72
|
+
"features.FEATURE_NAMES.\n"
|
|
73
|
+
f" weights: {list(feature_names)}\n"
|
|
74
|
+
f" code: {list(FEATURE_NAMES)}"
|
|
75
|
+
)
|
|
76
|
+
# A zero scale means a constant feature in training; dividing by it
|
|
77
|
+
# yields inf/nan. Training writes 1.0 for those, but defend anyway.
|
|
78
|
+
if any(s == 0.0 for s in scale):
|
|
79
|
+
raise RerankModelError("weights.json contains a zero scale entry")
|
|
80
|
+
|
|
81
|
+
self.feature_names = tuple(feature_names)
|
|
82
|
+
self.mean = tuple(float(v) for v in mean)
|
|
83
|
+
self.scale = tuple(float(v) for v in scale)
|
|
84
|
+
self.coef = tuple(float(v) for v in coef)
|
|
85
|
+
self.intercept = float(intercept)
|
|
86
|
+
self.meta = meta or {}
|
|
87
|
+
|
|
88
|
+
@classmethod
|
|
89
|
+
def load(cls, path: Optional[str] = None) -> "RerankModel":
|
|
90
|
+
"""Load a model from ``weights.json`` (or `path`)."""
|
|
91
|
+
path = path or WEIGHTS_PATH
|
|
92
|
+
try:
|
|
93
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
94
|
+
blob = json.load(fh)
|
|
95
|
+
except FileNotFoundError:
|
|
96
|
+
raise RerankModelError(
|
|
97
|
+
f"no reranker weights at {path}. Train one with "
|
|
98
|
+
"`python -m benchmarks.rerank.train`, or run with rerank=False."
|
|
99
|
+
)
|
|
100
|
+
except json.JSONDecodeError as exc:
|
|
101
|
+
raise RerankModelError(f"malformed weights at {path}: {exc}")
|
|
102
|
+
|
|
103
|
+
version = blob.get("version")
|
|
104
|
+
if version != SUPPORTED_VERSION:
|
|
105
|
+
raise RerankModelError(
|
|
106
|
+
f"weights.json version {version!r} but this build supports "
|
|
107
|
+
f"{SUPPORTED_VERSION}"
|
|
108
|
+
)
|
|
109
|
+
try:
|
|
110
|
+
return cls(
|
|
111
|
+
feature_names=blob["feature_names"],
|
|
112
|
+
mean=blob["mean"],
|
|
113
|
+
scale=blob["scale"],
|
|
114
|
+
coef=blob["coef"],
|
|
115
|
+
intercept=blob.get("intercept", 0.0),
|
|
116
|
+
meta={k: blob[k] for k in
|
|
117
|
+
("trained_on", "trained_at", "n_rows", "metrics")
|
|
118
|
+
if k in blob},
|
|
119
|
+
)
|
|
120
|
+
except KeyError as exc:
|
|
121
|
+
raise RerankModelError(f"weights.json missing key {exc}")
|
|
122
|
+
|
|
123
|
+
def score_vector(self, x: Sequence[float]) -> float:
|
|
124
|
+
"""Probability for one already-extracted feature vector."""
|
|
125
|
+
if len(x) != len(self.coef):
|
|
126
|
+
raise RerankModelError(
|
|
127
|
+
f"expected {len(self.coef)} features, got {len(x)}"
|
|
128
|
+
)
|
|
129
|
+
z = self.intercept
|
|
130
|
+
for xi, m, s, c in zip(x, self.mean, self.scale, self.coef):
|
|
131
|
+
z += c * (xi - m) / s
|
|
132
|
+
return _sigmoid(z)
|
|
133
|
+
|
|
134
|
+
def score_candidates(
|
|
135
|
+
self, ctx: QueryContext, candidates: Sequence[str]
|
|
136
|
+
) -> Dict[str, float]:
|
|
137
|
+
"""Probability for each candidate, extracting features as it goes."""
|
|
138
|
+
return {
|
|
139
|
+
cid: self.score_vector(extract_features(ctx, cid))
|
|
140
|
+
for cid in candidates
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
def rerank(
|
|
144
|
+
self, ctx: QueryContext, candidates: Sequence[str]
|
|
145
|
+
) -> List[str]:
|
|
146
|
+
"""`candidates` reordered by descending probability.
|
|
147
|
+
|
|
148
|
+
Ties break on the original stage-1 order, so a model with nothing to
|
|
149
|
+
say degrades to the shipped ranking rather than to an arbitrary one.
|
|
150
|
+
"""
|
|
151
|
+
scores = self.score_candidates(ctx, candidates)
|
|
152
|
+
order = {cid: i for i, cid in enumerate(candidates)}
|
|
153
|
+
return sorted(candidates, key=lambda c: (-scores[c], order[c]))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_CACHED: Optional[RerankModel] = None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def get_model(path: Optional[str] = None) -> RerankModel:
|
|
160
|
+
"""Process-wide cached model load (weights are read-only and ~4 KB)."""
|
|
161
|
+
global _CACHED
|
|
162
|
+
if path is not None:
|
|
163
|
+
return RerankModel.load(path)
|
|
164
|
+
if _CACHED is None:
|
|
165
|
+
_CACHED = RerankModel.load()
|
|
166
|
+
return _CACHED
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def is_available(path: Optional[str] = None) -> bool:
|
|
170
|
+
"""True when a usable model is on disk. Never raises."""
|
|
171
|
+
try:
|
|
172
|
+
get_model(path)
|
|
173
|
+
return True
|
|
174
|
+
except RerankModelError:
|
|
175
|
+
return False
|