File size: 9,856 Bytes
2194233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82a5b1b
2194233
 
 
 
 
82a5b1b
2194233
 
 
 
 
 
 
82a5b1b
2194233
 
 
 
 
 
 
82a5b1b
2194233
82a5b1b
2194233
 
 
 
 
82a5b1b
2194233
82a5b1b
2194233
 
 
 
 
82a5b1b
2194233
 
 
 
 
 
 
 
 
 
82a5b1b
2194233
 
 
 
 
 
 
 
 
 
 
82a5b1b
 
2194233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82a5b1b
2194233
 
 
 
82a5b1b
 
2194233
 
 
 
82a5b1b
2194233
 
 
 
82a5b1b
 
2194233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82a5b1b
2194233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82a5b1b
2194233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
"""
tests/test_env.py β€” Smoke tests and grader unit tests.

Run with:  pytest tests/ -v
"""
import asyncio
import pytest
from sql_env.models import SQLAction, SQLTask
from sql_env.grader import grade, generate_feedback
from sql_env.env import SQLCorrectionEnv
from sql_env.tasks import ALL_TASKS, EASY_TASKS, MEDIUM_TASKS, HARD_TASKS


# ── Helpers ───────────────────────────────────────────────────────────────────

def _make_task(broken: str, canonical: str, difficulty: str = "easy") -> SQLTask:
    return SQLTask(
        task_id="test_task",
        difficulty=difficulty,
        broken_query=broken,
        canonical_answer=canonical,
    )


def _action(query: str) -> SQLAction:
    return SQLAction(corrected_query=query)


# ── Grader unit tests ─────────────────────────────────────────────────────────

class TestGrader:
    def test_exact_match_returns_099(self):
        task = _make_task(
            "SELECT * FORM users",
            "SELECT * FROM users",
        )
        reward = grade(_action("SELECT * FROM users"), task)
        assert reward.value == 0.99

    def test_exact_match_case_insensitive(self):
        task = _make_task(
            "SELECT * FORM users",
            "SELECT * FROM users",
        )
        reward = grade(_action("select * from users"), task)
        assert reward.value == 0.99

    def test_exact_match_trailing_semicolon(self):
        task = _make_task(
            "SELECT * FORM users",
            "SELECT * FROM users",
        )
        reward = grade(_action("SELECT * FROM users;"), task)
        assert reward.value == 0.99

    def test_wrong_answer_not_perfect(self):
        task = _make_task(
            "SELECT * FORM users",
            "SELECT * FROM users",
        )
        reward = grade(_action("SELECT * FORM users"), task)
        assert reward.value < 0.99

    def test_completely_wrong_returns_001(self):
        task = _make_task(
            "SELECT * FORM users",
            "SELECT * FROM users",
        )
        reward = grade(_action("hello world"), task)
        assert reward.value == 0.01

    def test_basic_structure_returns_02(self):
        task = _make_task(
            "SELECT * FORM users WHERE id = 1",
            "SELECT * FROM users WHERE id = 1",
        )
        # Correct structure, still has FROM typo
        reward = grade(_action("SELECT * FORM users WHERE id = 1"), task)
        assert reward.value == pytest.approx(0.2, abs=0.05)

    def test_reward_range_is_strictly_open(self):
        task = _make_task(
            "SELCT * FORM users WEHRE id = 1",
            "SELECT * FROM users WHERE id = 1",
        )
        for query in [
            "hello world",
            "SELECT * FORM users",
            "SELECT * FROM users WHERE id = 1",
            "select * from users where id = 1",
        ]:
            reward = grade(_action(query), task)
            assert 0.0 < reward.value < 1.0, (
                f"Reward {reward.value} out of (0, 1) for query: {query}"
            )

    def test_feedback_not_empty(self):
        task = _make_task("SELECT * FORM users", "SELECT * FROM users")
        reward = grade(_action("SELECT * FROM users"), task)
        fb = generate_feedback(_action("SELECT * FROM users"), task, reward)
        assert isinstance(fb, str) and len(fb) > 0


# ── Task catalogue tests ──────────────────────────────────────────────────────

class TestTaskCatalogue:
    def test_easy_task_count(self):
        assert len(EASY_TASKS) >= 10, "Need at least 10 easy tasks"

    def test_medium_task_count(self):
        assert len(MEDIUM_TASKS) >= 10, "Need at least 10 medium tasks"

    def test_hard_task_count(self):
        assert len(HARD_TASKS) >= 5, "Need at least 5 hard tasks"

    def test_all_task_ids_unique(self):
        all_ids = [t.task_id for tasks in ALL_TASKS.values() for t in tasks]
        assert len(all_ids) == len(set(all_ids)), "Duplicate task IDs found"

    def test_easy_tasks_have_hints(self):
        for task in EASY_TASKS:
            assert task.error_hint is not None and len(task.error_hint) > 0, (
                f"Easy task {task.task_id} missing error_hint"
            )

    def test_hard_tasks_have_schema(self):
        for task in HARD_TASKS:
            assert task.schema_context is not None and len(task.schema_context) > 0, (
                f"Hard task {task.task_id} missing schema_context"
            )

    def test_canonical_answers_are_valid_sql(self):
        """Canonical answers must at least contain SELECT and FROM."""
        for difficulty, tasks in ALL_TASKS.items():
            for task in tasks:
                upper = task.canonical_answer.upper()
                assert "SELECT" in upper, (
                    f"{task.task_id}: canonical_answer missing SELECT"
                )
                assert "FROM" in upper, (
                    f"{task.task_id}: canonical_answer missing FROM"
                )

    def test_grading_canonical_answer_returns_perfect(self):
        """Every task must return 0.99 when given its own canonical answer."""
        for difficulty, tasks in ALL_TASKS.items():
            for task in tasks:
                action = _action(task.canonical_answer)
                reward = grade(action, task)
                assert reward.value == 0.99, (
                    f"{task.task_id}: canonical answer did not score 0.99 "
                    f"(got {reward.value})"
                )

    def test_grading_broken_query_below_perfect(self):
        """Broken queries must score below the perfect 0.99 score."""
        for difficulty, tasks in ALL_TASKS.items():
            for task in tasks:
                action = _action(task.broken_query)
                reward = grade(action, task)
                assert reward.value < 0.99, (
                    f"{task.task_id}: broken query unexpectedly scored 0.99"
                )


# ── Environment integration tests ─────────────────────────────────────────────

class TestEnvironment:
    def test_reset_returns_observation(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="easy")
            obs = await env.reset()
            assert obs.task_id is not None
            assert obs.broken_query is not None
            assert obs.step_number == 0
            assert obs.steps_remaining == 5

        asyncio.run(run())

    def test_step_returns_result(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="easy")
            await env.reset()
            result = await env.step(_action("SELECT * FROM users WHERE id = 1"))
            assert 0.0 < result.reward < 1.0
            assert isinstance(result.done, bool)
            assert result.observation.step_number == 1

        asyncio.run(run())

    def test_steps_remaining_decrements(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="easy")
            await env.reset()
            result = await env.step(_action("SELECT * FROM x"))
            assert result.observation.steps_remaining == 4

        asyncio.run(run())

    def test_correct_answer_terminates(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="easy", task_index=0)
            await env.reset()
            canonical = EASY_TASKS[0].canonical_answer
            result = await env.step(_action(canonical))
            assert result.done is True
            assert result.reward == pytest.approx(0.99)

        asyncio.run(run())

    def test_max_steps_terminates(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="easy", task_index=0)
            await env.reset()
            result = None
            for _ in range(5):
                result = await env.step(_action("SELECT * FORM users"))
            assert result.done is True

        asyncio.run(run())

    def test_done_episode_raises(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="easy", task_index=0)
            await env.reset()
            canonical = EASY_TASKS[0].canonical_answer
            await env.step(_action(canonical))  # this terminates
            with pytest.raises(RuntimeError):
                await env.step(_action("SELECT 1"))

        asyncio.run(run())

    def test_medium_hint_hidden(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="medium")
            obs = await env.reset()
            assert obs.error_hint is None

        asyncio.run(run())

    def test_hard_schema_present(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="hard")
            obs = await env.reset()
            assert obs.schema_context is not None

        asyncio.run(run())

    def test_state_reflects_progress(self):
        async def run():
            env = SQLCorrectionEnv(difficulty="easy", task_index=0)
            await env.reset()
            await env.step(_action("SELECT * FORM users"))
            state = await env.state()
            assert state["step_count"] == 1
            assert state["done"] is False

        asyncio.run(run())

    def test_all_difficulties_reset(self):
        async def run():
            for diff in ["easy", "medium", "hard"]:
                env = SQLCorrectionEnv(difficulty=diff)
                obs = await env.reset()
                assert obs.broken_query is not None

        asyncio.run(run())