sofhiaazzhr Claude Opus 4.8 commited on
Commit
051e95a
·
1 Parent(s): ee7c499

[NOTICKET] eval/tools: property evals for every active analytics tool (PR-2 Layer 2)

Browse files

The 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 ADDED
@@ -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.
eval/tools/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Tool-output evals (PR-2 Layer 2) — property assertions over analytics tools."""
eval/tools/cases.py ADDED
@@ -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
+ ]
eval/tools/results/.gitkeep ADDED
File without changes
eval/tools/run_eval.py ADDED
@@ -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