sravaniamere commited on
Commit
2194233
Β·
1 Parent(s): a59b8ec

Fix server.py, expand tasks to 40, add tests, clean observation schema

Browse files
Dockerfile CHANGED
@@ -18,7 +18,8 @@ COPY . .
18
  EXPOSE 7860
19
 
20
  # health check so HF Space knows when it's ready
21
- HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
22
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health', timeout=3)"
23
 
 
24
  CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
 
18
  EXPOSE 7860
19
 
20
  # health check so HF Space knows when it's ready
21
+ HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
22
  CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health', timeout=3)"
23
 
24
+ # server:app refers to the root server.py which re-exports app from sql_env.server
25
  CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
README.md CHANGED
@@ -12,7 +12,7 @@ tags:
12
  # SQL Correction RL Environment
13
 
14
  An **OpenEnv-compliant** reinforcement learning environment where an AI agent
15
- learns to fix broken SQL queries, a real task that developers face every day.
16
 
17
  ---
18
 
@@ -21,25 +21,28 @@ learns to fix broken SQL queries, a real task that developers face every day.
21
  SQL errors are one of the most common and costly mistakes in software
22
  development. This environment trains agents to identify and correct SQL syntax
23
  and logical errors, ranging from simple typos to complex multi-join query
24
- reconstruction.
25
 
26
  The environment provides **partial progress signals** at every step; the agent
27
  receives graded feedback even for near-correct answers, enabling meaningful
28
  learning across the full trajectory rather than sparse end-of-episode rewards.
 
 
29
 
30
  ---
31
 
32
  ## Observation Space
33
 
34
- | Field | Type | Description |
35
- |--------------------|-----------------|----------------------------------------------------------|
36
- | `task_id` | string | Unique identifier for the current task instance |
37
- | `broken_query` | string | The malformed SQL query the agent must fix |
38
- | `schema_context` | string or null | Table and column definitions when a task includes them |
39
- | `error_hint` | string or null | Plain-language hint about the error (easy tasks only) |
40
- | `step_number` | integer | Current step within the episode |
41
- | `previous_attempt` | string or null | The agent's SQL output from the previous step |
42
- | `feedback` | string or null | Grader feedback on the previous attempt |
 
43
 
44
  ## Action Space
45
 
@@ -51,11 +54,11 @@ learning across the full trajectory rather than sparse end-of-episode rewards.
51
 
52
  ## Tasks
53
 
54
- | Name | Difficulty | Max Steps | Description |
55
- |----------|------------|-----------|-------------|
56
- | `easy` | Easy | 5 | Fix a single syntax error (for example `FORM` -> `FROM`). Hint provided. |
57
- | `medium` | Medium | 5 | Fix multiple errors including missing keywords and wrong clauses. No hint. |
58
- | `hard` | Hard | 4 | Fix complex multi-join queries with subtle errors and wrong clause ordering. Schema provided, no hint. |
59
 
60
  ---
61
 
@@ -65,9 +68,14 @@ learning across the full trajectory rather than sparse end-of-episode rewards.
65
  |-------|-----------|
66
  | `1.0` | Exact match after normalization (perfect fix) |
67
  | `0.7` | All correct tokens present, structure slightly off |
68
- | `0.4` | Most keywords correct and token overlap is high |
 
69
  | `0.2` | Basic `SELECT ... FROM ...` structure present |
70
- | `0.0` | Query still incorrect |
 
 
 
 
71
 
72
  Episodes terminate when reward = 1.0 (success) or max steps is reached.
73
 
@@ -88,24 +96,27 @@ uvicorn server:app --host 0.0.0.0 --port 7860
88
 
89
  # Test endpoints
90
  curl -X POST http://localhost:7860/reset \
91
- -H "Content-Type: application/json" -d '{"task_name": "easy"}'
92
 
93
  curl -X POST http://localhost:7860/step \
94
  -H "Content-Type: application/json" \
95
- -d '{"corrected_query": "SELECT * FROM users WHERE id = 1;"}'
 
 
 
96
 
97
- curl -X POST http://localhost:7860/state \
98
- -H "Content-Type: application/json" -d '{}'
 
 
 
99
  ```
100
 
101
  ### Docker
102
 
103
  ```bash
104
  docker build -t sql-correction-env .
105
- docker run -p 7860:7860 \
106
- -e HF_TOKEN=your_token \
107
- -e MODEL_NAME=Qwen/Qwen2.5-72B-Instruct \
108
- sql-correction-env
109
  ```
110
 
111
  ### Running Inference
@@ -116,10 +127,8 @@ export API_BASE_URL=https://router.huggingface.co/v1
116
  export MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
117
  export ENV_URL=http://localhost:7860
118
 
119
- # Run each task
120
- SQL_ENV_TASK=easy python inference.py
121
- SQL_ENV_TASK=medium python inference.py
122
- SQL_ENV_TASK=hard python inference.py
123
  ```
124
 
125
  ---
@@ -138,10 +147,10 @@ SQL_ENV_TASK=hard python inference.py
138
 
139
  ## API Endpoints
140
 
141
- | Method | Path | Description |
142
- |--------|-----------|------------------------------------|
143
  | POST | `/reset` | Start new episode, returns observation |
144
- | POST | `/step` | Submit action, returns result |
145
- | POST | `/state` | Get current episode state |
146
- | GET | `/health` | Health check |
147
- # rebuilt
 
12
  # SQL Correction RL Environment
13
 
14
  An **OpenEnv-compliant** reinforcement learning environment where an AI agent
15
+ learns to fix broken SQL queries β€” a real task that developers face every day.
16
 
17
  ---
18
 
 
21
  SQL errors are one of the most common and costly mistakes in software
22
  development. This environment trains agents to identify and correct SQL syntax
23
  and logical errors, ranging from simple typos to complex multi-join query
24
+ reconstruction including column name mismatches.
25
 
26
  The environment provides **partial progress signals** at every step; the agent
27
  receives graded feedback even for near-correct answers, enabling meaningful
28
  learning across the full trajectory rather than sparse end-of-episode rewards.
29
+ A **stagnation penalty** further discourages agents from repeating the same
30
+ wrong answer across steps.
31
 
32
  ---
33
 
34
  ## Observation Space
35
 
36
+ | Field | Type | Description |
37
+ |--------------------|-----------------|--------------------------------------------------------------------|
38
+ | `task_id` | string | Unique identifier for the current task instance |
39
+ | `broken_query` | string | The malformed SQL query the agent must fix |
40
+ | `schema_context` | string or null | Table and column definitions (hard tasks only) |
41
+ | `error_hint` | string or null | Plain-language hint about the error (easy tasks only) |
42
+ | `step_number` | integer | Current step within the episode (0 = initial) |
43
+ | `steps_remaining` | integer | Steps left before the episode ends |
44
+ | `previous_attempt` | string or null | The agent's SQL output from the previous step |
45
+ | `feedback` | string or null | Grader feedback on the previous attempt |
46
 
47
  ## Action Space
48
 
 
54
 
55
  ## Tasks
56
 
57
+ | Name | Difficulty | Count | Max Steps | Description |
58
+ |----------|------------|-------|-----------|-------------|
59
+ | `easy` | Easy | 15 | 5 | Fix a single keyword typo (e.g. `FORM` β†’ `FROM`). Hint provided. |
60
+ | `medium` | Medium | 15 | 5 | Fix multiple errors including missing keywords and wrong clauses. No hint. |
61
+ | `hard` | Hard | 10 | 4 | Fix complex multi-join queries with subtle errors and wrong column names. Schema provided, no hint. |
62
 
63
  ---
64
 
 
68
  |-------|-----------|
69
  | `1.0` | Exact match after normalization (perfect fix) |
70
  | `0.7` | All correct tokens present, structure slightly off |
71
+ | `0.4` | Most keywords correct and token overlap is high (β‰₯85% keywords, β‰₯75% tokens) |
72
+ | `0.3` | Partial keyword and structure match (β‰₯65% keywords, β‰₯50% tokens) |
73
  | `0.2` | Basic `SELECT ... FROM ...` structure present |
74
+ | `0.0` | Response is not valid SQL |
75
+
76
+ A **stagnation penalty** of `βˆ’0.1` is applied when the agent submits the same
77
+ reward-equivalent answer for two or more consecutive steps, encouraging active
78
+ correction rather than looping.
79
 
80
  Episodes terminate when reward = 1.0 (success) or max steps is reached.
81
 
 
96
 
97
  # Test endpoints
98
  curl -X POST http://localhost:7860/reset \
99
+ -H "Content-Type: application/json" -d '{"difficulty": "easy"}'
100
 
101
  curl -X POST http://localhost:7860/step \
102
  -H "Content-Type: application/json" \
103
+ -d '{"action": {"corrected_query": "SELECT * FROM users WHERE id = 1"}}'
104
+
105
+ curl http://localhost:7860/tasks
106
+ ```
107
 
108
+ ### Run Tests
109
+
110
+ ```bash
111
+ pip install pytest
112
+ pytest tests/ -v
113
  ```
114
 
115
  ### Docker
116
 
117
  ```bash
118
  docker build -t sql-correction-env .
119
+ docker run -p 7860:7860 sql-correction-env
 
 
 
120
  ```
121
 
122
  ### Running Inference
 
127
  export MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
128
  export ENV_URL=http://localhost:7860
129
 
130
+ # Run all tasks
131
+ python inference.py
 
 
132
  ```
133
 
134
  ---
 
147
 
148
  ## API Endpoints
149
 
150
+ | Method | Path | Description |
151
+ |--------|-----------|----------------------------------------|
152
  | POST | `/reset` | Start new episode, returns observation |
153
+ | POST | `/step` | Submit action, returns result |
154
+ | POST | `/state` | Get current episode state |
155
+ | GET | `/health` | Health check |
156
+ | GET | `/tasks` | List available task difficulties |
inference.py CHANGED
@@ -26,12 +26,12 @@ except Exception:
26
  OpenAI = None
27
 
28
  API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
29
- MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
30
- API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "dummy")
31
- TASK_NAME = os.getenv("SQL_ENV_TASK", "easy")
32
- BENCHMARK = "sql-correction-env"
33
- ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")
34
- MAX_STEPS = 8
35
  SUCCESS_SCORE_THRESHOLD = 0.5
36
 
37
 
@@ -52,7 +52,6 @@ def log_step(
52
  ) -> None:
53
  err = error if error else "null"
54
  done_val = str(done).lower()
55
- # Collapse newlines so the entire step fits on one line (spec requirement)
56
  action_clean = action.replace("\n", " ").replace("\r", "").strip()
57
  print(
58
  f"[STEP] step={step} action={action_clean} "
@@ -80,26 +79,28 @@ SYSTEM_PROMPT = textwrap.dedent(
80
  You will be shown a broken SQL query that contains typos or keyword errors.
81
  Fix ALL errors and return ONLY the corrected SQL query.
82
  No explanation, no markdown, no code blocks, no backticks.
83
- Common errors: FORM->FROM, WEHRE->WHERE, GRUP->GROUP, HAVNG->HAVING,
84
- ORDR->ORDER, INNE->INNER, LFT->LEFT, BETWEN->BETWEEN, DSC->DESC, SELCT->SELECT.
 
 
 
 
85
  """
86
  ).strip()
87
 
88
-
89
  SQL_REPLACEMENTS = {
90
- "FORM": "FROM",
91
- "WEHRE": "WHERE",
92
- "WHER": "WHERE",
93
- "GRUP": "GROUP",
94
- "HAVNG": "HAVING",
95
- "ORDR": "ORDER",
96
- "INNE": "INNER",
97
- "LFT": "LEFT",
98
  "BETWEN": "BETWEEN",
99
- "DSC": "DESC",
100
- "SELCT": "SELECT",
101
- "LIMT": "LIMIT",
102
- "DPT_ID": "DEPT_ID",
103
  }
104
 
105
 
@@ -114,7 +115,9 @@ def heuristic_correct_sql(query: str) -> str:
114
 
115
 
116
  def get_model_action(
117
- client: Optional["OpenAI"], obs: dict, history: List[str]
 
 
118
  ) -> str:
119
  """Return a corrected SQL string. Falls back to heuristic on any failure."""
120
  heuristic = heuristic_correct_sql(obs.get("broken_query", ""))
@@ -128,10 +131,11 @@ def get_model_action(
128
  Broken SQL query:
129
  {obs.get("broken_query", "")}
130
 
131
- Schema context: {obs.get("schema_context") or "Not provided"}
132
- Error hint: {obs.get("error_hint") or "None"}
133
- Your previous attempt: {obs.get("previous_attempt") or "None"}
134
- Feedback: {obs.get("feedback") or "None"}
 
135
 
136
  Recent history:
137
  {history_block}
@@ -145,7 +149,7 @@ def get_model_action(
145
  model=MODEL_NAME,
146
  messages=[
147
  {"role": "system", "content": SYSTEM_PROMPT},
148
- {"role": "user", "content": user_prompt},
149
  ],
150
  temperature=0.2,
151
  max_tokens=300,
@@ -167,17 +171,14 @@ async def run_task(task_name: str) -> None:
167
  Run one full episode for `task_name`.
168
 
169
  The [END] log line is ALWAYS emitted via the finally block, even if an
170
- exception occurs mid-episode or the reset call fails. This is required
171
- by the hackathon spec to avoid disqualification.
172
  """
173
- # Initialise all accumulators BEFORE the try so finally can always read them
174
  rewards: List[float] = []
175
- history: List[str] = []
176
  steps_taken = 0
177
- score = 0.0
178
- success = False
179
 
180
- # Build LLM client (best-effort; None means heuristic-only mode)
181
  client = None
182
  if OpenAI is not None and API_KEY not in {"", "dummy"}:
183
  try:
@@ -191,47 +192,52 @@ async def run_task(task_name: str) -> None:
191
  try:
192
  http = httpx.AsyncClient(base_url=ENV_URL, timeout=60.0)
193
 
194
- # --- reset ---------------------------------------------------------
195
  reset_failed = False
196
  obs: dict = {}
197
  try:
198
- reset_resp = await http.post("/reset", json={"difficulty": task_name})
 
 
199
  reset_resp.raise_for_status()
200
  reset_data = reset_resp.json()
 
201
  obs = reset_data.get("observation", reset_data)
202
  except Exception as exc:
203
  print(f"[DEBUG] Reset failed: {exc}", flush=True)
204
- # Do NOT return here β€” fall through to finally so [END] is always logged
205
  reset_failed = True
206
 
207
  if not reset_failed:
208
- # --- step loop -------------------------------------------------
209
  for step in range(1, MAX_STEPS + 1):
210
- # Get action (never raises β€” heuristic is the ultimate fallback)
211
  try:
212
  action_str = get_model_action(client, obs, history)
213
  except Exception as exc:
214
  print(f"[DEBUG] Model action failed: {exc}", flush=True)
215
- action_str = heuristic_correct_sql(obs.get("broken_query", ""))
 
 
216
 
217
- # Submit action to environment
218
  try:
219
- step_resp = await http.post("/step", json={"action":{"corrected_query": action_str}})
 
 
 
 
220
  step_resp.raise_for_status()
221
  result = step_resp.json()
222
  except Exception as exc:
223
  print(f"[DEBUG] Step {step} request failed: {exc}", flush=True)
224
- # Treat as a 0-reward terminal step so episode ends cleanly
225
  rewards.append(0.0)
226
  steps_taken = step
227
  log_step(step, action_str, 0.0, True, str(exc))
228
  break
229
 
230
- obs = result.get("observation", obs)
231
  reward = float(result.get("reward", 0.0))
232
- done = bool(result.get("done", False))
233
- info = result.get("info")
234
- error = info.get("error") if isinstance(info, dict) else None
235
 
236
  rewards.append(reward)
237
  steps_taken = step
@@ -244,23 +250,19 @@ async def run_task(task_name: str) -> None:
244
  if done:
245
  break
246
 
247
- # Score = average reward across all steps, clamped to [0, 1]
248
  if rewards:
249
- score = min(max(sum(rewards) / len(rewards), 0.0), 1.0)
250
  success = score >= SUCCESS_SCORE_THRESHOLD
251
 
252
  except Exception as exc:
253
- # Catch-all for any unexpected error in the episode body
254
  print(f"[DEBUG] Unhandled episode error: {exc}", flush=True)
255
 
256
  finally:
257
- # Always close the HTTP client
258
  if http is not None:
259
  try:
260
  await http.aclose()
261
  except Exception as exc:
262
  print(f"[DEBUG] HTTP close error: {exc}", flush=True)
263
- # [END] MUST always be emitted β€” even after reset failure or exception
264
  log_end(success, steps_taken, score, rewards)
265
 
266
 
@@ -269,13 +271,8 @@ async def run_task(task_name: str) -> None:
269
  # ---------------------------------------------------------------------------
270
 
271
  async def main() -> None:
272
- """
273
- Run tasks according to SQL_ENV_TASK.
274
- If SQL_ENV_TASK is a single valid difficulty, run only that task.
275
- Otherwise run all three in sequence so all 3 tasks produce scores.
276
- """
277
  try:
278
- # Always run all 3 tasks β€” validator counts 3 [END] lines
279
  for difficulty in ("easy", "medium", "hard"):
280
  await run_task(difficulty)
281
  print("", flush=True)
 
26
  OpenAI = None
27
 
28
  API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
29
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
30
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY", "dummy")
31
+ TASK_NAME = os.getenv("SQL_ENV_TASK", "easy")
32
+ BENCHMARK = "sql-correction-env"
33
+ ENV_URL = os.getenv("ENV_URL", "http://localhost:7860")
34
+ MAX_STEPS = 8
35
  SUCCESS_SCORE_THRESHOLD = 0.5
36
 
37
 
 
52
  ) -> None:
53
  err = error if error else "null"
54
  done_val = str(done).lower()
 
55
  action_clean = action.replace("\n", " ").replace("\r", "").strip()
56
  print(
57
  f"[STEP] step={step} action={action_clean} "
 
79
  You will be shown a broken SQL query that contains typos or keyword errors.
80
  Fix ALL errors and return ONLY the corrected SQL query.
81
  No explanation, no markdown, no code blocks, no backticks.
82
+ Common keyword typos to watch for:
83
+ FORM->FROM, WEHRE->WHERE, WHER->WHERE,
84
+ GRUP->GROUP, HAVNG->HAVING, ORDR->ORDER,
85
+ INNE->INNER, LFT->LEFT, BETWEN->BETWEEN,
86
+ DSC->DESC, SELCT->SELECT, LIMT->LIMIT.
87
+ Also watch for column name errors described in the schema context.
88
  """
89
  ).strip()
90
 
 
91
  SQL_REPLACEMENTS = {
92
+ "FORM": "FROM",
93
+ "WEHRE": "WHERE",
94
+ "WHER": "WHERE",
95
+ "GRUP": "GROUP",
96
+ "HAVNG": "HAVING",
97
+ "ORDR": "ORDER",
98
+ "INNE": "INNER",
99
+ "LFT": "LEFT",
100
  "BETWEN": "BETWEEN",
101
+ "DSC": "DESC",
102
+ "SELCT": "SELECT",
103
+ "LIMT": "LIMIT",
 
104
  }
105
 
106
 
 
115
 
116
 
117
  def get_model_action(
118
+ client: Optional["OpenAI"],
119
+ obs: dict,
120
+ history: List[str],
121
  ) -> str:
122
  """Return a corrected SQL string. Falls back to heuristic on any failure."""
123
  heuristic = heuristic_correct_sql(obs.get("broken_query", ""))
 
131
  Broken SQL query:
132
  {obs.get("broken_query", "")}
133
 
134
+ Schema context: {obs.get("schema_context") or "Not provided"}
135
+ Error hint: {obs.get("error_hint") or "None"}
136
+ Steps remaining: {obs.get("steps_remaining", "?")}
137
+ Previous attempt: {obs.get("previous_attempt") or "None"}
138
+ Feedback: {obs.get("feedback") or "None"}
139
 
140
  Recent history:
141
  {history_block}
 
149
  model=MODEL_NAME,
150
  messages=[
151
  {"role": "system", "content": SYSTEM_PROMPT},
152
+ {"role": "user", "content": user_prompt},
153
  ],
154
  temperature=0.2,
155
  max_tokens=300,
 
171
  Run one full episode for `task_name`.
172
 
173
  The [END] log line is ALWAYS emitted via the finally block, even if an
174
+ exception occurs mid-episode or the reset call fails.
 
175
  """
 
176
  rewards: List[float] = []
177
+ history: List[str] = []
178
  steps_taken = 0
179
+ score = 0.0
180
+ success = False
181
 
 
182
  client = None
183
  if OpenAI is not None and API_KEY not in {"", "dummy"}:
184
  try:
 
192
  try:
193
  http = httpx.AsyncClient(base_url=ENV_URL, timeout=60.0)
194
 
195
+ # ── reset ───────────────────────────────────────────���──────────────
196
  reset_failed = False
197
  obs: dict = {}
198
  try:
199
+ reset_resp = await http.post(
200
+ "/reset", json={"difficulty": task_name}
201
+ )
202
  reset_resp.raise_for_status()
203
  reset_data = reset_resp.json()
204
+ # The openenv wrapper may nest the observation under "observation"
205
  obs = reset_data.get("observation", reset_data)
206
  except Exception as exc:
207
  print(f"[DEBUG] Reset failed: {exc}", flush=True)
 
208
  reset_failed = True
209
 
210
  if not reset_failed:
211
+ # ── step loop ──────────────────────────────────────────────────
212
  for step in range(1, MAX_STEPS + 1):
 
213
  try:
214
  action_str = get_model_action(client, obs, history)
215
  except Exception as exc:
216
  print(f"[DEBUG] Model action failed: {exc}", flush=True)
217
+ action_str = heuristic_correct_sql(
218
+ obs.get("broken_query", "")
219
+ )
220
 
 
221
  try:
222
+ # Action must be wrapped under {"action": {...}}
223
+ step_resp = await http.post(
224
+ "/step",
225
+ json={"action": {"corrected_query": action_str}},
226
+ )
227
  step_resp.raise_for_status()
228
  result = step_resp.json()
229
  except Exception as exc:
230
  print(f"[DEBUG] Step {step} request failed: {exc}", flush=True)
 
231
  rewards.append(0.0)
232
  steps_taken = step
233
  log_step(step, action_str, 0.0, True, str(exc))
234
  break
235
 
236
+ obs = result.get("observation", obs)
237
  reward = float(result.get("reward", 0.0))
238
+ done = bool(result.get("done", False))
239
+ info = result.get("info")
240
+ error = info.get("error") if isinstance(info, dict) else None
241
 
242
  rewards.append(reward)
243
  steps_taken = step
 
250
  if done:
251
  break
252
 
 
253
  if rewards:
254
+ score = min(max(sum(rewards) / len(rewards), 0.0), 1.0)
255
  success = score >= SUCCESS_SCORE_THRESHOLD
256
 
257
  except Exception as exc:
 
258
  print(f"[DEBUG] Unhandled episode error: {exc}", flush=True)
259
 
260
  finally:
 
261
  if http is not None:
262
  try:
263
  await http.aclose()
264
  except Exception as exc:
265
  print(f"[DEBUG] HTTP close error: {exc}", flush=True)
 
266
  log_end(success, steps_taken, score, rewards)
267
 
268
 
 
271
  # ---------------------------------------------------------------------------
272
 
273
  async def main() -> None:
274
+ """Run all three difficulties in sequence so validator sees 3 [END] lines."""
 
 
 
 
275
  try:
 
276
  for difficulty in ("easy", "medium", "hard"):
277
  await run_task(difficulty)
278
  print("", flush=True)
openenv.yaml CHANGED
@@ -13,30 +13,36 @@ spec_version: 1
13
  name: sql-correction-env
14
  type: sequential
15
  runtime: docker
16
- app: server.app:app
17
  port: 7860
18
 
19
  description: >
20
  An OpenEnv RL environment where an AI agent fixes broken SQL queries.
21
  Simulates a real developer task: identifying and correcting SQL syntax
22
  and logical errors across easy, medium, and hard difficulty levels.
 
23
 
24
  author: SyncShift
25
 
26
  tasks:
27
  - id: easy
28
- description: Fix a single syntax error. Error hint provided.
29
  steps: 5
 
30
  ideal_action: correct_sql
31
 
32
  - id: medium
33
  description: Fix multiple errors across keywords and clauses. No hint.
34
  steps: 5
 
35
  ideal_action: correct_sql
36
 
37
  - id: hard
38
- description: Fix many errors in complex multi-join queries. Schema provided.
 
 
39
  steps: 4
 
40
  ideal_action: correct_sql
41
 
42
  observation_space:
@@ -54,6 +60,8 @@ observation_space:
54
  nullable: true
55
  step_number:
56
  type: integer
 
 
57
  previous_attempt:
58
  type: string
59
  nullable: true
@@ -68,17 +76,19 @@ action_space:
68
  type: string
69
 
70
  reward:
71
- range: [0.001, 0.999]
72
  description: >
73
- 0.999 = exact match, 0.7 = right tokens minor structure diff,
74
- 0.4 = most keywords correct, 0.2 = basic structure present, 0.001 = invalid SQL.
 
 
75
 
76
  scoring:
77
- reward_range: [0.001, 0.999]
78
  success_threshold: 0.5
79
  score_formula: mean(step_rewards)
80
 
81
  constraints:
82
  max_runtime_seconds: 1200
83
  max_memory_gb: 8
84
- max_vcpu: 2
 
13
  name: sql-correction-env
14
  type: sequential
15
  runtime: docker
16
+ app: server:app
17
  port: 7860
18
 
19
  description: >
20
  An OpenEnv RL environment where an AI agent fixes broken SQL queries.
21
  Simulates a real developer task: identifying and correcting SQL syntax
22
  and logical errors across easy, medium, and hard difficulty levels.
23
+ Includes 40 tasks total with partial-credit grading and a stagnation penalty.
24
 
25
  author: SyncShift
26
 
27
  tasks:
28
  - id: easy
29
+ description: Fix a single keyword typo. Error hint provided.
30
  steps: 5
31
+ count: 15
32
  ideal_action: correct_sql
33
 
34
  - id: medium
35
  description: Fix multiple errors across keywords and clauses. No hint.
36
  steps: 5
37
+ count: 15
38
  ideal_action: correct_sql
39
 
40
  - id: hard
41
+ description: >
42
+ Fix many errors in complex multi-join queries including column name
43
+ mismatches. Schema provided, no hint.
44
  steps: 4
45
+ count: 10
46
  ideal_action: correct_sql
47
 
48
  observation_space:
 
60
  nullable: true
61
  step_number:
62
  type: integer
63
+ steps_remaining:
64
+ type: integer
65
  previous_attempt:
66
  type: string
67
  nullable: true
 
76
  type: string
77
 
78
  reward:
79
+ range: [0.0, 1.0]
80
  description: >
81
+ 1.0 = exact match, 0.7 = right tokens minor structure diff,
82
+ 0.4 = most keywords correct, 0.3 = partial match,
83
+ 0.2 = basic structure present, 0.0 = invalid SQL.
84
+ Stagnation penalty of -0.1 applied after 2+ identical-reward steps.
85
 
86
  scoring:
87
+ reward_range: [0.0, 1.0]
88
  success_threshold: 0.5
89
  score_formula: mean(step_rewards)
90
 
91
  constraints:
92
  max_runtime_seconds: 1200
93
  max_memory_gb: 8
94
+ max_vcpu: 2
pyproject.toml CHANGED
@@ -4,14 +4,17 @@ version = "1.0.0"
4
  description = "OpenEnv SQL Query Correction RL Environment"
5
  requires-python = ">=3.11"
6
  dependencies = [
7
- "fastapi",
8
- "uvicorn",
9
- "pydantic",
10
- "httpx",
11
- "openai",
12
- "openenv-core",
13
  ]
14
 
 
 
 
15
  [build-system]
16
  requires = ["hatchling"]
17
  build-backend = "hatchling.build"
@@ -21,3 +24,9 @@ packages = ["sql_env"]
21
 
22
  [project.scripts]
23
  server = "sql_env.server:main"
 
 
 
 
 
 
 
4
  description = "OpenEnv SQL Query Correction RL Environment"
5
  requires-python = ">=3.11"
6
  dependencies = [
7
+ "fastapi>=0.135.0",
8
+ "uvicorn>=0.43.0",
9
+ "pydantic>=2.12.0",
10
+ "httpx>=0.28.1",
11
+ "openai>=2.7.2",
12
+ "openenv-core==0.2.3",
13
  ]
14
 
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=8.0.0"]
17
+
18
  [build-system]
19
  requires = ["hatchling"]
20
  build-backend = "hatchling.build"
 
24
 
25
  [project.scripts]
26
  server = "sql_env.server:main"
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+ python_files = ["test_*.py"]
31
+ python_classes = ["Test*"]
32
+ python_functions = ["test_*"]
requirements.txt CHANGED
@@ -1,6 +1,7 @@
1
- fastapi
2
- uvicorn
3
- pydantic
4
  httpx>=0.28.1
5
  openai>=2.7.2
6
- openenv-core==0.2.3
 
 
1
+ fastapi>=0.135.0
2
+ uvicorn>=0.43.0
3
+ pydantic>=2.12.0
4
  httpx>=0.28.1
5
  openai>=2.7.2
6
+ openenv-core==0.2.3
7
+ pytest>=8.0.0
server.log DELETED
@@ -1,2 +0,0 @@
1
- nohup: ignoring input
2
- ERROR: Error loading ASGI app. Could not import module "app".
 
 
 
sql_env/__init__.py CHANGED
@@ -11,4 +11,4 @@ __all__ = [
11
  "StepResult",
12
  "ALL_TASKS",
13
  "TASK_SETS",
14
- ]
 
11
  "StepResult",
12
  "ALL_TASKS",
13
  "TASK_SETS",
14
+ ]
sql_env/env.py CHANGED
@@ -13,7 +13,13 @@ class SQLCorrectionEnv:
13
 
14
  The agent receives a broken SQL query and must return the corrected version.
15
  Reward is shaped across the full trajectory β€” partial credit is given for
16
- incremental improvements, penalizing stagnation and infinite loops.
 
 
 
 
 
 
17
  """
18
 
19
  def __init__(self, difficulty: str = "easy", task_index: Optional[int] = None):
@@ -30,7 +36,7 @@ class SQLCorrectionEnv:
30
  self._last_reward: float = 0.0
31
  self._stagnation_count: int = 0
32
 
33
- # ── OpenEnv Interface ─────────────────────────────────────
34
 
35
  async def reset(self) -> SQLObservation:
36
  """Reset the environment and return the initial observation."""
@@ -61,27 +67,24 @@ class SQLCorrectionEnv:
61
 
62
  self._step_count += 1
63
 
64
- # grade the action
65
  reward_model = grade(action, self._task)
66
  reward = reward_model.value
67
 
68
- # detect stagnation (same reward twice in a row) β€” penalize
69
  if abs(reward - self._last_reward) < 0.01 and self._step_count > 1:
70
  self._stagnation_count += 1
71
  if self._stagnation_count >= 2:
72
- reward = max(0.001, reward - 0.1) # stagnation penalty
73
  else:
74
  self._stagnation_count = 0
75
 
76
  self._last_reward = reward
77
 
78
- # generate feedback for the next observation
79
  feedback = generate_feedback(action, self._task, reward_model)
80
  self._last_feedback = feedback
81
  self._previous_attempt = action.corrected_query
82
 
83
- # episode ends on perfect score or max steps reached
84
- done = reward_model.value >= 0.99 or self._step_count >= self._task.max_steps
85
  self._done = done
86
 
87
  obs = self._make_observation()
@@ -95,7 +98,7 @@ class SQLCorrectionEnv:
95
  "step": self._step_count,
96
  "max_steps": self._task.max_steps,
97
  "task_id": self._task.task_id,
98
- }
99
  )
100
 
101
  async def state(self) -> dict:
@@ -117,16 +120,18 @@ class SQLCorrectionEnv:
117
  self._task = None
118
  self._done = True
119
 
120
- # ── Internal ──────────────────────────────────────────────
121
 
122
  def _make_observation(self) -> SQLObservation:
123
  assert self._task is not None
 
124
  return SQLObservation(
125
  task_id=self._task.task_id,
126
  broken_query=self._task.broken_query,
127
  schema_context=self._task.schema_context,
128
  error_hint=self._task.error_hint if self.difficulty == "easy" else None,
129
  step_number=self._step_count,
 
130
  previous_attempt=self._previous_attempt,
131
  feedback=self._last_feedback,
132
  )
 
13
 
14
  The agent receives a broken SQL query and must return the corrected version.
15
  Reward is shaped across the full trajectory β€” partial credit is given for
16
+ incremental improvements, penalizing stagnation.
17
+
18
+ Usage::
19
+
20
+ env = SQLCorrectionEnv(difficulty="easy")
21
+ obs = await env.reset()
22
+ result = await env.step(SQLAction(corrected_query="SELECT * FROM users"))
23
  """
24
 
25
  def __init__(self, difficulty: str = "easy", task_index: Optional[int] = None):
 
36
  self._last_reward: float = 0.0
37
  self._stagnation_count: int = 0
38
 
39
+ # ── OpenEnv Interface ─────────────────────────────────────────────────────
40
 
41
  async def reset(self) -> SQLObservation:
42
  """Reset the environment and return the initial observation."""
 
67
 
68
  self._step_count += 1
69
 
 
70
  reward_model = grade(action, self._task)
71
  reward = reward_model.value
72
 
73
+ # Stagnation penalty
74
  if abs(reward - self._last_reward) < 0.01 and self._step_count > 1:
75
  self._stagnation_count += 1
76
  if self._stagnation_count >= 2:
77
+ reward = max(0.0, reward - 0.1)
78
  else:
79
  self._stagnation_count = 0
80
 
81
  self._last_reward = reward
82
 
 
83
  feedback = generate_feedback(action, self._task, reward_model)
84
  self._last_feedback = feedback
85
  self._previous_attempt = action.corrected_query
86
 
87
+ done = reward_model.value >= 0.95 or self._step_count >= self._task.max_steps
 
88
  self._done = done
89
 
90
  obs = self._make_observation()
 
98
  "step": self._step_count,
99
  "max_steps": self._task.max_steps,
100
  "task_id": self._task.task_id,
101
+ },
102
  )
103
 
104
  async def state(self) -> dict:
 
120
  self._task = None
121
  self._done = True
122
 
123
+ # ── Internal ──────────────────────────────────────────────────────────────
124
 
125
  def _make_observation(self) -> SQLObservation:
126
  assert self._task is not None
127
+ steps_remaining = max(0, self._task.max_steps - self._step_count)
128
  return SQLObservation(
129
  task_id=self._task.task_id,
130
  broken_query=self._task.broken_query,
131
  schema_context=self._task.schema_context,
132
  error_hint=self._task.error_hint if self.difficulty == "easy" else None,
133
  step_number=self._step_count,
134
+ steps_remaining=steps_remaining,
135
  previous_attempt=self._previous_attempt,
136
  feedback=self._last_feedback,
137
  )
sql_env/grader.py CHANGED
@@ -2,11 +2,6 @@ import re
2
  from sql_env.models import SQLAction, SQLTask, SQLReward
3
 
4
 
5
- def _clamp(value: float) -> float:
6
- """Ensure score is strictly between 0 and 1 (gt=0.0, lt=1.0)."""
7
- return max(0.001, min(0.999, value))
8
-
9
-
10
  def _normalize(query: str) -> str:
11
  """Uppercase, collapse whitespace, strip trailing semicolons."""
12
  q = query.strip().upper()
@@ -27,7 +22,7 @@ def _sql_keywords_present(query: str) -> set:
27
  'ORDER', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'OUTER',
28
  'BETWEEN', 'DESC', 'ASC', 'LIMIT', 'COUNT', 'SUM',
29
  'AVG', 'MAX', 'MIN', 'AS', 'ON', 'AND', 'OR', 'NOT',
30
- 'IN', 'LIKE', 'IS', 'NULL', 'DISTINCT'
31
  }
32
  normed = _normalize(query)
33
  found = set()
@@ -39,34 +34,32 @@ def _sql_keywords_present(query: str) -> set:
39
 
40
  def grade(action: SQLAction, task: SQLTask) -> SQLReward:
41
  """
42
- 4-level grader with partial progress signals.
43
- 0.99 β€” exact normalized match
44
- 0.7 β€” same tokens, minor whitespace/alias differences
45
- 0.4 β€” key SQL keywords all present and correct table/column names
 
 
46
  0.2 β€” basic SELECT/FROM structure present
47
- 0.01 β€” completely wrong
48
- All scores are clamped to be strictly between 0.0 and 1.0.
49
  """
50
  agent = _normalize(action.corrected_query)
51
  correct = _normalize(task.canonical_answer)
52
 
53
- # ── Level 1: Exact match ─────────────────────────────────
54
  if agent == correct:
55
- return SQLReward(
56
- value=_clamp(0.999),
57
- reason="Exact match β€” perfect correction."
58
- )
59
 
60
- # ── Level 2: Same token set (right words, minor ordering) ─
61
  agent_tokens = _tokenize(action.corrected_query)
62
  correct_tokens = _tokenize(task.canonical_answer)
63
  if agent_tokens == correct_tokens:
64
  return SQLReward(
65
- value=_clamp(0.7),
66
- reason="All correct tokens present but structure differs slightly."
67
  )
68
 
69
- # ── Level 3: Most keywords correct + high token overlap ───
70
  correct_kws = _sql_keywords_present(task.canonical_answer)
71
  agent_kws = _sql_keywords_present(action.corrected_query)
72
  kw_overlap = len(correct_kws & agent_kws) / max(len(correct_kws), 1)
@@ -74,38 +67,49 @@ def grade(action: SQLAction, task: SQLTask) -> SQLReward:
74
 
75
  if kw_overlap >= 0.85 and token_overlap >= 0.75:
76
  return SQLReward(
77
- value=_clamp(0.4),
78
- reason=f"Most keywords correct ({kw_overlap:.0%} keyword match, {token_overlap:.0%} token match)."
 
 
 
79
  )
80
 
81
- # ── Level 3.5: Partial keyword and structure match ────────
82
- if kw_overlap >= 0.7 and token_overlap >= 0.55:
83
  return SQLReward(
84
- value=_clamp(0.3),
85
- reason=f"Partial keyword and structure match ({kw_overlap:.0%} keyword match, {token_overlap:.0%} token match)."
 
 
 
86
  )
87
 
88
- # ── Level 4: Basic structure present ─────────────────────
89
  if 'SELECT' in agent and 'FROM' in agent:
90
  return SQLReward(
91
- value=_clamp(0.2),
92
- reason="Basic SELECT/FROM structure present but significant errors remain."
93
  )
94
 
95
- # ── Level 0: No recognizable SQL ─────────────────────────
96
- return SQLReward(value=_clamp(0.001), reason="Response is not valid SQL.")
97
 
98
 
99
  def generate_feedback(action: SQLAction, task: SQLTask, reward: SQLReward) -> str:
100
- """Human-readable feedback shown in next observation."""
101
- if reward.value >= 0.99:
102
  return "Correct! Query matches perfectly."
103
  if reward.value >= 0.7:
104
  return "Very close β€” check spacing or minor clause differences."
105
  if reward.value >= 0.4:
106
- return "Good progress β€” most keywords are right, but check for typos in keywords or column names."
 
 
 
107
  if reward.value >= 0.3:
108
  return "Partial match β€” right direction but several keywords or columns are off."
109
  if reward.value >= 0.2:
110
- return "Basic structure is there β€” look carefully at every SQL keyword for typos."
111
- return "The response doesn't look like valid SQL. Start with SELECT ... FROM ..."
 
 
 
2
  from sql_env.models import SQLAction, SQLTask, SQLReward
3
 
4
 
 
 
 
 
 
5
  def _normalize(query: str) -> str:
6
  """Uppercase, collapse whitespace, strip trailing semicolons."""
7
  q = query.strip().upper()
 
22
  'ORDER', 'JOIN', 'INNER', 'LEFT', 'RIGHT', 'OUTER',
23
  'BETWEEN', 'DESC', 'ASC', 'LIMIT', 'COUNT', 'SUM',
24
  'AVG', 'MAX', 'MIN', 'AS', 'ON', 'AND', 'OR', 'NOT',
25
+ 'IN', 'LIKE', 'IS', 'NULL', 'DISTINCT',
26
  }
27
  normed = _normalize(query)
28
  found = set()
 
34
 
35
  def grade(action: SQLAction, task: SQLTask) -> SQLReward:
36
  """
37
+ 5-level grader with partial progress signals.
38
+
39
+ 1.0 β€” exact normalized match (perfect fix)
40
+ 0.7 β€” same token set, minor structural/whitespace differences
41
+ 0.4 β€” most SQL keywords correct AND high token overlap
42
+ 0.3 β€” partial keyword and structure match
43
  0.2 β€” basic SELECT/FROM structure present
44
+ 0.0 β€” not recognizable SQL
 
45
  """
46
  agent = _normalize(action.corrected_query)
47
  correct = _normalize(task.canonical_answer)
48
 
49
+ # ── Level 1: Exact match ─────────────────────────────────────────────────
50
  if agent == correct:
51
+ return SQLReward(value=1.0, reason="Exact match β€” perfect correction.")
 
 
 
52
 
53
+ # ── Level 2: Same token set (right words, minor ordering/alias diff) ─────
54
  agent_tokens = _tokenize(action.corrected_query)
55
  correct_tokens = _tokenize(task.canonical_answer)
56
  if agent_tokens == correct_tokens:
57
  return SQLReward(
58
+ value=0.7,
59
+ reason="All correct tokens present but structure differs slightly.",
60
  )
61
 
62
+ # ── Level 3: Most keywords correct + high token overlap ──────────────────
63
  correct_kws = _sql_keywords_present(task.canonical_answer)
64
  agent_kws = _sql_keywords_present(action.corrected_query)
65
  kw_overlap = len(correct_kws & agent_kws) / max(len(correct_kws), 1)
 
67
 
68
  if kw_overlap >= 0.85 and token_overlap >= 0.75:
69
  return SQLReward(
70
+ value=0.4,
71
+ reason=(
72
+ f"Most keywords correct "
73
+ f"({kw_overlap:.0%} keyword match, {token_overlap:.0%} token match)."
74
+ ),
75
  )
76
 
77
+ # ── Level 3.5: Partial keyword and structure match ────────────────────────
78
+ if kw_overlap >= 0.65 and token_overlap >= 0.50:
79
  return SQLReward(
80
+ value=0.3,
81
+ reason=(
82
+ f"Partial keyword and structure match "
83
+ f"({kw_overlap:.0%} keyword match, {token_overlap:.0%} token match)."
84
+ ),
85
  )
86
 
87
+ # ── Level 4: Basic structure present ─────────────────────────────────────
88
  if 'SELECT' in agent and 'FROM' in agent:
89
  return SQLReward(
90
+ value=0.2,
91
+ reason="Basic SELECT/FROM structure present but significant errors remain.",
92
  )
93
 
94
+ # ── Level 0: No recognizable SQL ─────────────────────────────────────────
95
+ return SQLReward(value=0.0, reason="Response is not valid SQL.")
96
 
97
 
98
  def generate_feedback(action: SQLAction, task: SQLTask, reward: SQLReward) -> str:
99
+ """Human-readable feedback shown in the next observation."""
100
+ if reward.value >= 1.0:
101
  return "Correct! Query matches perfectly."
102
  if reward.value >= 0.7:
103
  return "Very close β€” check spacing or minor clause differences."
104
  if reward.value >= 0.4:
105
+ return (
106
+ "Good progress β€” most keywords are right, "
107
+ "but check for typos in keywords or column names."
108
+ )
109
  if reward.value >= 0.3:
110
  return "Partial match β€” right direction but several keywords or columns are off."
111
  if reward.value >= 0.2:
112
+ return (
113
+ "Basic structure is there β€” look carefully at every SQL keyword for typos."
114
+ )
115
+ return "The response doesn't look like valid SQL. Start with SELECT ... FROM ..."
sql_env/models.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import List, Optional, Any, Callable
2
  from openenv.core.env_server.types import Action, Observation, State
3
  from pydantic import Field, BaseModel
4
 
@@ -8,15 +8,27 @@ class SQLAction(Action):
8
 
9
 
10
  class SQLObservation(Observation):
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  task_id: str
12
  broken_query: str
13
  schema_context: Optional[str] = None
14
  error_hint: Optional[str] = None
15
  step_number: int
 
16
  previous_attempt: Optional[str] = None
17
  feedback: Optional[str] = None
18
- reward: float = 0.001
19
- done: bool = False
20
 
21
 
22
  class SQLState(State):
@@ -30,7 +42,8 @@ class SQLState(State):
30
 
31
 
32
  class SQLReward(BaseModel):
33
- value: float = Field(gt=0.0, lt=1.0)
 
34
  reason: str
35
 
36
 
@@ -51,4 +64,4 @@ class StepResult(BaseModel):
51
  observation: SQLObservation
52
  reward: float
53
  done: bool
54
- info: dict = Field(default_factory=dict)
 
1
+ from typing import List, Optional, Any
2
  from openenv.core.env_server.types import Action, Observation, State
3
  from pydantic import Field, BaseModel
4
 
 
8
 
9
 
10
  class SQLObservation(Observation):
11
+ """
12
+ Observation returned to the agent each step.
13
+
14
+ Fields:
15
+ task_id: Unique identifier for the current task instance.
16
+ broken_query: The malformed SQL query the agent must fix.
17
+ schema_context: Table/column definitions (hard tasks only).
18
+ error_hint: Plain-language hint about the error (easy tasks only).
19
+ step_number: Current step within the episode (0 = initial observation).
20
+ steps_remaining: How many steps are left before the episode ends.
21
+ previous_attempt: The agent's SQL output from the previous step.
22
+ feedback: Grader feedback on the previous attempt.
23
+ """
24
  task_id: str
25
  broken_query: str
26
  schema_context: Optional[str] = None
27
  error_hint: Optional[str] = None
28
  step_number: int
29
+ steps_remaining: Optional[int] = None
30
  previous_attempt: Optional[str] = None
31
  feedback: Optional[str] = None
 
 
32
 
33
 
34
  class SQLState(State):
 
42
 
43
 
44
  class SQLReward(BaseModel):
45
+ # Allow full [0.0, 1.0] range so perfect matches can return exactly 1.0
46
+ value: float = Field(ge=0.0, le=1.0)
47
  reason: str
48
 
49
 
 
64
  observation: SQLObservation
65
  reward: float
66
  done: bool
67
+ info: dict = Field(default_factory=dict)
sql_env/server.py CHANGED
@@ -2,10 +2,8 @@
2
  FastAPI server using openenv.core base classes β€” required for validator.
3
  """
4
  import random
5
- from typing import Optional
6
  from openenv.core.env_server.http_server import create_app
7
  from openenv.core.env_server.interfaces import Environment
8
- from openenv.core.env_server.types import State
9
 
10
  try:
11
  from sql_env.models import SQLAction, SQLObservation, SQLState
@@ -27,12 +25,13 @@ class SQLCorrectionEnvironment(Environment):
27
  self._done = False
28
  self._last_reward = 0.0
29
  self._rewards_history = []
 
30
 
31
  def reset(self, seed=None, episode_id=None, **kwargs) -> SQLObservation:
32
  actual_difficulty = (
33
- kwargs.get("task_id") or
34
- kwargs.get("difficulty") or
35
- "easy"
36
  )
37
  self._difficulty = actual_difficulty
38
  tasks = TASK_SETS.get(actual_difficulty, TASK_SETS["easy"])
@@ -41,39 +40,37 @@ class SQLCorrectionEnvironment(Environment):
41
  self._done = False
42
  self._last_reward = 0.0
43
  self._rewards_history = []
44
- return SQLObservation(
45
- task_id=self._current_task.task_id,
46
- broken_query=self._current_task.broken_query,
47
- schema_context=self._current_task.schema_context,
48
- error_hint=self._current_task.error_hint,
49
- step_number=0,
50
- previous_attempt=None,
51
- feedback=None,
52
- reward=0.001,
53
- done=False,
54
- )
55
 
56
  def step(self, action: SQLAction) -> SQLObservation:
57
  if self._current_task is None:
58
  self.reset()
 
59
  self._step_count += 1
60
  reward_obj = grade(action, self._current_task)
61
  reward = reward_obj.value
 
 
 
 
 
 
 
 
 
62
  self._last_reward = reward
63
  self._rewards_history.append(reward)
64
- done = (reward >= 0.95) or (self._step_count >= self._current_task.max_steps)
 
 
 
65
  self._done = done
66
  feedback = generate_feedback(action, self._current_task, reward_obj)
67
- return SQLObservation(
68
- task_id=self._current_task.task_id,
69
- broken_query=self._current_task.broken_query,
70
- schema_context=self._current_task.schema_context,
71
- error_hint=self._current_task.error_hint,
72
- step_number=self._step_count,
73
  previous_attempt=action.corrected_query,
74
  feedback=feedback,
75
- reward=reward,
76
- done=done,
77
  )
78
 
79
  @property
@@ -97,96 +94,33 @@ class SQLCorrectionEnvironment(Environment):
97
  last_reward=self._last_reward,
98
  rewards_history=self._rewards_history,
99
  )
100
- def step(self, action: SQLAction) -> SQLObservation:
101
- if self._current_task is None:
102
- self.reset()
103
- self._step_count += 1
104
- reward_obj = grade(action, self._current_task)
105
- reward = reward_obj.value
106
- self._last_reward = reward
107
- self._rewards_history.append(reward)
108
- done = (reward >= 0.95) or (self._step_count >= self._current_task.max_steps)
109
- self._done = done
110
- feedback = generate_feedback(action, self._current_task, reward_obj)
111
  return SQLObservation(
112
  task_id=self._current_task.task_id,
113
  broken_query=self._current_task.broken_query,
114
  schema_context=self._current_task.schema_context,
115
- error_hint=self._current_task.error_hint,
 
 
 
 
 
116
  step_number=self._step_count,
117
- previous_attempt=action.corrected_query,
 
118
  feedback=feedback,
119
- reward=reward,
120
- done=done,
121
  )
122
 
123
- def state(self) -> SQLState:
124
- if self._current_task is None:
125
- return SQLState(
126
- task_id="none",
127
- difficulty="none",
128
- step_count=0,
129
- max_steps=0,
130
- done=False,
131
- last_reward=0.0,
132
- rewards_history=[],
133
- )
134
- return SQLState(
135
- task_id=self._current_task.task_id,
136
- difficulty=self._difficulty,
137
- step_count=self._step_count,
138
- max_steps=self._current_task.max_steps,
139
- done=self._done,
140
- last_reward=self._last_reward,
141
- rewards_history=self._rewards_history,
142
- )
143
-
144
- def step(self, action: SQLAction) -> SQLObservation:
145
- # Auto-reset if no task loaded (create_app may use fresh instances)
146
- if self._current_task is None:
147
- self.reset()
148
-
149
- self._step_count += 1
150
- reward_obj = grade(action, self._current_task)
151
- reward = reward_obj.value
152
- self._last_reward = reward
153
- self._rewards_history.append(reward)
154
- done = (reward >= 0.95) or (self._step_count >= self._current_task.max_steps)
155
- self._done = done
156
- feedback = generate_feedback(action, self._current_task, reward_obj)
157
- return SQLObservation(
158
- task_id=self._current_task.task_id,
159
- broken_query=self._current_task.broken_query,
160
- schema_context=self._current_task.schema_context,
161
- error_hint=self._current_task.error_hint,
162
- step_number=self._step_count,
163
- previous_attempt=action.corrected_query,
164
- feedback=feedback,
165
- reward=reward,
166
- done=done,
167
- )
168
-
169
- @property
170
- def state(self) -> SQLState:
171
- if self._current_task is None:
172
- return SQLState(
173
- task_id="none",
174
- difficulty="none",
175
- step_count=0,
176
- max_steps=0,
177
- done=False,
178
- last_reward=0.001,
179
- rewards_history=[],
180
- )
181
- return SQLState(
182
- task_id=self._current_task.task_id,
183
- difficulty=self._difficulty,
184
- step_count=self._step_count,
185
- max_steps=self._current_task.max_steps,
186
- done=self._done,
187
- last_reward=self._last_reward,
188
- rewards_history=self._rewards_history,
189
- )
190
 
191
  app = create_app(
192
  SQLCorrectionEnvironment,
@@ -196,6 +130,41 @@ app = create_app(
196
  )
197
 
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  def main():
200
  import uvicorn
201
  uvicorn.run(app, host="0.0.0.0", port=7860)
@@ -203,14 +172,3 @@ def main():
203
 
204
  if __name__ == "__main__":
205
  main()
206
- from fastapi import Request
207
-
208
- @app.get("/tasks")
209
- async def list_tasks():
210
- return {
211
- "tasks": [
212
- {"id": "easy", "difficulty": "easy", "description": "Fix a single syntax error.", "steps": 5, "ideal_action": "correct_sql", "has_grader": True, "grader": "sql_env.grader.grade"},
213
- {"id": "medium", "difficulty": "medium", "description": "Fix multiple errors.", "steps": 5, "ideal_action": "correct_sql", "has_grader": True, "grader": "sql_env.grader.grade"},
214
- {"id": "hard", "difficulty": "hard", "description": "Fix complex multi-join queries.", "steps": 4, "ideal_action": "correct_sql", "has_grader": True, "grader": "sql_env.grader.grade"},
215
- ]
216
- }
 
2
  FastAPI server using openenv.core base classes β€” required for validator.
3
  """
4
  import random
 
5
  from openenv.core.env_server.http_server import create_app
6
  from openenv.core.env_server.interfaces import Environment
 
7
 
8
  try:
9
  from sql_env.models import SQLAction, SQLObservation, SQLState
 
25
  self._done = False
26
  self._last_reward = 0.0
27
  self._rewards_history = []
28
+ self._stagnation_count = 0
29
 
30
  def reset(self, seed=None, episode_id=None, **kwargs) -> SQLObservation:
31
  actual_difficulty = (
32
+ kwargs.get("task_id")
33
+ or kwargs.get("difficulty")
34
+ or "easy"
35
  )
36
  self._difficulty = actual_difficulty
37
  tasks = TASK_SETS.get(actual_difficulty, TASK_SETS["easy"])
 
40
  self._done = False
41
  self._last_reward = 0.0
42
  self._rewards_history = []
43
+ self._stagnation_count = 0
44
+ return self._make_observation(previous_attempt=None, feedback=None)
 
 
 
 
 
 
 
 
 
45
 
46
  def step(self, action: SQLAction) -> SQLObservation:
47
  if self._current_task is None:
48
  self.reset()
49
+
50
  self._step_count += 1
51
  reward_obj = grade(action, self._current_task)
52
  reward = reward_obj.value
53
+
54
+ # Stagnation penalty: penalize repeating the same score
55
+ if abs(reward - self._last_reward) < 0.01 and self._step_count > 1:
56
+ self._stagnation_count += 1
57
+ if self._stagnation_count >= 2:
58
+ reward = max(0.0, reward - 0.1)
59
+ else:
60
+ self._stagnation_count = 0
61
+
62
  self._last_reward = reward
63
  self._rewards_history.append(reward)
64
+
65
+ done = (reward_obj.value >= 0.95) or (
66
+ self._step_count >= self._current_task.max_steps
67
+ )
68
  self._done = done
69
  feedback = generate_feedback(action, self._current_task, reward_obj)
70
+
71
+ return self._make_observation(
 
 
 
 
72
  previous_attempt=action.corrected_query,
73
  feedback=feedback,
 
 
74
  )
75
 
76
  @property
 
94
  last_reward=self._last_reward,
95
  rewards_history=self._rewards_history,
96
  )
97
+
98
+ # ── Internal helpers ──────────────────────────────────────────────────────
99
+
100
+ def _make_observation(
101
+ self,
102
+ previous_attempt: str | None,
103
+ feedback: str | None,
104
+ ) -> SQLObservation:
105
+ assert self._current_task is not None
106
+ max_steps = self._current_task.max_steps
107
+ steps_remaining = max(0, max_steps - self._step_count)
108
  return SQLObservation(
109
  task_id=self._current_task.task_id,
110
  broken_query=self._current_task.broken_query,
111
  schema_context=self._current_task.schema_context,
112
+ # Only surface the hint on easy tasks
113
+ error_hint=(
114
+ self._current_task.error_hint
115
+ if self._difficulty == "easy"
116
+ else None
117
+ ),
118
  step_number=self._step_count,
119
+ steps_remaining=steps_remaining,
120
+ previous_attempt=previous_attempt,
121
  feedback=feedback,
 
 
122
  )
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
 
125
  app = create_app(
126
  SQLCorrectionEnvironment,
 
130
  )
131
 
132
 
133
+ @app.get("/tasks")
134
+ async def list_tasks():
135
+ """List available task difficulties with metadata."""
136
+ return {
137
+ "tasks": [
138
+ {
139
+ "id": "easy",
140
+ "difficulty": "easy",
141
+ "description": "Fix a single SQL keyword typo. Error hint provided.",
142
+ "max_steps": 5,
143
+ "count": 15,
144
+ },
145
+ {
146
+ "id": "medium",
147
+ "difficulty": "medium",
148
+ "description": (
149
+ "Fix multiple errors across keywords and clauses. No hint."
150
+ ),
151
+ "max_steps": 5,
152
+ "count": 15,
153
+ },
154
+ {
155
+ "id": "hard",
156
+ "difficulty": "hard",
157
+ "description": (
158
+ "Fix complex multi-join queries including column name errors. "
159
+ "Schema provided, no hint."
160
+ ),
161
+ "max_steps": 4,
162
+ "count": 10,
163
+ },
164
+ ]
165
+ }
166
+
167
+
168
  def main():
169
  import uvicorn
170
  uvicorn.run(app, host="0.0.0.0", port=7860)
 
172
 
173
  if __name__ == "__main__":
174
  main()
 
 
 
 
 
 
 
 
 
 
 
sql_env/tasks/__init__.py CHANGED
@@ -8,4 +8,7 @@ ALL_TASKS = {
8
  "hard": HARD_TASKS,
9
  }
10
 
11
- TASK_SETS = ALL_TASKS
 
 
 
 
8
  "hard": HARD_TASKS,
9
  }
10
 
11
+ # Alias used by server.py
12
+ TASK_SETS = ALL_TASKS
13
+
14
+ __all__ = ["ALL_TASKS", "TASK_SETS", "EASY_TASKS", "MEDIUM_TASKS", "HARD_TASKS"]
sql_env/tasks/easy.py CHANGED
@@ -42,4 +42,84 @@ EASY_TASKS = [
42
  error_hint="There is a typo in a SQL keyword near the table name.",
43
  grader=grade,
44
  ),
45
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  error_hint="There is a typo in a SQL keyword near the table name.",
43
  grader=grade,
44
  ),
45
+ SQLTask(
46
+ task_id="easy_006",
47
+ difficulty="easy",
48
+ broken_query="SELECT id, email FORM users WHERE active = 1",
49
+ canonical_answer="SELECT id, email FROM users WHERE active = 1",
50
+ error_hint="There is a typo in a SQL keyword near the table name.",
51
+ grader=grade,
52
+ ),
53
+ SQLTask(
54
+ task_id="easy_007",
55
+ difficulty="easy",
56
+ broken_query="SELECT * FROM invoices WEHRE amount > 500",
57
+ canonical_answer="SELECT * FROM invoices WHERE amount > 500",
58
+ error_hint="There is a typo in the filtering keyword.",
59
+ grader=grade,
60
+ ),
61
+ SQLTask(
62
+ task_id="easy_008",
63
+ difficulty="easy",
64
+ broken_query="SELCT title, author FROM books",
65
+ canonical_answer="SELECT title, author FROM books",
66
+ error_hint="There is a typo in the first keyword of the query.",
67
+ grader=grade,
68
+ ),
69
+ SQLTask(
70
+ task_id="easy_009",
71
+ difficulty="easy",
72
+ broken_query="SELECT name FORM departments WHERE location = 'NYC'",
73
+ canonical_answer="SELECT name FROM departments WHERE location = 'NYC'",
74
+ error_hint="There is a typo in a SQL keyword near the table name.",
75
+ grader=grade,
76
+ ),
77
+ SQLTask(
78
+ task_id="easy_010",
79
+ difficulty="easy",
80
+ broken_query="SELECT product_name, price FROM catalog WEHRE category = 'electronics'",
81
+ canonical_answer="SELECT product_name, price FROM catalog WHERE category = 'electronics'",
82
+ error_hint="There is a typo in the filtering keyword.",
83
+ grader=grade,
84
+ ),
85
+ SQLTask(
86
+ task_id="easy_011",
87
+ difficulty="easy",
88
+ broken_query="SELECT * FORM sessions WHERE user_id = 42",
89
+ canonical_answer="SELECT * FROM sessions WHERE user_id = 42",
90
+ error_hint="There is a typo in a SQL keyword near the table name.",
91
+ grader=grade,
92
+ ),
93
+ SQLTask(
94
+ task_id="easy_012",
95
+ difficulty="easy",
96
+ broken_query="SELCT COUNT(*) FROM transactions WHERE type = 'credit'",
97
+ canonical_answer="SELECT COUNT(*) FROM transactions WHERE type = 'credit'",
98
+ error_hint="There is a typo in the first keyword of the query.",
99
+ grader=grade,
100
+ ),
101
+ SQLTask(
102
+ task_id="easy_013",
103
+ difficulty="easy",
104
+ broken_query="SELECT username, created_at FORM accounts WHERE verified = 1",
105
+ canonical_answer="SELECT username, created_at FROM accounts WHERE verified = 1",
106
+ error_hint="There is a typo in a SQL keyword near the table name.",
107
+ grader=grade,
108
+ ),
109
+ SQLTask(
110
+ task_id="easy_014",
111
+ difficulty="easy",
112
+ broken_query="SELECT * FROM reports WEHRE year = 2024",
113
+ canonical_answer="SELECT * FROM reports WHERE year = 2024",
114
+ error_hint="There is a typo in the filtering keyword.",
115
+ grader=grade,
116
+ ),
117
+ SQLTask(
118
+ task_id="easy_015",
119
+ difficulty="easy",
120
+ broken_query="SELCT AVG(score) FROM results WHERE subject = 'math'",
121
+ canonical_answer="SELECT AVG(score) FROM results WHERE subject = 'math'",
122
+ error_hint="There is a typo in the first keyword of the query.",
123
+ grader=grade,
124
+ ),
125
+ ]
sql_env/tasks/hard.py CHANGED
@@ -5,8 +5,24 @@ HARD_TASKS = [
5
  SQLTask(
6
  task_id="hard_001",
7
  difficulty="hard",
8
- broken_query="SELCT e.name, d.dept_name, SUM(s.amount) AS total_sales FORM employees e LFT JOIN departments d ON e.dpt_id = d.id INNE JOIN sales s ON e.id = s.emp_id WHER s.sale_date BETWEN '2024-01-01' AND '2024-12-31' GRUP BY e.name, d.dept_name HAVNG SUM(s.amount) > 10000 ORDR BY total_sales DSC",
9
- canonical_answer="SELECT e.name, d.dept_name, SUM(s.amount) AS total_sales FROM employees e LEFT JOIN departments d ON e.dept_id = d.id INNER JOIN sales s ON e.id = s.emp_id WHERE s.sale_date BETWEEN '2024-01-01' AND '2024-12-31' GROUP BY e.name, d.dept_name HAVING SUM(s.amount) > 10000 ORDER BY total_sales DESC",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  schema_context=(
11
  "employees(id INT, name VARCHAR, dept_id INT, salary DECIMAL)\n"
12
  "departments(id INT, dept_name VARCHAR)\n"
@@ -19,8 +35,26 @@ HARD_TASKS = [
19
  SQLTask(
20
  task_id="hard_002",
21
  difficulty="hard",
22
- broken_query="SELECT c.name, COUNT(o.id) AS order_count, SUM(oi.qty * p.price) AS revenue FORM customers c LFT JOIN orders o ON c.id = o.customer_id LFT JOIN order_items oi ON o.id = oi.order_id INNE JOIN products p ON oi.product_id = p.id WHER o.created_at >= '2024-01-01' GRUP BY c.name HAVNG revenue > 5000 ORDR BY revenue DSC LIMT 10",
23
- canonical_answer="SELECT c.name, COUNT(o.id) AS order_count, SUM(oi.qty * p.price) AS revenue FROM customers c LEFT JOIN orders o ON c.id = o.customer_id LEFT JOIN order_items oi ON o.id = oi.order_id INNER JOIN products p ON oi.product_id = p.id WHERE o.created_at >= '2024-01-01' GROUP BY c.name HAVING revenue > 5000 ORDER BY revenue DESC LIMIT 10",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  schema_context=(
25
  "customers(id INT, name VARCHAR, email VARCHAR)\n"
26
  "orders(id INT, customer_id INT, created_at DATETIME)\n"
@@ -34,13 +68,284 @@ HARD_TASKS = [
34
  SQLTask(
35
  task_id="hard_003",
36
  difficulty="hard",
37
- broken_query="SELECT dept, AVG(salary) AS avg_sal, MAX(salary) AS max_sal FORM employees WHER hire_date BETWEN '2020-01-01' AND '2023-12-31' AND status = 'active' GRUP BY dept HAVNG AVG(salary) > 60000 ORDR BY avg_sal DSC",
38
- canonical_answer="SELECT dept, AVG(salary) AS avg_sal, MAX(salary) AS max_sal FROM employees WHERE hire_date BETWEEN '2020-01-01' AND '2023-12-31' AND status = 'active' GROUP BY dept HAVING AVG(salary) > 60000 ORDER BY avg_sal DESC",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  schema_context=(
40
- "employees(id INT, name VARCHAR, dept VARCHAR, salary DECIMAL, hire_date DATE, status VARCHAR)"
 
 
 
 
41
  ),
42
  error_hint=None,
43
  max_steps=4,
44
  grader=grade,
45
  ),
46
- ]
 
5
  SQLTask(
6
  task_id="hard_001",
7
  difficulty="hard",
8
+ broken_query=(
9
+ "SELCT e.name, d.dept_name, SUM(s.amount) AS total_sales "
10
+ "FORM employees e LFT JOIN departments d ON e.dpt_id = d.id "
11
+ "INNE JOIN sales s ON e.id = s.emp_id "
12
+ "WHER s.sale_date BETWEN '2024-01-01' AND '2024-12-31' "
13
+ "GRUP BY e.name, d.dept_name "
14
+ "HAVNG SUM(s.amount) > 10000 "
15
+ "ORDR BY total_sales DSC"
16
+ ),
17
+ canonical_answer=(
18
+ "SELECT e.name, d.dept_name, SUM(s.amount) AS total_sales "
19
+ "FROM employees e LEFT JOIN departments d ON e.dept_id = d.id "
20
+ "INNER JOIN sales s ON e.id = s.emp_id "
21
+ "WHERE s.sale_date BETWEEN '2024-01-01' AND '2024-12-31' "
22
+ "GROUP BY e.name, d.dept_name "
23
+ "HAVING SUM(s.amount) > 10000 "
24
+ "ORDER BY total_sales DESC"
25
+ ),
26
  schema_context=(
27
  "employees(id INT, name VARCHAR, dept_id INT, salary DECIMAL)\n"
28
  "departments(id INT, dept_name VARCHAR)\n"
 
35
  SQLTask(
36
  task_id="hard_002",
37
  difficulty="hard",
38
+ broken_query=(
39
+ "SELECT c.name, COUNT(o.id) AS order_count, SUM(oi.qty * p.price) AS revenue "
40
+ "FORM customers c LFT JOIN orders o ON c.id = o.customer_id "
41
+ "LFT JOIN order_items oi ON o.id = oi.order_id "
42
+ "INNE JOIN products p ON oi.product_id = p.id "
43
+ "WHER o.created_at >= '2024-01-01' "
44
+ "GRUP BY c.name "
45
+ "HAVNG revenue > 5000 "
46
+ "ORDR BY revenue DSC LIMT 10"
47
+ ),
48
+ canonical_answer=(
49
+ "SELECT c.name, COUNT(o.id) AS order_count, SUM(oi.qty * p.price) AS revenue "
50
+ "FROM customers c LEFT JOIN orders o ON c.id = o.customer_id "
51
+ "LEFT JOIN order_items oi ON o.id = oi.order_id "
52
+ "INNER JOIN products p ON oi.product_id = p.id "
53
+ "WHERE o.created_at >= '2024-01-01' "
54
+ "GROUP BY c.name "
55
+ "HAVING revenue > 5000 "
56
+ "ORDER BY revenue DESC LIMIT 10"
57
+ ),
58
  schema_context=(
59
  "customers(id INT, name VARCHAR, email VARCHAR)\n"
60
  "orders(id INT, customer_id INT, created_at DATETIME)\n"
 
68
  SQLTask(
69
  task_id="hard_003",
70
  difficulty="hard",
71
+ broken_query=(
72
+ "SELECT dept, AVG(salary) AS avg_sal, MAX(salary) AS max_sal "
73
+ "FORM employees "
74
+ "WHER hire_date BETWEN '2020-01-01' AND '2023-12-31' AND status = 'active' "
75
+ "GRUP BY dept "
76
+ "HAVNG AVG(salary) > 60000 "
77
+ "ORDR BY avg_sal DSC"
78
+ ),
79
+ canonical_answer=(
80
+ "SELECT dept, AVG(salary) AS avg_sal, MAX(salary) AS max_sal "
81
+ "FROM employees "
82
+ "WHERE hire_date BETWEEN '2020-01-01' AND '2023-12-31' AND status = 'active' "
83
+ "GROUP BY dept "
84
+ "HAVING AVG(salary) > 60000 "
85
+ "ORDER BY avg_sal DESC"
86
+ ),
87
+ schema_context=(
88
+ "employees(id INT, name VARCHAR, dept VARCHAR, salary DECIMAL, "
89
+ "hire_date DATE, status VARCHAR)"
90
+ ),
91
+ error_hint=None,
92
+ max_steps=4,
93
+ grader=grade,
94
+ ),
95
+ SQLTask(
96
+ task_id="hard_004",
97
+ difficulty="hard",
98
+ broken_query=(
99
+ "SELCT p.name, cat.category_name, SUM(oi.quantity) AS total_sold "
100
+ "FORM products p "
101
+ "INNE JOIN categories cat ON p.cat_id = cat.id "
102
+ "INNE JOIN order_items oi ON p.id = oi.prod_id "
103
+ "INNE JOIN orders o ON oi.order_id = o.id "
104
+ "WHER o.status = 'completed' AND o.order_date >= '2024-01-01' "
105
+ "GRUP BY p.name, cat.category_name "
106
+ "ORDR BY total_sold DSC LIMT 20"
107
+ ),
108
+ canonical_answer=(
109
+ "SELECT p.name, cat.category_name, SUM(oi.quantity) AS total_sold "
110
+ "FROM products p "
111
+ "INNER JOIN categories cat ON p.category_id = cat.id "
112
+ "INNER JOIN order_items oi ON p.id = oi.product_id "
113
+ "INNER JOIN orders o ON oi.order_id = o.id "
114
+ "WHERE o.status = 'completed' AND o.order_date >= '2024-01-01' "
115
+ "GROUP BY p.name, cat.category_name "
116
+ "ORDER BY total_sold DESC LIMIT 20"
117
+ ),
118
+ schema_context=(
119
+ "products(id INT, name VARCHAR, category_id INT, price DECIMAL)\n"
120
+ "categories(id INT, category_name VARCHAR)\n"
121
+ "order_items(id INT, order_id INT, product_id INT, quantity INT)\n"
122
+ "orders(id INT, status VARCHAR, order_date DATE)"
123
+ ),
124
+ error_hint=None,
125
+ max_steps=4,
126
+ grader=grade,
127
+ ),
128
+ SQLTask(
129
+ task_id="hard_005",
130
+ difficulty="hard",
131
+ broken_query=(
132
+ "SELECT a.title, u.username, COUNT(c.id) AS comment_count, "
133
+ "AVG(r.score) AS avg_score "
134
+ "FORM articles a "
135
+ "INNE JOIN users u ON a.author_id = u.id "
136
+ "LFT JOIN comments c ON a.id = c.article_id "
137
+ "LFT JOIN ratings r ON a.id = r.article_id "
138
+ "WHER a.published_at BETWEN '2024-01-01' AND '2024-06-30' "
139
+ "GRUP BY a.title, u.username "
140
+ "HAVNG COUNT(c.id) > 5 "
141
+ "ORDR BY avg_score DSC"
142
+ ),
143
+ canonical_answer=(
144
+ "SELECT a.title, u.username, COUNT(c.id) AS comment_count, "
145
+ "AVG(r.score) AS avg_score "
146
+ "FROM articles a "
147
+ "INNER JOIN users u ON a.author_id = u.id "
148
+ "LEFT JOIN comments c ON a.id = c.article_id "
149
+ "LEFT JOIN ratings r ON a.id = r.article_id "
150
+ "WHERE a.published_at BETWEEN '2024-01-01' AND '2024-06-30' "
151
+ "GROUP BY a.title, u.username "
152
+ "HAVING COUNT(c.id) > 5 "
153
+ "ORDER BY avg_score DESC"
154
+ ),
155
+ schema_context=(
156
+ "articles(id INT, title VARCHAR, author_id INT, published_at DATE)\n"
157
+ "users(id INT, username VARCHAR, email VARCHAR)\n"
158
+ "comments(id INT, article_id INT, user_id INT, body TEXT)\n"
159
+ "ratings(id INT, article_id INT, user_id INT, score FLOAT)"
160
+ ),
161
+ error_hint=None,
162
+ max_steps=4,
163
+ grader=grade,
164
+ ),
165
+ SQLTask(
166
+ task_id="hard_006",
167
+ difficulty="hard",
168
+ broken_query=(
169
+ "SELCT w.warehouse_name, p.name, SUM(inv.qty) AS stock_total "
170
+ "FORM warehouses w "
171
+ "INNE JOIN inventory inv ON w.id = inv.wrhs_id "
172
+ "INNE JOIN products p ON inv.product_id = p.id "
173
+ "WHER inv.last_updated >= '2024-01-01' "
174
+ "GRUP BY w.warehouse_name, p.name "
175
+ "HAVNG SUM(inv.qty) < 50 "
176
+ "ORDR BY stock_total ASC"
177
+ ),
178
+ canonical_answer=(
179
+ "SELECT w.warehouse_name, p.name, SUM(inv.qty) AS stock_total "
180
+ "FROM warehouses w "
181
+ "INNER JOIN inventory inv ON w.id = inv.warehouse_id "
182
+ "INNER JOIN products p ON inv.product_id = p.id "
183
+ "WHERE inv.last_updated >= '2024-01-01' "
184
+ "GROUP BY w.warehouse_name, p.name "
185
+ "HAVING SUM(inv.qty) < 50 "
186
+ "ORDER BY stock_total ASC"
187
+ ),
188
+ schema_context=(
189
+ "warehouses(id INT, warehouse_name VARCHAR, location VARCHAR)\n"
190
+ "inventory(id INT, warehouse_id INT, product_id INT, qty INT, "
191
+ "last_updated DATE)\n"
192
+ "products(id INT, name VARCHAR, sku VARCHAR, price DECIMAL)"
193
+ ),
194
+ error_hint=None,
195
+ max_steps=4,
196
+ grader=grade,
197
+ ),
198
+ SQLTask(
199
+ task_id="hard_007",
200
+ difficulty="hard",
201
+ broken_query=(
202
+ "SELECT s.student_name, co.course_name, "
203
+ "AVG(g.grade) AS avg_grade, COUNT(g.id) AS assignments_done "
204
+ "FORM students s "
205
+ "INNE JOIN enrollments en ON s.id = en.student_id "
206
+ "INNE JOIN courses co ON en.course_id = co.id "
207
+ "INNE JOIN grades g ON s.id = g.std_id AND co.id = g.course_id "
208
+ "WHER en.semester = 'Fall2024' "
209
+ "GRUP BY s.student_name, co.course_name "
210
+ "HAVNG AVG(g.grade) >= 70 "
211
+ "ORDR BY avg_grade DSC"
212
+ ),
213
+ canonical_answer=(
214
+ "SELECT s.student_name, co.course_name, "
215
+ "AVG(g.grade) AS avg_grade, COUNT(g.id) AS assignments_done "
216
+ "FROM students s "
217
+ "INNER JOIN enrollments en ON s.id = en.student_id "
218
+ "INNER JOIN courses co ON en.course_id = co.id "
219
+ "INNER JOIN grades g ON s.id = g.student_id AND co.id = g.course_id "
220
+ "WHERE en.semester = 'Fall2024' "
221
+ "GROUP BY s.student_name, co.course_name "
222
+ "HAVING AVG(g.grade) >= 70 "
223
+ "ORDER BY avg_grade DESC"
224
+ ),
225
+ schema_context=(
226
+ "students(id INT, student_name VARCHAR, email VARCHAR)\n"
227
+ "enrollments(id INT, student_id INT, course_id INT, semester VARCHAR)\n"
228
+ "courses(id INT, course_name VARCHAR, credits INT)\n"
229
+ "grades(id INT, student_id INT, course_id INT, grade FLOAT)"
230
+ ),
231
+ error_hint=None,
232
+ max_steps=4,
233
+ grader=grade,
234
+ ),
235
+ SQLTask(
236
+ task_id="hard_008",
237
+ difficulty="hard",
238
+ broken_query=(
239
+ "SELCT e.name, m.name AS manager_name, d.dept_name, "
240
+ "e.salary, AVG(e2.salary) AS dept_avg "
241
+ "FORM employees e "
242
+ "LFT JOIN employees m ON e.manager_id = m.id "
243
+ "INNE JOIN departments d ON e.dept_id = d.id "
244
+ "INNE JOIN employees e2 ON e2.dept_id = e.dept_id "
245
+ "WHER e.salary > 50000 "
246
+ "GRUP BY e.name, m.name, d.dept_name, e.salary "
247
+ "HAVNG e.salary > AVG(e2.salary) "
248
+ "ORDR BY e.salary DSC"
249
+ ),
250
+ canonical_answer=(
251
+ "SELECT e.name, m.name AS manager_name, d.dept_name, "
252
+ "e.salary, AVG(e2.salary) AS dept_avg "
253
+ "FROM employees e "
254
+ "LEFT JOIN employees m ON e.manager_id = m.id "
255
+ "INNER JOIN departments d ON e.dept_id = d.id "
256
+ "INNER JOIN employees e2 ON e2.dept_id = e.dept_id "
257
+ "WHERE e.salary > 50000 "
258
+ "GROUP BY e.name, m.name, d.dept_name, e.salary "
259
+ "HAVING e.salary > AVG(e2.salary) "
260
+ "ORDER BY e.salary DESC"
261
+ ),
262
+ schema_context=(
263
+ "employees(id INT, name VARCHAR, dept_id INT, manager_id INT, "
264
+ "salary DECIMAL)\n"
265
+ "departments(id INT, dept_name VARCHAR, budget DECIMAL)"
266
+ ),
267
+ error_hint=None,
268
+ max_steps=4,
269
+ grader=grade,
270
+ ),
271
+ SQLTask(
272
+ task_id="hard_009",
273
+ difficulty="hard",
274
+ broken_query=(
275
+ "SELECT t.tag_name, COUNT(DISTINCT pt.post_id) AS post_count, "
276
+ "AVG(p.views) AS avg_views "
277
+ "FORM tags t "
278
+ "INNE JOIN post_tags pt ON t.id = pt.tag_id "
279
+ "INNE JOIN posts p ON pt.post_id = p.id "
280
+ "INNE JOIN users u ON p.user_id = u.id "
281
+ "WHER p.created_at >= '2024-01-01' AND u.role = 'author' "
282
+ "GRUP BY t.tag_name "
283
+ "HAVNG COUNT(DISTINCT pt.post_id) > 10 "
284
+ "ORDR BY avg_views DSC LIMT 15"
285
+ ),
286
+ canonical_answer=(
287
+ "SELECT t.tag_name, COUNT(DISTINCT pt.post_id) AS post_count, "
288
+ "AVG(p.views) AS avg_views "
289
+ "FROM tags t "
290
+ "INNER JOIN post_tags pt ON t.id = pt.tag_id "
291
+ "INNER JOIN posts p ON pt.post_id = p.id "
292
+ "INNER JOIN users u ON p.user_id = u.id "
293
+ "WHERE p.created_at >= '2024-01-01' AND u.role = 'author' "
294
+ "GROUP BY t.tag_name "
295
+ "HAVING COUNT(DISTINCT pt.post_id) > 10 "
296
+ "ORDER BY avg_views DESC LIMIT 15"
297
+ ),
298
+ schema_context=(
299
+ "tags(id INT, tag_name VARCHAR)\n"
300
+ "post_tags(post_id INT, tag_id INT)\n"
301
+ "posts(id INT, user_id INT, title VARCHAR, views INT, "
302
+ "created_at DATE)\n"
303
+ "users(id INT, username VARCHAR, role VARCHAR)"
304
+ ),
305
+ error_hint=None,
306
+ max_steps=4,
307
+ grader=grade,
308
+ ),
309
+ SQLTask(
310
+ task_id="hard_010",
311
+ difficulty="hard",
312
+ broken_query=(
313
+ "SELCT proj.name AS project_name, emp.name AS employee_name, "
314
+ "SUM(ts.hours) AS total_hours, ts.week_start "
315
+ "FORM projects proj "
316
+ "INNE JOIN project_members pm ON proj.id = pm.proj_id "
317
+ "INNE JOIN employees emp ON pm.employee_id = emp.id "
318
+ "INNE JOIN timesheets ts ON emp.id = ts.emp_id "
319
+ "AND proj.id = ts.project_id "
320
+ "WHER ts.week_start BETWEN '2024-01-01' AND '2024-03-31' "
321
+ "AND proj.status = 'active' "
322
+ "GRUP BY proj.name, emp.name, ts.week_start "
323
+ "HAVNG SUM(ts.hours) > 40 "
324
+ "ORDR BY total_hours DSC"
325
+ ),
326
+ canonical_answer=(
327
+ "SELECT proj.name AS project_name, emp.name AS employee_name, "
328
+ "SUM(ts.hours) AS total_hours, ts.week_start "
329
+ "FROM projects proj "
330
+ "INNER JOIN project_members pm ON proj.id = pm.project_id "
331
+ "INNER JOIN employees emp ON pm.employee_id = emp.id "
332
+ "INNER JOIN timesheets ts ON emp.id = ts.employee_id "
333
+ "AND proj.id = ts.project_id "
334
+ "WHERE ts.week_start BETWEEN '2024-01-01' AND '2024-03-31' "
335
+ "AND proj.status = 'active' "
336
+ "GROUP BY proj.name, emp.name, ts.week_start "
337
+ "HAVING SUM(ts.hours) > 40 "
338
+ "ORDER BY total_hours DESC"
339
+ ),
340
  schema_context=(
341
+ "projects(id INT, name VARCHAR, status VARCHAR, budget DECIMAL)\n"
342
+ "project_members(id INT, project_id INT, employee_id INT, role VARCHAR)\n"
343
+ "employees(id INT, name VARCHAR, dept_id INT, hourly_rate DECIMAL)\n"
344
+ "timesheets(id INT, employee_id INT, project_id INT, "
345
+ "hours DECIMAL, week_start DATE)"
346
  ),
347
  error_hint=None,
348
  max_steps=4,
349
  grader=grade,
350
  ),
351
+ ]
sql_env/tasks/medium.py CHANGED
@@ -42,4 +42,84 @@ MEDIUM_TASKS = [
42
  error_hint=None,
43
  grader=grade,
44
  ),
45
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  error_hint=None,
43
  grader=grade,
44
  ),
45
+ SQLTask(
46
+ task_id="medium_006",
47
+ difficulty="medium",
48
+ broken_query="SELECT category, AVG(price) AS avg_price FORM products GRUP BY category ORDR BY avg_price DESC",
49
+ canonical_answer="SELECT category, AVG(price) AS avg_price FROM products GROUP BY category ORDER BY avg_price DESC",
50
+ error_hint=None,
51
+ grader=grade,
52
+ ),
53
+ SQLTask(
54
+ task_id="medium_007",
55
+ difficulty="medium",
56
+ broken_query="SELECT u.email, COUNT(o.id) AS order_count FORM users u LFT JOIN orders o ON u.id = o.user_id GRUP BY u.email",
57
+ canonical_answer="SELECT u.email, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.email",
58
+ error_hint=None,
59
+ grader=grade,
60
+ ),
61
+ SQLTask(
62
+ task_id="medium_008",
63
+ difficulty="medium",
64
+ broken_query="SELECT id, title FORM articles WEHRE published = 1 ORDR BY created_at DESC LIMT 10",
65
+ canonical_answer="SELECT id, title FROM articles WHERE published = 1 ORDER BY created_at DESC LIMIT 10",
66
+ error_hint=None,
67
+ grader=grade,
68
+ ),
69
+ SQLTask(
70
+ task_id="medium_009",
71
+ difficulty="medium",
72
+ broken_query="SELECT region, SUM(revenue) AS total FORM sales GRUP BY region HAVNG SUM(revenue) > 100000",
73
+ canonical_answer="SELECT region, SUM(revenue) AS total FROM sales GROUP BY region HAVING SUM(revenue) > 100000",
74
+ error_hint=None,
75
+ grader=grade,
76
+ ),
77
+ SQLTask(
78
+ task_id="medium_010",
79
+ difficulty="medium",
80
+ broken_query="SELCT p.name, s.quantity FORM products p INNE JOIN stock s ON p.id = s.product_id WEHRE s.quantity < 10",
81
+ canonical_answer="SELECT p.name, s.quantity FROM products p INNER JOIN stock s ON p.id = s.product_id WHERE s.quantity < 10",
82
+ error_hint=None,
83
+ grader=grade,
84
+ ),
85
+ SQLTask(
86
+ task_id="medium_011",
87
+ difficulty="medium",
88
+ broken_query="SELECT customer_id, MAX(amount) AS max_order FORM orders GRUP BY customer_id ORDR BY max_order DESC",
89
+ canonical_answer="SELECT customer_id, MAX(amount) AS max_order FROM orders GROUP BY customer_id ORDER BY max_order DESC",
90
+ error_hint=None,
91
+ grader=grade,
92
+ ),
93
+ SQLTask(
94
+ task_id="medium_012",
95
+ difficulty="medium",
96
+ broken_query="SELECT t.name, COUNT(e.id) AS member_count FORM teams t LFT JOIN employees e ON t.id = e.team_id GRUP BY t.name",
97
+ canonical_answer="SELECT t.name, COUNT(e.id) AS member_count FROM teams t LEFT JOIN employees e ON t.id = e.team_id GROUP BY t.name",
98
+ error_hint=None,
99
+ grader=grade,
100
+ ),
101
+ SQLTask(
102
+ task_id="medium_013",
103
+ difficulty="medium",
104
+ broken_query="SELCT month, SUM(sales) FORM monthly_revenue WEHRE year = 2024 GRUP BY month ORDR BY month ASC",
105
+ canonical_answer="SELECT month, SUM(sales) FROM monthly_revenue WHERE year = 2024 GROUP BY month ORDER BY month ASC",
106
+ error_hint=None,
107
+ grader=grade,
108
+ ),
109
+ SQLTask(
110
+ task_id="medium_014",
111
+ difficulty="medium",
112
+ broken_query="SELECT c.name, AVG(r.rating) AS avg_rating FORM courses c INNE JOIN reviews r ON c.id = r.course_id GRUP BY c.name HAVNG AVG(r.rating) >= 4.0",
113
+ canonical_answer="SELECT c.name, AVG(r.rating) AS avg_rating FROM courses c INNER JOIN reviews r ON c.id = r.course_id GROUP BY c.name HAVING AVG(r.rating) >= 4.0",
114
+ error_hint=None,
115
+ grader=grade,
116
+ ),
117
+ SQLTask(
118
+ task_id="medium_015",
119
+ difficulty="medium",
120
+ broken_query="SELECT DISTINCT country FORM customers WEHRE signup_date > '2023-01-01' ORDR BY country ASC",
121
+ canonical_answer="SELECT DISTINCT country FROM customers WHERE signup_date > '2023-01-01' ORDER BY country ASC",
122
+ error_hint=None,
123
+ grader=grade,
124
+ ),
125
+ ]
tests/__init__.py ADDED
File without changes
tests/test_env.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tests/test_env.py β€” Smoke tests and grader unit tests.
3
+
4
+ Run with: pytest tests/ -v
5
+ """
6
+ import asyncio
7
+ import pytest
8
+ from sql_env.models import SQLAction, SQLTask
9
+ from sql_env.grader import grade, generate_feedback
10
+ from sql_env.env import SQLCorrectionEnv
11
+ from sql_env.tasks import ALL_TASKS, EASY_TASKS, MEDIUM_TASKS, HARD_TASKS
12
+
13
+
14
+ # ── Helpers ───────────────────────────────────────────────────────────────────
15
+
16
+ def _make_task(broken: str, canonical: str, difficulty: str = "easy") -> SQLTask:
17
+ return SQLTask(
18
+ task_id="test_task",
19
+ difficulty=difficulty,
20
+ broken_query=broken,
21
+ canonical_answer=canonical,
22
+ )
23
+
24
+
25
+ def _action(query: str) -> SQLAction:
26
+ return SQLAction(corrected_query=query)
27
+
28
+
29
+ # ── Grader unit tests ─────────────────────────────────────────────────────────
30
+
31
+ class TestGrader:
32
+ def test_exact_match_returns_one(self):
33
+ task = _make_task(
34
+ "SELECT * FORM users",
35
+ "SELECT * FROM users",
36
+ )
37
+ reward = grade(_action("SELECT * FROM users"), task)
38
+ assert reward.value == 1.0
39
+
40
+ def test_exact_match_case_insensitive(self):
41
+ task = _make_task(
42
+ "SELECT * FORM users",
43
+ "SELECT * FROM users",
44
+ )
45
+ reward = grade(_action("select * from users"), task)
46
+ assert reward.value == 1.0
47
+
48
+ def test_exact_match_trailing_semicolon(self):
49
+ task = _make_task(
50
+ "SELECT * FORM users",
51
+ "SELECT * FROM users",
52
+ )
53
+ reward = grade(_action("SELECT * FROM users;"), task)
54
+ assert reward.value == 1.0
55
+
56
+ def test_wrong_answer_not_one(self):
57
+ task = _make_task(
58
+ "SELECT * FORM users",
59
+ "SELECT * FROM users",
60
+ )
61
+ reward = grade(_action("SELECT * FORM users"), task)
62
+ assert reward.value < 1.0
63
+
64
+ def test_completely_wrong_returns_zero(self):
65
+ task = _make_task(
66
+ "SELECT * FORM users",
67
+ "SELECT * FROM users",
68
+ )
69
+ reward = grade(_action("hello world"), task)
70
+ assert reward.value == 0.0
71
+
72
+ def test_basic_structure_returns_02(self):
73
+ task = _make_task(
74
+ "SELECT * FORM users WHERE id = 1",
75
+ "SELECT * FROM users WHERE id = 1",
76
+ )
77
+ # Correct structure, still has FROM typo
78
+ reward = grade(_action("SELECT * FORM users WHERE id = 1"), task)
79
+ assert reward.value == pytest.approx(0.2, abs=0.05)
80
+
81
+ def test_reward_range(self):
82
+ task = _make_task(
83
+ "SELCT * FORM users WEHRE id = 1",
84
+ "SELECT * FROM users WHERE id = 1",
85
+ )
86
+ for query in [
87
+ "hello world",
88
+ "SELECT * FORM users",
89
+ "SELECT * FROM users WHERE id = 1",
90
+ "select * from users where id = 1",
91
+ ]:
92
+ reward = grade(_action(query), task)
93
+ assert 0.0 <= reward.value <= 1.0, (
94
+ f"Reward {reward.value} out of [0, 1] for query: {query}"
95
+ )
96
+
97
+ def test_feedback_not_empty(self):
98
+ task = _make_task("SELECT * FORM users", "SELECT * FROM users")
99
+ reward = grade(_action("SELECT * FROM users"), task)
100
+ fb = generate_feedback(_action("SELECT * FROM users"), task, reward)
101
+ assert isinstance(fb, str) and len(fb) > 0
102
+
103
+
104
+ # ── Task catalogue tests ──────────────────────────────────────────────────────
105
+
106
+ class TestTaskCatalogue:
107
+ def test_easy_task_count(self):
108
+ assert len(EASY_TASKS) >= 10, "Need at least 10 easy tasks"
109
+
110
+ def test_medium_task_count(self):
111
+ assert len(MEDIUM_TASKS) >= 10, "Need at least 10 medium tasks"
112
+
113
+ def test_hard_task_count(self):
114
+ assert len(HARD_TASKS) >= 5, "Need at least 5 hard tasks"
115
+
116
+ def test_all_task_ids_unique(self):
117
+ all_ids = [t.task_id for tasks in ALL_TASKS.values() for t in tasks]
118
+ assert len(all_ids) == len(set(all_ids)), "Duplicate task IDs found"
119
+
120
+ def test_easy_tasks_have_hints(self):
121
+ for task in EASY_TASKS:
122
+ assert task.error_hint is not None and len(task.error_hint) > 0, (
123
+ f"Easy task {task.task_id} missing error_hint"
124
+ )
125
+
126
+ def test_hard_tasks_have_schema(self):
127
+ for task in HARD_TASKS:
128
+ assert task.schema_context is not None and len(task.schema_context) > 0, (
129
+ f"Hard task {task.task_id} missing schema_context"
130
+ )
131
+
132
+ def test_canonical_answers_are_valid_sql(self):
133
+ """Canonical answers must at least contain SELECT and FROM."""
134
+ for difficulty, tasks in ALL_TASKS.items():
135
+ for task in tasks:
136
+ upper = task.canonical_answer.upper()
137
+ assert "SELECT" in upper, (
138
+ f"{task.task_id}: canonical_answer missing SELECT"
139
+ )
140
+ assert "FROM" in upper, (
141
+ f"{task.task_id}: canonical_answer missing FROM"
142
+ )
143
+
144
+ def test_grading_canonical_answer_returns_perfect(self):
145
+ """Every task must return 1.0 when given its own canonical answer."""
146
+ for difficulty, tasks in ALL_TASKS.items():
147
+ for task in tasks:
148
+ action = _action(task.canonical_answer)
149
+ reward = grade(action, task)
150
+ assert reward.value == 1.0, (
151
+ f"{task.task_id}: canonical answer did not score 1.0 "
152
+ f"(got {reward.value})"
153
+ )
154
+
155
+ def test_grading_broken_query_below_perfect(self):
156
+ """Broken queries must score below 1.0."""
157
+ for difficulty, tasks in ALL_TASKS.items():
158
+ for task in tasks:
159
+ action = _action(task.broken_query)
160
+ reward = grade(action, task)
161
+ assert reward.value < 1.0, (
162
+ f"{task.task_id}: broken query unexpectedly scored 1.0"
163
+ )
164
+
165
+
166
+ # ── Environment integration tests ─────────────────────────────────────────────
167
+
168
+ class TestEnvironment:
169
+ def test_reset_returns_observation(self):
170
+ async def run():
171
+ env = SQLCorrectionEnv(difficulty="easy")
172
+ obs = await env.reset()
173
+ assert obs.task_id is not None
174
+ assert obs.broken_query is not None
175
+ assert obs.step_number == 0
176
+ assert obs.steps_remaining == 5
177
+
178
+ asyncio.run(run())
179
+
180
+ def test_step_returns_result(self):
181
+ async def run():
182
+ env = SQLCorrectionEnv(difficulty="easy")
183
+ await env.reset()
184
+ result = await env.step(_action("SELECT * FROM users WHERE id = 1"))
185
+ assert 0.0 <= result.reward <= 1.0
186
+ assert isinstance(result.done, bool)
187
+ assert result.observation.step_number == 1
188
+
189
+ asyncio.run(run())
190
+
191
+ def test_steps_remaining_decrements(self):
192
+ async def run():
193
+ env = SQLCorrectionEnv(difficulty="easy")
194
+ await env.reset()
195
+ result = await env.step(_action("SELECT * FROM x"))
196
+ assert result.observation.steps_remaining == 4
197
+
198
+ asyncio.run(run())
199
+
200
+ def test_correct_answer_terminates(self):
201
+ async def run():
202
+ env = SQLCorrectionEnv(difficulty="easy", task_index=0)
203
+ await env.reset()
204
+ canonical = EASY_TASKS[0].canonical_answer
205
+ result = await env.step(_action(canonical))
206
+ assert result.done is True
207
+ assert result.reward == pytest.approx(1.0)
208
+
209
+ asyncio.run(run())
210
+
211
+ def test_max_steps_terminates(self):
212
+ async def run():
213
+ env = SQLCorrectionEnv(difficulty="easy", task_index=0)
214
+ await env.reset()
215
+ result = None
216
+ for _ in range(5):
217
+ result = await env.step(_action("SELECT * FORM users"))
218
+ assert result.done is True
219
+
220
+ asyncio.run(run())
221
+
222
+ def test_done_episode_raises(self):
223
+ async def run():
224
+ env = SQLCorrectionEnv(difficulty="easy", task_index=0)
225
+ await env.reset()
226
+ canonical = EASY_TASKS[0].canonical_answer
227
+ await env.step(_action(canonical)) # this terminates
228
+ with pytest.raises(RuntimeError):
229
+ await env.step(_action("SELECT 1"))
230
+
231
+ asyncio.run(run())
232
+
233
+ def test_medium_hint_hidden(self):
234
+ async def run():
235
+ env = SQLCorrectionEnv(difficulty="medium")
236
+ obs = await env.reset()
237
+ assert obs.error_hint is None
238
+
239
+ asyncio.run(run())
240
+
241
+ def test_hard_schema_present(self):
242
+ async def run():
243
+ env = SQLCorrectionEnv(difficulty="hard")
244
+ obs = await env.reset()
245
+ assert obs.schema_context is not None
246
+
247
+ asyncio.run(run())
248
+
249
+ def test_state_reflects_progress(self):
250
+ async def run():
251
+ env = SQLCorrectionEnv(difficulty="easy", task_index=0)
252
+ await env.reset()
253
+ await env.step(_action("SELECT * FORM users"))
254
+ state = await env.state()
255
+ assert state["step_count"] == 1
256
+ assert state["done"] is False
257
+
258
+ asyncio.run(run())
259
+
260
+ def test_all_difficulties_reset(self):
261
+ async def run():
262
+ for diff in ["easy", "medium", "hard"]:
263
+ env = SQLCorrectionEnv(difficulty=diff)
264
+ obs = await env.reset()
265
+ assert obs.broken_query is not None
266
+
267
+ asyncio.run(run())