agentpace 0.9.2__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.
- agentpace/__init__.py +3 -0
- agentpace/__main__.py +8 -0
- agentpace/_bundled/DATA-LICENSE +121 -0
- agentpace/_bundled/example-plan.json +11 -0
- agentpace/_bundled/run.schema.json +100 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W40-2026-W45.jsonl +3219 -0
- agentpace/_bundled/runs/4546788e7930eccd-2026-W44-2026-W45.jsonl +211 -0
- agentpace/_bundled/turn.schema.json +30 -0
- agentpace/adapters/__init__.py +31 -0
- agentpace/adapters/base.py +74 -0
- agentpace/adapters/claude_code.py +265 -0
- agentpace/adapters/cline.py +449 -0
- agentpace/adapters/codex.py +293 -0
- agentpace/adapters/gemini_cli.py +332 -0
- agentpace/adapters/opencode.py +397 -0
- agentpace/backtest.py +801 -0
- agentpace/cache.py +115 -0
- agentpace/charts.py +191 -0
- agentpace/claims.py +164 -0
- agentpace/classify.py +54 -0
- agentpace/cli.py +754 -0
- agentpace/estimate.py +694 -0
- agentpace/forecasts.py +284 -0
- agentpace/ingest.py +337 -0
- agentpace/paths.py +62 -0
- agentpace/pseudonym.py +51 -0
- agentpace/py.typed +0 -0
- agentpace/render.py +152 -0
- agentpace/report.py +502 -0
- agentpace/schema.py +131 -0
- agentpace/share.py +130 -0
- agentpace/site.py +133 -0
- agentpace/turns.py +282 -0
- agentpace/validate.py +115 -0
- agentpace-0.9.2.dist-info/METADATA +324 -0
- agentpace-0.9.2.dist-info/RECORD +39 -0
- agentpace-0.9.2.dist-info/WHEEL +4 -0
- agentpace-0.9.2.dist-info/entry_points.txt +2 -0
- agentpace-0.9.2.dist-info/licenses/LICENSE +21 -0
agentpace/cache.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Remember parsed runs, so a second ingest reads only what changed.
|
|
2
|
+
|
|
3
|
+
A cold ingest of a few thousand transcripts takes minutes, because every line has to be decoded.
|
|
4
|
+
The cache maps each transcript path to the size and modification time of the transcript and of
|
|
5
|
+
its companion files, such as Claude Code's `.meta.json`, and to the `RawRun` parsed from them. A
|
|
6
|
+
run is parsed again when any of those stamps changes. An unfinished run can only change by
|
|
7
|
+
writing to one of those files, so it needs no special case.
|
|
8
|
+
|
|
9
|
+
The cache also keeps runs whose transcript has since been deleted. Claude Code removes old
|
|
10
|
+
transcripts, and a session's surviving runs take their offsets from every sibling's spans, so
|
|
11
|
+
losing an early sibling would shift the rest and duplicate them in the store.
|
|
12
|
+
|
|
13
|
+
Privacy: the file stays in the local agentpace folder, beside the secret, and is never shared. It
|
|
14
|
+
holds only `RawRun` fields, with the model already normalized, plus the transcript path. Folder
|
|
15
|
+
names and agent types sit there as they sit in the transcripts it summarizes.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import dataclasses
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from agentpace.adapters.base import RawRun
|
|
27
|
+
|
|
28
|
+
FORMAT = 3 # 2: keys are os.path.normcase paths. 3: each entry holds a list of runs
|
|
29
|
+
|
|
30
|
+
# [size, mtime_ns] for the transcript and each companion file, or None for a missing companion.
|
|
31
|
+
Stamps = list[list[int] | None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _key(path: str) -> str:
|
|
35
|
+
"""One key per file. Windows paths are case-insensitive, so `c:\\` and `C:\\` must match, or a
|
|
36
|
+
deleted transcript would come back twice."""
|
|
37
|
+
return os.path.normcase(path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _to_json(run: RawRun) -> dict[str, Any]:
|
|
41
|
+
return dataclasses.asdict(run)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _from_json(data: dict[str, Any]) -> RawRun:
|
|
45
|
+
spans = tuple((float(start), float(end)) for start, end in data["spans"])
|
|
46
|
+
return RawRun(**{**data, "spans": spans})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _entry_runs(entry: dict[str, Any]) -> list[RawRun]:
|
|
50
|
+
return [_from_json(run) for run in entry["runs"]]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class RunCache:
|
|
54
|
+
def __init__(self, path: Path, adapter: str, adapter_version: int, idle_cap_s: int) -> None:
|
|
55
|
+
self.path = path
|
|
56
|
+
self._header = {
|
|
57
|
+
"format": FORMAT,
|
|
58
|
+
"adapter": adapter,
|
|
59
|
+
"adapter_version": adapter_version,
|
|
60
|
+
"idle_cap_s": idle_cap_s,
|
|
61
|
+
}
|
|
62
|
+
self.entries: dict[str, dict[str, Any]] = {}
|
|
63
|
+
self.rebuilt = False
|
|
64
|
+
self._load()
|
|
65
|
+
|
|
66
|
+
def _load(self) -> None:
|
|
67
|
+
if not self.path.exists():
|
|
68
|
+
return
|
|
69
|
+
try:
|
|
70
|
+
stored = json.loads(self.path.read_text(encoding="utf-8"))
|
|
71
|
+
if {key: stored.get(key) for key in self._header} != self._header:
|
|
72
|
+
raise ValueError("cache was written by another version")
|
|
73
|
+
entries = stored["entries"]
|
|
74
|
+
for entry in entries.values():
|
|
75
|
+
if not isinstance(entry["stamps"], list) or not isinstance(entry["runs"], list):
|
|
76
|
+
raise ValueError("cache entry has no stamps or runs")
|
|
77
|
+
_entry_runs(entry) # reject anything that no longer fits a RawRun
|
|
78
|
+
self.entries = entries
|
|
79
|
+
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
|
80
|
+
self.entries = {}
|
|
81
|
+
self.rebuilt = True
|
|
82
|
+
|
|
83
|
+
def lookup(self, path: str, stamps: Stamps) -> tuple[bool, list[RawRun]]:
|
|
84
|
+
"""Return (hit, runs). A hit needs the transcript and every companion file unchanged."""
|
|
85
|
+
entry = self.entries.get(_key(path))
|
|
86
|
+
if entry is None or entry["stamps"] != stamps:
|
|
87
|
+
return False, []
|
|
88
|
+
return True, _entry_runs(entry)
|
|
89
|
+
|
|
90
|
+
def store(self, path: str, stamps: Stamps, runs: list[RawRun]) -> None:
|
|
91
|
+
self.entries[_key(path)] = {"stamps": stamps, "runs": [_to_json(run) for run in runs]}
|
|
92
|
+
|
|
93
|
+
def vanished(self, sessions: set[tuple[str, str]], seen: set[str]) -> list[RawRun]:
|
|
94
|
+
"""Cached runs of these sessions whose transcript no longer exists."""
|
|
95
|
+
runs = []
|
|
96
|
+
seen = {_key(path) for path in seen}
|
|
97
|
+
for path, entry in self.entries.items():
|
|
98
|
+
if path in seen:
|
|
99
|
+
continue
|
|
100
|
+
found = [run for run in _entry_runs(entry) if (run.project, run.session) in sessions]
|
|
101
|
+
if found and not os.path.exists(path):
|
|
102
|
+
runs.extend(found)
|
|
103
|
+
return runs
|
|
104
|
+
|
|
105
|
+
def runs(self) -> list[RawRun]:
|
|
106
|
+
"""Every cached run, including runs whose transcript is gone."""
|
|
107
|
+
return [run for entry in self.entries.values() for run in _entry_runs(entry)]
|
|
108
|
+
|
|
109
|
+
def save(self) -> None:
|
|
110
|
+
"""Write atomically, so a reader never sees half a file."""
|
|
111
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
|
+
temporary = self.path.with_name(self.path.name + ".tmp")
|
|
113
|
+
document = {**self._header, "entries": self.entries}
|
|
114
|
+
temporary.write_text(json.dumps(document, separators=(",", ":")), encoding="utf-8")
|
|
115
|
+
os.replace(temporary, self.path)
|
agentpace/charts.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Small static SVG charts, written by hand so the CLI stays standard-library only.
|
|
2
|
+
|
|
3
|
+
Colors come from a palette checked for color-vision safety, and the charts follow the reader's
|
|
4
|
+
light or dark theme. Every mark carries a `<title>` for hover, and every number also appears in a
|
|
5
|
+
table in `docs/backtest.md`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import html
|
|
11
|
+
import math
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from agentpace.render import duration
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def escape(text: str) -> str:
|
|
19
|
+
"""Escape &, < and >, as xml.sax.saxutils.escape does, without importing the network stack
|
|
20
|
+
that module drags in."""
|
|
21
|
+
return html.escape(text, quote=False)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
_STYLE = """
|
|
25
|
+
<style>
|
|
26
|
+
svg {
|
|
27
|
+
--panel: #fcfcfb; --text: #0b0b0b; --muted: #52514e; --grid: #e4e3df;
|
|
28
|
+
--primary: #2a78d6; --second: #eb6834;
|
|
29
|
+
}
|
|
30
|
+
@media (prefers-color-scheme: dark) {
|
|
31
|
+
svg {
|
|
32
|
+
--panel: #1a1a19; --text: #f0efec; --muted: #c3c2b7; --grid: #3a3a37;
|
|
33
|
+
--primary: #3987e5; --second: #d95926;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
.panel { fill: var(--panel); }
|
|
37
|
+
text {
|
|
38
|
+
font: 13px system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
39
|
+
fill: var(--muted);
|
|
40
|
+
paint-order: stroke;
|
|
41
|
+
stroke: var(--panel);
|
|
42
|
+
stroke-width: 4px;
|
|
43
|
+
stroke-linejoin: round;
|
|
44
|
+
}
|
|
45
|
+
.title { font-size: 15px; font-weight: 600; fill: var(--text); }
|
|
46
|
+
.label { fill: var(--text); }
|
|
47
|
+
.grid { stroke: var(--grid); stroke-width: 1; }
|
|
48
|
+
.goal { stroke: var(--text); stroke-width: 1.5; stroke-dasharray: 4 3; }
|
|
49
|
+
.range { stroke: var(--primary); stroke-width: 8; stroke-linecap: round; }
|
|
50
|
+
.dot { fill: var(--primary); }
|
|
51
|
+
.second { fill: var(--second); }
|
|
52
|
+
.tick { stroke: var(--panel); stroke-width: 2; }
|
|
53
|
+
</style>
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
_TICKS = (
|
|
57
|
+
(10, "10 s"),
|
|
58
|
+
(30, "30 s"),
|
|
59
|
+
(60, "1 min"),
|
|
60
|
+
(300, "5 min"),
|
|
61
|
+
(600, "10 min"),
|
|
62
|
+
(1800, "30 min"),
|
|
63
|
+
(3600, "1 h"),
|
|
64
|
+
(10800, "3 h"),
|
|
65
|
+
(36000, "10 h"),
|
|
66
|
+
)
|
|
67
|
+
_MARGIN = 16
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class RangeBar:
|
|
72
|
+
label: str
|
|
73
|
+
n: int
|
|
74
|
+
p50: float
|
|
75
|
+
p80: float
|
|
76
|
+
p90: float
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _open(width: int, height: int, title: str) -> list[str]:
|
|
80
|
+
return [
|
|
81
|
+
f'<svg xmlns="http://www.w3.org/2000/svg" '
|
|
82
|
+
f'viewBox="{-_MARGIN} {-_MARGIN} {width + 2 * _MARGIN} {height + 2 * _MARGIN}" '
|
|
83
|
+
f'width="{width + 2 * _MARGIN}" height="{height + 2 * _MARGIN}" role="img" '
|
|
84
|
+
f'aria-label="{escape(title)}">',
|
|
85
|
+
f"<title>{escape(title)}</title>",
|
|
86
|
+
_STYLE,
|
|
87
|
+
f'<rect class="panel" x="{-_MARGIN}" y="{-_MARGIN}" width="{width + 2 * _MARGIN}" '
|
|
88
|
+
f'height="{height + 2 * _MARGIN}" rx="8"/>',
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def range_chart(title: str, bars: Sequence[RangeBar]) -> str:
|
|
93
|
+
"""Horizontal P50 to P90 bars on a log axis: durations span several orders of magnitude."""
|
|
94
|
+
left, right, top, row = 190, 150, 62, 36
|
|
95
|
+
width = 860
|
|
96
|
+
height = top + row * len(bars) + 34
|
|
97
|
+
low = min(bar.p50 for bar in bars) / 1.5
|
|
98
|
+
high = max(max(bar.p90 for bar in bars) * 1.1, 3700.0)
|
|
99
|
+
|
|
100
|
+
def x(value: float) -> float:
|
|
101
|
+
span = math.log(high) - math.log(low)
|
|
102
|
+
return left + (math.log(value) - math.log(low)) / span * (width - left - right)
|
|
103
|
+
|
|
104
|
+
parts = _open(width, height, title)
|
|
105
|
+
parts.append(f'<text class="title" x="0" y="20">{escape(title)}</text>')
|
|
106
|
+
parts.append(
|
|
107
|
+
'<text x="0" y="42">Bar runs from P50 to P90. The gap marks P80. '
|
|
108
|
+
"Log scale, seconds to hours.</text>"
|
|
109
|
+
)
|
|
110
|
+
for tick, label in _TICKS:
|
|
111
|
+
if low <= tick <= high:
|
|
112
|
+
position = x(tick)
|
|
113
|
+
parts.append(
|
|
114
|
+
f'<line class="grid" x1="{position:.1f}" y1="{top - 6}" '
|
|
115
|
+
f'x2="{position:.1f}" y2="{height - 26}"/>'
|
|
116
|
+
)
|
|
117
|
+
parts.append(
|
|
118
|
+
f'<text x="{position:.1f}" y="{height - 8}" text-anchor="middle">{label}</text>'
|
|
119
|
+
)
|
|
120
|
+
for index, bar in enumerate(bars):
|
|
121
|
+
y = top + row * index + row / 2
|
|
122
|
+
parts.append(
|
|
123
|
+
f'<text class="label" x="0" y="{y + 4:.1f}">{escape(bar.label)} '
|
|
124
|
+
f"<tspan>n={bar.n:,}</tspan></text>"
|
|
125
|
+
)
|
|
126
|
+
parts.append(
|
|
127
|
+
f"<g><title>{escape(bar.label)}: P50 {duration(bar.p50)}, P80 {duration(bar.p80)}, "
|
|
128
|
+
f"P90 {duration(bar.p90)} (n={bar.n:,})</title>"
|
|
129
|
+
f'<line class="range" x1="{x(bar.p50):.1f}" y1="{y:.1f}" '
|
|
130
|
+
f'x2="{x(bar.p90):.1f}" y2="{y:.1f}"/>'
|
|
131
|
+
f'<line class="tick" x1="{x(bar.p80):.1f}" y1="{y - 4:.1f}" '
|
|
132
|
+
f'x2="{x(bar.p80):.1f}" y2="{y + 4:.1f}"/></g>'
|
|
133
|
+
)
|
|
134
|
+
parts.append(
|
|
135
|
+
f'<text x="{x(bar.p90) + 12:.1f}" y="{y + 4:.1f}">'
|
|
136
|
+
f"{duration(bar.p50)} to {duration(bar.p90)}</text>"
|
|
137
|
+
)
|
|
138
|
+
parts.append("</svg>")
|
|
139
|
+
return "\n".join(parts) + "\n"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def coverage_chart(title: str, points: Sequence[tuple[str, float, bool]], goal: float) -> str:
|
|
143
|
+
"""How often the actual run landed at or under the P80 forecast, for each method."""
|
|
144
|
+
left, right, top, row = 150, 70, 78, 30
|
|
145
|
+
width = 760
|
|
146
|
+
height = top + row * len(points) + 34
|
|
147
|
+
|
|
148
|
+
def x(value: float) -> float:
|
|
149
|
+
return left + value / 100 * (width - left - right)
|
|
150
|
+
|
|
151
|
+
parts = _open(width, height, title)
|
|
152
|
+
parts.append(f'<text class="title" x="0" y="20">{escape(title)}</text>')
|
|
153
|
+
parts.append(
|
|
154
|
+
'<circle class="dot" cx="6" cy="38" r="5"/><text x="18" y="42">agentpace candidate</text>'
|
|
155
|
+
'<rect class="second" x="170" y="33" width="10" height="10"/>'
|
|
156
|
+
'<text x="188" y="42">baseline</text>'
|
|
157
|
+
)
|
|
158
|
+
for tick in (0, 20, 40, 60, 80, 100):
|
|
159
|
+
position = x(tick)
|
|
160
|
+
parts.append(
|
|
161
|
+
f'<line class="grid" x1="{position:.1f}" y1="{top - 6}" '
|
|
162
|
+
f'x2="{position:.1f}" y2="{height - 26}"/>'
|
|
163
|
+
f'<text x="{position:.1f}" y="{height - 8}" text-anchor="middle">{tick}%</text>'
|
|
164
|
+
)
|
|
165
|
+
parts.append(
|
|
166
|
+
f'<line class="goal" x1="{x(goal):.1f}" y1="{top - 10}" '
|
|
167
|
+
f'x2="{x(goal):.1f}" y2="{height - 26}"/>'
|
|
168
|
+
f'<text class="label" x="{x(goal):.1f}" y="{top - 14}" text-anchor="middle">'
|
|
169
|
+
f"target {goal:.0f}%</text>"
|
|
170
|
+
)
|
|
171
|
+
for index, (name, value, is_baseline) in enumerate(points):
|
|
172
|
+
y = top + row * index + row / 2
|
|
173
|
+
position = x(value)
|
|
174
|
+
parts.append(f'<text class="label" x="0" y="{y + 4:.1f}">{escape(name)}</text>')
|
|
175
|
+
tip = f"<title>{escape(name)}: {value:.1f}%</title>"
|
|
176
|
+
if is_baseline:
|
|
177
|
+
parts.append(
|
|
178
|
+
f'<g>{tip}<rect class="second" x="{position - 5:.1f}" y="{y - 5:.1f}" '
|
|
179
|
+
f'width="10" height="10"/></g>'
|
|
180
|
+
)
|
|
181
|
+
else:
|
|
182
|
+
parts.append(
|
|
183
|
+
f'<g>{tip}<circle class="dot" cx="{position:.1f}" cy="{y:.1f}" r="5"/></g>'
|
|
184
|
+
)
|
|
185
|
+
anchor, shift = ("end", -10) if value > 88 else ("start", 10)
|
|
186
|
+
parts.append(
|
|
187
|
+
f'<text x="{position + shift:.1f}" y="{y + 4:.1f}" text-anchor="{anchor}">'
|
|
188
|
+
f"{value:.1f}%</text>"
|
|
189
|
+
)
|
|
190
|
+
parts.append("</svg>")
|
|
191
|
+
return "\n".join(parts) + "\n"
|
agentpace/claims.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Keep published numbers tied to the data.
|
|
2
|
+
|
|
3
|
+
A number in a document sits between `<!-- claim:NAME -->` and `<!-- /claim -->`. This module works
|
|
4
|
+
out what each one should say. `agentpace claims --check` fails when any of them disagrees with the
|
|
5
|
+
rows and the backtest, so a figure in the README cannot outlive the data behind it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from agentpace import estimate as est
|
|
17
|
+
from agentpace import render
|
|
18
|
+
from agentpace.report import ci
|
|
19
|
+
from agentpace.schema import Row
|
|
20
|
+
|
|
21
|
+
_MARKER = re.compile(r"<!-- claim:([a-z0-9_]+) -->(.*?)<!-- /claim -->", re.DOTALL)
|
|
22
|
+
_MIN_TABLE_ROWS = 10
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _kind_table(rows: Sequence[Row]) -> str:
|
|
26
|
+
completed = [row for row in rows if row["outcome"] == "completed"]
|
|
27
|
+
entries = []
|
|
28
|
+
for kind in sorted({row["task_kind"] for row in completed}):
|
|
29
|
+
values = sorted(row["duration_s"] for row in completed if row["task_kind"] == kind)
|
|
30
|
+
if len(values) >= _MIN_TABLE_ROWS:
|
|
31
|
+
entries.append((kind, len(values), *(est.quantile(values, q) for q in est.QUANTILES)))
|
|
32
|
+
entries.sort(key=lambda entry: entry[2])
|
|
33
|
+
lines = ["| Task kind | Runs | P50 | P80 | P90 |", "|---|---:|---:|---:|---:|"]
|
|
34
|
+
lines += [
|
|
35
|
+
f"| {kind} | {n:,} | {render.duration(p50)} | {render.duration(p80)} | "
|
|
36
|
+
f"{render.duration(p90)} |"
|
|
37
|
+
for kind, n, p50, p80, p90 in entries
|
|
38
|
+
]
|
|
39
|
+
return "\n" + "\n".join(lines) + "\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# The timeline the README's quote example re-answers, passed as `--quoted` exactly as given.
|
|
43
|
+
QUOTE_EXAMPLE = "3 months"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _example_output(rows: Sequence[Row], plan_path: Path | None, quoted: str | None = None) -> str:
|
|
47
|
+
"""The quickstart's real output, so the README cannot show a result the tool would not give."""
|
|
48
|
+
if plan_path is None or not plan_path.exists():
|
|
49
|
+
return ""
|
|
50
|
+
tasks, cap = est.tasks_from_json(json.loads(plan_path.read_text(encoding="utf-8")))
|
|
51
|
+
return "\n" + render.plan_text(est.estimate(est.fit(rows), tasks, cap), quoted) + "\n"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def values(
|
|
55
|
+
rows: Sequence[Row],
|
|
56
|
+
report: dict[str, Any],
|
|
57
|
+
plan_path: Path | None = None,
|
|
58
|
+
quote_plan_path: Path | None = None,
|
|
59
|
+
) -> dict[str, str]:
|
|
60
|
+
data = report["dataset"]
|
|
61
|
+
selection = report["selection"]
|
|
62
|
+
winner = selection["winner"]
|
|
63
|
+
results = report["splits"]["time"]["tracks"]["pre-run"]
|
|
64
|
+
ours = results[winner]["session"]
|
|
65
|
+
ours_row = results[winner]["row"]
|
|
66
|
+
median = results["global-median"]["session"]
|
|
67
|
+
plan = report["plans"]["results"]["agentpace"]
|
|
68
|
+
naive_plan = report["plans"]["results"]["sum-of-medians"]
|
|
69
|
+
project = report["splits"]["project"]
|
|
70
|
+
contributor = report["splits"]["contributor"]["status"]
|
|
71
|
+
project_p80 = (
|
|
72
|
+
f"{project['tracks']['pre-run'][winner]['session']['coverage80']:.1f}%"
|
|
73
|
+
if project["status"] == "ok"
|
|
74
|
+
else "not available"
|
|
75
|
+
)
|
|
76
|
+
# Defined by rank, never by fold number, because ranks shift as data is added.
|
|
77
|
+
largest = project.get("without_largest")
|
|
78
|
+
project_top_harness = largest["harness"] if largest else "not available"
|
|
79
|
+
project_top_sessions = (
|
|
80
|
+
f"{largest['sessions']} of {largest['of_sessions']}" if largest else "not available"
|
|
81
|
+
)
|
|
82
|
+
completed = sorted(row["duration_s"] for row in rows if row["outcome"] == "completed")
|
|
83
|
+
found = report["splits"]["time"]["intervals"]
|
|
84
|
+
plan_found = report["plans"]["intervals"]
|
|
85
|
+
# The README describes the pooled selection-period number, so locked weeks stay out of it.
|
|
86
|
+
weeks = [w for w in report["splits"]["time"]["by_week"] if w["period"] == "selection"]
|
|
87
|
+
latest = weeks[-1]
|
|
88
|
+
|
|
89
|
+
def spread(field: str) -> str:
|
|
90
|
+
values = [week[field] for week in weeks]
|
|
91
|
+
return f"{min(values):.0f}% to {max(values):.0f}%"
|
|
92
|
+
|
|
93
|
+
def gap(name: str) -> str:
|
|
94
|
+
low, high = found["rival_minus_ours"][name]
|
|
95
|
+
return f"{low:+.0f} to {high:+.0f}"
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
"runs": f"{data['rows']:,}",
|
|
99
|
+
"completed": f"{data['completed']:,}",
|
|
100
|
+
"sessions": str(data["sessions"]),
|
|
101
|
+
"projects": str(data["projects"]),
|
|
102
|
+
"contributors": str(data["contributors"]),
|
|
103
|
+
"first_week": data["weeks"][0],
|
|
104
|
+
"last_week": data["weeks"][-1],
|
|
105
|
+
"top_project": f"{data['top_project_share']:.0f}%",
|
|
106
|
+
"top_sessions": f"{data['top_three_session_share']:.0f}%",
|
|
107
|
+
"overall_p50": render.duration(est.quantile(completed, 0.5)),
|
|
108
|
+
"overall_p90": render.duration(est.quantile(completed, 0.9)),
|
|
109
|
+
"winner": winner,
|
|
110
|
+
"plan_sessions": str(report["plans"]["sessions"]),
|
|
111
|
+
"plan_p50": ci(plan["coverage50"], plan_found["coverage50"], unit="%"),
|
|
112
|
+
"plan_p80": ci(plan["coverage80"], plan_found["coverage80"], unit="%"),
|
|
113
|
+
"plan_p90": ci(plan["coverage90"], plan_found["coverage90"], unit="%"),
|
|
114
|
+
"plan_pinball": ci(plan["pinball"], plan_found["pinball"]),
|
|
115
|
+
"naive_plan_pinball": f"{naive_plan['pinball']:.0f}",
|
|
116
|
+
"plan_mape": f"{plan['mape']:.0f}%",
|
|
117
|
+
"plan_mdape": f"{plan['mdape']:.0f}%",
|
|
118
|
+
"plan_width": f"{plan['width']:.1f}x",
|
|
119
|
+
"plan_width80": f"{plan['width80']:.1f}x",
|
|
120
|
+
"naive_plan_p80": f"{naive_plan['coverage80']:.0f}%",
|
|
121
|
+
"naive_plan_mape": f"{naive_plan['mape']:.0f}%",
|
|
122
|
+
"task_p50": f"{ours['coverage50']:.1f}%",
|
|
123
|
+
"task_p80": ci(ours["coverage80"], found["coverage80"], 1, "%"),
|
|
124
|
+
"task_p90": f"{ours['coverage90']:.1f}%",
|
|
125
|
+
"task_p80_row": f"{ours_row['coverage80']:.1f}%",
|
|
126
|
+
"task_mape": f"{ours['mape']:.0f}%",
|
|
127
|
+
"task_mdape": f"{ours['mdape']:.0f}%",
|
|
128
|
+
"task_width": f"{ours['width']:.1f}x",
|
|
129
|
+
"task_pinball": ci(ours["pinball"], found["pinball"]),
|
|
130
|
+
"median_pinball": f"{median['pinball']:.0f}",
|
|
131
|
+
"median_mape": f"{median['mape']:.0f}%",
|
|
132
|
+
"human_pinball": f"{results['human-guess']['session']['pinball']:.0f}",
|
|
133
|
+
"rounds_pinball": f"{results['fixed-rounds']['session']['pinball']:.0f}",
|
|
134
|
+
"median_pinball_gap": gap("global-median"),
|
|
135
|
+
"human_pinball_gap": gap("human-guess"),
|
|
136
|
+
"rounds_pinball_gap": gap("fixed-rounds"),
|
|
137
|
+
"weeks_tested": str(len(weeks)),
|
|
138
|
+
"weekly_p80": spread("coverage80_session"),
|
|
139
|
+
"weekly_p80_row": spread("coverage80_row"),
|
|
140
|
+
"latest_week": latest["week"],
|
|
141
|
+
"latest_week_p80": f"{latest['coverage80_session']:.0f}%",
|
|
142
|
+
"latest_week_p80_row": f"{latest['coverage80_row']:.0f}%",
|
|
143
|
+
"latest_week_sessions": str(latest["sessions"]),
|
|
144
|
+
"selection_last_week": report["selection"]["last_week"],
|
|
145
|
+
"locked_status": report["locked"]["status"],
|
|
146
|
+
"project_p80": project_p80,
|
|
147
|
+
"project_top_harness": project_top_harness,
|
|
148
|
+
"project_top_sessions": project_top_sessions,
|
|
149
|
+
"contributor_split": contributor,
|
|
150
|
+
"loss_count": str(len(report["losses"])),
|
|
151
|
+
"kind_table": _kind_table(rows),
|
|
152
|
+
"example_output": _example_output(rows, plan_path),
|
|
153
|
+
"quote_example_output": _example_output(rows, quote_plan_path, QUOTE_EXAMPLE),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def apply(text: str, known: dict[str, str]) -> str:
|
|
158
|
+
def replace(match: re.Match[str]) -> str:
|
|
159
|
+
name = match.group(1)
|
|
160
|
+
if name not in known:
|
|
161
|
+
raise ValueError(f"unknown claim: {name}")
|
|
162
|
+
return f"<!-- claim:{name} -->{known[name]}<!-- /claim -->"
|
|
163
|
+
|
|
164
|
+
return _MARKER.sub(replace, text)
|
agentpace/classify.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Deterministic task classification. No LLM calls and no text matching.
|
|
2
|
+
|
|
3
|
+
Metadata cannot separate feature, bugfix and refactor work, so the agent type is the only signal,
|
|
4
|
+
and it is known before a run starts. Tool-mix heuristics are deliberately absent: they use
|
|
5
|
+
post-run information and would dress up guesses as classification. User labels always win.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
_READ_ONLY_REVIEW = frozenset({"architect", "performance", "reviewer", "security", "verifier"})
|
|
13
|
+
_RESEARCH = frozenset({"Explore", "researcher", "web-fetch"})
|
|
14
|
+
_KINDS = {
|
|
15
|
+
"db-migration": "migration",
|
|
16
|
+
"debugger": "bugfix",
|
|
17
|
+
"docs": "docs",
|
|
18
|
+
"implementer": "feature",
|
|
19
|
+
"qa": "tests",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def strip_plugin(agent_type: str | None) -> str:
|
|
24
|
+
"""`some-plugin:reviewer` becomes `reviewer`."""
|
|
25
|
+
return (agent_type or "").rsplit(":", 1)[-1]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def classify_kind(agent_type: str | None, kind_map: Mapping[str, str] | None = None) -> str:
|
|
29
|
+
name = strip_plugin(agent_type)
|
|
30
|
+
if kind_map and name in kind_map:
|
|
31
|
+
return kind_map[name]
|
|
32
|
+
if name in _READ_ONLY_REVIEW:
|
|
33
|
+
return "review"
|
|
34
|
+
return _KINDS.get(name, "other")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def classify_risk(agent_type: str | None, kind: str) -> str:
|
|
38
|
+
name = strip_plugin(agent_type)
|
|
39
|
+
if kind == "migration":
|
|
40
|
+
return "high"
|
|
41
|
+
if name in _READ_ONLY_REVIEW or name in _RESEARCH:
|
|
42
|
+
return "low"
|
|
43
|
+
return "normal"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def size_bucket(edit_calls: int) -> str:
|
|
47
|
+
"""Measured after the run, so it is kept out of the default forecast."""
|
|
48
|
+
if edit_calls == 0:
|
|
49
|
+
return "na"
|
|
50
|
+
if edit_calls <= 3:
|
|
51
|
+
return "S"
|
|
52
|
+
if edit_calls <= 10:
|
|
53
|
+
return "M"
|
|
54
|
+
return "L" if edit_calls <= 30 else "XL"
|