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
@@ -0,0 +1,68 @@
1
+ """
2
+ diffcontext.verify — sufficiency scoring and user-defined retrieval test cases.
3
+
4
+ Turns "here are relevant files" into "this context is sufficient, with
5
+ measured confidence":
6
+
7
+ from diffcontext.verify import (
8
+ analyze_sufficiency, # structural sufficiency of one compile
9
+ load_cases, run_cases, # user-defined expectations, measured
10
+ cases_from_history, # auto ground truth from git co-change
11
+ calibrate, # does the score track measured recall?
12
+ )
13
+
14
+ See docs/VERIFY.md for the case file format and the honesty contract.
15
+ """
16
+
17
+ from .sufficiency import (
18
+ SufficiencyFinding,
19
+ SufficiencyReport,
20
+ analyze_sufficiency,
21
+ HIGH_SCORE_THRESHOLD,
22
+ )
23
+ from .cases import (
24
+ Case,
25
+ CaseResult,
26
+ CaseFormatError,
27
+ Calibration,
28
+ CalibrationBucket,
29
+ CALIBRATION_FILENAME,
30
+ load_cases,
31
+ save_cases,
32
+ run_cases,
33
+ cases_from_history,
34
+ calibrate,
35
+ fit_recall_model,
36
+ predict_recall,
37
+ save_calibration,
38
+ load_calibration,
39
+ render_results,
40
+ render_calibration,
41
+ )
42
+ from .history import CoChangeCase, extract_cochange_cases
43
+
44
+ __all__ = [
45
+ "SufficiencyFinding",
46
+ "SufficiencyReport",
47
+ "analyze_sufficiency",
48
+ "HIGH_SCORE_THRESHOLD",
49
+ "Case",
50
+ "CaseResult",
51
+ "CaseFormatError",
52
+ "Calibration",
53
+ "CalibrationBucket",
54
+ "CALIBRATION_FILENAME",
55
+ "load_cases",
56
+ "save_cases",
57
+ "run_cases",
58
+ "cases_from_history",
59
+ "calibrate",
60
+ "fit_recall_model",
61
+ "predict_recall",
62
+ "save_calibration",
63
+ "load_calibration",
64
+ "render_results",
65
+ "render_calibration",
66
+ "CoChangeCase",
67
+ "extract_cochange_cases",
68
+ ]
@@ -0,0 +1,631 @@
1
+ """
2
+ cases.py — User-defined test cases for context retrieval, and calibration.
3
+
4
+ A test case states an expectation the user KNOWS to be true about their
5
+ own repo: "when function X changes, a correct context must include Y".
6
+ Running the cases measures recall against those expectations; running
7
+ them with --calibrate additionally checks whether the structural
8
+ sufficiency score (sufficiency.py) actually tracks measured recall —
9
+ which is what turns the score from a heuristic into calibrated confidence.
10
+
11
+ Case file format (JSON; YAML also accepted if PyYAML is installed):
12
+
13
+ {
14
+ "version": 1,
15
+ "defaults": {"budget": 10000, "depth": 2, "top_k": 20, "min_recall": 1.0},
16
+ "cases": [
17
+ {
18
+ "name": "jwt-validation-change",
19
+ "task": "optional: what the change/request is about, in plain English",
20
+ "changed": ["./auth.py:validate_jwt"],
21
+ "must_include": ["./api.py:get_user", "./middleware.py:check_auth"],
22
+ "must_exclude": ["./billing.py:invoice_total"],
23
+ "budget": 8000,
24
+ "min_recall": 1.0
25
+ }
26
+ ]
27
+ }
28
+
29
+ Field semantics:
30
+ changed (required) symbol IDs treated as the modified code.
31
+ must_include (required) symbols a sufficient context MUST contain.
32
+ must_exclude (optional) symbols that must NOT appear (precision guard).
33
+ task (optional) natural-language intent; recorded in results,
34
+ reserved for future query-aware ranking.
35
+ budget token budget for compilation (0 = unlimited).
36
+ top_k max context symbols per changed symbol (0 = unlimited).
37
+ depth max dependency traversal depth.
38
+ min_recall pass threshold on must_include recall (default 1.0).
39
+
40
+ Pass rule: recall >= min_recall AND no must_exclude symbol was selected.
41
+ Symbols that don't exist in the index count as failures but are flagged
42
+ loudly with fuzzy-match suggestions, so a typo can't silently pass or
43
+ quietly deflate your numbers.
44
+ """
45
+
46
+ import json
47
+ import os
48
+ from dataclasses import dataclass, field
49
+ from typing import Dict, List, Optional
50
+
51
+ from ..pipeline import index_repository, analyze_impact, compile as compile_pipeline
52
+ from ..models import RepositoryIndex
53
+ from .sufficiency import analyze_sufficiency, SufficiencyReport
54
+ from .history import extract_cochange_cases
55
+
56
+ DEFAULT_BUDGET = 10000
57
+ DEFAULT_DEPTH = 2
58
+ DEFAULT_TOP_K = 20 # per changed symbol; benchmarked sweet spot
59
+ DEFAULT_MIN_RECALL = 1.0
60
+
61
+
62
+ @dataclass
63
+ class Case:
64
+ """One user-defined retrieval expectation."""
65
+ name: str
66
+ changed: List[str]
67
+ must_include: List[str]
68
+ must_exclude: List[str] = field(default_factory=list)
69
+ task: str = ""
70
+ budget: int = DEFAULT_BUDGET
71
+ depth: int = DEFAULT_DEPTH
72
+ top_k: int = DEFAULT_TOP_K
73
+ min_recall: float = DEFAULT_MIN_RECALL
74
+
75
+ def to_dict(self) -> dict:
76
+ d = {
77
+ "name": self.name,
78
+ "changed": self.changed,
79
+ "must_include": self.must_include,
80
+ }
81
+ if self.must_exclude:
82
+ d["must_exclude"] = self.must_exclude
83
+ if self.task:
84
+ d["task"] = self.task
85
+ if self.budget != DEFAULT_BUDGET:
86
+ d["budget"] = self.budget
87
+ if self.depth != DEFAULT_DEPTH:
88
+ d["depth"] = self.depth
89
+ if self.top_k != DEFAULT_TOP_K:
90
+ d["top_k"] = self.top_k
91
+ if self.min_recall != DEFAULT_MIN_RECALL:
92
+ d["min_recall"] = self.min_recall
93
+ return d
94
+
95
+
96
+ @dataclass
97
+ class CaseResult:
98
+ """Outcome of running one case against the pipeline."""
99
+ case: Case
100
+ passed: bool
101
+ recall: float # |must_include ∩ selected| / |must_include|
102
+ missing: List[str] # must_include symbols not selected
103
+ forbidden_hits: List[str] # must_exclude symbols that WERE selected
104
+ unknown_symbols: Dict[str, str] # symbol -> suggestion ("" if none)
105
+ selected_count: int
106
+ context_tokens: int
107
+ sufficiency: Optional[SufficiencyReport] = None
108
+ # |must_include ∩ retrieved| / |retrieved non-changed symbols|. A LOWER
109
+ # BOUND on true precision: co-change ground truth is incomplete, so some
110
+ # "noise" symbols are actually relevant (measured small — GT-adjusted
111
+ # precision stays within ~2x; RIGOR_REPORT_2026-07.md §2). None when
112
+ # nothing beyond the changed symbols was selected.
113
+ precision_lb: Optional[float] = None
114
+
115
+ def to_dict(self) -> dict:
116
+ return {
117
+ "name": self.case.name,
118
+ "passed": self.passed,
119
+ "recall": round(self.recall, 3),
120
+ "precision_lb": (
121
+ round(self.precision_lb, 3) if self.precision_lb is not None else None
122
+ ),
123
+ "missing": self.missing,
124
+ "forbidden_hits": self.forbidden_hits,
125
+ "unknown_symbols": self.unknown_symbols,
126
+ "selected_count": self.selected_count,
127
+ "context_tokens": self.context_tokens,
128
+ "sufficiency_score": (
129
+ round(self.sufficiency.score, 1) if self.sufficiency else None
130
+ ),
131
+ "sufficiency_verdict": (
132
+ self.sufficiency.verdict if self.sufficiency else None
133
+ ),
134
+ }
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Loading
139
+ # ---------------------------------------------------------------------------
140
+
141
+ class CaseFormatError(ValueError):
142
+ """Raised when a case file is malformed, with a message that says how to fix it."""
143
+
144
+
145
+ def load_cases(path: str) -> List[Case]:
146
+ """
147
+ Load cases from a JSON (or YAML, if PyYAML is installed) file.
148
+
149
+ Raises CaseFormatError with an actionable message on any structural
150
+ problem — a silent skip here would corrupt every number downstream.
151
+ """
152
+ with open(path, "r", encoding="utf-8") as f:
153
+ raw_text = f.read()
154
+
155
+ data = None
156
+ if path.endswith((".yaml", ".yml")):
157
+ try:
158
+ import yaml # optional dependency
159
+ except ImportError:
160
+ raise CaseFormatError(
161
+ f"{path} is YAML but PyYAML is not installed. "
162
+ "Either `pip install pyyaml` or convert the file to JSON."
163
+ )
164
+ data = yaml.safe_load(raw_text)
165
+ else:
166
+ try:
167
+ data = json.loads(raw_text)
168
+ except json.JSONDecodeError as e:
169
+ raise CaseFormatError(f"{path} is not valid JSON: {e}")
170
+
171
+ if not isinstance(data, dict) or "cases" not in data:
172
+ raise CaseFormatError(
173
+ f'{path} must be an object with a "cases" list '
174
+ '(see docs/VERIFY.md for the format).'
175
+ )
176
+
177
+ defaults = data.get("defaults", {})
178
+ if not isinstance(defaults, dict):
179
+ raise CaseFormatError('"defaults" must be an object.')
180
+
181
+ cases: List[Case] = []
182
+ for i, entry in enumerate(data["cases"]):
183
+ if not isinstance(entry, dict):
184
+ raise CaseFormatError(f"cases[{i}] must be an object.")
185
+ for req in ("changed", "must_include"):
186
+ if req not in entry or not isinstance(entry[req], list) or not entry[req]:
187
+ raise CaseFormatError(
188
+ f'cases[{i}] ("{entry.get("name", "?")}") needs a non-empty '
189
+ f'"{req}" list of symbol IDs like "./auth.py:validate_jwt".'
190
+ )
191
+ cases.append(Case(
192
+ name=entry.get("name", f"case-{i}"),
193
+ changed=list(entry["changed"]),
194
+ must_include=list(entry["must_include"]),
195
+ must_exclude=list(entry.get("must_exclude", [])),
196
+ task=entry.get("task", ""),
197
+ budget=int(entry.get("budget", defaults.get("budget", DEFAULT_BUDGET))),
198
+ depth=int(entry.get("depth", defaults.get("depth", DEFAULT_DEPTH))),
199
+ top_k=int(entry.get("top_k", defaults.get("top_k", DEFAULT_TOP_K))),
200
+ min_recall=float(
201
+ entry.get("min_recall", defaults.get("min_recall", DEFAULT_MIN_RECALL))
202
+ ),
203
+ ))
204
+
205
+ if not cases:
206
+ raise CaseFormatError(f'{path} has an empty "cases" list.')
207
+ return cases
208
+
209
+
210
+ def save_cases(cases: List[Case], path: str) -> None:
211
+ """Write cases to a JSON file in the documented format."""
212
+ payload = {"version": 1, "cases": [c.to_dict() for c in cases]}
213
+ with open(path, "w", encoding="utf-8") as f:
214
+ json.dump(payload, f, indent=2)
215
+ f.write("\n")
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # History-derived cases
220
+ # ---------------------------------------------------------------------------
221
+
222
+ def cases_from_history(
223
+ repo_path: str,
224
+ max_cases: int = 30,
225
+ skipped_out: Optional[List] = None,
226
+ ) -> List[Case]:
227
+ """
228
+ Auto-generate cases from git co-change history: functions modified in
229
+ the same commit are external evidence of relatedness (human behavior,
230
+ not our graph). One case per query symbol.
231
+
232
+ Mechanical refactors are excluded on the same thresholds the published
233
+ benchmark uses, so a number measured here is comparable to the one in
234
+ docs/BENCHMARKS.md. Pass a list as skipped_out to see what was dropped.
235
+ """
236
+ cochange = extract_cochange_cases(
237
+ repo_path, max_cases=max_cases, skipped_out=skipped_out,
238
+ )
239
+ cases = []
240
+ for cc in cochange:
241
+ cases.append(Case(
242
+ name=f"history-{cc.commit_hash}-{cc.query_symbol.split(':')[-1]}",
243
+ task=f"co-change from commit {cc.commit_hash}: {cc.commit_msg}",
244
+ changed=[cc.query_symbol],
245
+ must_include=list(cc.ground_truth_symbols),
246
+ # History cases are noisy (a commit can touch unrelated code),
247
+ # so demand majority recall rather than perfection.
248
+ min_recall=0.5,
249
+ ))
250
+ return cases
251
+
252
+
253
+ # ---------------------------------------------------------------------------
254
+ # Running
255
+ # ---------------------------------------------------------------------------
256
+
257
+ def _suggest(unknown: str, known) -> str:
258
+ # Shared fast path — see _suggest_similar_symbol for why plain
259
+ # get_close_matches chokes on symbol IDs (long shared path prefixes
260
+ # defeat difflib's prefilters).
261
+ from ..pipeline import _suggest_similar_symbol
262
+ return _suggest_similar_symbol(unknown, known) or ""
263
+
264
+
265
+ def run_cases(
266
+ repo_path: str,
267
+ cases: List[Case],
268
+ index: Optional[RepositoryIndex] = None,
269
+ cutoff: Optional[str] = None,
270
+ ) -> List[CaseResult]:
271
+ """
272
+ Run every case against the real pipeline (index once, reuse).
273
+
274
+ Recall counts ALL must_include entries in the denominator — a symbol
275
+ that doesn't exist in the index is a miss, not a silent skip, and gets
276
+ flagged with a fuzzy suggestion so typos are visible in the report.
277
+
278
+ cutoff: selection policy forwarded to compile ("gap" = the measured
279
+ precision operating point) so users can measure the recall/precision
280
+ tradeoff on their own repo's cases before adopting it.
281
+ """
282
+ repo_path = os.path.abspath(repo_path)
283
+ idx = index or index_repository(repo_path)
284
+ known_ids = idx.symbols.keys()
285
+
286
+ results: List[CaseResult] = []
287
+ for case in cases:
288
+ unknown: Dict[str, str] = {}
289
+ for sym in case.changed + case.must_include + case.must_exclude:
290
+ if sym not in idx.symbols and sym not in idx.graph:
291
+ unknown[sym] = _suggest(sym, known_ids)
292
+
293
+ impact = analyze_impact(idx, case.changed, max_depth=case.depth)
294
+ max_tokens = case.budget if case.budget > 0 else None
295
+ top_k = case.top_k * len(case.changed) if case.top_k > 0 else None
296
+ package = compile_pipeline(
297
+ idx, impact, max_tokens=max_tokens, top_k=top_k, cutoff=cutoff,
298
+ )
299
+
300
+ selected = {item.symbol_id for item in package.items}
301
+ want = set(case.must_include)
302
+ hit = want & selected
303
+ recall = len(hit) / len(want)
304
+ missing = sorted(want - selected)
305
+ forbidden_hits = sorted(set(case.must_exclude) & selected)
306
+
307
+ # Precision over what was actually retrieved (changed symbols are
308
+ # the query, not retrieval). Lower bound — see CaseResult.
309
+ retrieved = selected - set(case.changed)
310
+ precision_lb = (
311
+ len(want & retrieved) / len(retrieved) if retrieved else None
312
+ )
313
+
314
+ passed = recall >= case.min_recall and not forbidden_hits
315
+
316
+ sufficiency = analyze_sufficiency(idx, impact, package)
317
+
318
+ results.append(CaseResult(
319
+ case=case,
320
+ passed=passed,
321
+ recall=recall,
322
+ missing=missing,
323
+ forbidden_hits=forbidden_hits,
324
+ unknown_symbols=unknown,
325
+ selected_count=len(selected),
326
+ context_tokens=package.token_estimate,
327
+ sufficiency=sufficiency,
328
+ precision_lb=precision_lb,
329
+ ))
330
+ return results
331
+
332
+
333
+ # ---------------------------------------------------------------------------
334
+ # Calibration
335
+ # ---------------------------------------------------------------------------
336
+
337
+ @dataclass
338
+ class CalibrationBucket:
339
+ lo: float
340
+ hi: float
341
+ n: int
342
+ mean_recall: float
343
+
344
+
345
+ @dataclass
346
+ class Calibration:
347
+ """Does the structural sufficiency score track measured recall?"""
348
+ buckets: List[CalibrationBucket]
349
+ pearson_r: Optional[float] # None when undefined (constant series)
350
+ n_cases: int
351
+ # Fitted per-repo recall predictor over runtime-available features
352
+ # (see MODEL_FEATURES). None when too few cases to fit responsibly.
353
+ # Measured basis: benchmarks/calibration_at_scale.py — re-weighting the
354
+ # four score components alone has ~zero held-out predictive power, but
355
+ # this extended feature set beat the predict-the-mean baseline on
356
+ # held-out MAE in 8/9 Python repos (held-out r up to 0.65).
357
+ model: Optional[dict] = None
358
+
359
+ def to_dict(self) -> dict:
360
+ return {
361
+ "n_cases": self.n_cases,
362
+ "pearson_r": round(self.pearson_r, 3) if self.pearson_r is not None else None,
363
+ "buckets": [
364
+ {"range": [b.lo, b.hi], "n": b.n, "mean_recall": round(b.mean_recall, 3)}
365
+ for b in self.buckets
366
+ ],
367
+ "model": self.model,
368
+ }
369
+
370
+
371
+ # Runtime-available features for the fitted recall predictor. Every one is
372
+ # computable BEFORE knowing the answer: score components plus how much the
373
+ # selector kept/cut. (Ground-truth-dependent quantities must never be here.)
374
+ MODEL_FEATURES = (
375
+ "direct_closure", "high_score_retention", "local_graph_confidence",
376
+ "parse_health", "selected_count", "n_missing_direct", "n_dropped_high",
377
+ "context_tokens",
378
+ )
379
+ MIN_MODEL_CASES = 30
380
+ CALIBRATION_FILENAME = ".diffcontext-calibration.json"
381
+
382
+
383
+ def _model_features(sufficiency: SufficiencyReport, selected_count: int,
384
+ context_tokens: int) -> List[float]:
385
+ return [
386
+ sufficiency.direct_closure,
387
+ sufficiency.high_score_retention,
388
+ sufficiency.local_graph_confidence,
389
+ sufficiency.parse_health,
390
+ float(selected_count),
391
+ float(len(sufficiency.missing_direct)),
392
+ float(len(sufficiency.dropped_high_score)),
393
+ float(context_tokens),
394
+ ]
395
+
396
+
397
+ def _solve_linear(A: List[List[float]], b: List[float]) -> Optional[List[float]]:
398
+ """Gaussian elimination with partial pivoting. Returns None if singular."""
399
+ n = len(A)
400
+ M = [row[:] + [b[i]] for i, row in enumerate(A)]
401
+ for col in range(n):
402
+ piv = max(range(col, n), key=lambda r: abs(M[r][col]))
403
+ if abs(M[piv][col]) < 1e-12:
404
+ return None
405
+ M[col], M[piv] = M[piv], M[col]
406
+ for r in range(col + 1, n):
407
+ f = M[r][col] / M[col][col]
408
+ for c in range(col, n + 1):
409
+ M[r][c] -= f * M[col][c]
410
+ x = [0.0] * n
411
+ for r in range(n - 1, -1, -1):
412
+ x[r] = (M[r][n] - sum(M[r][c] * x[c] for c in range(r + 1, n))) / M[r][r]
413
+ return x
414
+
415
+
416
+ def fit_recall_model(results: List[CaseResult]) -> Optional[dict]:
417
+ """
418
+ Least-squares fit of measured recall on standardized runtime features.
419
+ Dependency-free (pure Python normal equations). Returns None when there
420
+ are too few cases — a model fit on a handful of points is noise with a
421
+ JSON file, and we refuse to produce one.
422
+ """
423
+ rows = [r for r in results if r.sufficiency is not None]
424
+ if len(rows) < MIN_MODEL_CASES:
425
+ return None
426
+ X = [_model_features(r.sufficiency, r.selected_count, r.context_tokens)
427
+ for r in rows]
428
+ y = [r.recall for r in rows]
429
+ n, d = len(X), len(MODEL_FEATURES)
430
+
431
+ means = [sum(row[j] for row in X) / n for j in range(d)]
432
+ stds = []
433
+ for j in range(d):
434
+ var = sum((row[j] - means[j]) ** 2 for row in X) / n
435
+ stds.append(var ** 0.5 if var > 1e-12 else 1.0)
436
+ Z = [[(row[j] - means[j]) / stds[j] for j in range(d)] + [1.0] for row in X]
437
+
438
+ # Ridge-regularized normal equations: (Z^T Z + λI) w = Z^T y. The tiny
439
+ # λ exists for degenerate columns — a zero-variance feature (e.g.
440
+ # parse_health on a repo with no broken files) standardizes to an
441
+ # all-zero column and would make plain least squares singular; with
442
+ # ridge it just gets weight 0. The intercept is not penalized.
443
+ dim = d + 1
444
+ lam = 1e-6
445
+ ZtZ = [[sum(Z[i][a] * Z[i][b_] for i in range(n))
446
+ + (lam if (a == b_ and a < d) else 0.0)
447
+ for b_ in range(dim)]
448
+ for a in range(dim)]
449
+ Zty = [sum(Z[i][a] * y[i] for i in range(n)) for a in range(dim)]
450
+ w = _solve_linear(ZtZ, Zty)
451
+ if w is None:
452
+ return None
453
+
454
+ preds = [max(0.0, min(1.0, sum(Z[i][a] * w[a] for a in range(dim))))
455
+ for i in range(n)]
456
+ mean_y = sum(y) / n
457
+ mae = sum(abs(p - yy) for p, yy in zip(preds, y)) / n
458
+ baseline_mae = sum(abs(mean_y - yy) for yy in y) / n
459
+ return {
460
+ "version": 1,
461
+ "features": list(MODEL_FEATURES),
462
+ "means": [round(v, 6) for v in means],
463
+ "stds": [round(v, 6) for v in stds],
464
+ "weights": [round(v, 6) for v in w[:-1]],
465
+ "intercept": round(w[-1], 6),
466
+ "n_cases": n,
467
+ "mean_recall": round(mean_y, 4),
468
+ "train_mae": round(mae, 4),
469
+ "baseline_mae": round(baseline_mae, 4),
470
+ }
471
+
472
+
473
+ def predict_recall(model: dict, sufficiency: SufficiencyReport,
474
+ selected_count: int, context_tokens: int) -> float:
475
+ """Apply a fitted calibration model; returns predicted recall in [0,1]."""
476
+ feats = _model_features(sufficiency, selected_count, context_tokens)
477
+ z = [(feats[j] - model["means"][j]) / model["stds"][j]
478
+ for j in range(len(model["features"]))]
479
+ raw = sum(zj * wj for zj, wj in zip(z, model["weights"])) + model["intercept"]
480
+ return max(0.0, min(1.0, raw))
481
+
482
+
483
+ def save_calibration(cal: Calibration, path: str) -> None:
484
+ with open(path, "w", encoding="utf-8") as f:
485
+ json.dump(cal.to_dict(), f, indent=2)
486
+ f.write("\n")
487
+
488
+
489
+ def load_calibration(path: str) -> Optional[dict]:
490
+ """Load a saved calibration file; returns its dict or None if absent/bad."""
491
+ try:
492
+ with open(path, "r", encoding="utf-8") as f:
493
+ data = json.load(f)
494
+ if isinstance(data, dict):
495
+ return data
496
+ except (OSError, json.JSONDecodeError):
497
+ pass
498
+ return None
499
+
500
+
501
+ def calibrate(results: List[CaseResult]) -> Calibration:
502
+ """
503
+ Map sufficiency-score buckets to observed recall, plus a Pearson
504
+ correlation. A positive, monotonic relationship is the evidence that
505
+ the structural score means something on this repo; a flat or negative
506
+ one is an honest null result and should be reported as such.
507
+ """
508
+ pairs = [
509
+ (r.sufficiency.score, r.recall)
510
+ for r in results if r.sufficiency is not None
511
+ ]
512
+ n = len(pairs)
513
+
514
+ buckets: List[CalibrationBucket] = []
515
+ for lo in (0, 20, 40, 60, 80):
516
+ hi = lo + 20
517
+ in_bucket = [rec for s, rec in pairs if lo <= s < hi or (hi == 100 and s == 100)]
518
+ buckets.append(CalibrationBucket(
519
+ lo=lo, hi=hi, n=len(in_bucket),
520
+ mean_recall=(sum(in_bucket) / len(in_bucket)) if in_bucket else 0.0,
521
+ ))
522
+
523
+ pearson: Optional[float] = None
524
+ if n >= 3:
525
+ xs = [p[0] for p in pairs]
526
+ ys = [p[1] for p in pairs]
527
+ mx = sum(xs) / n
528
+ my = sum(ys) / n
529
+ cov = sum((x - mx) * (y - my) for x, y in pairs)
530
+ vx = sum((x - mx) ** 2 for x in xs)
531
+ vy = sum((y - my) ** 2 for y in ys)
532
+ if vx > 0 and vy > 0:
533
+ pearson = cov / (vx ** 0.5 * vy ** 0.5)
534
+
535
+ return Calibration(buckets=buckets, pearson_r=pearson, n_cases=n,
536
+ model=fit_recall_model(results))
537
+
538
+
539
+ # ---------------------------------------------------------------------------
540
+ # Rendering
541
+ # ---------------------------------------------------------------------------
542
+
543
+ def render_results(results: List[CaseResult]) -> str:
544
+ lines = ["=== DIFFCONTEXT VERIFY: CASE RESULTS ==="]
545
+ n_pass = sum(1 for r in results if r.passed)
546
+ for r in results:
547
+ mark = "✓" if r.passed else "✗"
548
+ suff = f"suff={r.sufficiency.score:.0f}" if r.sufficiency else "suff=?"
549
+ lines.append(
550
+ f" {mark} {r.case.name}: recall {r.recall * 100:.0f}% "
551
+ f"(need ≥{r.case.min_recall * 100:.0f}%), "
552
+ f"{r.selected_count} symbols, {suff}"
553
+ )
554
+ for m in r.missing[:5]:
555
+ lines.append(f" missing: {m}")
556
+ if len(r.missing) > 5:
557
+ lines.append(f" ... and {len(r.missing) - 5} more missing")
558
+ for fh in r.forbidden_hits:
559
+ lines.append(f" FORBIDDEN symbol selected: {fh}")
560
+ for sym, sugg in r.unknown_symbols.items():
561
+ hint = f" — did you mean '{sugg}'?" if sugg else ""
562
+ lines.append(f" ⚠ '{sym}' not found in index (typo?){hint}")
563
+ mean_recall = sum(r.recall for r in results) / len(results) if results else 0.0
564
+ lines.append("")
565
+ total = (
566
+ f"TOTAL: {n_pass}/{len(results)} passed, mean recall {mean_recall * 100:.1f}%"
567
+ )
568
+ with_prec = [r for r in results if r.precision_lb is not None]
569
+ if with_prec:
570
+ mean_prec = sum(r.precision_lb for r in with_prec) / len(with_prec)
571
+ mean_syms = sum(r.selected_count for r in with_prec) / len(with_prec)
572
+ total += (
573
+ f", mean precision ≥{mean_prec * 100:.1f}% "
574
+ f"({mean_syms:.1f} symbols/case; lower bound — co-change GT is "
575
+ f"incomplete)"
576
+ )
577
+ lines.append(total)
578
+ lines.append("=== END CASE RESULTS ===")
579
+ return "\n".join(lines)
580
+
581
+
582
+ def render_calibration(cal: Calibration) -> str:
583
+ lines = [
584
+ "=== CALIBRATION: structural score vs measured recall ===",
585
+ f"Cases: {cal.n_cases}",
586
+ ]
587
+ for b in cal.buckets:
588
+ bar = "#" * int(b.mean_recall * 20)
589
+ lines.append(
590
+ f" score {b.lo:>3.0f}-{b.hi:<3.0f}: n={b.n:<3d} "
591
+ f"mean recall {b.mean_recall * 100:5.1f}% {bar}"
592
+ )
593
+ if cal.pearson_r is not None:
594
+ lines.append(f"Pearson r (score vs recall): {cal.pearson_r:+.3f}")
595
+ if cal.pearson_r >= 0.4:
596
+ lines.append(
597
+ "→ The structural score tracks measured recall on this repo: "
598
+ "higher scores are earned, not decorative."
599
+ )
600
+ elif cal.pearson_r >= 0.1:
601
+ lines.append(
602
+ "→ Weak positive relationship. Treat the score as a coarse "
603
+ "warning signal, not confidence."
604
+ )
605
+ else:
606
+ lines.append(
607
+ "→ NULL RESULT: the structural score does NOT track recall on "
608
+ "this repo. Do not trust the score here; trust the per-case "
609
+ "findings instead. (Reporting this honestly is the point.)"
610
+ )
611
+ else:
612
+ lines.append("Pearson r: undefined (need ≥3 cases with score/recall variance)")
613
+ if cal.model is not None:
614
+ m = cal.model
615
+ lines.append("")
616
+ lines.append(
617
+ f"Fitted recall predictor ({m['n_cases']} cases): train MAE "
618
+ f"{m['train_mae']:.3f} vs predict-the-mean {m['baseline_mae']:.3f}."
619
+ )
620
+ lines.append(
621
+ "Save it with --save-calibration; `diffcontext verify` will then "
622
+ "report a calibrated recall estimate instead of a bare score."
623
+ )
624
+ elif cal.n_cases < MIN_MODEL_CASES:
625
+ lines.append("")
626
+ lines.append(
627
+ f"(No recall predictor fitted: {cal.n_cases} cases < "
628
+ f"{MIN_MODEL_CASES} minimum. Run with more history cases.)"
629
+ )
630
+ lines.append("=== END CALIBRATION ===")
631
+ return "\n".join(lines)