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/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