Deadman922 commited on
Commit
08dd5be
·
verified ·
1 Parent(s): c4dc0b8

Upload 6 files

Browse files
Files changed (6) hide show
  1. server/app.py +17 -0
  2. server/environment.py +509 -0
  3. server/graders.py +843 -0
  4. server/main.py +140 -0
  5. server/models.py +191 -0
  6. server/tasks.py +690 -0
server/app.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Alias for server/main.py — required by openenv CLI structure.
3
+ The actual application is defined in server/main.py.
4
+ """
5
+
6
+ from server.main import app, run_server
7
+
8
+ __all__ = ["app"]
9
+
10
+
11
+ def main():
12
+ """Main function for openenv CLI compatibility."""
13
+ run_server()
14
+
15
+
16
+ if __name__ == "__main__":
17
+ main()
server/environment.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQL Quality Environment - Core environment manager."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random as _random
6
+ import sqlite3
7
+ import threading
8
+ import uuid
9
+ from typing import Any, Dict
10
+
11
+ from fastapi import HTTPException
12
+
13
+ from server.models import (
14
+ ActionType,
15
+ SQLAction,
16
+ SQLObservation,
17
+ SQLReward,
18
+ SQLState,
19
+ StepResponse,
20
+ TrajectoryStep,
21
+ )
22
+ from server.tasks import (
23
+ create_dedup_bug_task,
24
+ create_fk_integrity_task,
25
+ create_null_audit_task,
26
+ create_schema_drift_task,
27
+ create_type_repair_task,
28
+ )
29
+
30
+ TASK_CREATORS = {
31
+ "null_audit": create_null_audit_task,
32
+ "type_repair": create_type_repair_task,
33
+ "dedup_bug": create_dedup_bug_task,
34
+ "fk_integrity": create_fk_integrity_task,
35
+ "schema_drift": create_schema_drift_task,
36
+ }
37
+
38
+
39
+ class SQLQualityEnvironment:
40
+ """Main environment class for SQL Quality tasks."""
41
+
42
+ def __init__(self) -> None:
43
+ """Initialize environment."""
44
+ self._current_task_id: str | None = None
45
+ self._episode_id: str | None = None
46
+ self._attempt_number: int = 1
47
+ self._step_count: int = (
48
+ 0 # increments on every action (QUERY, PROFILE, FIX, SUBMIT)
49
+ )
50
+ self._max_steps_per_episode: int = 20 # prevent infinite loops
51
+ self._cumulative_reward: float = 0.0
52
+ self._done: bool = False
53
+ self._ground_truth: Dict[str, Any] = {}
54
+ self._cached_observation: SQLObservation | None = None
55
+ self._lock = threading.Lock()
56
+ # NEW: Multi-step trajectory support
57
+ self._trajectory: list = []
58
+ self._original_snapshot: dict = {}
59
+ self._original_conn: sqlite3.Connection | None = None
60
+ self._active: bool = False
61
+ self._last_feedback: str | None = None
62
+
63
+ def reset(self, task_id: str, seed: int | None = None) -> SQLObservation:
64
+ """Reset environment for a new episode.
65
+
66
+ Args:
67
+ task_id: One of the registered task IDs
68
+ seed: Optional deterministic episode seed
69
+
70
+ Returns:
71
+ SQLObservation for the new episode
72
+
73
+ Raises:
74
+ ValueError: If task_id is not valid
75
+ """
76
+ with self._lock:
77
+ # ── NEW: close any live connection from the previous episode ──────
78
+ if hasattr(self, "_original_conn") and self._original_conn is not None:
79
+ try:
80
+ self._original_conn.close()
81
+ except Exception:
82
+ pass # Already closed or never opened — safe to ignore
83
+ self._original_conn = None
84
+
85
+ # Validate task_id
86
+ if task_id not in TASK_CREATORS:
87
+ raise ValueError(f"Invalid task_id: {task_id}")
88
+
89
+ # Generate new episode
90
+ self._episode_id = str(uuid.uuid4())
91
+ self._attempt_number = 1
92
+ self._step_count = (
93
+ 0 # increments on every action (QUERY, PROFILE, FIX, SUBMIT)
94
+ )
95
+ self._cumulative_reward = 0.0
96
+ self._done = False
97
+ self._current_task_id = task_id
98
+ self._trajectory = []
99
+ self._original_snapshot = {}
100
+ self._ground_truth = {}
101
+ self._cached_observation = None
102
+ self._last_feedback = None
103
+
104
+ # Create task-specific database to extract observation data
105
+ conn = None
106
+ try:
107
+ # Generate random episode seed for diversity
108
+ episode_seed = seed if seed is not None else _random.randint(0, 2**31 - 1)
109
+ creator = TASK_CREATORS[task_id]
110
+ conn, observation, self._ground_truth = creator(episode_seed)
111
+
112
+ # Store original connection for graders (read-only use)
113
+ self._original_conn = conn
114
+
115
+ # Snapshot the target table for data preservation grading
116
+ target_table = self._ground_truth.get("table", "")
117
+ if target_table:
118
+ from server.graders import _snapshot_table
119
+
120
+ self._original_snapshot = _snapshot_table(conn, target_table)
121
+
122
+ # Mark episode as active
123
+ self._active = True
124
+
125
+ # Cache observation for reuse in step()
126
+ self._cached_observation = observation.model_copy(
127
+ update={"episode_id": self._episode_id}
128
+ )
129
+
130
+ # Keep connection open for multi-step operations
131
+ # Connection will be closed when episode ends or environment is reset
132
+
133
+ except Exception as e:
134
+ if conn is not None:
135
+ conn.close()
136
+ raise e
137
+
138
+ return self._cached_observation
139
+
140
+ def step(self, action: SQLAction) -> StepResponse:
141
+ """
142
+ Execute one agent action and return the resulting observation and reward.
143
+
144
+ Routes by action_type:
145
+ QUERY — execute SELECT on episode conn, return result in observation
146
+ PROFILE — compute column statistics, return in observation
147
+ FIX — execute on fresh_test_db(), score partially, continue episode
148
+ SUBMIT — call compute_episode_score(), end episode
149
+ """
150
+ with self._lock:
151
+ if not self._active:
152
+ raise HTTPException(
153
+ status_code=422, detail="No active episode. Call reset() first."
154
+ )
155
+ if self._done:
156
+ raise HTTPException(
157
+ status_code=422,
158
+ detail="Episode is already done. Call reset() to start a new episode.",
159
+ )
160
+
161
+ # Check max steps guard
162
+ if self._step_count >= self._max_steps_per_episode:
163
+ raise HTTPException(
164
+ status_code=422,
165
+ detail="Max investigation steps reached. Call SUBMIT or reset().",
166
+ )
167
+
168
+ action = self._normalize_action(action)
169
+
170
+ action_type = action.action_type
171
+
172
+ # --- QUERY action ---
173
+ if action_type == ActionType.QUERY:
174
+ return self._handle_query(action)
175
+
176
+ # --- PROFILE action ---
177
+ elif action_type == ActionType.PROFILE:
178
+ return self._handle_profile(action)
179
+
180
+ # --- FIX action ---
181
+ elif action_type == ActionType.FIX:
182
+ return self._handle_fix(action)
183
+
184
+ # --- SUBMIT action (or legacy single-step) ---
185
+ else:
186
+ return self._handle_submit(action)
187
+
188
+ def _normalize_action(self, action: SQLAction) -> SQLAction:
189
+ """Infer v2 action_type for backward-compatible payloads without action_type."""
190
+ if "action_type" in action.model_fields_set:
191
+ return action
192
+
193
+ inferred = ActionType.SUBMIT
194
+ if action.column_name:
195
+ inferred = ActionType.PROFILE
196
+ elif action.fix_query:
197
+ inferred = ActionType.FIX
198
+ elif action.sql:
199
+ inferred = (
200
+ ActionType.QUERY
201
+ if action.sql.strip().upper().startswith("SELECT")
202
+ else ActionType.FIX
203
+ )
204
+ elif action.answer or action.root_cause_table or action.offending_key:
205
+ inferred = ActionType.SUBMIT
206
+
207
+ return action.model_copy(update={"action_type": inferred})
208
+
209
+ def _handle_query(self, action: SQLAction) -> StepResponse:
210
+ """
211
+ Handle a read-only QUERY action.
212
+
213
+ Executes the SQL on the episode connection (read-only SELECT enforced).
214
+ Returns a result preview in the observation. Does NOT end the episode.
215
+ Awards small step_reward (0.05) for queries that target the right area.
216
+ """
217
+ sql = (action.sql or "").strip()
218
+
219
+ # Enforce read-only: only SELECT allowed
220
+ if not sql.upper().startswith("SELECT"):
221
+ step_reward = 0.0
222
+ result_preview = "Error: QUERY action only accepts SELECT statements."
223
+ else:
224
+ try:
225
+ cursor = self._original_conn.execute(sql)
226
+ rows = cursor.fetchmany(5)
227
+ cols = [d[0] for d in cursor.description]
228
+ result_preview = str([dict(zip(cols, row)) for row in rows])[:300]
229
+ # Small reward for targeted queries
230
+ from server.graders import _query_reveals_issue
231
+
232
+ step_reward = (
233
+ 0.05 if _query_reveals_issue(sql, self._ground_truth) else 0.01
234
+ )
235
+ except sqlite3.Error as e:
236
+ result_preview = f"SQL Error: {e}"
237
+ step_reward = 0.0
238
+
239
+ # Add to trajectory
240
+ self._trajectory.append(
241
+ TrajectoryStep(
242
+ step_number=len(self._trajectory) + 1,
243
+ action_type=ActionType.QUERY,
244
+ sql_submitted=sql,
245
+ result_preview=result_preview[:200],
246
+ step_reward=step_reward,
247
+ )
248
+ )
249
+
250
+ self._cumulative_reward = min(
251
+ 1.0, round(self._cumulative_reward + step_reward, 4)
252
+ )
253
+ self._last_feedback = result_preview[:200]
254
+ self._step_count += 1
255
+
256
+ # Build updated observation with trajectory history
257
+ obs = self._build_observation(result_preview=result_preview)
258
+
259
+ return StepResponse(
260
+ observation=obs,
261
+ reward=SQLReward(
262
+ score=self._cumulative_reward,
263
+ step_reward=step_reward,
264
+ feedback=result_preview[:200],
265
+ ),
266
+ done=False,
267
+ info={"action_type": "query", "step": len(self._trajectory)},
268
+ )
269
+
270
+ def _handle_profile(self, action: SQLAction) -> StepResponse:
271
+ """
272
+ Handle a PROFILE action — statistical summary of a named column.
273
+
274
+ Computes: null rate, distinct count, data type distribution, min/max.
275
+ Does NOT end the episode. Awards step_reward based on column relevance.
276
+ """
277
+ col = (action.column_name or "").strip()
278
+ result_preview = ""
279
+
280
+ if not col:
281
+ result_preview = "Error: column_name required for PROFILE action."
282
+ else:
283
+ # Try to find which table has this column
284
+ try:
285
+ tables = self._original_conn.execute(
286
+ "SELECT name FROM sqlite_master WHERE type='table'"
287
+ ).fetchall()
288
+ profile_results = []
289
+ for (tbl,) in tables:
290
+ try:
291
+ cols = [
292
+ row[1]
293
+ for row in self._original_conn.execute(
294
+ f"PRAGMA table_info({tbl})"
295
+ ).fetchall()
296
+ ]
297
+ if col in cols:
298
+ total = self._original_conn.execute(
299
+ f"SELECT COUNT(*) FROM {tbl}"
300
+ ).fetchone()[0]
301
+ nulls = self._original_conn.execute(
302
+ f"SELECT COUNT(*) FROM {tbl} WHERE {col} IS NULL"
303
+ ).fetchone()[0]
304
+ distinct = self._original_conn.execute(
305
+ f"SELECT COUNT(DISTINCT {col}) FROM {tbl}"
306
+ ).fetchone()[0]
307
+ profile_results.append(
308
+ f"{tbl}.{col}: total={total}, nulls={nulls} ({100*nulls//total if total else 0}%), distinct={distinct}"
309
+ )
310
+ except sqlite3.Error:
311
+ pass
312
+ result_preview = (
313
+ " | ".join(profile_results)
314
+ if profile_results
315
+ else f"Column '{col}' not found."
316
+ )
317
+ except sqlite3.Error as e:
318
+ result_preview = f"Profile error: {e}"
319
+
320
+ # Determine step_reward based on relevance to ground truth
321
+ ground_table = self._ground_truth.get("table", "").lower()
322
+ ground_col = self._ground_truth.get(
323
+ "column", self._ground_truth.get("null_column", "")
324
+ ).lower()
325
+ col_lower = col.lower()
326
+
327
+ if col_lower == ground_col:
328
+ step_reward = 0.08 # On-target: profiling exact relevant column
329
+ elif ground_table and ground_table in result_preview.lower():
330
+ step_reward = 0.05 # Near-target: profiled something from right table
331
+ elif col_lower in result_preview.lower() and len(col_lower) > 2:
332
+ step_reward = 0.01 # Off-target: profiled something real but irrelevant
333
+ else:
334
+ step_reward = 0.01 # Minimal: invalid column or junk probe
335
+
336
+ self._trajectory.append(
337
+ TrajectoryStep(
338
+ step_number=len(self._trajectory) + 1,
339
+ action_type=ActionType.PROFILE,
340
+ sql_submitted=f"PROFILE({col})",
341
+ result_preview=result_preview[:200],
342
+ step_reward=step_reward,
343
+ )
344
+ )
345
+
346
+ self._cumulative_reward = min(
347
+ 1.0, round(self._cumulative_reward + step_reward, 4)
348
+ )
349
+ self._last_feedback = result_preview[:200]
350
+ self._step_count += 1
351
+ obs = self._build_observation(result_preview=result_preview)
352
+
353
+ return StepResponse(
354
+ observation=obs,
355
+ reward=SQLReward(
356
+ score=self._cumulative_reward,
357
+ step_reward=step_reward,
358
+ feedback=result_preview[:200],
359
+ ),
360
+ done=False,
361
+ info={"action_type": "profile", "column_profiled": col},
362
+ )
363
+
364
+ def _handle_fix(self, action: SQLAction) -> StepResponse:
365
+ """
366
+ Handle a FIX action — execute write query on isolated copy for preview scoring.
367
+
368
+ Executes on a _fresh_test_db() copy. Does NOT mutate episode state.
369
+ Returns intermediate feedback on whether fix appears to work.
370
+ Does NOT end episode — agent can follow up with verification query or SUBMIT.
371
+ Awards intermediate reward based on quick fix assessment.
372
+ """
373
+ fix_sql = action.effective_sql or ""
374
+ step_reward = 0.0
375
+ feedback = ""
376
+
377
+ if fix_sql:
378
+ from server.graders import grade_fix_correctness
379
+
380
+ quick_score, severity = grade_fix_correctness(
381
+ fix_sql,
382
+ self._original_conn,
383
+ self._original_snapshot,
384
+ self._ground_truth,
385
+ )
386
+ step_reward = quick_score * 0.3 # Partial credit for fix attempt
387
+ feedback = f"Fix preview: {severity} ({quick_score:.2f}). Submit to finalize score."
388
+ else:
389
+ feedback = "No SQL provided in fix action."
390
+
391
+ self._trajectory.append(
392
+ TrajectoryStep(
393
+ step_number=len(self._trajectory) + 1,
394
+ action_type=ActionType.FIX,
395
+ sql_submitted=fix_sql,
396
+ result_preview=feedback[:200],
397
+ step_reward=step_reward,
398
+ )
399
+ )
400
+
401
+ self._cumulative_reward = min(
402
+ 1.0, round(self._cumulative_reward + step_reward, 4)
403
+ )
404
+ self._last_feedback = feedback
405
+ self._step_count += 1
406
+ obs = self._build_observation(result_preview=feedback)
407
+
408
+ return StepResponse(
409
+ observation=obs,
410
+ reward=SQLReward(
411
+ score=self._cumulative_reward,
412
+ step_reward=step_reward,
413
+ feedback=feedback,
414
+ ),
415
+ done=False,
416
+ info={
417
+ "action_type": "fix",
418
+ "severity_preview": severity if fix_sql else "N/A",
419
+ },
420
+ )
421
+
422
+ def _handle_submit(self, action: SQLAction) -> StepResponse:
423
+ """
424
+ Handle a SUBMIT action — compute final composite score and end episode.
425
+
426
+ Calls compute_episode_score() with the full trajectory.
427
+ Sets done=True. This is the only action that ends the episode.
428
+ """
429
+ from server.graders import compute_episode_score
430
+
431
+ reward = compute_episode_score(
432
+ trajectory=self._trajectory,
433
+ ground_truth=self._ground_truth,
434
+ original_conn=self._original_conn,
435
+ original_snapshot=self._original_snapshot,
436
+ submit_action=action,
437
+ )
438
+
439
+ self._done = True
440
+ self._active = False
441
+ self._cumulative_reward = min(
442
+ 1.0, round(self._cumulative_reward + reward.score, 4)
443
+ )
444
+ self._last_feedback = reward.feedback
445
+ obs = self._build_observation()
446
+
447
+ # Close connection when episode ends
448
+ if self._original_conn:
449
+ self._original_conn.close()
450
+ self._original_conn = None
451
+
452
+ return StepResponse(
453
+ observation=obs,
454
+ reward=reward,
455
+ done=True,
456
+ info={
457
+ "action_type": "submit",
458
+ "final_score": reward.score,
459
+ "dimensions": reward.dimensions,
460
+ "trajectory_length": len(self._trajectory),
461
+ },
462
+ )
463
+
464
+ def _build_observation(self, result_preview: str = "") -> SQLObservation:
465
+ """Build current observation including trajectory history."""
466
+ history = [
467
+ {
468
+ "step": s.step_number,
469
+ "type": s.action_type.value,
470
+ "sql": (s.sql_submitted or "")[:80],
471
+ "result": s.result_preview[:80],
472
+ "reward": s.step_reward,
473
+ }
474
+ for s in self._trajectory[-5:] # Last 5 steps only (context window)
475
+ ]
476
+ return SQLObservation(
477
+ task_id=self._current_task_id,
478
+ task_description=self._cached_observation.task_description,
479
+ schema_info=self._cached_observation.schema_info,
480
+ sample_rows=self._cached_observation.sample_rows,
481
+ attempt_number=self._attempt_number,
482
+ episode_id=self._episode_id,
483
+ trajectory_history=history,
484
+ investigation_hint=result_preview[:200] if result_preview else None,
485
+ previous_feedback=(
486
+ self._last_feedback if hasattr(self, "_last_feedback") else None
487
+ ),
488
+ max_attempts=self._cached_observation.max_attempts,
489
+ )
490
+
491
+ def state(self) -> SQLState:
492
+ """Get current environment state.
493
+
494
+ Returns:
495
+ SQLState with current episode information
496
+
497
+ Raises:
498
+ ValueError: If no episode has been started
499
+ """
500
+ if self._episode_id is None:
501
+ raise ValueError("No episode started. Call reset() first.")
502
+
503
+ return SQLState(
504
+ task_id=self._current_task_id,
505
+ episode_id=self._episode_id,
506
+ attempt_number=self._attempt_number,
507
+ cumulative_reward=self._cumulative_reward,
508
+ done=self._done,
509
+ )
server/graders.py ADDED
@@ -0,0 +1,843 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grading functions for SQL Quality Environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from difflib import SequenceMatcher
6
+ import sqlite3
7
+ from typing import Any, Dict
8
+
9
+ from server.models import ActionType, SQLAction, SQLReward, TrajectoryStep
10
+
11
+
12
+ def _fresh_test_db(original_conn: sqlite3.Connection) -> sqlite3.Connection:
13
+ """Create an isolated in-memory copy of a sqlite database."""
14
+ copy = sqlite3.connect(":memory:")
15
+ for line in original_conn.iterdump():
16
+ copy.execute(line)
17
+ copy.commit()
18
+ return copy
19
+
20
+
21
+ # Backward-compatible alias
22
+ _make_fresh_copy = _fresh_test_db
23
+
24
+
25
+ def _query_reveals_issue(sql: str, ground_truth: dict) -> bool:
26
+ """Return True when query references both ground-truth table and column."""
27
+ sql_lower = (sql or "").lower()
28
+ table = ground_truth.get("table", ground_truth.get("target_table", "")).lower()
29
+ col = ground_truth.get(
30
+ "column", ground_truth.get("null_column", ground_truth.get("target_column", ""))
31
+ ).lower()
32
+ return bool(table and col and table in sql_lower and col in sql_lower)
33
+
34
+
35
+ def _measure_data_preservation(
36
+ test_conn: sqlite3.Connection,
37
+ original_snapshot: dict,
38
+ table_name: str,
39
+ key_col: str = "id",
40
+ ) -> float:
41
+ """Measure row/value preservation ratio after a fix."""
42
+ if not original_snapshot:
43
+ return 1.0
44
+
45
+ try:
46
+ cursor = test_conn.execute(f"SELECT * FROM {table_name}")
47
+ cols = [d[0] for d in cursor.description]
48
+ if key_col not in cols:
49
+ return 0.0
50
+ key_idx = cols.index(key_col)
51
+ post_fix = {row[key_idx]: dict(zip(cols, row)) for row in cursor.fetchall()}
52
+ except sqlite3.Error:
53
+ return 0.0
54
+
55
+ if not post_fix:
56
+ return 0.0
57
+
58
+ original_ids = set(original_snapshot.keys())
59
+ post_ids = set(post_fix.keys())
60
+ row_score = len(original_ids & post_ids) / max(1, len(original_ids))
61
+
62
+ matched_rows = original_ids & post_ids
63
+ if not matched_rows:
64
+ return round(row_score * 0.5, 4)
65
+
66
+ value_matches = 0
67
+ value_total = 0
68
+ for rid in matched_rows:
69
+ orig_row = original_snapshot[rid]
70
+ post_row = post_fix[rid]
71
+ for col in orig_row:
72
+ value_total += 1
73
+ try:
74
+ if abs(float(str(orig_row[col])) - float(str(post_row.get(col, 0)))) < 0.001:
75
+ value_matches += 1
76
+ except (ValueError, TypeError):
77
+ if str(orig_row[col]) == str(post_row.get(col, "")):
78
+ value_matches += 1
79
+ value_score = value_matches / max(1, value_total)
80
+ return round(row_score * 0.5 + value_score * 0.5, 4)
81
+
82
+
83
+ def _snapshot_table(
84
+ conn: sqlite3.Connection,
85
+ table_name: str,
86
+ key_col: str = "id",
87
+ limit: int = 200,
88
+ ) -> dict:
89
+ """Capture table contents before a fix."""
90
+ try:
91
+ cursor = conn.execute(f"SELECT * FROM {table_name} LIMIT {limit}")
92
+ cols = [d[0] for d in cursor.description]
93
+ if key_col not in cols:
94
+ return {}
95
+ key_idx = cols.index(key_col)
96
+ return {row[key_idx]: dict(zip(cols, row)) for row in cursor.fetchall()}
97
+ except sqlite3.Error:
98
+ return {}
99
+
100
+
101
+ def _snapshot_for_preservation(original_snapshot: dict, ground_truth: dict) -> dict:
102
+ """Filter snapshots to preservation-critical columns when configured."""
103
+ preserve_columns = ground_truth.get("preserve_columns")
104
+ if not preserve_columns:
105
+ return original_snapshot
106
+ filtered: dict = {}
107
+ for row_id, row in original_snapshot.items():
108
+ filtered[row_id] = {col: row[col] for col in preserve_columns if col in row}
109
+ return filtered
110
+
111
+
112
+ def _null_audit_score(answer: str, ground_truth: dict) -> tuple[float, str, dict]:
113
+ """Schema-aware null_audit scoring with lexical near-miss partial credit."""
114
+ clean = (answer or "").strip()
115
+ if not clean:
116
+ return 0.0, "CATASTROPHIC", {
117
+ "answer_correct": 0.0,
118
+ "answer_in_schema": 0.0,
119
+ "lexical_near_miss": 0.0,
120
+ }
121
+
122
+ clean_lower = clean.lower()
123
+ correct = ground_truth.get("null_column", "").strip().lower()
124
+ all_cols = [c.lower() for c in ground_truth.get("all_columns", [])]
125
+
126
+ if clean_lower == correct:
127
+ return 1.0, "PERFECT", {
128
+ "answer_correct": 1.0,
129
+ "answer_in_schema": 0.0,
130
+ "lexical_near_miss": 0.0,
131
+ }
132
+
133
+ if clean_lower in all_cols:
134
+ return 0.2, "ACCEPTABLE", {
135
+ "answer_correct": 0.0,
136
+ "answer_in_schema": 0.2,
137
+ "lexical_near_miss": 0.0,
138
+ }
139
+
140
+ # Bounded lexical partial for out-of-schema near misses only.
141
+ best_similarity = 0.0
142
+ for col in all_cols:
143
+ best_similarity = max(best_similarity, SequenceMatcher(None, clean_lower, col).ratio())
144
+ if best_similarity >= 0.85:
145
+ lexical = 0.15
146
+ elif best_similarity >= 0.65:
147
+ lexical = 0.1
148
+ elif best_similarity >= 0.45:
149
+ lexical = 0.05
150
+ else:
151
+ lexical = 0.0
152
+
153
+ return lexical, ("DANGEROUS" if lexical > 0 else "CATASTROPHIC"), {
154
+ "answer_correct": 0.0,
155
+ "answer_in_schema": 0.0,
156
+ "lexical_near_miss": lexical,
157
+ }
158
+
159
+
160
+ def _evaluate_fix_outcome(
161
+ test_conn: sqlite3.Connection,
162
+ ground_truth: dict,
163
+ original_snapshot: dict | None = None,
164
+ ) -> tuple[bool, float, bool]:
165
+ """Evaluate task-specific resolution + preservation + schema integrity."""
166
+ task_type = ground_truth.get("task_type", "")
167
+ target_table = ground_truth.get(
168
+ "table",
169
+ ground_truth.get("target_table", ground_truth.get("root_cause_table", "transactions")),
170
+ )
171
+
172
+ try:
173
+ tables = {
174
+ row[0]
175
+ for row in test_conn.execute(
176
+ "SELECT name FROM sqlite_master WHERE type='table'"
177
+ ).fetchall()
178
+ }
179
+ except sqlite3.Error:
180
+ return False, 0.0, False
181
+
182
+ if target_table not in tables:
183
+ return False, 0.0, False
184
+
185
+ if task_type == "type_repair":
186
+ target_col = ground_truth.get("column", ground_truth.get("target_column", "amount"))
187
+ try:
188
+ cols = {
189
+ row[1]: row[2].upper()
190
+ for row in test_conn.execute(f"PRAGMA table_info({target_table})")
191
+ }
192
+ except sqlite3.Error:
193
+ return False, 0.0, True
194
+
195
+ issue_resolved = cols.get(target_col, "") in (
196
+ "REAL",
197
+ "NUMERIC",
198
+ "FLOAT",
199
+ "DOUBLE",
200
+ "INTEGER",
201
+ )
202
+ preserve_source = _snapshot_for_preservation(original_snapshot or {}, ground_truth)
203
+ preservation = _measure_data_preservation(test_conn, preserve_source, target_table)
204
+ return issue_resolved, preservation, True
205
+
206
+ if task_type == "dedup_bug":
207
+ key_col = ground_truth.get("column", ground_truth.get("offending_key", "order_id"))
208
+ expected_clean = ground_truth.get("expected_clean_count", 0)
209
+ try:
210
+ remaining_dupes = test_conn.execute(
211
+ f"SELECT COUNT(*) FROM (SELECT {key_col}, COUNT(*) c "
212
+ f"FROM {target_table} GROUP BY {key_col} HAVING c > 1)"
213
+ ).fetchone()[0]
214
+ final_count = test_conn.execute(
215
+ f"SELECT COUNT(*) FROM {target_table}"
216
+ ).fetchone()[0]
217
+ orphan_count = test_conn.execute(
218
+ "SELECT COUNT(*) FROM order_items oi "
219
+ "LEFT JOIN orders o ON oi.order_id = o.id "
220
+ "WHERE o.id IS NULL"
221
+ ).fetchone()[0]
222
+ except sqlite3.Error:
223
+ return False, 0.0, True
224
+
225
+ issue_resolved = remaining_dupes == 0
226
+ count_ok = abs(final_count - expected_clean) <= max(1, int(expected_clean * 0.02))
227
+ no_orphans = orphan_count == 0
228
+ if issue_resolved and count_ok and no_orphans:
229
+ preservation = 1.0
230
+ elif issue_resolved and count_ok:
231
+ preservation = 0.95
232
+ elif issue_resolved:
233
+ preservation = 0.85
234
+ elif final_count > 0:
235
+ preservation = 0.5
236
+ else:
237
+ preservation = 0.0
238
+ return issue_resolved, preservation, True
239
+
240
+ if task_type == "fk_integrity":
241
+ parent_table = ground_truth.get("parent_table", "customers")
242
+ parent_key = ground_truth.get("parent_key", "id")
243
+ fk_col = ground_truth.get("column", "customer_id")
244
+ valid_ids = set(ground_truth.get("valid_invoice_ids", []))
245
+ expected_clean = ground_truth.get("expected_clean_count", len(valid_ids))
246
+
247
+ try:
248
+ orphan_count = test_conn.execute(
249
+ f"SELECT COUNT(*) FROM {target_table} c "
250
+ f"LEFT JOIN {parent_table} p ON c.{fk_col} = p.{parent_key} "
251
+ f"WHERE p.{parent_key} IS NULL"
252
+ ).fetchone()[0]
253
+ survivors = {
254
+ row[0]
255
+ for row in test_conn.execute(f"SELECT id FROM {target_table}").fetchall()
256
+ }
257
+ except sqlite3.Error:
258
+ return False, 0.0, True
259
+
260
+ issue_resolved = orphan_count == 0
261
+ if not survivors:
262
+ return issue_resolved, 0.0, True
263
+ keep_ratio = len(survivors & valid_ids) / max(1, len(valid_ids))
264
+ unexpected_ratio = len(survivors - valid_ids) / max(1, len(survivors))
265
+ count_ok = abs(len(survivors) - expected_clean) <= max(1, int(expected_clean * 0.02))
266
+ if issue_resolved and count_ok and keep_ratio >= 0.995 and unexpected_ratio == 0:
267
+ preservation = 1.0
268
+ elif issue_resolved and keep_ratio >= 0.98:
269
+ preservation = 0.9
270
+ elif keep_ratio >= 0.7:
271
+ preservation = 0.6
272
+ else:
273
+ preservation = 0.2
274
+ return issue_resolved, preservation, True
275
+
276
+ if task_type == "schema_drift":
277
+ expected_columns = ground_truth.get(
278
+ "expected_columns",
279
+ ["event_id", "user_id", "event_type", "amount", "event_time"],
280
+ )
281
+ expected_rows = ground_truth.get("expected_row_count", 0)
282
+ canonical_table = ground_truth.get("table", "canonical_events")
283
+ if canonical_table not in tables:
284
+ return False, 0.0, False
285
+
286
+ try:
287
+ cols = {
288
+ row[1]: row[2].upper()
289
+ for row in test_conn.execute(f"PRAGMA table_info({canonical_table})")
290
+ }
291
+ row_count = test_conn.execute(
292
+ f"SELECT COUNT(*) FROM {canonical_table}"
293
+ ).fetchone()[0]
294
+ distinct_ids = test_conn.execute(
295
+ f"SELECT COUNT(DISTINCT event_id) FROM {canonical_table}"
296
+ ).fetchone()[0]
297
+ null_critical = test_conn.execute(
298
+ f"SELECT COUNT(*) FROM {canonical_table} "
299
+ "WHERE event_id IS NULL OR user_id IS NULL OR event_type IS NULL OR event_time IS NULL"
300
+ ).fetchone()[0]
301
+ numeric_amount = test_conn.execute(
302
+ f"SELECT COUNT(*) FROM {canonical_table} WHERE typeof(amount) IN ('real','integer')"
303
+ ).fetchone()[0]
304
+ except sqlite3.Error:
305
+ return False, 0.0, True
306
+
307
+ columns_ok = all(col in cols for col in expected_columns)
308
+ row_ok = row_count == expected_rows
309
+ ids_ok = distinct_ids == row_count
310
+ critical_ok = null_critical == 0
311
+ numeric_ratio = numeric_amount / max(1, row_count)
312
+ issue_resolved = columns_ok and row_ok and ids_ok and critical_ok and numeric_ratio >= 0.99
313
+
314
+ preservation = 0.0
315
+ if columns_ok:
316
+ preservation += 0.3
317
+ if row_ok:
318
+ preservation += 0.3
319
+ if ids_ok and critical_ok:
320
+ preservation += 0.25
321
+ preservation += min(0.15, numeric_ratio * 0.15)
322
+ return issue_resolved, round(min(1.0, preservation), 4), True
323
+
324
+ # Fallback
325
+ preserve_source = _snapshot_for_preservation(original_snapshot or {}, ground_truth)
326
+ preservation = _measure_data_preservation(test_conn, preserve_source, target_table)
327
+ return preservation >= 0.99, preservation, True
328
+
329
+
330
+ def grade_diagnostic_efficiency(
331
+ trajectory: list[TrajectoryStep],
332
+ ground_truth: dict,
333
+ ) -> float:
334
+ """D1: reward faster diagnosis of the correct table/column."""
335
+ query_steps = [
336
+ s for s in trajectory if s.action_type in (ActionType.QUERY, ActionType.PROFILE)
337
+ ]
338
+ for i, step in enumerate(query_steps):
339
+ if _query_reveals_issue(step.sql_submitted or "", ground_truth):
340
+ step_number = i + 1
341
+ if step_number <= 2:
342
+ return 1.0
343
+ if step_number <= 4:
344
+ return 0.7
345
+ if step_number <= 6:
346
+ return 0.4
347
+ return 0.2
348
+ return 0.0
349
+
350
+
351
+ def grade_hypothesis_quality(
352
+ trajectory: list[TrajectoryStep],
353
+ ground_truth: dict,
354
+ ) -> float:
355
+ """D2: reward systematic investigation sequence."""
356
+ query_steps = [
357
+ s for s in trajectory if s.action_type in (ActionType.QUERY, ActionType.PROFILE)
358
+ ]
359
+ if not query_steps:
360
+ return 0.0
361
+
362
+ table = ground_truth.get("table", ground_truth.get("target_table", "")).lower()
363
+ col = ground_truth.get(
364
+ "column", ground_truth.get("null_column", ground_truth.get("target_column", ""))
365
+ ).lower()
366
+ sqls = [s.sql_submitted or "" for s in query_steps]
367
+
368
+ first_table = next((i for i, q in enumerate(sqls) if table and table in q.lower()), None)
369
+ first_col = next((i for i, q in enumerate(sqls) if col and col in q.lower()), None)
370
+ if first_table is not None and first_col is not None and first_table < first_col:
371
+ return 1.0
372
+ if first_col is not None:
373
+ return 0.6
374
+ if first_table is not None:
375
+ return 0.3
376
+ return 0.0
377
+
378
+
379
+ def grade_fix_correctness(
380
+ fix_sql: str,
381
+ original_conn: sqlite3.Connection,
382
+ original_snapshot: dict,
383
+ ground_truth: dict,
384
+ ) -> tuple[float, str]:
385
+ """D3: task-aware fix correctness with catastrophic override tiers."""
386
+ task_type = ground_truth.get("task_type", "")
387
+ if task_type == "null_audit":
388
+ return 1.0, "PERFECT"
389
+ if not fix_sql or fix_sql.strip().upper() in ("SELECT 1", ""):
390
+ return 0.0, "CATASTROPHIC"
391
+
392
+ test_conn = None
393
+ try:
394
+ test_conn = _fresh_test_db(original_conn)
395
+ try:
396
+ test_conn.executescript(fix_sql)
397
+ test_conn.commit()
398
+ except sqlite3.Error:
399
+ return 0.0, "CATASTROPHIC"
400
+
401
+ issue_resolved, preservation, schema_intact = _evaluate_fix_outcome(
402
+ test_conn, ground_truth, original_snapshot
403
+ )
404
+ if not schema_intact or preservation < 0.5:
405
+ return 0.0, "CATASTROPHIC"
406
+ if issue_resolved and preservation >= 0.995:
407
+ return 1.0, "PERFECT"
408
+ if issue_resolved and preservation >= 0.95:
409
+ return 0.8, "ACCEPTABLE"
410
+ if issue_resolved and preservation >= 0.8:
411
+ return 0.5, "DEGRADED"
412
+ return 0.2, "DANGEROUS"
413
+ finally:
414
+ if test_conn:
415
+ test_conn.close()
416
+
417
+
418
+ def grade_self_verification(
419
+ trajectory: list[TrajectoryStep],
420
+ fix_index: int,
421
+ ground_truth: dict,
422
+ ) -> float:
423
+ """D4: bonus for post-fix verification query."""
424
+ post_fix = trajectory[fix_index + 1 :] if fix_index < len(trajectory) - 1 else []
425
+ post_fix_queries = [s for s in post_fix if s.action_type == ActionType.QUERY]
426
+ if not post_fix_queries:
427
+ return 0.0
428
+
429
+ table = ground_truth.get("table", "").lower()
430
+ col = ground_truth.get("column", ground_truth.get("null_column", "")).lower()
431
+ for step in post_fix_queries:
432
+ sql = (step.sql_submitted or "").lower()
433
+ if table in sql and col in sql:
434
+ return 0.15
435
+ return 0.0
436
+
437
+
438
+ def grade_generalization(
439
+ fix_sql: str,
440
+ original_conn: sqlite3.Connection,
441
+ ground_truth: dict,
442
+ ) -> float:
443
+ """D5: test whether the fix transfers to schema/data variants."""
444
+ task_type = ground_truth.get("task_type", "")
445
+ if task_type == "null_audit" or not fix_sql:
446
+ return 1.0
447
+
448
+ def _run_variant(
449
+ variant_conn: sqlite3.Connection,
450
+ variant_gt: dict | None = None,
451
+ ) -> bool:
452
+ gt = variant_gt or ground_truth
453
+ try:
454
+ variant_conn.executescript(fix_sql)
455
+ variant_conn.commit()
456
+ resolved, preservation, schema_intact = _evaluate_fix_outcome(
457
+ variant_conn,
458
+ gt,
459
+ {},
460
+ )
461
+ return bool(resolved and schema_intact and preservation >= 0.8)
462
+ except sqlite3.Error:
463
+ return False
464
+ finally:
465
+ variant_conn.close()
466
+
467
+ passes = 0
468
+
469
+ # Variant 1: base copy
470
+ try:
471
+ if _run_variant(_fresh_test_db(original_conn)):
472
+ passes += 1
473
+ except Exception:
474
+ pass
475
+
476
+ # Variant 2: schema transfer
477
+ try:
478
+ v2 = _fresh_test_db(original_conn)
479
+ if task_type == "type_repair":
480
+ v2.execute("ALTER TABLE transactions ADD COLUMN source_system TEXT DEFAULT 'legacy'")
481
+ v2.commit()
482
+ if _run_variant(v2):
483
+ passes += 1
484
+ elif task_type == "dedup_bug":
485
+ v2.execute("ALTER TABLE order_items ADD COLUMN ref_id INTEGER")
486
+ v2.execute("UPDATE order_items SET ref_id = order_id")
487
+ v2.commit()
488
+ if _run_variant(v2):
489
+ passes += 1
490
+ elif task_type == "fk_integrity":
491
+ v2.execute("ALTER TABLE invoices ADD COLUMN ingest_batch TEXT DEFAULT 'A'")
492
+ v2.commit()
493
+ if _run_variant(v2):
494
+ passes += 1
495
+ elif task_type == "schema_drift":
496
+ v2.execute("ALTER TABLE events_v2 ADD COLUMN metadata_json TEXT DEFAULT '{}'")
497
+ v2.commit()
498
+ if _run_variant(v2):
499
+ passes += 1
500
+ else:
501
+ v2.close()
502
+ except Exception:
503
+ pass
504
+
505
+ # Variant 3: harder data distribution
506
+ try:
507
+ v3 = _fresh_test_db(original_conn)
508
+ variant_gt = dict(ground_truth)
509
+ if task_type == "type_repair":
510
+ v3.execute(
511
+ "INSERT INTO transactions (id, user_id, amount, description, category, date, transaction_date) "
512
+ "SELECT id+10000, user_id, amount, description, category, date, transaction_date FROM transactions"
513
+ )
514
+ v3.commit()
515
+ if _run_variant(v3):
516
+ passes += 1
517
+ elif task_type == "dedup_bug":
518
+ v3.execute(
519
+ "INSERT INTO order_items (id, order_id, product_name, quantity, price) "
520
+ "SELECT NULL, order_id, product_name, quantity, price FROM order_items "
521
+ "WHERE rowid IN (SELECT MIN(rowid) FROM order_items GROUP BY order_id)"
522
+ )
523
+ v3.commit()
524
+ if _run_variant(v3):
525
+ passes += 1
526
+ elif task_type == "fk_integrity":
527
+ v3.execute(
528
+ "UPDATE invoices SET customer_id = 900000 + id WHERE id % 17 = 0"
529
+ )
530
+ v3.commit()
531
+ variant_gt["expected_clean_count"] = (
532
+ v3.execute(
533
+ "SELECT COUNT(*) FROM invoices i "
534
+ "JOIN customers c ON i.customer_id = c.id"
535
+ ).fetchone()[0]
536
+ )
537
+ variant_gt["valid_invoice_ids"] = [
538
+ row[0]
539
+ for row in v3.execute(
540
+ "SELECT i.id FROM invoices i JOIN customers c ON i.customer_id = c.id"
541
+ ).fetchall()
542
+ ]
543
+ if _run_variant(v3, variant_gt):
544
+ passes += 1
545
+ elif task_type == "schema_drift":
546
+ v3.execute(
547
+ "INSERT INTO events_v1 (id, user_id, event_name, amount_text, event_time) "
548
+ "SELECT id+10000, user_id, event_name, amount_text, event_time FROM events_v1 WHERE id <= 100"
549
+ )
550
+ v3.execute(
551
+ "INSERT INTO events_v2 (event_id, account_id, event_type, amount, event_ts, source) "
552
+ "SELECT event_id+20000, account_id, event_type, amount, event_ts, source FROM events_v2 WHERE event_id <= 700"
553
+ )
554
+ v3.commit()
555
+ variant_gt["expected_row_count"] = (
556
+ variant_gt.get("expected_row_count", 0) + 200
557
+ )
558
+ if _run_variant(v3, variant_gt):
559
+ passes += 1
560
+ else:
561
+ v3.close()
562
+ except Exception:
563
+ pass
564
+
565
+ if passes == 3:
566
+ return 1.0
567
+ if passes == 2:
568
+ return 0.6
569
+ if passes == 1:
570
+ return 0.3
571
+ return 0.0
572
+
573
+
574
+ def compute_episode_score(
575
+ trajectory: list[TrajectoryStep],
576
+ ground_truth: dict,
577
+ original_conn: sqlite3.Connection,
578
+ original_snapshot: dict,
579
+ submit_action: SQLAction,
580
+ ) -> SQLReward:
581
+ """Compute five-dimension composite score at SUBMIT."""
582
+ task_type = ground_truth.get("task_type", "")
583
+
584
+ # null_audit is intentionally direct: answer quality maps directly to final score.
585
+ if task_type == "null_audit":
586
+ score, severity, null_meta = _null_audit_score(
587
+ submit_action.answer or "",
588
+ ground_truth,
589
+ )
590
+ score = round(max(0.0, min(1.0, score)), 4)
591
+ return SQLReward(
592
+ score=score,
593
+ step_reward=score,
594
+ feedback=f"Null audit answer score: {score:.4f} ({severity})",
595
+ dimensions={
596
+ "d1_diagnostic_efficiency": 0.0,
597
+ "d2_hypothesis_quality": 0.0,
598
+ "d3_fix_correctness": round(score, 4),
599
+ "d3_fix_severity": severity,
600
+ "d4_self_verification": 0.0,
601
+ "d5_generalization": 1.0,
602
+ "d3_effective": round(score, 4),
603
+ "composite": score,
604
+ **null_meta,
605
+ },
606
+ partial_scores={
607
+ "d1": 0.0,
608
+ "d2": 0.0,
609
+ "d3": round(score, 4),
610
+ "d4": 0.0,
611
+ "d5": 1.0,
612
+ **null_meta,
613
+ },
614
+ )
615
+
616
+ d1 = grade_diagnostic_efficiency(trajectory, ground_truth)
617
+ d2 = grade_hypothesis_quality(trajectory, ground_truth)
618
+
619
+ fix_index = next(
620
+ (i for i, s in enumerate(trajectory) if s.action_type == ActionType.FIX),
621
+ -1,
622
+ )
623
+ fix_sql = trajectory[fix_index].sql_submitted if fix_index >= 0 else None
624
+
625
+ null_meta = {}
626
+ if fix_sql:
627
+ d3_raw, severity = grade_fix_correctness(
628
+ fix_sql,
629
+ original_conn,
630
+ original_snapshot,
631
+ ground_truth,
632
+ )
633
+ d5 = grade_generalization(fix_sql, original_conn, ground_truth)
634
+ else:
635
+ d5 = 0.0
636
+ if task_type == "dedup_bug":
637
+ correct_table = ground_truth.get("table", "order_items").lower()
638
+ correct_key = ground_truth.get("column", "order_id").lower()
639
+ table_ok = (submit_action.root_cause_table or "").lower() == correct_table
640
+ key_ok = (submit_action.offending_key or "").lower() == correct_key
641
+ if table_ok and key_ok:
642
+ d3_raw, severity = 0.7, "ACCEPTABLE"
643
+ elif table_ok:
644
+ d3_raw, severity = 0.4, "DANGEROUS"
645
+ else:
646
+ d3_raw, severity = 0.0, "CATASTROPHIC"
647
+ else:
648
+ d3_raw, severity = 0.0, "CATASTROPHIC"
649
+
650
+ d3_effective = d3_raw * (0.5 + 0.5 * d5)
651
+ d4 = grade_self_verification(trajectory, fix_index, ground_truth) if fix_index >= 0 else 0.0
652
+
653
+ raw_score = d1 * 0.20 + d2 * 0.20 + d3_effective * 0.35 + max(0.0, d4) * 0.10
654
+ final_score = min(1.0, raw_score + max(0.0, d4))
655
+
656
+ if severity == "CATASTROPHIC":
657
+ final_score = min(final_score, 0.05)
658
+
659
+ final_score = round(max(0.0, min(1.0, final_score)), 4)
660
+ feedback_parts = [
661
+ f"Diagnostic efficiency: {d1:.2f}",
662
+ f"Hypothesis quality: {d2:.2f}",
663
+ f"Fix correctness: {d3_raw:.2f} ({severity})",
664
+ f"Fix generalization: {d5:.2f}",
665
+ f"Self-verification: {d4:.2f}",
666
+ f"Composite score: {final_score:.4f}",
667
+ ]
668
+ if task_type == "null_audit" and null_meta.get("lexical_near_miss", 0.0) > 0:
669
+ feedback_parts.append(
670
+ f"Lexical near miss partial: {null_meta['lexical_near_miss']:.2f}"
671
+ )
672
+
673
+ dimensions = {
674
+ "d1_diagnostic_efficiency": round(d1, 4),
675
+ "d2_hypothesis_quality": round(d2, 4),
676
+ "d3_fix_correctness": round(d3_raw, 4),
677
+ "d3_fix_severity": severity,
678
+ "d4_self_verification": round(d4, 4),
679
+ "d5_generalization": round(d5, 4),
680
+ "d3_effective": round(d3_effective, 4),
681
+ "composite": final_score,
682
+ }
683
+ if null_meta:
684
+ dimensions.update(null_meta)
685
+
686
+ return SQLReward(
687
+ score=final_score,
688
+ step_reward=final_score,
689
+ feedback=" | ".join(feedback_parts),
690
+ dimensions=dimensions,
691
+ partial_scores={
692
+ "d1": round(d1, 4),
693
+ "d2": round(d2, 4),
694
+ "d3": round(d3_raw, 4),
695
+ "d4": round(d4, 4),
696
+ "d5": round(d5, 4),
697
+ **null_meta,
698
+ },
699
+ )
700
+
701
+
702
+ def grade_null_audit(action: SQLAction, ground_truth: Dict[str, Any]) -> SQLReward:
703
+ """Single-step null_audit scorer used by validation scripts."""
704
+ score, severity, meta = _null_audit_score(action.answer or "", ground_truth)
705
+ answer = (action.answer or "").strip()
706
+ if not answer:
707
+ feedback = "No answer submitted"
708
+ elif meta["answer_correct"] == 1.0:
709
+ feedback = f"Correct: identified '{ground_truth.get('null_column', '')}'."
710
+ elif meta["answer_in_schema"] > 0:
711
+ feedback = (
712
+ f"Submitted column '{answer}' exists in the schema but is not the high-null column."
713
+ )
714
+ elif meta["lexical_near_miss"] > 0:
715
+ feedback = (
716
+ f"Submitted column '{answer}' not in schema, but close to a valid column name."
717
+ )
718
+ else:
719
+ feedback = f"Submitted column '{answer}' is not present in the schema."
720
+
721
+ return SQLReward(
722
+ score=score,
723
+ step_reward=score,
724
+ feedback=feedback,
725
+ dimensions={
726
+ "legacy_mode": True,
727
+ "d3_fix_severity": severity,
728
+ "composite": score,
729
+ **meta,
730
+ },
731
+ partial_scores=meta,
732
+ )
733
+
734
+
735
+ def grade_type_repair(
736
+ action: SQLAction, ground_truth: dict, conn: sqlite3.Connection
737
+ ) -> SQLReward:
738
+ """Single-step type_repair scorer for exploit checks and compatibility."""
739
+ fix_sql = (action.fix_query or action.sql or "").strip()
740
+ target_table = ground_truth.get("target_table", "transactions")
741
+ partial_scores = {"syntax_valid": 0.0, "type_correct": 0.0, "data_valid": 0.0}
742
+
743
+ def _make(feedback: str) -> SQLReward:
744
+ score = round(sum(partial_scores.values()), 4)
745
+ return SQLReward(
746
+ score=score,
747
+ step_reward=score,
748
+ feedback=feedback,
749
+ dimensions={"legacy_mode": True, "composite": score, **partial_scores},
750
+ partial_scores=partial_scores.copy(),
751
+ )
752
+
753
+ if not fix_sql:
754
+ return _make("No fix_query provided")
755
+ if fix_sql.upper().startswith("SELECT"):
756
+ return _make("No-op fix_query did not modify the target schema")
757
+
758
+ snapshot = _snapshot_for_preservation(_snapshot_table(conn, target_table), ground_truth)
759
+ test_conn = _fresh_test_db(conn)
760
+ try:
761
+ try:
762
+ test_conn.executescript(fix_sql)
763
+ test_conn.commit()
764
+ except sqlite3.Error as exc:
765
+ return _make(f"SQL error: {exc}")
766
+
767
+ issue_resolved, preservation, schema_intact = _evaluate_fix_outcome(
768
+ test_conn,
769
+ {**ground_truth, "task_type": "type_repair"},
770
+ snapshot,
771
+ )
772
+ if not schema_intact:
773
+ return _make("Destructive query detected: target table missing")
774
+
775
+ partial_scores["syntax_valid"] = 0.3
776
+ if issue_resolved:
777
+ partial_scores["type_correct"] = 0.4
778
+ if preservation >= 0.995:
779
+ partial_scores["data_valid"] = 0.3
780
+
781
+ if partial_scores["type_correct"] == 0.0:
782
+ return _make("Fix executed but target column type is still incorrect")
783
+ if partial_scores["data_valid"] == 0.0:
784
+ return _make("Fix executed but data preservation check failed")
785
+ if sum(partial_scores.values()) == 1.0:
786
+ return _make("Correct fix: target column type repaired with data preserved")
787
+ return _make("Fix executed with partial credit")
788
+ finally:
789
+ test_conn.close()
790
+
791
+
792
+ def grade_dedup_bug(
793
+ action: SQLAction, ground_truth: Dict[str, Any], conn: sqlite3.Connection
794
+ ) -> SQLReward:
795
+ """Single-step dedup_bug scorer for exploit checks and compatibility."""
796
+ fix_sql = (action.fix_query or action.sql or "").strip()
797
+ target_table = ground_truth.get("table", ground_truth.get("root_cause_table", "order_items"))
798
+ target_key = ground_truth.get("column", ground_truth.get("offending_key", "order_id"))
799
+ table_ok = (action.root_cause_table or "").lower() == target_table.lower()
800
+ key_ok = (action.offending_key or "").lower() == target_key.lower()
801
+ partial_scores = {
802
+ "root_cause_identified": 0.4 if table_ok else 0.0,
803
+ "key_identified": 0.3 if key_ok else 0.0,
804
+ "fix_valid": 0.0,
805
+ }
806
+
807
+ sql_error = None
808
+ if fix_sql and fix_sql.upper() != "SELECT 1":
809
+ test_conn = _fresh_test_db(conn)
810
+ try:
811
+ test_conn.executescript(fix_sql)
812
+ test_conn.commit()
813
+ issue_resolved, preservation, schema_intact = _evaluate_fix_outcome(
814
+ test_conn,
815
+ {**ground_truth, "task_type": "dedup_bug"},
816
+ {},
817
+ )
818
+ if schema_intact and issue_resolved and preservation >= 0.95:
819
+ partial_scores["fix_valid"] = 0.3
820
+ except sqlite3.Error as exc:
821
+ sql_error = str(exc)
822
+ finally:
823
+ test_conn.close()
824
+
825
+ score = round(sum(partial_scores.values()), 4)
826
+ if sql_error:
827
+ feedback = f"SQL error: {sql_error}"
828
+ elif partial_scores["fix_valid"] == 0.3:
829
+ feedback = "Correct table, key, and dedup fix identified."
830
+ elif table_ok and key_ok:
831
+ feedback = "Correct table and key identified, but fix did not resolve duplication."
832
+ elif table_ok:
833
+ feedback = "Correct root cause table identified."
834
+ else:
835
+ feedback = "Root cause not identified."
836
+
837
+ return SQLReward(
838
+ score=score,
839
+ step_reward=score,
840
+ feedback=feedback,
841
+ dimensions={"legacy_mode": True, "composite": score, **partial_scores},
842
+ partial_scores=partial_scores,
843
+ )
server/main.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+
5
+ from server.environment import SQLQualityEnvironment
6
+ from server.models import (
7
+ HealthResponse,
8
+ ResetRequest,
9
+ SQLAction,
10
+ SQLObservation,
11
+ SQLState,
12
+ StepResponse,
13
+ )
14
+
15
+ app = FastAPI(
16
+ title="SQL Quality Audit Agent Environment",
17
+ description="OpenEnv-compatible environment for training AI agents on SQL data quality tasks",
18
+ version="0.1.0",
19
+ )
20
+
21
+ # Module-level singleton — ONE instance shared across all requests
22
+ _env = SQLQualityEnvironment()
23
+
24
+ # HTTP-level request lock to serialize concurrent requests
25
+ _request_lock = asyncio.Lock()
26
+
27
+
28
+ @app.get("/health", response_model=HealthResponse)
29
+ async def health() -> HealthResponse:
30
+ """Health check endpoint for HF Space ping validation.
31
+
32
+ Returns:
33
+ dict: Status response indicating the server is running
34
+ """
35
+ return HealthResponse(status="ok")
36
+
37
+
38
+ @app.post("/ping", response_model=HealthResponse)
39
+ async def ping() -> HealthResponse:
40
+ """Ping endpoint for submission validator — accepts empty body.
41
+
42
+ Returns:
43
+ dict: Status response indicating the server is responsive
44
+ """
45
+ return HealthResponse(status="ok")
46
+
47
+
48
+ @app.post("/reset", response_model=SQLObservation)
49
+ async def reset(request: ResetRequest) -> SQLObservation:
50
+ """Reset environment and return initial observation for specified task.
51
+
52
+ Args:
53
+ request: ResetRequest containing task_id and optional deterministic seed
54
+
55
+ Returns:
56
+ SQLObservation: Initial state of database for requested task
57
+
58
+ Raises:
59
+ HTTPException 422: If task_id is not a valid task identifier
60
+ HTTPException 500: If environment initialization fails
61
+ """
62
+ async with _request_lock:
63
+ VALID_TASKS = {
64
+ "null_audit",
65
+ "type_repair",
66
+ "dedup_bug",
67
+ "fk_integrity",
68
+ "schema_drift",
69
+ }
70
+ if request.task_id not in VALID_TASKS:
71
+ raise HTTPException(
72
+ status_code=422,
73
+ detail=f"Invalid task_id '{request.task_id}'. Must be one of: {VALID_TASKS}",
74
+ )
75
+ try:
76
+ return _env.reset(request.task_id, seed=request.seed)
77
+ except Exception as e:
78
+ raise HTTPException(status_code=500, detail=str(e))
79
+
80
+
81
+ @app.post("/step", response_model=StepResponse)
82
+ async def step(action: SQLAction) -> StepResponse:
83
+ """Submit an action and receive observation, reward, done flag, and info.
84
+
85
+ Args:
86
+ action: SQLAction containing task_id and fix_query or answer
87
+
88
+ Returns:
89
+ StepResponse: Updated observation, reward, done status, and info
90
+
91
+ Raises:
92
+ HTTPException 422: If action is invalid or episode is already done
93
+ HTTPException 500: If environment step execution fails
94
+ """
95
+ async with _request_lock:
96
+ try:
97
+ return _env.step(action)
98
+ except HTTPException:
99
+ raise
100
+ except ValueError as e:
101
+ raise HTTPException(status_code=422, detail=str(e))
102
+ except Exception as e:
103
+ raise HTTPException(status_code=500, detail=str(e))
104
+
105
+
106
+ @app.get("/state", response_model=SQLState)
107
+ async def state() -> SQLState:
108
+ """Get current episode state.
109
+
110
+ Returns:
111
+ SQLState: Current episode information including task_id, episode_id, attempt_number, cumulative_reward, and done status
112
+
113
+ Raises:
114
+ HTTPException 422: If no active episode exists
115
+ """
116
+ try:
117
+ return _env.state()
118
+ except ValueError as e:
119
+ raise HTTPException(status_code=422, detail=str(e))
120
+
121
+
122
+ def run_server() -> None:
123
+ """
124
+ Entry point for 'uv run server' command.
125
+
126
+ Uses PORT environment variable if set, otherwise defaults to 7860
127
+ (matching the Dockerfile EXPOSE and HF Spaces convention).
128
+ Override locally: PORT=8000 uv run server
129
+ """
130
+ import os
131
+
132
+ import uvicorn
133
+
134
+ port = int(os.getenv("PORT", "7860"))
135
+ uvicorn.run("server.main:app", host="0.0.0.0", port=port, reload=False)
136
+
137
+
138
+ def main() -> None:
139
+ """Primary CLI entrypoint expected by OpenEnv validators."""
140
+ run_server()
server/models.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+ from pydantic import BaseModel, Field, field_validator
7
+
8
+
9
+ class ActionType(str, Enum):
10
+ """
11
+ The four action types available to an agent in sql_quality_env.
12
+
13
+ QUERY — Read-only SELECT statement. Inspect schema or data.
14
+ Does not modify the database. Can be called multiple times.
15
+ PROFILE — Run null-rate and type distribution stats on a named column.
16
+ Shortcut for common inspection patterns. Read-only.
17
+ FIX — Submit a write query intended to resolve the data issue.
18
+ Executes on an isolated _fresh_test_db() copy for scoring.
19
+ SUBMIT — Declare final diagnosis and close the episode.
20
+ Triggers composite score computation. Episode ends after this.
21
+ """
22
+
23
+ QUERY = "query"
24
+ PROFILE = "profile"
25
+ FIX = "fix"
26
+ SUBMIT = "submit"
27
+
28
+
29
+ class SchemaInfo(BaseModel):
30
+ """Schema information for a database table."""
31
+
32
+ table_name: str
33
+ columns: list[str] # Format: "column_name (TYPE)"
34
+ row_count: int
35
+
36
+
37
+ class SQLObservation(BaseModel):
38
+ """What the agent sees at each step."""
39
+
40
+ task_id: str = Field(
41
+ description=(
42
+ "One of: null_audit, type_repair, dedup_bug, fk_integrity, schema_drift"
43
+ )
44
+ )
45
+ task_description: str = Field(
46
+ description="Natural language description of the task"
47
+ )
48
+ schema_info: list[SchemaInfo] = Field(description="Schema of all relevant tables")
49
+ sample_rows: dict[str, list[dict]] = Field(
50
+ description="First 5 rows of each table as list of dicts"
51
+ )
52
+ episode_id: Optional[str] = Field(
53
+ default=None, description="UUID4 identifying this episode"
54
+ )
55
+ attempt_number: int = Field(ge=1, description="Current attempt (1-indexed)")
56
+ max_attempts: int = Field(
57
+ ge=1, description="Maximum attempts allowed for this task"
58
+ )
59
+ previous_feedback: Optional[str] = Field(
60
+ default=None, description="Feedback from previous attempt, if any"
61
+ )
62
+ # NEW FIELDS:
63
+ trajectory_history: list[dict] = Field(
64
+ default_factory=list,
65
+ description=(
66
+ "List of previous actions and their results in this episode. "
67
+ "Each entry: {action_type, sql_or_answer, result_preview, step_reward}. "
68
+ "Empty on first step after reset()."
69
+ ),
70
+ )
71
+ available_actions: list[str] = Field(
72
+ default_factory=lambda: ["query", "profile", "fix", "submit"],
73
+ description="Action types available at this point in the episode.",
74
+ )
75
+ investigation_hint: str | None = Field(
76
+ default=None,
77
+ description=(
78
+ "Hint provided after a failed attempt. None on first step. "
79
+ "Populated with actionable feedback after each scored action."
80
+ ),
81
+ )
82
+
83
+
84
+ class SQLAction(BaseModel):
85
+ """
86
+ Agent action for sql_quality_env.
87
+
88
+ Fields:
89
+ task_id: Which task this action targets.
90
+ action_type: One of QUERY / PROFILE / FIX / SUBMIT.
91
+ sql: For QUERY and FIX actions — the SQL statement to execute.
92
+ column_name: For PROFILE actions — the column to profile.
93
+ answer: For null_audit SUBMIT — the column name identified.
94
+ fix_query: Alias for sql in FIX context (kept for backward compat).
95
+ root_cause_table: For dedup_bug SUBMIT — the table with duplicates.
96
+ offending_key: For dedup_bug SUBMIT — the key column with duplicates.
97
+ """
98
+
99
+ task_id: str
100
+ action_type: ActionType = ActionType.SUBMIT
101
+ sql: str | None = None
102
+ column_name: str | None = None
103
+ answer: str | None = None
104
+ fix_query: str | None = None
105
+ root_cause_table: str | None = None
106
+ offending_key: str | None = None
107
+
108
+ @property
109
+ def effective_sql(self) -> str | None:
110
+ """Return sql or fix_query, whichever is set. fix_query takes precedence."""
111
+ return self.fix_query or self.sql
112
+
113
+
114
+ class SQLReward(BaseModel):
115
+ """
116
+ Reward returned after each step.
117
+
118
+ Fields:
119
+ score: Episode-level composite score 0.0–1.0.
120
+ step_reward: Immediate reward for this specific action (not cumulative).
121
+ Used for dense reward shaping in RL training.
122
+ feedback: Human-readable explanation of what was scored and why.
123
+ dimensions: Per-dimension breakdown. Present only after SUBMIT.
124
+ None for intermediate QUERY/PROFILE/FIX actions.
125
+ partial_scores: Legacy field kept for backward compatibility.
126
+ """
127
+
128
+ score: float
129
+ step_reward: float = 0.0
130
+ feedback: str = ""
131
+ dimensions: dict | None = None
132
+ partial_scores: dict | None = None
133
+
134
+ @field_validator("score", "step_reward")
135
+ @classmethod
136
+ def clamp_to_unit(cls, v: float) -> float:
137
+ """Ensure scores stay in [0.0, 1.0]."""
138
+ return round(max(0.0, min(1.0, v)), 4)
139
+
140
+
141
+ class TrajectoryStep(BaseModel):
142
+ """A single step in the agent's investigation trajectory."""
143
+
144
+ step_number: int
145
+ action_type: ActionType
146
+ sql_submitted: str | None = None
147
+ answer_submitted: str | None = None
148
+ result_preview: str = ""
149
+ step_reward: float = 0.0
150
+ timestamp_ms: int = 0
151
+
152
+
153
+ class SQLState(BaseModel):
154
+ """Current episode state."""
155
+
156
+ task_id: str
157
+ episode_id: str = Field(description="UUID4 identifying this episode")
158
+ attempt_number: int
159
+ cumulative_reward: float = Field(ge=0.0)
160
+ done: bool
161
+
162
+
163
+ class ResetRequest(BaseModel):
164
+ """Request body for /reset endpoint."""
165
+
166
+ task_id: str = Field(
167
+ description=(
168
+ "One of: null_audit, type_repair, dedup_bug, fk_integrity, schema_drift"
169
+ )
170
+ )
171
+ seed: int | None = Field(
172
+ default=None,
173
+ description=(
174
+ "Optional deterministic episode seed. If omitted, environment chooses a random seed."
175
+ ),
176
+ )
177
+
178
+
179
+ class StepResponse(BaseModel):
180
+ """Response body for /step endpoint."""
181
+
182
+ observation: SQLObservation
183
+ reward: SQLReward
184
+ done: bool
185
+ info: dict
186
+
187
+
188
+ class HealthResponse(BaseModel):
189
+ """Response body for health/ping endpoints."""
190
+
191
+ status: str = Field(description="Service health status")
server/tasks.py ADDED
@@ -0,0 +1,690 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Task definitions for SQL Quality Environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from typing import Any, Dict, Tuple
7
+
8
+ from server.models import SchemaInfo, SQLObservation
9
+
10
+ # Allowed table names for validation
11
+ ALLOWED_TABLES = {
12
+ "users",
13
+ "transactions",
14
+ "categories",
15
+ "orders",
16
+ "order_items",
17
+ "shipments",
18
+ "customers",
19
+ "invoices",
20
+ "events_v1",
21
+ "events_v2",
22
+ "canonical_events",
23
+ }
24
+
25
+
26
+ def _validate_table_name(table_name: str) -> str:
27
+ """Validate table name against allowed set to prevent SQL injection."""
28
+ if table_name not in ALLOWED_TABLES:
29
+ raise ValueError(f"Invalid table name: {table_name}")
30
+ return table_name
31
+
32
+
33
+ def _extract_sample_rows(
34
+ conn: sqlite3.Connection, table_name: str, limit: int = 5
35
+ ) -> list:
36
+ """Extract sample rows with correct column names as dict keys."""
37
+ validated_name = _validate_table_name(table_name)
38
+ cursor = conn.execute(f"SELECT * FROM {validated_name} LIMIT ?", (limit,))
39
+ columns = [description[0] for description in cursor.description]
40
+ rows = cursor.fetchall()
41
+ return [dict(zip(columns, row)) for row in rows]
42
+
43
+
44
+ def create_null_audit_task(
45
+ seed: int = 42,
46
+ ) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
47
+ """Create null audit task with users table."""
48
+ import random
49
+
50
+ rng = random.Random(seed)
51
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
52
+ cursor = conn.cursor()
53
+
54
+ nullable_columns = ["email", "age", "city", "signup_date"]
55
+ null_column = rng.choice(nullable_columns)
56
+
57
+ cursor.execute(
58
+ """
59
+ CREATE TABLE users (
60
+ id INTEGER PRIMARY KEY,
61
+ name TEXT NOT NULL,
62
+ email TEXT,
63
+ age INTEGER,
64
+ city TEXT,
65
+ signup_date TEXT,
66
+ last_login_at TEXT,
67
+ referral_code TEXT
68
+ )
69
+ """
70
+ )
71
+
72
+ total_rows = 1000
73
+ target_null_count = 520
74
+ null_indices = set(rng.sample(range(total_rows), target_null_count))
75
+ users_data = []
76
+
77
+ for i in range(total_rows):
78
+ name = f"user_{i}"
79
+ email = f"user_{i}@example.com"
80
+ age = rng.randint(18, 80)
81
+ city = rng.choice(["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"])
82
+ signup_date = f"2023-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
83
+ last_login_at = (
84
+ f"2024-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
85
+ f"T{rng.randint(0, 23):02d}:{rng.randint(0, 59):02d}:00"
86
+ )
87
+ referral_code = (
88
+ f"REF_{rng.choice('ABCDEFGHJKLMNPQRSTUVWXYZ')}"
89
+ f"{rng.randint(0, 9)}"
90
+ f"{rng.choice('ABCDEFGHJKLMNPQRSTUVWXYZ')}"
91
+ )
92
+
93
+ if i in null_indices:
94
+ if null_column == "email":
95
+ email = None
96
+ elif null_column == "age":
97
+ age = None
98
+ elif null_column == "city":
99
+ city = None
100
+ elif null_column == "signup_date":
101
+ signup_date = None
102
+
103
+ if rng.random() < 0.1 and null_column != "email":
104
+ email = None
105
+ if rng.random() < 0.1 and null_column != "age":
106
+ age = None
107
+ if rng.random() < 0.1 and null_column != "city":
108
+ city = None
109
+ if rng.random() < 0.1 and null_column != "signup_date":
110
+ signup_date = None
111
+
112
+ users_data.append(
113
+ (i, name, email, age, city, signup_date, last_login_at, referral_code)
114
+ )
115
+
116
+ cursor.executemany(
117
+ """
118
+ INSERT INTO users (
119
+ id, name, email, age, city, signup_date, last_login_at, referral_code
120
+ )
121
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
122
+ """,
123
+ users_data,
124
+ )
125
+
126
+ cursor.execute("PRAGMA table_info(users)")
127
+ pragma_rows = cursor.fetchall()
128
+ columns = [f"{row[1]} ({row[2]})" for row in pragma_rows]
129
+ all_columns = [row[1] for row in pragma_rows]
130
+
131
+ sample_rows = {"users": _extract_sample_rows(conn, "users", 5)}
132
+ schema_info = [
133
+ SchemaInfo(table_name="users", columns=columns, row_count=total_rows)
134
+ ]
135
+
136
+ observation = SQLObservation(
137
+ task_id="null_audit",
138
+ task_description="Identify column in 'users' table with a null rate exceeding 50%",
139
+ schema_info=schema_info,
140
+ sample_rows=sample_rows,
141
+ attempt_number=1,
142
+ max_attempts=1,
143
+ previous_feedback=None,
144
+ )
145
+
146
+ ground_truth = {
147
+ "null_column": null_column, # existing — the column with NULLs
148
+ "all_columns": all_columns, # NEW — for partial credit
149
+ "table": "users", # NEW — for D1/D2 query matching
150
+ "column": null_column, # NEW — alias for unified access
151
+ "task_type": "null_audit", # NEW — for compute_episode_score routing
152
+ }
153
+
154
+ return conn, observation, ground_truth
155
+
156
+
157
+ def create_type_repair_task(
158
+ seed: int = 42,
159
+ ) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
160
+ """Create type repair task with transactions table."""
161
+ import random
162
+
163
+ rng = random.Random(seed)
164
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
165
+ cursor = conn.cursor()
166
+
167
+ cursor.execute(
168
+ """
169
+ CREATE TABLE transactions (
170
+ id INTEGER PRIMARY KEY,
171
+ user_id INTEGER NOT NULL,
172
+ amount TEXT,
173
+ description TEXT,
174
+ category TEXT,
175
+ date TEXT,
176
+ transaction_date TEXT
177
+ )
178
+ """
179
+ )
180
+
181
+ cursor.execute(
182
+ """
183
+ CREATE TABLE categories (
184
+ id INTEGER PRIMARY KEY,
185
+ name TEXT NOT NULL,
186
+ budget_limit REAL NOT NULL,
187
+ created_at TEXT NOT NULL
188
+ )
189
+ """
190
+ )
191
+
192
+ for i, (name, budget) in enumerate(
193
+ [
194
+ ("Food & Beverage", 5000.0),
195
+ ("Office Supplies", 2000.0),
196
+ ("Travel", 8000.0),
197
+ ("Software", 15000.0),
198
+ ("Hardware", 25000.0),
199
+ ("Marketing", 10000.0),
200
+ ("HR", 6000.0),
201
+ ("Legal", 12000.0),
202
+ ],
203
+ start=1,
204
+ ):
205
+ cursor.execute(
206
+ "INSERT INTO categories (id, name, budget_limit, created_at) VALUES (?, ?, ?, ?)",
207
+ (i, name, budget, "2024-01-01"),
208
+ )
209
+
210
+ transactions_data = []
211
+ for i in range(1000):
212
+ user_id = rng.randint(1, 100)
213
+ amount_value = round(rng.uniform(10.0, 1000.0), 2)
214
+ amount_text = f"{amount_value:.2f}"
215
+ description = f"Transaction {i}"
216
+ category = rng.choice(
217
+ [
218
+ "food",
219
+ "software",
220
+ "travel",
221
+ "hardware",
222
+ "marketing",
223
+ ]
224
+ )
225
+ date = f"2023-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
226
+ transaction_date = f"2023-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
227
+ transactions_data.append(
228
+ (i, user_id, amount_text, description, category, date, transaction_date)
229
+ )
230
+
231
+ cursor.executemany(
232
+ """
233
+ INSERT INTO transactions (
234
+ id, user_id, amount, description, category, date, transaction_date
235
+ )
236
+ VALUES (?, ?, ?, ?, ?, ?, ?)
237
+ """,
238
+ transactions_data,
239
+ )
240
+
241
+ schema_info = []
242
+ for table_name, row_count in [("transactions", 1000), ("categories", 8)]:
243
+ cursor.execute(f"PRAGMA table_info({table_name})")
244
+ columns = [f"{row[1]} ({row[2]})" for row in cursor.fetchall()]
245
+ schema_info.append(
246
+ SchemaInfo(table_name=table_name, columns=columns, row_count=row_count)
247
+ )
248
+
249
+ sample_rows = {
250
+ "transactions": _extract_sample_rows(conn, "transactions", 5),
251
+ "categories": _extract_sample_rows(conn, "categories", 5),
252
+ }
253
+
254
+ observation = SQLObservation(
255
+ task_id="type_repair",
256
+ task_description="A production data pipeline is reporting incorrect aggregate totals on financial records. Inspect all provided tables and sample rows to identify the data quality issue, then submit a fix_query that corrects it. Your fix must preserve all existing rows and their original values.",
257
+ schema_info=schema_info,
258
+ sample_rows=sample_rows,
259
+ attempt_number=1,
260
+ max_attempts=3,
261
+ previous_feedback=None,
262
+ )
263
+
264
+ ground_truth = {
265
+ "target_table": "transactions", # existing
266
+ "target_column": "amount", # existing
267
+ "current_type": "TEXT", # existing
268
+ "correct_type": "REAL", # existing
269
+ "sample_values": ["12.50", "345.75", "89.99"], # existing
270
+ "preserve_columns": ["id", "description", "amount"], # compatible core fields
271
+ "table": "transactions", # NEW — unified alias
272
+ "column": "amount", # NEW — unified alias
273
+ "task_type": "type_repair", # NEW
274
+ }
275
+
276
+ return conn, observation, ground_truth
277
+
278
+
279
+ def create_dedup_bug_task(
280
+ seed: int = 42,
281
+ ) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
282
+ """Create deduplication bug task with orders, order_items, and shipments."""
283
+ import random
284
+
285
+ rng = random.Random(seed)
286
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
287
+ cursor = conn.cursor()
288
+
289
+ cursor.execute(
290
+ """
291
+ CREATE TABLE orders (
292
+ id INTEGER PRIMARY KEY,
293
+ customer_id INTEGER NOT NULL,
294
+ total_amount REAL NOT NULL
295
+ )
296
+ """
297
+ )
298
+
299
+ cursor.execute(
300
+ """
301
+ CREATE TABLE order_items (
302
+ id INTEGER PRIMARY KEY,
303
+ order_id INTEGER NOT NULL,
304
+ product_name TEXT NOT NULL,
305
+ quantity INTEGER NOT NULL,
306
+ price REAL NOT NULL
307
+ )
308
+ """
309
+ )
310
+
311
+ cursor.execute(
312
+ """
313
+ CREATE TABLE shipments (
314
+ id INTEGER PRIMARY KEY,
315
+ order_item_id INTEGER NOT NULL,
316
+ tracking_number TEXT NOT NULL,
317
+ shipped_at TEXT NOT NULL
318
+ )
319
+ """
320
+ )
321
+
322
+ orders_data = []
323
+ for i in range(200):
324
+ customer_id = rng.randint(1, 50)
325
+ total_amount = round(rng.uniform(50.0, 500.0), 2)
326
+ orders_data.append((i, customer_id, total_amount))
327
+
328
+ cursor.executemany(
329
+ """
330
+ INSERT INTO orders (id, customer_id, total_amount)
331
+ VALUES (?, ?, ?)
332
+ """,
333
+ orders_data,
334
+ )
335
+
336
+ order_items_data = []
337
+ for order_id in range(160):
338
+ item_id = order_id
339
+ product_name = f"Product {item_id}"
340
+ quantity = rng.randint(1, 5)
341
+ price = round(rng.uniform(10.0, 100.0), 2)
342
+ order_items_data.append((item_id, order_id, product_name, quantity, price))
343
+
344
+ cursor.executemany(
345
+ """
346
+ INSERT INTO order_items (id, order_id, product_name, quantity, price)
347
+ VALUES (?, ?, ?, ?, ?)
348
+ """,
349
+ order_items_data,
350
+ )
351
+
352
+ duplicate_data = []
353
+ for order_id in range(160):
354
+ item_id = 160 + order_id
355
+ product_name = f"Duplicate Product {item_id}"
356
+ quantity = rng.randint(1, 5)
357
+ price = round(rng.uniform(10.0, 100.0), 2)
358
+ duplicate_data.append((item_id, order_id, product_name, quantity, price))
359
+
360
+ cursor.executemany(
361
+ """
362
+ INSERT INTO order_items (id, order_id, product_name, quantity, price)
363
+ VALUES (?, ?, ?, ?, ?)
364
+ """,
365
+ duplicate_data,
366
+ )
367
+
368
+ shipments_data = []
369
+ for shipment_id in range(160):
370
+ tracking_number = f"TRK{shipment_id:06d}"
371
+ shipped_at = (
372
+ f"2024-{rng.randint(1, 12):02d}-{rng.randint(1, 28):02d}"
373
+ f"T{rng.randint(8, 20):02d}:{rng.randint(0, 59):02d}:00"
374
+ )
375
+ shipments_data.append((shipment_id, shipment_id, tracking_number, shipped_at))
376
+
377
+ cursor.executemany(
378
+ """
379
+ INSERT INTO shipments (id, order_item_id, tracking_number, shipped_at)
380
+ VALUES (?, ?, ?, ?)
381
+ """,
382
+ shipments_data,
383
+ )
384
+
385
+ # Count legitimate rows (unique order_ids) for preservation grading
386
+ clean_count_cursor = conn.execute(
387
+ "SELECT COUNT(DISTINCT order_id) FROM order_items"
388
+ )
389
+ expected_clean_count = clean_count_cursor.fetchone()[0]
390
+ original_row_count = conn.execute("SELECT COUNT(*) FROM order_items").fetchone()[0]
391
+
392
+ schema_info = []
393
+ for table_name, row_count in [
394
+ ("orders", 200),
395
+ ("order_items", 320),
396
+ ("shipments", 160),
397
+ ]:
398
+ validated_name = _validate_table_name(table_name)
399
+ cursor.execute(f"PRAGMA table_info({validated_name})")
400
+ columns = [f"{row[1]} ({row[2]})" for row in cursor.fetchall()]
401
+ schema_info.append(
402
+ SchemaInfo(table_name=table_name, columns=columns, row_count=row_count)
403
+ )
404
+
405
+ sample_rows = {}
406
+ sample_rows["orders"] = _extract_sample_rows(conn, "orders", 5)
407
+
408
+ # Deterministic sampling: ensure at least one duplicate pair is visible
409
+ # First, get one order_id that has duplicates
410
+ cursor.execute("""
411
+ SELECT order_id FROM order_items
412
+ GROUP BY order_id HAVING COUNT(*) > 1
413
+ ORDER BY order_id LIMIT 1
414
+ """)
415
+ dup_order_id = cursor.fetchone()
416
+
417
+ if dup_order_id:
418
+ dup_order_id = dup_order_id[0]
419
+ # Get 2 rows with the duplicate order_id (to show the pattern)
420
+ cursor.execute("SELECT * FROM order_items WHERE order_id = ? LIMIT 2", (dup_order_id,))
421
+ dup_rows = cursor.fetchall()
422
+ dup_columns = [description[0] for description in cursor.description]
423
+
424
+ # Fill remaining samples with other rows (ensuring variety)
425
+ remaining = 5 - len(dup_rows)
426
+ cursor.execute("""
427
+ SELECT * FROM order_items
428
+ WHERE order_id != ?
429
+ ORDER BY rowid LIMIT ?
430
+ """, (dup_order_id, remaining))
431
+ other_rows = cursor.fetchall()
432
+
433
+ all_rows = dup_rows + other_rows
434
+ sample_rows["order_items"] = [dict(zip(dup_columns, row)) for row in all_rows]
435
+ else:
436
+ # Fallback to deterministic order if no duplicates found (shouldn't happen)
437
+ cursor.execute("SELECT * FROM order_items ORDER BY rowid LIMIT 5")
438
+ columns = [description[0] for description in cursor.description]
439
+ rows = cursor.fetchall()
440
+ sample_rows["order_items"] = [dict(zip(columns, row)) for row in rows]
441
+
442
+ sample_rows["shipments"] = _extract_sample_rows(conn, "shipments", 5)
443
+
444
+ observation = SQLObservation(
445
+ task_id="dedup_bug",
446
+ task_description="A reporting query that joins two tables is returning more rows than expected based on known business volumes. Inspect the schema and sample data, identify which table contains the data integrity issue and which column is responsible, then submit a fix_query that resolves the duplication.",
447
+ schema_info=schema_info,
448
+ sample_rows=sample_rows,
449
+ attempt_number=1,
450
+ max_attempts=5,
451
+ previous_feedback=None,
452
+ )
453
+
454
+ ground_truth = {
455
+ "root_cause_table": "order_items", # existing
456
+ "offending_key": "order_id", # existing
457
+ "expected_clean_count": expected_clean_count, # rows that SHOULD remain
458
+ "original_row_count": original_row_count, # rows before fix
459
+ "duplicate_count": original_row_count - expected_clean_count, # rows to delete
460
+ "table": "order_items", # NEW — unified alias
461
+ "column": "order_id", # NEW — unified alias
462
+ "task_type": "dedup_bug", # NEW
463
+ }
464
+
465
+ return conn, observation, ground_truth
466
+
467
+
468
+ def create_fk_integrity_task(
469
+ seed: int = 42,
470
+ ) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
471
+ """Create FK integrity task with orphan child rows in invoices.customer_id."""
472
+ import random
473
+
474
+ rng = random.Random(seed)
475
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
476
+ cursor = conn.cursor()
477
+
478
+ cursor.execute(
479
+ """
480
+ CREATE TABLE customers (
481
+ id INTEGER PRIMARY KEY,
482
+ name TEXT NOT NULL,
483
+ segment TEXT NOT NULL,
484
+ created_at TEXT NOT NULL
485
+ )
486
+ """
487
+ )
488
+ cursor.execute(
489
+ """
490
+ CREATE TABLE invoices (
491
+ id INTEGER PRIMARY KEY,
492
+ customer_id INTEGER NOT NULL,
493
+ amount REAL NOT NULL,
494
+ status TEXT NOT NULL,
495
+ issued_at TEXT NOT NULL
496
+ )
497
+ """
498
+ )
499
+
500
+ customer_rows = []
501
+ for i in range(1, 401):
502
+ customer_rows.append(
503
+ (
504
+ i,
505
+ f"Customer_{i}",
506
+ rng.choice(["enterprise", "mid_market", "smb"]),
507
+ f"2024-{rng.randint(1,12):02d}-{rng.randint(1,28):02d}",
508
+ )
509
+ )
510
+ cursor.executemany(
511
+ "INSERT INTO customers (id, name, segment, created_at) VALUES (?, ?, ?, ?)",
512
+ customer_rows,
513
+ )
514
+
515
+ invoice_rows = []
516
+ for i in range(1, 1201):
517
+ invoice_rows.append(
518
+ (
519
+ i,
520
+ rng.randint(1, 400),
521
+ round(rng.uniform(25.0, 2500.0), 2),
522
+ rng.choice(["issued", "paid", "overdue"]),
523
+ f"2025-{rng.randint(1,12):02d}-{rng.randint(1,28):02d}",
524
+ )
525
+ )
526
+
527
+ orphan_invoice_ids = set(rng.sample(range(1, 1201), 180))
528
+ rewritten_rows = []
529
+ valid_invoice_ids = []
530
+ for row in invoice_rows:
531
+ invoice_id, customer_id, amount, status, issued_at = row
532
+ if invoice_id in orphan_invoice_ids:
533
+ rewritten_rows.append(
534
+ (invoice_id, 10000 + rng.randint(1, 1000), amount, status, issued_at)
535
+ )
536
+ else:
537
+ rewritten_rows.append(row)
538
+ valid_invoice_ids.append(invoice_id)
539
+
540
+ cursor.executemany(
541
+ "INSERT INTO invoices (id, customer_id, amount, status, issued_at) VALUES (?, ?, ?, ?, ?)",
542
+ rewritten_rows,
543
+ )
544
+
545
+ schema_info = []
546
+ for table_name, row_count in [("customers", 400), ("invoices", 1200)]:
547
+ cursor.execute(f"PRAGMA table_info({table_name})")
548
+ columns = [f"{row[1]} ({row[2]})" for row in cursor.fetchall()]
549
+ schema_info.append(
550
+ SchemaInfo(table_name=table_name, columns=columns, row_count=row_count)
551
+ )
552
+
553
+ sample_rows = {
554
+ "customers": _extract_sample_rows(conn, "customers", 5),
555
+ "invoices": _extract_sample_rows(conn, "invoices", 5),
556
+ }
557
+
558
+ observation = SQLObservation(
559
+ task_id="fk_integrity",
560
+ task_description=(
561
+ "A billing pipeline shows referential integrity drift between parent and child records. "
562
+ "Inspect the schema and samples, then submit a fix_query that removes orphan relationships "
563
+ "while preserving valid invoice records."
564
+ ),
565
+ schema_info=schema_info,
566
+ sample_rows=sample_rows,
567
+ attempt_number=1,
568
+ max_attempts=4,
569
+ previous_feedback=None,
570
+ )
571
+
572
+ ground_truth = {
573
+ "task_type": "fk_integrity",
574
+ "table": "invoices",
575
+ "column": "customer_id",
576
+ "parent_table": "customers",
577
+ "parent_key": "id",
578
+ "expected_clean_count": len(valid_invoice_ids),
579
+ "valid_invoice_ids": valid_invoice_ids,
580
+ "orphan_count": len(orphan_invoice_ids),
581
+ "preserve_columns": ["id", "customer_id", "amount", "status", "issued_at"],
582
+ }
583
+ return conn, observation, ground_truth
584
+
585
+
586
+ def create_schema_drift_task(
587
+ seed: int = 42,
588
+ ) -> Tuple[sqlite3.Connection, SQLObservation, Dict[str, Any]]:
589
+ """Create schema drift task with two upstream versions that need canonicalization."""
590
+ import random
591
+
592
+ rng = random.Random(seed)
593
+ conn = sqlite3.connect(":memory:", check_same_thread=False)
594
+ cursor = conn.cursor()
595
+
596
+ cursor.execute(
597
+ """
598
+ CREATE TABLE events_v1 (
599
+ id INTEGER PRIMARY KEY,
600
+ user_id INTEGER NOT NULL,
601
+ event_name TEXT NOT NULL,
602
+ amount_text TEXT NOT NULL,
603
+ event_time TEXT NOT NULL
604
+ )
605
+ """
606
+ )
607
+ cursor.execute(
608
+ """
609
+ CREATE TABLE events_v2 (
610
+ event_id INTEGER PRIMARY KEY,
611
+ account_id INTEGER NOT NULL,
612
+ event_type TEXT NOT NULL,
613
+ amount REAL NOT NULL,
614
+ event_ts TEXT NOT NULL,
615
+ source TEXT NOT NULL
616
+ )
617
+ """
618
+ )
619
+
620
+ v1_rows = []
621
+ for i in range(1, 601):
622
+ amount = round(rng.uniform(5.0, 500.0), 2)
623
+ v1_rows.append(
624
+ (
625
+ i,
626
+ rng.randint(1, 300),
627
+ rng.choice(["purchase", "refund", "chargeback"]),
628
+ f"{amount:.2f}",
629
+ f"2025-{rng.randint(1,12):02d}-{rng.randint(1,28):02d}T{rng.randint(0,23):02d}:{rng.randint(0,59):02d}:00",
630
+ )
631
+ )
632
+ cursor.executemany(
633
+ "INSERT INTO events_v1 (id, user_id, event_name, amount_text, event_time) VALUES (?, ?, ?, ?, ?)",
634
+ v1_rows,
635
+ )
636
+
637
+ v2_rows = []
638
+ for i in range(601, 1201):
639
+ v2_rows.append(
640
+ (
641
+ i,
642
+ rng.randint(1, 300),
643
+ rng.choice(["purchase", "refund", "chargeback"]),
644
+ round(rng.uniform(5.0, 500.0), 2),
645
+ f"2025-{rng.randint(1,12):02d}-{rng.randint(1,28):02d}T{rng.randint(0,23):02d}:{rng.randint(0,59):02d}:00",
646
+ rng.choice(["batch_a", "batch_b"]),
647
+ )
648
+ )
649
+ cursor.executemany(
650
+ "INSERT INTO events_v2 (event_id, account_id, event_type, amount, event_ts, source) VALUES (?, ?, ?, ?, ?, ?)",
651
+ v2_rows,
652
+ )
653
+
654
+ schema_info = []
655
+ for table_name, row_count in [("events_v1", 600), ("events_v2", 600)]:
656
+ cursor.execute(f"PRAGMA table_info({table_name})")
657
+ columns = [f"{row[1]} ({row[2]})" for row in cursor.fetchall()]
658
+ schema_info.append(
659
+ SchemaInfo(table_name=table_name, columns=columns, row_count=row_count)
660
+ )
661
+
662
+ sample_rows = {
663
+ "events_v1": _extract_sample_rows(conn, "events_v1", 5),
664
+ "events_v2": _extract_sample_rows(conn, "events_v2", 5),
665
+ }
666
+
667
+ observation = SQLObservation(
668
+ task_id="schema_drift",
669
+ task_description=(
670
+ "Two upstream event producers emit the same business concept with drifted schemas. "
671
+ "Create a canonical_events artifact that normalizes both streams into one aligned schema "
672
+ "with numeric amounts and consistent timestamps."
673
+ ),
674
+ schema_info=schema_info,
675
+ sample_rows=sample_rows,
676
+ attempt_number=1,
677
+ max_attempts=4,
678
+ previous_feedback=None,
679
+ )
680
+
681
+ ground_truth = {
682
+ "task_type": "schema_drift",
683
+ "table": "canonical_events",
684
+ "column": "amount",
685
+ "source_tables": ["events_v1", "events_v2"],
686
+ "expected_columns": ["event_id", "user_id", "event_type", "amount", "event_time"],
687
+ "expected_row_count": 1200,
688
+ "preserve_columns": ["event_id", "user_id", "event_type", "amount", "event_time"],
689
+ }
690
+ return conn, observation, ground_truth