[NOTICKET] eval/tools: property evals for every active analytics tool (PR-2 Layer 2)
Browse filesThe first eval in the repo that asserts what a tool COMPUTED, not what plan
selected it (eval/planner) or what the router labelled it (eval/intent). It is the
committed, shared home for the fixtures written during PR-0/PR-1 — which otherwise
live gitignored under tests/ and so ran for no one else.
35 cases, one property each (never a golden float — k-means init and ETS
optimisation have legitimate freedom, so cases assert recovered signal / ordering /
bounds). Every PR-1 behaviour change has a case here that fails without the fix.
Coverage is 14/14 ACTIVE analytics tools; the runner reports any uncovered tool and
exits non-zero on failure so CI can gate on it.
No LLM, no DB, no network — the only compute is the tools themselves. Verified:
ruff clean, 35/35 pass, llm_calls=0, full pytest unaffected.
PR-2 Layer 2 (docs/plans/PR2_EVALUATION.md §4); ships on pr/22 alongside PR-1 so it
nets those four behaviour changes at merge, per §6.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- eval/tools/README.md +43 -0
- eval/tools/__init__.py +1 -0
- eval/tools/cases.py +440 -0
- eval/tools/results/.gitkeep +0 -0
- eval/tools/run_eval.py +156 -0
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# eval/tools — tool-output evals (PR-2 Layer 2)
|
| 2 |
+
|
| 3 |
+
The first eval in the repo that asserts **what a tool computed**, not what plan
|
| 4 |
+
selected it (`eval/planner`) or what the router labelled it (`eval/intent`). It is
|
| 5 |
+
the shared, committed home for the fixtures written during PR-0/PR-1, which
|
| 6 |
+
otherwise live gitignored under `tests/` and so run for no one else.
|
| 7 |
+
|
| 8 |
+
See [docs/plans/PR2_EVALUATION.md](../../docs/plans/PR2_EVALUATION.md) §4.
|
| 9 |
+
|
| 10 |
+
## What it checks
|
| 11 |
+
|
| 12 |
+
Each case plants a property in a fixture frame and asserts the tool reports it —
|
| 13 |
+
**a property, never a golden float** (k-means init and ETS optimisation have
|
| 14 |
+
legitimate freedom, so cases assert recovered signal / ordering / bounds, not
|
| 15 |
+
exact fitted numbers). Every PR-1 behaviour change has a case here that fails
|
| 16 |
+
without the fix.
|
| 17 |
+
|
| 18 |
+
## Run
|
| 19 |
+
|
| 20 |
+
```
|
| 21 |
+
PYTHONPATH=. python -m eval.tools.run_eval
|
| 22 |
+
PYTHONPATH=. python -m eval.tools.run_eval --tool analyze_cluster
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
Prints a per-tool table + summary and writes a timestamped JSON under `results/`
|
| 26 |
+
(never overwritten — one file per run, diffable). Exit code is non-zero on any
|
| 27 |
+
failure, so CI can gate on it.
|
| 28 |
+
|
| 29 |
+
## Cost
|
| 30 |
+
|
| 31 |
+
**Zero.** No LLM, no DB, no network. The only compute is the tools themselves,
|
| 32 |
+
which may fit sklearn/statsmodels locally — fast and deterministic.
|
| 33 |
+
|
| 34 |
+
## Coverage
|
| 35 |
+
|
| 36 |
+
The runner reports coverage against `ACTIVE_ANALYTICS_TOOLS` and names any tool
|
| 37 |
+
with no case. Adding a tool without a case is visible in every run's summary.
|
| 38 |
+
|
| 39 |
+
## Adding a case
|
| 40 |
+
|
| 41 |
+
Add an `async def` to `cases.py` that builds a fixture, calls the tool, and
|
| 42 |
+
asserts a property (raise `AssertionError` on failure), then register it in the
|
| 43 |
+
`CASES` list. Keep assertions on properties, not exact floats.
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Tool-output evals (PR-2 Layer 2) — property assertions over analytics tools."""
|
|
@@ -0,0 +1,440 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool-output eval cases — one property-assertion per behaviour that must stay true.
|
| 2 |
+
|
| 3 |
+
PR-2 Layer 2 (docs/plans/PR2_EVALUATION.md §4). These are the committed, shared
|
| 4 |
+
version of the fixtures Sofhia wrote during PR-0/PR-1 (which live gitignored under
|
| 5 |
+
`tests/`), plus one basic truth assertion for every other ACTIVE analytics tool so
|
| 6 |
+
the whole surface has a net.
|
| 7 |
+
|
| 8 |
+
The rule (§4): **assert a PROPERTY, not a golden float.** k-means init and ETS
|
| 9 |
+
optimisation have legitimate freedom, so cases assert recovered signal, correct
|
| 10 |
+
ordering, or bounds — never an exact fitted number. A case fails by raising
|
| 11 |
+
AssertionError (or any exception); the runner catches it and records the message.
|
| 12 |
+
|
| 13 |
+
NO LLM and NO DB are touched. sklearn/statsmodels fits are fine (they are not
|
| 14 |
+
models we call over the network) — they are what makes the ML tools' claims real.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from collections.abc import Awaitable, Callable
|
| 20 |
+
from dataclasses import dataclass
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import pandas as pd
|
| 24 |
+
|
| 25 |
+
from src.tools.analytics.ab_test import analyze_ab_test
|
| 26 |
+
from src.tools.analytics.aggregation import analyze_aggregate
|
| 27 |
+
from src.tools.analytics.anomaly import _ANOMALY_RATE_CEILING, _flag_rate_quality, analyze_anomaly
|
| 28 |
+
from src.tools.analytics.cluster import analyze_cluster
|
| 29 |
+
from src.tools.analytics.data_quality import analyze_data_quality
|
| 30 |
+
from src.tools.analytics.descriptive import ColumnNotFoundError, analyze_descriptive
|
| 31 |
+
from src.tools.analytics.forecast import analyze_forecast
|
| 32 |
+
from src.tools.analytics.importance import _infer_task, analyze_importance
|
| 33 |
+
from src.tools.analytics.merge import analyze_merge
|
| 34 |
+
from src.tools.analytics.pareto import analyze_pareto
|
| 35 |
+
from src.tools.analytics.relationship import NonNumericColumnError, analyze_correlation
|
| 36 |
+
from src.tools.analytics.segmentation import analyze_segmentation
|
| 37 |
+
from src.tools.analytics.temporal import analyze_trend
|
| 38 |
+
from src.tools.analytics.visualization import render_chart
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class EvalCase:
|
| 43 |
+
"""One property assertion against one tool.
|
| 44 |
+
|
| 45 |
+
`fn` builds a fixture with a property planted, calls the tool, and asserts the
|
| 46 |
+
tool reports it. It raises on failure and returns None on success.
|
| 47 |
+
"""
|
| 48 |
+
|
| 49 |
+
id: str
|
| 50 |
+
tool: str
|
| 51 |
+
prop: str
|
| 52 |
+
fn: Callable[[], Awaitable[None]]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _assert(cond: bool, msg: str) -> None:
|
| 56 |
+
if not cond:
|
| 57 |
+
raise AssertionError(msg)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# --------------------------------------------------------------------------- #
|
| 61 |
+
# analyze_data_quality (D-1) #
|
| 62 |
+
# --------------------------------------------------------------------------- #
|
| 63 |
+
async def _dq_zero_dup_when_none() -> None:
|
| 64 |
+
df = pd.DataFrame({"id": range(5), "v": [1, 2, 3, 4, 5]}) # all rows unique
|
| 65 |
+
out = analyze_data_quality(df)
|
| 66 |
+
_assert(out["duplicate_rows"]["count"] == 0, "unique rows must report 0 duplicates")
|
| 67 |
+
_assert(out["duplicate_rows"]["rate"] == 0.0, "0 duplicates -> rate 0.0")
|
| 68 |
+
_assert(out["issues"]["duplicate_row_count"] == 0, "issues must carry 0, not absent")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
async def _dq_counts_planted_dups() -> None:
|
| 72 |
+
df = pd.DataFrame({"a": [1, 1, 1, 2, 3], "b": ["x", "x", "x", "y", "z"]})
|
| 73 |
+
out = analyze_data_quality(df)
|
| 74 |
+
_assert(out["duplicate_rows"]["count"] == 2, "3 identical rows -> 2 redundant copies")
|
| 75 |
+
_assert(abs(out["duplicate_rows"]["rate"] - 2 / 5) < 1e-9, "rate = 2/5")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def _dq_subset_candidate_key() -> None:
|
| 79 |
+
df = pd.DataFrame({"id": [1, 2, 3], "grp": ["A", "A", "B"]})
|
| 80 |
+
out = analyze_data_quality(df, column_ids=["grp"])
|
| 81 |
+
_assert(out["duplicate_rows"]["count"] == 1, "subset dup: second 'A' is one duplicate")
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# --------------------------------------------------------------------------- #
|
| 85 |
+
# analyze_anomaly (D-3) #
|
| 86 |
+
# --------------------------------------------------------------------------- #
|
| 87 |
+
def _outlier_frame(n: int = 60) -> pd.DataFrame:
|
| 88 |
+
rng = np.random.RandomState(0)
|
| 89 |
+
x, y = rng.normal(0, 1, n), rng.normal(0, 1, n)
|
| 90 |
+
x[:3] = [50.0, -48.0, 55.0]
|
| 91 |
+
y[:3] = [50.0, -47.0, 60.0]
|
| 92 |
+
return pd.DataFrame({"x": x, "y": y})
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def _an_names_rows_via_label() -> None:
|
| 96 |
+
df = _outlier_frame(60)
|
| 97 |
+
df["eqm_no"] = [f"UNIT-{i:03d}" for i in range(60)]
|
| 98 |
+
out = await analyze_anomaly(df, ["x", "y"], contamination=0.1, label_columns=["eqm_no"])
|
| 99 |
+
_assert(out["n_anomalies"] > 0, "planted outliers must be flagged")
|
| 100 |
+
for rec in out["anomalies"]:
|
| 101 |
+
_assert(str(rec["eqm_no"]).startswith("UNIT-"), "each record must name its row")
|
| 102 |
+
_assert("x" in rec and "y" in rec, "scored values must still be present")
|
| 103 |
+
named = {rec["eqm_no"] for rec in out["anomalies"]}
|
| 104 |
+
_assert(bool({"UNIT-000", "UNIT-001", "UNIT-002"} & named), "planted units must appear")
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
async def _an_missing_label_raises() -> None:
|
| 108 |
+
df = _outlier_frame(30)
|
| 109 |
+
try:
|
| 110 |
+
await analyze_anomaly(df, ["x", "y"], label_columns=["ghost_id"])
|
| 111 |
+
except ColumnNotFoundError:
|
| 112 |
+
return
|
| 113 |
+
raise AssertionError("a missing label column must raise ColumnNotFoundError")
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def _an_quality_present_not_weak() -> None:
|
| 117 |
+
out = await analyze_anomaly(_outlier_frame(60), ["x", "y"], contamination=0.1)
|
| 118 |
+
q = out["quality"]
|
| 119 |
+
_assert(q["metric"] == "anomaly_rate", "quality metric must be anomaly_rate")
|
| 120 |
+
_assert(q["floor"] == _ANOMALY_RATE_CEILING, "floor must be the ceiling constant")
|
| 121 |
+
_assert(q["value"] == round(out["anomaly_rate"], 4), "quality value echoes the rate")
|
| 122 |
+
_assert(q["weak"] is False, "a ~0.1 flag rate is a normal run, not weak")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
async def _an_flag_rate_branches() -> None:
|
| 126 |
+
_assert(_flag_rate_quality(0.22)["weak"] is False, "clean-data floor 0.22 is not weak")
|
| 127 |
+
_assert(_flag_rate_quality(0.50)["weak"] is False, "boundary is strict > 0.5")
|
| 128 |
+
_assert(_flag_rate_quality(0.857)["weak"] is True, "0.86 flagged is weak")
|
| 129 |
+
_assert("noise" in _flag_rate_quality(0.857)["note"], "weak note names it as noise")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# --------------------------------------------------------------------------- #
|
| 133 |
+
# analyze_importance (D-4) #
|
| 134 |
+
# --------------------------------------------------------------------------- #
|
| 135 |
+
async def _imp_infer_non_numeric() -> None:
|
| 136 |
+
classify, reason = _infer_task(pd.Series(["a", "b", "a", "c"]))
|
| 137 |
+
_assert(classify is True and "categor" in reason.lower(), "text target -> classification")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
async def _imp_infer_float() -> None:
|
| 141 |
+
classify, reason = _infer_task(pd.Series([1.5, 2.25, 3.75, 0.1]))
|
| 142 |
+
_assert(classify is False and "continuous" in reason.lower(), "float target -> regression")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
async def _imp_infer_ordinal_integer() -> None:
|
| 146 |
+
classify, reason = _infer_task(pd.Series(list(range(15)) * 3))
|
| 147 |
+
_assert(classify is False and "ordinal" in reason.lower(), "dense 15-int -> ordinal regression")
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
async def _imp_infer_binary_flag() -> None:
|
| 151 |
+
classify, reason = _infer_task(pd.Series([0, 1, 1, 0, 1, 0]))
|
| 152 |
+
_assert(classify is True and "flag" in reason.lower(), "0/1 -> binary classification")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
async def _imp_infer_sparse_codes() -> None:
|
| 156 |
+
classify, reason = _infer_task(pd.Series([10, 20, 700, 10, 20, 700]))
|
| 157 |
+
_assert(classify is True and "categor" in reason.lower(), "sparse codes -> classification")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
async def _imp_infer_many_integers() -> None:
|
| 161 |
+
classify, _ = _infer_task(pd.Series(range(50)))
|
| 162 |
+
_assert(classify is False, "50 distinct integers -> regression")
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
async def _imp_infer_all_null() -> None:
|
| 166 |
+
classify, reason = _infer_task(pd.Series([None, None, None], dtype="float64"))
|
| 167 |
+
_assert("no non-null" in reason.lower(), "all-null column is empty, not 'non-numeric'")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def _imp_recovers_planted_signal() -> None:
|
| 171 |
+
rng = np.random.RandomState(0)
|
| 172 |
+
n = 120
|
| 173 |
+
driver = rng.normal(0, 1, n)
|
| 174 |
+
noise = rng.normal(0, 1, n)
|
| 175 |
+
target = 5.0 * driver + 0.1 * noise + rng.normal(0, 0.05, n)
|
| 176 |
+
df = pd.DataFrame({"driver": driver, "noise": noise, "target": target})
|
| 177 |
+
out = await analyze_importance(df, "target", feature_columns=["driver", "noise"])
|
| 178 |
+
_assert(out["task"] == "regression", "continuous target -> regression")
|
| 179 |
+
top = out["features"][0]["feature"]
|
| 180 |
+
_assert(top == "driver", f"planted driver must rank first, got {top}")
|
| 181 |
+
_assert(out["quality"]["weak"] is False, "a strong linear signal must not be weak")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# --------------------------------------------------------------------------- #
|
| 185 |
+
# analyze_pareto (D-6) #
|
| 186 |
+
# --------------------------------------------------------------------------- #
|
| 187 |
+
def _revenue() -> pd.DataFrame:
|
| 188 |
+
return pd.DataFrame({"region": ["A", "B", "C", "D", "A"], "revenue": [50, 10, 5, 5, 30]})
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
async def _pareto_additive_no_caveat() -> None:
|
| 192 |
+
out = analyze_pareto(_revenue(), "region", "revenue", agg="sum")
|
| 193 |
+
_assert(out["caveats"] == [], "sum on non-negative values -> no honesty caveat")
|
| 194 |
+
_assert(out["categories_for_80pct"] == 1, "A alone is 80% -> categories_for_80pct == 1")
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
async def _pareto_non_additive_warns() -> None:
|
| 198 |
+
out = analyze_pareto(_revenue(), "region", "revenue", agg="mean")
|
| 199 |
+
_assert(any("not additive" in c for c in out["caveats"]), "mean must be flagged non-additive")
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
async def _pareto_negative_warns() -> None:
|
| 203 |
+
df = pd.DataFrame({"region": ["A", "B", "C"], "delta": [50, -25, 10]})
|
| 204 |
+
out = analyze_pareto(df, "region", "delta", agg="sum")
|
| 205 |
+
_assert(any("negative" in c for c in out["caveats"]), "negative values must be flagged")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# --------------------------------------------------------------------------- #
|
| 209 |
+
# analyze_trend (D-7) #
|
| 210 |
+
# --------------------------------------------------------------------------- #
|
| 211 |
+
def _months(values: list[float]) -> pd.DataFrame:
|
| 212 |
+
dates = pd.date_range("2024-01-01", periods=len(values), freq="MS")
|
| 213 |
+
return pd.DataFrame({"date": dates, "v": values})
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
async def _trend_clean_agrees() -> None:
|
| 217 |
+
out = analyze_trend(_months([10, 20, 30, 40]), "date", "v", freq="month")
|
| 218 |
+
_assert(out["direction"] == "up" and out["slope"] > 0, "clean rise -> up, positive slope")
|
| 219 |
+
_assert(out["r_squared"] == 1.0, "perfect line -> R^2 == 1")
|
| 220 |
+
_assert(not any("Mixed signal" in c for c in out["caveats"]), "agreement -> no mixed caveat")
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
async def _trend_disagreement_flagged() -> None:
|
| 224 |
+
out = analyze_trend(_months([100, 40, 30, 20, 10, 110]), "date", "v", freq="month")
|
| 225 |
+
_assert(out["direction"] == "up", "endpoints go up (ends high)")
|
| 226 |
+
_assert(out["slope"] < 0, "OLS slope goes down")
|
| 227 |
+
_assert(any("Mixed signal" in c for c in out["caveats"]), "disagreement must be flagged")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# --------------------------------------------------------------------------- #
|
| 231 |
+
# analyze_cluster (D-10) #
|
| 232 |
+
# --------------------------------------------------------------------------- #
|
| 233 |
+
def _blobs(per: int = 12) -> pd.DataFrame:
|
| 234 |
+
rng = np.random.RandomState(0)
|
| 235 |
+
pts = np.vstack(
|
| 236 |
+
[rng.normal(c, 0.2, (per, 2)) for c in (0, 10, 20)]
|
| 237 |
+
)
|
| 238 |
+
return pd.DataFrame(pts, columns=["x", "y"])
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
async def _cluster_override_visible() -> None:
|
| 242 |
+
df = pd.DataFrame({"a": [1.0] * 25, "b": [2.0] * 25}) # identical -> collapse
|
| 243 |
+
out = await analyze_cluster(df, ["a", "b"], k=5)
|
| 244 |
+
_assert(out["requested_k"] == 5, "requested_k must echo the explicit k")
|
| 245 |
+
_assert(out["k"] != 5, "collapse must fall back to a different k")
|
| 246 |
+
_assert(any("Requested k=5" in c for c in out["caveats"]), "override must be visible")
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
async def _cluster_k_honoured() -> None:
|
| 250 |
+
out = await analyze_cluster(_blobs(10), ["x", "y"], k=3)
|
| 251 |
+
_assert(out["k"] == 3, "separable blobs -> explicit k=3 honoured")
|
| 252 |
+
_assert(not any("fell back" in c for c in out["caveats"]), "no fallback on a good fit")
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
async def _cluster_auto_finds_3() -> None:
|
| 256 |
+
out = await analyze_cluster(_blobs(12), ["x", "y"]) # no k
|
| 257 |
+
_assert(out["requested_k"] is None, "auto must report requested_k None")
|
| 258 |
+
_assert(out["k"] == 3, "silhouette must recover the 3 planted blobs")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
# --------------------------------------------------------------------------- #
|
| 262 |
+
# Basic coverage — one truth assertion for every other ACTIVE tool #
|
| 263 |
+
# --------------------------------------------------------------------------- #
|
| 264 |
+
async def _descriptive_basic() -> None:
|
| 265 |
+
out = analyze_descriptive(pd.DataFrame({"revenue": [10, 20, 30, 40]}), ["revenue"])["revenue"]
|
| 266 |
+
_assert(out["mean"] == 25 and out["median"] == 25, "mean/median of 10..40 = 25")
|
| 267 |
+
_assert(out["min"] == 10 and out["max"] == 40, "min/max = 10/40")
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
async def _aggregate_groupby_sum() -> None:
|
| 271 |
+
df = pd.DataFrame({"region": ["A", "A", "B", "B"], "revenue": [10, 20, 100, 200]})
|
| 272 |
+
rows = analyze_aggregate(df, {"revenue": ["sum"]}, group_by=["region"])
|
| 273 |
+
by = {r["region"]: r for r in rows}
|
| 274 |
+
_assert(by["A"]["revenue_sum"] == 30 and by["B"]["revenue_sum"] == 300, "per-group sums")
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
async def _correlation_recovers_positive() -> None:
|
| 278 |
+
out = analyze_correlation(pd.DataFrame({"x": [1, 2, 3, 4], "y": [2, 4, 6, 8]}), ["x", "y"])
|
| 279 |
+
_assert(abs(out["matrix"]["x"]["y"] - 1.0) < 1e-9, "y=2x -> correlation 1.0")
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
async def _correlation_refuses_non_numeric() -> None:
|
| 283 |
+
df = pd.DataFrame({"x": [1, 2], "label": ["a", "b"]})
|
| 284 |
+
try:
|
| 285 |
+
analyze_correlation(df, ["x", "label"])
|
| 286 |
+
except NonNumericColumnError:
|
| 287 |
+
return
|
| 288 |
+
raise AssertionError("a non-numeric column must be refused")
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
async def _merge_aligns_two_metrics() -> None:
|
| 292 |
+
left = pd.DataFrame({"section": ["S1", "S2"], "pa": [90.0, 80.0]})
|
| 293 |
+
right = pd.DataFrame({"section": ["S1", "S2"], "backlog": [3, 7]})
|
| 294 |
+
rows = analyze_merge(left, right, on=["section"], how="inner")
|
| 295 |
+
by = {r["section"]: r for r in rows}
|
| 296 |
+
_assert(len(rows) == 2, "inner join on matched keys -> 2 rows")
|
| 297 |
+
_assert(by["S1"]["pa"] == 90.0 and by["S1"]["backlog"] == 3, "both metrics aligned per key")
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _ab(a_vals: list[float], b_vals: list[float]) -> pd.DataFrame:
|
| 301 |
+
return pd.DataFrame({"grp": ["A"] * len(a_vals) + ["B"] * len(b_vals), "val": a_vals + b_vals})
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
async def _ab_significant_difference() -> None:
|
| 305 |
+
out = analyze_ab_test(_ab([10, 11, 9, 10, 12], [20, 21, 19, 20, 22]), "grp", "val", "A", "B")
|
| 306 |
+
_assert(out["comparison"] == "higher", "B > A")
|
| 307 |
+
_assert(out["test"]["significant"] is True, "clearly separated groups are significant")
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
async def _ab_overlap_not_significant() -> None:
|
| 311 |
+
out = analyze_ab_test(_ab([10, 11, 9, 10], [10, 12, 9, 11]), "grp", "val", "A", "B")
|
| 312 |
+
_assert(out["test"]["significant"] is not True, "overlapping groups are not significant")
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
async def _segmentation_quantile_balanced() -> None:
|
| 316 |
+
df = pd.DataFrame({"age": list(range(12))})
|
| 317 |
+
out = analyze_segmentation(df, "age", bins=4, method="quantile")
|
| 318 |
+
counts = [s["count"] for s in out["segments"]]
|
| 319 |
+
_assert(len(out["segments"]) == 4, "4 quantile bins")
|
| 320 |
+
_assert(max(counts) - min(counts) <= 1, "quantile bins must be balanced (counts within 1)")
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
async def _forecast_trend_beats_naive() -> None:
|
| 324 |
+
dates = pd.date_range("2024-01-01", periods=18, freq="MS")
|
| 325 |
+
df = pd.DataFrame({"month": dates, "v": [100 + 10 * i for i in range(18)]})
|
| 326 |
+
out = await analyze_forecast(df, "month", "v", horizon=3, freq="month")
|
| 327 |
+
_assert(len(out["forecast"]) == 3 and out["direction"] == "up", "rising trend projected up")
|
| 328 |
+
q = out["quality"]
|
| 329 |
+
_assert(q["value"] is not None and q["value"] < 1.0, "clean trend -> MASE < 1")
|
| 330 |
+
_assert(q["weak"] is False, "a validated sub-1 MASE is not weak")
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
async def _forecast_short_series_not_validated() -> None:
|
| 334 |
+
dates = pd.date_range("2024-01-01", periods=6, freq="MS")
|
| 335 |
+
df = pd.DataFrame({"month": dates, "v": [100 + 5 * i for i in range(6)]})
|
| 336 |
+
out = await analyze_forecast(df, "month", "v", horizon=2, freq="month")
|
| 337 |
+
q = out["quality"]
|
| 338 |
+
_assert(q["value"] is None, "too-short series cannot be validated -> no MASE number")
|
| 339 |
+
_assert("not validated" in q["note"], "must say 'not validated', not a silent number")
|
| 340 |
+
_assert(q["weak"] is True, "an unvalidated forecast is weak evidence")
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
# --------------------------------------------------------------------------- #
|
| 344 |
+
# render_chart — computes no numbers; its truth is a valid envelope mapping #
|
| 345 |
+
# the requested columns #
|
| 346 |
+
# --------------------------------------------------------------------------- #
|
| 347 |
+
async def _chart_builds_envelope() -> None:
|
| 348 |
+
df = pd.DataFrame({"region": ["A", "B", "C"], "revenue": [30, 20, 10]})
|
| 349 |
+
env = render_chart(df, "bar", x="region", y="revenue")
|
| 350 |
+
_assert(env["schema"] == "dataeyond.chart.v1", "must be a dataeyond.chart.v1 envelope")
|
| 351 |
+
_assert(env["chart_type"] == "bar", "chart_type echoes the request")
|
| 352 |
+
trace = env["plotly"]["data"][0]
|
| 353 |
+
_assert(list(trace["x"]) == ["A", "B", "C"], "x maps the requested column")
|
| 354 |
+
_assert(list(trace["y"]) == [30, 20, 10], "y maps the requested column")
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
async def _chart_missing_column_raises() -> None:
|
| 358 |
+
df = pd.DataFrame({"region": ["A"], "revenue": [1]})
|
| 359 |
+
try:
|
| 360 |
+
render_chart(df, "bar", x="region", y="ghost")
|
| 361 |
+
except ColumnNotFoundError:
|
| 362 |
+
return
|
| 363 |
+
raise AssertionError("a missing chart column must raise ColumnNotFoundError")
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
def _mk(tool: str, *fns: Callable[[], Awaitable[None]]) -> list[EvalCase]:
|
| 367 |
+
"""Build cases for one tool; id + prop derive from each function's name."""
|
| 368 |
+
return [
|
| 369 |
+
EvalCase(f.__name__.lstrip("_"), tool, f.__name__.lstrip("_").replace("_", " "), f)
|
| 370 |
+
for f in fns
|
| 371 |
+
]
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
CASES: list[EvalCase] = [
|
| 375 |
+
*_mk(
|
| 376 |
+
"analyze_data_quality",
|
| 377 |
+
_dq_zero_dup_when_none,
|
| 378 |
+
_dq_counts_planted_dups,
|
| 379 |
+
_dq_subset_candidate_key,
|
| 380 |
+
),
|
| 381 |
+
*_mk(
|
| 382 |
+
"analyze_anomaly",
|
| 383 |
+
_an_names_rows_via_label,
|
| 384 |
+
_an_missing_label_raises,
|
| 385 |
+
_an_quality_present_not_weak,
|
| 386 |
+
_an_flag_rate_branches,
|
| 387 |
+
),
|
| 388 |
+
*_mk(
|
| 389 |
+
"analyze_importance",
|
| 390 |
+
_imp_infer_non_numeric,
|
| 391 |
+
_imp_infer_float,
|
| 392 |
+
_imp_infer_ordinal_integer,
|
| 393 |
+
_imp_infer_binary_flag,
|
| 394 |
+
_imp_infer_sparse_codes,
|
| 395 |
+
_imp_infer_many_integers,
|
| 396 |
+
_imp_infer_all_null,
|
| 397 |
+
_imp_recovers_planted_signal,
|
| 398 |
+
),
|
| 399 |
+
*_mk(
|
| 400 |
+
"analyze_pareto",
|
| 401 |
+
_pareto_additive_no_caveat,
|
| 402 |
+
_pareto_non_additive_warns,
|
| 403 |
+
_pareto_negative_warns,
|
| 404 |
+
),
|
| 405 |
+
*_mk(
|
| 406 |
+
"analyze_trend",
|
| 407 |
+
_trend_clean_agrees,
|
| 408 |
+
_trend_disagreement_flagged,
|
| 409 |
+
),
|
| 410 |
+
*_mk(
|
| 411 |
+
"analyze_cluster",
|
| 412 |
+
_cluster_override_visible,
|
| 413 |
+
_cluster_k_honoured,
|
| 414 |
+
_cluster_auto_finds_3,
|
| 415 |
+
),
|
| 416 |
+
*_mk("analyze_descriptive", _descriptive_basic),
|
| 417 |
+
*_mk("analyze_aggregate", _aggregate_groupby_sum),
|
| 418 |
+
*_mk(
|
| 419 |
+
"analyze_correlation",
|
| 420 |
+
_correlation_recovers_positive,
|
| 421 |
+
_correlation_refuses_non_numeric,
|
| 422 |
+
),
|
| 423 |
+
*_mk("analyze_merge", _merge_aligns_two_metrics),
|
| 424 |
+
*_mk(
|
| 425 |
+
"analyze_ab_test",
|
| 426 |
+
_ab_significant_difference,
|
| 427 |
+
_ab_overlap_not_significant,
|
| 428 |
+
),
|
| 429 |
+
*_mk("analyze_segmentation", _segmentation_quantile_balanced),
|
| 430 |
+
*_mk(
|
| 431 |
+
"analyze_forecast",
|
| 432 |
+
_forecast_trend_beats_naive,
|
| 433 |
+
_forecast_short_series_not_validated,
|
| 434 |
+
),
|
| 435 |
+
*_mk(
|
| 436 |
+
"render_chart",
|
| 437 |
+
_chart_builds_envelope,
|
| 438 |
+
_chart_missing_column_raises,
|
| 439 |
+
),
|
| 440 |
+
]
|
|
File without changes
|
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tool-output eval runner (PR-2 Layer 2).
|
| 2 |
+
|
| 3 |
+
Runs every case in `cases.py` — each builds a fixture with a property planted and
|
| 4 |
+
asserts the tool reports it — then prints a per-tool table + aggregate summary and
|
| 5 |
+
writes a timestamped JSON report under `results/` (never overwritten).
|
| 6 |
+
|
| 7 |
+
**No LLM, no DB, no network.** The only compute is the tools themselves (which may
|
| 8 |
+
fit sklearn/statsmodels — fast, local, deterministic). This is the shared, committed
|
| 9 |
+
version of the fixtures that otherwise live gitignored under `tests/`.
|
| 10 |
+
|
| 11 |
+
Invoke as a module so `src` imports resolve:
|
| 12 |
+
|
| 13 |
+
PYTHONPATH=. python -m eval.tools.run_eval
|
| 14 |
+
PYTHONPATH=. python -m eval.tools.run_eval --tool analyze_cluster
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import asyncio
|
| 21 |
+
import json
|
| 22 |
+
import time
|
| 23 |
+
import traceback
|
| 24 |
+
from dataclasses import asdict, dataclass
|
| 25 |
+
from datetime import datetime
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
|
| 28 |
+
from eval.tools.cases import CASES, EvalCase
|
| 29 |
+
from src.tools.registry import ACTIVE_ANALYTICS_TOOLS
|
| 30 |
+
|
| 31 |
+
_HERE = Path(__file__).resolve().parent
|
| 32 |
+
RESULTS_DIR = _HERE / "results"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class CaseResult:
|
| 37 |
+
id: str
|
| 38 |
+
tool: str
|
| 39 |
+
prop: str
|
| 40 |
+
passed: bool
|
| 41 |
+
latency_ms: float
|
| 42 |
+
error: str | None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
async def run_case(case: EvalCase) -> CaseResult:
|
| 46 |
+
start = time.perf_counter()
|
| 47 |
+
error: str | None = None
|
| 48 |
+
try:
|
| 49 |
+
await case.fn()
|
| 50 |
+
passed = True
|
| 51 |
+
except Exception as exc: # noqa: BLE001 — a failed assertion is a failed case, recorded
|
| 52 |
+
passed = False
|
| 53 |
+
error = f"{type(exc).__name__}: {exc}".strip()
|
| 54 |
+
latency_ms = round((time.perf_counter() - start) * 1000, 1)
|
| 55 |
+
return CaseResult(case.id, case.tool, case.prop, passed, latency_ms, error)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _coverage() -> dict[str, list[str]]:
|
| 59 |
+
"""Which ACTIVE analytics tools have >= 1 case, and which have none (§4 acceptance)."""
|
| 60 |
+
covered = {c.tool for c in CASES}
|
| 61 |
+
active = {t.name for t in ACTIVE_ANALYTICS_TOOLS}
|
| 62 |
+
return {
|
| 63 |
+
"covered": sorted(active & covered),
|
| 64 |
+
"uncovered": sorted(active - covered),
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def format_table(results: list[CaseResult]) -> str:
|
| 69 |
+
header = f"{'ID':<38} {'TOOL':<22} {'ms':>6} OK"
|
| 70 |
+
rule = "-" * len(header)
|
| 71 |
+
lines = [rule, header, rule]
|
| 72 |
+
for r in results:
|
| 73 |
+
ok = "ok" if r.passed else "X"
|
| 74 |
+
lines.append(f"{r.id:<38} {r.tool:<22} {r.latency_ms:>6.1f} {ok}")
|
| 75 |
+
if not r.passed:
|
| 76 |
+
lines.append(f" -> {r.error}")
|
| 77 |
+
lines.append(rule)
|
| 78 |
+
return "\n".join(lines)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def format_summary(results: list[CaseResult], coverage: dict[str, list[str]]) -> str:
|
| 82 |
+
passed = sum(r.passed for r in results)
|
| 83 |
+
total = len(results)
|
| 84 |
+
lines = ["SUMMARY", f" {passed}/{total} cases passed"]
|
| 85 |
+
|
| 86 |
+
by_tool: dict[str, list[CaseResult]] = {}
|
| 87 |
+
for r in results:
|
| 88 |
+
by_tool.setdefault(r.tool, []).append(r)
|
| 89 |
+
lines.append("")
|
| 90 |
+
lines.append(" By tool")
|
| 91 |
+
for tool in sorted(by_tool):
|
| 92 |
+
sub = by_tool[tool]
|
| 93 |
+
lines.append(f" {tool:<24} {sum(s.passed for s in sub)}/{len(sub)}")
|
| 94 |
+
|
| 95 |
+
lines.append("")
|
| 96 |
+
lines.append(
|
| 97 |
+
f" Coverage: {len(coverage['covered'])}/"
|
| 98 |
+
f"{len(coverage['covered']) + len(coverage['uncovered'])} ACTIVE analytics tools"
|
| 99 |
+
)
|
| 100 |
+
if coverage["uncovered"]:
|
| 101 |
+
lines.append(f" UNCOVERED: {', '.join(coverage['uncovered'])}")
|
| 102 |
+
|
| 103 |
+
failures = [r for r in results if not r.passed]
|
| 104 |
+
if failures:
|
| 105 |
+
lines.append("")
|
| 106 |
+
lines.append(f" FAILURES ({len(failures)})")
|
| 107 |
+
for r in failures:
|
| 108 |
+
lines.append(f" {r.id:<38} {r.error}")
|
| 109 |
+
return "\n".join(lines)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
async def main() -> int:
|
| 113 |
+
parser = argparse.ArgumentParser(description="Tool-output eval (PR-2 Layer 2)")
|
| 114 |
+
parser.add_argument("--tool", default="", help="run only cases for this tool")
|
| 115 |
+
parser.add_argument("--no-table", action="store_true", help="skip the detail table")
|
| 116 |
+
args = parser.parse_args()
|
| 117 |
+
|
| 118 |
+
cases = [c for c in CASES if not args.tool or c.tool == args.tool]
|
| 119 |
+
|
| 120 |
+
started = datetime.now()
|
| 121 |
+
print(f"Tool-Output Eval -- {started:%Y-%m-%d %H:%M:%S}")
|
| 122 |
+
print(f"cases: {len(cases)} (no LLM, no DB)")
|
| 123 |
+
|
| 124 |
+
results = [await run_case(c) for c in cases]
|
| 125 |
+
coverage = _coverage()
|
| 126 |
+
|
| 127 |
+
if not args.no_table:
|
| 128 |
+
print(format_table(results))
|
| 129 |
+
print(format_summary(results, coverage))
|
| 130 |
+
|
| 131 |
+
report = {
|
| 132 |
+
"run": {
|
| 133 |
+
"timestamp": started.isoformat(timespec="seconds"),
|
| 134 |
+
"total": len(results),
|
| 135 |
+
"passed": sum(r.passed for r in results),
|
| 136 |
+
"runtime_ms": round(sum(r.latency_ms for r in results), 1),
|
| 137 |
+
"llm_calls": 0,
|
| 138 |
+
},
|
| 139 |
+
"coverage": coverage,
|
| 140 |
+
"cases": [asdict(r) for r in results],
|
| 141 |
+
}
|
| 142 |
+
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 143 |
+
out_path = RESULTS_DIR / f"tools_result_{started:%Y-%m-%d_%H%M%S}.json"
|
| 144 |
+
out_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 145 |
+
print(f"\n-> saved: {out_path.relative_to(_HERE.parent.parent)}")
|
| 146 |
+
|
| 147 |
+
# Non-zero exit on any failure so CI can gate on it.
|
| 148 |
+
return 0 if all(r.passed for r in results) else 1
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
if __name__ == "__main__":
|
| 152 |
+
try:
|
| 153 |
+
raise SystemExit(asyncio.run(main()))
|
| 154 |
+
except KeyboardInterrupt:
|
| 155 |
+
traceback.print_exc()
|
| 156 |
+
raise SystemExit(130) from None
|