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 +3 -0
- downshift/audit.py +188 -0
- downshift/cli.py +930 -0
- downshift/config.py +348 -0
- downshift/cost.py +212 -0
- downshift/decide.py +220 -0
- downshift/diff.py +387 -0
- downshift/evalgen.py +220 -0
- downshift/evals.py +395 -0
- downshift/export.py +412 -0
- downshift/gitref.py +65 -0
- downshift/llm.py +171 -0
- downshift/py.typed +0 -0
- downshift/report.py +380 -0
- downshift/resolve.py +439 -0
- downshift/runner.py +312 -0
- downshift/scanner.py +334 -0
- downshift/schema.py +356 -0
- downshift/scorer.py +219 -0
- downshift-0.1.0.dist-info/METADATA +218 -0
- downshift-0.1.0.dist-info/RECORD +24 -0
- downshift-0.1.0.dist-info/WHEEL +4 -0
- downshift-0.1.0.dist-info/entry_points.txt +2 -0
- downshift-0.1.0.dist-info/licenses/LICENSE +21 -0
downshift/__init__.py
ADDED
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", "")
|