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/estimate.py
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
"""All the math: from past runs to P50/P80/P90 for a plan of tasks.
|
|
2
|
+
|
|
3
|
+
Only pre-run facts predict a duration. `tool_calls`, `tokens`, `retries` and `outcome` describe
|
|
4
|
+
what happened during a run, so using them would leak the answer. Stored `size` is measured after
|
|
5
|
+
the run too, so it joins only with `use_size=True`.
|
|
6
|
+
|
|
7
|
+
A run's log duration is modeled as
|
|
8
|
+
|
|
9
|
+
log d = mu(cell) + delta(cell) + b(session) + e
|
|
10
|
+
|
|
11
|
+
mu Cell mean, shrunk toward its parent cell. Thin cells lean on their parents.
|
|
12
|
+
delta Uncertainty about mu. Drawn once per cell, per simulation, not per task. If each task drew
|
|
13
|
+
its own, a 100-task plan would average the uncertainty away.
|
|
14
|
+
b Shared by every task in one session. Same repo, same model, same day. Without it, totals
|
|
15
|
+
of many tasks concentrate and ranges come out falsely narrow.
|
|
16
|
+
e Within-session noise, resampled from real residuals so the real tail shape survives.
|
|
17
|
+
|
|
18
|
+
Rows can be weighted by 1/sqrt(runs in that session), because one automated session can hold
|
|
19
|
+
hundreds of near-identical runs. `n_eff` is the Kish effective sample size under those weights, and
|
|
20
|
+
it is what pooling thresholds use. Without weights it equals n. The published backtest found no
|
|
21
|
+
measurable gain from weighting, so the default is unweighted, and the option stays as a candidate.
|
|
22
|
+
|
|
23
|
+
A plan total is then simulated:
|
|
24
|
+
|
|
25
|
+
T = max(critical path, total work / P)
|
|
26
|
+
|
|
27
|
+
P is sampled from what real sessions at that concurrency achieved. This is a lower-bound makespan
|
|
28
|
+
with a learned throughput term, not a task scheduler.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import math
|
|
34
|
+
import random
|
|
35
|
+
import statistics
|
|
36
|
+
from collections import Counter, defaultdict
|
|
37
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from agentpace.schema import Row
|
|
42
|
+
|
|
43
|
+
QUANTILES = (0.5, 0.8, 0.9)
|
|
44
|
+
POOL_MIN = 30 # cells below this effective size are pooled, widened and warned about
|
|
45
|
+
MIN_ROWS = 10
|
|
46
|
+
MIN_SESSION_RUNS = 5 # sessions smaller than this are too noisy to measure a session effect
|
|
47
|
+
MIN_BUCKET_SESSIONS = 5
|
|
48
|
+
DEFAULT_DRAWS = 20_000
|
|
49
|
+
DEFAULT_SEED = 20261107
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class Method:
|
|
54
|
+
"""How cells are formed. Every backtest candidate is one of these, simplest first."""
|
|
55
|
+
|
|
56
|
+
name: str
|
|
57
|
+
depth: int # 0 global, 1 kind, 3 model x kind x risk
|
|
58
|
+
shrink: bool
|
|
59
|
+
session_weights: bool
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
METHODS = (
|
|
63
|
+
Method("global", depth=0, shrink=False, session_weights=False),
|
|
64
|
+
Method("kind", depth=1, shrink=False, session_weights=False),
|
|
65
|
+
Method("backoff", depth=3, shrink=False, session_weights=False),
|
|
66
|
+
Method("shrunk", depth=3, shrink=True, session_weights=False),
|
|
67
|
+
Method("weighted", depth=3, shrink=True, session_weights=True),
|
|
68
|
+
)
|
|
69
|
+
METHOD_BY_NAME = {method.name: method for method in METHODS}
|
|
70
|
+
# Set by the pre-registered selection in `agentpace backtest`. A test keeps them in step.
|
|
71
|
+
DEFAULT_METHOD = "shrunk"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def quantile(ordered: Sequence[float], q: float) -> float:
|
|
75
|
+
"""Linear interpolation between order statistics (Hyndman-Fan type 7)."""
|
|
76
|
+
if not ordered:
|
|
77
|
+
raise ValueError("no values")
|
|
78
|
+
position = q * (len(ordered) - 1)
|
|
79
|
+
low = int(position)
|
|
80
|
+
high = min(low + 1, len(ordered) - 1)
|
|
81
|
+
return ordered[low] + (ordered[high] - ordered[low]) * (position - low)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _kish(weights: Sequence[float]) -> float:
|
|
85
|
+
total = sum(weights)
|
|
86
|
+
return total * total / sum(w * w for w in weights) if total else 0.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _weighted_mean(values: Sequence[float], weights: Sequence[float]) -> float:
|
|
90
|
+
return sum(v * w for v, w in zip(values, weights, strict=True)) / sum(weights)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _weighted_variance(values: Sequence[float], weights: Sequence[float], mean: float) -> float:
|
|
94
|
+
return sum(w * (v - mean) ** 2 for v, w in zip(values, weights, strict=True)) / sum(weights)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Pool:
|
|
99
|
+
"""Residuals to resample from, with cumulative weights for fast draws."""
|
|
100
|
+
|
|
101
|
+
values: list[float]
|
|
102
|
+
cumulative: list[float]
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def build(cls, pairs: Sequence[tuple[float, float]]) -> Pool:
|
|
106
|
+
total = sum(weight for _, weight in pairs)
|
|
107
|
+
center = sum(value * weight for value, weight in pairs) / total if total else 0.0
|
|
108
|
+
values: list[float] = []
|
|
109
|
+
cumulative: list[float] = []
|
|
110
|
+
running = 0.0
|
|
111
|
+
for value, weight in pairs:
|
|
112
|
+
running += weight
|
|
113
|
+
values.append(value - center) # the location belongs to mu, not to the pool
|
|
114
|
+
cumulative.append(running)
|
|
115
|
+
return cls(values, cumulative)
|
|
116
|
+
|
|
117
|
+
def draw(self, rng: random.Random, count: int) -> list[float]:
|
|
118
|
+
if not self.values:
|
|
119
|
+
return [0.0] * count
|
|
120
|
+
return rng.choices(self.values, cum_weights=self.cumulative, k=count)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class Node:
|
|
125
|
+
"""One cell at one level of the hierarchy."""
|
|
126
|
+
|
|
127
|
+
key: tuple[str, ...]
|
|
128
|
+
n: int
|
|
129
|
+
n_eff: float
|
|
130
|
+
mean: float
|
|
131
|
+
mu: float
|
|
132
|
+
v: float # variance of our belief about mu
|
|
133
|
+
sigma2: float
|
|
134
|
+
pairs: list[tuple[float, float]] = field(default_factory=list)
|
|
135
|
+
pool: Pool | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True)
|
|
139
|
+
class Located:
|
|
140
|
+
"""The answer to "which cell does this task fall in?"."""
|
|
141
|
+
|
|
142
|
+
mu: float
|
|
143
|
+
v: float
|
|
144
|
+
pool: Pool
|
|
145
|
+
n: int
|
|
146
|
+
n_eff: float
|
|
147
|
+
label: str
|
|
148
|
+
pooled_from: str
|
|
149
|
+
thin: bool
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@dataclass(frozen=True)
|
|
153
|
+
class ThroughputBucket:
|
|
154
|
+
low: int
|
|
155
|
+
high: int
|
|
156
|
+
values: tuple[float, ...]
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def median(self) -> float:
|
|
160
|
+
return statistics.median(self.values)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class Model:
|
|
165
|
+
"""A fitted estimator."""
|
|
166
|
+
|
|
167
|
+
method: Method
|
|
168
|
+
path: tuple[str, ...]
|
|
169
|
+
root: Node
|
|
170
|
+
nodes: dict[tuple[str, ...], Node]
|
|
171
|
+
tau2: dict[int, float]
|
|
172
|
+
session_effects: list[float]
|
|
173
|
+
buckets: list[ThroughputBucket]
|
|
174
|
+
rows_used: int
|
|
175
|
+
rows_excluded: int
|
|
176
|
+
|
|
177
|
+
def locate(self, query: Mapping[str, str | None]) -> Located:
|
|
178
|
+
"""Walk down the hierarchy as far as the query and the data allow."""
|
|
179
|
+
node = self.root
|
|
180
|
+
mu, v = node.mu, node.v
|
|
181
|
+
pool_node = node
|
|
182
|
+
prefix: tuple[str, ...] = ()
|
|
183
|
+
missing = False
|
|
184
|
+
for depth, name in enumerate(self.path, start=1):
|
|
185
|
+
value = query.get(name)
|
|
186
|
+
if value is None:
|
|
187
|
+
break
|
|
188
|
+
prefix = (*prefix, value)
|
|
189
|
+
child = self.nodes.get(prefix)
|
|
190
|
+
if child is None:
|
|
191
|
+
# Never seen: the parent's mean, plus the spread between sibling cells.
|
|
192
|
+
v += self.tau2.get(depth, 0.0)
|
|
193
|
+
missing = True
|
|
194
|
+
break
|
|
195
|
+
node, mu, v = child, child.mu, child.v
|
|
196
|
+
if child.n_eff >= POOL_MIN:
|
|
197
|
+
pool_node = child
|
|
198
|
+
assert pool_node.pool is not None
|
|
199
|
+
return Located(
|
|
200
|
+
mu=mu,
|
|
201
|
+
v=v,
|
|
202
|
+
pool=pool_node.pool,
|
|
203
|
+
n=0 if missing else node.n,
|
|
204
|
+
n_eff=0.0 if missing else node.n_eff,
|
|
205
|
+
label=" x ".join(prefix) or "all",
|
|
206
|
+
pooled_from=" x ".join(pool_node.key) or "all",
|
|
207
|
+
thin=missing or node.n_eff < POOL_MIN,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
def throughput(self, cap: int) -> ThroughputBucket | None:
|
|
211
|
+
if not self.buckets:
|
|
212
|
+
return None
|
|
213
|
+
# A cap between observed peaks uses the nearest bucket at or below it.
|
|
214
|
+
reachable = [bucket for bucket in self.buckets if bucket.low <= cap]
|
|
215
|
+
return reachable[-1] if reachable else self.buckets[0]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _session_weights(rows: Sequence[Row], enabled: bool) -> list[float]:
|
|
219
|
+
if not enabled:
|
|
220
|
+
return [1.0] * len(rows)
|
|
221
|
+
sizes = Counter(row["batch"] for row in rows)
|
|
222
|
+
return [1.0 / math.sqrt(sizes[row["batch"]]) for row in rows]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _make_node(key: tuple[str, ...], members: Sequence[tuple[float, float]]) -> Node:
|
|
226
|
+
logs = [value for value, _ in members]
|
|
227
|
+
weights = [weight for _, weight in members]
|
|
228
|
+
mean = _weighted_mean(logs, weights)
|
|
229
|
+
return Node(
|
|
230
|
+
key=key,
|
|
231
|
+
n=len(members),
|
|
232
|
+
n_eff=_kish(weights),
|
|
233
|
+
mean=mean,
|
|
234
|
+
mu=mean,
|
|
235
|
+
v=0.0,
|
|
236
|
+
sigma2=_weighted_variance(logs, weights, mean),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _between_variance(children: Mapping[tuple[str, ...], Sequence[Node]], sigma2: float) -> float:
|
|
241
|
+
"""Method-of-moments estimate of the spread between cells, clamped at zero."""
|
|
242
|
+
q = c = 0.0
|
|
243
|
+
df = 0
|
|
244
|
+
for siblings in children.values():
|
|
245
|
+
h = [node.n_eff for node in siblings]
|
|
246
|
+
total = sum(h)
|
|
247
|
+
center = sum(a * node.mean for a, node in zip(h, siblings, strict=True)) / total
|
|
248
|
+
q += sum(a * (node.mean - center) ** 2 for a, node in zip(h, siblings, strict=True))
|
|
249
|
+
c += total - sum(a * a for a in h) / total
|
|
250
|
+
df += len(siblings) - 1
|
|
251
|
+
return max(0.0, (q - df * sigma2) / c) if c > 0 else 0.0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _fit_means(
|
|
255
|
+
keys: Sequence[tuple[str, ...]],
|
|
256
|
+
logs: Sequence[float],
|
|
257
|
+
weights: Sequence[float],
|
|
258
|
+
depth: int,
|
|
259
|
+
shrink: bool,
|
|
260
|
+
) -> tuple[Node, dict[tuple[str, ...], Node], dict[int, float]]:
|
|
261
|
+
root = _make_node((), list(zip(logs, weights, strict=True)))
|
|
262
|
+
root.v = root.sigma2 / root.n_eff
|
|
263
|
+
nodes: dict[tuple[str, ...], Node] = {}
|
|
264
|
+
tau2: dict[int, float] = {}
|
|
265
|
+
for level in range(1, depth + 1):
|
|
266
|
+
groups: dict[tuple[str, ...], list[tuple[float, float]]] = defaultdict(list)
|
|
267
|
+
for key, value, weight in zip(keys, logs, weights, strict=True):
|
|
268
|
+
groups[key[:level]].append((value, weight))
|
|
269
|
+
level_nodes = {key: _make_node(key, members) for key, members in sorted(groups.items())}
|
|
270
|
+
within = sum(n.sigma2 * n.n_eff for n in level_nodes.values())
|
|
271
|
+
sigma2 = within / sum(n.n_eff for n in level_nodes.values())
|
|
272
|
+
children: dict[tuple[str, ...], list[Node]] = defaultdict(list)
|
|
273
|
+
for key, node in level_nodes.items():
|
|
274
|
+
children[key[:-1]].append(node)
|
|
275
|
+
tau2[level] = _between_variance(children, sigma2) if shrink else 0.0
|
|
276
|
+
k = sigma2 / tau2[level] if tau2[level] > 0 else math.inf
|
|
277
|
+
for key, node in level_nodes.items():
|
|
278
|
+
parent = nodes.get(key[:-1], root)
|
|
279
|
+
if shrink:
|
|
280
|
+
w = node.n_eff / (node.n_eff + k)
|
|
281
|
+
belief = sigma2 / (node.n_eff + k) if math.isfinite(k) else 0.0
|
|
282
|
+
else:
|
|
283
|
+
w = 1.0 if node.n_eff >= POOL_MIN else 0.0
|
|
284
|
+
belief = sigma2 / node.n_eff if w else 0.0
|
|
285
|
+
node.mu = w * node.mean + (1 - w) * parent.mu
|
|
286
|
+
node.v = belief + (1 - w) ** 2 * parent.v
|
|
287
|
+
nodes.update(level_nodes)
|
|
288
|
+
return root, nodes, tau2
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _session_split(
|
|
292
|
+
rows: Sequence[Row], residuals: Sequence[float]
|
|
293
|
+
) -> tuple[list[float], list[float | None]]:
|
|
294
|
+
"""Split residuals into session effects and within-session noise, so neither counts twice.
|
|
295
|
+
|
|
296
|
+
Noise in a session mean is not a session effect, so effects are scaled down by the share of
|
|
297
|
+
variance that is real. Centering removes one degree of freedom, so noise is scaled back up.
|
|
298
|
+
"""
|
|
299
|
+
groups: dict[str, list[int]] = defaultdict(list)
|
|
300
|
+
for index, row in enumerate(rows):
|
|
301
|
+
groups[row["batch"]].append(index)
|
|
302
|
+
means = {batch: statistics.fmean(residuals[i] for i in ids) for batch, ids in groups.items()}
|
|
303
|
+
noise: list[float | None] = [None] * len(rows)
|
|
304
|
+
within = []
|
|
305
|
+
for batch, ids in groups.items():
|
|
306
|
+
size = len(ids)
|
|
307
|
+
if size < 2:
|
|
308
|
+
continue
|
|
309
|
+
scale = math.sqrt(size / (size - 1))
|
|
310
|
+
for i in ids:
|
|
311
|
+
noise[i] = (residuals[i] - means[batch]) * scale
|
|
312
|
+
within.append(noise[i])
|
|
313
|
+
large = [batch for batch, ids in groups.items() if len(ids) >= MIN_SESSION_RUNS]
|
|
314
|
+
if len(large) < MIN_SESSION_RUNS or not within:
|
|
315
|
+
return [], list(residuals)
|
|
316
|
+
observed = [means[batch] for batch in large]
|
|
317
|
+
variance = statistics.pvariance(observed, statistics.fmean(observed))
|
|
318
|
+
sigma2 = statistics.fmean(value * value for value in within if value is not None)
|
|
319
|
+
sampling = statistics.fmean(sigma2 / len(groups[batch]) for batch in large)
|
|
320
|
+
keep = math.sqrt(max(0.0, 1 - sampling / variance)) if variance > 0 else 0.0
|
|
321
|
+
return sorted(value * keep for value in observed), noise
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _throughput(rows: Sequence[Row]) -> list[ThroughputBucket]:
|
|
325
|
+
"""What sessions achieved: work divided by span, by peak concurrency, kept non-decreasing."""
|
|
326
|
+
groups: dict[str, list[Row]] = defaultdict(list)
|
|
327
|
+
for row in rows:
|
|
328
|
+
groups[row["batch"]].append(row)
|
|
329
|
+
by_peak: dict[int, list[float]] = defaultdict(list)
|
|
330
|
+
for members in groups.values():
|
|
331
|
+
if len(members) < 2:
|
|
332
|
+
continue
|
|
333
|
+
work = sum(row["duration_s"] for row in members)
|
|
334
|
+
first = min(row["offset_s"] for row in members)
|
|
335
|
+
last = max(row["offset_s"] + row["duration_s"] for row in members)
|
|
336
|
+
peak = max(row["parallel_agents"] for row in members)
|
|
337
|
+
by_peak[peak].append(work / max(last - first, 1))
|
|
338
|
+
merged: list[tuple[int, int, list[float]]] = []
|
|
339
|
+
for peak in sorted(by_peak):
|
|
340
|
+
values = by_peak[peak]
|
|
341
|
+
if merged and len(merged[-1][2]) < MIN_BUCKET_SESSIONS:
|
|
342
|
+
low, _, held = merged.pop()
|
|
343
|
+
merged.append((low, peak, held + values))
|
|
344
|
+
else:
|
|
345
|
+
merged.append((peak, peak, list(values)))
|
|
346
|
+
if len(merged) > 1 and len(merged[-1][2]) < MIN_BUCKET_SESSIONS:
|
|
347
|
+
_, high, tail = merged.pop()
|
|
348
|
+
low, _, held = merged.pop()
|
|
349
|
+
merged.append((low, high, held + tail))
|
|
350
|
+
# Pool adjacent violators: more agents must not look worse than fewer.
|
|
351
|
+
index = 0
|
|
352
|
+
while index + 1 < len(merged):
|
|
353
|
+
left, right = merged[index], merged[index + 1]
|
|
354
|
+
if statistics.median(left[2]) > statistics.median(right[2]):
|
|
355
|
+
merged[index : index + 2] = [(left[0], right[1], left[2] + right[2])]
|
|
356
|
+
index = max(index - 1, 0)
|
|
357
|
+
else:
|
|
358
|
+
index += 1
|
|
359
|
+
return [ThroughputBucket(low, high, tuple(sorted(values))) for low, high, values in merged]
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _usable(rows: Iterable[Row]) -> list[Row]:
|
|
363
|
+
return [row for row in rows if row["outcome"] == "completed" and row["duration_s"] > 0]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def fit(rows: Sequence[Row], method: str = DEFAULT_METHOD, use_size: bool = False) -> Model:
|
|
367
|
+
chosen = METHOD_BY_NAME[method]
|
|
368
|
+
usable = _usable(rows)
|
|
369
|
+
if len(usable) < MIN_ROWS:
|
|
370
|
+
raise ValueError(f"need at least {MIN_ROWS} completed runs, found {len(usable)}")
|
|
371
|
+
path = ("task_kind", "size", "risk", "model") if use_size else ("task_kind", "risk", "model")
|
|
372
|
+
depth = min(chosen.depth + 1, len(path)) if use_size and chosen.depth else chosen.depth
|
|
373
|
+
keys = [tuple(row[name] for name in path) for row in usable]
|
|
374
|
+
logs = [math.log(row["duration_s"]) for row in usable]
|
|
375
|
+
weights = _session_weights(usable, chosen.session_weights)
|
|
376
|
+
root, nodes, tau2 = _fit_means(keys, logs, weights, depth, chosen.shrink)
|
|
377
|
+
finest = [nodes.get(key[:depth], root) if depth else root for key in keys]
|
|
378
|
+
residuals = [value - node.mu for value, node in zip(logs, finest, strict=True)]
|
|
379
|
+
effects, noise = _session_split(usable, residuals)
|
|
380
|
+
for key, value, weight in zip(keys, noise, weights, strict=True):
|
|
381
|
+
if value is None:
|
|
382
|
+
continue
|
|
383
|
+
root.pairs.append((value, weight))
|
|
384
|
+
for level in range(1, depth + 1):
|
|
385
|
+
nodes[key[:level]].pairs.append((value, weight))
|
|
386
|
+
for node in (root, *nodes.values()):
|
|
387
|
+
node.pool = Pool.build(node.pairs)
|
|
388
|
+
return Model(
|
|
389
|
+
method=chosen,
|
|
390
|
+
path=path[:depth],
|
|
391
|
+
root=root,
|
|
392
|
+
nodes=nodes,
|
|
393
|
+
tau2=tau2,
|
|
394
|
+
session_effects=effects,
|
|
395
|
+
buckets=_throughput(usable),
|
|
396
|
+
rows_used=len(usable),
|
|
397
|
+
rows_excluded=len(rows) - len(usable),
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@dataclass(frozen=True)
|
|
402
|
+
class Task:
|
|
403
|
+
id: str
|
|
404
|
+
kind: str
|
|
405
|
+
risk: str = "normal"
|
|
406
|
+
size: str | None = None
|
|
407
|
+
model: str | None = None
|
|
408
|
+
depends_on: tuple[str, ...] = ()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@dataclass(frozen=True)
|
|
412
|
+
class TaskRange:
|
|
413
|
+
id: str
|
|
414
|
+
p50: float
|
|
415
|
+
p80: float
|
|
416
|
+
p90: float
|
|
417
|
+
cell: str
|
|
418
|
+
n: int
|
|
419
|
+
n_eff: float
|
|
420
|
+
criticality: float
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@dataclass(frozen=True)
|
|
424
|
+
class HumanPart:
|
|
425
|
+
"""Review, testing, decisions and waiting between agent turns. Never derived from agent time:
|
|
426
|
+
until it is measured, it is reported as not measured. No allowance for it is added to the
|
|
427
|
+
total; `included_in_total` stays False for that reason, and waits shorter than the idle cap sit
|
|
428
|
+
inside the session clock regardless."""
|
|
429
|
+
|
|
430
|
+
status: str = "not measured"
|
|
431
|
+
included_in_total: bool = False
|
|
432
|
+
# Filled only from the user's own history by `agentpace turns`, never from agent time.
|
|
433
|
+
source: str | None = None
|
|
434
|
+
per_turn_p50: float | None = None
|
|
435
|
+
per_turn_p80: float | None = None
|
|
436
|
+
per_turn_p90: float | None = None
|
|
437
|
+
gaps: int | None = None
|
|
438
|
+
sessions: int | None = None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@dataclass(frozen=True)
|
|
442
|
+
class PlanRange:
|
|
443
|
+
p50: float
|
|
444
|
+
p80: float
|
|
445
|
+
p90: float
|
|
446
|
+
tasks: tuple[TaskRange, ...]
|
|
447
|
+
critical_path: tuple[str, ...]
|
|
448
|
+
critical_path_share: float
|
|
449
|
+
agent_cap: int
|
|
450
|
+
effective_parallelism: float | None
|
|
451
|
+
parallelism_sessions: int
|
|
452
|
+
throughput_bound_share: float
|
|
453
|
+
method: str
|
|
454
|
+
size_used: bool
|
|
455
|
+
rows_used: int
|
|
456
|
+
rows_excluded: int
|
|
457
|
+
draws: int
|
|
458
|
+
seed: int
|
|
459
|
+
warnings: tuple[str, ...]
|
|
460
|
+
human: HumanPart = HumanPart()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def topological_order(tasks: Sequence[Task]) -> list[Task]:
|
|
464
|
+
by_id = {task.id: task for task in tasks}
|
|
465
|
+
if len(by_id) != len(tasks):
|
|
466
|
+
raise ValueError("task ids must be unique")
|
|
467
|
+
waiting = {task.id: len(set(task.depends_on)) for task in tasks}
|
|
468
|
+
followers: dict[str, list[str]] = defaultdict(list)
|
|
469
|
+
for task in tasks:
|
|
470
|
+
for dependency in set(task.depends_on):
|
|
471
|
+
if dependency not in by_id:
|
|
472
|
+
raise ValueError(f"task {task.id!r} depends on unknown task {dependency!r}")
|
|
473
|
+
followers[dependency].append(task.id)
|
|
474
|
+
ready = sorted(task_id for task_id, count in waiting.items() if count == 0)
|
|
475
|
+
order: list[Task] = []
|
|
476
|
+
while ready:
|
|
477
|
+
current = ready.pop(0)
|
|
478
|
+
order.append(by_id[current])
|
|
479
|
+
for follower in sorted(followers[current]):
|
|
480
|
+
waiting[follower] -= 1
|
|
481
|
+
if waiting[follower] == 0:
|
|
482
|
+
ready.append(follower)
|
|
483
|
+
if len(order) != len(tasks):
|
|
484
|
+
raise ValueError("the task graph has a cycle")
|
|
485
|
+
return order
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _clamped_exp(value: float) -> float:
|
|
489
|
+
return math.exp(min(value, 30.0))
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _warnings(model: Model, tasks: Sequence[Task], located: Mapping[str, Located]) -> list[str]:
|
|
493
|
+
notes: list[str] = []
|
|
494
|
+
seen: set[str] = set()
|
|
495
|
+
for task in tasks:
|
|
496
|
+
cell = located[task.id]
|
|
497
|
+
if cell.thin and cell.label not in seen:
|
|
498
|
+
seen.add(cell.label)
|
|
499
|
+
notes.append(
|
|
500
|
+
f"cell {cell.label}: n={cell.n}, effective n={cell.n_eff:.1f}, below {POOL_MIN}. "
|
|
501
|
+
f"Pooled with {cell.pooled_from} and interval widened."
|
|
502
|
+
)
|
|
503
|
+
if "size" not in model.path and any(task.size for task in tasks):
|
|
504
|
+
notes.append(
|
|
505
|
+
"size ignored: stored size is measured after a run, so it is left out unless you "
|
|
506
|
+
"pass --use-size."
|
|
507
|
+
)
|
|
508
|
+
return notes
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def estimate(
|
|
512
|
+
model: Model,
|
|
513
|
+
tasks: Sequence[Task],
|
|
514
|
+
agent_cap: int,
|
|
515
|
+
draws: int = DEFAULT_DRAWS,
|
|
516
|
+
seed: int = DEFAULT_SEED,
|
|
517
|
+
human: HumanPart | None = None,
|
|
518
|
+
) -> PlanRange:
|
|
519
|
+
"""Simulate a plan. Every result is a range, so no single number can be misread as a promise."""
|
|
520
|
+
if agent_cap < 1:
|
|
521
|
+
raise ValueError("agent_cap must be at least 1")
|
|
522
|
+
if not tasks:
|
|
523
|
+
raise ValueError("a plan needs at least one task")
|
|
524
|
+
if draws < 100:
|
|
525
|
+
raise ValueError("draws must be at least 100")
|
|
526
|
+
order = topological_order(tasks)
|
|
527
|
+
rng = random.Random(seed)
|
|
528
|
+
located = {
|
|
529
|
+
task.id: model.locate(
|
|
530
|
+
{"task_kind": task.kind, "risk": task.risk, "model": task.model, "size": task.size}
|
|
531
|
+
)
|
|
532
|
+
for task in order
|
|
533
|
+
}
|
|
534
|
+
notes = _warnings(model, order, located)
|
|
535
|
+
|
|
536
|
+
# Shared draws first, so tasks in one cell and one session really do move together.
|
|
537
|
+
effects = model.session_effects
|
|
538
|
+
shared = rng.choices(effects, k=draws) if effects else [0.0] * draws
|
|
539
|
+
cell_shift: dict[str, list[float]] = {}
|
|
540
|
+
for cell in located.values():
|
|
541
|
+
if cell.label not in cell_shift:
|
|
542
|
+
spread = math.sqrt(cell.v)
|
|
543
|
+
cell_shift[cell.label] = [rng.gauss(0.0, spread) for _ in range(draws)]
|
|
544
|
+
bucket = model.throughput(agent_cap)
|
|
545
|
+
if bucket is None:
|
|
546
|
+
notes.append("no multi-run sessions to learn parallelism from: assuming one at a time.")
|
|
547
|
+
rates = [1.0] * draws
|
|
548
|
+
else:
|
|
549
|
+
rates = [min(rate, float(agent_cap)) for rate in rng.choices(bucket.values, k=draws)]
|
|
550
|
+
|
|
551
|
+
sampled: dict[str, list[float]] = {}
|
|
552
|
+
for task in order:
|
|
553
|
+
cell = located[task.id]
|
|
554
|
+
noise = cell.pool.draw(rng, draws)
|
|
555
|
+
shift = cell_shift[cell.label]
|
|
556
|
+
sampled[task.id] = [
|
|
557
|
+
_clamped_exp(cell.mu + delta + b + e)
|
|
558
|
+
for delta, b, e in zip(shift, shared, noise, strict=True)
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
totals: list[float] = []
|
|
562
|
+
critical: Counter[str] = Counter()
|
|
563
|
+
paths: Counter[tuple[str, ...]] = Counter()
|
|
564
|
+
bound = 0
|
|
565
|
+
for i in range(draws):
|
|
566
|
+
finish: dict[str, float] = {}
|
|
567
|
+
previous: dict[str, str | None] = {}
|
|
568
|
+
work = 0.0
|
|
569
|
+
for task in order:
|
|
570
|
+
duration = sampled[task.id][i]
|
|
571
|
+
work += duration
|
|
572
|
+
start = 0.0
|
|
573
|
+
link: str | None = None
|
|
574
|
+
for dependency in task.depends_on:
|
|
575
|
+
if finish[dependency] > start:
|
|
576
|
+
start, link = finish[dependency], dependency
|
|
577
|
+
finish[task.id] = start + duration
|
|
578
|
+
previous[task.id] = link
|
|
579
|
+
tail = max(finish, key=lambda task_id: finish[task_id])
|
|
580
|
+
longest = finish[tail]
|
|
581
|
+
chain: list[str] = []
|
|
582
|
+
cursor: str | None = tail
|
|
583
|
+
while cursor is not None:
|
|
584
|
+
chain.append(cursor)
|
|
585
|
+
critical[cursor] += 1
|
|
586
|
+
cursor = previous[cursor]
|
|
587
|
+
paths[tuple(reversed(chain))] += 1
|
|
588
|
+
throughput = work / rates[i]
|
|
589
|
+
if throughput > longest:
|
|
590
|
+
bound += 1
|
|
591
|
+
totals.append(max(longest, throughput))
|
|
592
|
+
|
|
593
|
+
totals.sort()
|
|
594
|
+
task_ranges = []
|
|
595
|
+
for task in tasks:
|
|
596
|
+
ordered = sorted(sampled[task.id])
|
|
597
|
+
cell = located[task.id]
|
|
598
|
+
p50, p80, p90 = (quantile(ordered, q) for q in QUANTILES)
|
|
599
|
+
task_ranges.append(
|
|
600
|
+
TaskRange(
|
|
601
|
+
id=task.id,
|
|
602
|
+
p50=p50,
|
|
603
|
+
p80=p80,
|
|
604
|
+
p90=p90,
|
|
605
|
+
cell=cell.label,
|
|
606
|
+
n=cell.n,
|
|
607
|
+
n_eff=cell.n_eff,
|
|
608
|
+
criticality=critical[task.id] / draws,
|
|
609
|
+
)
|
|
610
|
+
)
|
|
611
|
+
path, path_count = max(paths.items(), key=lambda item: (item[1], item[0]))
|
|
612
|
+
total_p50, total_p80, total_p90 = (quantile(totals, q) for q in QUANTILES)
|
|
613
|
+
return PlanRange(
|
|
614
|
+
p50=total_p50,
|
|
615
|
+
p80=total_p80,
|
|
616
|
+
p90=total_p90,
|
|
617
|
+
tasks=tuple(task_ranges),
|
|
618
|
+
critical_path=path,
|
|
619
|
+
critical_path_share=path_count / draws,
|
|
620
|
+
agent_cap=agent_cap,
|
|
621
|
+
effective_parallelism=None if bucket is None else min(bucket.median, float(agent_cap)),
|
|
622
|
+
parallelism_sessions=0 if bucket is None else len(bucket.values),
|
|
623
|
+
throughput_bound_share=bound / draws,
|
|
624
|
+
method=model.method.name,
|
|
625
|
+
size_used="size" in model.path,
|
|
626
|
+
rows_used=model.rows_used,
|
|
627
|
+
rows_excluded=model.rows_excluded,
|
|
628
|
+
draws=draws,
|
|
629
|
+
seed=seed,
|
|
630
|
+
warnings=tuple(notes),
|
|
631
|
+
human=human or HumanPart(),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def cell_quantiles(
|
|
636
|
+
model: Model, query: Mapping[str, str | None], draws: int, rng: random.Random
|
|
637
|
+
) -> tuple[float, float, float]:
|
|
638
|
+
"""P50/P80/P90 for one task in a new session. Used by the backtest."""
|
|
639
|
+
cell = model.locate(query)
|
|
640
|
+
effects = model.session_effects
|
|
641
|
+
shared = rng.choices(effects, k=draws) if effects else [0.0] * draws
|
|
642
|
+
noise = cell.pool.draw(rng, draws)
|
|
643
|
+
spread = math.sqrt(cell.v)
|
|
644
|
+
values = sorted(
|
|
645
|
+
_clamped_exp(cell.mu + rng.gauss(0.0, spread) + b + e)
|
|
646
|
+
for b, e in zip(shared, noise, strict=True)
|
|
647
|
+
)
|
|
648
|
+
p50, p80, p90 = (quantile(values, q) for q in QUANTILES)
|
|
649
|
+
return p50, p80, p90
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def tasks_from_json(document: Any) -> tuple[list[Task], int]:
|
|
653
|
+
"""Validate a plan document. Errors name the problem, never the document's text."""
|
|
654
|
+
from agentpace import schema
|
|
655
|
+
|
|
656
|
+
if not isinstance(document, dict) or not isinstance(document.get("tasks"), list):
|
|
657
|
+
raise ValueError('a plan is an object with a "tasks" list')
|
|
658
|
+
cap = document.get("agent_cap", 1)
|
|
659
|
+
if not isinstance(cap, int) or isinstance(cap, bool) or cap < 1:
|
|
660
|
+
raise ValueError("agent_cap must be a positive integer")
|
|
661
|
+
plan_model = document.get("model")
|
|
662
|
+
if plan_model is not None and not isinstance(plan_model, str):
|
|
663
|
+
raise ValueError("model must be a string")
|
|
664
|
+
tasks = []
|
|
665
|
+
for position, item in enumerate(document["tasks"]):
|
|
666
|
+
where = f"task #{position + 1}"
|
|
667
|
+
if not isinstance(item, dict) or not isinstance(item.get("id"), (str, int)):
|
|
668
|
+
raise ValueError(f"{where}: needs an id")
|
|
669
|
+
kind = item.get("kind")
|
|
670
|
+
if kind not in schema.enum("task_kind"):
|
|
671
|
+
raise ValueError(f"{where}: kind must be one of {schema.enum('task_kind')}")
|
|
672
|
+
risk = item.get("risk", "normal")
|
|
673
|
+
if risk not in schema.enum("risk"):
|
|
674
|
+
raise ValueError(f"{where}: risk must be one of {schema.enum('risk')}")
|
|
675
|
+
size = item.get("size")
|
|
676
|
+
if size is not None and size not in schema.enum("size"):
|
|
677
|
+
raise ValueError(f"{where}: size must be one of {schema.enum('size')}")
|
|
678
|
+
dependencies = item.get("depends_on", [])
|
|
679
|
+
if not isinstance(dependencies, list):
|
|
680
|
+
raise ValueError(f"{where}: depends_on must be a list")
|
|
681
|
+
model = item.get("model")
|
|
682
|
+
if model is not None and not isinstance(model, str):
|
|
683
|
+
raise ValueError(f"{where}: model must be a string")
|
|
684
|
+
tasks.append(
|
|
685
|
+
Task(
|
|
686
|
+
id=str(item["id"]),
|
|
687
|
+
kind=kind,
|
|
688
|
+
risk=risk,
|
|
689
|
+
size=size,
|
|
690
|
+
model=item.get("model", plan_model),
|
|
691
|
+
depends_on=tuple(str(dependency) for dependency in dependencies),
|
|
692
|
+
)
|
|
693
|
+
)
|
|
694
|
+
return tasks, cap
|