dryscope 0.1.0__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.
dryscope/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dryscope — Code Match and docs track scanning."""
2
+
3
+ __version__ = "0.1.0"
dryscope/benchmark.py ADDED
@@ -0,0 +1,374 @@
1
+ """Helpers for running and scoring public dryscope benchmarks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import Counter
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+
9
+ ACTIONABLE_CODE_LABELS = {"real_refactor_candidate"}
10
+ NON_ACTIONABLE_CODE_LABELS = {"not_worth_refactoring"}
11
+ ACTIONABLE_DOCS_LABELS = {
12
+ "useful_section_match",
13
+ "useful_docs_map_candidate",
14
+ "useful_doc_pair_review",
15
+ }
16
+ NON_ACTIONABLE_DOCS_LABELS = {"intentional_repetition", "not_actionable"}
17
+ DocsSectionSignature = tuple[tuple[str, int], tuple[str, int]]
18
+
19
+
20
+ def _section_signature(items: Iterable[tuple[str, int]]) -> DocsSectionSignature:
21
+ """Return exactly two sorted section anchors as a stable signature."""
22
+ ordered = tuple(sorted(items))
23
+ if len(ordered) != 2:
24
+ raise ValueError("docs section signatures must contain exactly two sections")
25
+ return ordered[0], ordered[1]
26
+
27
+
28
+ def finding_signature(finding: dict, repo_root: str | Path) -> tuple[tuple[str, str], ...]:
29
+ """Return a stable signature for a finding using repo-relative unit paths.
30
+
31
+ The signature is the sorted tuple of ``(relative_path, unit_name)`` pairs.
32
+ This makes labels resilient to different clone and artifact locations.
33
+ """
34
+ root = Path(repo_root).resolve()
35
+ items: list[tuple[str, str]] = []
36
+ for unit in finding.get("units", []):
37
+ unit_path = Path(unit["file"])
38
+ if unit_path.is_absolute():
39
+ try:
40
+ rel_path = unit_path.resolve().relative_to(root).as_posix()
41
+ except ValueError:
42
+ rel_path = unit_path.as_posix()
43
+ else:
44
+ rel_path = unit_path.as_posix()
45
+ items.append((rel_path, unit["name"]))
46
+ return tuple(sorted(items))
47
+
48
+
49
+ def build_label_index(labels: list[dict]) -> dict[tuple[str, tuple[tuple[str, str], ...]], dict]:
50
+ """Index benchmark labels by ``(repo, signature)``."""
51
+ index: dict[tuple[str, tuple[tuple[str, str], ...]], dict] = {}
52
+ for label in labels:
53
+ signature = tuple(sorted((item["path"], item["name"]) for item in label["units"]))
54
+ index[(label["repo"], signature)] = label
55
+ return index
56
+
57
+
58
+ def score_labeled_findings(
59
+ repo_name: str,
60
+ findings: list[dict],
61
+ repo_root: str | Path,
62
+ labels: list[dict],
63
+ ) -> dict:
64
+ """Score findings against any stored public labels for that repo."""
65
+ label_index = build_label_index(labels)
66
+ matched: list[dict] = []
67
+ label_counts: Counter[str] = Counter()
68
+
69
+ for finding in findings:
70
+ signature = finding_signature(finding, repo_root)
71
+ label = label_index.get((repo_name, signature))
72
+ if label is None:
73
+ continue
74
+ matched.append(
75
+ {
76
+ "label": label["label"],
77
+ "units": list(signature),
78
+ "verdict": finding.get("verdict"),
79
+ "tier": finding.get("tier"),
80
+ }
81
+ )
82
+ label_counts[label["label"]] += 1
83
+
84
+ return {
85
+ "matched_count": len(matched),
86
+ "matched_labels": dict(label_counts),
87
+ "matched_findings": matched,
88
+ }
89
+
90
+
91
+ def _safe_divide(numerator: int, denominator: int) -> float | None:
92
+ if denominator == 0:
93
+ return None
94
+ return numerator / denominator
95
+
96
+
97
+ def _f1(precision: float | None, recall: float | None) -> float | None:
98
+ if precision is None or recall is None or precision + recall == 0:
99
+ return None
100
+ return 2 * precision * recall / (precision + recall)
101
+
102
+
103
+ def _unit_matches(actual: dict, expected: dict) -> bool:
104
+ actual_name = str(actual.get("name", ""))
105
+ expected_name = str(expected.get("name", ""))
106
+ if actual_name != expected_name:
107
+ return False
108
+
109
+ actual_path = Path(str(actual.get("file", ""))).as_posix()
110
+ expected_path = Path(str(expected.get("path", ""))).as_posix()
111
+ return actual_path == expected_path or actual_path.endswith(f"/{expected_path}")
112
+
113
+
114
+ def finding_matches_label_units(finding: dict, expected_units: Iterable[dict]) -> bool:
115
+ """Return whether a code finding contains exactly the expected labeled units.
116
+
117
+ Benchmark outputs may contain absolute clone paths while labels are stored
118
+ repo-relative. Matching by path suffix keeps quality scoring independent of
119
+ where the benchmark repos were cloned.
120
+ """
121
+ actual_units = list(finding.get("units", []))
122
+ expected = list(expected_units)
123
+ if len(actual_units) != len(expected):
124
+ return False
125
+
126
+ unmatched = actual_units[:]
127
+ for expected_unit in expected:
128
+ for i, actual in enumerate(unmatched):
129
+ if _unit_matches(actual, expected_unit):
130
+ unmatched.pop(i)
131
+ break
132
+ else:
133
+ return False
134
+ return not unmatched
135
+
136
+
137
+ def _quality_counts_to_metrics(counts: dict) -> dict:
138
+ tp = counts["true_positives"]
139
+ fp = counts["false_positives"]
140
+ fn = counts["false_negatives"]
141
+ precision = _safe_divide(tp, tp + fp)
142
+ recall = _safe_divide(tp, tp + fn)
143
+ return {
144
+ **counts,
145
+ "labeled_precision": precision,
146
+ "curated_recall": recall,
147
+ "f1": _f1(precision, recall),
148
+ }
149
+
150
+
151
+ def _label_quality_kind(
152
+ label_name: str,
153
+ positive_labels: set[str],
154
+ negative_labels: set[str],
155
+ ) -> str | None:
156
+ if label_name in positive_labels:
157
+ return "positive"
158
+ if label_name in negative_labels:
159
+ return "negative"
160
+ return None
161
+
162
+
163
+ def _scored_labels(
164
+ labels: list[dict],
165
+ repo_name: str,
166
+ positive_labels: set[str],
167
+ negative_labels: set[str],
168
+ *,
169
+ track: str | None = None,
170
+ ) -> list[dict]:
171
+ """Return labels for a repo that participate in quality metrics."""
172
+ return [
173
+ label
174
+ for label in labels
175
+ if label.get("repo") == repo_name
176
+ and (track is None or label.get("track") == track)
177
+ and _label_quality_kind(str(label.get("label", "")), positive_labels, negative_labels)
178
+ ]
179
+
180
+
181
+ def _add_rank_metrics(
182
+ metrics: dict,
183
+ true_positive_items: list[dict],
184
+ false_positive_items: list[dict],
185
+ positive_gold_count: int,
186
+ k_values: tuple[int, ...],
187
+ ) -> None:
188
+ """Add precision@K and recall@K to a metrics dict."""
189
+ metrics["precision_at_k"] = {}
190
+ metrics["recall_at_k"] = {}
191
+ for k in k_values:
192
+ tp_at_k = sum(1 for item in true_positive_items if item["rank"] <= k)
193
+ fp_at_k = sum(1 for item in false_positive_items if item["rank"] <= k)
194
+ metrics["precision_at_k"][str(k)] = _safe_divide(tp_at_k, tp_at_k + fp_at_k)
195
+ metrics["recall_at_k"][str(k)] = _safe_divide(tp_at_k, positive_gold_count)
196
+
197
+
198
+ def _quality_metrics_with_items(
199
+ scored_labels: list[dict],
200
+ positive_gold: list[dict],
201
+ surfaced_count: int,
202
+ true_positive_items: list[dict],
203
+ false_positive_items: list[dict],
204
+ false_negative_items: list[dict],
205
+ k_values: tuple[int, ...],
206
+ ) -> dict:
207
+ """Build benchmark quality metrics and attach item-level evidence."""
208
+ counts = {
209
+ "gold_positive_count": len(positive_gold),
210
+ "gold_negative_count": len(scored_labels) - len(positive_gold),
211
+ "surfaced_findings_count": surfaced_count,
212
+ "labeled_surfaced_count": len(true_positive_items) + len(false_positive_items),
213
+ "true_positives": len(true_positive_items),
214
+ "false_positives": len(false_positive_items),
215
+ "false_negatives": len(false_negative_items),
216
+ }
217
+ metrics = _quality_counts_to_metrics(counts)
218
+ _add_rank_metrics(
219
+ metrics,
220
+ true_positive_items,
221
+ false_positive_items,
222
+ len(positive_gold),
223
+ k_values,
224
+ )
225
+ metrics["true_positive_items"] = true_positive_items
226
+ metrics["false_positive_items"] = false_positive_items
227
+ metrics["false_negative_items"] = false_negative_items
228
+ return metrics
229
+
230
+
231
+ def score_code_quality(
232
+ repo_name: str,
233
+ findings: list[dict],
234
+ labels: list[dict],
235
+ *,
236
+ positive_labels: set[str] | None = None,
237
+ negative_labels: set[str] | None = None,
238
+ k_values: tuple[int, ...] = (5, 10, 15),
239
+ ) -> dict:
240
+ """Score Code Review output against curated positive/negative labels.
241
+
242
+ Unlabeled surfaced findings are intentionally not counted as false
243
+ positives. The precision denominator is only labeled surfaced findings.
244
+ Recall is over curated positive labels for the repo.
245
+ """
246
+ positive_labels = positive_labels or ACTIONABLE_CODE_LABELS
247
+ negative_labels = negative_labels or NON_ACTIONABLE_CODE_LABELS
248
+ scored_labels = _scored_labels(labels, repo_name, positive_labels, negative_labels)
249
+ positive_gold = [label for label in scored_labels if str(label.get("label")) in positive_labels]
250
+
251
+ matched_label_ids: set[int] = set()
252
+ true_positive_items: list[dict] = []
253
+ false_positive_items: list[dict] = []
254
+
255
+ for rank, finding in enumerate(findings, start=1):
256
+ for idx, label in enumerate(scored_labels):
257
+ if idx in matched_label_ids:
258
+ continue
259
+ if not finding_matches_label_units(finding, label.get("units", [])):
260
+ continue
261
+ matched_label_ids.add(idx)
262
+ item = {
263
+ "rank": rank,
264
+ "label": label["label"],
265
+ "units": label.get("units", []),
266
+ "verdict": finding.get("verdict"),
267
+ "tier": finding.get("tier"),
268
+ }
269
+ if label["label"] in positive_labels:
270
+ true_positive_items.append(item)
271
+ else:
272
+ false_positive_items.append(item)
273
+ break
274
+
275
+ false_negative_items = [
276
+ {
277
+ "label": label["label"],
278
+ "units": label.get("units", []),
279
+ }
280
+ for idx, label in enumerate(scored_labels)
281
+ if idx not in matched_label_ids and label["label"] in positive_labels
282
+ ]
283
+
284
+ return _quality_metrics_with_items(
285
+ scored_labels,
286
+ positive_gold,
287
+ len(findings),
288
+ true_positive_items,
289
+ false_positive_items,
290
+ false_negative_items,
291
+ k_values,
292
+ )
293
+
294
+
295
+ def docs_section_signature(section_pair: dict) -> DocsSectionSignature:
296
+ """Return a stable signature for a Section Match pair."""
297
+ chunk_a = section_pair.get("chunk_a", {})
298
+ chunk_b = section_pair.get("chunk_b", {})
299
+ return _section_signature(
300
+ (
301
+ (Path(str(chunk_a.get("file", ""))).as_posix(), int(chunk_a.get("line_start", 0))),
302
+ (Path(str(chunk_b.get("file", ""))).as_posix(), int(chunk_b.get("line_start", 0))),
303
+ )
304
+ )
305
+
306
+
307
+ def _docs_label_signature(label: dict) -> DocsSectionSignature:
308
+ sections = label.get("sections", [])
309
+ return _section_signature(
310
+ (Path(str(section["path"])).as_posix(), int(section["line_start"])) for section in sections
311
+ )
312
+
313
+
314
+ def score_docs_section_quality(
315
+ repo_name: str,
316
+ section_pairs: list[dict],
317
+ labels: list[dict],
318
+ *,
319
+ positive_labels: set[str] | None = None,
320
+ negative_labels: set[str] | None = None,
321
+ k_values: tuple[int, ...] = (5, 10, 15),
322
+ ) -> dict:
323
+ """Score Section Match output against curated docs labels."""
324
+ positive_labels = positive_labels or ACTIONABLE_DOCS_LABELS
325
+ negative_labels = negative_labels or NON_ACTIONABLE_DOCS_LABELS
326
+ scored_labels = _scored_labels(
327
+ labels,
328
+ repo_name,
329
+ positive_labels,
330
+ negative_labels,
331
+ track="docs-section-match",
332
+ )
333
+ positive_gold = [label for label in scored_labels if label["label"] in positive_labels]
334
+ label_index = {_docs_label_signature(label): label for label in scored_labels}
335
+
336
+ matched_signatures: set[tuple[tuple[str, int], tuple[str, int]]] = set()
337
+ true_positive_items: list[dict] = []
338
+ false_positive_items: list[dict] = []
339
+
340
+ for rank, pair in enumerate(section_pairs, start=1):
341
+ signature = docs_section_signature(pair)
342
+ label = label_index.get(signature)
343
+ if label is None or signature in matched_signatures:
344
+ continue
345
+ matched_signatures.add(signature)
346
+ item = {
347
+ "rank": rank,
348
+ "label": label["label"],
349
+ "sections": label.get("sections", []),
350
+ "similarity": pair.get("embedding_similarity"),
351
+ }
352
+ if label["label"] in positive_labels:
353
+ true_positive_items.append(item)
354
+ else:
355
+ false_positive_items.append(item)
356
+
357
+ false_negative_items = [
358
+ {
359
+ "label": label["label"],
360
+ "sections": label.get("sections", []),
361
+ }
362
+ for label in positive_gold
363
+ if _docs_label_signature(label) not in matched_signatures
364
+ ]
365
+
366
+ return _quality_metrics_with_items(
367
+ scored_labels,
368
+ positive_gold,
369
+ len(section_pairs),
370
+ true_positive_items,
371
+ false_positive_items,
372
+ false_negative_items,
373
+ k_values,
374
+ )
dryscope/cache.py ADDED
@@ -0,0 +1,175 @@
1
+ """SQLite cache for LLM and embedding results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import sqlite3
8
+ import threading
9
+ import time
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+
14
+ def _make_key(content: str, model: str, prompt_version: str) -> str:
15
+ """Create a cache key from content hash + model + prompt version."""
16
+ raw = f"{content}|{model}|{prompt_version}"
17
+ return hashlib.sha256(raw.encode()).hexdigest()
18
+
19
+
20
+ @dataclass
21
+ class CacheStats:
22
+ """Cache statistics."""
23
+
24
+ entry_count: int
25
+ embedding_count: int
26
+ coding_count: int
27
+ db_size_bytes: int
28
+ hit_count: int
29
+ miss_count: int
30
+
31
+
32
+ class Cache:
33
+ """SQLite-backed cache for embeddings and LLM responses."""
34
+
35
+ def __init__(self, db_path: Path) -> None:
36
+ self.db_path = db_path
37
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
38
+ self.conn = sqlite3.connect(
39
+ str(self.db_path),
40
+ check_same_thread=False,
41
+ timeout=30.0,
42
+ isolation_level=None,
43
+ )
44
+ self._hits = 0
45
+ self._misses = 0
46
+ self._lock = threading.Lock()
47
+ self._configure_connection()
48
+ self._init_db()
49
+
50
+ def _configure_connection(self) -> None:
51
+ """Tune SQLite for concurrent readers/writers across processes."""
52
+ self.conn.execute("PRAGMA busy_timeout=30000")
53
+ # Switching journal mode can race with another process opening the same
54
+ # cache. Retry briefly, then continue: an already-initialized cache can
55
+ # still be used even if this connection could not flip the mode itself.
56
+ attempts = 5
57
+ for attempt in range(attempts):
58
+ try:
59
+ self.conn.execute("PRAGMA journal_mode=WAL")
60
+ break
61
+ except sqlite3.OperationalError as exc:
62
+ if "locked" not in str(exc).lower() or attempt == attempts - 1:
63
+ break
64
+ time.sleep(0.1 * (attempt + 1))
65
+ self.conn.execute("PRAGMA synchronous=NORMAL")
66
+
67
+ def _execute_write(self, sql: str, params: tuple[object, ...]) -> None:
68
+ """Execute a write with simple retry for transient lock contention."""
69
+ attempts = 5
70
+ for attempt in range(attempts):
71
+ try:
72
+ self.conn.execute(sql, params)
73
+ return
74
+ except sqlite3.OperationalError as exc:
75
+ if "locked" not in str(exc).lower() or attempt == attempts - 1:
76
+ raise
77
+ time.sleep(0.1 * (attempt + 1))
78
+
79
+ def _init_db(self) -> None:
80
+ self.conn.execute("""
81
+ CREATE TABLE IF NOT EXISTS cache (
82
+ key TEXT PRIMARY KEY,
83
+ kind TEXT NOT NULL,
84
+ value TEXT NOT NULL,
85
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
86
+ )
87
+ """)
88
+ self.conn.commit()
89
+
90
+ def get_embedding(self, content: str, model: str) -> list[float] | None:
91
+ """Retrieve a cached embedding vector."""
92
+ key = _make_key(content, model, "embedding_v1")
93
+ with self._lock:
94
+ row = self.conn.execute(
95
+ "SELECT value FROM cache WHERE key = ? AND kind = 'embedding'",
96
+ (key,),
97
+ ).fetchone()
98
+ if row is not None:
99
+ self._hits += 1
100
+ return json.loads(row[0])
101
+ self._misses += 1
102
+ return None
103
+
104
+ def set_embedding(self, content: str, model: str, vector: list[float]) -> None:
105
+ """Store an embedding vector in the cache."""
106
+ key = _make_key(content, model, "embedding_v1")
107
+ with self._lock:
108
+ self._execute_write(
109
+ "INSERT OR REPLACE INTO cache (key, kind, value) VALUES (?, 'embedding', ?)",
110
+ (key, json.dumps(vector)),
111
+ )
112
+
113
+ def get_coding(self, content: str, model: str, prompt_version: str) -> str | None:
114
+ """Retrieve a cached LLM coding response."""
115
+ key = _make_key(content, model, prompt_version)
116
+ with self._lock:
117
+ row = self.conn.execute(
118
+ "SELECT value FROM cache WHERE key = ? AND kind = 'coding'",
119
+ (key,),
120
+ ).fetchone()
121
+ if row is not None:
122
+ self._hits += 1
123
+ return row[0]
124
+ self._misses += 1
125
+ return None
126
+
127
+ def set_coding(self, content: str, model: str, prompt_version: str, response: str) -> None:
128
+ """Store an LLM coding response in the cache."""
129
+ key = _make_key(content, model, prompt_version)
130
+ with self._lock:
131
+ self._execute_write(
132
+ "INSERT OR REPLACE INTO cache (key, kind, value) VALUES (?, 'coding', ?)",
133
+ (key, response),
134
+ )
135
+
136
+ def commit(self) -> None:
137
+ """Flush pending writes to disk."""
138
+ self.conn.commit()
139
+
140
+ def __enter__(self) -> Cache:
141
+ return self
142
+
143
+ def __exit__(self, *args: object) -> None:
144
+ self.close()
145
+
146
+ def stats(self) -> CacheStats:
147
+ """Get cache statistics."""
148
+ with self._lock:
149
+ total = self.conn.execute("SELECT COUNT(*) FROM cache").fetchone()[0]
150
+ embeddings = self.conn.execute(
151
+ "SELECT COUNT(*) FROM cache WHERE kind = 'embedding'"
152
+ ).fetchone()[0]
153
+ codings = self.conn.execute(
154
+ "SELECT COUNT(*) FROM cache WHERE kind = 'coding'"
155
+ ).fetchone()[0]
156
+ db_size = self.db_path.stat().st_size if self.db_path.exists() else 0
157
+ return CacheStats(
158
+ entry_count=total,
159
+ embedding_count=embeddings,
160
+ coding_count=codings,
161
+ db_size_bytes=db_size,
162
+ hit_count=self._hits,
163
+ miss_count=self._misses,
164
+ )
165
+
166
+ def clear(self) -> None:
167
+ """Delete all cache entries."""
168
+ with self._lock:
169
+ self.conn.execute("DELETE FROM cache")
170
+ self.conn.commit()
171
+
172
+ def close(self) -> None:
173
+ """Commit pending writes and close the database connection."""
174
+ self.conn.commit()
175
+ self.conn.close()