downshift 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.
downshift/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Downshift: cut LLM costs per PR."""
2
+
3
+ __version__ = "0.1.0"
downshift/audit.py ADDED
@@ -0,0 +1,188 @@
1
+ """Validate Bob audit files and compare them with the ast scan.
2
+
3
+ The Bob auditor writes downshift.audit.json in the same format as the scan
4
+ (see schema.py). `validate_file` checks one file and lints it for work Bob
5
+ left unfinished. `compare_scans` puts the ast scan and the audit side by
6
+ side so the gaps Bob closed are visible. The CLI commands `validate` and
7
+ `compare` are thin wrappers around these functions.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import asdict, dataclass, field
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from downshift.evals import expression_placeholders
17
+ from downshift.schema import CallSite, ScanResult, SchemaError
18
+
19
+ ENRICHMENT_FIELDS = ("purpose", "output_contract", "difficulty", "grading")
20
+
21
+ METRIC_LABELS = {
22
+ "call_sites": "Call sites",
23
+ "models_resolved": "Models resolved",
24
+ "prompts_resolved": "Prompts resolved",
25
+ "enriched": "Enriched",
26
+ "found_by_bob": "Found by Bob",
27
+ "via_helper": "Split from helpers",
28
+ }
29
+
30
+
31
+ # --- validate -----------------------------------------------------------------
32
+
33
+
34
+ @dataclass
35
+ class ValidationReport:
36
+ path: Path
37
+ result: ScanResult | None = None
38
+ error: str | None = None
39
+ warnings: list[str] = field(default_factory=list)
40
+
41
+ @property
42
+ def ok(self) -> bool:
43
+ return self.error is None
44
+
45
+
46
+ def is_enriched(site: CallSite) -> bool:
47
+ return all(getattr(site, name) is not None for name in ENRICHMENT_FIELDS)
48
+
49
+
50
+ def validate_file(path: Path) -> ValidationReport:
51
+ """Load a callsites or audit file; schema problems become `error`, lint becomes `warnings`."""
52
+ report = ValidationReport(path=path)
53
+ try:
54
+ report.result = ScanResult.load(path)
55
+ except SchemaError as exc:
56
+ report.error = str(exc)
57
+ return report
58
+ report.warnings = lint(report.result)
59
+ return report
60
+
61
+
62
+ def lint(result: ScanResult) -> list[str]:
63
+ """Problems that pass the schema but mean the audit is not finished."""
64
+ warnings: list[str] = []
65
+ ids = {site.id for site in result.call_sites}
66
+ from_bob = result.generated_by == "bob"
67
+
68
+ for site in result.call_sites:
69
+ if site.grading == "json_fields" and site.output_format != "json":
70
+ warnings.append(
71
+ f"{site.id}: grading is json_fields but output_format is {site.output_format}"
72
+ )
73
+ if site.via is not None and site.via in ids:
74
+ warnings.append(f"{site.id}: via {site.via} is still listed as its own call site")
75
+ if site.found_by == "bob" and not any(note.startswith("Bob:") for note in site.notes):
76
+ warnings.append(f"{site.id}: found_by is bob but there is no 'Bob:' note")
77
+ if from_bob:
78
+ missing = [name for name in ENRICHMENT_FIELDS if getattr(site, name) is None]
79
+ if missing:
80
+ warnings.append(f"{site.id}: missing {', '.join(missing)}")
81
+ if not site.model.resolved:
82
+ warnings.append(f"{site.id}: model still unresolved")
83
+ if not site.prompt_resolved:
84
+ warnings.append(f"{site.id}: prompt still unresolved")
85
+ for expression in expression_placeholders(site):
86
+ warnings.append(
87
+ f"{site.id}: placeholder {{{expression}}} is an expression; "
88
+ "use a plain name so eval inputs can fill it"
89
+ )
90
+
91
+ if from_bob and not any(site.found_by == "bob" for site in result.call_sites):
92
+ warnings.append("generated_by is bob but no call site has found_by bob")
93
+ return warnings
94
+
95
+
96
+ # --- compare ------------------------------------------------------------------
97
+
98
+
99
+ @dataclass(frozen=True)
100
+ class Metric:
101
+ name: str
102
+ ast: int
103
+ audit: int
104
+
105
+
106
+ @dataclass(frozen=True)
107
+ class SiteChange:
108
+ id: str
109
+ kind: str # split | removed | added | resolved | changed | enriched | unchanged
110
+ detail: str
111
+
112
+
113
+ @dataclass
114
+ class Comparison:
115
+ metrics: list[Metric]
116
+ changes: list[SiteChange]
117
+
118
+ def to_dict(self) -> dict[str, Any]:
119
+ return {
120
+ "metrics": [asdict(metric) for metric in self.metrics],
121
+ "changes": [asdict(change) for change in self.changes],
122
+ }
123
+
124
+
125
+ def metrics_for(result: ScanResult) -> dict[str, int]:
126
+ summary = result.summary()
127
+ sites = result.call_sites
128
+ return {
129
+ "call_sites": summary["call_sites"],
130
+ "models_resolved": summary["models_resolved"],
131
+ "prompts_resolved": summary["prompts_resolved"],
132
+ "enriched": sum(1 for site in sites if is_enriched(site)),
133
+ "found_by_bob": sum(1 for site in sites if site.found_by == "bob"),
134
+ "via_helper": sum(1 for site in sites if site.via is not None),
135
+ }
136
+
137
+
138
+ def compare_scans(ast: ScanResult, audit: ScanResult) -> Comparison:
139
+ """Side-by-side metrics plus one change row per call site id."""
140
+ before_metrics = metrics_for(ast)
141
+ after_metrics = metrics_for(audit)
142
+ metrics = [
143
+ Metric(label, before_metrics[key], after_metrics[key])
144
+ for key, label in METRIC_LABELS.items()
145
+ ]
146
+
147
+ before_sites = {site.id: site for site in ast.call_sites}
148
+ after_sites = {site.id: site for site in audit.call_sites}
149
+ changes: list[SiteChange] = []
150
+
151
+ for site_id in before_sites:
152
+ if site_id in after_sites:
153
+ continue
154
+ children = sorted(site.id for site in audit.call_sites if site.via == site_id)
155
+ if children:
156
+ changes.append(SiteChange(site_id, "split", "into " + ", ".join(children)))
157
+ else:
158
+ changes.append(SiteChange(site_id, "removed", "not in the audit"))
159
+
160
+ for site_id, after in after_sites.items():
161
+ changes.append(_change(site_id, before_sites.get(site_id), after))
162
+
163
+ changes.sort(key=lambda change: change.id)
164
+ return Comparison(metrics=metrics, changes=changes)
165
+
166
+
167
+ def _change(site_id: str, before: CallSite | None, after: CallSite) -> SiteChange:
168
+ if before is None:
169
+ detail = f"via {after.via}" if after.via else "not found by the scanner"
170
+ return SiteChange(site_id, "added", detail)
171
+
172
+ parts: list[str] = []
173
+ filled_gap = False
174
+ if after.model.value != before.model.value:
175
+ if before.model.resolved:
176
+ parts.append(f"model {before.model.value} -> {after.model.value}")
177
+ else:
178
+ parts.append(f"model resolved: {after.model.value}")
179
+ filled_gap = True
180
+ if after.prompt_resolved and not before.prompt_resolved:
181
+ parts.append("prompt resolved")
182
+ filled_gap = True
183
+ if parts:
184
+ return SiteChange(site_id, "resolved" if filled_gap else "changed", "; ".join(parts))
185
+
186
+ if is_enriched(after) and not is_enriched(before):
187
+ return SiteChange(site_id, "enriched", ", ".join(ENRICHMENT_FIELDS))
188
+ return SiteChange(site_id, "unchanged", "")