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/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"