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/runner.py ADDED
@@ -0,0 +1,312 @@
1
+ """Run eval sets against models and store scored results.
2
+
3
+ For each call site and model, the runner renders the site's prompt with every
4
+ eval case, calls the model with the site's own settings, scores the output and
5
+ appends one JSON line per case to results/<slug>/<model>.jsonl.
6
+
7
+ Runs are resumable: cases that already have a scored row are skipped, and rows
8
+ that failed (model or judge error) are retried. The last row for a case wins.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ from collections.abc import Callable, Mapping, Sequence
17
+ from dataclasses import asdict, dataclass, replace
18
+ from dataclasses import fields as dataclass_fields
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from downshift.evals import EvalCase, EvalSet, graded_fields, render_messages, slug_for
23
+ from downshift.llm import LLMClient, LLMError
24
+ from downshift.schema import CallSite
25
+ from downshift.scorer import Judge, score_case
26
+
27
+ RESULTS_SUFFIX = ".jsonl"
28
+ WARMUP_MESSAGES: list[dict[str, str]] = [{"role": "user", "content": "Reply with the word OK."}]
29
+
30
+
31
+ def model_filename(model: str) -> str:
32
+ """Filesystem-safe model name: `qwen2.5:7b` -> `qwen2.5-7b`."""
33
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", model).strip("-") or "model"
34
+
35
+
36
+ def results_path(results_dir: Path, site_id: str, model: str) -> Path:
37
+ return results_dir / slug_for(site_id) / f"{model_filename(model)}{RESULTS_SUFFIX}"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ResultRow:
42
+ """One eval case run on one model."""
43
+
44
+ case_id: str
45
+ model: str
46
+ output: str = ""
47
+ prompt_tokens: int = 0
48
+ completion_tokens: int = 0
49
+ latency_s: float = 0.0
50
+ score: float | None = None
51
+ passed: bool | None = None
52
+ detail: str = ""
53
+ judge_score: int | None = None
54
+ error: str | None = None
55
+ judge_model: str | None = None
56
+
57
+ @property
58
+ def ok(self) -> bool:
59
+ """Scored without errors."""
60
+ return self.error is None and self.score is not None
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ data = asdict(self)
64
+ data["latency_s"] = round(self.latency_s, 3)
65
+ return data
66
+
67
+ @classmethod
68
+ def from_dict(cls, data: Mapping[str, Any]) -> ResultRow:
69
+ known = {f.name for f in dataclass_fields(cls)}
70
+ return cls(**{k: v for k, v in data.items() if k in known})
71
+
72
+
73
+ def load_results(path: Path) -> dict[str, ResultRow]:
74
+ """Rows by case id. Unreadable lines are skipped; the last row for a case wins."""
75
+ rows: dict[str, ResultRow] = {}
76
+ if not path.is_file():
77
+ return rows
78
+ for raw in path.read_text(encoding="utf-8").splitlines():
79
+ if not raw.strip():
80
+ continue
81
+ try:
82
+ row = ResultRow.from_dict(json.loads(raw))
83
+ except (json.JSONDecodeError, TypeError, AttributeError):
84
+ continue
85
+ rows[row.case_id] = row
86
+ return rows
87
+
88
+
89
+ def append_row(path: Path, row: ResultRow) -> None:
90
+ """Append one row, starting a new line if a previous write was cut off."""
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ prefix = ""
93
+ if path.is_file() and path.stat().st_size:
94
+ with path.open("rb") as fh:
95
+ fh.seek(-1, os.SEEK_END)
96
+ if fh.read(1) != b"\n":
97
+ prefix = "\n"
98
+ with path.open("a", encoding="utf-8") as fh:
99
+ fh.write(prefix + json.dumps(row.to_dict(), ensure_ascii=False) + "\n")
100
+
101
+
102
+ @dataclass(frozen=True)
103
+ class RunSummary:
104
+ """Totals for one call site on one model, over the cases in scope."""
105
+
106
+ site_id: str
107
+ model: str
108
+ cases: int
109
+ scored: int
110
+ passed: int
111
+ errors: int
112
+ new: int
113
+ mean_score: float | None
114
+ avg_latency_s: float | None
115
+ avg_prompt_tokens: float | None
116
+ avg_completion_tokens: float | None
117
+
118
+ @property
119
+ def pass_rate(self) -> float | None:
120
+ return self.passed / self.scored if self.scored else None
121
+
122
+
123
+ def _mean(values: Sequence[float]) -> float | None:
124
+ return sum(values) / len(values) if values else None
125
+
126
+
127
+ def summarize_rows(
128
+ site_id: str,
129
+ model: str,
130
+ case_ids: Sequence[str],
131
+ rows: Mapping[str, ResultRow],
132
+ new: int = 0,
133
+ ) -> RunSummary:
134
+ """Summarize the rows for these case ids (rows for other cases are ignored)."""
135
+ selected = [rows[c] for c in case_ids if c in rows]
136
+ ok = [r for r in selected if r.ok]
137
+ return RunSummary(
138
+ site_id=site_id,
139
+ model=model,
140
+ cases=len(case_ids),
141
+ scored=len(ok),
142
+ passed=sum(1 for r in ok if r.passed),
143
+ errors=sum(1 for r in selected if r.error is not None),
144
+ new=new,
145
+ mean_score=_mean([r.score or 0.0 for r in ok]),
146
+ avg_latency_s=_mean([r.latency_s for r in ok]),
147
+ avg_prompt_tokens=_mean([float(r.prompt_tokens) for r in ok]),
148
+ avg_completion_tokens=_mean([float(r.completion_tokens) for r in ok]),
149
+ )
150
+
151
+
152
+ class Runner:
153
+ """Runs eval sets on models and appends scored rows to results files."""
154
+
155
+ def __init__(
156
+ self,
157
+ client: LLMClient,
158
+ *,
159
+ results_dir: Path,
160
+ judge: Judge | None = None,
161
+ limit: int | None = None,
162
+ warmup: bool = True,
163
+ on_case: Callable[[ResultRow], None] | None = None,
164
+ ) -> None:
165
+ self.client = client
166
+ self.results_dir = results_dir
167
+ self.judge = judge
168
+ self.limit = limit
169
+ self.warmup = warmup
170
+ self.on_case = on_case
171
+ self._warmed: set[str] = set()
172
+
173
+ def warm_up(self, model: str, client: LLMClient | None = None) -> None:
174
+ """One untimed call so model load time does not count as latency.
175
+
176
+ LLMError propagates: a model that cannot answer this cannot run evals.
177
+ """
178
+ if not self.warmup or model in self._warmed:
179
+ return
180
+ (client or self.client).complete(model, WARMUP_MESSAGES, temperature=0.0, max_tokens=5)
181
+ self._warmed.add(model)
182
+
183
+ def cases_for(self, eval_set: EvalSet) -> list[EvalCase]:
184
+ return eval_set.cases[: self.limit] if self.limit else list(eval_set.cases)
185
+
186
+ def run_site(self, site: CallSite, eval_set: EvalSet, model: str) -> RunSummary:
187
+ cases = self.cases_for(eval_set)
188
+ path = results_path(self.results_dir, site.id, model)
189
+ rows = load_results(path)
190
+ todo = [c for c in cases if not (c.id in rows and rows[c.id].ok)]
191
+ if todo:
192
+ self.warm_up(model)
193
+ if self.judge is not None and any(c.grading == "judge" for c in todo):
194
+ self.warm_up(self.judge.model, self.judge.client)
195
+ fields = graded_fields(site)
196
+ for case in todo:
197
+ row = self._run_case(site, eval_set, case, model, fields)
198
+ append_row(path, row)
199
+ rows[case.id] = row
200
+ if self.on_case is not None:
201
+ self.on_case(row)
202
+ return summarize_rows(site.id, model, [c.id for c in cases], rows, new=len(todo))
203
+
204
+ def _run_case(
205
+ self,
206
+ site: CallSite,
207
+ eval_set: EvalSet,
208
+ case: EvalCase,
209
+ model: str,
210
+ fields: list[str] | None,
211
+ ) -> ResultRow:
212
+ messages = render_messages(site, eval_set.inputs_for(case))
213
+ temperature = float(site.temperature) if site.temperature is not None else 0.0
214
+ try:
215
+ completion = self.client.complete(
216
+ model,
217
+ messages,
218
+ temperature=temperature,
219
+ max_tokens=site.max_tokens,
220
+ json_mode=site.output_format == "json",
221
+ )
222
+ except LLMError as exc:
223
+ return ResultRow(case.id, model, error=str(exc))
224
+ row = ResultRow(
225
+ case.id,
226
+ model,
227
+ output=completion.text,
228
+ prompt_tokens=completion.prompt_tokens,
229
+ completion_tokens=completion.completion_tokens,
230
+ latency_s=completion.latency_s,
231
+ )
232
+ try:
233
+ score = score_case(
234
+ case.grading,
235
+ case.expected,
236
+ completion.text,
237
+ fields=fields,
238
+ prompt_messages=messages,
239
+ judge=self.judge,
240
+ )
241
+ except (LLMError, ValueError) as exc:
242
+ return replace(row, error=f"scoring failed: {exc}")
243
+ return replace(
244
+ row,
245
+ score=score.value,
246
+ passed=score.passed,
247
+ detail=score.detail,
248
+ judge_score=score.judge_score,
249
+ judge_model=self.judge.model if case.grading == "judge" and self.judge else None,
250
+ )
251
+
252
+
253
+ def rescore_site(
254
+ site: CallSite,
255
+ eval_set: EvalSet,
256
+ model: str,
257
+ *,
258
+ results_dir: Path,
259
+ judge: Judge,
260
+ on_case: Callable[[ResultRow], None] | None = None,
261
+ ) -> tuple[RunSummary, RunSummary]:
262
+ """Re-grade saved outputs of a judge-graded site with `judge`. Returns (before, after).
263
+
264
+ Model outputs are never regenerated. Rows already graded by this judge are
265
+ skipped, rows whose model call failed (no output) are left for `run`, and
266
+ judge failures become error rows that are retried on the next rescore.
267
+ """
268
+ path = results_path(results_dir, site.id, model)
269
+ rows = load_results(path)
270
+ case_ids = [c.id for c in eval_set.cases]
271
+ before = summarize_rows(site.id, model, case_ids, rows)
272
+ new = 0
273
+ for case in eval_set.cases:
274
+ row = rows.get(case.id)
275
+ if row is None or case.grading != "judge":
276
+ continue
277
+ if row.error is not None and not row.error.startswith("scoring failed"):
278
+ continue
279
+ if row.ok and row.judge_model == judge.model:
280
+ continue
281
+ messages = render_messages(site, eval_set.inputs_for(case))
282
+ try:
283
+ score = score_case(
284
+ case.grading, case.expected, row.output, prompt_messages=messages, judge=judge
285
+ )
286
+ except (LLMError, ValueError) as exc:
287
+ updated = replace(
288
+ row,
289
+ score=None,
290
+ passed=None,
291
+ detail="",
292
+ judge_score=None,
293
+ judge_model=judge.model,
294
+ error=f"scoring failed: {exc}",
295
+ )
296
+ else:
297
+ updated = replace(
298
+ row,
299
+ score=score.value,
300
+ passed=score.passed,
301
+ detail=score.detail,
302
+ judge_score=score.judge_score,
303
+ judge_model=judge.model,
304
+ error=None,
305
+ )
306
+ append_row(path, updated)
307
+ rows[case.id] = updated
308
+ new += 1
309
+ if on_case is not None:
310
+ on_case(updated)
311
+ after = summarize_rows(site.id, model, case_ids, rows, new=new)
312
+ return before, after
downshift/scanner.py ADDED
@@ -0,0 +1,310 @@
1
+ """Find LLM call sites in Python source code using the ast module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import fnmatch
7
+ import os
8
+ from collections.abc import Iterator
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from downshift.config import ScanConfig
13
+ from downshift.resolve import Ctx, FunctionNode, Module, ModuleIndex, Resolver, keyword_arg
14
+ from downshift.schema import CallSite, ModelRef, PromptMessage, ScanResult
15
+
16
+ SKIP_DIRS = frozenset(
17
+ {
18
+ ".venv",
19
+ "venv",
20
+ "env",
21
+ ".git",
22
+ "node_modules",
23
+ "__pycache__",
24
+ "site-packages",
25
+ ".tox",
26
+ ".nox",
27
+ ".mypy_cache",
28
+ ".pytest_cache",
29
+ ".ruff_cache",
30
+ "build",
31
+ "dist",
32
+ ".downshift",
33
+ }
34
+ )
35
+
36
+ # (attribute suffix, api name, only counts if a model= keyword is present)
37
+ API_PATTERNS: tuple[tuple[tuple[str, ...], str, bool], ...] = (
38
+ (("chat", "completions", "create"), "openai.chat.completions", False),
39
+ (("chat", "completions", "parse"), "openai.chat.completions.parse", False),
40
+ (("responses", "create"), "openai.responses", True),
41
+ (("messages", "create"), "anthropic.messages", True),
42
+ )
43
+
44
+ MAX_TOKEN_KWARGS = ("max_tokens", "max_completion_tokens", "max_output_tokens")
45
+ JSON_FORMAT_TYPES = frozenset({"json_object", "json_schema"})
46
+
47
+
48
+ # --- public API ---------------------------------------------------------------
49
+
50
+
51
+ def scan_path(root: Path, scan_config: ScanConfig | None = None) -> ScanResult:
52
+ """Scan a directory (or a single .py file) for LLM call sites."""
53
+ scan_config = scan_config or ScanConfig()
54
+ if not root.exists():
55
+ raise FileNotFoundError(f"path does not exist: {root}")
56
+ root = root.resolve()
57
+
58
+ modules: list[Module] = []
59
+ warnings: list[str] = []
60
+ files_scanned = 0
61
+ for path, rel in iter_python_files(root, scan_config):
62
+ files_scanned += 1
63
+ try:
64
+ source = path.read_text(encoding="utf-8")
65
+ except (OSError, UnicodeDecodeError) as exc:
66
+ warnings.append(f"{rel}: could not read file, skipped ({exc})")
67
+ continue
68
+ try:
69
+ tree = ast.parse(source, filename=rel)
70
+ except SyntaxError as exc:
71
+ warnings.append(f"{rel}:{exc.lineno}: syntax error, skipped ({exc.msg})")
72
+ continue
73
+ modules.append(Module.build(tree, rel))
74
+
75
+ return ScanResult(
76
+ root=root.name,
77
+ files_scanned=files_scanned,
78
+ call_sites=_scan_modules(modules),
79
+ warnings=warnings,
80
+ )
81
+
82
+
83
+ def scan_source(source: str, rel: str = "<string>") -> list[CallSite]:
84
+ """Scan one module's source text. Handy for tests and editor integrations."""
85
+ return scan_module(ast.parse(source, filename=rel), rel)
86
+
87
+
88
+ def scan_module(tree: ast.Module, rel: str) -> list[CallSite]:
89
+ """Return the call sites in one parsed module, resolving names within it only."""
90
+ return _scan_modules([Module.build(tree, rel)])
91
+
92
+
93
+ def iter_python_files(root: Path, scan_config: ScanConfig) -> Iterator[tuple[Path, str]]:
94
+ """Yield (absolute path, posix path relative to root) for files to scan."""
95
+ if root.is_file():
96
+ yield root, root.name
97
+ return
98
+ for dirpath, dirnames, filenames in os.walk(root):
99
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
100
+ for name in sorted(filenames):
101
+ if not name.endswith(".py"):
102
+ continue
103
+ path = Path(dirpath) / name
104
+ rel = path.relative_to(root).as_posix()
105
+ if not any(fnmatch.fnmatch(rel, pattern) for pattern in scan_config.include):
106
+ continue
107
+ if any(fnmatch.fnmatch(rel, pattern) for pattern in scan_config.exclude):
108
+ continue
109
+ yield path, rel
110
+
111
+
112
+ # --- scanning -----------------------------------------------------------------
113
+
114
+
115
+ def _scan_modules(modules: list[Module]) -> list[CallSite]:
116
+ resolver = Resolver(ModuleIndex(modules))
117
+ sites: list[CallSite] = []
118
+ calls: list[tuple[str, str]] = []
119
+ for module in modules:
120
+ finder = _CallFinder()
121
+ finder.visit(module.tree)
122
+ calls.extend((f"{module.rel}::{caller}", callee) for caller, callee in finder.calls)
123
+ sites.extend(_build_sites(module, finder.found, resolver))
124
+ _attach_callers(sites, calls)
125
+ sites.sort(key=lambda site: (site.file, site.line))
126
+ return sites
127
+
128
+
129
+ def _build_sites(module: Module, found: list[_FoundCall], resolver: Resolver) -> list[CallSite]:
130
+ sites: list[CallSite] = []
131
+ seen: dict[str, int] = {}
132
+ for item in found:
133
+ base_id = f"{module.rel}::{item.qualname}"
134
+ seen[base_id] = seen.get(base_id, 0) + 1
135
+ site_id = base_id if seen[base_id] == 1 else f"{base_id}#{seen[base_id]}"
136
+
137
+ ctx = Ctx(module, item.func, item.node.lineno)
138
+ model = resolver.resolve_model(item.node, ctx)
139
+ messages = resolver.resolve_messages(item.node, item.api, ctx)
140
+ sites.append(
141
+ CallSite(
142
+ id=site_id,
143
+ file=module.rel,
144
+ line=item.node.lineno,
145
+ end_line=item.node.end_lineno,
146
+ function=item.qualname,
147
+ api=item.api,
148
+ model=model,
149
+ is_async=item.is_async,
150
+ messages=messages,
151
+ output_format=_output_format(item.node, item.api),
152
+ temperature=_temperature(item.node),
153
+ max_tokens=_max_tokens(item.node),
154
+ notes=_notes(model, messages),
155
+ )
156
+ )
157
+ return sites
158
+
159
+
160
+ def _attach_callers(sites: list[CallSite], calls: list[tuple[str, str]]) -> None:
161
+ """Name-based: record which functions call the function containing each call site."""
162
+ for site in sites:
163
+ if site.function == "<module>":
164
+ continue
165
+ short = site.function.split(".")[-1]
166
+ own = f"{site.file}::{site.function}"
167
+ site.callers = sorted(
168
+ {caller for caller, callee in calls if callee == short and caller != own}
169
+ )
170
+ if len(site.callers) > 1 and site.messages is None:
171
+ site.notes.append(
172
+ f"shared helper called from {len(site.callers)} places; each caller may be "
173
+ "a separate feature with its own prompt"
174
+ )
175
+
176
+
177
+ @dataclass
178
+ class _FoundCall:
179
+ node: ast.Call
180
+ api: str
181
+ qualname: str
182
+ func: FunctionNode | None
183
+ is_async: bool
184
+
185
+
186
+ class _CallFinder(ast.NodeVisitor):
187
+ """Walks a module, recording LLM calls and every function call's caller/callee."""
188
+
189
+ def __init__(self) -> None:
190
+ self.scope: list[str] = []
191
+ self.functions: list[FunctionNode] = []
192
+ self.found: list[_FoundCall] = []
193
+ self.calls: list[tuple[str, str]] = []
194
+ self._awaited: set[int] = set()
195
+
196
+ def visit_ClassDef(self, node: ast.ClassDef) -> None:
197
+ self.scope.append(node.name)
198
+ self.generic_visit(node)
199
+ self.scope.pop()
200
+
201
+ def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
202
+ self._visit_function(node)
203
+
204
+ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
205
+ self._visit_function(node)
206
+
207
+ def _visit_function(self, node: FunctionNode) -> None:
208
+ self.scope.append(node.name)
209
+ self.functions.append(node)
210
+ self.generic_visit(node)
211
+ self.functions.pop()
212
+ self.scope.pop()
213
+
214
+ def visit_Await(self, node: ast.Await) -> None:
215
+ if isinstance(node.value, ast.Call):
216
+ self._awaited.add(id(node.value))
217
+ self.generic_visit(node)
218
+
219
+ def visit_Call(self, node: ast.Call) -> None:
220
+ qualname = ".".join(self.scope) or "<module>"
221
+ if isinstance(node.func, ast.Name):
222
+ self.calls.append((qualname, node.func.id))
223
+ elif isinstance(node.func, ast.Attribute):
224
+ self.calls.append((qualname, node.func.attr))
225
+
226
+ api = _match_api(node)
227
+ if api is not None:
228
+ func = self.functions[-1] if self.functions else None
229
+ self.found.append(_FoundCall(node, api, qualname, func, id(node) in self._awaited))
230
+ self.generic_visit(node)
231
+
232
+
233
+ def _attribute_chain(node: ast.expr) -> list[str]:
234
+ """client.chat.completions.create -> ["chat", "completions", "create"]."""
235
+ parts: list[str] = []
236
+ while isinstance(node, ast.Attribute):
237
+ parts.append(node.attr)
238
+ node = node.value
239
+ parts.reverse()
240
+ return parts
241
+
242
+
243
+ def _match_api(call: ast.Call) -> str | None:
244
+ chain = _attribute_chain(call.func)
245
+ for suffix, api, needs_model in API_PATTERNS:
246
+ if len(chain) >= len(suffix) and tuple(chain[-len(suffix) :]) == suffix:
247
+ if needs_model and not any(kw.arg == "model" for kw in call.keywords):
248
+ return None
249
+ return api
250
+ return None
251
+
252
+
253
+ # --- reading call arguments ---------------------------------------------------
254
+
255
+
256
+ def _output_format(call: ast.Call, api: str) -> str:
257
+ if api.endswith(".parse"):
258
+ return "json"
259
+ fmt = keyword_arg(call, "response_format")
260
+ if isinstance(fmt, ast.Dict):
261
+ for key, val in zip(fmt.keys, fmt.values, strict=True):
262
+ if (
263
+ isinstance(key, ast.Constant)
264
+ and key.value == "type"
265
+ and isinstance(val, ast.Constant)
266
+ and val.value in JSON_FORMAT_TYPES
267
+ ):
268
+ return "json"
269
+ return "text"
270
+
271
+
272
+ def _temperature(call: ast.Call) -> float | None:
273
+ value = keyword_arg(call, "temperature")
274
+ if isinstance(value, ast.Constant) and isinstance(value.value, (int, float)):
275
+ if isinstance(value.value, bool):
276
+ return None
277
+ return float(value.value)
278
+ return None
279
+
280
+
281
+ def _max_tokens(call: ast.Call) -> int | None:
282
+ value = keyword_arg(call, *MAX_TOKEN_KWARGS)
283
+ if (
284
+ isinstance(value, ast.Constant)
285
+ and isinstance(value.value, int)
286
+ and not isinstance(value.value, bool)
287
+ and value.value >= 1
288
+ ):
289
+ return value.value
290
+ return None
291
+
292
+
293
+ def _notes(model: ModelRef, messages: list[PromptMessage] | None) -> list[str]:
294
+ notes: list[str] = []
295
+ if model.source == "kwargs":
296
+ notes.append(f"model is passed via {model.expression} and could not be resolved statically")
297
+ elif model.source == "missing":
298
+ notes.append("no model argument found at this call")
299
+ elif model.source == "env_default":
300
+ notes.append(f"model comes from env var {model.env_var}; assuming default {model.value!r}")
301
+ elif model.source == "env":
302
+ notes.append(f"model comes from env var {model.env_var} with no default")
303
+ elif model.source == "dynamic":
304
+ notes.append(f"model is computed at runtime ({model.expression})")
305
+
306
+ if messages is None:
307
+ notes.append("messages are built at runtime; the prompt template could not be recovered")
308
+ elif not all(m.resolved for m in messages):
309
+ notes.append("part of the prompt comes from a runtime value the scanner could not see")
310
+ return notes