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.
Files changed (178) hide show
  1. tuningfork/__init__.py +3 -0
  2. tuningfork/__main__.py +6 -0
  3. tuningfork/access.py +129 -0
  4. tuningfork/analysis/__init__.py +53 -0
  5. tuningfork/analysis/conditions.py +149 -0
  6. tuningfork/analysis/distribution.py +511 -0
  7. tuningfork/analysis/divergence.py +177 -0
  8. tuningfork/analysis/forking.py +304 -0
  9. tuningfork/analysis/normalize.py +423 -0
  10. tuningfork/analysis/reasoning.py +87 -0
  11. tuningfork/analysis/screen.py +300 -0
  12. tuningfork/analysis/stats.py +171 -0
  13. tuningfork/analysis/tokens.py +623 -0
  14. tuningfork/analysis/validation.py +323 -0
  15. tuningfork/api/__init__.py +6 -0
  16. tuningfork/api/connections.py +183 -0
  17. tuningfork/api/control.py +179 -0
  18. tuningfork/api/data.py +429 -0
  19. tuningfork/api/guard.py +218 -0
  20. tuningfork/api/imports.py +159 -0
  21. tuningfork/api/jobs.py +118 -0
  22. tuningfork/api/refinements.py +214 -0
  23. tuningfork/api/scenarios.py +158 -0
  24. tuningfork/api/skills.py +130 -0
  25. tuningfork/api/trash.py +87 -0
  26. tuningfork/backends/__init__.py +24 -0
  27. tuningfork/backends/base.py +192 -0
  28. tuningfork/backends/formats/__init__.py +47 -0
  29. tuningfork/backends/formats/base.py +79 -0
  30. tuningfork/backends/formats/gemma4.py +258 -0
  31. tuningfork/backends/formats/qwen3_coder.py +506 -0
  32. tuningfork/backends/formats/qwen_json.py +412 -0
  33. tuningfork/backends/llamacpp/__init__.py +108 -0
  34. tuningfork/backends/llamacpp/anthropic.py +149 -0
  35. tuningfork/backends/llamacpp/raw.py +272 -0
  36. tuningfork/backends/probe.py +844 -0
  37. tuningfork/backends/raw.py +103 -0
  38. tuningfork/breakpoints/__init__.py +27 -0
  39. tuningfork/breakpoints/conditions.py +369 -0
  40. tuningfork/breakpoints/engine.py +441 -0
  41. tuningfork/breakpoints/events.py +71 -0
  42. tuningfork/cli.py +5210 -0
  43. tuningfork/config.py +747 -0
  44. tuningfork/control.py +334 -0
  45. tuningfork/demo/__init__.py +7 -0
  46. tuningfork/demo/agent.py +137 -0
  47. tuningfork/demo/lab.py +523 -0
  48. tuningfork/demo/mock.py +434 -0
  49. tuningfork/demo/task.py +123 -0
  50. tuningfork/demo/toy.py +309 -0
  51. tuningfork/endpoints.py +213 -0
  52. tuningfork/experiment/__init__.py +55 -0
  53. tuningfork/experiment/forking.py +253 -0
  54. tuningfork/experiment/sampling.py +926 -0
  55. tuningfork/experiment/scoring.py +254 -0
  56. tuningfork/experiment/skill.py +448 -0
  57. tuningfork/harness/__init__.py +31 -0
  58. tuningfork/harness/base.py +194 -0
  59. tuningfork/harness/claude_code.py +314 -0
  60. tuningfork/harness/hermes.py +599 -0
  61. tuningfork/jobs.py +571 -0
  62. tuningfork/launches.py +189 -0
  63. tuningfork/mcp/__init__.py +10 -0
  64. tuningfork/mcp/backend.py +167 -0
  65. tuningfork/mcp/http.py +100 -0
  66. tuningfork/mcp/schema.py +164 -0
  67. tuningfork/mcp/server.py +414 -0
  68. tuningfork/mcp/stdio.py +98 -0
  69. tuningfork/mcp/tools.py +1591 -0
  70. tuningfork/ops/__init__.py +36 -0
  71. tuningfork/ops/addskill.py +396 -0
  72. tuningfork/ops/approve.py +619 -0
  73. tuningfork/ops/batch.py +487 -0
  74. tuningfork/ops/catalog.py +985 -0
  75. tuningfork/ops/chain.py +501 -0
  76. tuningfork/ops/checks.py +461 -0
  77. tuningfork/ops/common.py +319 -0
  78. tuningfork/ops/compact.py +1198 -0
  79. tuningfork/ops/connections.py +506 -0
  80. tuningfork/ops/diff.py +339 -0
  81. tuningfork/ops/experiments.py +922 -0
  82. tuningfork/ops/forking.py +317 -0
  83. tuningfork/ops/forks.py +707 -0
  84. tuningfork/ops/glossary.py +351 -0
  85. tuningfork/ops/holds.py +271 -0
  86. tuningfork/ops/imports.py +1485 -0
  87. tuningfork/ops/inspect.py +978 -0
  88. tuningfork/ops/launch.py +80 -0
  89. tuningfork/ops/packet.py +270 -0
  90. tuningfork/ops/private.py +245 -0
  91. tuningfork/ops/proposer.py +433 -0
  92. tuningfork/ops/refine.py +796 -0
  93. tuningfork/ops/scenarios.py +503 -0
  94. tuningfork/ops/screen.py +910 -0
  95. tuningfork/ops/skills.py +539 -0
  96. tuningfork/ops/spend.py +209 -0
  97. tuningfork/ops/tally.py +338 -0
  98. tuningfork/ops/targets.py +251 -0
  99. tuningfork/ops/timeline.py +732 -0
  100. tuningfork/ops/tokens.py +335 -0
  101. tuningfork/ops/trash.py +606 -0
  102. tuningfork/ops/validate.py +486 -0
  103. tuningfork/ops/variants.py +134 -0
  104. tuningfork/ops/where.py +592 -0
  105. tuningfork/owners.py +50 -0
  106. tuningfork/paths.py +160 -0
  107. tuningfork/proxy/__init__.py +1 -0
  108. tuningfork/proxy/app.py +988 -0
  109. tuningfork/proxy/held.py +446 -0
  110. tuningfork/proxy/launches.py +210 -0
  111. tuningfork/proxy/local.py +148 -0
  112. tuningfork/proxy/recorder.py +386 -0
  113. tuningfork/proxy/replay.py +99 -0
  114. tuningfork/proxy/sse.py +346 -0
  115. tuningfork/replay.py +490 -0
  116. tuningfork/runner.py +1156 -0
  117. tuningfork/sandbox.py +339 -0
  118. tuningfork/snapshots.py +270 -0
  119. tuningfork/specs.py +724 -0
  120. tuningfork/store/__init__.py +6 -0
  121. tuningfork/store/blobs.py +88 -0
  122. tuningfork/store/db.py +2817 -0
  123. tuningfork/store/masking.py +156 -0
  124. tuningfork/store/tree.py +118 -0
  125. tuningfork/ui/__init__.py +33 -0
  126. tuningfork/ui/static/app.css +1745 -0
  127. tuningfork/ui/static/index.html +104 -0
  128. tuningfork/ui/static/js/api.js +92 -0
  129. tuningfork/ui/static/js/app.js +415 -0
  130. tuningfork/ui/static/js/auth.js +88 -0
  131. tuningfork/ui/static/js/checktry.js +258 -0
  132. tuningfork/ui/static/js/cost.js +23 -0
  133. tuningfork/ui/static/js/dom.js +308 -0
  134. tuningfork/ui/static/js/forms.js +246 -0
  135. tuningfork/ui/static/js/holdtools.js +131 -0
  136. tuningfork/ui/static/js/jobs.js +332 -0
  137. tuningfork/ui/static/js/launcher.js +95 -0
  138. tuningfork/ui/static/js/live.js +129 -0
  139. tuningfork/ui/static/js/markdown.js +306 -0
  140. tuningfork/ui/static/js/nav.js +30 -0
  141. tuningfork/ui/static/js/payload.js +287 -0
  142. tuningfork/ui/static/js/results.js +587 -0
  143. tuningfork/ui/static/js/start.js +593 -0
  144. tuningfork/ui/static/js/stepinfo.js +231 -0
  145. tuningfork/ui/static/js/targets.js +144 -0
  146. tuningfork/ui/static/js/timeline.js +1002 -0
  147. tuningfork/ui/static/js/tokens.js +412 -0
  148. tuningfork/ui/static/js/trash.js +113 -0
  149. tuningfork/ui/static/js/variants.js +197 -0
  150. tuningfork/ui/static/js/views/batch.js +194 -0
  151. tuningfork/ui/static/js/views/chains.js +91 -0
  152. tuningfork/ui/static/js/views/compare.js +305 -0
  153. tuningfork/ui/static/js/views/connections.js +555 -0
  154. tuningfork/ui/static/js/views/experiments.js +365 -0
  155. tuningfork/ui/static/js/views/home.js +397 -0
  156. tuningfork/ui/static/js/views/import.js +570 -0
  157. tuningfork/ui/static/js/views/jobs.js +446 -0
  158. tuningfork/ui/static/js/views/live.js +848 -0
  159. tuningfork/ui/static/js/views/refine.js +1352 -0
  160. tuningfork/ui/static/js/views/run.js +246 -0
  161. tuningfork/ui/static/js/views/runs.js +264 -0
  162. tuningfork/ui/static/js/views/scenarios.js +574 -0
  163. tuningfork/ui/static/js/views/skills.js +466 -0
  164. tuningfork/ui/static/js/views/step.js +144 -0
  165. tuningfork/ui/static/js/views/trash.js +90 -0
  166. tuningfork/ui/static/js/where.js +141 -0
  167. tuningfork/ui/static/js/words.js +55 -0
  168. tuningfork/wire/__init__.py +28 -0
  169. tuningfork/wire/anthropic.py +605 -0
  170. tuningfork/wire/base.py +176 -0
  171. tuningfork/wire/completion.py +136 -0
  172. tuningfork/wire/openai.py +162 -0
  173. tuningfork_lab-0.6.0.dist-info/METADATA +315 -0
  174. tuningfork_lab-0.6.0.dist-info/RECORD +178 -0
  175. tuningfork_lab-0.6.0.dist-info/WHEEL +4 -0
  176. tuningfork_lab-0.6.0.dist-info/entry_points.txt +2 -0
  177. tuningfork_lab-0.6.0.dist-info/licenses/LICENSE +201 -0
  178. tuningfork_lab-0.6.0.dist-info/licenses/NOTICE +23 -0
tuningfork/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """tuningfork — a step debugger and experiment lab for agent skills on local LLMs."""
2
+
3
+ __version__ = "0.6.0"
tuningfork/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """`python -m tuningfork`: the CLI, for a client config that names a Python, not a script."""
2
+
3
+ from tuningfork.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app(prog_name="tuningfork")
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)