File size: 23,123 Bytes
5dd1bb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# Verification Specification

**Feature:** F001
**Generated from:** specs/F001-VERIFICATION_INPUT.json
**Generated:** 2026-03-24

---

## 1. Unit Tests

### 1.1 SQLAction Type

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_sqlaction_valid_describe | Create action with DESCRIBE type | `SQLAction(action_type="DESCRIBE", argument="employees")` | Fields set correctly | happy |
| test_sqlaction_valid_sample | Create action with SAMPLE type | `SQLAction(action_type="SAMPLE", argument="orders")` | Fields set correctly | happy |
| test_sqlaction_valid_query | Create action with QUERY type | `SQLAction(action_type="QUERY", argument="SELECT 1")` | Fields set correctly | happy |
| test_sqlaction_valid_answer | Create action with ANSWER type | `SQLAction(action_type="ANSWER", argument="42")` | Fields set correctly | happy |
| test_sqlaction_empty_argument | Argument is empty string | `SQLAction(action_type="QUERY", argument="")` | Accepted at type level (validation at step) | edge |
| test_sqlaction_whitespace_argument | Argument is only whitespace | `SQLAction(action_type="QUERY", argument="   ")` | Accepted at type level | edge |
| test_sqlaction_serialization | Round-trip JSON serialization | Create, serialize, deserialize | Identical fields | happy |

### 1.2 SQLObservation Type

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_obs_all_fields_present | All required fields populated | Full observation construction | All fields accessible with correct types | happy |
| test_obs_done_false_initial | Initial observation has done=False | After reset | `done == False` | happy |
| test_obs_reward_none_nonterminal | Non-terminal obs has reward=None | After non-ANSWER step | `reward is None` | happy |
| test_obs_reward_set_terminal | Terminal obs has numeric reward | After ANSWER step | `reward in {0.0, 1.0}` | happy |
| test_obs_step_count_type | step_count is int | Any observation | `isinstance(step_count, int)` | happy |
| test_obs_budget_remaining_type | budget_remaining is int | Any observation | `isinstance(budget_remaining, int)` | happy |
| test_obs_action_history_list | action_history is a list of strings | After several steps | `isinstance(action_history, list)` and all elements are `str` | happy |
| test_obs_error_empty_on_success | error is empty string on success | After successful action | `error == ""` | happy |
| test_obs_schema_info_nonempty | schema_info is non-empty after reset | After reset | `len(schema_info) > 0` | happy |

### 1.3 QuestionRecord Type

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_qr_all_fields | All fields populated from valid JSON | Parsed question JSON | All 8 fields present and correct types | happy |
| test_qr_difficulty_values | difficulty in allowed set | Various records | `difficulty in {"easy", "medium", "hard"}` | happy |
| test_qr_answer_type_values | answer_type in allowed set | Various records | `answer_type in {"integer", "float", "string", "list"}` | happy |
| test_qr_tables_involved_nonempty | tables_involved has at least one entry | Valid question | `len(tables_involved) >= 1` | happy |

### 1.4 EpisodeContext Type

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_ctx_initial_step_count | step_count starts at 0 | After reset | `step_count == 0` | happy |
| test_ctx_initial_budget | budget starts at configured value (default 15) | After reset | `budget == 15` | happy |
| test_ctx_described_tables_empty | described_tables starts empty | After reset | `len(described_tables) == 0` | happy |
| test_ctx_action_log_empty | action_log starts empty | After reset | `len(action_log) == 0` | happy |
| test_ctx_done_false_initial | done starts False | After reset | `done == False` | happy |
| test_ctx_gold_answer_computed | gold_answer is set after reset | After reset | `gold_answer is not None` | happy |

### 1.5 SQLEnvironment.__init__

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_init_valid | Init with valid paths loads questions | Valid questions_path, db_dir | `len(questions) > 0` | happy |
| test_init_missing_questions_file | questions_path does not exist | `"/nonexistent/q.json"` | `FileNotFoundError` | error |
| test_init_missing_db_dir | db_dir does not exist | `"/nonexistent/dbs/"` | `FileNotFoundError` or `ValueError` | error |
| test_init_invalid_json | questions file is not valid JSON | Path to file with `"{bad"` | `ValueError` | error |
| test_init_empty_questions | questions file is empty array `[]` | Path to file with `"[]"` | `ValueError` (no questions) | error |
| test_init_custom_budget | Custom step_budget is stored | `step_budget=10` | Environment uses 10-step budget | happy |
| test_init_default_budget | Default step_budget is 15 | Omit step_budget | Budget is 15 | happy |

### 1.6 SQLEnvironment.reset

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_reset_returns_observation | Returns SQLObservation | `reset()` | `isinstance(result, SQLObservation)` | happy |
| test_reset_obs_has_question | Observation contains question text | `reset()` | `len(obs.question) > 0` | happy |
| test_reset_obs_has_table_names | schema_info contains table names | `reset()` | schema_info mentions at least one table | happy |
| test_reset_obs_no_columns | schema_info does NOT reveal columns initially | `reset()` | No column names in schema_info | happy |
| test_reset_obs_done_false | done is False | `reset()` | `obs.done == False` | happy |
| test_reset_obs_reward_none | reward is None | `reset()` | `obs.reward is None` | happy |
| test_reset_obs_step_count_zero | step_count is 0 | `reset()` | `obs.step_count == 0` | happy |
| test_reset_obs_budget_full | budget_remaining equals configured budget | `reset()` | `obs.budget_remaining == 15` | happy |
| test_reset_obs_empty_history | action_history is empty | `reset()` | `obs.action_history == []` | happy |
| test_reset_obs_empty_error | error is empty string | `reset()` | `obs.error == ""` | happy |
| test_reset_seed_determinism | Same seed yields same question | `reset(seed=42)` twice | Same question both times | happy |
| test_reset_different_seeds | Different seeds can yield different questions | `reset(seed=1)` vs `reset(seed=999)` | Not necessarily same question (probabilistic) | happy |
| test_reset_no_seed_random | Without seed, question is randomly selected | `reset()` multiple times | At least one different question (probabilistic) | happy |
| test_reset_episode_id_set | Custom episode_id is reflected | `reset(episode_id="ep-123")` | Context has episode_id="ep-123" | happy |
| test_reset_missing_db_file | Question references nonexistent DB | Question with bad db_name | `FileNotFoundError` | error |
| test_reset_clears_previous_episode | Calling reset mid-episode starts fresh | reset, step, reset | Second reset has step_count=0, empty history | happy |

### 1.7 SQLEnvironment.step

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_step_returns_observation | Returns SQLObservation | Any valid action | `isinstance(result, SQLObservation)` | happy |
| test_step_never_raises | step never raises exceptions | Various invalid inputs | Returns obs with error field, no exception | happy |
| test_step_increments_step_count | step_count increases by 1 | DESCRIBE action | `obs.step_count == 1` | happy |
| test_step_decrements_budget | budget_remaining decreases by 1 | DESCRIBE/SAMPLE/QUERY | `obs.budget_remaining == 14` | happy |
| test_step_answer_no_budget_decrement | ANSWER does not decrement budget | ANSWER action | budget unchanged from before | happy |
| test_step_appends_action_history | action_history grows each step | Two DESCRIBE actions | `len(obs.action_history) == 2` | happy |
| test_step_invalid_action_type | Unknown action_type returns error | `action_type="UNKNOWN"` | `obs.error` contains "Unknown action type" and lists valid types | error |
| test_step_empty_argument | Empty argument returns error | `argument=""` | `obs.error` contains "cannot be empty" | error |
| test_step_whitespace_argument | Whitespace-only argument returns error | `argument="   "` | `obs.error` contains "cannot be empty" | error |

### 1.8 SQLEnvironment._execute_sql

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_exec_valid_select | Executes a valid SELECT | `"SELECT 1"` | Returns list with results | happy |
| test_exec_non_select_rejected | Non-SELECT is rejected | `"DROP TABLE x"` | `ValueError` raised | error |
| test_exec_insert_rejected | INSERT is rejected | `"INSERT INTO t VALUES(1)"` | `ValueError` raised | error |
| test_exec_update_rejected | UPDATE is rejected | `"UPDATE t SET x=1"` | `ValueError` raised | error |
| test_exec_delete_rejected | DELETE is rejected | `"DELETE FROM t"` | `ValueError` raised | error |
| test_exec_create_rejected | CREATE is rejected | `"CREATE TABLE t(x INT)"` | `ValueError` raised | error |
| test_exec_semicolon_multi | Multiple statements rejected | `"SELECT 1; DROP TABLE t"` | Error or only first executed safely | error |
| test_exec_syntax_error | Malformed SQL | `"SELCET * FORM t"` | `sqlite3.OperationalError` | error |
| test_exec_timeout | Query exceeding timeout is interrupted | Long-running query (e.g., cartesian join) | Error raised within ~5 seconds | error |
| test_exec_result_truncation | Results truncated to 20 rows | Query returning 100 rows | `len(result) <= 20` | edge |
| test_exec_empty_result | Query returning no rows | `"SELECT * FROM t WHERE 1=0"` | Empty list `[]` | edge |
| test_exec_read_only | Connection is truly read-only | Attempt write via raw SQL | Error raised | error |
| test_exec_case_insensitive_select | `select` (lowercase) is accepted | `"select 1"` | Returns results | edge |
| test_exec_select_with_leading_whitespace | Leading whitespace before SELECT | `"  SELECT 1"` | Returns results | edge |
| test_exec_select_with_comment | SQL comment before SELECT | `"-- comment\nSELECT 1"` | Handled correctly (accepted or rejected consistently) | edge |

### 1.9 SQLEnvironment._handle_describe

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_describe_valid_table | Known table returns schema info | Existing table name | Result contains column names, types, row count | happy |
| test_describe_unknown_table | Unknown table returns error with list | `"nonexistent_table"` | Error containing "not found" and listing available tables | error |
| test_describe_case_sensitivity | Table name matching case behavior | Mixed case table name | Consistent behavior (either matches or provides helpful error) | edge |
| test_describe_updates_described_set | Described table is tracked | DESCRIBE a table | Table appears in described_tables set | happy |
| test_describe_repeated_table | Describing same table twice works | DESCRIBE same table twice | No error, returns same info | edge |

### 1.10 SQLEnvironment._handle_sample

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_sample_valid_table | Known table returns sample rows | Existing table name | Formatted rows returned | happy |
| test_sample_unknown_table | Unknown table returns error | `"nonexistent_table"` | Error with available table list | error |
| test_sample_default_limit | Default limit is 5 rows | Omit limit parameter | At most 5 rows returned | happy |
| test_sample_empty_table | Table with no rows | Empty table | Returns empty or "no rows" result | edge |

### 1.11 SQLEnvironment._handle_query

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_query_valid_select | Valid SELECT returns formatted results | `"SELECT * FROM t LIMIT 3"` | Formatted result string | happy |
| test_query_non_select | Non-SELECT returns error | `"DROP TABLE t"` | Error about SELECT-only | error |
| test_query_syntax_error | Bad SQL returns error | `"SELCET *"` | Error with sqlite3 message | error |
| test_query_timeout | Slow query returns timeout error | Expensive query | Error mentioning "timed out" and "5.0 seconds" | error |
| test_query_truncation_indicator | >20 rows shows truncation notice | Query returning many rows | Result indicates truncation occurred | edge |
| test_query_exactly_20_rows | Exactly 20 rows shows no truncation | Query returning 20 rows | No truncation indicator | edge |

### 1.12 SQLEnvironment._handle_answer

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_answer_correct | Correct answer yields reward 1.0 | Gold answer value | `(True, 1.0)` | happy |
| test_answer_incorrect | Incorrect answer yields reward 0.0 | Wrong value | `(False, 0.0)` | happy |
| test_answer_case_insensitive | Case-insensitive comparison | `"PARIS"` vs gold `"paris"` | `(True, 1.0)` | edge |
| test_answer_whitespace_stripped | Leading/trailing whitespace stripped | `"  42  "` vs gold `"42"` | `(True, 1.0)` | edge |
| test_answer_sets_done | Episode is marked done after answer | Any answer | `done == True` | happy |
| test_answer_empty_string | Empty answer handled | `""` | `(False, 0.0)` | edge |

### 1.13 SQLEnvironment._load_questions

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_load_valid_json | Valid questions JSON parsed | Path to valid file | `list[QuestionRecord]` with correct count | happy |
| test_load_file_not_found | Missing file raises error | `"/nonexistent.json"` | `FileNotFoundError` | error |
| test_load_invalid_json | Malformed JSON raises error | Path to `"{bad"` | `ValueError` | error |
| test_load_missing_fields | Record missing required field | JSON with missing `gold_sql` | `ValueError` or `KeyError` | error |

### 1.14 SQLEnvironment._open_db

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_open_valid_db | Opens existing database | Valid db_name | `sqlite3.Connection` returned | happy |
| test_open_missing_db | Missing database raises error | `"nonexistent_db"` | `FileNotFoundError` | error |
| test_open_read_only | Connection is read-only | Valid db_name | Write operations fail | happy |

### 1.15 SQLEnvironment._build_observation

| Test | Description | Input | Expected | Category |
|------|-------------|-------|----------|----------|
| test_build_obs_reflects_context | Observation matches episode context | Various context states | Fields match context values | happy |
| test_build_obs_schema_progressive | schema_info includes described columns | After DESCRIBE action | schema_info contains column details for described tables | happy |
| test_build_obs_schema_initial | Initial schema_info has only table names | Before any DESCRIBE | Only table names, no columns | happy |

**Run:** `pytest tests/unit/test_sql_environment.py -v`

---

## 2. Integration Tests

### Flow: Full Episode Lifecycle (Reset-Explore-Answer)

| Step | Action | Expected | Verification |
|------|--------|----------|--------------|
| 1 | `reset(seed=42)` | Observation with question, table names, step=0, budget=15 | Assert all initial observation fields |
| 2 | `step(DESCRIBE, "employees")` | Column info in result, step=1, budget=14 | Assert result contains column names, step incremented |
| 3 | `step(SAMPLE, "employees")` | Sample rows in result, step=2, budget=13 | Assert formatted rows in result |
| 4 | `step(QUERY, "SELECT COUNT(*) FROM employees")` | Count result, step=3, budget=12 | Assert numeric result in output |
| 5 | `step(ANSWER, "<correct_value>")` | done=True, reward=1.0 | Assert terminal state with positive reward |
| 6 | Verify no more steps accepted | Post-episode step attempt handled gracefully | Assert error or episode-over signal |

### Flow: Budget Exhaustion

| Step | Action | Expected | Verification |
|------|--------|----------|--------------|
| 1 | `reset()` with budget=3 | Observation with budget=3 | Assert budget_remaining==3 |
| 2 | `step(DESCRIBE, "t1")` | budget=2 | Assert budget_remaining==2 |
| 3 | `step(DESCRIBE, "t2")` | budget=1 | Assert budget_remaining==1 |
| 4 | `step(DESCRIBE, "t3")` | budget=0, done=True, reward=0.0 | Assert terminal state, zero reward |

### Flow: Error Recovery Within Episode

| Step | Action | Expected | Verification |
|------|--------|----------|--------------|
| 1 | `reset()` | Normal initial observation | Assert done==False |
| 2 | `step(QUERY, "DROP TABLE x")` | Error in obs.error, done=False | Assert error message, episode continues |
| 3 | `step(QUERY, "SELCET * FORM t")` | Error in obs.error, done=False | Assert SQL error message, episode continues |
| 4 | `step(DESCRIBE, "nonexistent")` | Error in obs.error, done=False | Assert "not found" error, episode continues |
| 5 | `step(QUERY, "SELECT 1")` | Success result, no error | Assert error=="" and result has data |

### Flow: Progressive Schema Discovery

| Step | Action | Expected | Verification |
|------|--------|----------|--------------|
| 1 | `reset()` | schema_info has table names only | Assert table names present, no column details |
| 2 | `step(DESCRIBE, "table_a")` | schema_info now includes table_a columns | Assert column names for table_a appear |
| 3 | `step(DESCRIBE, "table_b")` | schema_info includes both table_a and table_b columns | Assert both tables' columns present |

### Flow: Seed Determinism

| Step | Action | Expected | Verification |
|------|--------|----------|--------------|
| 1 | `reset(seed=42)` | Observation A | Record question and db |
| 2 | `reset(seed=42)` | Observation B identical to A | Assert same question_text and schema_info |

**Run:** `pytest tests/integration/test_episode_lifecycle.py -v`

---

## 3. API Tests

### POST /reset

| Test | Request | Status | Response | Category |
|------|---------|--------|----------|----------|
| test_reset_no_params | `{}` | 200 | SQLObservation with done=False, budget=15 | happy |
| test_reset_with_seed | `{"seed": 42}` | 200 | Deterministic SQLObservation | happy |
| test_reset_with_episode_id | `{"episode_id": "ep-1"}` | 200 | SQLObservation (episode_id reflected in context) | happy |
| test_reset_null_seed | `{"seed": null}` | 200 | Random question selected | happy |
| test_reset_server_error_missing_db | (DB file removed) | 500 | Error response indicating database not found | error |
| test_reset_response_schema | `{}` | 200 | Response contains all SQLObservation fields | happy |

### POST /step

| Test | Request | Status | Response | Category |
|------|---------|--------|----------|----------|
| test_step_describe | `{"action_type": "DESCRIBE", "argument": "employees"}` | 200 | SQLObservation with schema result | happy |
| test_step_sample | `{"action_type": "SAMPLE", "argument": "employees"}` | 200 | SQLObservation with sample rows | happy |
| test_step_query | `{"action_type": "QUERY", "argument": "SELECT 1"}` | 200 | SQLObservation with query result | happy |
| test_step_answer | `{"action_type": "ANSWER", "argument": "42"}` | 200 | SQLObservation with done=True | happy |
| test_step_missing_action_type | `{"argument": "x"}` | 422 | Validation error | error |
| test_step_missing_argument | `{"action_type": "QUERY"}` | 422 | Validation error | error |
| test_step_empty_body | `{}` | 422 | Validation error | error |
| test_step_invalid_action_type | `{"action_type": "HACK", "argument": "x"}` | 200 | SQLObservation with error field set | error |
| test_step_without_reset | Call step before reset | 200 or 500 | Graceful error (not a crash) | error |

**Run:** `pytest tests/api/test_endpoints.py -v`

---

## 4. E2E Tests

### Scenario: Agent Solves Easy Question

**Setup:** Environment initialized with Spider questions and databases. Select an easy question with a known answer.
**Actions:**
1. POST /reset with seed that selects an easy question
2. POST /step with DESCRIBE on relevant table
3. POST /step with SAMPLE on relevant table
4. POST /step with QUERY using correct SQL
5. POST /step with ANSWER providing correct value
**Expected:** reward=1.0, done=True, total steps <= 15

### Scenario: Agent Exhausts Budget

**Setup:** Environment initialized with budget=3.
**Actions:**
1. POST /reset
2. POST /step with DESCRIBE x3
**Expected:** After 3rd step: done=True, reward=0.0, budget_remaining=0

### Scenario: Agent Submits Wrong Answer

**Setup:** Environment initialized normally.
**Actions:**
1. POST /reset
2. POST /step with ANSWER providing deliberately wrong value
**Expected:** done=True, reward=0.0

### Scenario: Agent Recovers From Errors

**Setup:** Environment initialized normally.
**Actions:**
1. POST /reset
2. POST /step with QUERY "DROP TABLE x" (rejected)
3. POST /step with DESCRIBE "nonexistent_table" (error)
4. POST /step with valid QUERY
5. POST /step with correct ANSWER
**Expected:** Error steps counted against budget, valid steps succeed, episode completes with reward=1.0

**Run:** `pytest tests/e2e/test_full_episodes.py -v`

---

## 5. Edge Cases Checklist

- [ ] Null/None seed and episode_id in reset
- [ ] Empty string argument for all action types
- [ ] Whitespace-only argument for all action types
- [ ] Very long SQL query (10KB+)
- [ ] SQL with unicode characters in string literals
- [ ] SQL with special characters (backticks, double quotes, brackets)
- [ ] Table name with special characters or spaces
- [ ] ANSWER with numeric value as string vs integer
- [ ] ANSWER with leading zeros ("042" vs "42")
- [ ] ANSWER with trailing decimal ("42.0" vs "42")
- [ ] Budget of 1 (single step before exhaustion)
- [ ] Budget of 0 (should episode immediately end?)
- [ ] Calling step after episode is done
- [ ] Calling reset multiple times without stepping
- [ ] Concurrent episodes (if supported)
- [ ] Database with zero tables
- [ ] Database with very large tables (performance)
- [ ] Question whose gold_sql returns empty result
- [ ] SELECT with subqueries, CTEs, UNION
- [ ] SQL injection attempts via argument field (`"; DROP TABLE--`)
- [ ] Very long table name in DESCRIBE/SAMPLE

---

## 6. Evidence Requirements

| Category | Evidence Type | Example |
|----------|---------------|---------|
| Unit tests | pytest output | `45 passed` |
| Integration | pytest output | `5 passed` |
| API tests | pytest output or httpx test client | `9 passed` |
| E2E | pytest output with real Spider DB | `4 passed` |
| SQL sandbox | Demonstrate write rejection | `ValueError: Only SELECT queries are allowed` |
| Timeout | Demonstrate query interruption | `Query timed out after 5.0 seconds` |
| Budget | Demonstrate forced termination | `done=True, reward=0.0 at budget=0` |
| Answer comparison | Demonstrate case-insensitive match | `"PARIS" == "paris" -> reward=1.0` |