@matt82198/aesop 0.1.0-beta.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ bench_runner.py — Held-out benchmark scorer for fleet subagent capability claims.
4
+
5
+ Wave-26 context: an external critique observed that the claim "Haiku is sufficient
6
+ for fleet subagent work" was unmeasured and, where it *was* measured at all, was
7
+ self-graded by the fleet (agents grading agents) with results parked in a private
8
+ MEMORY.md. That is not evidence. This module is the MEASUREMENT APPARATUS, not a
9
+ verdict: it loads a fixed set of held-out tasks (bench/tasks.jsonl), scores a
10
+ model runner's outputs against externally-fixed ground truth (bench/ground_truth.jsonl)
11
+ by exact string match or regex match, and prints a per-model accuracy table.
12
+
13
+ It ships with a MOCK runner (`mock_runner`) so the SCORING LOGIC can be tested and
14
+ demonstrated completely offline, without calling any model or spending any tokens.
15
+ The mock runner is a small deterministic heuristic (regex/string-parsing stand-in).
16
+ Its accuracy says NOTHING about how Haiku, Sonnet, or Opus would actually score —
17
+ see bench/README.md for how to wire a real model runner.
18
+
19
+ Usage:
20
+ python tools/bench_runner.py # run the mock runner, print table
21
+ python tools/bench_runner.py --runner mock
22
+ python tools/bench_runner.py --tasks path/to/tasks.jsonl --ground-truth path/to/gt.jsonl
23
+
24
+ Cost axis (wave-32): accuracy alone can't settle "is Haiku good enough" — the
25
+ whole cost thesis is "equal quality at ~1/3 the cost," which requires reporting
26
+ cost *alongside* accuracy. A runner may therefore return either a bare string
27
+ (text only, backward-compatible) or a `(text, usage)` pair, where usage carries
28
+ a token count and/or a latency figure. run_bench() records per-task usage,
29
+ summarize_cost() aggregates it, and the tables print accuracy and cost together.
30
+ This module still never calls a real model or spends a token; usage is supplied
31
+ by whatever runner the caller wires in.
32
+
33
+ Exit codes: 0 always on a completed run (this is a measurement tool, not a gate).
34
+ Programmatic API: load_tasks(), load_ground_truth(), score_output(), run_bench(),
35
+ normalize_runner_output(), summarize_cost(), build_summary(), print_comparison().
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import json
41
+ import re
42
+ import sys
43
+ from pathlib import Path
44
+ from typing import Callable, Dict, List, Optional, Tuple
45
+
46
+ BENCH_DIR = Path(__file__).resolve().parent.parent / "bench"
47
+ DEFAULT_TASKS_PATH = BENCH_DIR / "tasks.jsonl"
48
+ DEFAULT_GROUND_TRUTH_PATH = BENCH_DIR / "ground_truth.jsonl"
49
+
50
+ REQUIRED_TASK_FIELDS = ("id", "category", "match", "prompt")
51
+ VALID_MATCH_TYPES = ("exact", "regex")
52
+
53
+ # A model runner is any callable that takes a prompt string and returns either:
54
+ # * the model's raw text response (a str), OR
55
+ # * a (text, usage) pair, where `usage` is a token/latency figure — a dict
56
+ # like {"tokens": 812, "latency_ms": 430.0}, or a bare int/float taken as a
57
+ # token count.
58
+ # The bare-str form is the original contract and stays fully supported; runners
59
+ # that don't measure cost simply keep returning a string. Real runners
60
+ # (Haiku/Sonnet/Opus/etc.) are wired by the caller; this module never imports a
61
+ # model SDK itself.
62
+ ModelRunner = Callable[[str], object]
63
+
64
+ # Normalized per-task usage record: either field may be None when the runner did
65
+ # not report it. A task where the runner returned a bare string has usage=None.
66
+ USAGE_KEYS = ("tokens", "latency_ms")
67
+
68
+
69
+ def normalize_runner_output(output: object) -> Tuple[Optional[str], Optional[dict]]:
70
+ """Normalize a runner's return value into (text, usage).
71
+
72
+ Accepts, in order:
73
+ * str (or None) -> (output, None) [original contract]
74
+ * (text, usage) 2-tuple -> (text, normalized_usage)
75
+ where usage may be:
76
+ - a dict with any of USAGE_KEYS -> those fields (others None)
77
+ - an int/float -> {"tokens": int(usage), "latency_ms": None}
78
+ - None -> None
79
+
80
+ Any other shape is a runner bug and raises ValueError, so a malformed runner
81
+ fails loudly rather than silently scoring blank.
82
+ """
83
+ if output is None or isinstance(output, str):
84
+ return output, None
85
+ if isinstance(output, tuple):
86
+ if len(output) != 2:
87
+ raise ValueError(
88
+ f"runner returned a {len(output)}-tuple; expected a bare str or a "
89
+ f"(text, usage) 2-tuple"
90
+ )
91
+ text, usage = output
92
+ if text is not None and not isinstance(text, str):
93
+ raise ValueError(f"runner (text, usage) tuple has non-str text: {type(text).__name__}")
94
+ return text, _normalize_usage(usage)
95
+ raise ValueError(
96
+ f"runner returned unsupported type {type(output).__name__}; expected a bare "
97
+ f"str or a (text, usage) 2-tuple"
98
+ )
99
+
100
+
101
+ def _normalize_usage(usage: object) -> Optional[dict]:
102
+ if usage is None:
103
+ return None
104
+ if isinstance(usage, bool):
105
+ # bool is an int subclass; a bool token count is almost certainly a bug.
106
+ raise ValueError("usage token count must be a number, not a bool")
107
+ if isinstance(usage, (int, float)):
108
+ return {"tokens": int(usage), "latency_ms": None}
109
+ if isinstance(usage, dict):
110
+ return {
111
+ "tokens": usage.get("tokens"),
112
+ "latency_ms": usage.get("latency_ms"),
113
+ }
114
+ raise ValueError(
115
+ f"usage must be a dict, an int/float token count, or None; got {type(usage).__name__}"
116
+ )
117
+
118
+
119
+ def load_jsonl(path: Path) -> List[dict]:
120
+ """Load a JSON-Lines file into a list of dicts. Blank lines are skipped."""
121
+ items: List[dict] = []
122
+ with open(path, "r", encoding="utf-8") as f:
123
+ for lineno, raw_line in enumerate(f, start=1):
124
+ line = raw_line.strip()
125
+ if not line:
126
+ continue
127
+ try:
128
+ items.append(json.loads(line))
129
+ except json.JSONDecodeError as exc:
130
+ raise ValueError(f"{path}:{lineno}: invalid JSON: {exc}") from exc
131
+ return items
132
+
133
+
134
+ def load_tasks(tasks_path: Optional[Path] = None) -> List[dict]:
135
+ """Load the task list and validate required fields are present."""
136
+ path = Path(tasks_path) if tasks_path else DEFAULT_TASKS_PATH
137
+ tasks = load_jsonl(path)
138
+ for task in tasks:
139
+ missing = [f for f in REQUIRED_TASK_FIELDS if f not in task]
140
+ if missing:
141
+ raise ValueError(f"task {task.get('id', '<unknown>')} missing fields: {missing}")
142
+ if task["match"] not in VALID_MATCH_TYPES:
143
+ raise ValueError(
144
+ f"task {task['id']} has invalid match type {task['match']!r}; "
145
+ f"expected one of {VALID_MATCH_TYPES}"
146
+ )
147
+ return tasks
148
+
149
+
150
+ def load_ground_truth(ground_truth_path: Optional[Path] = None) -> Dict[str, dict]:
151
+ """Load ground truth entries keyed by task id."""
152
+ path = Path(ground_truth_path) if ground_truth_path else DEFAULT_GROUND_TRUTH_PATH
153
+ entries = load_jsonl(path)
154
+ by_id: Dict[str, dict] = {}
155
+ for entry in entries:
156
+ if "id" not in entry:
157
+ raise ValueError(f"ground truth entry missing 'id': {entry}")
158
+ by_id[entry["id"]] = entry
159
+ return by_id
160
+
161
+
162
+ def score_output(output: Optional[str], ground_truth_entry: dict, match_type: str) -> bool:
163
+ """Score a single model output against its ground truth entry.
164
+
165
+ exact: case-insensitive, whitespace-trimmed string equality against
166
+ ground_truth_entry["expected"].
167
+ regex: re.search of ground_truth_entry["expected_regex"] against the
168
+ trimmed output (search, not fullmatch — callers anchor with ^/$
169
+ in the pattern when they need a full-string match).
170
+ """
171
+ if output is None:
172
+ return False
173
+ trimmed = output.strip()
174
+ if match_type == "regex":
175
+ pattern = ground_truth_entry.get("expected_regex")
176
+ if pattern is None:
177
+ raise ValueError("ground truth entry for a regex task is missing 'expected_regex'")
178
+ return re.search(pattern, trimmed) is not None
179
+ if match_type == "exact":
180
+ expected = ground_truth_entry.get("expected")
181
+ if expected is None:
182
+ raise ValueError("ground truth entry for an exact task is missing 'expected'")
183
+ return trimmed.lower() == str(expected).strip().lower()
184
+ raise ValueError(f"unknown match type: {match_type!r}")
185
+
186
+
187
+ def run_bench(
188
+ tasks: List[dict],
189
+ ground_truth: Dict[str, dict],
190
+ runner: ModelRunner,
191
+ ) -> Tuple[List[dict], float]:
192
+ """Run every task through `runner`, score against `ground_truth`.
193
+
194
+ Returns (per_task_results, accuracy) where accuracy is correct/total
195
+ (0.0 for an empty task list, never a division error).
196
+ """
197
+ results: List[dict] = []
198
+ correct = 0
199
+ for task in tasks:
200
+ tid = task["id"]
201
+ if tid not in ground_truth:
202
+ raise KeyError(f"no ground truth entry for task id {tid!r}")
203
+ gt_entry = ground_truth[tid]
204
+ text, usage = normalize_runner_output(runner(task["prompt"]))
205
+ ok = score_output(text, gt_entry, task["match"])
206
+ if ok:
207
+ correct += 1
208
+ results.append(
209
+ {
210
+ "id": tid,
211
+ "category": task.get("category", ""),
212
+ "output": text,
213
+ "correct": ok,
214
+ # Cost axis: None when the runner returned a bare string.
215
+ "usage": usage,
216
+ "tokens": usage.get("tokens") if usage else None,
217
+ "latency_ms": usage.get("latency_ms") if usage else None,
218
+ }
219
+ )
220
+ accuracy = correct / len(tasks) if tasks else 0.0
221
+ return results, accuracy
222
+
223
+
224
+ def summarize_cost(results: List[dict]) -> dict:
225
+ """Aggregate the per-task cost axis recorded by run_bench.
226
+
227
+ Returns a dict with total/average tokens and latency across the tasks that
228
+ reported them. Fields are None when no task reported that figure, so a
229
+ string-only (cost-free) run summarizes cleanly to all-None rather than 0.
230
+ """
231
+ token_vals = [r["tokens"] for r in results if r.get("tokens") is not None]
232
+ latency_vals = [r["latency_ms"] for r in results if r.get("latency_ms") is not None]
233
+ n = len(results)
234
+
235
+ def _avg(vals):
236
+ return (sum(vals) / len(vals)) if vals else None
237
+
238
+ return {
239
+ "n_tasks": n,
240
+ "n_with_tokens": len(token_vals),
241
+ "n_with_latency": len(latency_vals),
242
+ "total_tokens": sum(token_vals) if token_vals else None,
243
+ "avg_tokens": _avg(token_vals),
244
+ "total_latency_ms": sum(latency_vals) if latency_vals else None,
245
+ "avg_latency_ms": _avg(latency_vals),
246
+ "has_cost": bool(token_vals or latency_vals),
247
+ }
248
+
249
+
250
+ def build_summary(model_name: str, results: List[dict], accuracy: float) -> dict:
251
+ """Combine accuracy and cost into one per-model row for a comparison table."""
252
+ n = len(results)
253
+ n_correct = sum(1 for r in results if r["correct"])
254
+ row = {
255
+ "model": model_name,
256
+ "n_tasks": n,
257
+ "n_correct": n_correct,
258
+ "accuracy": accuracy,
259
+ }
260
+ row.update(summarize_cost(results))
261
+ return row
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Mock runner — zero-cost, deterministic, offline. Exists ONLY to prove the
266
+ # scoring pipeline works end to end without a network call or an API key.
267
+ # Do not read its accuracy as evidence about any real model's capability.
268
+ # ---------------------------------------------------------------------------
269
+
270
+ _FILE_RE = re.compile(r"File:\s*(\S+)")
271
+ _FAILED_TEST_RE = re.compile(r"FAILED\s+\S+::(\w+)")
272
+ _ISSUE_NUM_RE = re.compile(r"#(\d+)")
273
+ _PR_TITLE_RE = re.compile(r"Title:\s*(\w+):")
274
+ _EXC_TYPE_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*(?:Error|Exception)):")
275
+ _VERSION_RE = re.compile(r"\[(\d+\.\d+\.\d+)\]")
276
+ _IDENTIFIER_RE = re.compile(r"Identifier:\s*(\S+)")
277
+ _FILE_LINE_RE = re.compile(r"([A-Za-z0-9_\-./]+\.py:\d+)")
278
+
279
+
280
+ def _classify_file_path(path: str) -> str:
281
+ filename = path.rsplit("/", 1)[-1]
282
+ if path.startswith("tests/") or "/tests/" in path or filename.startswith("test_"):
283
+ return "test"
284
+ ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
285
+ if ext in {"md", "rst", "txt"}:
286
+ return "docs"
287
+ if ext in {"json", "yaml", "yml", "toml", "ini", "cfg"}:
288
+ return "config"
289
+ return "code"
290
+
291
+
292
+ def _snake_to_camel(identifier: str) -> str:
293
+ parts = identifier.split("_")
294
+ if not parts:
295
+ return identifier
296
+ return parts[0] + "".join(p.capitalize() for p in parts[1:])
297
+
298
+
299
+ def mock_runner(prompt: str) -> str:
300
+ """Deterministic offline stand-in for a real model runner.
301
+
302
+ Handles extraction/classification/transform tasks with plain regex and
303
+ string logic (no judgment required). Deliberately does NOT attempt the
304
+ semantic "is this a real bug" judgment task and always answers "no" for
305
+ it — a cheap default that is sometimes wrong, so the scorer has at least
306
+ one guaranteed miss to prove it distinguishes correct from incorrect
307
+ (a mock that always scored 100% would not exercise the failure path).
308
+ """
309
+ m = _FILE_RE.search(prompt)
310
+ if m and "Classify this changed file path" in prompt:
311
+ return _classify_file_path(m.group(1))
312
+
313
+ m = _FAILED_TEST_RE.search(prompt)
314
+ if m and "failing test function" in prompt:
315
+ return m.group(1)
316
+
317
+ if "issue/PR number" in prompt:
318
+ m = _ISSUE_NUM_RE.search(prompt)
319
+ if m:
320
+ return m.group(1)
321
+
322
+ m = _PR_TITLE_RE.search(prompt)
323
+ if m and "PR title" in prompt:
324
+ return m.group(1)
325
+
326
+ if "exception class name" in prompt:
327
+ matches = _EXC_TYPE_RE.findall(prompt)
328
+ if matches:
329
+ return matches[-1]
330
+
331
+ if "is the finding a real bug" in prompt:
332
+ return "no"
333
+
334
+ m = _VERSION_RE.search(prompt)
335
+ if m and "semantic version" in prompt:
336
+ return m.group(1)
337
+
338
+ m = _IDENTIFIER_RE.search(prompt)
339
+ if m and "camelCase" in prompt:
340
+ return _snake_to_camel(m.group(1))
341
+
342
+ m = _FILE_LINE_RE.search(prompt)
343
+ if m and "path:line" in prompt:
344
+ return m.group(1)
345
+
346
+ return ""
347
+
348
+
349
+ RUNNERS: Dict[str, ModelRunner] = {
350
+ "mock": mock_runner,
351
+ }
352
+
353
+
354
+ def _fmt(value, suffix: str = "") -> str:
355
+ return "-" if value is None else f"{value:g}{suffix}"
356
+
357
+
358
+ def print_table(model_name: str, results: List[dict], accuracy: float, stream=None) -> None:
359
+ stream = stream or sys.stdout
360
+ cost = summarize_cost(results)
361
+ show_cost = cost["has_cost"]
362
+ print(f"\nBenchmark results -- runner: {model_name}", file=stream)
363
+ print("-" * 72, file=stream)
364
+ if show_cost:
365
+ print(f"{'id':<6}{'category':<28}{'result':<8}{'tokens':<10}{'latency_ms':<12}", file=stream)
366
+ for r in results:
367
+ mark = "PASS" if r["correct"] else "FAIL"
368
+ print(
369
+ f"{r['id']:<6}{r['category']:<28}{mark:<8}"
370
+ f"{_fmt(r.get('tokens')):<10}{_fmt(r.get('latency_ms')):<12}",
371
+ file=stream,
372
+ )
373
+ else:
374
+ print(f"{'id':<6}{'category':<28}{'result':<8}", file=stream)
375
+ for r in results:
376
+ mark = "PASS" if r["correct"] else "FAIL"
377
+ print(f"{r['id']:<6}{r['category']:<28}{mark:<8}", file=stream)
378
+ print("-" * 72, file=stream)
379
+ n = len(results)
380
+ n_correct = sum(1 for r in results if r["correct"])
381
+ print(f"Accuracy: {n_correct}/{n} = {accuracy:.1%}", file=stream)
382
+ if show_cost:
383
+ # Cost axis printed right under accuracy so the two are read together.
384
+ print(
385
+ f"Cost: total_tokens={_fmt(cost['total_tokens'])} "
386
+ f"avg_tokens/task={_fmt(round(cost['avg_tokens'], 1) if cost['avg_tokens'] is not None else None)} "
387
+ f"total_latency_ms={_fmt(cost['total_latency_ms'])} "
388
+ f"avg_latency_ms/task={_fmt(round(cost['avg_latency_ms'], 1) if cost['avg_latency_ms'] is not None else None)}",
389
+ file=stream,
390
+ )
391
+
392
+
393
+ def print_comparison(summaries: List[dict], stream=None) -> None:
394
+ """Print accuracy AND cost side by side across models.
395
+
396
+ Each element of `summaries` is a build_summary() row. This is the table that
397
+ lets "equal accuracy at a fraction of the cost" be shown rather than
398
+ asserted: models line up in rows, accuracy and token/latency cost in columns.
399
+ """
400
+ stream = stream or sys.stdout
401
+ print("\nModel comparison -- accuracy vs cost", file=stream)
402
+ print("-" * 72, file=stream)
403
+ print(
404
+ f"{'model':<12}{'accuracy':<12}{'avg_tokens':<12}{'total_tokens':<14}{'avg_latency_ms':<16}",
405
+ file=stream,
406
+ )
407
+ for s in summaries:
408
+ acc = f"{s['n_correct']}/{s['n_tasks']}={s['accuracy']:.0%}"
409
+ avg_tok = _fmt(round(s["avg_tokens"], 1) if s.get("avg_tokens") is not None else None)
410
+ tot_tok = _fmt(s.get("total_tokens"))
411
+ avg_lat = _fmt(round(s["avg_latency_ms"], 1) if s.get("avg_latency_ms") is not None else None)
412
+ print(f"{s['model']:<12}{acc:<12}{avg_tok:<12}{tot_tok:<14}{avg_lat:<16}", file=stream)
413
+ print("-" * 72, file=stream)
414
+
415
+
416
+ def main(argv: Optional[List[str]] = None) -> int:
417
+ parser = argparse.ArgumentParser(description=__doc__)
418
+ parser.add_argument(
419
+ "--runner",
420
+ default="mock",
421
+ choices=sorted(RUNNERS.keys()),
422
+ help="Which registered model runner to score (default: mock, offline/zero-cost).",
423
+ )
424
+ parser.add_argument("--tasks", default=None, help="Override path to tasks.jsonl")
425
+ parser.add_argument("--ground-truth", default=None, help="Override path to ground_truth.jsonl")
426
+ args = parser.parse_args(argv)
427
+
428
+ tasks = load_tasks(args.tasks)
429
+ ground_truth = load_ground_truth(args.ground_truth)
430
+ runner = RUNNERS[args.runner]
431
+
432
+ results, accuracy = run_bench(tasks, ground_truth, runner)
433
+ print_table(args.runner, results, accuracy)
434
+ return 0
435
+
436
+
437
+ if __name__ == "__main__":
438
+ sys.exit(main())
package/tools/buildlog.py CHANGED
@@ -33,13 +33,10 @@ import sys
33
33
  from datetime import datetime
34
34
  from pathlib import Path
35
35
 
36
-
37
- def get_state_dir():
38
- """Resolve state directory from env var or current working directory."""
39
- if os.environ.get("AESOP_STATE_ROOT"):
40
- return Path(os.environ["AESOP_STATE_ROOT"])
41
- # Default to ./state (relative to cwd)
42
- return Path.cwd() / "state"
36
+ try:
37
+ from common import get_state_dir
38
+ except ImportError:
39
+ from tools.common import get_state_dir
43
40
 
44
41
 
45
42
  def get_git_head(repo_path):