junaid0600 commited on
Commit
3c1b0c7
·
1 Parent(s): f812d5b

Complete SQL Query Debugger OpenEnv - 24/24 tests passing, Docker verified

Browse files
Files changed (13) hide show
  1. .gitignore +37 -0
  2. Dockerfile +13 -0
  3. README.md +235 -0
  4. api/server.py +293 -0
  5. baseline.py +249 -0
  6. env/environment.py +291 -0
  7. env/graders.py +440 -0
  8. env/reward.py +204 -0
  9. openenv.yaml +108 -0
  10. requirements.txt +7 -0
  11. tests/__init__.py +0 -0
  12. tests/test_environment.py +139 -0
  13. tests/test_graders.py +140 -0
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment variables — NEVER commit real keys
2
+ .env
3
+
4
+ # Python cache
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.pyo
8
+ *.pyd
9
+ .Python
10
+
11
+ # Virtual environments
12
+ venv/
13
+ env/
14
+ ENV/
15
+ .venv/
16
+
17
+ # Pytest cache
18
+ .pytest_cache/
19
+ *.pytest_cache
20
+
21
+ # VS Code
22
+ .vscode/
23
+
24
+ # Docker
25
+ *.tar
26
+
27
+ # OS files
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Test files we don't need in prod
32
+ test_*.py
33
+
34
+ # Distribution
35
+ dist/
36
+ build/
37
+ *.egg-info/
Dockerfile CHANGED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ COPY . .
10
+
11
+ EXPOSE 7860
12
+
13
+ CMD ["uvicorn", "api.server:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SQL Query Debugger — OpenEnv Environment
2
+
3
+ > **META × PyTorch × SST OpenEnv Hackathon** | Round 1 | March 28 – April 5, 2025
4
+
5
+ An OpenEnv-compliant reinforcement learning environment where AI agents learn to debug SQL queries across three difficulty levels: syntax errors, logic bugs, and performance issues.
6
+
7
+ ---
8
+
9
+ ## Motivation
10
+
11
+ SQL is the most widely used data language in the world. Every software engineer, data scientist, and analyst writes SQL daily. Yet debugging SQL queries remains a frustrating, time-consuming task — a developer staring at a wrong JOIN or a missing index can lose hours of productive work.
12
+
13
+ Despite this, no OpenEnv environment exists for SQL debugging. Existing RL benchmarks focus on code generation, not debugging. This environment fills that gap by training agents to diagnose and fix real SQL problems that real engineers face every day — from simple syntax errors to complex N+1 performance anti-patterns that silently destroy application performance at scale.
14
+
15
+ ## Why This Domain?
16
+
17
+ SQL debugging is uniquely well-suited for RL evaluation:
18
+
19
+ 1. **Deterministic grading** — a fixed query either matches expected output or it doesn't. No ambiguity, no LLM-based scoring.
20
+ 2. **Natural difficulty curve** — syntax errors (easy) → logic bugs (medium) → performance anti-patterns (hard) map perfectly to agent skill levels.
21
+ 3. **Real business value** — companies lose millions in engineering hours and infrastructure costs to slow or incorrect SQL. An agent that debugs SQL has immediate commercial value.
22
+ 4. **Gap in ecosystem** — no OpenEnv environment for SQL debugging exists. This is genuinely novel.
23
+
24
+ ---
25
+
26
+ ## Environment Overview
27
+
28
+ | Property | Value |
29
+ |---|---|
30
+ | Domain | SQL Query Debugging |
31
+ | Tasks | 15 (5 easy, 5 medium, 5 hard) |
32
+ | Max Steps | 20 per episode |
33
+ | Reward Type | Dense (-1.0 to 1.0) |
34
+ | Grader Type | Deterministic (programmatic) |
35
+ | API Port | 7860 |
36
+
37
+ ---
38
+
39
+ ## Action Space
40
+
41
+ Agents can take 6 action types:
42
+
43
+ | Action | Description | Reward Signal |
44
+ |---|---|---|
45
+ | `identify_error` | Identify error location and type | +0.15 step reward + partial grader |
46
+ | `propose_fix` | Propose a fix without committing | +0.25 step reward + 40% grader score |
47
+ | `submit_answer` | Submit final fixed query | Full grader score |
48
+ | `request_hint` | Request a progressive hint | -0.05 penalty |
49
+ | `explain_issue` | Explain the issue in detail | +0.10 step reward |
50
+ | `optimize_query` | Submit optimized query (hard tasks) | +0.20 step reward + full grader score |
51
+
52
+ ---
53
+
54
+ ## Observation Space
55
+
56
+ Every observation contains:
57
+ ```json
58
+ {
59
+ "task_id": "easy_001",
60
+ "task_description": "Fix the SQL syntax error: missing comma in SELECT clause",
61
+ "current_context": {
62
+ "buggy_query": "SELECT id name email FROM users WHERE active = 1",
63
+ "error_message": "ERROR: syntax error at or near 'name'",
64
+ "database_schema": {"users": ["id INT", "name VARCHAR", "email VARCHAR"]},
65
+ "error_type_hint": "syntax",
66
+ "steps_remaining": 20
67
+ },
68
+ "step_count": 0,
69
+ "difficulty": "easy",
70
+ "max_steps": 20,
71
+ "hints_used": 0,
72
+ "previous_actions": []
73
+ }
74
+ ```
75
+
76
+ **Critical:** Ground truth (fixed query) is never included in the observation.
77
+
78
+ ---
79
+
80
+ ## Task Descriptions
81
+
82
+ ### Easy — Syntax Errors
83
+ | ID | Description |
84
+ |---|---|
85
+ | easy_001 | Missing commas in SELECT clause |
86
+ | easy_002 | Missing WHERE keyword |
87
+ | easy_003 | Unclosed string literal |
88
+ | easy_004 | ORDER instead of ORDER BY |
89
+ | easy_005 | GROUP instead of GROUP BY |
90
+
91
+ ### Medium — Logic Bugs
92
+ | ID | Description |
93
+ |---|---|
94
+ | medium_001 | INNER JOIN excludes users with zero orders — should be LEFT JOIN |
95
+ | medium_002 | Wrong JOIN condition causing incorrect product associations |
96
+ | medium_003 | Aggregate function in WHERE instead of HAVING |
97
+ | medium_004 | Correlated subquery correlating on wrong column |
98
+ | medium_005 | COUNT(DISTINCT *) — invalid DISTINCT usage |
99
+
100
+ ### Hard — Performance Issues
101
+ | ID | Description |
102
+ |---|---|
103
+ | hard_001 | N+1 correlated subqueries in SELECT — O(n) DB hits |
104
+ | hard_002 | Function on indexed column prevents index usage |
105
+ | hard_003 | Implicit cartesian product — missing JOIN condition |
106
+ | hard_004 | SELECT * across 3-table JOIN causing over-fetching |
107
+ | hard_005 | Window function in WHERE clause + missing PARTITION BY |
108
+
109
+ ---
110
+
111
+ ## Reward Design
112
+
113
+ Reward is **dense** — the agent receives signal at every step, not just at the end.
114
+ ```
115
+ Step 1: identify_error correctly → +0.15 (step) + 0.03 (partial grader)
116
+ Step 2: propose_fix with good query → +0.25 (step) + 0.36 (40% grader)
117
+ Step 3: submit_answer perfectly → +0.90 (full grader) + 0.10 (efficiency bonus)
118
+
119
+ Hint requested → -0.05 (penalty)
120
+ Same action 3x in a row → -0.05 per repeat (loop penalty)
121
+ Null / invalid action → -0.10 (penalty)
122
+ Max steps reached → -0.10 (penalty)
123
+ ```
124
+
125
+ ---
126
+
127
+ ## API Endpoints
128
+
129
+ | Endpoint | Method | Description |
130
+ |---|---|---|
131
+ | `/health` | GET | Liveness check — always 200 |
132
+ | `/reset` | POST | Start new episode → Observation |
133
+ | `/step` | POST | Submit action → (obs, reward, done, info) |
134
+ | `/state` | GET | Current episode state |
135
+ | `/tasks` | GET | All 15 tasks + action schema |
136
+ | `/grader` | POST | Grade an episode → float score |
137
+ | `/baseline` | POST | Run baseline agent → scores JSON |
138
+
139
+ ---
140
+
141
+ ## Setup & Installation
142
+
143
+ ### Requirements
144
+ - Python 3.11+
145
+ - Docker Desktop
146
+
147
+ ### Local Setup
148
+ ```bash
149
+ # Clone the repository
150
+ git clone https://github.com/YOUR_USERNAME/sql-query-debugger
151
+ cd sql-query-debugger
152
+
153
+ # Install dependencies
154
+ pip install -r requirements.txt
155
+
156
+ # Set environment variable
157
+ cp .env.example .env
158
+ # Edit .env and add your OPENAI_API_KEY
159
+
160
+ # Run the server
161
+ uvicorn api.server:app --host 0.0.0.0 --port 7860 --reload
162
+ ```
163
+
164
+ ### Docker Setup
165
+ ```bash
166
+ # Build
167
+ docker build -t sql-query-debugger .
168
+
169
+ # Run
170
+ docker run -p 7860:7860 -e OPENAI_API_KEY=your-key sql-query-debugger
171
+ ```
172
+
173
+ ### Verify
174
+ ```bash
175
+ curl http://localhost:7860/health
176
+ # {"status":"ok","version":"1.0.0"}
177
+
178
+ curl -X POST http://localhost:7860/reset -H "Content-Type: application/json" -d '{}'
179
+ # Returns initial Observation
180
+
181
+ curl http://localhost:7860/tasks
182
+ # Returns all 15 tasks with action schema
183
+ ```
184
+
185
+ ---
186
+
187
+ ## Baseline Scores
188
+
189
+ The rule-based baseline agent scores:
190
+
191
+ | Difficulty | Task | Score | Steps |
192
+ |---|---|---|---|
193
+ | Easy | easy_001 | 0.80 | 2 |
194
+ | Medium | medium_001 | 0.98 | 2 |
195
+ | Hard | hard_001 | 0.80 | 2 |
196
+ | **Average** | | **0.86** | **2** |
197
+
198
+ Baseline uses heuristic rules — no LLM calls. A trained RL agent is expected to significantly outperform this baseline on hard tasks.
199
+
200
+ ---
201
+
202
+ ## Project Structure
203
+ ```
204
+ sql-query-debugger/
205
+ ├── openenv.yaml # OpenEnv metadata
206
+ ├── Dockerfile # Container definition
207
+ ├── requirements.txt # Pinned dependencies
208
+ ├── README.md # This file
209
+ ├── baseline.py # Baseline inference script
210
+ ├── .env.example # Environment variable template
211
+ ├── env/
212
+ │ ├── environment.py # Core: step() reset() state()
213
+ │ ├── models.py # Pydantic models
214
+ │ ├── tasks.py # Task definitions + manager
215
+ │ ├── graders.py # Deterministic graders
216
+ │ └── reward.py # Dense reward shaping
217
+ ├── api/
218
+ │ └── server.py # FastAPI — all 7 endpoints
219
+ ├── dataset/
220
+ │ ├── easy_cases.json # 5 syntax error tasks
221
+ │ ├── medium_cases.json # 5 logic bug tasks
222
+ │ └── hard_cases.json # 5 performance tasks
223
+ └── tests/
224
+ ├── test_environment.py
225
+ └── test_graders.py
226
+ ```
227
+
228
+ ---
229
+
230
+ ## Built For
231
+
232
+ **META × PyTorch × SST OpenEnv Hackathon**
233
+ Round 1: March 28 – April 5, 2025 | $30,000 Prize Pool
234
+
235
+ *Build something you would be proud to show to a senior engineer at Meta.*
api/server.py CHANGED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ import asyncio
4
+ from typing import Optional
5
+ from contextlib import asynccontextmanager
6
+
7
+ from fastapi import FastAPI, HTTPException, Request
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import JSONResponse
10
+ from pydantic import ValidationError
11
+
12
+ from env.environment import environment
13
+ from env.models import (
14
+ Action, Observation, EpisodeState,
15
+ DifficultyLevel, ActionType,
16
+ StepResponse, ResetResponse, TaskListResponse,
17
+ BaselineResponse, BaselineResult,
18
+ GraderRequest, GraderResponse,
19
+ HealthResponse, TaskInfo
20
+ )
21
+ from env.tasks import task_manager, ACTION_SCHEMA
22
+ from env.graders import grade
23
+
24
+
25
+ # ─────────────────────────────────────────────
26
+ # STARTUP / SHUTDOWN
27
+ # ─────────────────────────────────────────────
28
+
29
+ _startup_time = time.time()
30
+
31
+ @asynccontextmanager
32
+ async def lifespan(app: FastAPI):
33
+ # Warm up — pre-load datasets and reset environment
34
+ environment.reset(difficulty="easy")
35
+ yield
36
+ # Shutdown — nothing to clean up
37
+
38
+ # ─────────────────────────────────────────────
39
+ # APP DEFINITION
40
+ # ─────────────────────────────────────────────
41
+
42
+ app = FastAPI(
43
+ title = "SQL Query Debugger — OpenEnv Environment",
44
+ description = (
45
+ "An OpenEnv-compliant reinforcement learning environment where AI agents "
46
+ "learn to debug SQL queries across syntax errors, logic bugs, and performance issues. "
47
+ "Built for the META × PyTorch × SST OpenEnv Hackathon."
48
+ ),
49
+ version = "1.0.0",
50
+ lifespan = lifespan,
51
+ docs_url = "/docs",
52
+ redoc_url = "/redoc",
53
+ )
54
+
55
+ app.add_middleware(
56
+ CORSMiddleware,
57
+ allow_origins = ["*"],
58
+ allow_credentials = True,
59
+ allow_methods = ["*"],
60
+ allow_headers = ["*"],
61
+ )
62
+
63
+
64
+ # ─────────────────────────────────────────────
65
+ # GLOBAL EXCEPTION HANDLER
66
+ # ─────────────────────────────────────────────
67
+
68
+ @app.exception_handler(Exception)
69
+ async def global_exception_handler(request: Request, exc: Exception):
70
+ return JSONResponse(
71
+ status_code = 500,
72
+ content = {"error": str(exc), "type": type(exc).__name__}
73
+ )
74
+
75
+
76
+ # ─────────────────────────────────────────────
77
+ # 1. /health — GET
78
+ # Must always return 200 even if env not initialized
79
+ # ─────────────────────────────────────────────
80
+
81
+ @app.get("/health", response_model=HealthResponse, tags=["System"])
82
+ async def health():
83
+ """
84
+ Liveness check. Always returns 200.
85
+ Used by HF Space health monitoring.
86
+ """
87
+ return HealthResponse(
88
+ status = "ok",
89
+ version = "1.0.0",
90
+ uptime = round(time.time() - _startup_time, 2)
91
+ )
92
+
93
+
94
+ # ─────────────────────────────────────────────
95
+ # 2. /reset — POST
96
+ # Starts new episode, returns Observation
97
+ # ─────────────────────────────────────────────
98
+
99
+ class ResetRequest(Action.__class__):
100
+ pass
101
+
102
+ from pydantic import BaseModel
103
+
104
+ class ResetBody(BaseModel):
105
+ difficulty: Optional[str] = None
106
+ task_id: Optional[str] = None
107
+
108
+ @app.post("/reset", response_model=Observation, tags=["Environment"])
109
+ async def reset(body: ResetBody = ResetBody()):
110
+ """
111
+ Starts a fresh episode.
112
+ Returns the initial Observation the agent sees.
113
+
114
+ Edge case: always returns valid Observation even if dataset issues occur.
115
+ """
116
+ try:
117
+ obs = environment.reset(
118
+ difficulty = body.difficulty,
119
+ task_id = body.task_id
120
+ )
121
+ return obs
122
+ except ValueError as e:
123
+ raise HTTPException(status_code=400, detail=str(e))
124
+ except Exception as e:
125
+ raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
126
+
127
+
128
+ # ─────────────────────────────────────────────
129
+ # 3. /step — POST
130
+ # Accepts Action, returns StepResponse
131
+ # ─────────────────────────────────────────────
132
+
133
+ @app.post("/step", response_model=StepResponse, tags=["Environment"])
134
+ async def step(action: Action):
135
+ """
136
+ Submits an action to the environment.
137
+ Returns (observation, reward, done, info).
138
+
139
+ Edge cases:
140
+ - Invalid/malformed action → reward=-0.1, done=False
141
+ - Episode already done → returns terminal state
142
+ - Null payload → graceful penalty
143
+ """
144
+ try:
145
+ response = environment.step(action)
146
+ return response
147
+ except ValidationError as e:
148
+ # Malformed action — return penalty reward, never crash
149
+ obs = environment.state()
150
+ return StepResponse(
151
+ observation = environment._build_observation(),
152
+ reward = __import__("env.models", fromlist=["Reward"]).Reward(
153
+ score = -0.1,
154
+ breakdown = {"validation_error": -0.1},
155
+ feedback = f"Malformed action: {str(e)}"
156
+ ),
157
+ done = False,
158
+ info = {"error": "validation_error", "detail": str(e)}
159
+ )
160
+ except Exception as e:
161
+ raise HTTPException(status_code=500, detail=f"Step failed: {str(e)}")
162
+
163
+
164
+ # ─────────────────────────────────────────────
165
+ # 4. /state — GET
166
+ # Returns current environment state
167
+ # ─────────────────────────────────────────────
168
+
169
+ @app.get("/state", response_model=EpisodeState, tags=["Environment"])
170
+ async def state():
171
+ """
172
+ Returns full current environment state.
173
+ Works before reset() is called — returns default empty state.
174
+ Must always be JSON-serializable.
175
+ """
176
+ return environment.state()
177
+
178
+
179
+ # ─────────────────────────────────────────────
180
+ # 5. /tasks — GET
181
+ # Lists all tasks + action schema
182
+ # ─────────────────────────────────────────────
183
+
184
+ @app.get("/tasks", response_model=TaskListResponse, tags=["Tasks"])
185
+ async def tasks():
186
+ """
187
+ Lists all 15 tasks with full action schema definitions.
188
+ Validator checks for action field definitions, not just task names.
189
+ """
190
+ all_tasks = task_manager.list_all_tasks()
191
+ return TaskListResponse(
192
+ tasks = all_tasks,
193
+ total = len(all_tasks),
194
+ action_types = [a.value for a in ActionType]
195
+ )
196
+
197
+
198
+ # ─────────────────────────────────────────────
199
+ # 6. /grader — POST
200
+ # Grades a completed episode
201
+ # ─────────────────────────────────────────────
202
+
203
+ @app.post("/grader", response_model=GraderResponse, tags=["Grading"])
204
+ async def grader(request: GraderRequest):
205
+ """
206
+ Grades a completed episode.
207
+ Returns float score between 0.0 and 1.0.
208
+
209
+ Edge cases:
210
+ - Null/empty episode → returns 0.0, never crashes
211
+ - Unknown task_id → returns 0.0 with explanation
212
+ """
213
+ try:
214
+ # Edge case: null action in request
215
+ if request.action is None:
216
+ return GraderResponse(
217
+ score = 0.0,
218
+ feedback = "No action provided for grading.",
219
+ breakdown = {"error": "null_action"}
220
+ )
221
+
222
+ score, breakdown, feedback = grade(request.action, request.task_id)
223
+ return GraderResponse(
224
+ score = score,
225
+ feedback = feedback,
226
+ breakdown = breakdown
227
+ )
228
+ except Exception as e:
229
+ # Never crash — return 0.0
230
+ return GraderResponse(
231
+ score = 0.0,
232
+ feedback = f"Grader error: {str(e)}",
233
+ breakdown = {"error": str(e)}
234
+ )
235
+
236
+
237
+ # ─────────────────────────────────────────────
238
+ # 7. /baseline — POST
239
+ # Runs baseline inference, returns scores
240
+ # Must complete within 60 seconds
241
+ # ─────────────────────────────────────────────
242
+
243
+ @app.post("/baseline", response_model=BaselineResponse, tags=["Baseline"])
244
+ async def baseline():
245
+ """
246
+ Runs the baseline agent against all 3 difficulty levels.
247
+ Returns scores JSON. Must complete within 60 seconds.
248
+
249
+ Edge case: OPENAI_API_KEY not set → returns error scores without crashing.
250
+ """
251
+ try:
252
+ # Import here to avoid circular imports
253
+ import baseline as baseline_module
254
+ results = await asyncio.wait_for(
255
+ asyncio.to_thread(baseline_module.run_baseline),
256
+ timeout=55.0 # 5s buffer before 60s limit
257
+ )
258
+ return results
259
+ except asyncio.TimeoutError:
260
+ # Return partial results on timeout
261
+ return BaselineResponse(
262
+ results=[
263
+ BaselineResult(task_id="timeout", difficulty=DifficultyLevel.EASY,
264
+ score=0.0, steps=0, feedback="Baseline timed out after 55 seconds.")
265
+ ],
266
+ average_score=0.0
267
+ )
268
+ except Exception as e:
269
+ return BaselineResponse(
270
+ results=[
271
+ BaselineResult(task_id="error", difficulty=DifficultyLevel.EASY,
272
+ score=0.0, steps=0, feedback=f"Baseline error: {str(e)}")
273
+ ],
274
+ average_score=0.0
275
+ )
276
+
277
+
278
+ # ─────────────────────────────────────────────
279
+ # ROOT — redirect to docs
280
+ # ─────────────────────────────────────────────
281
+
282
+ @app.get("/", tags=["System"])
283
+ async def root():
284
+ return {
285
+ "name": "SQL Query Debugger — OpenEnv Environment",
286
+ "version": "1.0.0",
287
+ "docs": "/docs",
288
+ "health": "/health",
289
+ "endpoints": ["/reset", "/step", "/state", "/tasks", "/grader", "/baseline", "/health"],
290
+ "hackathon": "META × PyTorch × SST OpenEnv Hackathon",
291
+ "domain": "SQL Query Debugging",
292
+ "tasks_count": 15,
293
+ }
baseline.py CHANGED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ from env.environment import SQLDebuggerEnvironment
4
+ from env.models import (
5
+ Action, ActionType, DifficultyLevel,
6
+ BaselineResponse, BaselineResult
7
+ )
8
+
9
+
10
+ # BASELINE AGENT
11
+ # Uses rule-based heuristics — no GPT-4
12
+ # Must complete within 60 seconds
13
+ # OPENAI_API_KEY must come from environment
14
+
15
+ def _check_api_key():
16
+ """Edge case: OPENAI_API_KEY not set → raise clear error."""
17
+ key = os.environ.get("OPENAI_API_KEY")
18
+ if not key:
19
+ raise ValueError(
20
+ "OPENAI_API_KEY environment variable is not set. "
21
+ "Please set it before running baseline: "
22
+ "set OPENAI_API_KEY=your-key-here"
23
+ )
24
+ return key
25
+
26
+
27
+ def _rule_based_agent(env: SQLDebuggerEnvironment, task: dict) -> tuple[float, int, str]:
28
+ """
29
+ Rule-based baseline agent that analyzes the buggy query
30
+ and attempts a fix using heuristics.
31
+ Fast — no API calls needed for baseline scoring.
32
+ """
33
+ context = task.get("current_context", {})
34
+ buggy_query = context.get("buggy_query", "")
35
+ error_msg = context.get("error_message", "")
36
+ error_type = context.get("error_type_hint", "syntax")
37
+ category = context.get("category", "syntax")
38
+
39
+ steps_taken = 0
40
+ total_reward = 0.0
41
+
42
+ # ── Step 1: Identify the error ────────────────────────────────
43
+ identify_payload = {
44
+ "error_location": _guess_error_location(buggy_query, error_msg, category),
45
+ "error_type": error_type,
46
+ "explanation": f"Detected {category} issue in query: {error_msg[:100]}"
47
+ }
48
+ action1 = Action(
49
+ action_type=ActionType.IDENTIFY_ERROR,
50
+ payload=identify_payload
51
+ )
52
+ resp1 = env.step(action1)
53
+ total_reward += resp1.reward.score
54
+ steps_taken += 1
55
+
56
+ if resp1.done:
57
+ return total_reward, steps_taken, resp1.reward.feedback
58
+
59
+ # ── Step 2: Submit answer based on heuristic fix ──────────────
60
+ fixed_query = _apply_heuristic_fix(buggy_query, category, error_msg)
61
+ explanation = _generate_explanation(buggy_query, fixed_query, category)
62
+
63
+ if category == "performance":
64
+ action2 = Action(
65
+ action_type=ActionType.OPTIMIZE_QUERY,
66
+ payload={
67
+ "optimized_query": fixed_query,
68
+ "optimization_type": f"Fix {category} issue: {error_type}",
69
+ "explanation": explanation,
70
+ "root_cause": f"Performance issue detected: {error_msg[:100]}",
71
+ "expected_improvement":"Significant reduction in query execution time",
72
+ "confidence": 0.6
73
+ }
74
+ )
75
+ else:
76
+ action2 = Action(
77
+ action_type=ActionType.SUBMIT_ANSWER,
78
+ payload={
79
+ "fixed_query": fixed_query,
80
+ "explanation": explanation,
81
+ "error_type": error_type,
82
+ "error_location": identify_payload["error_location"],
83
+ "confidence": 0.6
84
+ }
85
+ )
86
+
87
+ resp2 = env.step(action2)
88
+ total_reward += resp2.reward.score
89
+ steps_taken += 1
90
+
91
+ return total_reward, steps_taken, resp2.reward.feedback
92
+
93
+
94
+ def _guess_error_location(query: str, error_msg: str, category: str) -> str:
95
+ """Heuristic: guess where the error is based on keywords."""
96
+ q = query.upper()
97
+ e = error_msg.upper()
98
+
99
+ if "SELECT" in e or "COLUMN" in e:
100
+ return "SELECT clause"
101
+ if "WHERE" in e or "FILTER" in e:
102
+ return "WHERE clause"
103
+ if "JOIN" in e or "ON" in e:
104
+ return "JOIN condition"
105
+ if "GROUP" in e or "HAVING" in e:
106
+ return "GROUP BY / HAVING clause"
107
+ if "ORDER" in e:
108
+ return "ORDER BY clause"
109
+ if category == "performance":
110
+ return "Query structure — performance bottleneck"
111
+ return "Unknown location"
112
+
113
+
114
+ def _apply_heuristic_fix(query: str, category: str, error_msg: str) -> str:
115
+ """
116
+ Apply simple heuristic fixes based on category.
117
+ Not perfect — baseline is meant to score low-medium,
118
+ showing the environment has room for agent improvement.
119
+ """
120
+ q = query.strip()
121
+
122
+ if category == "syntax":
123
+ # Fix missing commas in SELECT
124
+ if "syntax error" in error_msg.lower() and "name" in error_msg.lower():
125
+ import re
126
+ q = re.sub(r"SELECT\s+(\w+)\s+(\w+)", r"SELECT \1, \2", q, flags=re.IGNORECASE)
127
+
128
+ # Fix missing WHERE
129
+ if "WHERE" not in q.upper() and "=" in q:
130
+ q = q.replace(" id =", " WHERE id =")
131
+ q = q.replace(" name =", " WHERE name =")
132
+
133
+ # Fix unclosed string
134
+ if q.count("'") % 2 != 0:
135
+ q = q + "'"
136
+
137
+ # Fix ORDER → ORDER BY
138
+ import re
139
+ q = re.sub(r"\bORDER\s+(?!BY)(\w)", r"ORDER BY \1", q, flags=re.IGNORECASE)
140
+
141
+ # Fix GROUP → GROUP BY
142
+ q = re.sub(r"\bGROUP\s+(?!BY)(\w)", r"GROUP BY \1", q, flags=re.IGNORECASE)
143
+
144
+ elif category == "logic":
145
+ # Fix INNER JOIN → LEFT JOIN for inclusion
146
+ if "INNER JOIN" in q.upper():
147
+ q = q.replace("INNER JOIN", "LEFT JOIN").replace("inner join", "LEFT JOIN")
148
+
149
+ # Fix WHERE aggregate → HAVING
150
+ import re
151
+ having_pattern = re.compile(
152
+ r"WHERE\s+(AVG|SUM|COUNT|MAX|MIN)\s*\(", re.IGNORECASE
153
+ )
154
+ if having_pattern.search(q):
155
+ # Move aggregate condition to HAVING
156
+ q = having_pattern.sub("HAVING \\1(", q)
157
+
158
+ elif category == "performance":
159
+ # For performance issues, suggest JOIN-based rewrite
160
+ if "SELECT *" in q.upper():
161
+ q = q.replace("SELECT *", "SELECT id, name, status, created_at")
162
+
163
+ return q
164
+
165
+
166
+ def _generate_explanation(buggy: str, fixed: str, category: str) -> str:
167
+ """Generate a human-readable explanation of the fix."""
168
+ if buggy.strip() == fixed.strip():
169
+ return f"Analyzed the {category} issue. The query may require deeper inspection."
170
+
171
+ explanations = {
172
+ "syntax": "Fixed syntax error in the SQL query by correcting the query structure.",
173
+ "logic": "Fixed logic error by correcting the JOIN type and query conditions.",
174
+ "performance": "Optimized query performance by restructuring to avoid expensive operations.",
175
+ }
176
+ base = explanations.get(category, "Applied heuristic fix to the SQL query.")
177
+ return f"{base} Original: '{buggy[:60]}...' Fixed: '{fixed[:60]}...'"
178
+
179
+
180
+ # ─────────────────────────────────────────────
181
+ # MAIN BASELINE RUNNER
182
+ # ─────────────────────────────────────────────
183
+
184
+ def run_baseline() -> BaselineResponse:
185
+ """
186
+ Runs baseline agent against one task of each difficulty.
187
+ Returns BaselineResponse with scores for all 3 tasks.
188
+ Must complete within 60 seconds.
189
+ """
190
+ # Check API key exists (even if rule-based agent doesn't use it,
191
+ # the spec requires it to be validated)
192
+ try:
193
+ _check_api_key()
194
+ except ValueError as e:
195
+ print(f"Warning: {e}")
196
+ # Continue with rule-based agent anyway for demo
197
+
198
+ results = []
199
+ difficulties = [
200
+ (DifficultyLevel.EASY, "easy_001"),
201
+ (DifficultyLevel.MEDIUM, "medium_001"),
202
+ (DifficultyLevel.HARD, "hard_001"),
203
+ ]
204
+
205
+ for difficulty, task_id in difficulties:
206
+ env = SQLDebuggerEnvironment()
207
+ try:
208
+ obs = env.reset(difficulty=difficulty.value, task_id=task_id)
209
+ task_context = {"current_context": obs.current_context}
210
+
211
+ start = time.time()
212
+ score, steps, feedback = _rule_based_agent(env, task_context)
213
+ elapsed = time.time() - start
214
+
215
+ results.append(BaselineResult(
216
+ task_id = task_id,
217
+ difficulty = difficulty,
218
+ score = round(score, 4),
219
+ steps = steps,
220
+ feedback = f"{feedback} (elapsed: {elapsed:.2f}s)"
221
+ ))
222
+ print(f"Baseline {difficulty.value}: score={round(score,4)}, steps={steps}")
223
+
224
+ except Exception as e:
225
+ results.append(BaselineResult(
226
+ task_id = task_id,
227
+ difficulty = difficulty,
228
+ score = 0.0,
229
+ steps = 0,
230
+ feedback = f"Error: {str(e)}"
231
+ ))
232
+
233
+ avg = round(sum(r.score for r in results) / len(results), 4) if results else 0.0
234
+ print(f"Baseline average score: {avg}")
235
+
236
+ return BaselineResponse(results=results, average_score=avg)
237
+
238
+
239
+ # ─────────────────────────────────────────────
240
+ # DIRECT RUN
241
+ # ─────────────────────────────────────────────
242
+
243
+ if __name__ == "__main__":
244
+ print("Running baseline agent...")
245
+ response = run_baseline()
246
+ print(f"\nFinal Results:")
247
+ for r in response.results:
248
+ print(f" {r.difficulty.value:8} | {r.task_id:12} | score={r.score} | steps={r.steps}")
249
+ print(f"\nAverage Score: {response.average_score}")
env/environment.py CHANGED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import random
3
+ from typing import Optional
4
+ from pydantic import ValidationError
5
+
6
+ from env.models import (
7
+ Action, Observation, Reward, EpisodeState,
8
+ DifficultyLevel, ActionType, StepResponse
9
+ )
10
+ from env.tasks import task_manager
11
+ from env.reward import compute_reward, is_done, MAX_STEPS
12
+
13
+
14
+ class SQLDebuggerEnvironment:
15
+ """
16
+ OpenEnv-compliant SQL Query Debugger Environment.
17
+
18
+ Implements the 3 required methods:
19
+ reset() → Observation
20
+ step() → (Observation, Reward, done, info)
21
+ state() → EpisodeState
22
+
23
+ Design principles:
24
+ - Dense reward signal at every step
25
+ - No state leakage between episodes
26
+ - Graceful handling of all edge cases
27
+ - Deterministic grading
28
+ - Thread-safe episode state
29
+ """
30
+
31
+ def __init__(self):
32
+ self._state = EpisodeState()
33
+ self._current_task = None
34
+ self._started_at = None
35
+
36
+ # ─────────────────────────────────────────────
37
+ # reset() → Observation
38
+ # ─────────────────────────────────────────────
39
+
40
+ def reset(self, difficulty: Optional[str] = None, task_id: Optional[str] = None) -> Observation:
41
+ """
42
+ Starts a fresh episode. Clears ALL state from previous episode.
43
+ Loads a new task from the dataset.
44
+ Returns the initial Observation the agent sees.
45
+
46
+ Edge cases handled:
47
+ - reset() called mid-episode → cleanly resets, no state leakage
48
+ - invalid difficulty → defaults to random
49
+ - dataset empty → raises ValueError with clear message
50
+ """
51
+
52
+ # ── Resolve difficulty ────────────────────────────────────
53
+ if difficulty is not None:
54
+ try:
55
+ diff_enum = DifficultyLevel(difficulty.lower())
56
+ except ValueError:
57
+ # Invalid difficulty — pick random
58
+ diff_enum = random.choice(list(DifficultyLevel))
59
+ else:
60
+ diff_enum = random.choice(list(DifficultyLevel))
61
+
62
+ # ── Load task ─────────────────────────────────────────────
63
+ try:
64
+ task = task_manager.get_task(diff_enum, task_id=task_id)
65
+ except Exception as e:
66
+ raise ValueError(f"Failed to load task: {str(e)}")
67
+
68
+ # ── Reset ALL state — no leakage ──────────────────────────
69
+ self._current_task = task
70
+ self._started_at = time.time()
71
+ self._state = EpisodeState(
72
+ task_id = task["id"],
73
+ difficulty = diff_enum,
74
+ step_count = 0,
75
+ total_reward = 0.0,
76
+ done = False,
77
+ hints_used = 0,
78
+ previous_actions = [],
79
+ action_counts = {},
80
+ started_at = self._started_at,
81
+ last_reward = 0.0,
82
+ initialized = True,
83
+ )
84
+
85
+ # ── Build initial observation ─────────────────────────────
86
+ context = task_manager.build_observation_context(task)
87
+ return Observation(
88
+ task_id = task["id"],
89
+ task_description = task["description"],
90
+ current_context = context,
91
+ step_count = 0,
92
+ difficulty = diff_enum,
93
+ max_steps = MAX_STEPS,
94
+ hints_used = 0,
95
+ previous_actions = [],
96
+ metadata = {
97
+ "category": task.get("category", ""),
98
+ "estimated_steps": task.get("estimated_fix_steps", 5),
99
+ "started_at": self._started_at,
100
+ }
101
+ )
102
+
103
+ # ─────────────────────────────────────────────
104
+ # step() → (Observation, Reward, done, info)
105
+ # ─────────────────────────────────────────────
106
+
107
+ def step(self, action: Optional[Action]) -> StepResponse:
108
+ """
109
+ Accepts an Action, processes it, updates state,
110
+ computes dense reward, returns next Observation.
111
+
112
+ Edge cases handled:
113
+ - step() called before reset() → auto-resets
114
+ - null action → reward=-0.1, done=False, never crash
115
+ - malformed action payload → catches ValidationError
116
+ - agent loops (same action 3+ times) → loop penalty
117
+ - episode already done → returns terminal observation
118
+ - max steps reached → forces done=True
119
+ - extremely long payload → truncated in models.py
120
+ """
121
+
122
+ # ── Auto-reset if not initialized ────────────────────────
123
+ if not self._state.initialized or self._current_task is None:
124
+ obs = self.reset()
125
+ return StepResponse(
126
+ observation=obs,
127
+ reward=Reward(score=0.0, breakdown={"auto_reset": True}, feedback="Environment auto-reset."),
128
+ done=False,
129
+ info={"auto_reset": True}
130
+ )
131
+
132
+ # ── Episode already done ──────────────────────────────────
133
+ if self._state.done:
134
+ obs = self._build_observation()
135
+ return StepResponse(
136
+ observation=obs,
137
+ reward=Reward(score=0.0, breakdown={"episode_done": True}, feedback="Episode already finished. Call reset()."),
138
+ done=True,
139
+ info={"episode_done": True, "total_reward": self._state.total_reward}
140
+ )
141
+
142
+ # ── Handle null / invalid action ─────────────────────────
143
+ if action is None or action.payload is None:
144
+ self._state.step_count += 1
145
+ obs = self._build_observation()
146
+ reward = Reward(
147
+ score=-0.1,
148
+ breakdown={"invalid_action": -0.1},
149
+ feedback="Null or invalid action received. Penalty -0.1."
150
+ )
151
+ self._state.last_reward = -0.1
152
+ self._state.total_reward = round(self._state.total_reward - 0.1, 4)
153
+ done = self._state.step_count >= MAX_STEPS
154
+ self._state.done = done
155
+ return StepResponse(observation=obs, reward=reward, done=done, info={"error": "null_action"})
156
+
157
+ # ── Validate action type ──────────────────────────────────
158
+ try:
159
+ action_type_val = action.action_type.value if hasattr(action.action_type, "value") else str(action.action_type)
160
+ except Exception:
161
+ action_type_val = "unknown"
162
+
163
+ # ── Update step count ─────────────────────────────────────
164
+ self._state.step_count += 1
165
+ self._state.previous_actions.append(action_type_val)
166
+ self._state.action_counts[action_type_val] = self._state.action_counts.get(action_type_val, 0) + 1
167
+
168
+ # ── Track hints ───────────────────────────────────────────
169
+ if action.action_type == ActionType.REQUEST_HINT:
170
+ self._state.hints_used += 1
171
+ # Inject hint into next observation context
172
+ hint_text = task_manager.get_hint(self._current_task, self._state.hints_used)
173
+ self._current_task["_last_hint"] = hint_text
174
+
175
+ # ── Compute dense reward ──────────────────────────────────
176
+ reward = compute_reward(
177
+ action = action,
178
+ task_id = self._state.task_id,
179
+ difficulty = self._state.difficulty,
180
+ step_count = self._state.step_count,
181
+ previous_actions = self._state.previous_actions[:-1], # exclude current
182
+ hints_used = self._state.hints_used,
183
+ estimated_steps = self._current_task.get("estimated_fix_steps", 5),
184
+ action_counts = self._state.action_counts,
185
+ )
186
+
187
+ # ── Update cumulative reward ──────────────────────────────
188
+ self._state.last_reward = reward.score
189
+ self._state.total_reward = round(self._state.total_reward + reward.score, 4)
190
+
191
+ # ── Check done condition ──────────────────────────────────
192
+ done = is_done(
193
+ action_type = action.action_type,
194
+ step_count = self._state.step_count,
195
+ grader_score = reward.breakdown.get("grader_score", 0.0),
196
+ )
197
+ self._state.done = done
198
+
199
+ # ── Build next observation ────────────────────────────────
200
+ obs = self._build_observation()
201
+
202
+ # ── Build info dict ───────────────────────────────────────
203
+ info = {
204
+ "step_count": self._state.step_count,
205
+ "total_reward": self._state.total_reward,
206
+ "hints_used": self._state.hints_used,
207
+ "action_counts": self._state.action_counts,
208
+ "task_id": self._state.task_id,
209
+ "difficulty": self._state.difficulty.value if self._state.difficulty else None,
210
+ }
211
+ if done:
212
+ info["episode_summary"] = {
213
+ "total_steps": self._state.step_count,
214
+ "total_reward": self._state.total_reward,
215
+ "hints_used": self._state.hints_used,
216
+ "duration_sec": round(time.time() - (self._started_at or time.time()), 2),
217
+ }
218
+
219
+ return StepResponse(observation=obs, reward=reward, done=done, info=info)
220
+
221
+ # ─────────────────────────────────────────────
222
+ # state() → EpisodeState
223
+ # ─────────────────────────────────────────────
224
+
225
+ def state(self) -> EpisodeState:
226
+ """
227
+ Returns the full current state at any point.
228
+ Must be JSON-serializable. Must always reflect latest step.
229
+
230
+ Edge case: state() called before reset() → returns default empty state.
231
+ Never crashes.
232
+ """
233
+ return self._state
234
+
235
+ # ─────────────────────────────────────────────
236
+ # INTERNAL HELPERS
237
+ # ─────────────────────────────────────────────
238
+
239
+ def _build_observation(self) -> Observation:
240
+ """
241
+ Builds the current Observation from internal state.
242
+ Injects hint into context if one was just requested.
243
+ CRITICAL: Never leaks fixed_query (ground truth) to agent.
244
+ """
245
+ if self._current_task is None:
246
+ # Fallback safe observation
247
+ return Observation(
248
+ task_id = "none",
249
+ task_description = "No task loaded. Call reset() first.",
250
+ current_context = {},
251
+ step_count = self._state.step_count,
252
+ difficulty = DifficultyLevel.EASY,
253
+ max_steps = MAX_STEPS,
254
+ hints_used = self._state.hints_used,
255
+ previous_actions = self._state.previous_actions,
256
+ metadata = {}
257
+ )
258
+
259
+ context = task_manager.build_observation_context(self._current_task)
260
+
261
+ # Inject hint if available
262
+ if "_last_hint" in self._current_task:
263
+ context["last_hint"] = self._current_task["_last_hint"]
264
+
265
+ # Add step progress info
266
+ context["steps_remaining"] = MAX_STEPS - self._state.step_count
267
+ context["total_reward_so_far"] = self._state.total_reward
268
+
269
+ return Observation(
270
+ task_id = self._state.task_id or "none",
271
+ task_description = self._current_task.get("description", ""),
272
+ current_context = context,
273
+ step_count = self._state.step_count,
274
+ difficulty = self._state.difficulty or DifficultyLevel.EASY,
275
+ max_steps = MAX_STEPS,
276
+ hints_used = self._state.hints_used,
277
+ previous_actions = self._state.previous_actions.copy(),
278
+ metadata = {
279
+ "category": self._current_task.get("category", ""),
280
+ "estimated_steps": self._current_task.get("estimated_fix_steps", 5),
281
+ "total_reward": self._state.total_reward,
282
+ "action_counts": self._state.action_counts,
283
+ }
284
+ )
285
+
286
+
287
+ # ─────────────────────────────────────────────
288
+ # SINGLETON INSTANCE (used by FastAPI)
289
+ # ─────────────────────────────────────────────
290
+
291
+ environment = SQLDebuggerEnvironment()
env/graders.py CHANGED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from env.models import Action, DifficultyLevel
3
+ from env.tasks import task_manager
4
+
5
+ # HELPERS
6
+
7
+
8
+ def _normalize(text: str) -> str:
9
+ """Normalize SQL for comparison — lowercase, strip whitespace, collapse spaces."""
10
+ if not isinstance(text, str):
11
+ return ""
12
+ return re.sub(r"\s+", " ", text.strip().lower())
13
+
14
+ def _safe_get(payload: dict, key: str, default=None):
15
+ """Safe dict access — never KeyError."""
16
+ if not isinstance(payload, dict):
17
+ return default
18
+ return payload.get(key, default)
19
+
20
+ def _score_explanation(explanation: str) -> float:
21
+ """Score explanation quality by length and keyword richness."""
22
+ if not explanation or not isinstance(explanation, str):
23
+ return 0.0
24
+ explanation = explanation.strip()
25
+ if len(explanation) < 10:
26
+ return 0.0
27
+ if len(explanation) < 30:
28
+ return 0.05
29
+ if len(explanation) < 80:
30
+ return 0.10
31
+ return 0.15
32
+
33
+ def _score_confidence(confidence) -> float:
34
+ """Give partial credit for providing a valid confidence score."""
35
+ try:
36
+ c = float(confidence)
37
+ if 0.0 <= c <= 1.0:
38
+ return 0.05
39
+ except (TypeError, ValueError):
40
+ pass
41
+ return 0.0
42
+
43
+ def _query_similarity(submitted: str, expected: str) -> float:
44
+ """
45
+ Multi-level SQL similarity check.
46
+ Returns 0.0 - 1.0 based on how close the submitted query is to expected.
47
+ Handles case, whitespace, and keyword-level matching.
48
+ """
49
+ s = _normalize(submitted)
50
+ e = _normalize(expected)
51
+
52
+ # Exact match after normalization
53
+ if s == e:
54
+ return 1.0
55
+
56
+ # Tokenize and check keyword overlap
57
+ s_tokens = set(s.split())
58
+ e_tokens = set(e.split())
59
+
60
+ if not e_tokens:
61
+ return 0.0
62
+
63
+ overlap = len(s_tokens & e_tokens) / len(e_tokens)
64
+
65
+ # Check critical keywords present
66
+ critical_keywords = _extract_critical_keywords(e)
67
+ critical_found = sum(1 for kw in critical_keywords if kw in s)
68
+ critical_score = critical_found / len(critical_keywords) if critical_keywords else 0.0
69
+
70
+ # Weighted combination
71
+ return round((overlap * 0.4) + (critical_score * 0.6), 4)
72
+
73
+ def _extract_critical_keywords(query: str) -> list[str]:
74
+ """Extract SQL keywords that are critical to correctness."""
75
+ keywords = [
76
+ "left join", "inner join", "right join",
77
+ "group by", "order by", "having",
78
+ "partition by", "coalesce", "distinct",
79
+ "where", "on", "and", "or", "not",
80
+ "count", "sum", "avg", "max", "min",
81
+ "select", "from", "join"
82
+ ]
83
+ found = []
84
+ q = query.lower()
85
+ for kw in keywords:
86
+ if kw in q:
87
+ found.append(kw)
88
+ return found
89
+
90
+ def _score_error_type(submitted_type: str, expected_type: str) -> float:
91
+ """Score for correctly identifying the error type."""
92
+ if not submitted_type:
93
+ return 0.0
94
+ s = submitted_type.strip().lower()
95
+ e = expected_type.strip().lower()
96
+ if s == e:
97
+ return 0.10
98
+ # Partial: performance ↔ optimization are related
99
+ related = {
100
+ "performance": ["optimization", "slow", "index", "scan"],
101
+ "logic": ["semantic", "incorrect", "wrong"],
102
+ "syntax": ["parse", "grammar", "token"]
103
+ }
104
+ for canonical, aliases in related.items():
105
+ if e == canonical and any(alias in s for alias in aliases):
106
+ return 0.05
107
+ return 0.0
108
+
109
+ def _score_error_location(submitted_location: str, expected_location: str) -> float:
110
+ """Score for correctly identifying WHERE in the query the error is."""
111
+ if not submitted_location or not expected_location:
112
+ return 0.0
113
+ s = submitted_location.strip().lower()
114
+ e = expected_location.strip().lower()
115
+ if s == e:
116
+ return 0.15
117
+ # Partial: check if key location words overlap
118
+ e_words = set(e.split())
119
+ s_words = set(s.split())
120
+ overlap = len(e_words & s_words) / len(e_words) if e_words else 0.0
121
+ return round(overlap * 0.10, 4)
122
+
123
+
124
+ # GRADERS PER DIFFICULTY
125
+
126
+ def grade_easy(action: Action, ground_truth: dict) -> tuple[float, dict, str]:
127
+ """
128
+ Easy task grader — syntax errors.
129
+ Max score: 1.0
130
+ Partial credit across: fix correctness, error location, error type, explanation, confidence.
131
+ DETERMINISTIC: same input always returns same score.
132
+ """
133
+ # Edge case: null or malformed action
134
+ if action is None or action.payload is None:
135
+ return 0.0, {"error": "null_action"}, "No action provided."
136
+
137
+ payload = action.payload
138
+ score = 0.0
139
+ breakdown = {}
140
+ feedback_parts = []
141
+
142
+ action_type = action.action_type.value if hasattr(action.action_type, "value") else str(action.action_type)
143
+
144
+ # ── 1. Query fix correctness (0.50) ──────────────────────────
145
+ submitted_query = _safe_get(payload, "fixed_query", "") or _safe_get(payload, "optimized_query", "")
146
+ expected_query = ground_truth.get("fixed_query", "")
147
+ similarity = _query_similarity(submitted_query, expected_query)
148
+
149
+ if similarity >= 1.0:
150
+ fix_score = 0.50
151
+ feedback_parts.append("Correct fix applied.")
152
+ elif similarity >= 0.75:
153
+ fix_score = 0.30
154
+ feedback_parts.append("Fix is mostly correct but has minor differences.")
155
+ elif similarity >= 0.50:
156
+ fix_score = 0.15
157
+ feedback_parts.append("Fix is partially correct.")
158
+ else:
159
+ fix_score = 0.0
160
+ feedback_parts.append("Fix is incorrect or not provided.")
161
+
162
+ score += fix_score
163
+ breakdown["fix_correctness"] = round(fix_score, 4)
164
+
165
+ # ── 2. Error location (0.15) ─────────────────────────────────
166
+ submitted_location = _safe_get(payload, "error_location", "")
167
+ expected_location = ground_truth.get("error_location", "")
168
+ loc_score = _score_error_location(str(submitted_location), expected_location)
169
+ score += loc_score
170
+ breakdown["error_location"] = round(loc_score, 4)
171
+ if loc_score > 0:
172
+ feedback_parts.append("Correctly identified error location.")
173
+
174
+ # ── 3. Error type (0.10) ─────────────────────────────────────
175
+ submitted_type = _safe_get(payload, "error_type", "")
176
+ expected_type = ground_truth.get("error_type", "syntax")
177
+ type_score = _score_error_type(str(submitted_type), expected_type)
178
+ score += type_score
179
+ breakdown["error_type"] = round(type_score, 4)
180
+ if type_score > 0:
181
+ feedback_parts.append("Correctly identified error type.")
182
+
183
+ # ── 4. Explanation quality (0.15) ────────────────────────────
184
+ explanation = _safe_get(payload, "explanation", "") or _safe_get(payload, "change_made", "")
185
+ expl_score = _score_explanation(str(explanation) if explanation else "")
186
+ score += expl_score
187
+ breakdown["explanation"] = round(expl_score, 4)
188
+ if expl_score > 0:
189
+ feedback_parts.append("Explanation provided.")
190
+
191
+ # ── 5. Confidence (0.05) ─────────────────────────────────────
192
+ confidence = _safe_get(payload, "confidence", None)
193
+ conf_score = _score_confidence(confidence)
194
+ score += conf_score
195
+ breakdown["confidence"] = round(conf_score, 4)
196
+
197
+ # ── 6. Hint penalty ──────────────────────────────────────────
198
+ # Hint penalty is applied in reward.py, not here
199
+
200
+ final_score = round(min(score, 1.0), 4)
201
+ feedback = " ".join(feedback_parts) if feedback_parts else "No valid response provided."
202
+ return final_score, breakdown, feedback
203
+
204
+
205
+ def grade_medium(action: Action, ground_truth: dict) -> tuple[float, dict, str]:
206
+ """
207
+ Medium task grader — logic errors (wrong JOINs, wrong aggregations, etc).
208
+ Max score: 1.0
209
+ Higher bar: must correctly identify the logic flaw, not just syntax.
210
+ DETERMINISTIC: same input always returns same score.
211
+ """
212
+ if action is None or action.payload is None:
213
+ return 0.0, {"error": "null_action"}, "No action provided."
214
+
215
+ payload = action.payload
216
+ score = 0.0
217
+ breakdown = {}
218
+ feedback_parts = []
219
+
220
+ # ── 1. Query fix correctness (0.40) ──────────────────────────
221
+ submitted_query = _safe_get(payload, "fixed_query", "") or _safe_get(payload, "optimized_query", "")
222
+ expected_query = ground_truth.get("fixed_query", "")
223
+ similarity = _query_similarity(submitted_query, expected_query)
224
+
225
+ if similarity >= 1.0:
226
+ fix_score = 0.40
227
+ feedback_parts.append("Correct fix applied.")
228
+ elif similarity >= 0.80:
229
+ fix_score = 0.28
230
+ feedback_parts.append("Fix is mostly correct.")
231
+ elif similarity >= 0.60:
232
+ fix_score = 0.16
233
+ feedback_parts.append("Fix is partially correct.")
234
+ elif similarity >= 0.40:
235
+ fix_score = 0.08
236
+ feedback_parts.append("Fix shows some understanding.")
237
+ else:
238
+ fix_score = 0.0
239
+ feedback_parts.append("Fix is incorrect or missing.")
240
+
241
+ score += fix_score
242
+ breakdown["fix_correctness"] = round(fix_score, 4)
243
+
244
+ # ── 2. Identifies the logic flaw (0.20) ──────────────────────
245
+ explanation = str(_safe_get(payload, "explanation", "") or _safe_get(payload, "change_made", "") or "")
246
+ error_type = ground_truth.get("error_type", "logic")
247
+ category = ground_truth.get("category", "")
248
+
249
+ logic_keywords = {
250
+ "logic": ["join", "left join", "inner join", "having", "where", "group by",
251
+ "aggregate", "subquery", "correlation", "distinct", "count"],
252
+ "performance": ["index", "scan", "n+1", "correlated", "cartesian", "window"]
253
+ }
254
+
255
+ keywords_to_check = logic_keywords.get(error_type, logic_keywords["logic"])
256
+ expl_lower = explanation.lower()
257
+ keyword_hits = sum(1 for kw in keywords_to_check if kw in expl_lower)
258
+ logic_score = min(keyword_hits * 0.05, 0.20)
259
+ score += logic_score
260
+ breakdown["logic_flaw_identification"] = round(logic_score, 4)
261
+ if logic_score > 0:
262
+ feedback_parts.append("Shows understanding of the logic flaw.")
263
+
264
+ # ── 3. Error location (0.15) ─────────────────────────────────
265
+ submitted_location = _safe_get(payload, "error_location", "")
266
+ expected_location = ground_truth.get("error_location", "")
267
+ loc_score = _score_error_location(str(submitted_location), expected_location)
268
+ score += loc_score
269
+ breakdown["error_location"] = round(loc_score, 4)
270
+
271
+ # ── 4. Explanation quality (0.15) ────────────────────────────
272
+ expl_score = _score_explanation(explanation)
273
+ score += expl_score
274
+ breakdown["explanation"] = round(expl_score, 4)
275
+
276
+ # ── 5. Confidence (0.05) ─────────────────────────────────────
277
+ confidence = _safe_get(payload, "confidence", None)
278
+ conf_score = _score_confidence(confidence)
279
+ score += conf_score
280
+ breakdown["confidence"] = round(conf_score, 4)
281
+
282
+ # ── 6. Impact analysis bonus (0.05) ──────────────────────────
283
+ impact = str(_safe_get(payload, "impact", "") or "")
284
+ if len(impact.strip()) > 20:
285
+ score += 0.05
286
+ breakdown["impact_analysis"] = 0.05
287
+ feedback_parts.append("Impact analysis provided.")
288
+ else:
289
+ breakdown["impact_analysis"] = 0.0
290
+
291
+ final_score = round(min(score, 1.0), 4)
292
+ feedback = " ".join(feedback_parts) if feedback_parts else "No valid response provided."
293
+ return final_score, breakdown, feedback
294
+
295
+
296
+ def grade_hard(action: Action, ground_truth: dict) -> tuple[float, dict, str]:
297
+ """
298
+ Hard task grader — performance issues (N+1, missing index, cartesian, etc).
299
+ Max score: 1.0 but frontier models expected ~0.10-0.20.
300
+ Extremely strict — requires deep understanding of performance concepts.
301
+ DETERMINISTIC: same input always returns same score.
302
+ """
303
+ if action is None or action.payload is None:
304
+ return 0.0, {"error": "null_action"}, "No action provided."
305
+
306
+ payload = action.payload
307
+ score = 0.0
308
+ breakdown = {}
309
+ feedback_parts = []
310
+
311
+ rubric = ground_truth.get("scoring_rubric", {})
312
+
313
+ # ── 1. Query correctness (0.30) ──────────────────────────────
314
+ submitted_query = (
315
+ _safe_get(payload, "optimized_query", "")
316
+ or _safe_get(payload, "fixed_query", "")
317
+ or ""
318
+ )
319
+ expected_query = ground_truth.get("fixed_query", "")
320
+ similarity = _query_similarity(submitted_query, expected_query)
321
+
322
+ if similarity >= 1.0:
323
+ fix_score = 0.30
324
+ feedback_parts.append("Perfectly optimized query.")
325
+ elif similarity >= 0.85:
326
+ fix_score = 0.22
327
+ feedback_parts.append("Query is mostly correct.")
328
+ elif similarity >= 0.65:
329
+ fix_score = 0.14
330
+ feedback_parts.append("Query shows correct approach but incomplete.")
331
+ elif similarity >= 0.40:
332
+ fix_score = 0.07
333
+ feedback_parts.append("Query partially addresses the issue.")
334
+ else:
335
+ fix_score = 0.0
336
+ feedback_parts.append("Query does not address the performance issue.")
337
+
338
+ score += fix_score
339
+ breakdown["query_correctness"] = round(fix_score, 4)
340
+
341
+ # ── 2. Performance concept identification (0.30) ──────────────
342
+ explanation = str(_safe_get(payload, "explanation", "") or _safe_get(payload, "change_made", "") or "")
343
+ optimization = str(_safe_get(payload, "optimization_type", "") or "")
344
+ combined_text = (explanation + " " + optimization).lower()
345
+ perf_issue = ground_truth.get("performance_issue", {})
346
+ issue_type = perf_issue.get("type", "").lower()
347
+
348
+ performance_concept_map = {
349
+ "n+1": ["n+1", "correlated subquery", "subquery per row", "multiple queries", "join instead"],
350
+ "full table scan": ["full table scan", "index not used", "function on column", "sargable", "range scan", "seek"],
351
+ "cartesian product": ["cartesian", "cross join", "missing join condition", "implicit join", "comma join"],
352
+ "select *": ["select *", "over-fetch", "covering index", "column projection", "unnecessary columns"],
353
+ "window function": ["window function", "partition by", "row_number", "subquery filter", "where clause window"]
354
+ }
355
+
356
+ concept_score = 0.0
357
+ for concept, keywords in performance_concept_map.items():
358
+ if any(concept_part in issue_type for concept_part in concept.split()):
359
+ hits = sum(1 for kw in keywords if kw in combined_text)
360
+ concept_score = min(hits * 0.06, 0.30)
361
+ break
362
+
363
+ score += concept_score
364
+ breakdown["performance_concept"] = round(concept_score, 4)
365
+ if concept_score > 0:
366
+ feedback_parts.append("Demonstrates understanding of the performance issue.")
367
+
368
+ # ── 3. Explanation depth (0.15) ───────────────────────────────
369
+ expl_score = _score_explanation(explanation)
370
+ # Hard tasks require deeper explanations — bonus for long explanations
371
+ if len(explanation.strip()) > 150:
372
+ expl_score = min(expl_score + 0.05, 0.15)
373
+ score += expl_score
374
+ breakdown["explanation_depth"] = round(expl_score, 4)
375
+
376
+ # ── 4. Root cause analysis (0.10) ─────────────────────────────
377
+ root_cause = str(_safe_get(payload, "root_cause", "") or "")
378
+ if len(root_cause.strip()) > 30:
379
+ score += 0.10
380
+ breakdown["root_cause_analysis"] = 0.10
381
+ feedback_parts.append("Root cause analysis provided.")
382
+ else:
383
+ breakdown["root_cause_analysis"] = 0.0
384
+
385
+ # ── 5. Expected improvement (0.10) ────────────────────────────
386
+ improvement = str(_safe_get(payload, "expected_improvement", "") or "")
387
+ if len(improvement.strip()) > 20:
388
+ score += 0.10
389
+ breakdown["expected_improvement"] = 0.10
390
+ feedback_parts.append("Performance improvement estimate provided.")
391
+ else:
392
+ breakdown["expected_improvement"] = 0.0
393
+
394
+ # ── 6. Confidence (0.05) ──────────────────────────────────────
395
+ confidence = _safe_get(payload, "confidence", None)
396
+ conf_score = _score_confidence(confidence)
397
+ score += conf_score
398
+ breakdown["confidence"] = round(conf_score, 4)
399
+
400
+ # Hard cap: frontier model should score ~0.10-0.20
401
+ # We do NOT artificially cap — the rubric naturally produces low scores
402
+ final_score = round(min(score, 1.0), 4)
403
+ feedback = " ".join(feedback_parts) if feedback_parts else "Performance issue not identified."
404
+ return final_score, breakdown, feedback
405
+
406
+
407
+ # ─────────────────────────────────────────────
408
+ # MAIN GRADER DISPATCHER
409
+ # ─────────────────────────────────────────────
410
+
411
+ def grade(action: Action, task_id: str) -> tuple[float, dict, str]:
412
+ """
413
+ Main grader entry point.
414
+ Looks up ground truth, dispatches to correct grader by difficulty.
415
+ ALWAYS returns (float, dict, str) — never crashes.
416
+ """
417
+ # Edge case: null action
418
+ if action is None:
419
+ return 0.0, {"error": "null_action"}, "No action provided."
420
+
421
+ # Edge case: unknown task
422
+ ground_truth = task_manager.get_ground_truth(task_id)
423
+ if ground_truth is None:
424
+ return 0.0, {"error": "unknown_task"}, f"Task '{task_id}' not found."
425
+
426
+ # Dispatch by difficulty
427
+ difficulty = ground_truth.get("id", "").split("_")[0]
428
+
429
+ try:
430
+ if difficulty == "easy":
431
+ return grade_easy(action, ground_truth)
432
+ elif difficulty == "medium":
433
+ return grade_medium(action, ground_truth)
434
+ elif difficulty == "hard":
435
+ return grade_hard(action, ground_truth)
436
+ else:
437
+ return 0.0, {"error": "unknown_difficulty"}, f"Unknown difficulty: {difficulty}"
438
+ except Exception as e:
439
+ # Never crash — return 0.0 with error info
440
+ return 0.0, {"error": str(e)}, f"Grader error: {str(e)}"
env/reward.py CHANGED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from env.models import Action, Reward, DifficultyLevel, ActionType
2
+ from env.graders import grade
3
+
4
+ # CONSTANTS
5
+
6
+ MAX_STEPS = 20
7
+ HINT_PENALTY = -0.05 # Per hint requested
8
+ LOOP_PENALTY = -0.05 # Same action 3+ times in a row
9
+ INVALID_PENALTY = -0.10 # Null / malformed action
10
+ STEP_EFFICIENCY_BONUS = 0.10 # Bonus for solving in fewer steps than estimated
11
+
12
+ # Dense reward per action type (before grader score)
13
+ STEP_REWARDS = {
14
+ ActionType.IDENTIFY_ERROR: 0.15, # Rewarded for diagnosing
15
+ ActionType.PROPOSE_FIX: 0.25, # Rewarded for attempting fix
16
+ ActionType.SUBMIT_ANSWER: 0.00, # Final score comes from grader
17
+ ActionType.REQUEST_HINT: 0.00, # No reward, only penalty
18
+ ActionType.EXPLAIN_ISSUE: 0.10, # Rewarded for explaining
19
+ ActionType.OPTIMIZE_QUERY: 0.20, # Rewarded for optimization attempt
20
+ }
21
+
22
+
23
+ # LOOP DETECTOR
24
+
25
+
26
+ def _detect_loop(previous_actions: list[str], current_action: str) -> bool:
27
+ """
28
+ Returns True if the agent has submitted the same action type
29
+ 3 or more times in a row — indicating a stuck loop.
30
+ """
31
+ if len(previous_actions) < 2:
32
+ return False
33
+ last_two = previous_actions[-2:]
34
+ return all(a == current_action for a in last_two)
35
+
36
+
37
+ def _count_consecutive(previous_actions: list[str], current_action: str) -> int:
38
+ """Count how many times the current action has been repeated consecutively."""
39
+ count = 1
40
+ for a in reversed(previous_actions):
41
+ if a == current_action:
42
+ count += 1
43
+ else:
44
+ break
45
+ return count
46
+
47
+
48
+ # EFFICIENCY BONUS
49
+
50
+
51
+ def _efficiency_bonus(step_count: int, estimated_steps: int) -> float:
52
+ """
53
+ Bonus reward if agent solves faster than estimated.
54
+ Encourages efficient reasoning, not just correct answers.
55
+ """
56
+ if step_count <= 0 or estimated_steps <= 0:
57
+ return 0.0
58
+ if step_count <= estimated_steps:
59
+ ratio = step_count / estimated_steps
60
+ # More bonus the faster — scales from 0.10 down to 0.0
61
+ return round(STEP_EFFICIENCY_BONUS * (1.0 - ratio + 0.1), 4)
62
+ return 0.0
63
+
64
+
65
+ # MAIN REWARD FUNCTION
66
+
67
+ def compute_reward(
68
+ action: Action,
69
+ task_id: str,
70
+ difficulty: DifficultyLevel,
71
+ step_count: int,
72
+ previous_actions: list[str],
73
+ hints_used: int,
74
+ estimated_steps: int,
75
+ action_counts: dict[str, int],
76
+ ) -> Reward:
77
+ """
78
+ Computes a DENSE reward signal for every step.
79
+ Never returns 0.0 for all steps — reward varies at each step.
80
+
81
+ Dense reward components:
82
+ 1. Step reward — small reward just for taking valid action
83
+ 2. Grader score — full grader score on submit_answer / optimize_query
84
+ 3. Loop penalty — repeated same action 3+ times
85
+ 4. Hint penalty — accumulated hint cost
86
+ 5. Efficiency bonus — solved faster than estimated steps
87
+ 6. Invalid penalty — null / malformed action
88
+
89
+ Score is always clamped to [-1.0, 1.0].
90
+ """
91
+
92
+ breakdown = {}
93
+ feedback_parts = []
94
+ final_score = 0.0
95
+
96
+ # ── Edge case: null action ────────────────────────────────────
97
+ if action is None or action.payload is None:
98
+ return Reward(
99
+ score=-0.1,
100
+ breakdown={"invalid_action": -0.1},
101
+ feedback="Invalid or null action received. Penalty applied."
102
+ )
103
+
104
+ action_type_val = action.action_type.value if hasattr(action.action_type, "value") else str(action.action_type)
105
+ action_type_enum = action.action_type
106
+
107
+ # ── 1. Step reward (dense signal) ────────────────────────────
108
+ step_reward = STEP_REWARDS.get(action_type_enum, 0.05)
109
+ breakdown["step_reward"] = round(step_reward, 4)
110
+ final_score += step_reward
111
+ if step_reward > 0:
112
+ feedback_parts.append(f"Action '{action_type_val}' rewarded +{step_reward}.")
113
+
114
+ # ── 2. Grader score for terminal actions ──────────────────────
115
+ grader_score = 0.0
116
+ is_terminal = action_type_enum in (ActionType.SUBMIT_ANSWER, ActionType.OPTIMIZE_QUERY)
117
+
118
+ if is_terminal:
119
+ raw_score, grader_breakdown, grader_feedback = grade(action, task_id)
120
+ grader_score = raw_score
121
+ breakdown["grader_score"] = round(grader_score, 4)
122
+ breakdown["grader_breakdown"] = grader_breakdown
123
+ final_score += grader_score
124
+ feedback_parts.append(grader_feedback)
125
+
126
+ # Efficiency bonus — only on correct terminal action
127
+ if grader_score >= 0.5:
128
+ eff_bonus = _efficiency_bonus(step_count, estimated_steps)
129
+ if eff_bonus > 0:
130
+ final_score += eff_bonus
131
+ breakdown["efficiency_bonus"] = round(eff_bonus, 4)
132
+ feedback_parts.append(f"Efficiency bonus +{eff_bonus} for solving in {step_count} steps.")
133
+
134
+ elif action_type_enum == ActionType.PROPOSE_FIX:
135
+ # Partial grader score for propose_fix — encourages iterative improvement
136
+ raw_score, grader_breakdown, _ = grade(action, task_id)
137
+ partial = round(raw_score * 0.4, 4) # 40% of full grader score
138
+ grader_score = partial
139
+ breakdown["partial_grader_score"] = partial
140
+ final_score += partial
141
+ if partial > 0:
142
+ feedback_parts.append(f"Partial fix credit +{partial}.")
143
+
144
+ elif action_type_enum == ActionType.IDENTIFY_ERROR:
145
+ # Small grader check on error identification
146
+ raw_score, _, _ = grade(action, task_id)
147
+ partial = round(raw_score * 0.2, 4) # 20% for identification step
148
+ breakdown["identification_score"] = partial
149
+ final_score += partial
150
+
151
+ # ── 3. Loop penalty ───────────────────────────────────────────
152
+ if _detect_loop(previous_actions, action_type_val):
153
+ consecutive = _count_consecutive(previous_actions, action_type_val)
154
+ loop_pen = LOOP_PENALTY * min(consecutive - 2, 3) # Cap at 3x penalty
155
+ final_score += loop_pen
156
+ breakdown["loop_penalty"] = round(loop_pen, 4)
157
+ feedback_parts.append(f"Loop detected ({consecutive}x same action). Penalty {loop_pen}.")
158
+
159
+ # ── 4. Hint penalty ───────────────────────────────────────────
160
+ if action_type_enum == ActionType.REQUEST_HINT:
161
+ hint_pen = HINT_PENALTY
162
+ final_score += hint_pen
163
+ breakdown["hint_penalty"] = round(hint_pen, 4)
164
+ feedback_parts.append(f"Hint requested. Penalty {hint_pen}.")
165
+
166
+ # ── 5. Max steps penalty ──────────────────────────────────────
167
+ if step_count >= MAX_STEPS - 1:
168
+ final_score += -0.10
169
+ breakdown["max_steps_penalty"] = -0.10
170
+ feedback_parts.append("Approaching max steps limit. Penalty applied.")
171
+
172
+ # ── Clamp to [-1.0, 1.0] ─────────────────────────────────────
173
+ final_score = round(max(-1.0, min(1.0, final_score)), 4)
174
+ breakdown["total"] = final_score
175
+
176
+ feedback = " ".join(feedback_parts) if feedback_parts else "Step processed."
177
+
178
+ return Reward(
179
+ score=final_score,
180
+ breakdown=breakdown,
181
+ feedback=feedback
182
+ )
183
+
184
+
185
+ # EPISODE DONE CONDITION
186
+
187
+ def is_done(
188
+ action_type: ActionType,
189
+ step_count: int,
190
+ grader_score: float = 0.0,
191
+ ) -> bool:
192
+ """
193
+ Episode ends when:
194
+ 1. Agent submits final answer (submit_answer / optimize_query)
195
+ 2. Max steps reached
196
+ 3. Perfect score achieved
197
+ """
198
+ if action_type in (ActionType.SUBMIT_ANSWER, ActionType.OPTIMIZE_QUERY):
199
+ return True
200
+ if step_count >= MAX_STEPS:
201
+ return True
202
+ if grader_score >= 1.0:
203
+ return True
204
+ return False
openenv.yaml CHANGED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: sql-query-debugger
2
+ version: '1.0.0'
3
+ description: 'An OpenEnv environment where AI agents learn to debug SQL queries across syntax errors, logic bugs, and performance issues'
4
+ tags: [openenv, real-world, sql, debugging, performance, reinforcement-learning]
5
+
6
+ tasks:
7
+ - id: easy_001
8
+ difficulty: easy
9
+ description: 'Fix SQL syntax error: missing commas in SELECT clause'
10
+
11
+ - id: easy_002
12
+ difficulty: easy
13
+ description: 'Fix SQL syntax error: missing WHERE keyword'
14
+
15
+ - id: easy_003
16
+ difficulty: easy
17
+ description: 'Fix SQL syntax error: unclosed string literal'
18
+
19
+ - id: easy_004
20
+ difficulty: easy
21
+ description: 'Fix SQL syntax error: ORDER used instead of ORDER BY'
22
+
23
+ - id: easy_005
24
+ difficulty: easy
25
+ description: 'Fix SQL syntax error: GROUP used instead of GROUP BY'
26
+
27
+ - id: medium_001
28
+ difficulty: medium
29
+ description: 'Fix wrong JOIN type: INNER JOIN excludes users with no orders'
30
+
31
+ - id: medium_002
32
+ difficulty: medium
33
+ description: 'Fix wrong JOIN condition causing incorrect product associations'
34
+
35
+ - id: medium_003
36
+ difficulty: medium
37
+ description: 'Fix aggregation logic: HAVING clause used incorrectly as WHERE'
38
+
39
+ - id: medium_004
40
+ difficulty: medium
41
+ description: 'Fix correlated subquery correlating on wrong column'
42
+
43
+ - id: medium_005
44
+ difficulty: medium
45
+ description: 'Fix DISTINCT misuse with COUNT causing invalid query'
46
+
47
+ - id: hard_001
48
+ difficulty: hard
49
+ description: 'Detect and fix N+1 query pattern with correlated subqueries'
50
+
51
+ - id: hard_002
52
+ difficulty: hard
53
+ description: 'Fix function on indexed column preventing index usage'
54
+
55
+ - id: hard_003
56
+ difficulty: hard
57
+ description: 'Fix implicit cartesian product from missing JOIN condition'
58
+
59
+ - id: hard_004
60
+ difficulty: hard
61
+ description: 'Fix SELECT * in multi-table JOIN causing over-fetching'
62
+
63
+ - id: hard_005
64
+ difficulty: hard
65
+ description: 'Fix window function misuse with missing PARTITION BY and ORDER BY'
66
+
67
+ action_space:
68
+ type: discrete
69
+ actions:
70
+ - identify_error
71
+ - propose_fix
72
+ - submit_answer
73
+ - request_hint
74
+ - explain_issue
75
+ - optimize_query
76
+
77
+ observation_space:
78
+ type: dict
79
+ fields:
80
+ - task_id
81
+ - task_description
82
+ - current_context
83
+ - step_count
84
+ - difficulty
85
+ - max_steps
86
+ - hints_used
87
+ - previous_actions
88
+
89
+ reward:
90
+ min: -1.0
91
+ max: 1.0
92
+ type: dense
93
+ description: 'Dense reward signal at every step. Partial credit for identification, fixing, and explanation quality.'
94
+
95
+ episode:
96
+ max_steps: 20
97
+ termination: 'submit_answer or optimize_query action, or max_steps reached'
98
+
99
+ api:
100
+ port: 7860
101
+ endpoints:
102
+ - GET /health
103
+ - POST /reset
104
+ - POST /step
105
+ - GET /state
106
+ - GET /tasks
107
+ - POST /grader
108
+ - POST /baseline
requirements.txt CHANGED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi==0.135.2
2
+ uvicorn==0.42.0
3
+ pydantic==2.12.5
4
+ openai==2.30.0
5
+ python-dotenv==1.2.2
6
+ pytest==9.0.2
7
+ huggingface_hub==1.8.0
tests/__init__.py ADDED
File without changes
tests/test_environment.py CHANGED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from env.environment import SQLDebuggerEnvironment
3
+ from env.models import Action, ActionType, DifficultyLevel
4
+
5
+
6
+ @pytest.fixture
7
+ def env():
8
+ e = SQLDebuggerEnvironment()
9
+ return e
10
+
11
+
12
+ def test_state_before_reset(env):
13
+ """state() before reset must not crash — returns default state."""
14
+ s = env.state()
15
+ assert s.initialized == False
16
+ assert s.step_count == 0
17
+
18
+
19
+ def test_reset_easy(env):
20
+ obs = env.reset(difficulty="easy")
21
+ assert obs.task_id.startswith("easy")
22
+ assert obs.step_count == 0
23
+ assert obs.difficulty == DifficultyLevel.EASY
24
+ assert "fixed_query" not in obs.current_context
25
+ assert "buggy_query" in obs.current_context
26
+
27
+
28
+ def test_reset_medium(env):
29
+ obs = env.reset(difficulty="medium")
30
+ assert obs.task_id.startswith("medium")
31
+
32
+
33
+ def test_reset_hard(env):
34
+ obs = env.reset(difficulty="hard")
35
+ assert obs.task_id.startswith("hard")
36
+
37
+
38
+ def test_reset_clears_state(env):
39
+ """Reset mid-episode must clear all state — no leakage."""
40
+ env.reset(difficulty="easy")
41
+ action = Action(action_type=ActionType.IDENTIFY_ERROR,
42
+ payload={"error_location": "SELECT", "error_type": "syntax"})
43
+ env.step(action)
44
+ assert env.state().step_count == 1
45
+
46
+ # Reset mid-episode
47
+ env.reset(difficulty="medium")
48
+ assert env.state().step_count == 0
49
+ assert env.state().total_reward == 0.0
50
+ assert env.state().previous_actions == []
51
+
52
+
53
+ def test_step_identify_error(env):
54
+ env.reset(difficulty="easy")
55
+ action = Action(action_type=ActionType.IDENTIFY_ERROR,
56
+ payload={"error_location": "SELECT clause", "error_type": "syntax",
57
+ "explanation": "Missing commas"})
58
+ resp = env.step(action)
59
+ assert resp.reward.score > 0
60
+ assert resp.done == False
61
+ assert resp.observation.step_count == 1
62
+
63
+
64
+ def test_step_null_action(env):
65
+ """Null action must return -0.1, never crash."""
66
+ env.reset(difficulty="easy")
67
+ resp = env.step(None)
68
+ assert resp.reward.score == -0.1
69
+ assert resp.done == False
70
+
71
+
72
+ def test_step_after_done(env):
73
+ """Step after done must not crash."""
74
+ env.reset(difficulty="easy", task_id="easy_001")
75
+ action = Action(action_type=ActionType.SUBMIT_ANSWER,
76
+ payload={"fixed_query": "SELECT id, name, email FROM users WHERE active = 1",
77
+ "explanation": "Fixed", "confidence": 0.9})
78
+ env.step(action)
79
+ assert env.state().done == True
80
+
81
+ # Step again after done
82
+ resp = env.step(action)
83
+ assert resp.done == True
84
+ assert "Call reset()" in resp.reward.feedback
85
+
86
+
87
+ def test_dense_reward(env):
88
+ """Reward must vary at each step — not only at end."""
89
+ env.reset(difficulty="easy")
90
+ rewards = []
91
+ actions = [
92
+ Action(action_type=ActionType.IDENTIFY_ERROR,
93
+ payload={"error_location": "SELECT", "error_type": "syntax"}),
94
+ Action(action_type=ActionType.EXPLAIN_ISSUE,
95
+ payload={"explanation": "Missing commas between column names in SELECT"}),
96
+ ]
97
+ for a in actions:
98
+ r = env.step(a)
99
+ rewards.append(r.reward.score)
100
+ if r.done:
101
+ break
102
+
103
+ # Rewards must not all be zero
104
+ assert any(r != 0.0 for r in rewards)
105
+
106
+
107
+ def test_max_steps(env):
108
+ """Episode must terminate at max_steps."""
109
+ env.reset(difficulty="easy")
110
+ action = Action(action_type=ActionType.IDENTIFY_ERROR,
111
+ payload={"error_location": "x", "error_type": "syntax"})
112
+ done = False
113
+ for _ in range(25):
114
+ resp = env.step(action)
115
+ if resp.done:
116
+ done = True
117
+ break
118
+ assert done == True
119
+
120
+
121
+ def test_hint_injected_in_context(env):
122
+ """Hint must appear in next observation after request_hint."""
123
+ env.reset(difficulty="easy")
124
+ action = Action(action_type=ActionType.REQUEST_HINT,
125
+ payload={"hint_type": "location"})
126
+ resp = env.step(action)
127
+ assert "last_hint" in resp.observation.current_context
128
+
129
+
130
+ def test_state_reflects_latest_step(env):
131
+ """state() must always reflect the latest step accurately."""
132
+ env.reset(difficulty="easy")
133
+ action = Action(action_type=ActionType.IDENTIFY_ERROR,
134
+ payload={"error_location": "SELECT", "error_type": "syntax"})
135
+ env.step(action)
136
+ s = env.state()
137
+ assert s.step_count == 1
138
+ assert s.initialized == True
139
+ assert "identify_error" in s.previous_actions
tests/test_graders.py CHANGED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from env.models import Action, ActionType
3
+ from env.graders import grade, _normalize, _query_similarity
4
+
5
+
6
+ def test_easy_perfect_score():
7
+ action = Action(
8
+ action_type=ActionType.SUBMIT_ANSWER,
9
+ payload={
10
+ "fixed_query": "SELECT id, name, email FROM users WHERE active = 1",
11
+ "explanation": "Added missing commas between column names in SELECT clause",
12
+ "error_type": "syntax",
13
+ "error_location":"SELECT clause",
14
+ "confidence": 0.95
15
+ }
16
+ )
17
+ score, breakdown, feedback = grade(action, "easy_001")
18
+ assert score > 0.5
19
+ assert 0.0 <= score <= 1.0
20
+
21
+
22
+ def test_null_action_returns_zero():
23
+ score, breakdown, feedback = grade(None, "easy_001")
24
+ assert score == 0.0
25
+ assert "null" in feedback.lower() or "no action" in feedback.lower()
26
+
27
+
28
+ def test_unknown_task_returns_zero():
29
+ action = Action(action_type=ActionType.SUBMIT_ANSWER,
30
+ payload={"fixed_query": "SELECT 1", "explanation": "test"})
31
+ score, _, _ = grade(action, "nonexistent_task_999")
32
+ assert score == 0.0
33
+
34
+
35
+ def test_determinism():
36
+ """Same input must always return same score."""
37
+ action = Action(
38
+ action_type=ActionType.SUBMIT_ANSWER,
39
+ payload={
40
+ "fixed_query": "SELECT id, name, email FROM users WHERE active = 1",
41
+ "explanation": "Fixed commas",
42
+ "error_type": "syntax",
43
+ "error_location":"SELECT clause",
44
+ "confidence": 0.9
45
+ }
46
+ )
47
+ scores = [grade(action, "easy_001")[0] for _ in range(5)]
48
+ assert len(set(scores)) == 1
49
+
50
+
51
+ def test_score_range():
52
+ """All graders must return score in 0.0 - 1.0."""
53
+ action = Action(action_type=ActionType.SUBMIT_ANSWER,
54
+ payload={"fixed_query": "SELECT 1", "explanation": "test"})
55
+ for task_id in ["easy_001", "medium_001", "hard_001"]:
56
+ score, _, _ = grade(action, task_id)
57
+ assert 0.0 <= score <= 1.0, f"Score out of range for {task_id}: {score}"
58
+
59
+
60
+ def test_no_binary_graders():
61
+ """Graders must not always return only 0 or only 1."""
62
+ payloads = [
63
+ {"fixed_query": "SELECT id, name, email FROM users WHERE active = 1",
64
+ "explanation": "Fixed", "confidence": 0.9},
65
+ {"fixed_query": "SELECT *", "explanation": "wrong"},
66
+ {"fixed_query": "", "explanation": ""},
67
+ ]
68
+ for task_id in ["easy_001", "medium_001", "hard_001"]:
69
+ scores = set()
70
+ for p in payloads:
71
+ action = Action(action_type=ActionType.SUBMIT_ANSWER, payload=p)
72
+ score, _, _ = grade(action, task_id)
73
+ scores.add(score)
74
+ assert len(scores) > 1, f"Grader for {task_id} returns same score always"
75
+
76
+
77
+ def test_empty_string_answer():
78
+ """Empty string must return 0.0, not crash."""
79
+ action = Action(action_type=ActionType.SUBMIT_ANSWER,
80
+ payload={"fixed_query": "", "explanation": ""})
81
+ score, _, _ = grade(action, "easy_001")
82
+ assert score == 0.0 or score < 0.3
83
+
84
+
85
+ def test_case_insensitive_normalization():
86
+ """Grader normalizes case — UPPER and lower should score similarly."""
87
+ action_upper = Action(action_type=ActionType.SUBMIT_ANSWER,
88
+ payload={"fixed_query": "SELECT ID, NAME, EMAIL FROM USERS WHERE ACTIVE = 1",
89
+ "explanation": "Fixed", "confidence": 0.9})
90
+ action_lower = Action(action_type=ActionType.SUBMIT_ANSWER,
91
+ payload={"fixed_query": "select id, name, email from users where active = 1",
92
+ "explanation": "Fixed", "confidence": 0.9})
93
+ score_upper, _, _ = grade(action_upper, "easy_001")
94
+ score_lower, _, _ = grade(action_lower, "easy_001")
95
+ assert abs(score_upper - score_lower) < 0.1
96
+
97
+
98
+ def test_whitespace_normalization():
99
+ """Extra whitespace must not affect score."""
100
+ action = Action(action_type=ActionType.SUBMIT_ANSWER,
101
+ payload={"fixed_query": " SELECT id, name, email FROM users WHERE active = 1 ",
102
+ "explanation": "Fixed", "confidence": 0.9})
103
+ score, _, _ = grade(action, "easy_001")
104
+ assert score > 0.5
105
+
106
+
107
+ def test_medium_logic_grader():
108
+ action = Action(
109
+ action_type=ActionType.SUBMIT_ANSWER,
110
+ payload={
111
+ "fixed_query": "SELECT u.id, u.name, COUNT(o.id) as order_count FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name",
112
+ "explanation": "Changed INNER JOIN to LEFT JOIN to include users with zero orders",
113
+ "error_type": "logic",
114
+ "error_location": "JOIN type",
115
+ "confidence": 0.9
116
+ }
117
+ )
118
+ score, _, _ = grade(action, "medium_001")
119
+ assert score > 0.4
120
+
121
+
122
+ def test_hard_grader_frontier_model_range():
123
+ """Hard grader must allow scores in 0.10-0.20 range for partial answers."""
124
+ action = Action(
125
+ action_type=ActionType.OPTIMIZE_QUERY,
126
+ payload={
127
+ "optimized_query": "SELECT u.id FROM users u LEFT JOIN orders o ON u.id = o.user_id",
128
+ "optimization_type": "Replace N+1 with JOIN",
129
+ "explanation": "N+1 pattern detected",
130
+ "confidence": 0.5
131
+ }
132
+ )
133
+ score, _, _ = grade(action, "hard_001")
134
+ assert 0.0 <= score <= 1.0
135
+
136
+
137
+ def test_query_similarity_helper():
138
+ assert _query_similarity("SELECT id FROM users", "SELECT id FROM users") == 1.0
139
+ assert _query_similarity("", "SELECT id FROM users") < 0.5
140
+ assert _query_similarity("SELECT id FROM users", "") == 0.0