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.
Files changed (40) hide show
  1. diffcontext/__init__.py +233 -0
  2. diffcontext/_warn_once.py +112 -0
  3. diffcontext/cache.py +216 -0
  4. diffcontext/cli/__init__.py +655 -0
  5. diffcontext/context/__init__.py +1 -0
  6. diffcontext/context/compiler.py +643 -0
  7. diffcontext/context/selector.py +258 -0
  8. diffcontext/diff/__init__.py +1 -0
  9. diffcontext/diff/git_diff.py +298 -0
  10. diffcontext/diff/state_manager.py +75 -0
  11. diffcontext/graph_builder.py +1026 -0
  12. diffcontext/history.py +154 -0
  13. diffcontext/impact/__init__.py +1 -0
  14. diffcontext/impact/blast_radius.py +58 -0
  15. diffcontext/impact/scoring.py +223 -0
  16. diffcontext/impact/traversal.py +58 -0
  17. diffcontext/impact/visualizer.py +338 -0
  18. diffcontext/languages/__init__.py +80 -0
  19. diffcontext/languages/typescript.py +960 -0
  20. diffcontext/lexical.py +108 -0
  21. diffcontext/models.py +180 -0
  22. diffcontext/parser.py +183 -0
  23. diffcontext/pipeline.py +887 -0
  24. diffcontext/py.typed +0 -0
  25. diffcontext/rerank/__init__.py +17 -0
  26. diffcontext/rerank/features.py +356 -0
  27. diffcontext/rerank/model.py +175 -0
  28. diffcontext/resolver.py +288 -0
  29. diffcontext/scanner.py +153 -0
  30. diffcontext/symbols.py +254 -0
  31. diffcontext/verify/__init__.py +68 -0
  32. diffcontext/verify/cases.py +631 -0
  33. diffcontext/verify/history.py +396 -0
  34. diffcontext/verify/sufficiency.py +324 -0
  35. diffcontext-0.5.1.dist-info/METADATA +219 -0
  36. diffcontext-0.5.1.dist-info/RECORD +40 -0
  37. diffcontext-0.5.1.dist-info/WHEEL +5 -0
  38. diffcontext-0.5.1.dist-info/entry_points.txt +2 -0
  39. diffcontext-0.5.1.dist-info/licenses/LICENSE +21 -0
  40. 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