ma-agents 3.13.2-skillname.0 → 3.14.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 (71) hide show
  1. package/README.md +1 -1
  2. package/lib/agents.js +13 -3
  3. package/lib/bmad-cache/bmb/.claude-plugin/marketplace.json +3 -3
  4. package/lib/bmad-cache/bmb/_git_preserved/index +0 -0
  5. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.idx +0 -0
  6. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/{pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.pack → pack-b388526edf307cf56f8e65673466cff074105abf.pack} +0 -0
  7. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-b388526edf307cf56f8e65673466cff074105abf.rev +0 -0
  8. package/lib/bmad-cache/bmb/_git_preserved/packed-refs +1 -1
  9. package/lib/bmad-cache/bmb/_git_preserved/refs/heads/main +1 -1
  10. package/lib/bmad-cache/bmb/_git_preserved/shallow +1 -1
  11. package/lib/bmad-cache/bmb/package-lock.json +2 -2
  12. package/lib/bmad-cache/bmb/package.json +1 -1
  13. package/lib/bmad-cache/bmb/samples/bmad-agent-code-coach/scripts/wake.py +78 -0
  14. package/lib/bmad-cache/bmb/samples/bmad-agent-creative-muse/scripts/wake.py +78 -0
  15. package/lib/bmad-cache/bmb/samples/bmad-agent-sentinel/scripts/wake.py +78 -0
  16. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/customize-template.toml +3 -0
  17. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/init-sanctum-template.py +11 -5
  18. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/report-shell.html +1073 -0
  19. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/assets/wake-template.py +78 -0
  20. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/customize.toml +48 -0
  21. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/count_tokens.py +78 -0
  22. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass.py +258 -0
  23. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/process-template.py +20 -0
  24. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/render_report.py +387 -0
  25. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/adapter-claude-code.json +9 -0
  26. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/aggregate_benchmark.py +236 -0
  27. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/memlog.py +197 -0
  28. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_evals.py +513 -390
  29. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/run_triggers.py +347 -251
  30. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_env_isolation.py +79 -0
  31. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/tests/test_trigger_detection.py +123 -0
  32. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/tests/test-validate-module.py +82 -4
  33. package/lib/bmad-cache/bmb/skills/bmad-module-builder/scripts/validate-module.py +55 -11
  34. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/customize-template.toml +3 -5
  35. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/assets/report-shell.html +860 -0
  36. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/customize.toml +59 -0
  37. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/count_tokens.py +77 -0
  38. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/init_skill.py +132 -0
  39. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-prompt-metrics.py +186 -221
  40. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/quick_validate.py +117 -0
  41. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/render_report.py +387 -0
  42. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_canon_sync.py +46 -0
  43. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_count_tokens.py +180 -0
  44. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/tests/test_render_report.py +171 -0
  45. package/lib/bmad-cache/cache-manifest.json +4 -4
  46. package/lib/bmad-extension/.claude-plugin/marketplace.json.template +1 -1
  47. package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +2 -2
  48. package/lib/bmad.js +6 -1
  49. package/lib/templates/instruction-block-git.template.md +25 -25
  50. package/lib/templates/instruction-block-onprem.template.md +86 -86
  51. package/lib/templates/instruction-block-universal.template.md +29 -29
  52. package/package.json +3 -3
  53. package/lib/.bmad-extension-plugin.build-37364-1782300865129/.claude-plugin/marketplace.json +0 -109
  54. package/lib/.bmad-extension-plugin.build-37364-1782300865129/skills/module-help.csv +0 -62
  55. package/lib/.bmad-extension-plugin.build-37364-1782300865129/skills/module.yaml +0 -20
  56. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.idx +0 -0
  57. package/lib/bmad-cache/bmb/_git_preserved/objects/pack/pack-8f8b045fef5af6911495cf3b2a89f1ed75e120f7.rev +0 -0
  58. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/references/sample-init-sanctum.py +0 -274
  59. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/generate-html-report.py +0 -534
  60. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-execution-deps.py +0 -337
  61. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-prompt-metrics.py +0 -425
  62. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-sanctum-architecture.py +0 -385
  63. package/lib/bmad-cache/bmb/skills/bmad-agent-builder/scripts/prepass-structure-capabilities.py +0 -482
  64. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/assets/Dockerfile +0 -29
  65. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/docker_setup.py +0 -115
  66. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/generate_report.py +0 -184
  67. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/pty_runner.py +0 -171
  68. package/lib/bmad-cache/bmb/skills/bmad-eval-runner/scripts/utils.py +0 -260
  69. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/extract-report-json.py +0 -287
  70. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/generate-html-report.py +0 -588
  71. package/lib/bmad-cache/bmb/skills/bmad-workflow-builder/scripts/prepass-execution-deps.py +0 -288
@@ -0,0 +1,387 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.10"
4
+ # ///
5
+ """Render the analysis report deterministically from findings JSON.
6
+
7
+ Injects a validated findings JSON object into the report shell's
8
+ report-data island and writes the self-contained HTML atomically.
9
+ With --md, also writes a markdown rendering of the same data as the
10
+ archival artifact.
11
+
12
+ Refuses (non-zero exit, message on stderr) when the JSON does not
13
+ parse, fails shape validation, or still carries the shell's
14
+ placeholder subject — a refused render means fix the findings file
15
+ and re-run, never hand-edit the HTML.
16
+
17
+ Usage:
18
+ uv run render_report.py <findings.json> --shell <report-shell.html> \
19
+ -o <out.html> [--md <out.md>]
20
+
21
+ On success prints one JSON line: output paths, grade, and severity
22
+ counts derived from the findings array.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import tempfile
32
+ from pathlib import Path
33
+
34
+ SEVERITIES = ("critical", "high", "medium", "low")
35
+ GRADES = ("excellent", "good", "fair", "poor")
36
+ PLACEHOLDER_SUBJECT = "__PLACEHOLDER__"
37
+ ISLAND_RE = re.compile(
38
+ r'(<script[^>]*\bid="report-data"[^>]*>)(.*?)(</script>)', re.DOTALL
39
+ )
40
+
41
+
42
+ def fail(message: str) -> None:
43
+ print(f"render_report: {message}", file=sys.stderr)
44
+ sys.exit(1)
45
+
46
+
47
+ def validate(data: object) -> list[str]:
48
+ """Return a list of shape errors; empty list means valid."""
49
+ if not isinstance(data, dict):
50
+ return ["top level must be a JSON object"]
51
+ errors: list[str] = []
52
+
53
+ subject = data.get("subject")
54
+ if not isinstance(subject, str) or not subject.strip():
55
+ errors.append('"subject" must be a non-empty string')
56
+ elif PLACEHOLDER_SUBJECT in subject:
57
+ errors.append(
58
+ f'"subject" still carries the placeholder {PLACEHOLDER_SUBJECT}; '
59
+ "this is the unfilled shell sample, not real findings"
60
+ )
61
+
62
+ findings = data.get("findings")
63
+ if not isinstance(findings, list):
64
+ errors.append('"findings" must be an array (use [] for a clean pass)')
65
+ else:
66
+ for i, finding in enumerate(findings):
67
+ if not isinstance(finding, dict):
68
+ errors.append(f"findings[{i}] must be an object")
69
+
70
+ grade = data.get("grade")
71
+ if grade is not None and grade not in GRADES:
72
+ errors.append(f'"grade" must be one of: {", ".join(GRADES)}')
73
+
74
+ for key in ("themes", "recommendations"):
75
+ value = data.get(key)
76
+ if value is not None and (
77
+ not isinstance(value, list)
78
+ or any(not isinstance(item, dict) for item in value)
79
+ ):
80
+ errors.append(f'"{key}" must be an array of objects')
81
+
82
+ strengths = data.get("strengths")
83
+ if strengths is not None and (
84
+ not isinstance(strengths, list)
85
+ or any(not isinstance(item, str) for item in strengths)
86
+ ):
87
+ errors.append('"strengths" must be an array of strings')
88
+
89
+ return errors
90
+
91
+
92
+ def severity_counts(findings: list[dict]) -> dict[str, int]:
93
+ counts = {sev: 0 for sev in SEVERITIES}
94
+ for finding in findings:
95
+ sev = finding.get("severity")
96
+ counts[sev if sev in counts else "low"] += 1
97
+ return counts
98
+
99
+
100
+ def inject(shell_html: str, data: dict) -> str:
101
+ payload = json.dumps(data, ensure_ascii=False, indent=2)
102
+ # A "</" sequence inside a JSON string would close the script tag
103
+ # early in the browser; "<\/" is the same string to JSON.parse.
104
+ payload = payload.replace("</", "<\\/")
105
+
106
+ def replace(match: re.Match) -> str:
107
+ return match.group(1) + "\n" + payload + "\n" + match.group(3)
108
+
109
+ new_html, count = ISLAND_RE.subn(replace, shell_html, count=1)
110
+ if count != 1:
111
+ fail('shell has no <script id="report-data"> island to fill')
112
+ return new_html
113
+
114
+
115
+ def atomic_write(path: Path, text: str) -> None:
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ fd, tmp = tempfile.mkstemp(
118
+ dir=path.parent, prefix=path.name + ".", suffix=".tmp"
119
+ )
120
+ try:
121
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
122
+ handle.write(text)
123
+ handle.flush()
124
+ os.fsync(handle.fileno())
125
+ os.replace(tmp, path)
126
+ except BaseException:
127
+ try:
128
+ os.unlink(tmp)
129
+ except OSError:
130
+ pass
131
+ raise
132
+
133
+
134
+ def _finding_lines(finding: dict, heading_level: str) -> list[str]:
135
+ fid = str(finding.get("id", ""))
136
+ title = str(finding.get("title", "(untitled finding)"))
137
+ lines = [f"{heading_level} {fid} — {title}" if fid else f"{heading_level} {title}", ""]
138
+ for key, label in (
139
+ ("lens", "Lens"),
140
+ ("location", "Location"),
141
+ ("evidence", "Evidence"),
142
+ ("recommendation", "Recommendation"),
143
+ ("proposed_smallest", "Proposed smallest"),
144
+ ("predicted_delta", "Predicted delta"),
145
+ ):
146
+ value = finding.get(key)
147
+ if value:
148
+ value = f"`{value}`" if key == "location" else str(value)
149
+ lines.append(f"- {label}: {value}")
150
+ lines.append("")
151
+ return lines
152
+
153
+
154
+ def render_md(data: dict) -> str:
155
+ findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
156
+ by_id = {str(f.get("id")): f for f in findings if f.get("id") is not None}
157
+ counts = severity_counts(findings)
158
+ lines: list[str] = []
159
+
160
+ lines.append(f"# Analysis Report: {data.get('subject', '')}")
161
+ lines.append("")
162
+ meta = []
163
+ if data.get("generated"):
164
+ meta.append(f"Generated: {data['generated']}")
165
+ if data.get("schema_version") is not None:
166
+ meta.append(f"Schema: {data['schema_version']}")
167
+ if meta:
168
+ lines.append(" · ".join(meta))
169
+ lines.append("")
170
+
171
+ if data.get("grade"):
172
+ lines.append(f"**Grade: {str(data['grade']).capitalize()}**")
173
+ lines.append("")
174
+ if data.get("verdict"):
175
+ lines.append(f"> {data['verdict']}")
176
+ lines.append("")
177
+ summary = data.get("summary")
178
+ if isinstance(summary, str) and summary:
179
+ lines.append(summary)
180
+ lines.append("")
181
+
182
+ lines.append("| Severity | Count |")
183
+ lines.append("| --- | --- |")
184
+ for sev in SEVERITIES:
185
+ lines.append(f"| {sev.capitalize()} | {counts[sev]} |")
186
+ lines.append("")
187
+
188
+ themes = data.get("themes") or []
189
+ if themes:
190
+ lines.append("## Themes")
191
+ lines.append("")
192
+ for i, theme in enumerate(themes, 1):
193
+ lines.append(f"### {i}. {theme.get('title', '(untitled theme)')}")
194
+ lines.append("")
195
+ if theme.get("root_cause"):
196
+ lines.append(f"- Root cause: {theme['root_cause']}")
197
+ if theme.get("action"):
198
+ lines.append(f"- Fix: {theme['action']}")
199
+ ids = theme.get("finding_ids") or []
200
+ if ids:
201
+ lines.append("- Findings:")
202
+ for fid in ids:
203
+ finding = by_id.get(str(fid))
204
+ if finding:
205
+ loc = finding.get("location")
206
+ suffix = f" — `{loc}`" if loc else ""
207
+ lines.append(
208
+ f" - `{fid}` {finding.get('title', '')}{suffix}"
209
+ )
210
+ else:
211
+ lines.append(f" - `{fid}`")
212
+ lines.append("")
213
+
214
+ strengths = data.get("strengths") or []
215
+ if strengths:
216
+ lines.append("## Strengths")
217
+ lines.append("")
218
+ for strength in strengths:
219
+ lines.append(f"- {strength}")
220
+ lines.append("")
221
+
222
+ recommendations = data.get("recommendations") or []
223
+ if recommendations:
224
+ lines.append("## Recommendations")
225
+ lines.append("")
226
+ for i, rec in enumerate(recommendations, 1):
227
+ rank = rec.get("rank", i)
228
+ resolves = rec.get("resolves")
229
+ if isinstance(resolves, list) and resolves:
230
+ suffix = " (resolves: " + ", ".join(map(str, resolves)) + ")"
231
+ elif isinstance(resolves, (int, float)):
232
+ suffix = f" (resolves {int(resolves)} findings)"
233
+ else:
234
+ suffix = ""
235
+ lines.append(f"{rank}. {rec.get('action', '')}{suffix}")
236
+ lines.append("")
237
+
238
+ # Optional agent blocks: rendered only when present so the same
239
+ # renderer serves both the workflow and agent schemas.
240
+ profile = data.get("agent_profile")
241
+ if isinstance(profile, dict) and any(profile.values()):
242
+ lines.append("## Agent Profile")
243
+ lines.append("")
244
+ for key, label in (
245
+ ("name", "Name"),
246
+ ("title", "Title"),
247
+ ("agent_type", "Type"),
248
+ ("mission", "Mission"),
249
+ ):
250
+ if profile.get(key):
251
+ lines.append(f"- {label}: {profile[key]}")
252
+ lines.append("")
253
+
254
+ capabilities = data.get("capabilities")
255
+ if isinstance(capabilities, list) and capabilities:
256
+ lines.append("## Capabilities")
257
+ lines.append("")
258
+ for cap in capabilities:
259
+ if not isinstance(cap, dict) or not cap.get("name"):
260
+ continue
261
+ kind = f" ({cap['kind']})" if cap.get("kind") else ""
262
+ note = f" — {cap['note']}" if cap.get("note") else ""
263
+ lines.append(f"- **{cap['name']}**{kind}{note}")
264
+ lines.append("")
265
+
266
+ detailed = data.get("detailed_analysis")
267
+ if isinstance(detailed, dict) and detailed:
268
+ lines.append("## Per-Lens Verdicts")
269
+ lines.append("")
270
+ for lens, verdict in detailed.items():
271
+ if verdict:
272
+ lines.append(f"- **{lens}**: {verdict}")
273
+ lines.append("")
274
+
275
+ sanctum = data.get("sanctum")
276
+ if isinstance(sanctum, dict) and sanctum.get("present") is not False:
277
+ rows = []
278
+ if sanctum.get("location"):
279
+ rows.append(f"- Location: `{sanctum['location']}`")
280
+ files = sanctum.get("files") or []
281
+ if files:
282
+ rows.append("- Files: " + ", ".join(f"`{f}`" for f in files))
283
+ if sanctum.get("note"):
284
+ rows.append(f"- Note: {sanctum['note']}")
285
+ if rows:
286
+ lines.append("## Sanctum (runtime memory)")
287
+ lines.append("")
288
+ lines.extend(rows)
289
+ lines.append("")
290
+
291
+ experience = data.get("experience")
292
+ if isinstance(experience, dict):
293
+ journeys = [
294
+ j for j in experience.get("journeys") or [] if isinstance(j, dict)
295
+ ]
296
+ headless = experience.get("headless")
297
+ if journeys or headless:
298
+ lines.append("## Experience")
299
+ lines.append("")
300
+ for journey in journeys:
301
+ steps = f" — {journey['steps']}" if journey.get("steps") else ""
302
+ lines.append(f"- **{journey.get('name', '(unnamed journey)')}**{steps}")
303
+ if headless:
304
+ lines.append(f"- Headless: {headless}")
305
+ lines.append("")
306
+
307
+ lines.append("## Findings")
308
+ lines.append("")
309
+ if not findings:
310
+ lines.append("No findings: the scanners returned a clean pass.")
311
+ lines.append("")
312
+ else:
313
+ for sev in SEVERITIES:
314
+ group = [
315
+ f
316
+ for f in findings
317
+ if (f.get("severity") if f.get("severity") in SEVERITIES else "low")
318
+ == sev
319
+ ]
320
+ if not group:
321
+ continue
322
+ lines.append(f"### {sev.capitalize()} ({len(group)})")
323
+ lines.append("")
324
+ for finding in group:
325
+ lines.extend(_finding_lines(finding, "####"))
326
+
327
+ return "\n".join(lines).rstrip() + "\n"
328
+
329
+
330
+ def main() -> int:
331
+ parser = argparse.ArgumentParser(
332
+ description="Inject findings JSON into the report shell and render HTML (+ optional markdown)."
333
+ )
334
+ parser.add_argument("findings", type=Path, help="path to findings.json")
335
+ parser.add_argument(
336
+ "--shell", type=Path, required=True, help="path to report-shell.html"
337
+ )
338
+ parser.add_argument(
339
+ "-o", "--output", type=Path, required=True, help="output HTML path"
340
+ )
341
+ parser.add_argument(
342
+ "--md", type=Path, help="also write a markdown rendering to this path"
343
+ )
344
+ args = parser.parse_args()
345
+
346
+ try:
347
+ raw = args.findings.read_text(encoding="utf-8")
348
+ except OSError as err:
349
+ fail(f"cannot read {args.findings}: {err}")
350
+ try:
351
+ data = json.loads(raw)
352
+ except json.JSONDecodeError as err:
353
+ fail(f"{args.findings} is not valid JSON: {err}")
354
+
355
+ errors = validate(data)
356
+ if errors:
357
+ fail(
358
+ f"{args.findings} failed shape validation:\n - "
359
+ + "\n - ".join(errors)
360
+ )
361
+
362
+ try:
363
+ shell_html = args.shell.read_text(encoding="utf-8")
364
+ except OSError as err:
365
+ fail(f"cannot read shell {args.shell}: {err}")
366
+
367
+ atomic_write(args.output, inject(shell_html, data))
368
+ if args.md:
369
+ atomic_write(args.md, render_md(data))
370
+
371
+ findings = [f for f in data.get("findings", []) if isinstance(f, dict)]
372
+ print(
373
+ json.dumps(
374
+ {
375
+ "html_report": str(args.output),
376
+ "md_report": str(args.md) if args.md else None,
377
+ "grade": data.get("grade"),
378
+ "counts": severity_counts(findings),
379
+ "findings": len(findings),
380
+ }
381
+ )
382
+ )
383
+ return 0
384
+
385
+
386
+ if __name__ == "__main__":
387
+ sys.exit(main())
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "claude-code",
3
+ "invocation": ["claude", "-p", "{prompt}", "--output-format", "stream-json", "--verbose", "--dangerously-skip-permissions"],
4
+ "auth_env": "ANTHROPIC_API_KEY",
5
+ "transcript": { "format": "stdout-jsonl" },
6
+ "skill_dir": ".claude/skills",
7
+ "load_signal": { "skill_tool": "Skill", "read_tool": "Read" },
8
+ "env_passthrough": []
9
+ }
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.9"
4
+ # ///
5
+ """Variance benchmark: summarize a metric across N runs, and compare two configs.
6
+
7
+ A single skill run is noisy. Running the same case N times and summarizing the
8
+ spread tells you whether a difference between two versions is real or just noise.
9
+ This script computes, per numeric metric, the mean, the sample standard deviation
10
+ (n-1, the unbiased estimator for a sample), the min, and the max across N runs.
11
+ Given two such config summaries it reports the delta on each shared metric so a
12
+ "did the change help" question gets a number instead of a guess.
13
+
14
+ Input shapes accepted for a single config:
15
+ - a list of run records, each a flat dict of metric -> number
16
+ [{"elapsed_s": 12.1, "total_tokens": 800}, {"elapsed_s": 11.4, ...}]
17
+ - {"runs": [ ...records... ]}
18
+ - a directory of run folders, each holding timing.json files written by
19
+ run_evals.py (the script reads every timing.json under the directory and
20
+ treats each as one run record)
21
+
22
+ Usage:
23
+ Summarize one config across its runs:
24
+ python3 aggregate_benchmark.py --runs CONFIG_A.json
25
+ python3 aggregate_benchmark.py --runs RUN_DIR/ (reads timing.json files)
26
+
27
+ Compare two configs (each summarized, then delta = B - A):
28
+ python3 aggregate_benchmark.py --baseline A.json --variant B.json
29
+
30
+ Self-test on a known fixture (no external input needed):
31
+ python3 aggregate_benchmark.py --self-test
32
+
33
+ Output is one JSON object on stdout.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import json
40
+ import math
41
+ import sys
42
+ from pathlib import Path
43
+
44
+
45
+ NUMERIC = (int, float)
46
+
47
+
48
+ # --- statistics -------------------------------------------------------------
49
+
50
+ def sample_stddev(values: list[float]) -> float:
51
+ """Sample standard deviation using n-1 (Bessel's correction).
52
+
53
+ Returns 0.0 for fewer than two values, where the sample variance is
54
+ undefined and reporting zero spread is the least surprising choice.
55
+ """
56
+ n = len(values)
57
+ if n < 2:
58
+ return 0.0
59
+ mean = sum(values) / n
60
+ var = sum((x - mean) ** 2 for x in values) / (n - 1)
61
+ return math.sqrt(var)
62
+
63
+
64
+ def summarize_metric(values: list[float]) -> dict:
65
+ return {
66
+ "n": len(values),
67
+ "mean": (sum(values) / len(values)) if values else 0.0,
68
+ "stddev": sample_stddev(values),
69
+ "min": min(values) if values else 0.0,
70
+ "max": max(values) if values else 0.0,
71
+ }
72
+
73
+
74
+ def collect_numeric_metrics(records: list[dict]) -> dict[str, list[float]]:
75
+ """Group every numeric field across records by metric name."""
76
+ by_metric: dict[str, list[float]] = {}
77
+ for rec in records:
78
+ if not isinstance(rec, dict):
79
+ continue
80
+ for key, val in rec.items():
81
+ if isinstance(val, bool):
82
+ continue # bools are ints in Python; not a metric
83
+ if isinstance(val, NUMERIC):
84
+ by_metric.setdefault(key, []).append(float(val))
85
+ return by_metric
86
+
87
+
88
+ def summarize_config(records: list[dict]) -> dict:
89
+ by_metric = collect_numeric_metrics(records)
90
+ return {
91
+ "runs": len(records),
92
+ "metrics": {name: summarize_metric(vals)
93
+ for name, vals in sorted(by_metric.items())},
94
+ }
95
+
96
+
97
+ def delta_configs(baseline: dict, variant: dict) -> dict:
98
+ """Per shared metric, delta = variant.mean - baseline.mean, plus context."""
99
+ b_metrics = baseline.get("metrics", {})
100
+ v_metrics = variant.get("metrics", {})
101
+ shared = sorted(set(b_metrics) & set(v_metrics))
102
+ out: dict[str, dict] = {}
103
+ for name in shared:
104
+ b = b_metrics[name]
105
+ v = v_metrics[name]
106
+ diff = v["mean"] - b["mean"]
107
+ pct = (diff / b["mean"] * 100.0) if b["mean"] != 0 else None
108
+ out[name] = {
109
+ "baseline_mean": b["mean"],
110
+ "variant_mean": v["mean"],
111
+ "delta": diff,
112
+ "delta_pct": pct,
113
+ "baseline_stddev": b["stddev"],
114
+ "variant_stddev": v["stddev"],
115
+ }
116
+ return out
117
+
118
+
119
+ # --- input loading ----------------------------------------------------------
120
+
121
+ def load_records(path: Path) -> list[dict]:
122
+ """Load run records from a JSON file, a {'runs': [...]} file, or a dir of
123
+ timing.json files."""
124
+ if path.is_dir():
125
+ records: list[dict] = []
126
+ for f in sorted(path.rglob("timing.json")):
127
+ try:
128
+ data = json.loads(f.read_text(encoding="utf-8"))
129
+ except (OSError, json.JSONDecodeError):
130
+ continue
131
+ if isinstance(data, dict):
132
+ records.append(data)
133
+ return records
134
+
135
+ data = json.loads(path.read_text(encoding="utf-8"))
136
+ if isinstance(data, dict) and "runs" in data:
137
+ data = data["runs"]
138
+ if not isinstance(data, list):
139
+ raise ValueError(f"expected a list of run records in {path}")
140
+ return [r for r in data if isinstance(r, dict)]
141
+
142
+
143
+ # --- self-test --------------------------------------------------------------
144
+
145
+ def run_self_test() -> int:
146
+ """Verify mean/stddev/min/max/delta on a known fixture."""
147
+ config_a = [
148
+ {"elapsed_s": 10.0, "total_tokens": 100},
149
+ {"elapsed_s": 12.0, "total_tokens": 200},
150
+ {"elapsed_s": 14.0, "total_tokens": 300},
151
+ ]
152
+ summary_a = summarize_config(config_a)
153
+ el = summary_a["metrics"]["elapsed_s"]
154
+ # mean of 10,12,14 = 12; n-1 stddev = sqrt(((-2)^2+0+2^2)/2)=sqrt(4)=2
155
+ assert el["n"] == 3, el
156
+ assert abs(el["mean"] - 12.0) < 1e-9, el
157
+ assert abs(el["stddev"] - 2.0) < 1e-9, el
158
+ assert el["min"] == 10.0 and el["max"] == 14.0, el
159
+ tok = summary_a["metrics"]["total_tokens"]
160
+ # mean of 100,200,300 = 200; n-1 stddev = sqrt((10000+0+10000)/2)=100
161
+ assert abs(tok["mean"] - 200.0) < 1e-9, tok
162
+ assert abs(tok["stddev"] - 100.0) < 1e-9, tok
163
+
164
+ # single value -> stddev 0
165
+ one = summarize_config([{"x": 5}])
166
+ assert one["metrics"]["x"]["stddev"] == 0.0, one
167
+
168
+ # bools are not treated as metrics
169
+ with_bool = summarize_config([{"ok": True, "x": 1}, {"ok": False, "x": 3}])
170
+ assert "ok" not in with_bool["metrics"], with_bool
171
+ assert abs(with_bool["metrics"]["x"]["mean"] - 2.0) < 1e-9, with_bool
172
+
173
+ # delta: variant slower by 3s on mean, faster question answered by sign
174
+ config_b = [
175
+ {"elapsed_s": 13.0, "total_tokens": 90},
176
+ {"elapsed_s": 15.0, "total_tokens": 110},
177
+ {"elapsed_s": 17.0, "total_tokens": 100},
178
+ ]
179
+ summary_b = summarize_config(config_b)
180
+ d = delta_configs(summary_a, summary_b)
181
+ # elapsed mean: A=12, B=15 -> delta +3, pct +25%
182
+ assert abs(d["elapsed_s"]["delta"] - 3.0) < 1e-9, d
183
+ assert abs(d["elapsed_s"]["delta_pct"] - 25.0) < 1e-9, d
184
+ # tokens mean: A=200, B=100 -> delta -100, pct -50%
185
+ assert abs(d["total_tokens"]["delta"] + 100.0) < 1e-9, d
186
+ assert abs(d["total_tokens"]["delta_pct"] + 50.0) < 1e-9, d
187
+
188
+ print(json.dumps({"self_test": "passed",
189
+ "checked": ["mean", "stddev_n_minus_1", "min", "max",
190
+ "single_value_stddev", "bool_excluded",
191
+ "delta", "delta_pct"]}))
192
+ return 0
193
+
194
+
195
+ # --- main -------------------------------------------------------------------
196
+
197
+ def main(argv: list[str] | None = None) -> int:
198
+ p = argparse.ArgumentParser(
199
+ description=__doc__,
200
+ formatter_class=argparse.RawDescriptionHelpFormatter,
201
+ )
202
+ p.add_argument("--runs", type=Path,
203
+ help="summarize one config (JSON file or dir of timing.json)")
204
+ p.add_argument("--baseline", type=Path,
205
+ help="baseline config for a two-config comparison")
206
+ p.add_argument("--variant", type=Path,
207
+ help="variant config for a two-config comparison")
208
+ p.add_argument("--self-test", action="store_true",
209
+ help="run the built-in fixture self-test and exit")
210
+ args = p.parse_args(argv)
211
+
212
+ if args.self_test:
213
+ return run_self_test()
214
+
215
+ if args.baseline and args.variant:
216
+ b = summarize_config(load_records(args.baseline))
217
+ v = summarize_config(load_records(args.variant))
218
+ out = {
219
+ "baseline": b,
220
+ "variant": v,
221
+ "delta": delta_configs(b, v),
222
+ }
223
+ print(json.dumps(out, indent=2))
224
+ return 0
225
+
226
+ if args.runs:
227
+ out = summarize_config(load_records(args.runs))
228
+ print(json.dumps(out, indent=2))
229
+ return 0
230
+
231
+ p.error("provide --runs, or both --baseline and --variant, or --self-test")
232
+ return 2
233
+
234
+
235
+ if __name__ == "__main__":
236
+ sys.exit(main())