downshift 0.1.0.dev0__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 +755 -0
- downshift/config.py +348 -0
- downshift/cost.py +212 -0
- downshift/decide.py +220 -0
- downshift/evalgen.py +220 -0
- downshift/evals.py +395 -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 +310 -0
- downshift/schema.py +356 -0
- downshift/scorer.py +219 -0
- downshift-0.1.0.dev0.dist-info/METADATA +24 -0
- downshift-0.1.0.dev0.dist-info/RECORD +21 -0
- downshift-0.1.0.dev0.dist-info/WHEEL +4 -0
- downshift-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- downshift-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
downshift/report.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Assemble eval results into a Report and render it as Markdown.
|
|
2
|
+
|
|
3
|
+
`build_report` reads eval sets and result files for every call site in a ScanResult,
|
|
4
|
+
calls decide_site and cost_summary, and returns a Report.
|
|
5
|
+
`render_markdown` turns a Report into a deterministic Markdown string (no dates,
|
|
6
|
+
no version numbers, no absolute paths).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from downshift.config import Config
|
|
15
|
+
from downshift.cost import CostSummary, cost_summary
|
|
16
|
+
from downshift.decide import (
|
|
17
|
+
DOWNGRADE,
|
|
18
|
+
CandidateCheck,
|
|
19
|
+
Decision,
|
|
20
|
+
ModelStats,
|
|
21
|
+
decide_site,
|
|
22
|
+
load_site_stats,
|
|
23
|
+
)
|
|
24
|
+
from downshift.evals import EVAL_SUFFIX, EvalError, load_eval_set, slug_for
|
|
25
|
+
from downshift.schema import CallSite, ScanResult
|
|
26
|
+
|
|
27
|
+
NEAR_MISS_MARGIN = 0.05
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ReportError(Exception):
|
|
31
|
+
"""Raised when an eval file cannot be loaded for a call site."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Report:
|
|
36
|
+
"""All data needed to render the report."""
|
|
37
|
+
|
|
38
|
+
sites: tuple[CallSite, ...]
|
|
39
|
+
decisions: tuple[Decision, ...]
|
|
40
|
+
costs: CostSummary
|
|
41
|
+
missing_evals: tuple[str, ...] # site ids with no eval file, sorted
|
|
42
|
+
baseline: str
|
|
43
|
+
candidates: tuple[str, ...]
|
|
44
|
+
threshold: float
|
|
45
|
+
min_pass_rate: float
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def downgraded(self) -> tuple[Decision, ...]:
|
|
49
|
+
return tuple(d for d in self.decisions if d.action == DOWNGRADE)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def below_floor(self) -> tuple[Decision, ...]:
|
|
53
|
+
return tuple(d for d in self.decisions if d.baseline_below_floor)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def near_misses(self) -> tuple[tuple[str, CandidateCheck], ...]:
|
|
57
|
+
"""(site_id, CandidateCheck) for failed checks near the threshold."""
|
|
58
|
+
result: list[tuple[str, CandidateCheck]] = []
|
|
59
|
+
low = self.threshold - NEAR_MISS_MARGIN
|
|
60
|
+
for decision in self.decisions:
|
|
61
|
+
for check in decision.checks:
|
|
62
|
+
if check.passed:
|
|
63
|
+
continue
|
|
64
|
+
if check.ratio is None:
|
|
65
|
+
continue
|
|
66
|
+
if not (low <= check.ratio < self.threshold):
|
|
67
|
+
continue
|
|
68
|
+
if check.pass_rate is None or check.pass_rate < self.min_pass_rate:
|
|
69
|
+
continue
|
|
70
|
+
result.append((decision.site_id, check))
|
|
71
|
+
return tuple(result)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def missing_data(self) -> tuple[Decision, ...]:
|
|
75
|
+
"""Decisions whose baseline stats are missing or incomplete."""
|
|
76
|
+
result: list[Decision] = []
|
|
77
|
+
for decision in self.decisions:
|
|
78
|
+
base = decision.stats.get(decision.baseline)
|
|
79
|
+
if base is None or not base.complete:
|
|
80
|
+
result.append(decision)
|
|
81
|
+
return tuple(result)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_report(
|
|
85
|
+
scan: ScanResult,
|
|
86
|
+
config: Config,
|
|
87
|
+
evals_dir: Path,
|
|
88
|
+
results_dir: Path,
|
|
89
|
+
*,
|
|
90
|
+
threshold: float | None = None,
|
|
91
|
+
min_pass_rate: float | None = None,
|
|
92
|
+
) -> Report:
|
|
93
|
+
"""Assemble a Report from disk.
|
|
94
|
+
|
|
95
|
+
For each call site in the scan (sorted by id): if the eval file is missing
|
|
96
|
+
the site goes into missing_evals; if the file has problems, ReportError is raised.
|
|
97
|
+
"""
|
|
98
|
+
eff_threshold = threshold if threshold is not None else config.quality_threshold
|
|
99
|
+
eff_min_pass_rate = min_pass_rate if min_pass_rate is not None else config.min_pass_rate
|
|
100
|
+
|
|
101
|
+
sites_sorted = sorted(scan.call_sites, key=lambda s: s.id)
|
|
102
|
+
|
|
103
|
+
decided_sites: list[CallSite] = []
|
|
104
|
+
decisions: list[Decision] = []
|
|
105
|
+
missing_evals: list[str] = []
|
|
106
|
+
|
|
107
|
+
for site in sites_sorted:
|
|
108
|
+
eval_path = evals_dir / f"{slug_for(site.id)}{EVAL_SUFFIX}"
|
|
109
|
+
if not eval_path.is_file():
|
|
110
|
+
missing_evals.append(site.id)
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
eval_set = load_eval_set(eval_path)
|
|
115
|
+
except EvalError as exc:
|
|
116
|
+
raise ReportError(f"{eval_path}: {exc}") from exc
|
|
117
|
+
if eval_set.problems:
|
|
118
|
+
raise ReportError(f"{eval_path}: {eval_set.problems[0]}")
|
|
119
|
+
|
|
120
|
+
stats = load_site_stats(site.id, eval_set, config.models.all_models, results_dir)
|
|
121
|
+
decision = decide_site(
|
|
122
|
+
site.id,
|
|
123
|
+
stats,
|
|
124
|
+
baseline=config.models.baseline,
|
|
125
|
+
candidates=list(config.models.candidates),
|
|
126
|
+
prices=config.pricing,
|
|
127
|
+
threshold=eff_threshold,
|
|
128
|
+
min_pass_rate=eff_min_pass_rate,
|
|
129
|
+
)
|
|
130
|
+
decided_sites.append(site)
|
|
131
|
+
decisions.append(decision)
|
|
132
|
+
|
|
133
|
+
costs = cost_summary(decisions, config.pricing, config.volume)
|
|
134
|
+
|
|
135
|
+
return Report(
|
|
136
|
+
sites=tuple(decided_sites),
|
|
137
|
+
decisions=tuple(decisions),
|
|
138
|
+
costs=costs,
|
|
139
|
+
missing_evals=tuple(sorted(missing_evals)),
|
|
140
|
+
baseline=config.models.baseline,
|
|
141
|
+
candidates=config.models.candidates,
|
|
142
|
+
threshold=eff_threshold,
|
|
143
|
+
min_pass_rate=eff_min_pass_rate,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# Rendering helpers
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _fmt_money(x: float | None) -> str:
|
|
153
|
+
if x is None:
|
|
154
|
+
return "n/a"
|
|
155
|
+
return f"${x:,.2f}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _fmt_savings_pct(x: float | None) -> str:
|
|
159
|
+
if x is None:
|
|
160
|
+
return "n/a"
|
|
161
|
+
return f"{x:.1%}"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _fmt_pass_rate(x: float | None) -> str:
|
|
165
|
+
if x is None:
|
|
166
|
+
return "n/a"
|
|
167
|
+
return f"{x:.0%}"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def render_markdown(report: Report) -> str:
|
|
171
|
+
"""Render a Report as a deterministic Markdown string ending with one newline."""
|
|
172
|
+
lines: list[str] = []
|
|
173
|
+
|
|
174
|
+
# ------------------------------------------------------------------ header
|
|
175
|
+
lines.append("# Downshift report")
|
|
176
|
+
lines.append("")
|
|
177
|
+
lines.append(
|
|
178
|
+
"> Costs are projections: measured tokens per call x illustrative prices x assumed volume"
|
|
179
|
+
)
|
|
180
|
+
lines.append("> from the config. They are not a bill.")
|
|
181
|
+
lines.append("")
|
|
182
|
+
|
|
183
|
+
# ------------------------------------------------------------------ summary
|
|
184
|
+
lines.append("## Summary")
|
|
185
|
+
lines.append("")
|
|
186
|
+
lines.append("| | Monthly cost |")
|
|
187
|
+
lines.append("|---|---:|")
|
|
188
|
+
|
|
189
|
+
costs = report.costs
|
|
190
|
+
before = _fmt_money(costs.before_monthly if costs.sites else None)
|
|
191
|
+
after = _fmt_money(costs.after_monthly if costs.sites else None)
|
|
192
|
+
|
|
193
|
+
# Only show a number when we actually have known costs
|
|
194
|
+
known_sites = [s for s in costs.sites if s.known]
|
|
195
|
+
if known_sites:
|
|
196
|
+
before = _fmt_money(costs.before_monthly)
|
|
197
|
+
after = _fmt_money(costs.after_monthly)
|
|
198
|
+
else:
|
|
199
|
+
before = "n/a"
|
|
200
|
+
after = "n/a"
|
|
201
|
+
|
|
202
|
+
lines.append(f"| Before (all on `{report.baseline}`) | {before} |")
|
|
203
|
+
lines.append(f"| After | {after} |")
|
|
204
|
+
|
|
205
|
+
savings_str = _fmt_money(costs.savings if known_sites else None)
|
|
206
|
+
savings_pct_str = _fmt_savings_pct(costs.savings_pct)
|
|
207
|
+
lines.append(f"| Savings | {savings_str} ({savings_pct_str}) |")
|
|
208
|
+
lines.append("")
|
|
209
|
+
|
|
210
|
+
total_decided = len(report.decisions)
|
|
211
|
+
n_down = len(report.downgraded)
|
|
212
|
+
lines.append(f"Downgraded **{n_down} of {total_decided}** call sites.")
|
|
213
|
+
lines.append("")
|
|
214
|
+
|
|
215
|
+
rule = (
|
|
216
|
+
f"Rule: a cheaper model must keep at least {report.threshold:.0%} of the baseline pass rate"
|
|
217
|
+
)
|
|
218
|
+
if report.min_pass_rate > 0:
|
|
219
|
+
rule += (
|
|
220
|
+
f" and pass at least {report.min_pass_rate:.0%} of cases on its own."
|
|
221
|
+
" Decisions use pass rate, not mean score."
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
rule += ". Decisions use pass rate, not mean score."
|
|
225
|
+
lines.append(rule)
|
|
226
|
+
lines.append("")
|
|
227
|
+
|
|
228
|
+
if costs.unknown:
|
|
229
|
+
n_unknown = len(costs.unknown)
|
|
230
|
+
lines.append(
|
|
231
|
+
f"Totals exclude {n_unknown} call sites with unknown cost (see Needs attention)."
|
|
232
|
+
)
|
|
233
|
+
lines.append("")
|
|
234
|
+
|
|
235
|
+
# ------------------------------------------------------------------ decisions table
|
|
236
|
+
lines.append("## Decisions")
|
|
237
|
+
lines.append("")
|
|
238
|
+
lines.append(
|
|
239
|
+
"| Call site | Grading | Decision | Model | Pass rate | Before / month | After / month |"
|
|
240
|
+
)
|
|
241
|
+
lines.append("|---|---|---|---|---|---:|---:|")
|
|
242
|
+
|
|
243
|
+
for site, decision in zip(report.sites, report.decisions, strict=True):
|
|
244
|
+
grading = site.grading or "-"
|
|
245
|
+
action = decision.action
|
|
246
|
+
model = f"`{decision.model}`"
|
|
247
|
+
|
|
248
|
+
base_rate = decision.baseline_pass_rate
|
|
249
|
+
if action == DOWNGRADE:
|
|
250
|
+
# chosen model pass rate
|
|
251
|
+
chosen_stats = decision.stats.get(decision.model)
|
|
252
|
+
chosen_rate = chosen_stats.pass_rate if chosen_stats is not None else None
|
|
253
|
+
pass_rate_cell = f"{_fmt_pass_rate(base_rate)} -> {_fmt_pass_rate(chosen_rate)}"
|
|
254
|
+
else:
|
|
255
|
+
pass_rate_cell = _fmt_pass_rate(base_rate)
|
|
256
|
+
|
|
257
|
+
site_cost_obj = next((c for c in costs.sites if c.site_id == decision.site_id), None)
|
|
258
|
+
before_m = _fmt_money(site_cost_obj.before_monthly if site_cost_obj else None)
|
|
259
|
+
after_m = _fmt_money(site_cost_obj.after_monthly if site_cost_obj else None)
|
|
260
|
+
|
|
261
|
+
lines.append(
|
|
262
|
+
f"| `{site.id}` | {grading} | {action} | {model}"
|
|
263
|
+
f" | {pass_rate_cell} | {before_m} | {after_m} |"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
lines.append("")
|
|
267
|
+
|
|
268
|
+
# ------------------------------------------------------------------ quality table
|
|
269
|
+
all_models = (report.baseline, *report.candidates)
|
|
270
|
+
lines.append("## Quality per model")
|
|
271
|
+
lines.append("")
|
|
272
|
+
model_headers = " | ".join(f"`{m}`" for m in all_models)
|
|
273
|
+
lines.append(f"| Call site | {model_headers} |")
|
|
274
|
+
sep_cols = " | ".join("---" for _ in all_models)
|
|
275
|
+
lines.append(f"|---|{sep_cols}|")
|
|
276
|
+
|
|
277
|
+
has_judge = False
|
|
278
|
+
for site in report.sites:
|
|
279
|
+
if site.grading == "judge":
|
|
280
|
+
has_judge = True
|
|
281
|
+
break
|
|
282
|
+
|
|
283
|
+
for site, decision in zip(report.sites, report.decisions, strict=True):
|
|
284
|
+
cells: list[str] = []
|
|
285
|
+
for model in all_models:
|
|
286
|
+
ms: ModelStats | None = decision.stats.get(model)
|
|
287
|
+
if ms is None or ms.summary.scored == 0:
|
|
288
|
+
cell = "n/a"
|
|
289
|
+
else:
|
|
290
|
+
s = ms.summary
|
|
291
|
+
cell = f"{s.passed}/{s.scored} ({_fmt_pass_rate(ms.pass_rate)})"
|
|
292
|
+
if ms.avg_judge_score is not None:
|
|
293
|
+
cell += f", judge {ms.avg_judge_score:.1f}/5"
|
|
294
|
+
if s.errors > 0:
|
|
295
|
+
cell += f", {s.errors} errors"
|
|
296
|
+
# Bold the chosen model's cell
|
|
297
|
+
chosen = decision.model
|
|
298
|
+
if model == chosen:
|
|
299
|
+
cell = f"**{cell}**"
|
|
300
|
+
cells.append(cell)
|
|
301
|
+
|
|
302
|
+
row = " | ".join(cells)
|
|
303
|
+
lines.append(f"| `{site.id}` | {row} |")
|
|
304
|
+
|
|
305
|
+
lines.append("")
|
|
306
|
+
if has_judge:
|
|
307
|
+
lines.append(
|
|
308
|
+
"Judge-graded cases pass at 4/5 or higher; judge scores are averages on a 1 to 5 scale."
|
|
309
|
+
)
|
|
310
|
+
lines.append("")
|
|
311
|
+
|
|
312
|
+
# ------------------------------------------------------------------ needs attention
|
|
313
|
+
lines.append("## Needs attention")
|
|
314
|
+
lines.append("")
|
|
315
|
+
|
|
316
|
+
below_floor = report.below_floor
|
|
317
|
+
near_misses = report.near_misses
|
|
318
|
+
missing_data_decisions = report.missing_data
|
|
319
|
+
missing_evals = report.missing_evals
|
|
320
|
+
|
|
321
|
+
has_floor_section = report.min_pass_rate > 0 and bool(below_floor)
|
|
322
|
+
has_near_miss = bool(near_misses)
|
|
323
|
+
has_missing = bool(missing_evals) or bool(missing_data_decisions)
|
|
324
|
+
|
|
325
|
+
if not has_floor_section and not has_near_miss and not has_missing:
|
|
326
|
+
lines.append("Nothing needs attention.")
|
|
327
|
+
lines.append("")
|
|
328
|
+
else:
|
|
329
|
+
if has_floor_section:
|
|
330
|
+
lines.append("### Baseline below the floor")
|
|
331
|
+
lines.append("")
|
|
332
|
+
for decision in below_floor:
|
|
333
|
+
base_rate = decision.baseline_pass_rate
|
|
334
|
+
lines.append(
|
|
335
|
+
f"- `{decision.site_id}`: baseline passes"
|
|
336
|
+
f" {_fmt_pass_rate(base_rate)} of cases,"
|
|
337
|
+
f" below the {_fmt_pass_rate(report.min_pass_rate)} floor."
|
|
338
|
+
" Improve the prompt or model before downgrading."
|
|
339
|
+
)
|
|
340
|
+
lines.append("")
|
|
341
|
+
|
|
342
|
+
if has_near_miss:
|
|
343
|
+
lines.append("### Near misses")
|
|
344
|
+
lines.append("")
|
|
345
|
+
for site_id, check in near_misses:
|
|
346
|
+
lines.append(
|
|
347
|
+
f"- `{site_id}`: `{check.model}` keeps"
|
|
348
|
+
f" {_fmt_pass_rate(check.ratio)} of the baseline pass"
|
|
349
|
+
f" rate (needs {_fmt_pass_rate(report.threshold)})."
|
|
350
|
+
)
|
|
351
|
+
lines.append("")
|
|
352
|
+
|
|
353
|
+
if has_missing:
|
|
354
|
+
lines.append("### Missing data")
|
|
355
|
+
lines.append("")
|
|
356
|
+
for sid in missing_evals:
|
|
357
|
+
lines.append(f"- `{sid}`: no eval set, not decided.")
|
|
358
|
+
for decision in missing_data_decisions:
|
|
359
|
+
lines.append(f"- `{decision.site_id}`: {decision.reason}.")
|
|
360
|
+
lines.append("")
|
|
361
|
+
|
|
362
|
+
# ------------------------------------------------------------------ details
|
|
363
|
+
lines.append("## Details")
|
|
364
|
+
lines.append("")
|
|
365
|
+
for site, decision in zip(report.sites, report.decisions, strict=True):
|
|
366
|
+
action_text = (
|
|
367
|
+
f"downgrade to <code>{decision.model}</code>"
|
|
368
|
+
if decision.action == DOWNGRADE
|
|
369
|
+
else f"keep <code>{decision.model}</code>"
|
|
370
|
+
)
|
|
371
|
+
lines.append("<details>")
|
|
372
|
+
lines.append(f"<summary><code>{site.id}</code>: {action_text}</summary>")
|
|
373
|
+
lines.append("")
|
|
374
|
+
lines.append(f"- Decision: {decision.reason}")
|
|
375
|
+
for check in decision.checks:
|
|
376
|
+
lines.append(f"- `{check.model}`: {check.reason}")
|
|
377
|
+
lines.append("")
|
|
378
|
+
lines.append("</details>")
|
|
379
|
+
|
|
380
|
+
return "\n".join(lines) + "\n"
|