tuningfork-lab 0.6.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.
- tuningfork/__init__.py +3 -0
- tuningfork/__main__.py +6 -0
- tuningfork/access.py +129 -0
- tuningfork/analysis/__init__.py +53 -0
- tuningfork/analysis/conditions.py +149 -0
- tuningfork/analysis/distribution.py +511 -0
- tuningfork/analysis/divergence.py +177 -0
- tuningfork/analysis/forking.py +304 -0
- tuningfork/analysis/normalize.py +423 -0
- tuningfork/analysis/reasoning.py +87 -0
- tuningfork/analysis/screen.py +300 -0
- tuningfork/analysis/stats.py +171 -0
- tuningfork/analysis/tokens.py +623 -0
- tuningfork/analysis/validation.py +323 -0
- tuningfork/api/__init__.py +6 -0
- tuningfork/api/connections.py +183 -0
- tuningfork/api/control.py +179 -0
- tuningfork/api/data.py +429 -0
- tuningfork/api/guard.py +218 -0
- tuningfork/api/imports.py +159 -0
- tuningfork/api/jobs.py +118 -0
- tuningfork/api/refinements.py +214 -0
- tuningfork/api/scenarios.py +158 -0
- tuningfork/api/skills.py +130 -0
- tuningfork/api/trash.py +87 -0
- tuningfork/backends/__init__.py +24 -0
- tuningfork/backends/base.py +192 -0
- tuningfork/backends/formats/__init__.py +47 -0
- tuningfork/backends/formats/base.py +79 -0
- tuningfork/backends/formats/gemma4.py +258 -0
- tuningfork/backends/formats/qwen3_coder.py +506 -0
- tuningfork/backends/formats/qwen_json.py +412 -0
- tuningfork/backends/llamacpp/__init__.py +108 -0
- tuningfork/backends/llamacpp/anthropic.py +149 -0
- tuningfork/backends/llamacpp/raw.py +272 -0
- tuningfork/backends/probe.py +844 -0
- tuningfork/backends/raw.py +103 -0
- tuningfork/breakpoints/__init__.py +27 -0
- tuningfork/breakpoints/conditions.py +369 -0
- tuningfork/breakpoints/engine.py +441 -0
- tuningfork/breakpoints/events.py +71 -0
- tuningfork/cli.py +5210 -0
- tuningfork/config.py +747 -0
- tuningfork/control.py +334 -0
- tuningfork/demo/__init__.py +7 -0
- tuningfork/demo/agent.py +137 -0
- tuningfork/demo/lab.py +523 -0
- tuningfork/demo/mock.py +434 -0
- tuningfork/demo/task.py +123 -0
- tuningfork/demo/toy.py +309 -0
- tuningfork/endpoints.py +213 -0
- tuningfork/experiment/__init__.py +55 -0
- tuningfork/experiment/forking.py +253 -0
- tuningfork/experiment/sampling.py +926 -0
- tuningfork/experiment/scoring.py +254 -0
- tuningfork/experiment/skill.py +448 -0
- tuningfork/harness/__init__.py +31 -0
- tuningfork/harness/base.py +194 -0
- tuningfork/harness/claude_code.py +314 -0
- tuningfork/harness/hermes.py +599 -0
- tuningfork/jobs.py +571 -0
- tuningfork/launches.py +189 -0
- tuningfork/mcp/__init__.py +10 -0
- tuningfork/mcp/backend.py +167 -0
- tuningfork/mcp/http.py +100 -0
- tuningfork/mcp/schema.py +164 -0
- tuningfork/mcp/server.py +414 -0
- tuningfork/mcp/stdio.py +98 -0
- tuningfork/mcp/tools.py +1591 -0
- tuningfork/ops/__init__.py +36 -0
- tuningfork/ops/addskill.py +396 -0
- tuningfork/ops/approve.py +619 -0
- tuningfork/ops/batch.py +487 -0
- tuningfork/ops/catalog.py +985 -0
- tuningfork/ops/chain.py +501 -0
- tuningfork/ops/checks.py +461 -0
- tuningfork/ops/common.py +319 -0
- tuningfork/ops/compact.py +1198 -0
- tuningfork/ops/connections.py +506 -0
- tuningfork/ops/diff.py +339 -0
- tuningfork/ops/experiments.py +922 -0
- tuningfork/ops/forking.py +317 -0
- tuningfork/ops/forks.py +707 -0
- tuningfork/ops/glossary.py +351 -0
- tuningfork/ops/holds.py +271 -0
- tuningfork/ops/imports.py +1485 -0
- tuningfork/ops/inspect.py +978 -0
- tuningfork/ops/launch.py +80 -0
- tuningfork/ops/packet.py +270 -0
- tuningfork/ops/private.py +245 -0
- tuningfork/ops/proposer.py +433 -0
- tuningfork/ops/refine.py +796 -0
- tuningfork/ops/scenarios.py +503 -0
- tuningfork/ops/screen.py +910 -0
- tuningfork/ops/skills.py +539 -0
- tuningfork/ops/spend.py +209 -0
- tuningfork/ops/tally.py +338 -0
- tuningfork/ops/targets.py +251 -0
- tuningfork/ops/timeline.py +732 -0
- tuningfork/ops/tokens.py +335 -0
- tuningfork/ops/trash.py +606 -0
- tuningfork/ops/validate.py +486 -0
- tuningfork/ops/variants.py +134 -0
- tuningfork/ops/where.py +592 -0
- tuningfork/owners.py +50 -0
- tuningfork/paths.py +160 -0
- tuningfork/proxy/__init__.py +1 -0
- tuningfork/proxy/app.py +988 -0
- tuningfork/proxy/held.py +446 -0
- tuningfork/proxy/launches.py +210 -0
- tuningfork/proxy/local.py +148 -0
- tuningfork/proxy/recorder.py +386 -0
- tuningfork/proxy/replay.py +99 -0
- tuningfork/proxy/sse.py +346 -0
- tuningfork/replay.py +490 -0
- tuningfork/runner.py +1156 -0
- tuningfork/sandbox.py +339 -0
- tuningfork/snapshots.py +270 -0
- tuningfork/specs.py +724 -0
- tuningfork/store/__init__.py +6 -0
- tuningfork/store/blobs.py +88 -0
- tuningfork/store/db.py +2817 -0
- tuningfork/store/masking.py +156 -0
- tuningfork/store/tree.py +118 -0
- tuningfork/ui/__init__.py +33 -0
- tuningfork/ui/static/app.css +1745 -0
- tuningfork/ui/static/index.html +104 -0
- tuningfork/ui/static/js/api.js +92 -0
- tuningfork/ui/static/js/app.js +415 -0
- tuningfork/ui/static/js/auth.js +88 -0
- tuningfork/ui/static/js/checktry.js +258 -0
- tuningfork/ui/static/js/cost.js +23 -0
- tuningfork/ui/static/js/dom.js +308 -0
- tuningfork/ui/static/js/forms.js +246 -0
- tuningfork/ui/static/js/holdtools.js +131 -0
- tuningfork/ui/static/js/jobs.js +332 -0
- tuningfork/ui/static/js/launcher.js +95 -0
- tuningfork/ui/static/js/live.js +129 -0
- tuningfork/ui/static/js/markdown.js +306 -0
- tuningfork/ui/static/js/nav.js +30 -0
- tuningfork/ui/static/js/payload.js +287 -0
- tuningfork/ui/static/js/results.js +587 -0
- tuningfork/ui/static/js/start.js +593 -0
- tuningfork/ui/static/js/stepinfo.js +231 -0
- tuningfork/ui/static/js/targets.js +144 -0
- tuningfork/ui/static/js/timeline.js +1002 -0
- tuningfork/ui/static/js/tokens.js +412 -0
- tuningfork/ui/static/js/trash.js +113 -0
- tuningfork/ui/static/js/variants.js +197 -0
- tuningfork/ui/static/js/views/batch.js +194 -0
- tuningfork/ui/static/js/views/chains.js +91 -0
- tuningfork/ui/static/js/views/compare.js +305 -0
- tuningfork/ui/static/js/views/connections.js +555 -0
- tuningfork/ui/static/js/views/experiments.js +365 -0
- tuningfork/ui/static/js/views/home.js +397 -0
- tuningfork/ui/static/js/views/import.js +570 -0
- tuningfork/ui/static/js/views/jobs.js +446 -0
- tuningfork/ui/static/js/views/live.js +848 -0
- tuningfork/ui/static/js/views/refine.js +1352 -0
- tuningfork/ui/static/js/views/run.js +246 -0
- tuningfork/ui/static/js/views/runs.js +264 -0
- tuningfork/ui/static/js/views/scenarios.js +574 -0
- tuningfork/ui/static/js/views/skills.js +466 -0
- tuningfork/ui/static/js/views/step.js +144 -0
- tuningfork/ui/static/js/views/trash.js +90 -0
- tuningfork/ui/static/js/where.js +141 -0
- tuningfork/ui/static/js/words.js +55 -0
- tuningfork/wire/__init__.py +28 -0
- tuningfork/wire/anthropic.py +605 -0
- tuningfork/wire/base.py +176 -0
- tuningfork/wire/completion.py +136 -0
- tuningfork/wire/openai.py +162 -0
- tuningfork_lab-0.6.0.dist-info/METADATA +315 -0
- tuningfork_lab-0.6.0.dist-info/RECORD +178 -0
- tuningfork_lab-0.6.0.dist-info/WHEEL +4 -0
- tuningfork_lab-0.6.0.dist-info/entry_points.txt +2 -0
- tuningfork_lab-0.6.0.dist-info/licenses/LICENSE +201 -0
- tuningfork_lab-0.6.0.dist-info/licenses/NOTICE +23 -0
tuningfork/__init__.py
ADDED
tuningfork/__main__.py
ADDED
tuningfork/access.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The access token: what a request needs to change tuningfork's state.
|
|
2
|
+
|
|
3
|
+
Reading stays open to the person at the machine. Anything that changes state — pausing,
|
|
4
|
+
a breakpoint, a decision at a held call, and from Phase 5 starting work that launches
|
|
5
|
+
harnesses — needs the token a running proxy was started with. A web page open in the same
|
|
6
|
+
browser can send requests to a loopback port, but it cannot read the token: it is in the
|
|
7
|
+
address `tuningfork serve` printed, in this browser tab's session, and in a file only this
|
|
8
|
+
user can read.
|
|
9
|
+
|
|
10
|
+
An MCP client that connects to the proxy (`/_tuningfork/mcp`) holds a token of its own, the
|
|
11
|
+
MCP token, which opens that endpoint and nothing else: an agent offered tools there cannot
|
|
12
|
+
use its client's token to call what the tools leave out, such as approving an edit.
|
|
13
|
+
|
|
14
|
+
The token is not a sandbox for the harness. A launched harness runs shell commands as this
|
|
15
|
+
user, and can read anything this user can; what the token keeps out is everything that is
|
|
16
|
+
not this user at this machine.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hmac
|
|
22
|
+
import os
|
|
23
|
+
import secrets
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any, Mapping
|
|
27
|
+
|
|
28
|
+
#: Where a request carries the token.
|
|
29
|
+
HEADER = "x-tuningfork-token"
|
|
30
|
+
#: Pins the token a proxy starts with, and gives the CLI one without the file.
|
|
31
|
+
ENV = "TUNINGFORK_TOKEN"
|
|
32
|
+
#: The proxy's current token, in its recordings folder, readable by this user only.
|
|
33
|
+
TOKEN_FILE = ".access-token"
|
|
34
|
+
#: The MCP endpoint's own token, beside it: pinned by `MCP_ENV`, else new each start.
|
|
35
|
+
MCP_ENV = "TUNINGFORK_MCP_TOKEN"
|
|
36
|
+
MCP_TOKEN_FILE = ".mcp-token"
|
|
37
|
+
#: The built-in proposer's API key, for an endpoint that needs one (`ops.proposer`); else
|
|
38
|
+
#: the file `PROPOSER_KEY_FILE` in the recordings folder.
|
|
39
|
+
PROPOSER_KEY_ENV = "TUNINGFORK_PROPOSER_KEY"
|
|
40
|
+
PROPOSER_KEY_FILE = ".proposer-key"
|
|
41
|
+
#: Never handed to anything tuningfork runs for a task: a check runs as this user, in a
|
|
42
|
+
#: workspace a model wrote.
|
|
43
|
+
SECRET_ENVS = (ENV, MCP_ENV, PROPOSER_KEY_ENV)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def new_token() -> str:
|
|
47
|
+
return secrets.token_urlsafe(24)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def token_path(home: Path | str) -> Path:
|
|
51
|
+
return Path(home) / TOKEN_FILE
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def mcp_token_path(home: Path | str) -> Path:
|
|
55
|
+
return Path(home) / MCP_TOKEN_FILE
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_token(home: Path | str, token: str) -> Path:
|
|
59
|
+
"""Write the token where the CLI looks for it."""
|
|
60
|
+
return write_secret(token_path(home), token)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def write_secret(path: Path | str, text: str) -> Path:
|
|
64
|
+
"""Write a secret atomically, mode 0600 from the moment the file exists — a secret
|
|
65
|
+
briefly readable by others would be a secret given away."""
|
|
66
|
+
path = Path(path)
|
|
67
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
69
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
70
|
+
try:
|
|
71
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
72
|
+
handle.write(f"{text}\n")
|
|
73
|
+
os.chmod(temporary, 0o600) # whatever the umask made of the mode above
|
|
74
|
+
os.replace(temporary, path)
|
|
75
|
+
except BaseException:
|
|
76
|
+
temporary.unlink(missing_ok=True)
|
|
77
|
+
raise
|
|
78
|
+
return path
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def read_token(path: Path | str) -> str | None:
|
|
82
|
+
"""The token in a token file, or None when there is none to read."""
|
|
83
|
+
try:
|
|
84
|
+
text = Path(path).read_text(encoding="utf-8").strip()
|
|
85
|
+
except OSError:
|
|
86
|
+
return None
|
|
87
|
+
return text or None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def matches(given: str | None, expected: str | None) -> bool:
|
|
91
|
+
"""Compared in constant time: how long a refusal takes says nothing about the token."""
|
|
92
|
+
if not given or not expected:
|
|
93
|
+
return False
|
|
94
|
+
return hmac.compare_digest(given.encode("utf-8"), expected.encode("utf-8"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class Access:
|
|
99
|
+
"""How a server decides who may change its state."""
|
|
100
|
+
|
|
101
|
+
token: str
|
|
102
|
+
#: Where the token was written, for the CLI to find; reported by `/health`.
|
|
103
|
+
file: Path | None = None
|
|
104
|
+
#: The MCP endpoint's token: it opens `/_tuningfork/mcp`, and nothing else.
|
|
105
|
+
mcp_token: str | None = None
|
|
106
|
+
mcp_file: Path | None = None
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def start(cls, home: Path | str, token: str | None = None, mcp_token: str | None = None) -> Access:
|
|
110
|
+
"""A proxy's access: the tokens it was given, or new ones, written into its home."""
|
|
111
|
+
token = token or new_token()
|
|
112
|
+
mcp_token = mcp_token or new_token()
|
|
113
|
+
if matches(mcp_token, token):
|
|
114
|
+
raise ValueError(f"{MCP_ENV} must differ from {ENV}: the MCP token opens the MCP endpoint alone")
|
|
115
|
+
return cls(token=token, file=write_token(home, token), mcp_token=mcp_token,
|
|
116
|
+
mcp_file=write_secret(mcp_token_path(home), mcp_token))
|
|
117
|
+
|
|
118
|
+
def allows(self, given: str | None) -> bool:
|
|
119
|
+
"""Whether `given` is the access token, which every change but MCP needs."""
|
|
120
|
+
return matches(given, self.token)
|
|
121
|
+
|
|
122
|
+
def allows_mcp(self, given: str | None) -> bool:
|
|
123
|
+
"""Whether `given` is the MCP token, which the MCP endpoint alone takes."""
|
|
124
|
+
return matches(given, self.mcp_token)
|
|
125
|
+
|
|
126
|
+
def status(self, headers: Mapping[str, Any]) -> dict[str, Any]:
|
|
127
|
+
"""What `/status` says about the asking page's token: whether one is needed, and
|
|
128
|
+
whether the one it sent (if any) is it."""
|
|
129
|
+
return {"required": True, "valid": self.allows(headers.get(HEADER))}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Outcome normalization, statistics, distributions, and divergence localization."""
|
|
2
|
+
|
|
3
|
+
from tuningfork.analysis.distribution import (
|
|
4
|
+
ABComparison,
|
|
5
|
+
Cluster,
|
|
6
|
+
ClusterShift,
|
|
7
|
+
Distribution,
|
|
8
|
+
TargetTest,
|
|
9
|
+
compare_arms,
|
|
10
|
+
distribution,
|
|
11
|
+
)
|
|
12
|
+
from tuningfork.analysis.divergence import (
|
|
13
|
+
DivergenceReport,
|
|
14
|
+
OutcomeGroup,
|
|
15
|
+
StepComparison,
|
|
16
|
+
compare_runs,
|
|
17
|
+
)
|
|
18
|
+
from tuningfork.analysis.normalize import (
|
|
19
|
+
KNOWN_NORMALIZERS,
|
|
20
|
+
Outcome,
|
|
21
|
+
outcome_for,
|
|
22
|
+
step_outcome,
|
|
23
|
+
)
|
|
24
|
+
from tuningfork.analysis.stats import (
|
|
25
|
+
Rate,
|
|
26
|
+
fisher_exact,
|
|
27
|
+
rate,
|
|
28
|
+
samples_needed,
|
|
29
|
+
wilson_interval,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"KNOWN_NORMALIZERS",
|
|
34
|
+
"ABComparison",
|
|
35
|
+
"Cluster",
|
|
36
|
+
"ClusterShift",
|
|
37
|
+
"Distribution",
|
|
38
|
+
"DivergenceReport",
|
|
39
|
+
"Outcome",
|
|
40
|
+
"OutcomeGroup",
|
|
41
|
+
"Rate",
|
|
42
|
+
"StepComparison",
|
|
43
|
+
"TargetTest",
|
|
44
|
+
"compare_arms",
|
|
45
|
+
"compare_runs",
|
|
46
|
+
"distribution",
|
|
47
|
+
"fisher_exact",
|
|
48
|
+
"outcome_for",
|
|
49
|
+
"rate",
|
|
50
|
+
"samples_needed",
|
|
51
|
+
"step_outcome",
|
|
52
|
+
"wilson_interval",
|
|
53
|
+
]
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Pass rates by condition: runs of one task, under one harness, model, skill version and
|
|
2
|
+
server.
|
|
3
|
+
|
|
4
|
+
A rate pooled across wordings or models measures none of them. Once a task has been run
|
|
5
|
+
before and after an edit — which is the whole workflow — its runs are two samples, not one,
|
|
6
|
+
and each gets its own rate. So do runs answered by different servers: LM Studio and
|
|
7
|
+
llama-server can both report the same model name for one GGUF, with chat templates that
|
|
8
|
+
need not agree.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Iterable
|
|
15
|
+
|
|
16
|
+
from tuningfork.analysis.stats import Rate, rate
|
|
17
|
+
from tuningfork.store.db import Run
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def pass_rate(results: Iterable[dict[str, Any]]) -> Rate | None:
|
|
21
|
+
"""The check pass rate over stored run results (`runner.RunOutcome.to_result`).
|
|
22
|
+
|
|
23
|
+
None when no run has a verdict. Runs the batch marked unusable are left out: a run
|
|
24
|
+
whose skill changed underneath it, that timed out, was edited at a breakpoint or whose
|
|
25
|
+
model calls failed describes neither wording.
|
|
26
|
+
"""
|
|
27
|
+
verdicts = [
|
|
28
|
+
result["passed"]
|
|
29
|
+
for result in results
|
|
30
|
+
if result.get("usable") and result.get("passed") is not None
|
|
31
|
+
]
|
|
32
|
+
return rate(sum(verdicts), len(verdicts)) if verdicts else None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
#: What `exclusion_from` says of a run that timed out.
|
|
36
|
+
TIMED_OUT = "timed out"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def exclusion_from(result: dict[str, Any] | None) -> str | None:
|
|
40
|
+
"""Why a run with this stored result (`runner.RunOutcome.to_result`) is not counted, in
|
|
41
|
+
words; None when nothing in it says so."""
|
|
42
|
+
result = result or {}
|
|
43
|
+
|
|
44
|
+
def steps(key: str) -> str:
|
|
45
|
+
return ", ".join(map(str, result[key]))
|
|
46
|
+
|
|
47
|
+
if result.get("skills_changed"):
|
|
48
|
+
return f"skills changed: {', '.join(result['skills_changed'])}"
|
|
49
|
+
if result.get("timed_out"):
|
|
50
|
+
return TIMED_OUT
|
|
51
|
+
if result.get("edited_steps"):
|
|
52
|
+
return f"edited at a breakpoint (step {steps('edited_steps')})"
|
|
53
|
+
if result.get("failed_steps"):
|
|
54
|
+
return f"model call failed (step {steps('failed_steps')})"
|
|
55
|
+
if result.get("thread_trouble"):
|
|
56
|
+
return f"a call failed or was edited in {', '.join(result['thread_trouble'])}"
|
|
57
|
+
departed = result.get("departed")
|
|
58
|
+
if departed:
|
|
59
|
+
where = f"step {departed.get('step')}"
|
|
60
|
+
if departed.get("thread"):
|
|
61
|
+
where = f"thread {departed['thread']} {where}"
|
|
62
|
+
return f"the replay departed from the recording at {where}"
|
|
63
|
+
if result.get("leaked"):
|
|
64
|
+
return f"the original wording reached the model (step {steps('leaked')})"
|
|
65
|
+
if result.get("reached") is False:
|
|
66
|
+
return f"the harness never reached step {result.get('fork_step')}"
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def fork_counted(passed: bool | None, exclusion: str | None) -> bool | None:
|
|
71
|
+
"""How a fork run to the end counts in its arm: as its check said; failed when it timed
|
|
72
|
+
out, as the gate counts a timeout (`analysis.validation.COUNTING`) — an edit that makes the
|
|
73
|
+
model loop must not drop out of its own count; None, left out, when something else kept it
|
|
74
|
+
from measuring the wording, or no check judged it."""
|
|
75
|
+
if exclusion == TIMED_OUT:
|
|
76
|
+
return False
|
|
77
|
+
if exclusion is not None or passed is None:
|
|
78
|
+
return None
|
|
79
|
+
return bool(passed)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def counted_verdict(result: dict[str, Any] | None) -> str | None:
|
|
83
|
+
"""How one run fared, as `pass_rate` counts it: "passed" or "failed"; "not counted" for a
|
|
84
|
+
verdict every rate leaves out; None for a run with no verdict."""
|
|
85
|
+
result = result or {}
|
|
86
|
+
if result.get("passed") is None:
|
|
87
|
+
return None
|
|
88
|
+
if not result.get("usable"):
|
|
89
|
+
return "not counted"
|
|
90
|
+
return "passed" if result["passed"] else "failed"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass(slots=True)
|
|
94
|
+
class Condition:
|
|
95
|
+
task_id: str | None
|
|
96
|
+
harness: str | None
|
|
97
|
+
model: str | None
|
|
98
|
+
#: `Run.skill_version`: a fingerprint of the skill files the runs were given.
|
|
99
|
+
skill_version: str | None
|
|
100
|
+
#: The server the runs were answered by (`Store.server`); None for runs recorded before
|
|
101
|
+
#: servers were, which form a condition of their own.
|
|
102
|
+
server_id: str | None = None
|
|
103
|
+
runs: list[Run] = field(default_factory=list)
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def run_ids(self) -> list[str]:
|
|
107
|
+
return [run.id for run in self.runs]
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def skill_file(self) -> str | None:
|
|
111
|
+
"""The file a batch installed as SKILL.md in place of the spec's own, if every run had
|
|
112
|
+
it. Runs given the same text another way share the fingerprint, and the condition: a
|
|
113
|
+
validation's original arm installs a copy of the skill's own file."""
|
|
114
|
+
files = {run.parity.get("skill_file") or None for run in self.runs}
|
|
115
|
+
return files.pop() if len(files) == 1 else None
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def passed(self) -> Rate | None:
|
|
119
|
+
return pass_rate(run.result for run in self.runs if run.result)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def uncounted(self) -> int:
|
|
123
|
+
"""Runs with a verdict that may not be counted: timed out, skill changed, edited."""
|
|
124
|
+
return sum(1 for run in self.runs if run.result and not run.result.get("usable"))
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def first(self) -> float:
|
|
128
|
+
return min(run.started_at for run in self.runs)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def latest(self) -> float:
|
|
132
|
+
return max(run.started_at for run in self.runs)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def conditions(runs: Iterable[Run]) -> list[Condition]:
|
|
136
|
+
"""Runs grouped by task, harness, model, skill version and server, oldest first.
|
|
137
|
+
|
|
138
|
+
The skill version is the content the runs were given, not how it got there: a variant
|
|
139
|
+
installed with `batch --skill` and the same text later copied into SKILL.md are one
|
|
140
|
+
condition. A launch's subagent threads and an experiment's forks are not runs of the
|
|
141
|
+
task, and are expected to have been left out already (`Store.runs` does).
|
|
142
|
+
"""
|
|
143
|
+
groups: dict[tuple[str | None, ...], Condition] = {}
|
|
144
|
+
for run in runs:
|
|
145
|
+
key = (run.task_id, run.harness, run.model, run.skill_version, run.server_id)
|
|
146
|
+
if key not in groups:
|
|
147
|
+
groups[key] = Condition(*key)
|
|
148
|
+
groups[key].runs.append(run)
|
|
149
|
+
return sorted(groups.values(), key=lambda condition: condition.first)
|