CoolFace
Apppublic

ritvik360/nl2sql-bench

sourceHugging Faceupdated 6mo agoView on Hugging Face
0likes
test_all.py494 linesDownload Raw Back to tests
1"""2nl2sql-bench/tests/test_all.py3================================4Comprehensive test suite covering:5  - Database seeder (determinism + row counts)6  - Grader (all reward components, step penalty, edge cases)7  - Task registry (all 3 tasks load and produce valid examples)8  - Environment (reset, step, episode boundary, done logic)9  - Inference log format (regex checks on START / STEP / END)10 11Run with:12    pytest tests/ -v13or from project root:14    PYTHONPATH=.:server pytest tests/ -v15"""16 17from __future__ import annotations18 19import re20import sqlite321import sys22import os23from pathlib import Path24 25import pytest26 27# ── Path setup so tests can import from both project root and server/ ──────28ROOT   = Path(__file__).parent.parent29SERVER = ROOT / "server"30sys.path.insert(0, str(ROOT))31sys.path.insert(0, str(SERVER))32 33# ── Fixtures ───────────────────────────────────────────────────────────────34 35@pytest.fixture(scope="session")36def db_conn():37    """Shared in-memory SQLite connection with full schema + seed data."""38    from db.seed import seed_database39    schema = (SERVER / "db" / "schema.sql").read_text()40    conn = sqlite3.connect(":memory:", check_same_thread=False)41    conn.row_factory = sqlite3.Row42    conn.executescript(schema)43    seed_database(conn)44    yield conn45    conn.close()46 47 48@pytest.fixture49def fresh_env():50    """A fresh NL2SQLEnvironment instance per test."""51    from environment import NL2SQLEnvironment52    return NL2SQLEnvironment()53 54 55# ══════════════════════════════════════════════════════════════════════════════56# 1. DATABASE SEEDER57# ══════════════════════════════════════════════════════════════════════════════58 59class TestSeeder:60 61    def test_categories_count(self, db_conn):62        row = db_conn.execute("SELECT COUNT(*) FROM categories").fetchone()63        assert row[0] == 8, "Should have exactly 8 categories"64 65    def test_products_count(self, db_conn):66        row = db_conn.execute("SELECT COUNT(*) FROM products").fetchone()67        assert row[0] == 64, "Should have 8 products × 8 categories = 64"68 69    def test_customers_count(self, db_conn):70        row = db_conn.execute("SELECT COUNT(*) FROM customers").fetchone()71        assert row[0] == 15072 73    def test_orders_exist(self, db_conn):74        row = db_conn.execute("SELECT COUNT(*) FROM orders").fetchone()75        assert row[0] > 100, "Should have a meaningful number of orders"76 77    def test_order_items_exist(self, db_conn):78        row = db_conn.execute("SELECT COUNT(*) FROM order_items").fetchone()79        assert row[0] > 20080 81    def test_reviews_exist(self, db_conn):82        row = db_conn.execute("SELECT COUNT(*) FROM reviews").fetchone()83        assert row[0] > 5084 85    def test_determinism(self, db_conn):86        """Seeding a second connection with the same seed gives identical counts."""87        from db.seed import seed_database88        schema = (SERVER / "db" / "schema.sql").read_text()89        conn2 = sqlite3.connect(":memory:")90        conn2.executescript(schema)91        seed_database(conn2)92 93        for tbl in ["categories", "products", "customers", "orders",94                    "order_items", "reviews"]:95            c1 = db_conn.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0]96            c2 = conn2.execute(f"SELECT COUNT(*) FROM {tbl}").fetchone()[0]97            assert c1 == c2, f"Table {tbl} count mismatch: {c1} vs {c2}"98        conn2.close()99 100    def test_tiers_valid(self, db_conn):101        bad = db_conn.execute(102            "SELECT COUNT(*) FROM customers WHERE tier NOT IN ('bronze','silver','gold')"103        ).fetchone()[0]104        assert bad == 0105 106    def test_statuses_valid(self, db_conn):107        bad = db_conn.execute(108            "SELECT COUNT(*) FROM orders "109            "WHERE status NOT IN ('pending','processing','shipped','delivered','cancelled')"110        ).fetchone()[0]111        assert bad == 0112 113    def test_ratings_valid(self, db_conn):114        bad = db_conn.execute(115            "SELECT COUNT(*) FROM reviews WHERE rating < 1 OR rating > 5"116        ).fetchone()[0]117        assert bad == 0118 119    def test_referential_integrity(self, db_conn):120        """Order items should reference valid orders and products."""121        orphan_orders = db_conn.execute(122            "SELECT COUNT(*) FROM order_items oi "123            "LEFT JOIN orders o ON o.id = oi.order_id WHERE o.id IS NULL"124        ).fetchone()[0]125        assert orphan_orders == 0126 127        orphan_products = db_conn.execute(128            "SELECT COUNT(*) FROM order_items oi "129            "LEFT JOIN products p ON p.id = oi.product_id WHERE p.id IS NULL"130        ).fetchone()[0]131        assert orphan_products == 0132 133 134# ══════════════════════════════════════════════════════════════════════════════135# 2. GRADER136# ══════════════════════════════════════════════════════════════════════════════137 138class TestGrader:139 140    def test_exact_match_first_step(self):141        from grader import grade142        gt = [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]143        result = grade(144            actual_rows=gt.copy(),145            ground_truth_rows=gt,146            error=None,147            step=1,148            order_sensitive=False,149        )150        assert result.reward == pytest.approx(1.0)151        assert result.exact_match is True152        assert result.syntax_ok is True153        assert result.columns_match is True154        assert result.row_count_match is True155        assert result.step_penalty == 0.0156 157    def test_syntax_error_gives_zero(self):158        from grader import grade159        result = grade(160            actual_rows=None,161            ground_truth_rows=[{"x": 1}],162            error="near 'SELCT': syntax error",163            step=1,164        )165        assert result.reward == 0.0166        assert result.syntax_ok is False167 168    def test_step_penalty_applied(self):169        from grader import grade170        gt = [{"n": 1}]171        result = grade(172            actual_rows=gt.copy(),173            ground_truth_rows=gt,174            error=None,175            step=3,   # penalty = (3-1)*0.05 = 0.10176        )177        assert result.reward == pytest.approx(1.0 - 0.10)178        assert result.step_penalty == pytest.approx(0.10)179 180    def test_columns_wrong_zero_higher_components(self):181        from grader import grade182        gt   = [{"name": "Alice", "score": 10}]183        actual = [{"user": "Alice", "points": 10}]  # wrong column names184        result = grade(actual_rows=actual, ground_truth_rows=gt, error=None, step=1)185        assert result.columns_match is False186        assert result.exact_match is False187        # Only syntax score: 0.10188        assert result.reward == pytest.approx(0.10)189 190    def test_correct_columns_wrong_rows(self):191        from grader import grade192        gt     = [{"name": "Alice"}, {"name": "Bob"}]193        actual = [{"name": "Charlie"}, {"name": "Dave"}]194        result = grade(actual_rows=actual, ground_truth_rows=gt, error=None, step=1)195        assert result.columns_match is True196        assert result.row_count_match is True197        assert result.exact_match is False198        # syntax(0.10) + columns(0.20) + row_count(0.20) = 0.50199        assert result.reward == pytest.approx(0.50)200 201    def test_order_sensitive_wrong_order_is_not_exact(self):202        from grader import grade203        gt     = [{"id": 1}, {"id": 2}]204        actual = [{"id": 2}, {"id": 1}]  # reversed205        result = grade(206            actual_rows=actual,207            ground_truth_rows=gt,208            error=None,209            step=1,210            order_sensitive=True,211        )212        assert result.exact_match is False213 214    def test_order_insensitive_accepts_different_row_order(self):215        from grader import grade216        gt     = [{"id": 1}, {"id": 2}]217        actual = [{"id": 2}, {"id": 1}]  # different order but same content218        result = grade(219            actual_rows=actual,220            ground_truth_rows=gt,221            error=None,222            step=1,223            order_sensitive=False,224        )225        assert result.exact_match is True226 227    def test_penalty_never_makes_reward_negative(self):228        from grader import grade229        # Step 99 with syntax error → reward must be >= 0230        result = grade(231            actual_rows=None,232            ground_truth_rows=[{"x": 1}],233            error="some error",234            step=99,235        )236        assert result.reward >= 0.0237 238    def test_execute_query_blocks_writes(self, db_conn):239        from grader import execute_query240        rows, err = execute_query(db_conn, "INSERT INTO categories(name) VALUES ('x')")241        assert rows is None242        assert "not allowed" in err.lower() or "INSERT" in err243 244    def test_execute_query_returns_rows(self, db_conn):245        from grader import execute_query246        rows, err = execute_query(db_conn, "SELECT id, name FROM categories ORDER BY id")247        assert err is None248        assert len(rows) == 8249        assert "id" in rows[0]250        assert "name" in rows[0]251 252    def test_compute_ground_truth(self, db_conn):253        from grader import compute_ground_truth254        rows = compute_ground_truth(db_conn, "SELECT COUNT(*) AS n FROM customers")255        assert len(rows) == 1256        assert rows[0]["n"] == 150257 258 259# ══════════════════════════════════════════════════════════════════════════════260# 3. TASK REGISTRY261# ══════════════════════════════════════════════════════════════════════════════262 263class TestTasks:264 265    def test_all_tasks_registered(self):266        from tasks import all_task_names267        names = all_task_names()268        assert "simple-filter"    in names269        assert "join-aggregation" in names270        assert "analytics-window" in names271 272    @pytest.mark.parametrize("task_name", [273        "simple-filter", "join-aggregation", "analytics-window"274    ])275    def test_task_has_examples(self, task_name):276        from tasks import get_task277        task = get_task(task_name)278        assert len(task.examples) >= 3, f"{task_name} needs at least 3 examples"279 280    @pytest.mark.parametrize("task_name", [281        "simple-filter", "join-aggregation", "analytics-window"282    ])283    def test_task_sql_runs_on_real_db(self, task_name, db_conn):284        """Every ground-truth SQL must execute cleanly against the seeded DB."""285        from tasks import get_task286        from grader import execute_query287        task = get_task(task_name)288        for ex in task.examples:289            rows, error = execute_query(db_conn, ex.sql)290            assert error is None, (291                f"Task {task_name!r} SQL failed:\n{ex.sql}\nError: {error}"292            )293            assert rows is not None294 295    @pytest.mark.parametrize("task_name", [296        "simple-filter", "join-aggregation", "analytics-window"297    ])298    def test_task_roundrobin(self, task_name):299        from tasks import get_task300        task = get_task(task_name)301        n = len(task.examples)302        seen = [task.next_example() for _ in range(n * 2)]303        # After n calls, second half should repeat first half304        assert seen[:n] == seen[n:]305 306    def test_schema_context_non_empty(self):307        from tasks import get_task308        task = get_task("simple-filter")309        ctx = task.schema_context()310        assert "customers" in ctx311        assert "orders" in ctx312        assert "products" in ctx313 314 315# ══════════════════════════════════════════════════════════════════════════════316# 4. ENVIRONMENT317# ══════════════════════════════════════════════════════════════════════════════318 319class TestEnvironment:320 321    def test_reset_returns_observation(self, fresh_env):322        obs = fresh_env.reset(task_name="simple-filter")323        assert obs.question != ""324        assert obs.schema_context != ""325        assert obs.task_name == "simple-filter"326        assert obs.done is False327        assert obs.step == 0328        assert obs.reward is None329 330    def test_reset_state(self, fresh_env):331        fresh_env.reset(task_name="join-aggregation")332        state = fresh_env.state333        assert state.task_name == "join-aggregation"334        assert state.task_difficulty == "medium"335        assert state.step_count == 0336        assert state.solved is False337 338    def test_step_increments_step_count(self, fresh_env):339        from models import NL2SQLAction340        fresh_env.reset(task_name="simple-filter")341        fresh_env.step(NL2SQLAction(query="SELECT 1"))342        assert fresh_env.state.step_count == 1343 344    def test_step_syntax_error_gives_nonzero_error(self, fresh_env):345        from models import NL2SQLAction346        fresh_env.reset(task_name="simple-filter")347        obs = fresh_env.step(NL2SQLAction(query="SELCT * FORM broken"))348        assert obs.last_error is not None349        assert obs.reward == 0.0350 351    def test_step_valid_query_returns_result(self, fresh_env):352        from models import NL2SQLAction353        fresh_env.reset(task_name="simple-filter")354        obs = fresh_env.step(NL2SQLAction(355            query="SELECT id, name FROM customers ORDER BY name LIMIT 5"356        ))357        assert obs.last_error is None358        assert len(obs.last_result) <= 5359        assert obs.reward >= 0.0360 361    def test_exact_match_ends_episode(self, fresh_env):362        """Submitting the exact ground-truth SQL should solve the episode."""363        from models import NL2SQLAction364        fresh_env.reset(task_name="simple-filter")365        # Get the ground truth SQL from the internal example366        gt_sql = fresh_env._example.sql367        obs = fresh_env.step(NL2SQLAction(query=gt_sql))368        assert obs.done is True369        assert fresh_env.state.solved is True370        assert obs.reward == pytest.approx(1.0)  # step 1, full score371 372    def test_max_steps_ends_episode(self, fresh_env):373        """Exhausting all steps should end the episode even without solving."""374        from models import NL2SQLAction375        from environment import MAX_STEPS376        fresh_env.reset(task_name="analytics-window")377        obs = None378        for _ in range(MAX_STEPS):379            obs = fresh_env.step(NL2SQLAction(query="SELECT 1"))380        assert obs is not None381        assert obs.done is True382 383    def test_reset_clears_previous_episode(self, fresh_env):384        from models import NL2SQLAction385        fresh_env.reset(task_name="simple-filter")386        fresh_env.step(NL2SQLAction(query="SELECT 1"))387        # Second reset should clear state388        obs = fresh_env.reset(task_name="join-aggregation")389        assert fresh_env.state.step_count == 0390        assert obs.step == 0391        assert obs.task_name == "join-aggregation"392 393    @pytest.mark.parametrize("task_name", [394        "simple-filter", "join-aggregation", "analytics-window"395    ])396    def test_all_tasks_solvable(self, task_name):397        """Ground-truth SQL should always produce reward == 1.0 on step 1."""398        from environment import NL2SQLEnvironment399        from models import NL2SQLAction400        env = NL2SQLEnvironment()401        env.reset(task_name=task_name)402        gt_sql = env._example.sql403        obs = env.step(NL2SQLAction(query=gt_sql))404        assert obs.done is True405        assert obs.reward == pytest.approx(1.0), (406            f"Task {task_name!r}: ground-truth SQL did not score 1.0.\n"407            f"SQL: {gt_sql}\nError: {obs.last_error}\nReward: {obs.reward}"408        )409 410    def test_score_normalised_to_0_1(self, fresh_env):411        from models import NL2SQLAction412        fresh_env.reset(task_name="simple-filter")413        for _ in range(3):414            obs = fresh_env.step(NL2SQLAction(query="SELECT 1 AS x"))415            assert 0.0 <= obs.score <= 1.0416 417    def test_write_query_blocked(self, fresh_env):418        from models import NL2SQLAction419        fresh_env.reset(task_name="simple-filter")420        obs = fresh_env.step(NL2SQLAction(421            query="INSERT INTO categories(name) VALUES ('hack')"422        ))423        assert obs.last_error is not None424        assert "not allowed" in obs.last_error.lower() or "INSERT" in obs.last_error425 426 427# ══════════════════════════════════════════════════════════════════════════════428# 5. LOG FORMAT COMPLIANCE429# ══════════════════════════════════════════════════════════════════════════════430 431class TestLogFormat:432    """Validate that the inference.py log helpers emit correct format."""433 434    START_RE = re.compile(435        r"^\[START\] task=\S+ env=\S+ model=\S+$"436    )437    STEP_RE = re.compile(438        r"^\[STEP\] step=\d+ action=.+ reward=\d+\.\d{2} "439        r"done=(true|false) error=.+$"440    )441    END_RE = re.compile(442        r"^\[END\] success=(true|false) steps=\d+ score=\d+\.\d{3} "443        r"rewards=[\d.,]+$"444    )445 446    def _capture(self, func, *args, **kwargs) -> str:447        import io448        from contextlib import redirect_stdout449        buf = io.StringIO()450        with redirect_stdout(buf):451            func(*args, **kwargs)452        return buf.getvalue().strip()453 454    def test_log_start_format(self):455        sys.path.insert(0, str(ROOT))456        from inference import log_start457        out = self._capture(log_start, "simple-filter", "Qwen/Qwen2.5-72B")458        assert self.START_RE.match(out), f"Bad [START] format: {out!r}"459 460    def test_log_step_format_null_error(self):461        from inference import log_step462        out = self._capture(log_step, 1, "SELECT 1", 0.10, False, None)463        assert self.STEP_RE.match(out), f"Bad [STEP] format: {out!r}"464 465    def test_log_step_format_with_error(self):466        from inference import log_step467        out = self._capture(log_step, 2, "SELCT 1", 0.0, False, "syntax error")468        assert self.STEP_RE.match(out), f"Bad [STEP] format: {out!r}"469 470    def test_log_end_format_success(self):471        from inference import log_end472        out = self._capture(log_end, True, 3, 0.850, [0.50, 1.0, 1.0])473        assert self.END_RE.match(out), f"Bad [END] format: {out!r}"474 475    def test_log_end_format_failure(self):476        from inference import log_end477        out = self._capture(log_end, False, 5, 0.100, [0.1, 0.0, 0.0, 0.0, 0.0])478        assert self.END_RE.match(out), f"Bad [END] format: {out!r}"479 480    def test_reward_two_decimal_places(self):481        from inference import log_step482        out = self._capture(log_step, 1, "SELECT 1", 0.5, False, None)483        # reward= field must have exactly 2 decimal places484        match = re.search(r"reward=(\d+\.\d+)", out)485        assert match, "No reward= field found"486        assert len(match.group(1).split(".")[1]) == 2487 488    def test_score_three_decimal_places(self):489        from inference import log_end490        out = self._capture(log_end, True, 1, 1.0, [1.0])491        match = re.search(r"score=(\d+\.\d+)", out)492        assert match493        assert len(match.group(1).split(".")[1]) == 3494