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.
@@ -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