Spaces:
Sleeping
Sleeping
| """Unit tests for `lexsi_ds.agent.tools.train_tabular_model`. | |
| Covers: | |
| - happy path (XGBoost default, foundation model, sample_percentage) | |
| - missing context df → missing_df | |
| - target column not in df → bad_target | |
| - all-null target → empty_target | |
| - no entity column resolvable → no_entity | |
| - foundation model without compute_type → missing_compute_type | |
| - no tab_project → no_tab_project | |
| - cache contract: last_model_id, last_train_tag, last_model_task, | |
| last_train_df_label are populated correctly. | |
| """ | |
| from __future__ import annotations | |
| import pandas as pd | |
| import pytest | |
| from lexsi_ds.agent.tools.train_tabular_model import ( | |
| TOOL, | |
| TrainTabularModelArgs, | |
| _build_project_config, | |
| _mint_train_tag, | |
| _resolve_active_model_name, | |
| _resolve_entity_col, | |
| ) | |
| # ----- happy path ----- | |
| def test_happy_path_xgboost(ctx_with_fake, seed_sql_result, loan_context_df): | |
| seed_sql_result(ctx_with_fake, "ctx_df", loan_context_df) | |
| args = TrainTabularModelArgs( | |
| df_label="ctx_df", | |
| target_column="status", | |
| entity_column="loan_id", | |
| task_type="classification", | |
| ) | |
| out = TOOL.run(args, ctx_with_fake) | |
| assert out.ok, out.summary | |
| assert out.payload["model_type"] == "XGBoost" | |
| assert out.payload["target_column"] == "status" | |
| assert out.payload["entity_column"] == "loan_id" | |
| assert out.payload["model_id"].startswith("XGBoost_") | |
| # Cache contract | |
| assert ctx_with_fake.cache["last_model_id"] == out.payload["model_id"] | |
| assert ctx_with_fake.cache["last_train_tag"] == out.payload["tag"] | |
| assert ctx_with_fake.cache["last_train_df_label"] == "ctx_df" | |
| assert ctx_with_fake.cache["last_model_task"]["target_column"] == "status" | |
| def test_happy_path_foundation_model_requires_compute( | |
| ctx_with_fake, seed_sql_result, loan_context_df | |
| ): | |
| seed_sql_result(ctx_with_fake, "ctx_df", loan_context_df) | |
| args = TrainTabularModelArgs( | |
| df_label="ctx_df", | |
| target_column="status", | |
| entity_column="loan_id", | |
| model_type="TabPFN", | |
| compute_type="small", | |
| ) | |
| out = TOOL.run(args, ctx_with_fake) | |
| assert out.ok, out.summary | |
| assert out.payload["model_type"] == "TabPFN" | |
| assert "TabPFN" in out.payload["model_id"] | |
| def test_happy_path_sample_percentage_passthrough( | |
| ctx_with_fake, seed_sql_result, loan_context_df | |
| ): | |
| seed_sql_result(ctx_with_fake, "ctx_df", loan_context_df) | |
| args = TrainTabularModelArgs( | |
| df_label="ctx_df", | |
| target_column="status", | |
| entity_column="loan_id", | |
| sample_percentage=0.5, | |
| ) | |
| out = TOOL.run(args, ctx_with_fake) | |
| assert out.ok | |
| # ----- error branches ----- | |
| def test_missing_context_df(ctx_with_fake): | |
| args = TrainTabularModelArgs( | |
| df_label="not_present", | |
| target_column="status", | |
| entity_column="loan_id", | |
| ) | |
| out = TOOL.run(args, ctx_with_fake) | |
| assert not out.ok | |
| assert out.error == "missing_df" | |
| assert "no DataFrame cached" in out.summary | |
| def test_bad_target_column(ctx_with_fake, seed_sql_result, loan_context_df): | |
| seed_sql_result(ctx_with_fake, "ctx_df", loan_context_df) | |
| args = TrainTabularModelArgs( | |
| df_label="ctx_df", | |
| target_column="nonexistent", | |
| entity_column="loan_id", | |
| ) | |
| out = TOOL.run(args, ctx_with_fake) | |
| assert not out.ok | |
| assert out.error == "bad_target" | |
| def test_empty_target_column(ctx_with_fake, seed_sql_result, loan_context_df): | |
| df = loan_context_df.copy() | |
| df["status"] = None | |
| seed_sql_result(ctx_with_fake, "ctx_df", df) | |
| args = TrainTabularModelArgs( | |
| df_label="ctx_df", | |
| target_column="status", | |
| entity_column="loan_id", | |
| ) | |
| out = TOOL.run(args, ctx_with_fake) | |
| assert not out.ok | |
| assert out.error == "empty_target" | |
| def test_foundation_model_without_compute_type( | |
| ctx_with_fake, seed_sql_result, loan_context_df | |
| ): | |
| seed_sql_result(ctx_with_fake, "ctx_df", loan_context_df) | |
| args = TrainTabularModelArgs( | |
| df_label="ctx_df", | |
| target_column="status", | |
| entity_column="loan_id", | |
| model_type="OrionMSP", | |
| ) | |
| out = TOOL.run(args, ctx_with_fake) | |
| assert not out.ok | |
| assert out.error == "missing_compute_type" | |
| assert "OrionMSP" in out.summary | |
| def test_no_tab_project(ctx_offline, seed_sql_result, loan_context_df): | |
| seed_sql_result(ctx_offline, "ctx_df", loan_context_df) | |
| args = TrainTabularModelArgs( | |
| df_label="ctx_df", | |
| target_column="status", | |
| entity_column="loan_id", | |
| ) | |
| out = TOOL.run(args, ctx_offline) | |
| assert not out.ok | |
| assert out.error == "no_tab_project" | |
| # ----- helpers ----- | |
| def test_resolve_entity_col_explicit(loan_context_df): | |
| args = TrainTabularModelArgs( | |
| df_label="x", target_column="status", entity_column="loan_id" | |
| ) | |
| col, warn = _resolve_entity_col(args, loan_context_df) | |
| assert col == "loan_id" | |
| assert warn is None | |
| def test_resolve_entity_col_auto_pick(loan_context_df): | |
| args = TrainTabularModelArgs(df_label="x", target_column="status") | |
| col, warn = _resolve_entity_col(args, loan_context_df) | |
| assert col == "loan_id" | |
| assert warn is not None and "auto-picked" in warn | |
| def test_resolve_entity_col_no_unique_column(): | |
| df = pd.DataFrame({"a": [1, 1, 1], "b": ["x", "x", "x"], "y": [0, 1, 0]}) | |
| args = TrainTabularModelArgs(df_label="x", target_column="y") | |
| col, warn = _resolve_entity_col(args, df) | |
| assert col is None | |
| def test_mint_train_tag_is_safe(): | |
| """Lexsi rejects ANY non-alphanumeric in tag names (including `_`). | |
| Strip everything except [a-zA-Z0-9]; uniqueness comes from run_id.""" | |
| tag = _mint_train_tag("run123", "ctx-df with spaces & chars!") | |
| assert tag.isalnum(), f"non-alphanumeric chars in tag: {tag!r}" | |
| assert tag.startswith("agenttrain") | |
| assert "run123" in tag # run_id preserved | |
| def test_build_project_config_has_required_keys(): | |
| """ProjectConfig dict carries the identifier / hygiene / sampling / XAI | |
| keys. `pred_label` is intentionally omitted (no longer required by the | |
| current lexsi-sdk upload+train path).""" | |
| cfg = _build_project_config( | |
| unique_identifier="loan_id", | |
| true_label="status", | |
| tag="agent_train_x", | |
| model_name="XGBoost", | |
| sample_percentage=None, | |
| ) | |
| expected_keys = { | |
| "unique_identifier", "true_label", "tag", "model_name", | |
| "feature_exclude", "feature_encodings", "drop_duplicate_uid", | |
| "handle_errors", "handle_data_imbalance", "sample_percentage", "xai_method", | |
| } | |
| assert set(cfg.keys()) == expected_keys, ( | |
| f"ProjectConfig key set drift detected. " | |
| f"Missing: {expected_keys - set(cfg.keys())}, " | |
| f"Extra: {set(cfg.keys()) - expected_keys}" | |
| ) | |
| # Values | |
| assert cfg["unique_identifier"] == "loan_id" | |
| assert cfg["true_label"] == "status" | |
| assert cfg["tag"] == "agent_train_x" | |
| # XAI defaults to [] — training-time SHAP fails on fresh projects | |
| # with "could not convert string to float: 'class_X'" (activity log | |
| # #05-013). Per-case SHAP runs later via case_predict. | |
| assert cfg["xai_method"] == [] | |
| # pred_label is no longer emitted — the SDK doesn't require it. | |
| assert "pred_label" not in cfg | |
| assert cfg["sample_percentage"] is None | |
| def test_build_project_config_includes_sample_percentage(): | |
| cfg = _build_project_config( | |
| unique_identifier="loan_id", | |
| true_label="status", | |
| tag="x", | |
| model_name="XGBoost", | |
| sample_percentage=0.5, | |
| ) | |
| assert cfg["sample_percentage"] == 0.5 | |
| def test_resolve_active_model_name_uses_models_fallback(fake_tab_project, loan_context_df): | |
| # Train one model | |
| fake_tab_project.upload_data( | |
| loan_context_df, tag="t1", | |
| config={"unique_identifier": "loan_id", "true_label": "status"}, | |
| ) | |
| name = _resolve_active_model_name(fake_tab_project) | |
| assert name.startswith("XGBoost_") | |