spanlight 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.
- spanlight/__init__.py +59 -0
- spanlight/_client.py +459 -0
- spanlight/_config.py +138 -0
- spanlight/_context.py +79 -0
- spanlight/_decorator.py +345 -0
- spanlight/_experiments.py +256 -0
- spanlight/_experiments_api.py +182 -0
- spanlight/_experiments_parse.py +68 -0
- spanlight/_experiments_report.py +100 -0
- spanlight/_experiments_runs.py +122 -0
- spanlight/_experiments_setup.py +85 -0
- spanlight/_exporter.py +462 -0
- spanlight/_globals.py +198 -0
- spanlight/_ids.py +45 -0
- spanlight/_prompt_client.py +241 -0
- spanlight/_prompts.py +247 -0
- spanlight/_request_hash.py +58 -0
- spanlight/_scores.py +57 -0
- spanlight/_serialize.py +126 -0
- spanlight/_span.py +337 -0
- spanlight/_templates.py +238 -0
- spanlight/_version.py +3 -0
- spanlight/integrations/__init__.py +5 -0
- spanlight/integrations/_common.py +422 -0
- spanlight/integrations/anthropic.py +349 -0
- spanlight/integrations/openai.py +356 -0
- spanlight/py.typed +0 -0
- spanlight-0.1.0.dist-info/METADATA +447 -0
- spanlight-0.1.0.dist-info/RECORD +31 -0
- spanlight-0.1.0.dist-info/WHEEL +4 -0
- spanlight-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The slice of the dashboard API (``/api/v1``) the experiment runner reads and writes.
|
|
2
|
+
|
|
3
|
+
Authenticated with a personal access token. Every failure becomes an :class:`ExperimentError`
|
|
4
|
+
whose message names the request and the server's problem ``code`` and ``detail`` and never the
|
|
5
|
+
token or a response body that is not a problem document.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import quote, urlsplit
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from ._experiments_parse import PromptRef
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
|
|
20
|
+
_PAGE_SIZE = 200
|
|
21
|
+
# A guard against a server that keeps returning cursors: 50 pages of 200 is 10 000 rows.
|
|
22
|
+
_MAX_PAGES = 50
|
|
23
|
+
_ESCAPE = "~"
|
|
24
|
+
_LOCAL_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ExperimentError(Exception):
|
|
28
|
+
"""The run could not be set up or finished; the message is safe to print."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class ResolvedPrompt:
|
|
33
|
+
"""One prompt version the experiment will run."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
version: int
|
|
37
|
+
version_id: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _require_safe_host(host: str) -> None:
|
|
41
|
+
"""Refuse to send the access token over plain http, except to the local machine."""
|
|
42
|
+
parts = urlsplit(host)
|
|
43
|
+
if parts.scheme == "https" and parts.hostname:
|
|
44
|
+
return
|
|
45
|
+
if parts.scheme == "http" and parts.hostname in _LOCAL_HOSTS:
|
|
46
|
+
return
|
|
47
|
+
raise ExperimentError(
|
|
48
|
+
"the host must be an https:// URL (http:// is allowed only for localhost); "
|
|
49
|
+
"the access token is never sent in clear text"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def wire_name(name: str) -> str:
|
|
54
|
+
"""A prompt name as it travels in a path: the server strips exactly one leading ``~``."""
|
|
55
|
+
escaped = f"{_ESCAPE}{name}" if name in {".", ".."} or name.startswith(_ESCAPE) else name
|
|
56
|
+
return quote(escaped, safe="/")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Api:
|
|
60
|
+
"""A small JSON client for one Spanlight host."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
*,
|
|
65
|
+
host: str,
|
|
66
|
+
token: str,
|
|
67
|
+
project_id: str,
|
|
68
|
+
timeout: float = 30.0,
|
|
69
|
+
transport: httpx.BaseTransport | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
_require_safe_host(host)
|
|
72
|
+
self.project_id = project_id
|
|
73
|
+
self._client = httpx.Client(
|
|
74
|
+
base_url=f"{host.rstrip('/')}/api/v1",
|
|
75
|
+
headers={
|
|
76
|
+
"Authorization": f"Bearer {token}",
|
|
77
|
+
"Accept": "application/json",
|
|
78
|
+
"User-Agent": f"spanlight-python/{__version__} experiments",
|
|
79
|
+
},
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
transport=transport,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def close(self) -> None:
|
|
85
|
+
"""Release the connection pool."""
|
|
86
|
+
self._client.close()
|
|
87
|
+
|
|
88
|
+
def project(self, path: str = "") -> str:
|
|
89
|
+
"""A path under this project."""
|
|
90
|
+
return f"/projects/{self.project_id}{path}"
|
|
91
|
+
|
|
92
|
+
def request(
|
|
93
|
+
self,
|
|
94
|
+
method: str,
|
|
95
|
+
path: str,
|
|
96
|
+
*,
|
|
97
|
+
params: dict[str, Any] | None = None,
|
|
98
|
+
headers: dict[str, str] | None = None,
|
|
99
|
+
body: dict[str, Any] | None = None,
|
|
100
|
+
) -> Any:
|
|
101
|
+
"""Send one request and return the decoded JSON body."""
|
|
102
|
+
try:
|
|
103
|
+
response = self._client.request(method, path, params=params, headers=headers, json=body)
|
|
104
|
+
except httpx.HTTPError as error:
|
|
105
|
+
raise ExperimentError(
|
|
106
|
+
f"{method} {path}: cannot reach the server ({type(error).__name__})"
|
|
107
|
+
) from None
|
|
108
|
+
if response.is_success:
|
|
109
|
+
try:
|
|
110
|
+
return response.json()
|
|
111
|
+
except ValueError:
|
|
112
|
+
raise ExperimentError(f"{method} {path}: the answer is not JSON") from None
|
|
113
|
+
raise ExperimentError(f"{method} {path}: {_problem(response)}")
|
|
114
|
+
|
|
115
|
+
def pages(self, path: str, params: dict[str, Any] | None = None) -> Iterator[dict[str, Any]]:
|
|
116
|
+
"""Every item of a keyset-paginated list, newest first."""
|
|
117
|
+
cursor: str | None = None
|
|
118
|
+
for _ in range(_MAX_PAGES):
|
|
119
|
+
query: dict[str, Any] = {**(params or {}), "limit": _PAGE_SIZE}
|
|
120
|
+
if cursor:
|
|
121
|
+
query["cursor"] = cursor
|
|
122
|
+
page = self.request("GET", path, params=query)
|
|
123
|
+
yield from page["items"]
|
|
124
|
+
cursor = page.get("next_cursor")
|
|
125
|
+
if not cursor:
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _problem(response: httpx.Response) -> str:
|
|
130
|
+
"""``401 UNAUTHENTICATED: detail`` from a problem document, else just the status."""
|
|
131
|
+
try:
|
|
132
|
+
body = response.json()
|
|
133
|
+
code, detail = body.get("code"), body.get("detail")
|
|
134
|
+
except (ValueError, AttributeError):
|
|
135
|
+
return str(response.status_code)
|
|
136
|
+
parts = [str(response.status_code)]
|
|
137
|
+
if isinstance(code, str):
|
|
138
|
+
parts.append(code)
|
|
139
|
+
text = " ".join(parts)
|
|
140
|
+
return f"{text}: {detail}" if isinstance(detail, str) and detail else text
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def find_by_name(api: Api, path: str, name: str) -> dict[str, Any] | None:
|
|
144
|
+
"""The listed item called ``name``, or ``None``."""
|
|
145
|
+
return next((item for item in api.pages(path) if item.get("name") == name), None)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def resolve_dataset(api: Api, name: str) -> dict[str, Any]:
|
|
149
|
+
"""The dataset called ``name`` (the server filters by exact name)."""
|
|
150
|
+
for item in api.pages(api.project("/datasets"), {"name": name}):
|
|
151
|
+
if item.get("name") == name:
|
|
152
|
+
return item
|
|
153
|
+
raise ExperimentError(f"dataset {name!r} not found in the project")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def resolve_prompt(api: Api, ref: PromptRef) -> ResolvedPrompt:
|
|
157
|
+
"""Pin a prompt reference to one version: its label (``production`` by default) or number."""
|
|
158
|
+
base = api.project(f"/prompts/{wire_name(ref.name)}")
|
|
159
|
+
version = ref.version
|
|
160
|
+
if version is None:
|
|
161
|
+
label = ref.label or "production"
|
|
162
|
+
prompt = api.request("GET", base)
|
|
163
|
+
if label == "latest":
|
|
164
|
+
version = int(prompt["latest"]["version"])
|
|
165
|
+
else:
|
|
166
|
+
labels = {entry["label"]: int(entry["version"]) for entry in prompt["labels"]}
|
|
167
|
+
if label not in labels:
|
|
168
|
+
raise ExperimentError(f"prompt {ref.name!r} has no label {label!r}")
|
|
169
|
+
version = labels[label]
|
|
170
|
+
found = api.request("GET", f"{base}/versions/{version}")
|
|
171
|
+
return ResolvedPrompt(name=ref.name, version=version, version_id=found["id"])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def resolve_evaluators(api: Api, names: list[str]) -> list[str]:
|
|
175
|
+
"""Evaluator ids for ``names``, in order; an unknown name is an error."""
|
|
176
|
+
if not names:
|
|
177
|
+
return []
|
|
178
|
+
by_name = {item["name"]: item["id"] for item in api.pages(api.project("/evaluators"))}
|
|
179
|
+
missing = [name for name in names if name not in by_name]
|
|
180
|
+
if missing:
|
|
181
|
+
raise ExperimentError(f"evaluator not found in the project: {', '.join(missing)}")
|
|
182
|
+
return [by_name[name] for name in names]
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Parsing the experiment runner's command-line values. Pure: no I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal, InvalidOperation
|
|
8
|
+
|
|
9
|
+
# Prompt names never contain `@` or `:`, so either one can only start a label or a version.
|
|
10
|
+
_VERSION = re.compile(r"^[1-9][0-9]{0,9}$")
|
|
11
|
+
_LABEL = re.compile(r"^[a-z0-9][a-z0-9_-]{0,31}$")
|
|
12
|
+
_SCORE_NAME = re.compile(r"^[^\s=,]{1,100}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class PromptRef:
|
|
17
|
+
"""A prompt and the version to run: a label, a version number, or neither (``production``)."""
|
|
18
|
+
|
|
19
|
+
name: str
|
|
20
|
+
label: str | None
|
|
21
|
+
version: int | None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_fail_below(spec: str) -> dict[str, Decimal]:
|
|
25
|
+
"""Parse ``relevance=0.8,format=1`` into ``{"relevance": 0.8, "format": 1}``.
|
|
26
|
+
|
|
27
|
+
An empty spec means no thresholds. Raises ``ValueError`` for a malformed pair, a repeated
|
|
28
|
+
score name or a value that is not a finite number.
|
|
29
|
+
"""
|
|
30
|
+
thresholds: dict[str, Decimal] = {}
|
|
31
|
+
for pair in spec.split(","):
|
|
32
|
+
pair = pair.strip()
|
|
33
|
+
if not pair:
|
|
34
|
+
continue
|
|
35
|
+
name, separator, raw = pair.partition("=")
|
|
36
|
+
name, raw = name.strip(), raw.strip()
|
|
37
|
+
if not separator or not _SCORE_NAME.match(name):
|
|
38
|
+
raise ValueError(f"expected score=number, got {pair!r}")
|
|
39
|
+
if name in thresholds:
|
|
40
|
+
raise ValueError(f"score {name!r} appears twice")
|
|
41
|
+
try:
|
|
42
|
+
value = Decimal(raw)
|
|
43
|
+
except InvalidOperation:
|
|
44
|
+
raise ValueError(f"threshold for {name!r} is not a number: {raw!r}") from None
|
|
45
|
+
if not value.is_finite():
|
|
46
|
+
raise ValueError(f"threshold for {name!r} is not a finite number: {raw!r}")
|
|
47
|
+
thresholds[name] = value
|
|
48
|
+
return thresholds
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def parse_prompt_ref(ref: str) -> PromptRef:
|
|
52
|
+
"""Parse ``support/answer@production``, ``support/answer:3`` or a bare ``support/answer``."""
|
|
53
|
+
ref = ref.strip()
|
|
54
|
+
if "@" in ref and ":" in ref:
|
|
55
|
+
raise ValueError(f"give a label or a version, not both: {ref!r}")
|
|
56
|
+
if "@" in ref:
|
|
57
|
+
name, _, label = ref.rpartition("@")
|
|
58
|
+
if not name or not _LABEL.match(label):
|
|
59
|
+
raise ValueError(f"not a prompt label reference: {ref!r}")
|
|
60
|
+
return PromptRef(name, label, None)
|
|
61
|
+
if ":" in ref:
|
|
62
|
+
name, _, number = ref.rpartition(":")
|
|
63
|
+
if not name or not _VERSION.match(number):
|
|
64
|
+
raise ValueError(f"not a prompt version reference: {ref!r}")
|
|
65
|
+
return PromptRef(name, None, int(number))
|
|
66
|
+
if not ref:
|
|
67
|
+
raise ValueError("the prompt reference is empty")
|
|
68
|
+
return PromptRef(ref, None, None)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Scoring a finished run against its thresholds, and the Markdown report of it. Pure: no I/O."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from decimal import Decimal
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_PLACES = Decimal("0.000001")
|
|
9
|
+
_UNKNOWN = "—"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fmt(value: Decimal) -> str:
|
|
13
|
+
"""A number without trailing zeros: ``0.75``, ``1``, ``10``."""
|
|
14
|
+
return format(value.quantize(_PLACES).normalize(), "f")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _mean(stat: Any) -> Decimal | None:
|
|
18
|
+
"""The mean of a numeric score; ``None`` for a categorical score or a missing one."""
|
|
19
|
+
mean = stat.get("mean") if isinstance(stat, dict) else None
|
|
20
|
+
if isinstance(mean, bool) or not isinstance(mean, int | float):
|
|
21
|
+
return None
|
|
22
|
+
return Decimal(str(mean))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def check_thresholds(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[str]:
|
|
26
|
+
"""One line per score whose mean is below its threshold or that the run did not produce.
|
|
27
|
+
|
|
28
|
+
A score nobody recorded (every item errored, or the evaluator was over budget) fails the
|
|
29
|
+
gate: an unknown value never counts as a pass.
|
|
30
|
+
"""
|
|
31
|
+
scores = totals.get("scores") or {}
|
|
32
|
+
failures: list[str] = []
|
|
33
|
+
for name, threshold in fail_below.items():
|
|
34
|
+
mean = _mean(scores.get(name))
|
|
35
|
+
if mean is None:
|
|
36
|
+
failures.append(f"{name}: no numeric score recorded < {fmt(threshold)}")
|
|
37
|
+
elif mean < threshold:
|
|
38
|
+
failures.append(f"{name}: {fmt(mean)} < {fmt(threshold)}")
|
|
39
|
+
return failures
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _score_rows(totals: dict[str, Any], fail_below: dict[str, Decimal]) -> list[str]:
|
|
43
|
+
scores = totals.get("scores") or {}
|
|
44
|
+
rows = ["| Score | Mean | Items | Threshold | Result |", "| --- | --- | --- | --- | --- |"]
|
|
45
|
+
for name in sorted({*scores, *fail_below}):
|
|
46
|
+
stat = scores.get(name)
|
|
47
|
+
mean = _mean(stat)
|
|
48
|
+
count = stat.get("count") if isinstance(stat, dict) else None
|
|
49
|
+
threshold = fail_below.get(name)
|
|
50
|
+
if threshold is None:
|
|
51
|
+
result = ""
|
|
52
|
+
elif mean is not None and mean >= threshold:
|
|
53
|
+
result = "pass"
|
|
54
|
+
else:
|
|
55
|
+
result = "FAIL"
|
|
56
|
+
rows.append(
|
|
57
|
+
f"| {name} | {fmt(mean) if mean is not None else _UNKNOWN} "
|
|
58
|
+
f"| {count if count is not None else _UNKNOWN} "
|
|
59
|
+
f"| {'>= ' + fmt(threshold) if threshold is not None else _UNKNOWN} | {result} |"
|
|
60
|
+
)
|
|
61
|
+
return rows
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _number(value: Any) -> str:
|
|
65
|
+
return _UNKNOWN if value is None else str(value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def render_markdown(
|
|
69
|
+
*,
|
|
70
|
+
title: str,
|
|
71
|
+
status: str,
|
|
72
|
+
error: str | None,
|
|
73
|
+
url: str,
|
|
74
|
+
totals: dict[str, Any] | None,
|
|
75
|
+
fail_below: dict[str, Decimal],
|
|
76
|
+
failures: list[str],
|
|
77
|
+
) -> str:
|
|
78
|
+
"""The report printed to the log and appended to the GitHub step summary."""
|
|
79
|
+
verdict = "passed" if status == "done" and not failures else "failed"
|
|
80
|
+
lines = [
|
|
81
|
+
f"### Spanlight eval gate: {title}",
|
|
82
|
+
"",
|
|
83
|
+
f"Gate **{verdict}**. [Open the run]({url})",
|
|
84
|
+
"",
|
|
85
|
+
]
|
|
86
|
+
if status != "done":
|
|
87
|
+
lines += [f"The run did not finish ({status}): {error or 'no reason given'}.", ""]
|
|
88
|
+
if totals is not None:
|
|
89
|
+
lines += [
|
|
90
|
+
f"Items {_number(totals.get('items'))}, errors {_number(totals.get('errors'))}, "
|
|
91
|
+
f"cost {_number(totals.get('cost_usd'))} USD, "
|
|
92
|
+
f"p50 {_number(totals.get('latency_p50_ms'))} ms, "
|
|
93
|
+
f"p95 {_number(totals.get('latency_p95_ms'))} ms.",
|
|
94
|
+
"",
|
|
95
|
+
*_score_rows(totals, fail_below),
|
|
96
|
+
"",
|
|
97
|
+
]
|
|
98
|
+
if failures:
|
|
99
|
+
lines += ["Below threshold:", "", *[f"- {line}" for line in failures], ""]
|
|
100
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Starting a run and following it to its end, and how a run ends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._experiments_api import Api, ExperimentError
|
|
11
|
+
from ._experiments_report import check_thresholds
|
|
12
|
+
|
|
13
|
+
# Consecutive failed polls before the runner gives up: a deploy of the server must not fail a gate.
|
|
14
|
+
_POLL_RETRIES = 3
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ExperimentOutcome:
|
|
19
|
+
"""How one run ended.
|
|
20
|
+
|
|
21
|
+
``status`` is ``done``, ``failed`` or ``timeout``; ``totals`` is the run's totals from the
|
|
22
|
+
API (``None`` when it never produced any); ``failures`` lists the thresholds that were missed.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
run_id: str
|
|
26
|
+
totals: dict[str, Any] | None
|
|
27
|
+
failures: list[str]
|
|
28
|
+
url: str
|
|
29
|
+
status: str = "done"
|
|
30
|
+
error: str | None = None
|
|
31
|
+
title: str = field(default="", compare=False)
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def passed(self) -> bool:
|
|
35
|
+
"""Whether the run finished and every threshold held."""
|
|
36
|
+
return self.status == "done" and not self.failures
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def outcome_of(
|
|
40
|
+
final: dict[str, Any] | None,
|
|
41
|
+
run_id: str,
|
|
42
|
+
url: str,
|
|
43
|
+
title: str,
|
|
44
|
+
timeout_s: int,
|
|
45
|
+
fail_below: dict[str, Decimal],
|
|
46
|
+
) -> ExperimentOutcome:
|
|
47
|
+
"""Turn the last state of a run (``None``: still going at the deadline) into an outcome."""
|
|
48
|
+
if final is None:
|
|
49
|
+
return ExperimentOutcome(
|
|
50
|
+
run_id, None, [], url, "timeout", f"no result after {timeout_s} s", title
|
|
51
|
+
)
|
|
52
|
+
totals = final.get("totals")
|
|
53
|
+
if final["status"] == "failed":
|
|
54
|
+
return ExperimentOutcome(run_id, totals, [], url, "failed", final.get("error"), title)
|
|
55
|
+
failures = check_thresholds(totals or {}, fail_below)
|
|
56
|
+
return ExperimentOutcome(run_id, totals, failures, url, "done", None, title)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def org_of(api: Api) -> str:
|
|
60
|
+
return str(api.request("GET", api.project())["org_id"])
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def start_run(
|
|
64
|
+
api: Api, experiment_id: str, version_id: str, env: Mapping[str, str]
|
|
65
|
+
) -> dict[str, Any]:
|
|
66
|
+
"""Queue a run. Inside GitHub Actions a retried request of one attempt starts only one run."""
|
|
67
|
+
headers: dict[str, str] = {}
|
|
68
|
+
run_id, attempt = env.get("GITHUB_RUN_ID"), env.get("GITHUB_RUN_ATTEMPT")
|
|
69
|
+
if run_id:
|
|
70
|
+
# The whole experiment id keeps two gate steps of one workflow run from sharing a key.
|
|
71
|
+
headers["Idempotency-Key"] = f"{run_id}-{attempt or '1'}-{experiment_id}"
|
|
72
|
+
path = api.project(f"/experiments/{experiment_id}/runs")
|
|
73
|
+
# The run names its prompt version, so a moved label runs the new version in this experiment.
|
|
74
|
+
body = {"target_override": {"prompt_version_id": version_id}}
|
|
75
|
+
started: dict[str, Any] = api.request("POST", path, headers=headers, body=body)
|
|
76
|
+
return started
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def wait_idle(
|
|
80
|
+
api: Api,
|
|
81
|
+
experiment_id: str,
|
|
82
|
+
deadline: float,
|
|
83
|
+
poll_s: float,
|
|
84
|
+
sleep: Callable[[float], None],
|
|
85
|
+
clock: Callable[[], float],
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Wait until the experiment has no queued or running run: it allows only one at a time."""
|
|
88
|
+
path = api.project(f"/experiments/{experiment_id}/runs")
|
|
89
|
+
while True:
|
|
90
|
+
latest = api.request("GET", path, params={"limit": 1})["items"]
|
|
91
|
+
if not latest or latest[0]["status"] not in {"queued", "running"}:
|
|
92
|
+
return
|
|
93
|
+
if clock() >= deadline:
|
|
94
|
+
raise ExperimentError("another run of this experiment did not finish in time")
|
|
95
|
+
sleep(poll_s)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def wait(
|
|
99
|
+
api: Api,
|
|
100
|
+
experiment_id: str,
|
|
101
|
+
run_id: str,
|
|
102
|
+
deadline: float,
|
|
103
|
+
poll_s: float,
|
|
104
|
+
sleep: Callable[[float], None],
|
|
105
|
+
clock: Callable[[], float],
|
|
106
|
+
) -> dict[str, Any] | None:
|
|
107
|
+
"""Poll until the run is ``done`` or ``failed``; ``None`` when the deadline passes first."""
|
|
108
|
+
path = api.project(f"/experiments/{experiment_id}/runs/{run_id}")
|
|
109
|
+
errors = 0
|
|
110
|
+
while True:
|
|
111
|
+
try:
|
|
112
|
+
run: dict[str, Any] = api.request("GET", path)
|
|
113
|
+
errors = 0
|
|
114
|
+
if run["status"] in {"done", "failed"}:
|
|
115
|
+
return run
|
|
116
|
+
except ExperimentError:
|
|
117
|
+
errors += 1
|
|
118
|
+
if errors >= _POLL_RETRIES:
|
|
119
|
+
raise
|
|
120
|
+
if clock() >= deadline:
|
|
121
|
+
return None
|
|
122
|
+
sleep(poll_s)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Find or create the experiment a CI run belongs to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import uuid
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._experiments_api import Api, ExperimentError, ResolvedPrompt, find_by_name
|
|
12
|
+
|
|
13
|
+
# The server's limit on an experiment name.
|
|
14
|
+
_MAX_NAME = 200
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def normalize_credential_id(value: str) -> str:
|
|
18
|
+
"""The credential id in canonical lower-case form, so a pasted upper-case id still matches."""
|
|
19
|
+
try:
|
|
20
|
+
return str(uuid.UUID(value.strip()))
|
|
21
|
+
except ValueError:
|
|
22
|
+
raise ExperimentError("--credential-id is not a UUID") from None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def experiment_name(
|
|
26
|
+
dataset: str, prompt: str, evaluator_names: list[str], credential_id: str, model: str
|
|
27
|
+
) -> str:
|
|
28
|
+
"""``ci/<dataset>/<prompt>/<8 hex>``; the digest follows the evaluators, credential and model.
|
|
29
|
+
|
|
30
|
+
An experiment cannot be edited, so a gate that changes one of these gets its own experiment
|
|
31
|
+
instead of silently running under the old settings.
|
|
32
|
+
"""
|
|
33
|
+
settings = json.dumps(
|
|
34
|
+
[sorted(set(evaluator_names)), credential_id, model], separators=(",", ":")
|
|
35
|
+
)
|
|
36
|
+
digest = hashlib.sha256(settings.encode()).hexdigest()[:8]
|
|
37
|
+
name = f"ci/{dataset}/{prompt}/{digest}"
|
|
38
|
+
if len(name) > _MAX_NAME:
|
|
39
|
+
raise ExperimentError(
|
|
40
|
+
f"the experiment name would be {len(name)} characters; the limit is {_MAX_NAME}. "
|
|
41
|
+
"Use a shorter dataset or prompt name"
|
|
42
|
+
)
|
|
43
|
+
return name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def experiment_for(
|
|
47
|
+
api: Api,
|
|
48
|
+
dataset: dict[str, Any],
|
|
49
|
+
version: ResolvedPrompt,
|
|
50
|
+
evaluator_names: list[str],
|
|
51
|
+
evaluator_ids: list[str],
|
|
52
|
+
*,
|
|
53
|
+
credential_id: str,
|
|
54
|
+
model: str,
|
|
55
|
+
max_cost_usd: Decimal | None,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""The experiment for this dataset, prompt and settings, created on first use.
|
|
58
|
+
|
|
59
|
+
Runs of one experiment stay comparable. It is created with the resolved version as its base
|
|
60
|
+
target; every run then names the version it wants through ``target_override``, so a label that
|
|
61
|
+
moved runs the new version. ``max_cost_usd`` applies only when the experiment is created.
|
|
62
|
+
"""
|
|
63
|
+
name = experiment_name(dataset["name"], version.name, evaluator_names, credential_id, model)
|
|
64
|
+
path = api.project("/experiments")
|
|
65
|
+
existing = find_by_name(api, path, name)
|
|
66
|
+
if existing is not None:
|
|
67
|
+
return existing
|
|
68
|
+
config: dict[str, Any] = {
|
|
69
|
+
"target": {
|
|
70
|
+
"kind": "prompt_version",
|
|
71
|
+
"prompt_version_id": version.version_id,
|
|
72
|
+
"credential_id": credential_id,
|
|
73
|
+
"model": model,
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if max_cost_usd is not None:
|
|
77
|
+
config["max_cost_usd"] = f"{max_cost_usd:f}"
|
|
78
|
+
body = {
|
|
79
|
+
"name": name,
|
|
80
|
+
"dataset_id": dataset["id"],
|
|
81
|
+
"config": config,
|
|
82
|
+
"evaluator_ids": evaluator_ids,
|
|
83
|
+
}
|
|
84
|
+
created: dict[str, Any] = api.request("POST", path, body=body)
|
|
85
|
+
return created
|