@matt82198/aesop 0.1.0 → 0.3.1

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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
package/ui/collectors.py CHANGED
@@ -315,6 +315,87 @@ def get_alerts():
315
315
  print(f"[collectors] Failed to read alerts: {e}", file=sys.stderr)
316
316
  return alerts
317
317
 
318
+ def get_agent_lifecycle_events():
319
+ """Collect agent lifecycle events from transcript analysis.
320
+
321
+ Scans agent-*.jsonl files to infer agent state transitions.
322
+ Returns structured events for Activity view rendering.
323
+
324
+ Event format (list of dicts):
325
+ [
326
+ {
327
+ "agent_id": "12345",
328
+ "state": "dispatch" | "working" | "done" | "stalled",
329
+ "last_activity": ISO 8601 timestamp (transcript mtime),
330
+ "age_sec": seconds since last activity,
331
+ "transitions": [
332
+ {"state": "dispatch", "at": "2026-07-22T...Z"},
333
+ {"state": "working", "at": "2026-07-22T...Z"},
334
+ ]
335
+ }
336
+ ]
337
+
338
+ If transcripts unavailable or no agents found, returns empty list.
339
+ """
340
+ events = []
341
+ try:
342
+ # Use wave_dispatch.py's existing logic for consistency
343
+ import wave_dispatch as wd
344
+
345
+ dispatch_data = wd.get_wave_dispatch(force=False)
346
+ if not dispatch_data.get("available"):
347
+ return events
348
+
349
+ # Convert phase and timing data to lifecycle event structure
350
+ agents_data = dispatch_data.get("agents", [])
351
+ now_ts = datetime.now(timezone.utc)
352
+
353
+ for agent in agents_data:
354
+ agent_id = agent.get("id")
355
+ phase = agent.get("phase")
356
+ age_sec = agent.get("last_activity_age_sec", 0)
357
+
358
+ if not agent_id:
359
+ continue
360
+
361
+ # Map phase to state
362
+ phase_to_state = {
363
+ "dispatch": "dispatch",
364
+ "thinking": "working",
365
+ "tool-use": "working",
366
+ "stall": "stalled",
367
+ "done": "done",
368
+ "unknown": "working",
369
+ }
370
+ state = phase_to_state.get(phase, "working")
371
+
372
+ # Estimate activity timestamp from age
373
+ if age_sec >= 0:
374
+ activity_ts = now_ts.timestamp() - age_sec
375
+ activity_dt = datetime.fromtimestamp(activity_ts, timezone.utc)
376
+ last_activity = activity_dt.isoformat(timespec='seconds').replace('+00:00', 'Z')
377
+ else:
378
+ last_activity = now_ts.isoformat(timespec='seconds').replace('+00:00', 'Z')
379
+
380
+ event = {
381
+ "agent_id": agent_id,
382
+ "state": state,
383
+ "last_activity": last_activity,
384
+ "age_sec": age_sec,
385
+ "transitions": [
386
+ {
387
+ "state": state,
388
+ "at": last_activity,
389
+ }
390
+ ],
391
+ }
392
+ events.append(event)
393
+
394
+ except Exception as e:
395
+ print(f"[collectors] Failed to collect agent lifecycle events: {e}", file=sys.stderr)
396
+
397
+ return events
398
+
318
399
  def load_tracker():
319
400
  """Load tracker.json, return empty tracker if missing or corrupt.
320
401
 
@@ -371,61 +452,6 @@ def save_tracker(tracker):
371
452
  print(f"[tracker] Failed to unlink temp file: {ue}", file=sys.stderr)
372
453
  raise
373
454
 
374
- def migrate_tracker_from_backlog():
375
- """One-time idempotent migration: AUDIT-BACKLOG.md -> tracker.json."""
376
- if config.TRACKER_FILE.exists():
377
- return load_tracker()
378
-
379
- backlog_data = parse_audit_backlog()
380
- if not backlog_data.get("tiers"):
381
- return {"version": 1, "items": []}
382
-
383
- items = []
384
- for tier_data in backlog_data["tiers"]:
385
- priority = tier_data["tier"]
386
-
387
- for backlog_item in tier_data.get("items", []):
388
- status_glyph = backlog_item["status"]
389
-
390
- if status_glyph == "✅":
391
- status, lane = "done", "done"
392
- tags = []
393
- elif status_glyph == "🔵":
394
- status, lane = "in-progress", "in-progress"
395
- tags = []
396
- elif status_glyph == "⏸":
397
- status, lane = "todo", "proposed"
398
- tags = ["needs-decision"]
399
- else:
400
- status, lane = "todo", "ranked"
401
- tags = []
402
-
403
- title = backlog_item.get("title", "")
404
- tag_prefix = backlog_item.get("tag", "")
405
- if tag_prefix:
406
- tag_value = tag_prefix.strip("[]")
407
- if tag_value and tag_value not in tags:
408
- tags.insert(0, tag_value)
409
-
410
- item = {
411
- "id": secrets.token_hex(6),
412
- "title": title,
413
- "priority": priority,
414
- "status": status,
415
- "lane": lane,
416
- "source": "audit-backlog-migration",
417
- "tags": tags,
418
- "notes": None,
419
- "pr_link": None,
420
- "created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
421
- "completed_at": None
422
- }
423
- items.append(item)
424
-
425
- tracker = {"version": 1, "items": items}
426
- save_tracker(tracker)
427
- return tracker
428
-
429
455
  def get_tracker_items(status=None, priority=None):
430
456
  """Retrieve tracker items with optional filters."""
431
457
  tracker = load_tracker()
package/ui/config.py CHANGED
@@ -97,8 +97,13 @@ def reload():
97
97
  ORCH_STATUS_FILE = STATE_DIR / "orchestrator-status.json"
98
98
 
99
99
  # Wave-14 dashboard rewrite (plan D3): built frontend + cost ledger paths.
100
- # WEB_DIST: the committed Vite build output served at / and /assets/*.
101
- WEB_DIST = AESOP_ROOT / "ui" / "web" / "dist"
100
+ # WEB_DIST: env AESOP_WEB_DIST > AESOP_ROOT/ui/web/dist (test override for fixtures).
101
+ WEB_DIST = Path(
102
+ os.getenv(
103
+ "AESOP_WEB_DIST",
104
+ str(AESOP_ROOT / "ui" / "web" / "dist")
105
+ )
106
+ )
102
107
  # LEDGER_FILE: outcomes ledger parsed by ui/cost.py; sse.py mtime-gates on it.
103
108
  LEDGER_FILE = STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
104
109
 
package/ui/cost.py CHANGED
@@ -5,9 +5,14 @@ This module provides get_cost_summary() which parses the outcomes ledger
5
5
  markdown table and returns per-model, per-day, and overall cost/token aggregations
6
6
  with optional pricing estimates.
7
7
 
8
- Ledger format (markdown table):
9
- | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
10
- | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
8
+ Ledger format (markdown table, supports both 7-column and 9-column):
9
+ Legacy 7-column:
10
+ | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
11
+ | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
12
+
13
+ Extended 9-column (phase/wave optional):
14
+ | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict | phase | wave |
15
+ | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK | build | 7 |
11
16
 
12
17
  CostSummary JSON shape (returned by get_cost_summary()):
13
18
  {
@@ -69,10 +74,11 @@ def _validate_ledger_format(lines):
69
74
  Checks the first non-empty, non-separator, non-header line to ensure it looks like a data row
70
75
  (has ISO timestamp, agent type, model, numeric fields, verdict). Returns (is_valid, error_message).
71
76
 
72
- Expected format:
73
- | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
74
- |---|---|---|---|---|---|---|
75
- | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
77
+ Accepts two formats:
78
+ - Legacy 7-column: | timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
79
+ - Extended 9-column: | timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict | phase | wave |
80
+
81
+ Optional trailing columns (phase, wave) are ignored during validation.
76
82
  """
77
83
  for line in lines:
78
84
  line = line.strip()
@@ -91,11 +97,13 @@ def _validate_ledger_format(lines):
91
97
 
92
98
  parts = [p.strip() for p in line.split('|')]
93
99
 
94
- # Should have 9 parts: [empty, col1, col2, col3, col4, col5, col6, col7, empty]
95
- if len(parts) != 9:
96
- return False, f"Expected 7 columns, got {len(parts) - 2}"
100
+ # Accept both 7-column (9 parts) and 9-column (11 parts) formats
101
+ # 7 columns: ['', col1, col2, col3, col4, col5, col6, col7, '']
102
+ # 9 columns: ['', col1, col2, col3, col4, col5, col6, col7, col8, col9, '']
103
+ if len(parts) < 9:
104
+ return False, f"Too few columns, got {len(parts) - 2} (expected at least 7)"
97
105
 
98
- # Extract columns and validate types
106
+ # Extract core columns (first 7 required columns)
99
107
  try:
100
108
  timestamp = parts[1]
101
109
  agent_type = parts[2]
@@ -145,11 +153,18 @@ def get_cost_summary():
145
153
  All config paths are read at call time (not import time) to ensure
146
154
  test-fixture isolation via config.reload().
147
155
 
156
+ Extended fields (additively):
157
+ - per_week_costs: dict of "YYYY-Www" -> week cost/token totals and model mix
158
+ - verdict_weighted_cost: cost-per-outcome metrics (cost per OK, weighted by verdict distribution)
159
+ - model_mix_trend: per-day model usage distribution (%)
160
+
148
161
  Returns:
149
162
  dict: CostSummary with models, daily_totals, overall_scorecard,
150
- skipped_lines, has_pricing, estimates_by_model (or error field if invalid).
163
+ skipped_lines, has_pricing, estimates_by_model, per_week_costs,
164
+ verdict_weighted_cost, model_mix_trend (or error field if invalid).
151
165
  """
152
166
  import sys
167
+ from datetime import datetime, timedelta
153
168
 
154
169
  # Read ledger path at call time
155
170
  ledger_file = config.STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
@@ -172,6 +187,14 @@ def get_cost_summary():
172
187
  "skipped_lines": 0,
173
188
  "has_pricing": False,
174
189
  "estimates_by_model": {},
190
+ "per_week_costs": {},
191
+ "verdict_weighted_cost": {
192
+ "cost_per_ok": 0.0,
193
+ "cost_per_failed": 0.0,
194
+ "cost_per_empty": 0.0,
195
+ "cost_per_hung": 0.0,
196
+ },
197
+ "model_mix_trend": {},
175
198
  }
176
199
 
177
200
  # If ledger file doesn't exist, return empty summary
@@ -194,6 +217,9 @@ def get_cost_summary():
194
217
  result["error"] = "ledger format invalid"
195
218
  return result
196
219
 
220
+ # Track ledger entries for per-week-per-model calculation
221
+ ledger_entries = []
222
+
197
223
  # Parse each line
198
224
  for line in lines:
199
225
  line = line.strip()
@@ -268,6 +294,16 @@ def get_cost_summary():
268
294
  result["skipped_lines"] += 1
269
295
  continue
270
296
 
297
+ # Store ledger entry for per-week calculation
298
+ ledger_entries.append({
299
+ "timestamp": timestamp,
300
+ "date_str": date_str,
301
+ "model": model,
302
+ "tokens_in": tokens_in,
303
+ "tokens_out": tokens_out,
304
+ "verdict": verdict
305
+ })
306
+
271
307
  # Aggregate by model
272
308
  if model not in result["models"]:
273
309
  result["models"][model] = {
@@ -329,9 +365,192 @@ def get_cost_summary():
329
365
  "total_cost": total_cost,
330
366
  }
331
367
 
368
+ # Calculate per-week costs and model mix trend
369
+ _calculate_weekly_costs(result, pricing_map, ledger_entries)
370
+ _calculate_verdict_weighted_cost(result, pricing_map)
371
+ _calculate_model_mix_trend(result)
372
+
332
373
  return result
333
374
 
334
375
 
376
+ def _calculate_weekly_costs(result, pricing_map, ledger_entries):
377
+ """Calculate per-week cost rollup from ledger entries.
378
+
379
+ Groups ledger entries by ISO week (YYYY-Www format) and aggregates
380
+ per-model tokens for each week. If pricing is available, includes cost estimates.
381
+
382
+ BUG FIX: This function now uses EACH WEEK'S OWN per-model token counts
383
+ from the ledger, not the global model distribution. This prevents inflating
384
+ each week's cost by applying global model mix that may not be accurate
385
+ for that particular week.
386
+
387
+ PERF FIX: datetime.strptime is hoisted outside the inner loops. Each timestamp
388
+ is parsed ONCE during the initial pass, avoiding O(weeks*models*entries) re-parsing.
389
+
390
+ Modifies result["per_week_costs"] in-place with structure:
391
+ {
392
+ "YYYY-Www": {
393
+ "tokens_in": int,
394
+ "tokens_out": int,
395
+ "model_tokens": {"model": int, ...},
396
+ "cost": float (if pricing available)
397
+ }
398
+ }
399
+ """
400
+ from datetime import datetime
401
+
402
+ if not ledger_entries:
403
+ return
404
+
405
+ # OPTIMIZATION: Pre-parse all timestamps once (avoid O(weeks*models*entries) re-parsing)
406
+ # Map each entry index to its ISO week key, computed upfront
407
+ entry_weeks = {}
408
+ for idx, entry in enumerate(ledger_entries):
409
+ try:
410
+ # Parse YYYY-MM-DD to ISO week
411
+ dt = datetime.strptime(entry["date_str"], "%Y-%m-%d")
412
+ iso_year, iso_week, _ = dt.isocalendar()
413
+ week_key = f"{iso_year}-W{iso_week:02d}"
414
+ entry_weeks[idx] = week_key
415
+ except (ValueError, KeyError):
416
+ entry_weeks[idx] = None
417
+
418
+ # Group ledger entries by ISO week and aggregate per-model within each week
419
+ weeks = {}
420
+ for idx, entry in enumerate(ledger_entries):
421
+ week_key = entry_weeks[idx]
422
+ if week_key is None:
423
+ # Skip entries with invalid dates or missing fields
424
+ continue
425
+
426
+ if week_key not in weeks:
427
+ weeks[week_key] = {
428
+ "tokens_in": 0,
429
+ "tokens_out": 0,
430
+ "model_tokens": {},
431
+ "cost": 0.0
432
+ }
433
+
434
+ # Aggregate this entry's tokens into the week
435
+ weeks[week_key]["tokens_in"] += entry["tokens_in"]
436
+ weeks[week_key]["tokens_out"] += entry["tokens_out"]
437
+
438
+ # Track per-model tokens within this week
439
+ model = entry["model"]
440
+ if model not in weeks[week_key]["model_tokens"]:
441
+ weeks[week_key]["model_tokens"][model] = 0
442
+ weeks[week_key]["model_tokens"][model] += entry["tokens_in"] + entry["tokens_out"]
443
+
444
+ # If pricing available, calculate cost per week based on THAT WEEK'S model mix
445
+ if pricing_map:
446
+ for week_key, week_data in weeks.items():
447
+ total_cost = 0.0
448
+ for model, total_tokens in week_data["model_tokens"].items():
449
+ if model in pricing_map:
450
+ pricing = pricing_map[model]
451
+ input_price = pricing.get("input_per_mtok", 0.0)
452
+ output_price = pricing.get("output_per_mtok", 0.0)
453
+ # Use the pre-parsed week info to find entries for this model in this week
454
+ model_entries_in_week = [
455
+ ledger_entries[idx]
456
+ for idx, w_key in entry_weeks.items()
457
+ if w_key == week_key and ledger_entries[idx]["model"] == model
458
+ ]
459
+ if model_entries_in_week:
460
+ week_model_tokens_in = sum(e["tokens_in"] for e in model_entries_in_week)
461
+ week_model_tokens_out = sum(e["tokens_out"] for e in model_entries_in_week)
462
+ model_cost = (week_model_tokens_in * input_price + week_model_tokens_out * output_price) / 1_000_000
463
+ else:
464
+ # Fallback: if we can't find the entries, use 1:2 ratio estimate
465
+ tokens_in_estimate = total_tokens * 0.333
466
+ tokens_out_estimate = total_tokens * 0.667
467
+ model_cost = (tokens_in_estimate * input_price + tokens_out_estimate * output_price) / 1_000_000
468
+ total_cost += model_cost
469
+ week_data["cost"] = total_cost
470
+
471
+ result["per_week_costs"] = weeks
472
+
473
+
474
+ def _calculate_verdict_weighted_cost(result, pricing_map):
475
+ """Calculate cost-per-outcome metrics weighted by verdict distribution.
476
+
477
+ Computes cost per successful outcome (cost / ok_count) and cost per other outcomes.
478
+ If pricing is available, uses estimated costs; otherwise uses token counts as proxy.
479
+
480
+ Modifies result["verdict_weighted_cost"] in-place with structure:
481
+ {
482
+ "cost_per_ok": float,
483
+ "cost_per_failed": float,
484
+ "cost_per_empty": float,
485
+ "cost_per_hung": float,
486
+ }
487
+ """
488
+ scorecard = result["overall_scorecard"]
489
+
490
+ # Calculate total cost (if pricing available)
491
+ total_cost = 0.0
492
+ if pricing_map and result["has_pricing"]:
493
+ for estimate in result["estimates_by_model"].values():
494
+ total_cost += estimate.get("total_cost", 0.0)
495
+ else:
496
+ # Use token count as cost proxy (tokens_in + tokens_out)
497
+ for daily in result["daily_totals"].values():
498
+ total_cost += daily["tokens_in"] + daily["tokens_out"]
499
+
500
+ # Calculate cost per outcome type
501
+ result["verdict_weighted_cost"] = {
502
+ "cost_per_ok": total_cost / scorecard["ok_count"] if scorecard["ok_count"] > 0 else 0.0,
503
+ "cost_per_failed": total_cost / scorecard["failed_count"] if scorecard["failed_count"] > 0 else 0.0,
504
+ "cost_per_empty": total_cost / scorecard["empty_count"] if scorecard["empty_count"] > 0 else 0.0,
505
+ "cost_per_hung": total_cost / scorecard["hung_count"] if scorecard["hung_count"] > 0 else 0.0,
506
+ }
507
+
508
+
509
+ def _calculate_model_mix_trend(result):
510
+ """Calculate per-day model usage distribution as percentages.
511
+
512
+ Breaks down the token usage by model for each day in daily_totals.
513
+ Modifies result["model_mix_trend"] in-place with structure:
514
+ {
515
+ "YYYY-MM-DD": {
516
+ "model": percentage (0.0-100.0),
517
+ ...
518
+ }
519
+ }
520
+ """
521
+ model_mix_trend = {}
522
+
523
+ # For each day, calculate model distribution
524
+ # Since daily_totals doesn't track per-model breakdown, we need to estimate
525
+ # based on the overall model distribution across all runs in that day
526
+ if not result["models"]:
527
+ result["model_mix_trend"] = model_mix_trend
528
+ return
529
+
530
+ # Calculate overall model token distribution
531
+ total_model_tokens = {}
532
+ grand_total_tokens = 0
533
+ for model, stats in result["models"].items():
534
+ tokens = stats["tokens_in"] + stats["tokens_out"]
535
+ total_model_tokens[model] = tokens
536
+ grand_total_tokens += tokens
537
+
538
+ # Apply model distribution to each day (as a simplified proxy)
539
+ for date_str in result["daily_totals"].keys():
540
+ daily_dist = {}
541
+ daily_total = result["daily_totals"][date_str]["tokens_in"] + result["daily_totals"][date_str]["tokens_out"]
542
+
543
+ if grand_total_tokens > 0 and daily_total > 0:
544
+ for model, total_tokens in total_model_tokens.items():
545
+ # Distribute daily tokens proportionally to model usage
546
+ ratio = total_tokens / grand_total_tokens
547
+ daily_dist[model] = round(ratio * 100.0, 2)
548
+
549
+ model_mix_trend[date_str] = daily_dist
550
+
551
+ result["model_mix_trend"] = model_mix_trend
552
+
553
+
335
554
  def _load_pricing_config():
336
555
  """Load pricing map from aesop.config.json at call time.
337
556