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/pseudonym.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Pseudonyms derived from a random local secret.
|
|
2
|
+
|
|
3
|
+
Nothing about a person goes in: the contributor hash is a fixed label under the secret, so there
|
|
4
|
+
is no email or handle for a dictionary attack to guess. The secret never leaves this machine.
|
|
5
|
+
Deleting it makes you a new contributor.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import os
|
|
13
|
+
import secrets
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_SECRET_BYTES = 32
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_secret(home: Path) -> bytes:
|
|
20
|
+
"""Read the local secret, creating it on first use with owner-only permissions."""
|
|
21
|
+
path = home / "secret"
|
|
22
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
try:
|
|
24
|
+
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
25
|
+
except FileExistsError:
|
|
26
|
+
return bytes.fromhex(path.read_text(encoding="ascii").strip())
|
|
27
|
+
secret = secrets.token_bytes(_SECRET_BYTES)
|
|
28
|
+
with os.fdopen(descriptor, "w", encoding="ascii") as handle:
|
|
29
|
+
handle.write(secret.hex())
|
|
30
|
+
return secret
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _digest(secret: bytes, message: str, length: int) -> str:
|
|
34
|
+
return hmac.new(secret, message.encode("utf-8"), hashlib.sha256).hexdigest()[:length]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def contributor(secret: bytes) -> str:
|
|
38
|
+
return _digest(secret, "contributor-v1", 16)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def batch(secret: bytes, session: str) -> str:
|
|
42
|
+
return _digest(secret, f"batch-v1:{session}", 12)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def turn_session(secret: bytes, session: str) -> str:
|
|
46
|
+
"""A separate label from `batch`, so a turn could never be joined to a run row by session."""
|
|
47
|
+
return _digest(secret, f"turn-v1:{session}", 12)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def project(secret: bytes, folder: str) -> str:
|
|
51
|
+
return _digest(secret, f"project-v1:{folder}", 12)
|
agentpace/py.typed
ADDED
|
File without changes
|
agentpace/render.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Plain-text and JSON views of results. No math here."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agentpace.estimate import HumanPart, PlanRange
|
|
10
|
+
|
|
11
|
+
HUMAN_PART = "Human part (review, testing, decisions, waiting between agent turns)"
|
|
12
|
+
CLOCK_NOTE = (
|
|
13
|
+
"The session clock counts agent runs and the gaps between them, each gap up to the idle cap "
|
|
14
|
+
"(900 s by default), so waiting on you between runs sits inside the total up to that cap."
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def human_text(human: HumanPart) -> str:
|
|
19
|
+
"""The human block. It names the quantity and its status, and never offers a number derived
|
|
20
|
+
from agent time. A measured form quotes only the user's own waits, per human turn. No
|
|
21
|
+
allowance for the human part is added to the total, but waits up to the idle cap already sit
|
|
22
|
+
inside the session clock, so the measured form says not to add them."""
|
|
23
|
+
p50, p80, p90 = human.per_turn_p50, human.per_turn_p80, human.per_turn_p90
|
|
24
|
+
if p50 is not None and p80 is not None and p90 is not None:
|
|
25
|
+
return (
|
|
26
|
+
f"{HUMAN_PART}: {human.status} from {human.source}. Waiting on you per human turn: "
|
|
27
|
+
f"P50 {duration(p50)}, P80 {duration(p80)}, P90 {duration(p90)} "
|
|
28
|
+
f"({human.gaps} gaps, {human.sessions} sessions); elapsed time, not effort. "
|
|
29
|
+
"No allowance for it is added to the total above, and up to the idle cap such waits "
|
|
30
|
+
"already sit inside the session clock, so do not add them to it. "
|
|
31
|
+
"Do not derive it from the agent time."
|
|
32
|
+
)
|
|
33
|
+
return (
|
|
34
|
+
f"{HUMAN_PART}: {human.status}. No allowance for it is added to the total above. "
|
|
35
|
+
"State it separately; do not derive it from the agent time."
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def duration(seconds: float) -> str:
|
|
40
|
+
"""Two significant figures: anything finer would suggest precision the data lacks."""
|
|
41
|
+
minutes = seconds / 60
|
|
42
|
+
if seconds < 90:
|
|
43
|
+
return f"{seconds:.0f} s"
|
|
44
|
+
if minutes < 90:
|
|
45
|
+
return f"{minutes:.0f} min"
|
|
46
|
+
hours = minutes / 60
|
|
47
|
+
return f"{hours:.1f} h" if hours < 48 else f"{hours / 24:.1f} days"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def quote_text(quoted: str) -> str:
|
|
51
|
+
"""A timeline someone already gave, verbatim. It is calendar time from an unknown source, so
|
|
52
|
+
it is never converted into agent time, compared with the range or turned into a ratio."""
|
|
53
|
+
return f'Quoted: "{quoted}" (calendar time, as quoted; unverified, not converted)'
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def plan_text(plan: PlanRange, quoted: str | None = None) -> str:
|
|
57
|
+
width = max(len("task"), *(len(task.id) for task in plan.tasks))
|
|
58
|
+
lines = [
|
|
59
|
+
f"Plan total on the session clock: P50 {duration(plan.p50)}, "
|
|
60
|
+
f"P80 {duration(plan.p80)}, "
|
|
61
|
+
f"P90 {duration(plan.p90)}",
|
|
62
|
+
*([quote_text(quoted)] if quoted is not None else []),
|
|
63
|
+
CLOCK_NOTE,
|
|
64
|
+
human_text(plan.human),
|
|
65
|
+
"",
|
|
66
|
+
f"{'task':<{width}} {'P50':>9} {'P80':>9} {'P90':>9} {'critical':>8} cell (n)",
|
|
67
|
+
]
|
|
68
|
+
for task in plan.tasks:
|
|
69
|
+
lines.append(
|
|
70
|
+
f"{task.id:<{width}} {duration(task.p50):>9} {duration(task.p80):>9} "
|
|
71
|
+
f"{duration(task.p90):>9} {task.criticality:>7.0%} {task.cell} (n={task.n})"
|
|
72
|
+
)
|
|
73
|
+
lines += [
|
|
74
|
+
"",
|
|
75
|
+
f"Critical path: {' -> '.join(plan.critical_path)} "
|
|
76
|
+
f"(longest in {plan.critical_path_share:.0%} of simulations)",
|
|
77
|
+
]
|
|
78
|
+
if plan.effective_parallelism is None:
|
|
79
|
+
lines.append(f"Effective parallelism at cap {plan.agent_cap}: not learned yet")
|
|
80
|
+
else:
|
|
81
|
+
lines.append(
|
|
82
|
+
f"Effective parallelism at cap {plan.agent_cap}: {plan.effective_parallelism:.2f} "
|
|
83
|
+
f"(from {plan.parallelism_sessions} sessions). Throughput set the total in "
|
|
84
|
+
f"{plan.throughput_bound_share:.0%} of simulations."
|
|
85
|
+
)
|
|
86
|
+
excluded = f", {plan.rows_excluded} not completed so excluded" if plan.rows_excluded else ""
|
|
87
|
+
lines.append(
|
|
88
|
+
f"Method: {plan.method}. {plan.rows_used} runs{excluded}. "
|
|
89
|
+
f"{plan.draws} simulations, seed {plan.seed}."
|
|
90
|
+
)
|
|
91
|
+
if plan.size_used:
|
|
92
|
+
lines.append("Size used: oracle track, stored size is measured after a run.")
|
|
93
|
+
if plan.warnings:
|
|
94
|
+
lines += ["", "Warnings:", *(f" - {note}" for note in plan.warnings)]
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def plan_dict(plan: PlanRange) -> dict[str, Any]:
|
|
99
|
+
data = dataclasses.asdict(plan)
|
|
100
|
+
data["human"] = {key: value for key, value in data["human"].items() if value is not None}
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def plan_json(plan: PlanRange) -> str:
|
|
105
|
+
return json.dumps(plan_dict(plan), indent=2)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def score_text(forecast: dict[str, Any], score: dict[str, Any], source: str) -> str:
|
|
109
|
+
actual = score["actual_s"]
|
|
110
|
+
hits = ", ".join(
|
|
111
|
+
f"P{key[1:]} {'yes' if actual <= forecast[key] else 'no'}" for key in ("p50", "p80", "p90")
|
|
112
|
+
)
|
|
113
|
+
lines = [
|
|
114
|
+
f"Forecast {forecast['id']}: P50 {duration(forecast['p50'])}, "
|
|
115
|
+
f"P80 {duration(forecast['p80'])}, P90 {duration(forecast['p90'])}",
|
|
116
|
+
f"Actual: {duration(actual)} ({source})",
|
|
117
|
+
f"Within: {hits}",
|
|
118
|
+
]
|
|
119
|
+
if "quoted_s" in forecast:
|
|
120
|
+
ratio = forecast["quoted_s"] / actual
|
|
121
|
+
closer = "closer" if score["quote_closer"] else "not closer"
|
|
122
|
+
lines.append(
|
|
123
|
+
f'Quoted: "{forecast["quoted"]}" (as quoted, calendar time), {ratio:.1f}x the actual, '
|
|
124
|
+
f"{closer} than the P50 by ratio"
|
|
125
|
+
)
|
|
126
|
+
elif "quoted" in forecast:
|
|
127
|
+
lines.append(f'Quoted: "{forecast["quoted"]}", not parseable, so not scored')
|
|
128
|
+
lines.append(f"Recorded score for {forecast['id']}.")
|
|
129
|
+
return "\n".join(lines)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ledger_summary_text(summary: Any) -> str:
|
|
133
|
+
lines = [f"Your forecasts, n = {summary.scored}, of which {summary.attested} attested by you."]
|
|
134
|
+
if summary.within is None:
|
|
135
|
+
lines.append(f"Not enough scored forecasts (n = {summary.scored}, need 5).")
|
|
136
|
+
else:
|
|
137
|
+
within = ", ".join(f"P{key[1:]} {share:.0%}" for key, share in summary.within.items())
|
|
138
|
+
lines.append(f"Actual within: {within}. Mean pinball loss: {summary.pinball:.0f} s.")
|
|
139
|
+
if summary.quotes:
|
|
140
|
+
if summary.quote_closer is None:
|
|
141
|
+
lines.append(f"Quoted timelines: not enough to rate (n = {summary.quotes}, need 5).")
|
|
142
|
+
else:
|
|
143
|
+
lines.append(
|
|
144
|
+
f"Quoted timelines (n = {summary.quotes}, calendar time as quoted): closer than "
|
|
145
|
+
f"the P50 in {summary.quote_closer:.0%}, median {summary.quote_ratio:.1f}x the "
|
|
146
|
+
"actual."
|
|
147
|
+
)
|
|
148
|
+
lines.append(
|
|
149
|
+
"Actuals are session spans on their own clock (agent runs plus the gaps between them, "
|
|
150
|
+
"each gap up to the idle cap), or figures you attested, as given."
|
|
151
|
+
)
|
|
152
|
+
return "\n".join(lines)
|