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
|
@@ -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)
|