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/decide.py ADDED
@@ -0,0 +1,220 @@
1
+ """Downgrade decisions: the cheapest model that keeps enough of the baseline's quality.
2
+
3
+ Decisions use pass rate (share of cases fully right), never mean score. A model can get
4
+ most JSON fields right and still fail most cases.
5
+
6
+ A candidate qualifies when all of these hold:
7
+ - its results are complete (every case scored, no error rows),
8
+ - pass rate / baseline pass rate >= threshold,
9
+ - pass rate >= min_pass_rate (absolute floor, 0 = off),
10
+ - it costs less per call than the baseline (priced with its own measured tokens).
11
+ The cheapest qualifying candidate wins; ties go to the higher pass rate, then list order.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Iterable, Mapping, Sequence
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from downshift.config import ModelPrice
21
+ from downshift.evals import EvalSet
22
+ from downshift.runner import ResultRow, RunSummary, load_results, results_path, summarize_rows
23
+
24
+ KEEP = "keep"
25
+ DOWNGRADE = "downgrade"
26
+ EPSILON = 1e-9 # 18/22 vs 20/22 must count as exactly 0.90
27
+
28
+
29
+ def call_cost(price: ModelPrice, prompt_tokens: float, completion_tokens: float) -> float:
30
+ """USD for one call with these (average) token counts."""
31
+ total = prompt_tokens * price.input_per_mtok + completion_tokens * price.output_per_mtok
32
+ return total / 1_000_000
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ModelStats:
37
+ """Results of one model on one call site."""
38
+
39
+ summary: RunSummary
40
+ avg_judge_score: float | None = None
41
+
42
+ @property
43
+ def model(self) -> str:
44
+ return self.summary.model
45
+
46
+ @property
47
+ def pass_rate(self) -> float | None:
48
+ return self.summary.pass_rate
49
+
50
+ @property
51
+ def complete(self) -> bool:
52
+ s = self.summary
53
+ return s.cases > 0 and s.errors == 0 and s.scored == s.cases
54
+
55
+ def cost_per_call(self, price: ModelPrice) -> float | None:
56
+ s = self.summary
57
+ if s.avg_prompt_tokens is None or s.avg_completion_tokens is None:
58
+ return None
59
+ return call_cost(price, s.avg_prompt_tokens, s.avg_completion_tokens)
60
+
61
+
62
+ def stats_for(
63
+ site_id: str, model: str, case_ids: Sequence[str], rows: Mapping[str, ResultRow]
64
+ ) -> ModelStats:
65
+ """Stats for these case ids. Avg judge score counts only scored rows that have one."""
66
+ summary = summarize_rows(site_id, model, case_ids, rows)
67
+ judged = [
68
+ float(rows[c].judge_score) # type: ignore[arg-type]
69
+ for c in case_ids
70
+ if c in rows and rows[c].ok and rows[c].judge_score is not None
71
+ ]
72
+ avg = sum(judged) / len(judged) if judged else None
73
+ return ModelStats(summary=summary, avg_judge_score=avg)
74
+
75
+
76
+ def load_site_stats(
77
+ site_id: str, eval_set: EvalSet, models: Iterable[str], results_dir: Path
78
+ ) -> dict[str, ModelStats]:
79
+ """Stats per model for one site, from results files (last row per case wins)."""
80
+ case_ids = [case.id for case in eval_set.cases]
81
+ out: dict[str, ModelStats] = {}
82
+ for model in models:
83
+ rows = load_results(results_path(results_dir, site_id, model))
84
+ out[model] = stats_for(site_id, model, case_ids, rows)
85
+ return out
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class CandidateCheck:
90
+ """Why one candidate did or did not qualify."""
91
+
92
+ model: str
93
+ pass_rate: float | None
94
+ ratio: float | None
95
+ cost_per_call: float | None
96
+ passed: bool
97
+ reason: str
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class Decision:
102
+ site_id: str
103
+ action: str # KEEP or DOWNGRADE
104
+ model: str # the model to use after the decision
105
+ baseline: str
106
+ reason: str
107
+ baseline_pass_rate: float | None
108
+ baseline_below_floor: bool
109
+ checks: tuple[CandidateCheck, ...]
110
+ stats: Mapping[str, ModelStats]
111
+
112
+ @property
113
+ def downgraded(self) -> bool:
114
+ return self.action == DOWNGRADE
115
+
116
+
117
+ def _price(prices: Mapping[str, ModelPrice], model: str) -> ModelPrice:
118
+ try:
119
+ return prices[model]
120
+ except KeyError:
121
+ raise ValueError(f"no pricing for model {model!r}") from None
122
+
123
+
124
+ def _incomplete(s: RunSummary) -> str:
125
+ return f"incomplete results ({s.errors} errors, {s.scored}/{s.cases} scored)"
126
+
127
+
128
+ def _check(
129
+ model: str,
130
+ s: ModelStats | None,
131
+ *,
132
+ base_rate: float,
133
+ base_cost: float | None,
134
+ prices: Mapping[str, ModelPrice],
135
+ threshold: float,
136
+ min_pass_rate: float,
137
+ ) -> CandidateCheck:
138
+ if s is None or s.summary.scored == 0:
139
+ return CandidateCheck(model, None, None, None, False, "no results")
140
+ rate = s.pass_rate or 0.0
141
+ ratio = rate / base_rate
142
+ cost = s.cost_per_call(_price(prices, model))
143
+
144
+ def fail(reason: str) -> CandidateCheck:
145
+ return CandidateCheck(model, rate, ratio, cost, False, reason)
146
+
147
+ if not s.complete:
148
+ return fail(_incomplete(s.summary))
149
+ if ratio < threshold - EPSILON:
150
+ return fail(f"keeps {ratio:.0%} of baseline quality, needs {threshold:.0%}")
151
+ if rate < min_pass_rate - EPSILON:
152
+ return fail(f"pass rate {rate:.0%} is below the floor {min_pass_rate:.0%}")
153
+ if cost is None or base_cost is None or cost >= base_cost:
154
+ return fail("not cheaper than the baseline")
155
+ return CandidateCheck(
156
+ model, rate, ratio, cost, True, f"keeps {ratio:.0%} of baseline quality and costs less"
157
+ )
158
+
159
+
160
+ def decide_site(
161
+ site_id: str,
162
+ stats: Mapping[str, ModelStats],
163
+ *,
164
+ baseline: str,
165
+ candidates: Sequence[str],
166
+ prices: Mapping[str, ModelPrice],
167
+ threshold: float,
168
+ min_pass_rate: float = 0.0,
169
+ ) -> Decision:
170
+ """Keep the baseline or downgrade to the cheapest qualifying candidate."""
171
+ if not 0.0 < threshold <= 1.0:
172
+ raise ValueError(f"threshold must be in (0, 1], got {threshold}")
173
+ if not 0.0 <= min_pass_rate <= 1.0:
174
+ raise ValueError(f"min_pass_rate must be in [0, 1], got {min_pass_rate}")
175
+
176
+ def keep(
177
+ reason: str,
178
+ base_rate: float | None = None,
179
+ below: bool = False,
180
+ checks: tuple[CandidateCheck, ...] = (),
181
+ ) -> Decision:
182
+ return Decision(site_id, KEEP, baseline, baseline, reason, base_rate, below, checks, stats)
183
+
184
+ base = stats.get(baseline)
185
+ if base is None or base.summary.scored == 0:
186
+ return keep("no baseline results")
187
+ if not base.complete:
188
+ return keep(f"baseline has {_incomplete(base.summary)}", base.pass_rate)
189
+ base_rate = base.pass_rate or 0.0
190
+ below = base_rate < min_pass_rate - EPSILON
191
+ if base_rate == 0.0:
192
+ return keep("baseline passes no cases; nothing to compare against", 0.0, below)
193
+
194
+ base_cost = base.cost_per_call(_price(prices, baseline))
195
+ checks = tuple(
196
+ _check(
197
+ model,
198
+ stats.get(model),
199
+ base_rate=base_rate,
200
+ base_cost=base_cost,
201
+ prices=prices,
202
+ threshold=threshold,
203
+ min_pass_rate=min_pass_rate,
204
+ )
205
+ for model in candidates
206
+ if model != baseline
207
+ )
208
+ order = {c.model: i for i, c in enumerate(checks)}
209
+ passing = [c for c in checks if c.passed]
210
+ if not passing:
211
+ reason = "no candidate models" if not checks else "no cheaper model passed the checks"
212
+ return keep(reason, base_rate, below, checks)
213
+
214
+ best = min(
215
+ passing,
216
+ key=lambda c: (c.cost_per_call or 0.0, -(c.pass_rate or 0.0), order[c.model]),
217
+ )
218
+ return Decision(
219
+ site_id, DOWNGRADE, best.model, baseline, best.reason, base_rate, below, checks, stats
220
+ )
downshift/diff.py ADDED
@@ -0,0 +1,387 @@
1
+ """Projected LLM cost change between two scans (Phase 8).
2
+
3
+ Static estimate, no model calls: prompt tokens are words in the resolved prompt text,
4
+ completion tokens are max_tokens (or a default), prices and volume come from config.
5
+ A model that static analysis cannot resolve is priced as the baseline and marked assumed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from dataclasses import dataclass
12
+
13
+ from downshift.config import Config, ModelPrice
14
+ from downshift.llm import estimate_tokens
15
+ from downshift.schema import CallSite
16
+
17
+ DAYS_PER_MONTH = 30
18
+ DEFAULT_COMPLETION_TOKENS = 256
19
+
20
+ ADDED = "added"
21
+ REMOVED = "removed"
22
+ CHANGED = "changed"
23
+ UNCHANGED = "unchanged"
24
+ STATUS_SYMBOL = {ADDED: "+", CHANGED: "~", REMOVED: "-", UNCHANGED: "="}
25
+ _ORDER = {ADDED: 0, CHANGED: 1, REMOVED: 2, UNCHANGED: 3}
26
+
27
+ MARK_MODEL = "†"
28
+ MARK_OUTPUT = "‡"
29
+ MARK_PROMPT = "§"
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class SiteEstimate:
34
+ """Projected cost of one call site at one ref."""
35
+
36
+ site_id: str
37
+ model: str
38
+ model_assumed: bool
39
+ prompt_tokens: int
40
+ prompt_partial: bool
41
+ completion_tokens: int
42
+ completion_assumed: bool
43
+ calls_per_day: int
44
+ per_call: float | None
45
+ days: int = DAYS_PER_MONTH
46
+
47
+ @property
48
+ def known(self) -> bool:
49
+ return self.per_call is not None
50
+
51
+ @property
52
+ def monthly(self) -> float | None:
53
+ if self.per_call is None:
54
+ return None
55
+ return self.per_call * self.calls_per_day * self.days
56
+
57
+
58
+ def resolved_model(site: CallSite) -> str | None:
59
+ """The model name static analysis found, or None."""
60
+ ref = site.model
61
+ if ref is None or not ref.resolved or not ref.value:
62
+ return None
63
+ return ref.value
64
+
65
+
66
+ def prompt_estimate(site: CallSite) -> tuple[int, bool]:
67
+ """(estimated prompt tokens, partial) from the resolved message text."""
68
+ if not site.messages:
69
+ return 0, True
70
+ tokens = 0
71
+ partial = False
72
+ for message in site.messages:
73
+ if message.resolved:
74
+ tokens += estimate_tokens(message.content)
75
+ else:
76
+ partial = True
77
+ return tokens, partial
78
+
79
+
80
+ def estimate_site(
81
+ site: CallSite,
82
+ *,
83
+ prices: Mapping[str, ModelPrice],
84
+ baseline: str,
85
+ calls_per_day: Callable[[str], int],
86
+ days: int = DAYS_PER_MONTH,
87
+ default_completion: int = DEFAULT_COMPLETION_TOKENS,
88
+ ) -> SiteEstimate:
89
+ found = resolved_model(site)
90
+ model = found if found is not None else baseline
91
+ prompt_tokens, partial = prompt_estimate(site)
92
+ if site.max_tokens is not None:
93
+ completion, completion_assumed = site.max_tokens, False
94
+ else:
95
+ completion, completion_assumed = default_completion, True
96
+ price = prices.get(model)
97
+ per_call = price.cost(prompt_tokens, completion) if price is not None else None
98
+ return SiteEstimate(
99
+ site_id=site.id,
100
+ model=model,
101
+ model_assumed=found is None,
102
+ prompt_tokens=prompt_tokens,
103
+ prompt_partial=partial,
104
+ completion_tokens=completion,
105
+ completion_assumed=completion_assumed,
106
+ calls_per_day=calls_per_day(site.id),
107
+ per_call=per_call,
108
+ days=days,
109
+ )
110
+
111
+
112
+ def _monthly(estimate: SiteEstimate | None) -> float:
113
+ if estimate is None:
114
+ return 0.0
115
+ return estimate.monthly or 0.0
116
+
117
+
118
+ def _model_label(estimate: SiteEstimate) -> str:
119
+ return estimate.model + (MARK_MODEL if estimate.model_assumed else "")
120
+
121
+
122
+ def _reasons(before: SiteEstimate, after: SiteEstimate) -> tuple[str, ...]:
123
+ reasons: list[str] = []
124
+ if before.model != after.model or before.model_assumed != after.model_assumed:
125
+ reasons.append(f"model {_model_label(before)} -> {_model_label(after)}")
126
+ if before.prompt_tokens != after.prompt_tokens:
127
+ reasons.append(f"prompt ~{before.prompt_tokens} -> ~{after.prompt_tokens} tokens")
128
+ if before.completion_tokens != after.completion_tokens:
129
+ reasons.append(f"max output {before.completion_tokens} -> {after.completion_tokens} tokens")
130
+ if before.calls_per_day != after.calls_per_day:
131
+ reasons.append(f"calls/day {before.calls_per_day:,} -> {after.calls_per_day:,}")
132
+ return tuple(reasons)
133
+
134
+
135
+ @dataclass(frozen=True)
136
+ class SiteDiff:
137
+ site_id: str
138
+ status: str
139
+ before: SiteEstimate | None
140
+ after: SiteEstimate | None
141
+ reasons: tuple[str, ...] = ()
142
+
143
+ @property
144
+ def known(self) -> bool:
145
+ sides = [e for e in (self.before, self.after) if e is not None]
146
+ return all(e.known for e in sides)
147
+
148
+ @property
149
+ def delta(self) -> float | None:
150
+ if not self.known:
151
+ return None
152
+ return _monthly(self.after) - _monthly(self.before)
153
+
154
+ @property
155
+ def model_assumed(self) -> bool:
156
+ return any(e is not None and e.model_assumed for e in (self.before, self.after))
157
+
158
+ @property
159
+ def completion_assumed(self) -> bool:
160
+ return any(e is not None and e.completion_assumed for e in (self.before, self.after))
161
+
162
+ @property
163
+ def prompt_partial(self) -> bool:
164
+ return any(e is not None and e.prompt_partial for e in (self.before, self.after))
165
+
166
+
167
+ @dataclass(frozen=True)
168
+ class CostDiff:
169
+ sites: tuple[SiteDiff, ...]
170
+ baseline: str
171
+ days: int = DAYS_PER_MONTH
172
+ default_completion: int = DEFAULT_COMPLETION_TOKENS
173
+
174
+ def _with(self, status: str) -> list[SiteDiff]:
175
+ return [s for s in self.sites if s.status == status]
176
+
177
+ @property
178
+ def added(self) -> list[SiteDiff]:
179
+ return self._with(ADDED)
180
+
181
+ @property
182
+ def removed(self) -> list[SiteDiff]:
183
+ return self._with(REMOVED)
184
+
185
+ @property
186
+ def changed(self) -> list[SiteDiff]:
187
+ return self._with(CHANGED)
188
+
189
+ @property
190
+ def unchanged(self) -> list[SiteDiff]:
191
+ return self._with(UNCHANGED)
192
+
193
+ @property
194
+ def has_changes(self) -> bool:
195
+ return any(s.status != UNCHANGED for s in self.sites)
196
+
197
+ @property
198
+ def unknown(self) -> list[SiteDiff]:
199
+ return [s for s in self.sites if not s.known]
200
+
201
+ @property
202
+ def before_monthly(self) -> float:
203
+ return sum(_monthly(s.before) for s in self.sites if s.known)
204
+
205
+ @property
206
+ def after_monthly(self) -> float:
207
+ return sum(_monthly(s.after) for s in self.sites if s.known)
208
+
209
+ @property
210
+ def delta(self) -> float:
211
+ return self.after_monthly - self.before_monthly
212
+
213
+ @property
214
+ def delta_pct(self) -> float | None:
215
+ if self.before_monthly <= 0:
216
+ return None
217
+ return self.delta / self.before_monthly * 100
218
+
219
+
220
+ def diff_sites(
221
+ base: Sequence[CallSite],
222
+ head: Sequence[CallSite],
223
+ *,
224
+ prices: Mapping[str, ModelPrice],
225
+ baseline: str,
226
+ calls_per_day: Callable[[str], int],
227
+ days: int = DAYS_PER_MONTH,
228
+ default_completion: int = DEFAULT_COMPLETION_TOKENS,
229
+ ) -> CostDiff:
230
+ """Compare two sets of call sites by id and price both sides."""
231
+
232
+ def est(site: CallSite) -> SiteEstimate:
233
+ return estimate_site(
234
+ site,
235
+ prices=prices,
236
+ baseline=baseline,
237
+ calls_per_day=calls_per_day,
238
+ days=days,
239
+ default_completion=default_completion,
240
+ )
241
+
242
+ before = {s.id: est(s) for s in base}
243
+ after = {s.id: est(s) for s in head}
244
+ diffs: list[SiteDiff] = []
245
+ for site_id in sorted(set(before) | set(after)):
246
+ b = before.get(site_id)
247
+ a = after.get(site_id)
248
+ if b is None:
249
+ diffs.append(SiteDiff(site_id, ADDED, None, a))
250
+ elif a is None:
251
+ diffs.append(SiteDiff(site_id, REMOVED, b, None))
252
+ else:
253
+ reasons = _reasons(b, a)
254
+ status = CHANGED if reasons else UNCHANGED
255
+ diffs.append(SiteDiff(site_id, status, b, a, reasons))
256
+ diffs.sort(key=lambda d: (_ORDER[d.status], d.site_id))
257
+ return CostDiff(
258
+ sites=tuple(diffs), baseline=baseline, days=days, default_completion=default_completion
259
+ )
260
+
261
+
262
+ def diff_for_config(
263
+ base: Sequence[CallSite],
264
+ head: Sequence[CallSite],
265
+ config: Config,
266
+ *,
267
+ days: int = DAYS_PER_MONTH,
268
+ default_completion: int = DEFAULT_COMPLETION_TOKENS,
269
+ ) -> CostDiff:
270
+ return diff_sites(
271
+ base,
272
+ head,
273
+ prices=config.pricing,
274
+ baseline=config.models.baseline,
275
+ calls_per_day=config.volume.calls_per_day,
276
+ days=days,
277
+ default_completion=default_completion,
278
+ )
279
+
280
+
281
+ def _money(value: float | None) -> str:
282
+ if value is None:
283
+ return "unknown"
284
+ return f"${value:,.2f}"
285
+
286
+
287
+ def _signed(value: float | None) -> str:
288
+ if value is None:
289
+ return "unknown"
290
+ if abs(value) < 0.005:
291
+ return "$0.00"
292
+ sign = "+" if value > 0 else "-"
293
+ return f"{sign}${abs(value):,.2f}"
294
+
295
+
296
+ def _tokens(estimate: SiteEstimate) -> str:
297
+ prompt = f"{estimate.prompt_tokens}{MARK_PROMPT if estimate.prompt_partial else ''}"
298
+ out = f"{estimate.completion_tokens}{MARK_OUTPUT if estimate.completion_assumed else ''}"
299
+ return f"{prompt} + {out}"
300
+
301
+
302
+ def _pair(before: str | None, after: str | None) -> str:
303
+ if before is None:
304
+ return after or ""
305
+ if after is None:
306
+ return before
307
+ return before if before == after else f"{before} -> {after}"
308
+
309
+
310
+ def _row(site: SiteDiff) -> str:
311
+ b, a = site.before, site.after
312
+ model = _pair(_model_label(b) if b else None, _model_label(a) if a else None)
313
+ tokens = _pair(_tokens(b) if b else None, _tokens(a) if a else None)
314
+ calls = _pair(f"{b.calls_per_day:,}" if b else None, f"{a.calls_per_day:,}" if a else None)
315
+ if b is None:
316
+ monthly = _money(a.monthly if a else None)
317
+ elif a is None:
318
+ monthly = "$0.00"
319
+ else:
320
+ monthly = f"{_money(b.monthly)} -> {_money(a.monthly)}"
321
+ cells = [
322
+ STATUS_SYMBOL[site.status],
323
+ f"`{site.site_id}`",
324
+ model,
325
+ tokens,
326
+ calls,
327
+ monthly,
328
+ _signed(site.delta),
329
+ ]
330
+ return "| " + " | ".join(cells) + " |"
331
+
332
+
333
+ def render_markdown(diff: CostDiff, *, base: str = "main", head: str = "HEAD") -> str:
334
+ """Deterministic markdown for a PR comment."""
335
+ counts = (
336
+ f"{len(diff.added)} added, {len(diff.changed)} changed, "
337
+ f"{len(diff.removed)} removed, {len(diff.unchanged)} unchanged"
338
+ )
339
+ lines = ["## Downshift cost diff", ""]
340
+ if not diff.has_changes:
341
+ lines.append(
342
+ f"No LLM call site cost changes in `{head}` vs `{base}` ({counts}). "
343
+ f"Projected monthly cost stays at **{_money(diff.after_monthly)}**."
344
+ )
345
+ lines.append("")
346
+ else:
347
+ pct = diff.delta_pct
348
+ pct_text = "" if pct is None else f", {'+' if pct > 0 else ''}{pct:.1f}%"
349
+ lines.append(
350
+ f"Projected monthly LLM cost, `{head}` vs `{base}`: "
351
+ f"**{_money(diff.before_monthly)} -> {_money(diff.after_monthly)} "
352
+ f"({_signed(diff.delta)}{pct_text})**"
353
+ )
354
+ lines.append("")
355
+ lines.append(f"Call sites: {counts}. Key: `+` added, `~` changed, `-` removed.")
356
+ lines.append("")
357
+ lines.append("| | Call site | Model | In + out | Calls/day | Monthly | Delta |")
358
+ lines.append("|---|---|---|---|---|---|---|")
359
+ lines.extend(_row(s) for s in diff.sites if s.status != UNCHANGED)
360
+ lines.append("")
361
+
362
+ shown = [s for s in diff.sites if s.status != UNCHANGED]
363
+ notes: list[str] = []
364
+ if any(s.model_assumed for s in shown):
365
+ notes.append(
366
+ f"{MARK_MODEL} Model not resolved by static analysis; priced as the baseline "
367
+ f"`{diff.baseline}`. Run the Bob auditor for exact models."
368
+ )
369
+ if any(s.completion_assumed for s in shown):
370
+ notes.append(f"{MARK_OUTPUT} No max_tokens set; assumed {diff.default_completion}.")
371
+ if any(s.prompt_partial for s in shown):
372
+ notes.append(f"{MARK_PROMPT} Prompt only partly resolved; unresolved text not counted.")
373
+ if diff.unknown:
374
+ notes.append(
375
+ f"{len(diff.unknown)} call site(s) use a model with no configured price "
376
+ "and are left out of the totals."
377
+ )
378
+ for note in notes:
379
+ lines.append(f"- {note}")
380
+ if notes:
381
+ lines.append("")
382
+ lines.append(
383
+ "_Projection, not a bill: tokens estimated from source (words in resolved prompt "
384
+ f"text; output = max_tokens, or {diff.default_completion} if unset) x illustrative "
385
+ f"prices x configured calls/day x {diff.days} days._"
386
+ )
387
+ return "\n".join(lines) + "\n"