ishaq101's picture sofhiaazzhr's picture
/feat ds tools (#22)
e201ae4
Raw History Blame Contribute Delete
19.2 kB
"""Tool-output eval cases — one property-assertion per behaviour that must stay true.
PR-2 Layer 2 (docs/plans/PR2_EVALUATION.md §4). These are the committed, shared
version of the fixtures Sofhia wrote during PR-0/PR-1 (which live gitignored under
`tests/`), plus one basic truth assertion for every other ACTIVE analytics tool so
the whole surface has a net.
The rule (§4): **assert a PROPERTY, not a golden float.** k-means init and ETS
optimisation have legitimate freedom, so cases assert recovered signal, correct
ordering, or bounds — never an exact fitted number. A case fails by raising
AssertionError (or any exception); the runner catches it and records the message.
NO LLM and NO DB are touched. sklearn/statsmodels fits are fine (they are not
models we call over the network) — they are what makes the ML tools' claims real.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import numpy as np
import pandas as pd
from src.tools.analytics.ab_test import analyze_ab_test
from src.tools.analytics.aggregation import analyze_aggregate
from src.tools.analytics.anomaly import _ANOMALY_RATE_CEILING, _flag_rate_quality, analyze_anomaly
from src.tools.analytics.cluster import analyze_cluster
from src.tools.analytics.data_quality import analyze_data_quality
from src.tools.analytics.descriptive import ColumnNotFoundError, analyze_descriptive
from src.tools.analytics.forecast import analyze_forecast
from src.tools.analytics.importance import _infer_task, analyze_importance
from src.tools.analytics.merge import analyze_merge
from src.tools.analytics.pareto import analyze_pareto
from src.tools.analytics.relationship import NonNumericColumnError, analyze_correlation
from src.tools.analytics.segmentation import analyze_segmentation
from src.tools.analytics.temporal import analyze_trend
from src.tools.analytics.visualization import render_chart
@dataclass
class EvalCase:
"""One property assertion against one tool.
`fn` builds a fixture with a property planted, calls the tool, and asserts the
tool reports it. It raises on failure and returns None on success.
"""
id: str
tool: str
prop: str
fn: Callable[[], Awaitable[None]]
def _assert(cond: bool, msg: str) -> None:
if not cond:
raise AssertionError(msg)
# --------------------------------------------------------------------------- #
# analyze_data_quality (D-1) #
# --------------------------------------------------------------------------- #
async def _dq_zero_dup_when_none() -> None:
df = pd.DataFrame({"id": range(5), "v": [1, 2, 3, 4, 5]}) # all rows unique
out = analyze_data_quality(df)
_assert(out["duplicate_rows"]["count"] == 0, "unique rows must report 0 duplicates")
_assert(out["duplicate_rows"]["rate"] == 0.0, "0 duplicates -> rate 0.0")
_assert(out["issues"]["duplicate_row_count"] == 0, "issues must carry 0, not absent")
async def _dq_counts_planted_dups() -> None:
df = pd.DataFrame({"a": [1, 1, 1, 2, 3], "b": ["x", "x", "x", "y", "z"]})
out = analyze_data_quality(df)
_assert(out["duplicate_rows"]["count"] == 2, "3 identical rows -> 2 redundant copies")
_assert(abs(out["duplicate_rows"]["rate"] - 2 / 5) < 1e-9, "rate = 2/5")
async def _dq_subset_candidate_key() -> None:
df = pd.DataFrame({"id": [1, 2, 3], "grp": ["A", "A", "B"]})
out = analyze_data_quality(df, column_ids=["grp"])
_assert(out["duplicate_rows"]["count"] == 1, "subset dup: second 'A' is one duplicate")
# --------------------------------------------------------------------------- #
# analyze_anomaly (D-3) #
# --------------------------------------------------------------------------- #
def _outlier_frame(n: int = 60) -> pd.DataFrame:
rng = np.random.RandomState(0)
x, y = rng.normal(0, 1, n), rng.normal(0, 1, n)
x[:3] = [50.0, -48.0, 55.0]
y[:3] = [50.0, -47.0, 60.0]
return pd.DataFrame({"x": x, "y": y})
async def _an_names_rows_via_label() -> None:
df = _outlier_frame(60)
df["eqm_no"] = [f"UNIT-{i:03d}" for i in range(60)]
out = await analyze_anomaly(df, ["x", "y"], contamination=0.1, label_columns=["eqm_no"])
_assert(out["n_anomalies"] > 0, "planted outliers must be flagged")
for rec in out["anomalies"]:
_assert(str(rec["eqm_no"]).startswith("UNIT-"), "each record must name its row")
_assert("x" in rec and "y" in rec, "scored values must still be present")
named = {rec["eqm_no"] for rec in out["anomalies"]}
_assert(bool({"UNIT-000", "UNIT-001", "UNIT-002"} & named), "planted units must appear")
async def _an_missing_label_raises() -> None:
df = _outlier_frame(30)
try:
await analyze_anomaly(df, ["x", "y"], label_columns=["ghost_id"])
except ColumnNotFoundError:
return
raise AssertionError("a missing label column must raise ColumnNotFoundError")
async def _an_quality_present_not_weak() -> None:
out = await analyze_anomaly(_outlier_frame(60), ["x", "y"], contamination=0.1)
q = out["quality"]
_assert(q["metric"] == "anomaly_rate", "quality metric must be anomaly_rate")
_assert(q["floor"] == _ANOMALY_RATE_CEILING, "floor must be the ceiling constant")
_assert(q["value"] == round(out["anomaly_rate"], 4), "quality value echoes the rate")
_assert(q["weak"] is False, "a ~0.1 flag rate is a normal run, not weak")
async def _an_flag_rate_branches() -> None:
_assert(_flag_rate_quality(0.22)["weak"] is False, "clean-data floor 0.22 is not weak")
_assert(_flag_rate_quality(0.50)["weak"] is False, "boundary is strict > 0.5")
_assert(_flag_rate_quality(0.857)["weak"] is True, "0.86 flagged is weak")
_assert("noise" in _flag_rate_quality(0.857)["note"], "weak note names it as noise")
# --------------------------------------------------------------------------- #
# analyze_importance (D-4) #
# --------------------------------------------------------------------------- #
async def _imp_infer_non_numeric() -> None:
classify, reason = _infer_task(pd.Series(["a", "b", "a", "c"]))
_assert(classify is True and "categor" in reason.lower(), "text target -> classification")
async def _imp_infer_float() -> None:
classify, reason = _infer_task(pd.Series([1.5, 2.25, 3.75, 0.1]))
_assert(classify is False and "continuous" in reason.lower(), "float target -> regression")
async def _imp_infer_ordinal_integer() -> None:
classify, reason = _infer_task(pd.Series(list(range(15)) * 3))
_assert(classify is False and "ordinal" in reason.lower(), "dense 15-int -> ordinal regression")
async def _imp_infer_binary_flag() -> None:
classify, reason = _infer_task(pd.Series([0, 1, 1, 0, 1, 0]))
_assert(classify is True and "flag" in reason.lower(), "0/1 -> binary classification")
async def _imp_infer_sparse_codes() -> None:
classify, reason = _infer_task(pd.Series([10, 20, 700, 10, 20, 700]))
_assert(classify is True and "categor" in reason.lower(), "sparse codes -> classification")
async def _imp_infer_many_integers() -> None:
classify, _ = _infer_task(pd.Series(range(50)))
_assert(classify is False, "50 distinct integers -> regression")
async def _imp_infer_all_null() -> None:
classify, reason = _infer_task(pd.Series([None, None, None], dtype="float64"))
_assert("no non-null" in reason.lower(), "all-null column is empty, not 'non-numeric'")
async def _imp_recovers_planted_signal() -> None:
rng = np.random.RandomState(0)
n = 120
driver = rng.normal(0, 1, n)
noise = rng.normal(0, 1, n)
target = 5.0 * driver + 0.1 * noise + rng.normal(0, 0.05, n)
df = pd.DataFrame({"driver": driver, "noise": noise, "target": target})
out = await analyze_importance(df, "target", feature_columns=["driver", "noise"])
_assert(out["task"] == "regression", "continuous target -> regression")
top = out["features"][0]["feature"]
_assert(top == "driver", f"planted driver must rank first, got {top}")
_assert(out["quality"]["weak"] is False, "a strong linear signal must not be weak")
# --------------------------------------------------------------------------- #
# analyze_pareto (D-6) #
# --------------------------------------------------------------------------- #
def _revenue() -> pd.DataFrame:
return pd.DataFrame({"region": ["A", "B", "C", "D", "A"], "revenue": [50, 10, 5, 5, 30]})
async def _pareto_additive_no_caveat() -> None:
out = analyze_pareto(_revenue(), "region", "revenue", agg="sum")
_assert(out["caveats"] == [], "sum on non-negative values -> no honesty caveat")
_assert(out["categories_for_80pct"] == 1, "A alone is 80% -> categories_for_80pct == 1")
async def _pareto_non_additive_warns() -> None:
out = analyze_pareto(_revenue(), "region", "revenue", agg="mean")
_assert(any("not additive" in c for c in out["caveats"]), "mean must be flagged non-additive")
async def _pareto_negative_warns() -> None:
df = pd.DataFrame({"region": ["A", "B", "C"], "delta": [50, -25, 10]})
out = analyze_pareto(df, "region", "delta", agg="sum")
_assert(any("negative" in c for c in out["caveats"]), "negative values must be flagged")
# --------------------------------------------------------------------------- #
# analyze_trend (D-7) #
# --------------------------------------------------------------------------- #
def _months(values: list[float]) -> pd.DataFrame:
dates = pd.date_range("2024-01-01", periods=len(values), freq="MS")
return pd.DataFrame({"date": dates, "v": values})
async def _trend_clean_agrees() -> None:
out = analyze_trend(_months([10, 20, 30, 40]), "date", "v", freq="month")
_assert(out["direction"] == "up" and out["slope"] > 0, "clean rise -> up, positive slope")
_assert(out["r_squared"] == 1.0, "perfect line -> R^2 == 1")
_assert(not any("Mixed signal" in c for c in out["caveats"]), "agreement -> no mixed caveat")
async def _trend_disagreement_flagged() -> None:
out = analyze_trend(_months([100, 40, 30, 20, 10, 110]), "date", "v", freq="month")
_assert(out["direction"] == "up", "endpoints go up (ends high)")
_assert(out["slope"] < 0, "OLS slope goes down")
_assert(any("Mixed signal" in c for c in out["caveats"]), "disagreement must be flagged")
# --------------------------------------------------------------------------- #
# analyze_cluster (D-10) #
# --------------------------------------------------------------------------- #
def _blobs(per: int = 12) -> pd.DataFrame:
rng = np.random.RandomState(0)
pts = np.vstack(
[rng.normal(c, 0.2, (per, 2)) for c in (0, 10, 20)]
)
return pd.DataFrame(pts, columns=["x", "y"])
async def _cluster_override_visible() -> None:
df = pd.DataFrame({"a": [1.0] * 25, "b": [2.0] * 25}) # identical -> collapse
out = await analyze_cluster(df, ["a", "b"], k=5)
_assert(out["requested_k"] == 5, "requested_k must echo the explicit k")
_assert(out["k"] != 5, "collapse must fall back to a different k")
_assert(any("Requested k=5" in c for c in out["caveats"]), "override must be visible")
async def _cluster_k_honoured() -> None:
out = await analyze_cluster(_blobs(10), ["x", "y"], k=3)
_assert(out["k"] == 3, "separable blobs -> explicit k=3 honoured")
_assert(not any("fell back" in c for c in out["caveats"]), "no fallback on a good fit")
async def _cluster_auto_finds_3() -> None:
out = await analyze_cluster(_blobs(12), ["x", "y"]) # no k
_assert(out["requested_k"] is None, "auto must report requested_k None")
_assert(out["k"] == 3, "silhouette must recover the 3 planted blobs")
# --------------------------------------------------------------------------- #
# Basic coverage — one truth assertion for every other ACTIVE tool #
# --------------------------------------------------------------------------- #
async def _descriptive_basic() -> None:
out = analyze_descriptive(pd.DataFrame({"revenue": [10, 20, 30, 40]}), ["revenue"])["revenue"]
_assert(out["mean"] == 25 and out["median"] == 25, "mean/median of 10..40 = 25")
_assert(out["min"] == 10 and out["max"] == 40, "min/max = 10/40")
async def _aggregate_groupby_sum() -> None:
df = pd.DataFrame({"region": ["A", "A", "B", "B"], "revenue": [10, 20, 100, 200]})
rows = analyze_aggregate(df, {"revenue": ["sum"]}, group_by=["region"])
by = {r["region"]: r for r in rows}
_assert(by["A"]["revenue_sum"] == 30 and by["B"]["revenue_sum"] == 300, "per-group sums")
async def _correlation_recovers_positive() -> None:
out = analyze_correlation(pd.DataFrame({"x": [1, 2, 3, 4], "y": [2, 4, 6, 8]}), ["x", "y"])
_assert(abs(out["matrix"]["x"]["y"] - 1.0) < 1e-9, "y=2x -> correlation 1.0")
async def _correlation_refuses_non_numeric() -> None:
df = pd.DataFrame({"x": [1, 2], "label": ["a", "b"]})
try:
analyze_correlation(df, ["x", "label"])
except NonNumericColumnError:
return
raise AssertionError("a non-numeric column must be refused")
async def _merge_aligns_two_metrics() -> None:
left = pd.DataFrame({"section": ["S1", "S2"], "pa": [90.0, 80.0]})
right = pd.DataFrame({"section": ["S1", "S2"], "backlog": [3, 7]})
rows = analyze_merge(left, right, on=["section"], how="inner")
by = {r["section"]: r for r in rows}
_assert(len(rows) == 2, "inner join on matched keys -> 2 rows")
_assert(by["S1"]["pa"] == 90.0 and by["S1"]["backlog"] == 3, "both metrics aligned per key")
def _ab(a_vals: list[float], b_vals: list[float]) -> pd.DataFrame:
return pd.DataFrame({"grp": ["A"] * len(a_vals) + ["B"] * len(b_vals), "val": a_vals + b_vals})
async def _ab_significant_difference() -> None:
out = analyze_ab_test(_ab([10, 11, 9, 10, 12], [20, 21, 19, 20, 22]), "grp", "val", "A", "B")
_assert(out["comparison"] == "higher", "B > A")
_assert(out["test"]["significant"] is True, "clearly separated groups are significant")
async def _ab_overlap_not_significant() -> None:
out = analyze_ab_test(_ab([10, 11, 9, 10], [10, 12, 9, 11]), "grp", "val", "A", "B")
_assert(out["test"]["significant"] is not True, "overlapping groups are not significant")
async def _segmentation_quantile_balanced() -> None:
df = pd.DataFrame({"age": list(range(12))})
out = analyze_segmentation(df, "age", bins=4, method="quantile")
counts = [s["count"] for s in out["segments"]]
_assert(len(out["segments"]) == 4, "4 quantile bins")
_assert(max(counts) - min(counts) <= 1, "quantile bins must be balanced (counts within 1)")
async def _forecast_trend_beats_naive() -> None:
dates = pd.date_range("2024-01-01", periods=18, freq="MS")
df = pd.DataFrame({"month": dates, "v": [100 + 10 * i for i in range(18)]})
out = await analyze_forecast(df, "month", "v", horizon=3, freq="month")
_assert(len(out["forecast"]) == 3 and out["direction"] == "up", "rising trend projected up")
q = out["quality"]
_assert(q["value"] is not None and q["value"] < 1.0, "clean trend -> MASE < 1")
_assert(q["weak"] is False, "a validated sub-1 MASE is not weak")
async def _forecast_short_series_not_validated() -> None:
dates = pd.date_range("2024-01-01", periods=6, freq="MS")
df = pd.DataFrame({"month": dates, "v": [100 + 5 * i for i in range(6)]})
out = await analyze_forecast(df, "month", "v", horizon=2, freq="month")
q = out["quality"]
_assert(q["value"] is None, "too-short series cannot be validated -> no MASE number")
_assert("not validated" in q["note"], "must say 'not validated', not a silent number")
_assert(q["weak"] is True, "an unvalidated forecast is weak evidence")
# --------------------------------------------------------------------------- #
# render_chart — computes no numbers; its truth is a valid envelope mapping #
# the requested columns #
# --------------------------------------------------------------------------- #
async def _chart_builds_envelope() -> None:
df = pd.DataFrame({"region": ["A", "B", "C"], "revenue": [30, 20, 10]})
env = render_chart(df, "bar", x="region", y="revenue")
_assert(env["schema"] == "dataeyond.chart.v1", "must be a dataeyond.chart.v1 envelope")
_assert(env["chart_type"] == "bar", "chart_type echoes the request")
trace = env["plotly"]["data"][0]
_assert(list(trace["x"]) == ["A", "B", "C"], "x maps the requested column")
_assert(list(trace["y"]) == [30, 20, 10], "y maps the requested column")
async def _chart_missing_column_raises() -> None:
df = pd.DataFrame({"region": ["A"], "revenue": [1]})
try:
render_chart(df, "bar", x="region", y="ghost")
except ColumnNotFoundError:
return
raise AssertionError("a missing chart column must raise ColumnNotFoundError")
def _mk(tool: str, *fns: Callable[[], Awaitable[None]]) -> list[EvalCase]:
"""Build cases for one tool; id + prop derive from each function's name."""
return [
EvalCase(f.__name__.lstrip("_"), tool, f.__name__.lstrip("_").replace("_", " "), f)
for f in fns
]
CASES: list[EvalCase] = [
*_mk(
"analyze_data_quality",
_dq_zero_dup_when_none,
_dq_counts_planted_dups,
_dq_subset_candidate_key,
),
*_mk(
"analyze_anomaly",
_an_names_rows_via_label,
_an_missing_label_raises,
_an_quality_present_not_weak,
_an_flag_rate_branches,
),
*_mk(
"analyze_importance",
_imp_infer_non_numeric,
_imp_infer_float,
_imp_infer_ordinal_integer,
_imp_infer_binary_flag,
_imp_infer_sparse_codes,
_imp_infer_many_integers,
_imp_infer_all_null,
_imp_recovers_planted_signal,
),
*_mk(
"analyze_pareto",
_pareto_additive_no_caveat,
_pareto_non_additive_warns,
_pareto_negative_warns,
),
*_mk(
"analyze_trend",
_trend_clean_agrees,
_trend_disagreement_flagged,
),
*_mk(
"analyze_cluster",
_cluster_override_visible,
_cluster_k_honoured,
_cluster_auto_finds_3,
),
*_mk("analyze_descriptive", _descriptive_basic),
*_mk("analyze_aggregate", _aggregate_groupby_sum),
*_mk(
"analyze_correlation",
_correlation_recovers_positive,
_correlation_refuses_non_numeric,
),
*_mk("analyze_merge", _merge_aligns_two_metrics),
*_mk(
"analyze_ab_test",
_ab_significant_difference,
_ab_overlap_not_significant,
),
*_mk("analyze_segmentation", _segmentation_quantile_balanced),
*_mk(
"analyze_forecast",
_forecast_trend_beats_naive,
_forecast_short_series_not_validated,
),
*_mk(
"render_chart",
_chart_builds_envelope,
_chart_missing_column_raises,
),
]