lexsi-ds-agent / tests /test_p2_feature_engineering.py
bp-lexsi's picture
Add P1/P2 agent tools: data-quality, sampling, join-paths, feature/target engineering
8bf2593
Raw
History Blame Contribute Delete
16.5 kB
"""Tests for the P2 feature & target engineering tools (owner: Aditya).
Fully hermetic β€” in-memory DuckDB only, no PKDD DuckDB file, no Lexsi
SDK, no LLM. Reuses the offline-synthetic fixture family from
`conftest.py` (`ctx_offline`, `loan_context_df`, `seed_sql_result`) and
adds one temporal fixture local to this module.
Covers, per the sprint definition of done:
* happy path + error paths for each tool;
* the cache-key contracts (`last_split`, `last_target_definition`,
`last_imbalance_report`, in-place `sql_result:<label>` mutation);
* the feature-lineage audit log;
* registry wiring (all four tools present and Tool-protocol-shaped).
"""
from __future__ import annotations
import pandas as pd
import pytest
from lexsi_ds.agent.tools import REGISTRY, Tool
from lexsi_ds.agent.tools.define_target import TOOL as define_target_tool
from lexsi_ds.agent.tools.define_target import DefineTargetArgs
from lexsi_ds.agent.tools.derive_feature import TOOL as derive_feature_tool
from lexsi_ds.agent.tools.derive_feature import DeriveFeatureArgs
from lexsi_ds.agent.tools.handle_imbalance import TOOL as handle_imbalance_tool
from lexsi_ds.agent.tools.handle_imbalance import HandleImbalanceArgs
from lexsi_ds.agent.tools.time_split import TOOL as time_split_tool
from lexsi_ds.agent.tools.time_split import TimeSplitArgs
P2_TOOLS = ["derive_feature", "define_target", "time_split", "handle_imbalance"]
@pytest.fixture
def temporal_loan_df() -> pd.DataFrame:
"""120 loans across 2016-2019 with a label that drifts over time β€”
the shape the leakage scenario needs."""
import random
rng = random.Random(13)
rows = []
for i in range(120):
year = 2016 + i // 30
month = 1 + (i * 7) % 12
amount = rng.randint(5_000, 200_000)
duration = rng.choice([12, 24, 36, 48, 60])
# default rate drifts upward over the years (temporal signal)
p_default = 0.05 + 0.08 * (year - 2016) + 0.2 * (amount / 200_000)
status = "B" if rng.random() < p_default else "A"
rows.append({
"loan_id": 2000 + i,
"granted_date": f"{year}-{month:02d}-15",
"amount": amount,
"duration": duration,
"payments": round(amount / duration, 2),
"status": status,
})
return pd.DataFrame(rows)
# ===========================================================================
# registry wiring
# ===========================================================================
def test_p2_tools_registered_and_protocol_shaped():
for name in P2_TOOLS:
assert name in REGISTRY, f"{name} missing from REGISTRY"
tool = REGISTRY[name]
assert isinstance(tool, Tool)
assert tool.spec.name == name
assert tool.spec.description and tool.spec.returns
# ===========================================================================
# derive_feature
# ===========================================================================
def test_derive_feature_duckdb_happy(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = derive_feature_tool.run(
DeriveFeatureArgs(df_label="loans", name="payment_burden",
expression="payments / amount"),
ctx_offline,
)
assert res.ok, res.summary
df = ctx_offline.cache["sql_result:loans"]
assert "payment_burden" in df.columns
assert len(df) == len(loan_context_df)
assert "payment_burden" in res.summary
# lineage recorded
lineage = ctx_offline.cache["feature_lineage"]
assert lineage[-1]["tool"] == "derive_feature"
assert lineage[-1]["column"] == "payment_burden"
def test_derive_feature_pandas_engine(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = derive_feature_tool.run(
DeriveFeatureArgs(df_label="loans", name="amt_per_month",
expression="amount / duration", engine="pandas"),
ctx_offline,
)
assert res.ok, res.summary
assert "amt_per_month" in ctx_offline.cache["sql_result:loans"].columns
def test_derive_feature_unknown_label(ctx_offline):
res = derive_feature_tool.run(
DeriveFeatureArgs(df_label="nope", name="x", expression="1 + 1"),
ctx_offline,
)
assert not res.ok
assert res.error == "unknown_label"
def test_derive_feature_unknown_column_lists_available(
ctx_offline, loan_context_df, seed_sql_result
):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = derive_feature_tool.run(
DeriveFeatureArgs(df_label="loans", name="risk_score",
expression="loan_amount / monthly_income"),
ctx_offline,
)
assert not res.ok
assert "Available columns" in res.summary
assert "risk_score" not in ctx_offline.cache["sql_result:loans"].columns
def test_derive_feature_overwrite_guard(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = derive_feature_tool.run(
DeriveFeatureArgs(df_label="loans", name="amount", expression="amount * 2"),
ctx_offline,
)
assert not res.ok and res.error == "column_exists"
res2 = derive_feature_tool.run(
DeriveFeatureArgs(df_label="loans", name="amount",
expression="amount * 2", overwrite=True),
ctx_offline,
)
assert res2.ok, res2.summary
def test_derive_feature_blocks_statement_sql(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = derive_feature_tool.run(
DeriveFeatureArgs(df_label="loans", name="x",
expression="1; DROP TABLE loans"),
ctx_offline,
)
assert not res.ok and res.error == "forbidden_expression"
def test_derive_feature_rejects_aggregate(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = derive_feature_tool.run(
DeriveFeatureArgs(df_label="loans", name="total", expression="SUM(amount)"),
ctx_offline,
)
assert not res.ok # 1 value for 60 rows β†’ non_rowwise_expression
assert res.error == "non_rowwise_expression"
# ===========================================================================
# time_split
# ===========================================================================
def test_time_split_cutoff_happy(ctx_offline, temporal_loan_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
res = time_split_tool.run(
TimeSplitArgs(df_label="loans", time_col="granted_date",
cutoff="2018-01-01"),
ctx_offline,
)
assert res.ok, res.summary
train = ctx_offline.cache["sql_result:train"]
test = ctx_offline.cache["sql_result:test"]
assert len(train) + len(test) == len(temporal_loan_df)
# no temporal overlap β€” the anti-leakage invariant
assert pd.to_datetime(train["granted_date"]).max() < pd.to_datetime(
test["granted_date"]
).min()
split = ctx_offline.cache["last_split"]
assert split["kind"] == "time"
assert split["n_train"] == len(train) and split["n_test"] == len(test)
def test_time_split_fraction_mode(ctx_offline, temporal_loan_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
res = time_split_tool.run(
TimeSplitArgs(df_label="loans", time_col="granted_date",
test_fraction=0.25, train_label="tr", test_label="te"),
ctx_offline,
)
assert res.ok, res.summary
te = ctx_offline.cache["sql_result:te"]
assert 0 < len(te) <= int(0.35 * len(temporal_loan_df)) # ties allowed
def test_time_split_requires_exactly_one_mode(ctx_offline, temporal_loan_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
both = time_split_tool.run(
TimeSplitArgs(df_label="loans", time_col="granted_date",
cutoff="2018-01-01", test_fraction=0.2),
ctx_offline,
)
neither = time_split_tool.run(
TimeSplitArgs(df_label="loans", time_col="granted_date"),
ctx_offline,
)
assert not both.ok and both.error == "bad_args"
assert not neither.ok and neither.error == "bad_args"
def test_time_split_degenerate_cutoff(ctx_offline, temporal_loan_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
res = time_split_tool.run(
TimeSplitArgs(df_label="loans", time_col="granted_date",
cutoff="1990-01-01"),
ctx_offline,
)
assert not res.ok and res.error == "degenerate_split"
assert "spans" in res.summary # reports the real range for recovery
def test_time_split_non_time_column(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = time_split_tool.run(
TimeSplitArgs(df_label="loans", time_col="status", cutoff="2018-01-01"),
ctx_offline,
)
assert not res.ok and res.error == "bad_time_column"
def test_time_split_small_side_warning(ctx_offline, temporal_loan_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
res = time_split_tool.run(
TimeSplitArgs(df_label="loans", time_col="granted_date",
cutoff="2019-11-01"),
ctx_offline,
)
assert res.ok
assert "Warnings" in res.summary # tiny test side
# ===========================================================================
# define_target
# ===========================================================================
def test_define_target_rule_with_scope(ctx_offline, temporal_loan_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
res = define_target_tool.run(
DefineTargetArgs(df_label="loans", target_column="y_default",
positive_definition="status = 'B'",
scope_filter="status IN ('A','B')",
entity_column="loan_id"),
ctx_offline,
)
assert res.ok, res.summary
df = ctx_offline.cache["sql_result:loans"]
assert set(df["y_default"].unique()) == {0, 1}
definition = ctx_offline.cache["last_target_definition"]
assert definition["target_column"] == "y_default"
assert definition["entity_column"] == "loan_id"
assert definition["class_balance"] is not None
assert "Class balance" in res.summary
def test_define_target_existing_numeric_regression(
ctx_offline, loan_context_df, seed_sql_result
):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = define_target_tool.run(
DefineTargetArgs(df_label="loans", target_column="amount",
task_type="regression"),
ctx_offline,
)
assert res.ok, res.summary
assert "range" in res.summary.lower()
def test_define_target_single_class_error(ctx_offline, temporal_loan_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
res = define_target_tool.run(
DefineTargetArgs(df_label="loans", target_column="y",
positive_definition="status = 'X'",
scope_filter="status = 'A'"),
ctx_offline,
)
assert not res.ok and res.error == "single_class_target"
def test_define_target_unknown_existing_column(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = define_target_tool.run(
DefineTargetArgs(df_label="loans", target_column="not_there"),
ctx_offline,
)
assert not res.ok and res.error == "unknown_column"
def test_define_target_regression_non_numeric(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = define_target_tool.run(
DefineTargetArgs(df_label="loans", target_column="status",
task_type="regression"),
ctx_offline,
)
assert not res.ok and res.error == "non_numeric_target"
# ===========================================================================
# handle_imbalance
# ===========================================================================
def test_handle_imbalance_severe(ctx_offline, seed_sql_result):
df = pd.DataFrame({
"y": [1] * 8 + [0] * 192,
"x": range(200),
})
seed_sql_result(ctx_offline, "loans", df)
res = handle_imbalance_tool.run(
HandleImbalanceArgs(df_label="loans", target_column="y"),
ctx_offline,
)
assert res.ok, res.summary
report = ctx_offline.cache["last_imbalance_report"]
assert report["severity"] == "severe"
assert report["imbalance_ratio"] == 24.0
assert report["strategies"][0]["strategy"] == "class_weights"
# offline β‡’ no Lexsi synthetic option in the proposal
assert all(
s["strategy"] != "lexsi_synthetic_augmentation"
for s in report["strategies"]
)
assert "AUROC" in res.summary
def test_handle_imbalance_balanced(ctx_offline, seed_sql_result):
df = pd.DataFrame({"y": [0, 1] * 50})
seed_sql_result(ctx_offline, "d", df)
res = handle_imbalance_tool.run(
HandleImbalanceArgs(df_label="d", target_column="y"), ctx_offline
)
assert res.ok
assert ctx_offline.cache["last_imbalance_report"]["severity"] == "balanced"
assert res.payload["strategies"][0]["strategy"] == "none"
def test_handle_imbalance_defaults_target_from_definition(
ctx_offline, temporal_loan_df, seed_sql_result
):
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
define_target_tool.run(
DefineTargetArgs(df_label="loans", target_column="y_default",
positive_definition="status = 'B'"),
ctx_offline,
)
res = handle_imbalance_tool.run(
HandleImbalanceArgs(df_label="loans"), ctx_offline # no target arg
)
assert res.ok, res.summary
assert res.payload["target_column"] == "y_default"
def test_handle_imbalance_continuous_target_error(
ctx_offline, loan_context_df, seed_sql_result
):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = handle_imbalance_tool.run(
HandleImbalanceArgs(df_label="loans", target_column="amount"),
ctx_offline,
)
assert not res.ok and res.error == "non_categorical_target"
def test_handle_imbalance_no_target_anywhere(ctx_offline, loan_context_df, seed_sql_result):
seed_sql_result(ctx_offline, "loans", loan_context_df)
res = handle_imbalance_tool.run(
HandleImbalanceArgs(df_label="loans"), ctx_offline
)
assert not res.ok and res.error == "no_target"
# ===========================================================================
# end-to-end P2 chain + lineage (the demo-step-3 shape, offline)
# ===========================================================================
def test_p2_chain_lineage(ctx_offline, temporal_loan_df, seed_sql_result):
"""run_sql β†’ derive_feature β†’ define_target β†’ time_split β†’
handle_imbalance leaves a complete, ordered audit trail."""
seed_sql_result(ctx_offline, "loans", temporal_loan_df)
assert derive_feature_tool.run(
DeriveFeatureArgs(df_label="loans", name="payment_burden",
expression="payments / amount"),
ctx_offline,
).ok
assert define_target_tool.run(
DefineTargetArgs(df_label="loans", target_column="y_default",
positive_definition="status = 'B'",
entity_column="loan_id"),
ctx_offline,
).ok
assert time_split_tool.run(
TimeSplitArgs(df_label="loans", time_col="granted_date",
cutoff="2018-01-01"),
ctx_offline,
).ok
assert handle_imbalance_tool.run(
HandleImbalanceArgs(df_label="train"), ctx_offline
).ok
lineage = ctx_offline.cache["feature_lineage"]
assert [r["tool"] for r in lineage] == [
"derive_feature", "define_target", "time_split", "handle_imbalance",
]
assert [r["step"] for r in lineage] == [1, 2, 3, 4]
# the train slice carries both the derived feature and the target
train = ctx_offline.cache["sql_result:train"]
assert {"payment_burden", "y_default"} <= set(train.columns)