File size: 16,942 Bytes
69e310f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
"""AlphaBrief evaluation harness.

Runs the **full graph** N times against live market data and reports:

* end-to-end success rate,
* post-verification numeric accuracy (must be 100%),
* tool-call error rate,
* average cost and latency per brief.

Plus two **fault-injection controls**, which are the point: a metric that only
ever says 100% is worthless unless you can also show the check fires. The
controls prove the verifier catches a corrupted figure and that a dead ticker
degrades instead of crashing.

Mock-clock safe: no assertion depends on wall-clock time or on a particular
market session, only on internal consistency between the brief and raw state.

    python eval/run_eval.py --runs 20

Writes ``eval/results.md``.
"""

from __future__ import annotations

import argparse
import asyncio
import os
import statistics
import sys
import tempfile
import time
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
if str(API_ROOT) not in sys.path:
    sys.path.insert(0, str(API_ROOT))

DEFAULT_WATCHLIST = ["AAPL", "MSFT", "NVDA"]
RESULTS_PATH = Path(__file__).resolve().parent / "results.md"


@dataclass
class RunOutcome:
    """One evaluated run."""

    index: int
    run_id: str
    status: str
    verified: bool
    claims_checked: int = 0
    claims_matched: int = 0
    claims_mismatched: int = 0
    coverage: float = 0.0
    tool_calls: int = 0
    tool_call_errors: int = 0
    cost_usd: float = 0.0
    latency_ms: float = 0.0
    iterations: int = 0
    partial: bool = False
    error: str | None = None

    @property
    def succeeded(self) -> bool:
        return self.status in ("AWAITING_APPROVAL", "HUMAN_REVIEW") and self.claims_checked > 0


@dataclass
class EvalReport:
    """Aggregate results."""

    runs: list[RunOutcome] = field(default_factory=list)
    controls: dict[str, dict[str, Any]] = field(default_factory=dict)
    started_at: str = ""
    engine: str = ""
    watchlist: list[str] = field(default_factory=list)

    @property
    def success_rate(self) -> float:
        if not self.runs:
            return 0.0
        return sum(1 for r in self.runs if r.succeeded) / len(self.runs)

    @property
    def numeric_accuracy(self) -> float:
        """Fraction of claims in *verified* briefs confirmed by recomputation."""
        verified = [r for r in self.runs if r.verified]
        checked = sum(r.claims_checked for r in verified)
        matched = sum(r.claims_matched for r in verified)
        if checked == 0:
            return 0.0
        return matched / checked

    @property
    def tool_error_rate(self) -> float:
        total = sum(r.tool_calls for r in self.runs)
        errors = sum(r.tool_call_errors for r in self.runs)
        return (errors / total) if total else 0.0

    @property
    def avg_cost(self) -> float:
        return statistics.fmean([r.cost_usd for r in self.runs]) if self.runs else 0.0

    @property
    def avg_latency_ms(self) -> float:
        values = [r.latency_ms for r in self.runs if r.latency_ms > 0]
        return statistics.fmean(values) if values else 0.0

    @property
    def cold_latency_ms(self) -> float:
        """Run 1 β€” the only run that pays real provider round-trips."""
        first = next((run for run in self.runs if run.index == 1), None)
        return first.latency_ms if first else 0.0

    @property
    def warm_latency_ms(self) -> float:
        """Runs 2..N β€” orchestration overhead with the provider cache warm."""
        values = [run.latency_ms for run in self.runs if run.index > 1 and run.latency_ms > 0]
        return statistics.fmean(values) if values else 0.0

    @property
    def p95_latency_ms(self) -> float:
        values = sorted(r.latency_ms for r in self.runs if r.latency_ms > 0)
        if not values:
            return 0.0
        index = min(len(values) - 1, round(0.95 * (len(values) - 1)))
        return values[index]


def _configure_environment(database_url: str) -> None:
    """Free, deterministic, network-polite configuration for the eval."""
    os.environ.setdefault("LLM_ENGINE", "auto")
    os.environ["MCP_TRANSPORT"] = "inmemory"
    os.environ["MCP_SHARED_SERVER"] = "true"
    os.environ["DATABASE_URL"] = database_url
    os.environ.setdefault("PROVIDER_MIN_INTERVAL_SECONDS", "0.15")
    os.environ.setdefault("APPROVAL_TOKEN", "eval-local-token")
    os.environ.setdefault("LOG_LEVEL", "WARNING")
    # The eval approves every run to free its watchlist slot, and `Settings`
    # reads `../../.env` β€” so a working operator credential would make `make
    # eval` mail a real inbox once per scored run. Assigned rather than
    # setdefault: this has to beat the dotenv file.
    os.environ["SMTP_HOST"] = ""
    os.environ["SMTP_USERNAME"] = ""
    os.environ["SMTP_PASSWORD"] = ""
    os.environ["SMTP_FROM"] = ""
    os.environ["SMTP_TO"] = ""


async def _await_gate(service: Any, run_id: str, timeout_s: float = 180.0) -> dict[str, Any]:
    """Poll until the run leaves RUNNING."""
    deadline = time.monotonic() + timeout_s
    while time.monotonic() < deadline:
        record = await service.get_run(run_id)
        if isinstance(record, dict) and record["status"] not in ("RUNNING", "QUEUED"):
            return record
        await asyncio.sleep(0.4)
    record = await service.get_run(run_id)
    fallback: dict[str, Any] = {"status": "TIMEOUT"}
    return record if isinstance(record, dict) else fallback


async def _evaluate_once(service: Any, index: int, tickers: list[str]) -> RunOutcome:
    from app.models.run import RunMode

    started = time.perf_counter()
    try:
        run_id = await service.start_run(tickers=tickers, mode=RunMode.STANDARD, trigger="eval")
    except Exception as exc:  # noqa: BLE001 - an eval must record failures, not die
        return RunOutcome(
            index=index, run_id="-", status="START_FAILED", verified=False, error=str(exc)
        )

    record = await _await_gate(service, run_id)
    gate = await service.gate_payload(run_id)
    report = (gate or {}).get("verification") or {}
    events = await service.bus.history(run_id)
    tool_events = [e for e in events if str(e.kind) == "mcp.tool_call"]
    tool_errors = sum(1 for e in tool_events if not e.payload.get("ok", True))

    return RunOutcome(
        index=index,
        run_id=run_id,
        status=str(record.get("status")),
        verified=bool(report.get("ok")),
        claims_checked=int(report.get("checked_claims", 0)),
        claims_matched=int(report.get("matched", 0)),
        claims_mismatched=int(report.get("mismatched", 0)),
        coverage=float(report.get("coverage", 0.0)),
        tool_calls=len(tool_events),
        tool_call_errors=tool_errors,
        cost_usd=float(record.get("cost_usd", 0.0)),
        latency_ms=float(record.get("latency_ms", (time.perf_counter() - started) * 1000)),
        iterations=int(record.get("iterations", 0)),
        partial=bool(record.get("partial", False)),
    )


async def _run_control(service: Any, mode_name: str, tickers: list[str]) -> dict[str, Any]:
    """Fault-injection control: proves the guarantees are not vacuous."""
    from app.models.run import RunMode

    mode = RunMode(mode_name)
    run_id = await service.start_run(tickers=tickers, mode=mode, trigger="eval-control")
    record = await _await_gate(service, run_id)
    gate = await service.gate_payload(run_id)
    report = (gate or {}).get("verification") or {}
    brief = (gate or {}).get("brief") or {}
    return {
        "mode": mode_name,
        "run_id": run_id,
        "status": str(record.get("status")),
        "verified": bool(report.get("ok")),
        "claims_checked": int(report.get("checked_claims", 0)),
        "claims_mismatched": int(report.get("mismatched", 0)),
        "partial": bool(brief.get("partial", False)),
        "snapshot_rows": len(brief.get("snapshot", [])),
        "errors": int(record.get("error_count", 0)),
        "crashed": str(record.get("status")) == "FAILED",
    }


async def run_eval(count: int, tickers: list[str]) -> EvalReport:
    """Execute `count` scored runs plus the fault-injection controls."""
    from app.core.settings import get_settings
    from app.services.runner import RunService

    settings = get_settings()
    report = EvalReport(
        started_at=datetime.now(UTC).isoformat(timespec="seconds"),
        engine=settings.resolved_engine,
        watchlist=tickers,
    )
    service = RunService()

    for index in range(1, count + 1):
        outcome = await _evaluate_once(service, index, tickers)
        report.runs.append(outcome)
        flag = "ok " if outcome.succeeded and outcome.verified else "FAIL"
        print(
            f"  [{index:>2}/{count}] {flag} {outcome.status:<18} "
            f"claims {outcome.claims_matched}/{outcome.claims_checked} "
            f"tools {outcome.tool_calls} "
            f"{outcome.latency_ms / 1000:.2f}s ${outcome.cost_usd:.4f}"
        )
        # Release the watchlist slot so the next run may start.
        await _release(service, outcome.run_id)

    print("\n  fault-injection controls:")
    for mode_name, control_tickers in (
        ("demo_mismatch", tickers[:1]),
        ("demo_fault", tickers[:1]),
    ):
        control = await _run_control(service, mode_name, control_tickers)
        report.controls[mode_name] = control
        print(
            f"   {mode_name:<14} status={control['status']:<14} "
            f"verified={control['verified']} mismatched={control['claims_mismatched']} "
            f"crashed={control['crashed']}"
        )
        await _release(service, control["run_id"])

    await service.shutdown()
    return report


async def _release(service: Any, run_id: str) -> None:
    """Approve a paused run so its watchlist slot is freed for the next one."""
    from app.models.run import HumanDecision

    try:
        await service.submit_decision(run_id, HumanDecision(action="approve", reviewer="eval"))
    except Exception:  # noqa: BLE001 - already terminal, nothing to release
        return


def render_results(report: EvalReport) -> str:
    """Render `eval/results.md`."""
    accuracy = report.numeric_accuracy
    lines: list[str] = [
        "# AlphaBrief β€” Evaluation Results",
        "",
        f"- **Generated:** {report.started_at}",
        f"- **Runs scored:** {len(report.runs)}",
        f"- **Watchlist:** {', '.join(report.watchlist)}",
        f"- **Model engine:** `{report.engine}`",
        "- **Data:** live yfinance prices and live Yahoo/Google RSS headlines. "
        "No synthetic fixtures.",
        "",
        "## Headline metrics",
        "",
        "| Metric | Result | Target |",
        "| --- | ---: | ---: |",
        f"| End-to-end success rate | {report.success_rate * 100:.1f}% | β‰₯ 95% |",
        f"| Post-verification numeric accuracy | {accuracy * 100:.2f}% | 100% |",
        f"| Tool-call error rate | {report.tool_error_rate * 100:.2f}% | ≀ 5% |",
        f"| Average cost per brief | ${report.avg_cost:.4f} | ≀ $0.15 |",
        f"| Cold-start latency (run 1, live provider calls) | {report.cold_latency_ms / 1000:.2f}s | β€” |",
        f"| Warm latency per brief (runs 2–N, cached providers) | {report.warm_latency_ms / 1000:.2f}s | β€” |",
        f"| p95 latency per brief | {report.p95_latency_ms / 1000:.2f}s | β€” |",
        "",
        "### Reading these numbers honestly",
        "",
        "- **Latency.** The harness shares one MCP server, and therefore one provider",
        "  cache, across the whole session so that twenty runs are polite to free",
        "  providers. Run 1 pays the real yfinance and RSS round-trips; runs 2–N measure",
        "  orchestration overhead only. Both are reported rather than blended, because",
        "  the blended average would flatter the system.",
        f"- **Cost.** `${report.avg_cost:.4f}` is the measured spend on the",
        f"  `{report.engine}` engine. On the deterministic engine that is genuinely zero β€”",
        "  it makes no Anthropic calls. With `ANTHROPIC_API_KEY` set, the same graph runs",
        "  on Haiku 4.5 + Sonnet 4.6 at roughly $0.05–0.15 per full 5-ticker brief;",
        "  the budget guard aborts the run rather than exceed `TOKEN_BUDGET_USD`.",
        "- **Accuracy.** This is not a model score. It is the fraction of published",
        "  figures that a second, independent implementation reproduced from the raw",
        "  price bars. A figure that fails is not published at all.",
        "",
        "**Post-verification numeric accuracy is 100% by construction, not by luck.**",
        "Every figure is minted from a tool-computed metric, then independently",
        "recomputed from the raw price bars by `app/graph/recompute.py` before the",
        "brief can reach the approval gate. A figure that fails is not published β€”",
        "it triggers one regeneration and then `HUMAN_REVIEW`.",
        "",
        "## Fault-injection controls",
        "",
        "A verifier that always passes proves nothing. These runs deliberately break",
        "the system to show the checks actually fire.",
        "",
        "| Control | Status | Verified | Mismatches caught | Crashed |",
        "| --- | --- | --- | ---: | --- |",
    ]
    for name, control in report.controls.items():
        lines.append(
            f"| `{name}` | {control['status']} | {control['verified']} | "
            f"{control['claims_mismatched']} | {control['crashed']} |"
        )
    lines += [
        "",
        "- `demo_mismatch` corrupts one claim after assembly. Expected: the verifier",
        "  catches it, one regeneration is attempted, and the run lands in",
        "  `HUMAN_REVIEW` rather than being delivered.",
        "- `demo_fault` injects an unresolvable ticker. Expected: the run completes",
        "  with per-ticker errors and a brief explicitly marked partial β€” never a crash.",
        "",
        "## Per-run detail",
        "",
        "| # | Status | Verified | Claims matched | Tool calls | Iterations | Latency | Cost |",
        "| ---: | --- | --- | ---: | ---: | ---: | ---: | ---: |",
    ]
    for run in report.runs:
        lines.append(
            f"| {run.index} | {run.status} | {run.verified} | "
            f"{run.claims_matched}/{run.claims_checked} | {run.tool_calls} | "
            f"{run.iterations} | {run.latency_ms / 1000:.2f}s | ${run.cost_usd:.4f} |"
        )
    lines += [
        "",
        "---",
        "",
        "Reproduce with `python eval/run_eval.py --runs "
        f"{len(report.runs)}`. Figures move between runs because the market moves;",
        "the invariants (accuracy, graceful degradation, termination) do not.",
        "",
    ]
    return "\n".join(lines)


def main() -> int:
    parser = argparse.ArgumentParser(description="Evaluate the AlphaBrief pipeline.")
    parser.add_argument("--runs", type=int, default=20, help="scored runs (default: 20)")
    parser.add_argument(
        "--tickers",
        type=str,
        default=",".join(DEFAULT_WATCHLIST),
        help="comma-separated watchlist",
    )
    parser.add_argument(
        "--database-url",
        type=str,
        default="",
        help="override the eval database (default: a temporary SQLite file)",
    )
    args = parser.parse_args()

    tickers = [t.strip().upper() for t in args.tickers.split(",") if t.strip()]
    database_url = args.database_url or f"sqlite:///{Path(tempfile.mkdtemp()) / 'eval.db'}"
    _configure_environment(database_url)

    print(f"AlphaBrief eval β€” {args.runs} runs over {', '.join(tickers)}\n")
    report = asyncio.run(run_eval(args.runs, tickers))

    RESULTS_PATH.write_text(render_results(report), encoding="utf-8")
    accuracy = report.numeric_accuracy

    print(f"\n  success rate            {report.success_rate * 100:.1f}%")
    print(f"  numeric accuracy        {accuracy * 100:.2f}%")
    print(f"  tool-call error rate    {report.tool_error_rate * 100:.2f}%")
    print(f"  avg cost / brief        ${report.avg_cost:.4f} ({report.engine} engine)")
    print(f"  cold-start latency      {report.cold_latency_ms / 1000:.2f}s (live providers)")
    print(f"  warm latency / brief    {report.warm_latency_ms / 1000:.2f}s (cached providers)")
    print(f"\n  wrote {RESULTS_PATH}")

    mismatch_control = report.controls.get("demo_mismatch", {})
    fault_control = report.controls.get("demo_fault", {})
    ok = (
        accuracy >= 1.0
        and report.success_rate >= 0.95
        and mismatch_control.get("claims_mismatched", 0) > 0
        and not mismatch_control.get("verified", True)
        and not fault_control.get("crashed", True)
    )
    print("\n  RESULT:", "PASS" if ok else "FAIL")
    return 0 if ok else 1


if __name__ == "__main__":
    raise SystemExit(main())