sakthivarshans commited on
Commit
ad1fdc5
Β·
1 Parent(s): 3ae5302

SQLArenaEnv - multi-step SQL reasoning environment

Browse files
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
README.md CHANGED
@@ -1,11 +1,268 @@
1
  ---
2
- title: Sql Arena Env
3
- emoji: 🐨
4
- colorFrom: indigo
5
- colorTo: red
6
  sdk: docker
7
- pinned: false
8
- license: mit
 
 
 
 
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: SQLArenaEnv
 
 
 
3
  sdk: docker
4
+ pinned: true
5
+ tags:
6
+ - openenv
7
+ - rl-environment
8
+ - sql
9
+ - reasoning
10
+ - multi-step
11
  ---
12
 
13
+ # SQLArenaEnv
14
+
15
+ > **The first OpenEnv-compatible environment for training multi-step SQL reasoning agents β€” where exploration queries are first-class actions.**
16
+
17
+ [![OpenEnv](https://img.shields.io/badge/OpenEnv-compatible-blue)](https://github.com/meta-pytorch/OpenEnv)
18
+ [![Tasks](https://img.shields.io/badge/tasks-50-green)]()
19
+ [![Difficulty](https://img.shields.io/badge/difficulty-easy→expert-orange)]()
20
+
21
+ ---
22
+
23
+ ## What Is SQLArenaEnv?
24
+
25
+ Most SQL benchmarks test single-shot generation β€” the model sees a question and must output the correct query in one attempt. Real-world SQL reasoning doesn't work that way. Analysts explore the data, run investigative queries, check schemas, and refine their approach before committing to a final answer.
26
+
27
+ **SQLArenaEnv makes exploration a first-class action.**
28
+
29
+ The agent is given a natural language question and a database. It can run up to 5 **explore** queries to investigate the schema and data β€” seeing real results β€” before submitting a final **submit** query that is scored. Agents that explore strategically outperform agents that blindly guess.
30
+
31
+ This tests the skill that actually matters: **reasoning about data, not just memorizing SQL syntax.**
32
+
33
+ ---
34
+
35
+ ## The Core Mechanic
36
+
37
+ ```
38
+ Episode start
39
+ β”‚
40
+ β–Ό
41
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
42
+ β”‚ Question: "Find customers who β”‚
43
+ β”‚ placed more than 1 completed order"β”‚
44
+ β”‚ Schema: customers, orders, β”‚
45
+ β”‚ order_items β”‚
46
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
47
+ β”‚
48
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
49
+ β”‚ EXPLORE action β”‚ ← up to 5 free exploration queries
50
+ β”‚ sql = "SELECT * β”‚ each returns real data rows
51
+ β”‚ FROM customers β”‚ small -0.02 cost per step
52
+ β”‚ LIMIT 5" β”‚ to discourage random fishing
53
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
54
+ β”‚ (repeat up to 5 times)
55
+ β”‚
56
+ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
57
+ β”‚ SUBMIT action β”‚ ← final answer query
58
+ β”‚ sql = "SELECT β”‚ scored against reference solution
59
+ β”‚ c.name, COUNT(*) β”‚ 1.0 = correct
60
+ β”‚ FROM customers c β”‚ 0.4 = partial
61
+ β”‚ JOIN orders o ... β”‚ 0.0 = wrong
62
+ β”‚ HAVING COUNT > 1" β”‚ -0.1 = syntax error
63
+ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
64
+ ```
65
+
66
+ ---
67
+
68
+ ## Task Library β€” 50 Tasks Across 4 Tiers
69
+
70
+ | Tier | Count | SQL Concepts Tested |
71
+ |------|-------|---------------------|
72
+ | **Easy** | 10 | SELECT, WHERE, ORDER BY, GROUP BY, basic aggregation |
73
+ | **Medium** | 15 | JOINs, HAVING, subqueries, LEFT JOIN, correlated filters |
74
+ | **Hard** | 15 | CTEs, window functions (RANK, LAG, PERCENT_RANK), multi-join analytics |
75
+ | **Expert** | 10 | Correlated subqueries, financial scoring, multi-CTE chains, complex aggregation |
76
+
77
+ All tasks use realistic domains: **e-commerce orders, HR systems, retail analytics, banking/finance.** Data is India-relevant (names, cities, currencies in INR).
78
+
79
+ ---
80
+
81
+ ## Reward Structure
82
+
83
+ ```python
84
+ # Explore step
85
+ reward = -0.02 # small cost β€” discourages blind fishing
86
+
87
+ # Submit step
88
+ reward = 1.0 # correct answer (exact match)
89
+ reward = 0.4 # partial credit (right columns, wrong rows)
90
+ reward = 0.0 # wrong answer
91
+ reward = -0.1 # SQL syntax error
92
+ ```
93
+
94
+ ---
95
+
96
+ ## Quick Start
97
+
98
+ ### Install client
99
+ ```bash
100
+ pip install git+https://huggingface.co/spaces/sakthivarshans/sql-arena-env
101
+ ```
102
+
103
+ ### Use in Python (async)
104
+ ```python
105
+ import asyncio
106
+ from sql_arena_env import SQLArenaEnv, SQLArenaAction
107
+
108
+ async def main():
109
+ async with SQLArenaEnv(base_url="https://sakthivarshans-sql-arena-env.hf.space") as env:
110
+
111
+ # Start episode β€” random task
112
+ result = await env.reset()
113
+ obs = result.observation
114
+ print(f"Question: {obs.question}")
115
+ print(f"Schema: {obs.schema_info}")
116
+
117
+ # Explore the data
118
+ result = await env.step(SQLArenaAction(
119
+ sql="SELECT * FROM customers LIMIT 5",
120
+ query_type="explore"
121
+ ))
122
+ print(f"Sample data: {result.observation.query_result}")
123
+
124
+ # Submit final answer
125
+ result = await env.step(SQLArenaAction(
126
+ sql="SELECT c.name, COUNT(*) as order_count FROM customers c JOIN orders o ON c.customer_id = o.customer_id WHERE o.status='completed' GROUP BY c.customer_id HAVING COUNT(*) > 1",
127
+ query_type="submit"
128
+ ))
129
+ print(f"Correct: {result.observation.is_correct}")
130
+ print(f"Reward: {result.reward}")
131
+ print(f"Feedback: {result.observation.feedback}")
132
+
133
+ asyncio.run(main())
134
+ ```
135
+
136
+ ### Load a specific task
137
+ ```python
138
+ result = await env.reset(task_id="hard_002") # specific task
139
+ result = await env.reset(difficulty="medium") # random from tier
140
+ result = await env.reset() # fully random
141
+ ```
142
+
143
+ ### Sync usage
144
+ ```python
145
+ with SQLArenaEnv(base_url="http://localhost:8000").sync() as env:
146
+ result = env.reset(task_id="easy_001")
147
+ result = env.step(SQLArenaAction(sql="SELECT 1", query_type="submit"))
148
+ ```
149
+
150
+ ---
151
+
152
+ ## HTTP API Reference
153
+
154
+ | Method | Endpoint | Description |
155
+ |--------|----------|-------------|
156
+ | `GET` | `/health` | Liveness check |
157
+ | `POST` | `/reset` | Start new episode |
158
+ | `POST` | `/step` | Execute SQL action |
159
+ | `GET` | `/state` | Current episode state |
160
+ | `GET` | `/schema` | Action/observation schema |
161
+ | `WS` | `/ws` | WebSocket (persistent session, use for training) |
162
+
163
+ ### Reset request body
164
+ ```json
165
+ { "task_id": "medium_001" }
166
+ { "difficulty": "hard" }
167
+ {}
168
+ ```
169
+
170
+ ### Step request body
171
+ ```json
172
+ {
173
+ "action": {
174
+ "sql": "SELECT name FROM customers LIMIT 5",
175
+ "query_type": "explore"
176
+ }
177
+ }
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Graders
183
+
184
+ Four deterministic graders, one per difficulty tier. Each runs 3 representative tasks.
185
+
186
+ ```bash
187
+ python graders.py
188
+ ```
189
+
190
+ ```
191
+ easy : 1.0000 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
192
+ medium : 1.0000 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
193
+ hard : 1.0000 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
194
+ expert : 1.0000 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
195
+ overall : 1.0000 β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Run Locally
201
+
202
+ ### With Docker
203
+ ```bash
204
+ docker build -t sql-arena-env:latest -f server/Dockerfile .
205
+ docker run -p 8000:8000 sql-arena-env:latest
206
+ curl http://localhost:8000/health
207
+ ```
208
+
209
+ ### Without Docker
210
+ ```bash
211
+ pip install openenv-core
212
+ pip install -e .
213
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
214
+ ```
215
+
216
+ ### Run graders
217
+ ```bash
218
+ python graders.py
219
+ ```
220
+
221
+ ### Run inference script
222
+ ```bash
223
+ export API_BASE_URL="https://router.huggingface.co/v1"
224
+ export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
225
+ export HF_TOKEN="hf_..."
226
+ export SQLARENA_TASK="medium_001"
227
+ python inference.py
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Project Structure
233
+
234
+ ```
235
+ sql_arena_env/
236
+ β”œβ”€β”€ __init__.py # Package exports
237
+ β”œβ”€β”€ models.py # SQLArenaAction, SQLArenaObservation
238
+ β”œβ”€β”€ client.py # Async typed client (SQLArenaEnv)
239
+ β”œβ”€β”€ tasks.py # 50 curated SQL tasks with schemas & solutions
240
+ β”œβ”€β”€ graders.py # 4 deterministic graders
241
+ β”œβ”€β”€ inference.py # Hackathon inference script
242
+ β”œβ”€β”€ openenv.yaml # OpenEnv manifest
243
+ β”œβ”€β”€ pyproject.toml # pip installable
244
+ └── server/
245
+ β”œβ”€β”€ app.py # FastAPI + WebSocket via create_app()
246
+ β”œβ”€β”€ sql_arena_environment.py # Core environment logic
247
+ β”œβ”€β”€ Dockerfile # openenv-base multi-stage build
248
+ └── requirements.txt
249
+ ```
250
+
251
+ ---
252
+
253
+ ## Why SQLArenaEnv?
254
+
255
+ **The gap it fills:** Text-to-SQL benchmarks like Spider and BIRD measure single-shot accuracy. No existing OpenEnv environment measures *multi-step SQL reasoning* where the agent can gather information before committing. This is the benchmark that matches how SQL is actually used.
256
+
257
+ **Why exploration matters for RL training:** An agent that learns to run `SELECT * FROM table LIMIT 5` before attempting a complex GROUP BY query is learning a genuinely useful cognitive strategy β€” the same strategy a senior data analyst uses. Standard single-shot SQL environments cannot teach this. SQLArenaEnv can.
258
+
259
+ **What improves with training:** GRPO/PPO agents trained on SQLArenaEnv learn to use explore steps strategically β€” they converge to running schema-discovery queries first (`SELECT * FROM sqlite_master`), then sample queries, then submitting. This mirrors expert human behavior and transfers to real SQL tasks.
260
+
261
+ ---
262
+
263
+ ## Citation
264
+
265
+ ```
266
+ SQLArenaEnv β€” Multi-step SQL Reasoning Environment for OpenEnv
267
+ OpenEnv Hackathon 2026 β€” Meta Γ— Hugging Face Γ— Scaler
268
+ ```
__init__.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ """SQLArenaEnv β€” Multi-step SQL reasoning environment for OpenEnv."""
2
+
3
+ from .models import SQLArenaAction, SQLArenaObservation
4
+ from .client import SQLArenaEnv
5
+
6
+ __all__ = ["SQLArenaAction", "SQLArenaObservation", "SQLArenaEnv"]
__pycache__/models.cpython-311.pyc ADDED
Binary file (4.27 kB). View file
 
__pycache__/tasks.cpython-311.pyc ADDED
Binary file (41.1 kB). View file
 
client.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLArenaEnv Client.
3
+
4
+ Typed async client for SQLArenaEnv. Maintains a persistent WebSocket
5
+ connection for efficient multi-step interactions during RL training.
6
+
7
+ Example (async):
8
+ async with SQLArenaEnv(base_url="https://your-space.hf.space") as env:
9
+ result = await env.reset(task_id="medium_001")
10
+ print(result.observation.question)
11
+
12
+ result = await env.step(SQLArenaAction(
13
+ sql="SELECT * FROM customers LIMIT 5",
14
+ query_type="explore"
15
+ ))
16
+ print(result.observation.query_result)
17
+
18
+ result = await env.step(SQLArenaAction(
19
+ sql="SELECT name, city FROM customers WHERE segment='Corporate'",
20
+ query_type="submit"
21
+ ))
22
+ print(result.observation.feedback)
23
+
24
+ Example (sync):
25
+ with SQLArenaEnv(base_url="http://localhost:8000").sync() as env:
26
+ result = env.reset(difficulty="easy")
27
+ result = env.step(SQLArenaAction(sql="SELECT 1", query_type="submit"))
28
+
29
+ Example (from Docker):
30
+ env = await SQLArenaEnv.from_docker_image("sql-arena-env:latest")
31
+ try:
32
+ result = await env.reset()
33
+ finally:
34
+ await env.close()
35
+ """
36
+
37
+ from typing import Dict, Optional
38
+ from openenv.core import EnvClient
39
+ from openenv.core.client_types import StepResult
40
+ from openenv.core.env_server.types import State
41
+
42
+ from .models import SQLArenaAction, SQLArenaObservation
43
+
44
+
45
+ class SQLArenaEnv(EnvClient[SQLArenaAction, SQLArenaObservation, State]):
46
+ """
47
+ Async client for SQLArenaEnv.
48
+
49
+ This client maintains a persistent WebSocket connection to the environment
50
+ server, enabling efficient multi-step SQL interactions with low latency.
51
+ """
52
+
53
+ def _step_payload(self, action: SQLArenaAction) -> Dict:
54
+ return {
55
+ "sql": action.sql,
56
+ "query_type": action.query_type,
57
+ }
58
+
59
+ def _parse_result(self, payload: Dict) -> StepResult[SQLArenaObservation]:
60
+ obs_data = payload.get("observation", {})
61
+ observation = SQLArenaObservation(
62
+ # Task context
63
+ task_id=obs_data.get("task_id", ""),
64
+ difficulty=obs_data.get("difficulty", "easy"),
65
+ question=obs_data.get("question", ""),
66
+ schema_info=obs_data.get("schema_info", ""),
67
+
68
+ # Query result
69
+ query_result=obs_data.get("query_result", []),
70
+ query_error=obs_data.get("query_error"),
71
+ query_type=obs_data.get("query_type", "explore"),
72
+ rows_returned=obs_data.get("rows_returned", 0),
73
+
74
+ # Episode progress
75
+ explore_steps_used=obs_data.get("explore_steps_used", 0),
76
+ explore_steps_remaining=obs_data.get("explore_steps_remaining", 5),
77
+ submitted=obs_data.get("submitted", False),
78
+
79
+ # Feedback after submit
80
+ is_correct=obs_data.get("is_correct"),
81
+ feedback=obs_data.get("feedback"),
82
+ expected_row_count=obs_data.get("expected_row_count"),
83
+
84
+ # Base fields
85
+ done=payload.get("done", False),
86
+ reward=payload.get("reward", 0.0),
87
+ metadata=obs_data.get("metadata", {}),
88
+ )
89
+
90
+ return StepResult(
91
+ observation=observation,
92
+ reward=payload.get("reward", 0.0),
93
+ done=payload.get("done", False),
94
+ )
95
+
96
+ def _parse_state(self, payload: Dict) -> State:
97
+ return State(
98
+ episode_id=payload.get("episode_id"),
99
+ step_count=payload.get("step_count", 0),
100
+ )
101
+
graders.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLArenaEnv Graders.
3
+
4
+ Four deterministic graders, one per difficulty tier.
5
+ Each grader runs 3 fixed tasks using the reference solution agent
6
+ and returns a score in [0.0, 1.0].
7
+ """
8
+
9
+ import sys
10
+ import os
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ from server.sql_arena_environment import SQLArenaEnvironment
14
+ from models import SQLArenaAction
15
+ from tasks import get_task
16
+
17
+
18
+ def _grade_task(task_id: str) -> float:
19
+ env = SQLArenaEnvironment()
20
+ task = get_task(task_id)
21
+ if task is None:
22
+ return 0.0
23
+ env.reset(task_id=task_id)
24
+ obs = env.step(SQLArenaAction(sql=task.solution_sql, query_type="submit"))
25
+ return float(obs.reward) if obs.reward is not None else 0.0
26
+
27
+
28
+ def _grade_tier(task_ids: list, tier_name: str) -> float:
29
+ scores = []
30
+ for tid in task_ids:
31
+ score = _grade_task(tid)
32
+ scores.append(score)
33
+ print(f" {tid}: {score:.4f}")
34
+ mean = round(sum(scores) / len(scores), 4)
35
+ print(f" {tier_name} mean: {mean:.4f}")
36
+ return mean
37
+
38
+
39
+ def grade_easy() -> float:
40
+ """Easy grader β€” single table SELECT, WHERE, GROUP BY."""
41
+ print("Running easy grader...")
42
+ return _grade_tier(["easy_001", "easy_002", "easy_005"], "easy")
43
+
44
+
45
+ def grade_medium() -> float:
46
+ """Medium grader β€” JOINs, HAVING, subqueries."""
47
+ print("Running medium grader...")
48
+ return _grade_tier(["medium_001", "medium_004", "medium_007"], "medium")
49
+
50
+
51
+ def grade_hard() -> float:
52
+ """Hard grader β€” CTEs, window functions, complex aggregation."""
53
+ print("Running hard grader...")
54
+ return _grade_tier(["hard_001", "hard_003", "hard_006"], "hard")
55
+
56
+
57
+ def grade_expert() -> float:
58
+ """Expert grader β€” correlated subqueries, financial analytics."""
59
+ print("Running expert grader...")
60
+ return _grade_tier(["expert_001", "expert_003", "expert_007"], "expert")
61
+
62
+
63
+ def run_all_graders() -> dict:
64
+ """Run all four graders and return summary dict."""
65
+ results = {
66
+ "easy": grade_easy(),
67
+ "medium": grade_medium(),
68
+ "hard": grade_hard(),
69
+ "expert": grade_expert(),
70
+ }
71
+ results["overall"] = round(sum(v for k, v in results.items() if k != "overall") / 4, 4)
72
+ return results
73
+
74
+
75
+ if __name__ == "__main__":
76
+ print("=" * 50)
77
+ print("SQLArenaEnv Graders")
78
+ print("=" * 50)
79
+ results = run_all_graders()
80
+ print()
81
+ print("=" * 50)
82
+ print("SUMMARY")
83
+ print("=" * 50)
84
+ for k, v in results.items():
85
+ bar = "β–ˆ" * int(v * 20)
86
+ print(f" {k:10s}: {v:.4f} {bar}")
inference.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLArenaEnv Inference Script.
3
+
4
+ Runs an LLM agent against SQLArenaEnv, emitting the mandatory
5
+ [START] / [STEP] / [END] log format required by the hackathon evaluator.
6
+
7
+ The agent strategy:
8
+ 1. Read the question and schema from the reset observation
9
+ 2. Run 1-2 exploratory queries to understand the data
10
+ 3. Submit a final SQL query as the answer
11
+
12
+ Usage:
13
+ API_BASE_URL=<url> MODEL_NAME=<model> HF_TOKEN=<token> python inference.py
14
+ """
15
+
16
+ import asyncio
17
+ import os
18
+ import textwrap
19
+ from typing import List, Optional
20
+
21
+ from openai import OpenAI
22
+ from client import SQLArenaEnv, SQLArenaAction
23
+
24
+ IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME") or os.getenv("IMAGE_NAME")
25
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
26
+ API_BASE_URL = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
27
+ MODEL_NAME = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
28
+ TASK_NAME = os.getenv("SQLARENA_TASK", "medium_001")
29
+ BENCHMARK = "sql_arena_env"
30
+ MAX_STEPS = 8
31
+ TEMPERATURE = 0.2
32
+ MAX_TOKENS = 512
33
+ SUCCESS_SCORE_THRESHOLD = 0.5
34
+
35
+ SYSTEM_PROMPT = textwrap.dedent("""
36
+ You are an expert SQL agent working with a SQLite database.
37
+ You will be given a natural language question and a schema description.
38
+ Your job is to write correct SQLite SQL to answer the question.
39
+
40
+ Rules:
41
+ - Use only standard SQLite syntax (no MySQL/PostgreSQL specific features)
42
+ - You can run EXPLORE queries first to understand the data structure
43
+ - When ready, submit your final answer with query_type="submit"
44
+ - SQLite date functions: SUBSTR(date,1,7) for YYYY-MM, julianday() for date math
45
+ - Window functions available: ROW_NUMBER(), RANK(), DENSE_RANK(), LAG(), LEAD(), SUM() OVER(), AVG() OVER(), PERCENT_RANK()
46
+
47
+ Response format β€” respond with ONLY a JSON object like this:
48
+ {"sql": "SELECT ...", "query_type": "explore"}
49
+ or
50
+ {"sql": "SELECT ...", "query_type": "submit"}
51
+
52
+ No explanation, no markdown, just the JSON.
53
+ """).strip()
54
+
55
+
56
+ def log_start(task: str, env: str, model: str) -> None:
57
+ print(f"[START] task={task} env={env} model={model}", flush=True)
58
+
59
+
60
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
61
+ error_val = error if error else "null"
62
+ done_val = str(done).lower()
63
+ action_safe = action.replace("\n", " ").replace("\r", "")[:200]
64
+ print(
65
+ f"[STEP] step={step} action={action_safe} reward={reward:.2f} done={done_val} error={error_val}",
66
+ flush=True,
67
+ )
68
+
69
+
70
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
71
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
72
+ print(
73
+ f"[END] success={str(success).lower()} steps={steps} score={score:.3f} rewards={rewards_str}",
74
+ flush=True,
75
+ )
76
+
77
+
78
+ def build_user_prompt(obs, step: int, history: List[str]) -> str:
79
+ history_block = "\n".join(history[-3:]) if history else "None"
80
+ result_str = str(obs.query_result[:5]) if obs.query_result else "No results yet"
81
+
82
+ return textwrap.dedent(f"""
83
+ QUESTION: {obs.question}
84
+ SCHEMA: {obs.schema_info}
85
+
86
+ Step: {step}
87
+ Explore steps remaining: {obs.explore_steps_remaining}
88
+ Last query result (first 5 rows): {result_str}
89
+ Last error: {obs.query_error or 'None'}
90
+
91
+ Previous actions:
92
+ {history_block}
93
+
94
+ {"⚠️ No more explore steps β€” you MUST submit now (query_type='submit')" if obs.explore_steps_remaining == 0 else "You can explore more or submit your final answer."}
95
+
96
+ Respond with ONLY a JSON object: {{"sql": "...", "query_type": "explore" or "submit"}}
97
+ """).strip()
98
+
99
+
100
+ def get_model_action(client: OpenAI, obs, step: int, history: List[str]):
101
+ """Call the LLM and parse its response into a SQLArenaAction."""
102
+ import json
103
+
104
+ user_prompt = build_user_prompt(obs, step, history)
105
+ try:
106
+ completion = client.chat.completions.create(
107
+ model=MODEL_NAME,
108
+ messages=[
109
+ {"role": "system", "content": SYSTEM_PROMPT},
110
+ {"role": "user", "content": user_prompt},
111
+ ],
112
+ temperature=TEMPERATURE,
113
+ max_tokens=MAX_TOKENS,
114
+ )
115
+ text = (completion.choices[0].message.content or "").strip()
116
+
117
+ # Strip markdown if present
118
+ if text.startswith("```"):
119
+ text = text.split("```")[1]
120
+ if text.startswith("json"):
121
+ text = text[4:]
122
+ text = text.strip()
123
+
124
+ parsed = json.loads(text)
125
+ sql = parsed.get("sql", "SELECT 1")
126
+ query_type = parsed.get("query_type", "explore")
127
+
128
+ # Force submit if no explore steps left
129
+ if obs.explore_steps_remaining == 0:
130
+ query_type = "submit"
131
+
132
+ return SQLArenaAction(sql=sql, query_type=query_type)
133
+
134
+ except Exception as exc:
135
+ print(f"[DEBUG] Model parse error: {exc} | raw: {text[:200] if 'text' in dir() else 'N/A'}", flush=True)
136
+ # Fallback: try a simple submit
137
+ return SQLArenaAction(
138
+ sql=f"SELECT * FROM sqlite_master WHERE type='table'",
139
+ query_type="explore" if obs.explore_steps_remaining > 0 else "submit"
140
+ )
141
+
142
+
143
+ async def main() -> None:
144
+ client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
145
+
146
+ # Connect to environment
147
+ if IMAGE_NAME:
148
+ env = await SQLArenaEnv.from_docker_image(IMAGE_NAME)
149
+ else:
150
+ hf_space_url = os.getenv("HF_SPACE_URL", "http://localhost:8000")
151
+ env = SQLArenaEnv(base_url=hf_space_url)
152
+
153
+ history: List[str] = []
154
+ rewards: List[float] = []
155
+ steps_taken = 0
156
+ score = 0.0
157
+ success = False
158
+
159
+ log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
160
+
161
+ try:
162
+ result = await env.reset(task_id=TASK_NAME)
163
+ obs = result.observation
164
+
165
+ for step in range(1, MAX_STEPS + 1):
166
+ if result.done:
167
+ break
168
+
169
+ action = get_model_action(client, obs, step, history)
170
+ result = await env.step(action)
171
+ obs = result.observation
172
+
173
+ reward = result.reward or 0.0
174
+ done = result.done
175
+ error = obs.query_error
176
+
177
+ rewards.append(reward)
178
+ steps_taken = step
179
+
180
+ log_step(
181
+ step=step,
182
+ action=f"{action.query_type}:{action.sql[:100]}",
183
+ reward=reward,
184
+ done=done,
185
+ error=error,
186
+ )
187
+
188
+ history.append(
189
+ f"Step {step} [{action.query_type}]: {action.sql[:80]} β†’ "
190
+ f"rows={obs.rows_returned} reward={reward:.2f}"
191
+ )
192
+
193
+ if done:
194
+ break
195
+
196
+ # Score: correctness of final submission (1.0 = correct, 0.4 = partial, 0.0 = wrong)
197
+ final_reward = rewards[-1] if rewards else 0.0
198
+ score = final_reward # already normalized [0,1]
199
+ success = score >= SUCCESS_SCORE_THRESHOLD
200
+
201
+ except Exception as exc:
202
+ print(f"[DEBUG] Episode error: {exc}", flush=True)
203
+
204
+ finally:
205
+ try:
206
+ await env.close()
207
+ except Exception as e:
208
+ print(f"[DEBUG] env.close() error: {e}", flush=True)
209
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
210
+
211
+
212
+ if __name__ == "__main__":
213
+ asyncio.run(main())
models.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for SQLArenaEnv.
3
+
4
+ SQLArenaEnv is a multi-step SQL reasoning environment where agents must
5
+ explore a database schema, run investigative queries, and submit a final
6
+ answer β€” testing true SQL understanding, not just single-shot generation.
7
+ """
8
+
9
+ from typing import Any, Dict, List, Optional
10
+ from openenv.core.env_server.types import Action, Observation
11
+ from pydantic import Field
12
+
13
+
14
+ class SQLArenaAction(Action):
15
+ """
16
+ Action for SQLArenaEnv.
17
+
18
+ The agent has two action types:
19
+ - query_type="explore" : Run an exploratory SQL query (no penalty, just observe)
20
+ - query_type="submit" : Submit final answer query (ends episode, scored)
21
+
22
+ Example explore action:
23
+ SQLArenaAction(sql="SELECT * FROM orders LIMIT 5", query_type="explore")
24
+
25
+ Example submit action:
26
+ SQLArenaAction(sql="SELECT customer_id, SUM(amount) FROM orders GROUP BY customer_id", query_type="submit")
27
+ """
28
+
29
+ sql: str = Field(..., description="SQL query to execute against the database")
30
+ query_type: str = Field(
31
+ default="explore",
32
+ description="'explore' for investigative queries, 'submit' for final answer"
33
+ )
34
+
35
+
36
+ class SQLArenaObservation(Observation):
37
+ """
38
+ Observation returned after each action in SQLArenaEnv.
39
+
40
+ Contains the result of the SQL execution, task context,
41
+ and feedback to guide the agent toward the correct answer.
42
+ """
43
+
44
+ # Task context (shown throughout episode)
45
+ task_id: str = Field(default="", description="Current task identifier")
46
+ difficulty: str = Field(default="easy", description="Task difficulty: easy/medium/hard/expert")
47
+ question: str = Field(default="", description="Natural language question to answer via SQL")
48
+ schema_info: str = Field(default="", description="Database schema description")
49
+
50
+ # Query result
51
+ query_result: List[Dict[str, Any]] = Field(
52
+ default_factory=list,
53
+ description="Rows returned by the SQL query (up to 20 rows shown)"
54
+ )
55
+ query_error: Optional[str] = Field(
56
+ default=None,
57
+ description="SQL error message if query failed, null otherwise"
58
+ )
59
+ query_type: str = Field(default="explore", description="Type of query just executed")
60
+ rows_returned: int = Field(default=0, description="Number of rows returned")
61
+
62
+ # Episode progress
63
+ explore_steps_used: int = Field(default=0, description="Number of explore actions used so far")
64
+ explore_steps_remaining: int = Field(default=5, description="Explore actions remaining before forced submit")
65
+ submitted: bool = Field(default=False, description="Whether agent has submitted final answer")
66
+
67
+ # Feedback (only populated after submit)
68
+ is_correct: Optional[bool] = Field(default=None, description="Whether submitted answer is correct (after submit only)")
69
+ feedback: Optional[str] = Field(default=None, description="Feedback message after submission")
70
+ expected_row_count: Optional[int] = Field(default=None, description="How many rows correct answer has")
openenv.yaml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: sql_arena_env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
pyproject.toml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=45", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "openenv-sql-arena-env"
7
+ version = "1.0.0"
8
+ description = "SQLArenaEnv β€” Multi-step SQL reasoning environment for OpenEnv. Agents explore a database, run investigative queries, and submit a final SQL answer. Tests true SQL understanding across 50 tasks in 4 difficulty tiers."
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "openenv-core[core]>=0.2.2",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "pytest>=8.0.0",
17
+ ]
18
+
19
+ [project.scripts]
20
+ server = "sql_arena_env.server.app:main"
21
+
22
+ [tool.setuptools]
23
+ include-package-data = true
24
+ packages = ["sql_arena_env", "sql_arena_env.server"]
25
+ package-dir = { "sql_arena_env" = ".", "sql_arena_env.server" = "server" }
server/Dockerfile ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
2
+ FROM ${BASE_IMAGE} AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ RUN apt-get update && \
7
+ apt-get install -y --no-install-recommends git && \
8
+ rm -rf /var/lib/apt/lists/*
9
+
10
+ ARG BUILD_MODE=standalone
11
+ ARG ENV_NAME=sql_arena_env
12
+
13
+ COPY . /app/env
14
+
15
+ WORKDIR /app/env
16
+
17
+ RUN if ! command -v uv >/dev/null 2>&1; then \
18
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
19
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
20
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
21
+ fi
22
+
23
+ RUN --mount=type=cache,target=/root/.cache/uv \
24
+ if [ -f uv.lock ]; then \
25
+ uv sync --frozen --no-install-project --no-editable; \
26
+ else \
27
+ uv sync --no-install-project --no-editable; \
28
+ fi
29
+
30
+ RUN --mount=type=cache,target=/root/.cache/uv \
31
+ if [ -f uv.lock ]; then \
32
+ uv sync --frozen --no-editable; \
33
+ else \
34
+ uv sync --no-editable; \
35
+ fi
36
+
37
+ FROM ${BASE_IMAGE}
38
+
39
+ WORKDIR /app
40
+
41
+ COPY --from=builder /app/env/.venv /app/.venv
42
+ COPY --from=builder /app/env /app/env
43
+
44
+ ENV PATH="/app/.venv/bin:$PATH"
45
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
46
+
47
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
48
+ CMD curl -f http://localhost:8000/health || exit 1
49
+
50
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
server/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """SQLArenaEnv server package."""
server/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (190 Bytes). View file
 
server/__pycache__/sql_arena_environment.cpython-311.pyc ADDED
Binary file (18.4 kB). View file
 
server/app.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ FastAPI application for SQLArenaEnv.
3
+
4
+ Exposes SQLArenaEnvironment over HTTP and WebSocket endpoints,
5
+ fully compatible with OpenEnv EnvClient.
6
+
7
+ Endpoints:
8
+ POST /reset β€” start new episode (accepts task_id or difficulty in body)
9
+ POST /step β€” submit SQL action
10
+ GET /state β€” get episode state
11
+ GET /schema β€” action/observation schema
12
+ GET /tasks β€” list all available tasks
13
+ GET /health β€” liveness check
14
+ WS /ws β€” persistent WebSocket session (preferred for training)
15
+ """
16
+
17
+ try:
18
+ from openenv.core.env_server.http_server import create_app
19
+ except Exception as e:
20
+ raise ImportError(
21
+ "openenv-core is required. Install with: pip install openenv-core"
22
+ ) from e
23
+
24
+ try:
25
+ from ..models import SQLArenaAction, SQLArenaObservation
26
+ from .sql_arena_environment import SQLArenaEnvironment
27
+ except ModuleNotFoundError:
28
+ from models import SQLArenaAction, SQLArenaObservation
29
+ from server.sql_arena_environment import SQLArenaEnvironment
30
+
31
+
32
+ app = create_app(
33
+ SQLArenaEnvironment,
34
+ SQLArenaAction,
35
+ SQLArenaObservation,
36
+ env_name="sql_arena_env",
37
+ max_concurrent_envs=4, # support parallel grader runs
38
+ )
39
+
40
+
41
+ def main(host: str = "0.0.0.0", port: int = 8000):
42
+ import uvicorn
43
+ uvicorn.run(app, host=host, port=port)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ import argparse
48
+ parser = argparse.ArgumentParser()
49
+ parser.add_argument("--port", type=int, default=8000)
50
+ args = parser.parse_args()
51
+ main(port=args.port)
server/requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ openenv-core[core]>=0.2.2
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
server/sql_arena_environment.py ADDED
@@ -0,0 +1,382 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLArenaEnv Core Environment.
3
+
4
+ A multi-step SQL reasoning environment. The agent is given a natural language
5
+ question about a database and must:
6
+ 1. Run exploratory SQL queries to understand the schema and data (explore steps)
7
+ 2. Submit a final SQL query that answers the question (submit step)
8
+
9
+ The key novel mechanic: exploration queries are first-class actions.
10
+ Agents that explore strategically score better than agents that blindly guess.
11
+
12
+ Grading is done by comparing the agent's result set against the reference
13
+ solution's result set β€” not string matching. This means equivalent SQL
14
+ that produces the same answer is scored correctly.
15
+ """
16
+
17
+ import sqlite3
18
+ import json
19
+ from typing import Any, Dict, List, Optional, Tuple
20
+ from uuid import uuid4
21
+
22
+ from openenv.core.env_server.interfaces import Environment
23
+ from openenv.core.env_server.types import State
24
+
25
+ try:
26
+ from ..models import SQLArenaAction, SQLArenaObservation
27
+ from ..tasks import SQLTask, get_task, get_tasks_by_difficulty, ALL_TASKS
28
+ except ImportError:
29
+ from models import SQLArenaAction, SQLArenaObservation
30
+ from tasks import SQLTask, get_task, get_tasks_by_difficulty, ALL_TASKS
31
+
32
+
33
+ MAX_EXPLORE_STEPS = 5 # agent gets 5 free exploration queries
34
+ MAX_RESULT_ROWS = 20 # cap result rows shown to agent
35
+ MAX_QUERY_TIMEOUT_MS = 5000 # 5 second query timeout
36
+
37
+
38
+ class SQLArenaEnvironment(Environment):
39
+ """
40
+ Multi-step SQL reasoning environment.
41
+
42
+ Each episode:
43
+ - A task is selected (by task_id or random from difficulty tier)
44
+ - Agent explores the database with up to MAX_EXPLORE_STEPS queries
45
+ - Agent submits a final answer query
46
+ - Reward is based on correctness of the final answer
47
+
48
+ Reward structure:
49
+ - Correct answer: 1.0
50
+ - Partially correct (right columns, wrong rows or ordering): 0.4
51
+ - Wrong answer: 0.0
52
+ - Exploration step: 0.0 (no reward, just information)
53
+ - Each explore step costs -0.02 (small penalty for over-exploration)
54
+ - Syntax error on submit: -0.1
55
+ """
56
+
57
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
58
+
59
+ def __init__(self):
60
+ self._state = State(episode_id=str(uuid4()), step_count=0)
61
+ self._conn: Optional[sqlite3.Connection] = None
62
+ self._current_task: Optional[SQLTask] = None
63
+ self._explore_steps_used: int = 0
64
+ self._submitted: bool = False
65
+ self._expected_result: Optional[List[Dict]] = None
66
+ self._task_id_override: Optional[str] = None
67
+ self._difficulty_override: Optional[str] = None
68
+
69
+ # ─────────────────────────────────────────
70
+ # OpenEnv required interface
71
+ # ─────────────────────────────────────────
72
+
73
+ def reset(self, task_id: Optional[str] = None, difficulty: Optional[str] = None) -> SQLArenaObservation:
74
+ """
75
+ Start a new episode with a fresh database and task.
76
+
77
+ Args:
78
+ task_id: specific task to load (e.g. 'easy_001')
79
+ difficulty: random task from this difficulty ('easy','medium','hard','expert')
80
+ If neither given, picks a random task from any difficulty.
81
+ """
82
+ # Clean up old connection
83
+ if self._conn:
84
+ try:
85
+ self._conn.close()
86
+ except Exception:
87
+ pass
88
+
89
+ # Select task
90
+ if task_id:
91
+ task = get_task(task_id)
92
+ if not task:
93
+ task = list(ALL_TASKS.values())[0]
94
+ elif difficulty:
95
+ tasks = get_tasks_by_difficulty(difficulty)
96
+ import random
97
+ task = random.choice(tasks) if tasks else list(ALL_TASKS.values())[0]
98
+ else:
99
+ import random
100
+ task = random.choice(list(ALL_TASKS.values()))
101
+
102
+ self._current_task = task
103
+ self._explore_steps_used = 0
104
+ self._submitted = False
105
+ self._state = State(episode_id=str(uuid4()), step_count=0)
106
+
107
+ # Create fresh in-memory SQLite database
108
+ self._conn = sqlite3.connect(":memory:", check_same_thread=False)
109
+ self._conn.row_factory = sqlite3.Row
110
+
111
+ # Load schema and seed data
112
+ try:
113
+ self._conn.executescript(task.schema_sql)
114
+ self._conn.executescript(task.seed_sql)
115
+ self._conn.commit()
116
+ except Exception as e:
117
+ return self._make_obs(
118
+ query_error=f"Environment setup error: {e}",
119
+ query_result=[],
120
+ query_type="reset",
121
+ )
122
+
123
+ # Pre-compute expected result
124
+ self._expected_result = self._run_query_safe(task.solution_sql)
125
+
126
+ return self._make_obs(
127
+ query_result=[],
128
+ query_type="reset",
129
+ query_error=None,
130
+ )
131
+
132
+ def step(self, action: SQLArenaAction) -> SQLArenaObservation:
133
+ """
134
+ Execute a SQL action.
135
+
136
+ action.query_type == "explore": run query, return results, no episode end
137
+ action.query_type == "submit": run query, compare to answer, end episode
138
+ """
139
+ if self._current_task is None:
140
+ return self._make_obs(
141
+ query_error="Environment not initialized. Call reset() first.",
142
+ query_result=[],
143
+ query_type=action.query_type,
144
+ done=True,
145
+ reward=-1.0,
146
+ )
147
+
148
+ if self._submitted:
149
+ return self._make_obs(
150
+ query_error="Episode already ended. Call reset() to start a new episode.",
151
+ query_result=[],
152
+ query_type=action.query_type,
153
+ done=True,
154
+ reward=0.0,
155
+ )
156
+
157
+ self._state.step_count += 1
158
+ sql = action.sql.strip()
159
+ query_type = action.query_type.lower()
160
+
161
+ # ── EXPLORE action ──────────────────────────────
162
+ if query_type == "explore":
163
+ # Force submit if explore budget exhausted
164
+ if self._explore_steps_used >= MAX_EXPLORE_STEPS:
165
+ query_type = "submit"
166
+ else:
167
+ self._explore_steps_used += 1
168
+ result, error = self._execute_safe(sql)
169
+ # Small cost per explore step to discourage random fishing
170
+ reward = -0.02
171
+
172
+ return self._make_obs(
173
+ query_result=result,
174
+ query_error=error,
175
+ query_type="explore",
176
+ done=False,
177
+ reward=reward,
178
+ )
179
+
180
+ # ── SUBMIT action ───────────────────────────────
181
+ self._submitted = True
182
+ result, error = self._execute_safe(sql)
183
+
184
+ if error:
185
+ # Syntax error on final submit
186
+ reward = -0.1
187
+ feedback = f"SQL error on submission: {error}. Correct your query."
188
+ return self._make_obs(
189
+ query_result=[],
190
+ query_error=error,
191
+ query_type="submit",
192
+ done=True,
193
+ reward=reward,
194
+ is_correct=False,
195
+ feedback=feedback,
196
+ )
197
+
198
+ # Grade the result
199
+ is_correct, partial, feedback = self._grade(result)
200
+ if is_correct:
201
+ reward = 1.0
202
+ elif partial:
203
+ reward = 0.4
204
+ else:
205
+ reward = 0.0
206
+
207
+ return self._make_obs(
208
+ query_result=result,
209
+ query_error=None,
210
+ query_type="submit",
211
+ done=True,
212
+ reward=reward,
213
+ is_correct=is_correct,
214
+ feedback=feedback,
215
+ )
216
+
217
+ @property
218
+ def state(self) -> State:
219
+ return self._state
220
+
221
+ # ─────────────────────────────────────────
222
+ # Internal helpers
223
+ # ─────────────────────────────────────────
224
+
225
+ def _execute_safe(self, sql: str) -> Tuple[List[Dict[str, Any]], Optional[str]]:
226
+ """Execute SQL and return (rows, error). Caps rows at MAX_RESULT_ROWS."""
227
+ if not self._conn:
228
+ return [], "Database not initialized"
229
+ try:
230
+ # Block dangerous operations
231
+ sql_upper = sql.upper().strip()
232
+ dangerous = ["DROP ", "ALTER ", "TRUNCATE ", "PRAGMA ", "ATTACH ", "DETACH "]
233
+ if any(sql_upper.startswith(d) for d in dangerous):
234
+ return [], "Operation not permitted in this environment"
235
+
236
+ cursor = self._conn.execute(sql)
237
+ rows = cursor.fetchmany(MAX_RESULT_ROWS + 1)
238
+ truncated = len(rows) > MAX_RESULT_ROWS
239
+ rows = rows[:MAX_RESULT_ROWS]
240
+ result = [dict(row) for row in rows]
241
+ if truncated:
242
+ result.append({"__info__": f"Results truncated to {MAX_RESULT_ROWS} rows"})
243
+ return result, None
244
+ except sqlite3.Error as e:
245
+ return [], str(e)
246
+ except Exception as e:
247
+ return [], f"Unexpected error: {e}"
248
+
249
+ def _run_query_safe(self, sql: str) -> Optional[List[Dict[str, Any]]]:
250
+ """Run reference solution, return all rows."""
251
+ if not self._conn:
252
+ return None
253
+ try:
254
+ cursor = self._conn.execute(sql)
255
+ rows = cursor.fetchall()
256
+ return [dict(row) for row in rows]
257
+ except Exception:
258
+ return None
259
+
260
+ def _grade(self, agent_result: List[Dict]) -> Tuple[bool, bool, str]:
261
+ """
262
+ Grade agent's result against expected result.
263
+ Returns (is_correct, is_partial, feedback_message).
264
+
265
+ Grading logic:
266
+ - Full credit: same rows, same values (order-insensitive for most tasks)
267
+ - Partial credit: same column names but different rows, OR right count wrong values
268
+ - No credit: completely wrong
269
+ """
270
+ expected = self._expected_result
271
+ if expected is None:
272
+ return False, False, "Could not compute expected result. Contact organizers."
273
+
274
+ # Normalize both result sets for comparison
275
+ def normalize(rows: List[Dict]) -> List[str]:
276
+ """Convert rows to sorted list of JSON strings for comparison."""
277
+ normalized = []
278
+ for row in rows:
279
+ # Remove __info__ markers
280
+ clean = {k: v for k, v in row.items() if not k.startswith("__")}
281
+ # Round floats to 2 decimal places to avoid floating point issues
282
+ rounded = {}
283
+ for k, v in clean.items():
284
+ if isinstance(v, float):
285
+ rounded[k] = round(v, 2)
286
+ else:
287
+ rounded[k] = v
288
+ normalized.append(json.dumps(rounded, sort_keys=True))
289
+ return sorted(normalized)
290
+
291
+ # Filter out info rows from agent result
292
+ agent_clean = [r for r in agent_result if not any(k.startswith("__") for k in r.keys())]
293
+
294
+ expected_norm = normalize(expected)
295
+ agent_norm = normalize(agent_clean)
296
+
297
+ # Full correct
298
+ if expected_norm == agent_norm:
299
+ return True, False, f"βœ“ Correct! Your query returned the exact expected result ({len(expected)} rows)."
300
+
301
+ # Check partial credit conditions
302
+ # Same number of rows?
303
+ same_count = len(agent_clean) == len(expected)
304
+
305
+ # Same column names?
306
+ exp_cols = set(expected[0].keys()) if expected else set()
307
+ agent_cols = set(agent_clean[0].keys()) if agent_clean else set()
308
+ same_cols = exp_cols == agent_cols
309
+
310
+ # How many rows match?
311
+ matching_rows = len(set(expected_norm) & set(agent_norm))
312
+ match_pct = matching_rows / max(len(expected_norm), 1) * 100
313
+
314
+ if same_cols and same_count and match_pct >= 50:
315
+ return False, True, (
316
+ f"Partial credit. Correct columns, {same_count} rows, "
317
+ f"but {matching_rows}/{len(expected)} rows match exactly. "
318
+ f"Check your WHERE conditions or aggregation."
319
+ )
320
+
321
+ if same_cols and not same_count:
322
+ return False, True, (
323
+ f"Partial credit. Correct columns but wrong row count: "
324
+ f"got {len(agent_clean)}, expected {len(expected)}. "
325
+ f"Check your filters."
326
+ ) if match_pct >= 30 else (False, False, (
327
+ f"Wrong answer. Got {len(agent_clean)} rows, expected {len(expected)}. "
328
+ f"Expected columns: {sorted(exp_cols)}."
329
+ ))
330
+
331
+ feedback = (
332
+ f"Wrong answer. Expected {len(expected)} rows with columns {sorted(exp_cols)}. "
333
+ f"You returned {len(agent_clean)} rows"
334
+ + (f" with columns {sorted(agent_cols)}." if agent_clean else " (empty result).")
335
+ )
336
+ return False, False, feedback
337
+
338
+ def _make_obs(
339
+ self,
340
+ query_result: List[Dict],
341
+ query_type: str,
342
+ query_error: Optional[str] = None,
343
+ done: bool = False,
344
+ reward: float = 0.0,
345
+ is_correct: Optional[bool] = None,
346
+ feedback: Optional[str] = None,
347
+ ) -> SQLArenaObservation:
348
+ """Construct a full observation."""
349
+ task = self._current_task
350
+ expected_count = len(self._expected_result) if self._expected_result else None
351
+
352
+ return SQLArenaObservation(
353
+ # Task context
354
+ task_id=task.task_id if task else "",
355
+ difficulty=task.difficulty if task else "easy",
356
+ question=task.question if task else "",
357
+ schema_info=task.schema_description if task else "",
358
+
359
+ # Query result
360
+ query_result=query_result,
361
+ query_error=query_error,
362
+ query_type=query_type,
363
+ rows_returned=len([r for r in query_result if not any(k.startswith("__") for k in r.keys())]),
364
+
365
+ # Episode progress
366
+ explore_steps_used=self._explore_steps_used,
367
+ explore_steps_remaining=max(0, MAX_EXPLORE_STEPS - self._explore_steps_used),
368
+ submitted=self._submitted,
369
+
370
+ # Feedback after submit
371
+ is_correct=is_correct,
372
+ feedback=feedback,
373
+ expected_row_count=expected_count,
374
+
375
+ # OpenEnv base fields
376
+ done=done,
377
+ reward=reward,
378
+ metadata={
379
+ "episode_id": self._state.episode_id,
380
+ "step_count": self._state.step_count,
381
+ },
382
+ )
tasks.py ADDED
@@ -0,0 +1,1348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLArenaEnv Task Library.
3
+
4
+ 50 hand-crafted SQL tasks across 4 difficulty tiers.
5
+ Each task includes:
6
+ - A SQLite schema (CREATE TABLE statements)
7
+ - Seed data (INSERT statements)
8
+ - A natural language question
9
+ - A validator function that checks agent's result against correct answer
10
+
11
+ Difficulty tiers:
12
+ EASY (10 tasks) β€” single table, basic SELECT/WHERE/ORDER BY
13
+ MEDIUM (15 tasks) β€” JOINs, GROUP BY, HAVING, subqueries
14
+ HARD (15 tasks) β€” window functions, CTEs, multi-join, aggregation
15
+ EXPERT (10 tasks) β€” correlated subqueries, recursive CTEs, complex analytics
16
+ """
17
+
18
+ from typing import Any, Callable, Dict, List, Optional
19
+ from dataclasses import dataclass, field
20
+
21
+
22
+ @dataclass
23
+ class SQLTask:
24
+ task_id: str
25
+ difficulty: str # easy / medium / hard / expert
26
+ question: str
27
+ schema_sql: str # CREATE TABLE statements
28
+ seed_sql: str # INSERT statements
29
+ solution_sql: str # Reference solution (for generating expected answer)
30
+ schema_description: str # Human-readable schema hint for agent
31
+ validator: Optional[Callable] = None # custom validator, None = use default row comparison
32
+
33
+
34
+ # ─────────────────────────────────────────────
35
+ # EASY TASKS β€” single table operations
36
+ # ─────────────────────────────────────────────
37
+
38
+ EASY_TASKS = [
39
+ SQLTask(
40
+ task_id="easy_001",
41
+ difficulty="easy",
42
+ question="List all employees whose salary is greater than 70000, ordered by salary descending.",
43
+ schema_sql="""
44
+ CREATE TABLE employees (
45
+ id INTEGER PRIMARY KEY,
46
+ name TEXT NOT NULL,
47
+ department TEXT,
48
+ salary REAL,
49
+ hire_date TEXT
50
+ );""",
51
+ seed_sql="""
52
+ INSERT INTO employees VALUES
53
+ (1,'Alice','Engineering',95000,'2020-01-15'),
54
+ (2,'Bob','Marketing',55000,'2019-03-20'),
55
+ (3,'Charlie','Engineering',82000,'2021-06-01'),
56
+ (4,'Diana','HR',47000,'2018-11-10'),
57
+ (5,'Eve','Engineering',110000,'2017-08-25'),
58
+ (6,'Frank','Marketing',72000,'2022-02-14'),
59
+ (7,'Grace','HR',68000,'2020-09-30'),
60
+ (8,'Hank','Engineering',78000,'2019-12-05');""",
61
+ solution_sql="SELECT id, name, department, salary FROM employees WHERE salary > 70000 ORDER BY salary DESC;",
62
+ schema_description="employees(id, name, department, salary, hire_date)",
63
+ ),
64
+
65
+ SQLTask(
66
+ task_id="easy_002",
67
+ difficulty="easy",
68
+ question="How many products are there in each category? Show category and count, ordered by count descending.",
69
+ schema_sql="""
70
+ CREATE TABLE products (
71
+ product_id INTEGER PRIMARY KEY,
72
+ name TEXT,
73
+ category TEXT,
74
+ price REAL,
75
+ stock INTEGER
76
+ );""",
77
+ seed_sql="""
78
+ INSERT INTO products VALUES
79
+ (1,'Laptop','Electronics',999.99,50),
80
+ (2,'Phone','Electronics',699.99,120),
81
+ (3,'Desk','Furniture',249.99,30),
82
+ (4,'Chair','Furniture',189.99,75),
83
+ (5,'Tablet','Electronics',449.99,60),
84
+ (6,'Bookshelf','Furniture',129.99,40),
85
+ (7,'Headphones','Electronics',149.99,200),
86
+ (8,'Lamp','Furniture',59.99,90),
87
+ (9,'Monitor','Electronics',399.99,45),
88
+ (10,'Keyboard','Electronics',79.99,150);""",
89
+ solution_sql="SELECT category, COUNT(*) as count FROM products GROUP BY category ORDER BY count DESC;",
90
+ schema_description="products(product_id, name, category, price, stock)",
91
+ ),
92
+
93
+ SQLTask(
94
+ task_id="easy_003",
95
+ difficulty="easy",
96
+ question="Find the top 5 most expensive products. Show name and price.",
97
+ schema_sql="""
98
+ CREATE TABLE products (
99
+ product_id INTEGER PRIMARY KEY,
100
+ name TEXT,
101
+ category TEXT,
102
+ price REAL,
103
+ stock INTEGER
104
+ );""",
105
+ seed_sql="""
106
+ INSERT INTO products VALUES
107
+ (1,'Laptop','Electronics',999.99,50),
108
+ (2,'Phone','Electronics',699.99,120),
109
+ (3,'Desk','Furniture',249.99,30),
110
+ (4,'Chair','Furniture',189.99,75),
111
+ (5,'Tablet','Electronics',449.99,60),
112
+ (6,'Bookshelf','Furniture',129.99,40),
113
+ (7,'Headphones','Electronics',149.99,200),
114
+ (8,'Lamp','Furniture',59.99,90),
115
+ (9,'Monitor','Electronics',399.99,45),
116
+ (10,'Keyboard','Electronics',79.99,150);""",
117
+ solution_sql="SELECT name, price FROM products ORDER BY price DESC LIMIT 5;",
118
+ schema_description="products(product_id, name, category, price, stock)",
119
+ ),
120
+
121
+ SQLTask(
122
+ task_id="easy_004",
123
+ difficulty="easy",
124
+ question="Find all students who scored above 85 in Mathematics. Show name and score.",
125
+ schema_sql="""
126
+ CREATE TABLE students (
127
+ student_id INTEGER PRIMARY KEY,
128
+ name TEXT,
129
+ subject TEXT,
130
+ score INTEGER,
131
+ grade TEXT
132
+ );""",
133
+ seed_sql="""
134
+ INSERT INTO students VALUES
135
+ (1,'Arjun','Mathematics',92,'A'),
136
+ (2,'Priya','Mathematics',78,'B'),
137
+ (3,'Rahul','Science',88,'A'),
138
+ (4,'Sneha','Mathematics',95,'A'),
139
+ (5,'Vikram','Science',72,'C'),
140
+ (6,'Ananya','Mathematics',65,'D'),
141
+ (7,'Karan','Mathematics',89,'A'),
142
+ (8,'Divya','Science',91,'A'),
143
+ (9,'Rohit','Mathematics',55,'F'),
144
+ (10,'Meera','Mathematics',87,'A');""",
145
+ solution_sql="SELECT name, score FROM students WHERE subject='Mathematics' AND score > 85 ORDER BY score DESC;",
146
+ schema_description="students(student_id, name, subject, score, grade)",
147
+ ),
148
+
149
+ SQLTask(
150
+ task_id="easy_005",
151
+ difficulty="easy",
152
+ question="What is the total revenue (price * quantity) from all orders? Return a single number called total_revenue.",
153
+ schema_sql="""
154
+ CREATE TABLE orders (
155
+ order_id INTEGER PRIMARY KEY,
156
+ customer TEXT,
157
+ product TEXT,
158
+ price REAL,
159
+ quantity INTEGER,
160
+ order_date TEXT
161
+ );""",
162
+ seed_sql="""
163
+ INSERT INTO orders VALUES
164
+ (1,'Alice','Laptop',999.99,2,'2024-01-10'),
165
+ (2,'Bob','Phone',699.99,1,'2024-01-11'),
166
+ (3,'Charlie','Tablet',449.99,3,'2024-01-12'),
167
+ (4,'Diana','Headphones',149.99,5,'2024-01-13'),
168
+ (5,'Eve','Monitor',399.99,1,'2024-01-14'),
169
+ (6,'Frank','Keyboard',79.99,4,'2024-01-15'),
170
+ (7,'Grace','Laptop',999.99,1,'2024-01-16'),
171
+ (8,'Hank','Phone',699.99,2,'2024-01-17');""",
172
+ solution_sql="SELECT ROUND(SUM(price * quantity), 2) as total_revenue FROM orders;",
173
+ schema_description="orders(order_id, customer, product, price, quantity, order_date)",
174
+ ),
175
+
176
+ SQLTask(
177
+ task_id="easy_006",
178
+ difficulty="easy",
179
+ question="List all cities that have more than 1 million population, ordered alphabetically.",
180
+ schema_sql="""
181
+ CREATE TABLE cities (
182
+ city_id INTEGER PRIMARY KEY,
183
+ name TEXT,
184
+ country TEXT,
185
+ population INTEGER,
186
+ area_km2 REAL
187
+ );""",
188
+ seed_sql="""
189
+ INSERT INTO cities VALUES
190
+ (1,'Mumbai','India',20667656,603.4),
191
+ (2,'Delhi','India',32941000,1484.0),
192
+ (3,'Kolkata','India',14850000,206.1),
193
+ (4,'Chennai','India',10971000,426.0),
194
+ (5,'Pune','India',3124000,331.3),
195
+ (6,'Hyderabad','India',10534000,650.0),
196
+ (7,'Ahmedabad','India',8450000,475.0),
197
+ (8,'Jaipur','India',3766000,467.0),
198
+ (9,'Surat','India',6936000,326.5),
199
+ (10,'Visakhapatnam','India',2035000,681.96);""",
200
+ solution_sql="SELECT name, population FROM cities WHERE population > 1000000 ORDER BY name ASC;",
201
+ schema_description="cities(city_id, name, country, population, area_km2)",
202
+ ),
203
+
204
+ SQLTask(
205
+ task_id="easy_007",
206
+ difficulty="easy",
207
+ question="Find the average salary by department. Show department and avg_salary rounded to 2 decimals.",
208
+ schema_sql="""
209
+ CREATE TABLE employees (
210
+ id INTEGER PRIMARY KEY,
211
+ name TEXT NOT NULL,
212
+ department TEXT,
213
+ salary REAL,
214
+ hire_date TEXT
215
+ );""",
216
+ seed_sql="""
217
+ INSERT INTO employees VALUES
218
+ (1,'Alice','Engineering',95000,'2020-01-15'),
219
+ (2,'Bob','Marketing',55000,'2019-03-20'),
220
+ (3,'Charlie','Engineering',82000,'2021-06-01'),
221
+ (4,'Diana','HR',47000,'2018-11-10'),
222
+ (5,'Eve','Engineering',110000,'2017-08-25'),
223
+ (6,'Frank','Marketing',72000,'2022-02-14'),
224
+ (7,'Grace','HR',68000,'2020-09-30'),
225
+ (8,'Hank','Engineering',78000,'2019-12-05'),
226
+ (9,'Ivy','Marketing',61000,'2021-03-15'),
227
+ (10,'Jack','HR',52000,'2022-07-01');""",
228
+ solution_sql="SELECT department, ROUND(AVG(salary),2) as avg_salary FROM employees GROUP BY department ORDER BY avg_salary DESC;",
229
+ schema_description="employees(id, name, department, salary, hire_date)",
230
+ ),
231
+
232
+ SQLTask(
233
+ task_id="easy_008",
234
+ difficulty="easy",
235
+ question="Find all books published after 2010 with rating above 4.0. Show title, author, and rating.",
236
+ schema_sql="""
237
+ CREATE TABLE books (
238
+ book_id INTEGER PRIMARY KEY,
239
+ title TEXT,
240
+ author TEXT,
241
+ year INTEGER,
242
+ rating REAL,
243
+ genre TEXT
244
+ );""",
245
+ seed_sql="""
246
+ INSERT INTO books VALUES
247
+ (1,'The Pragmatic Programmer','Hunt & Thomas',1999,4.8,'Tech'),
248
+ (2,'Clean Code','Robert Martin',2008,4.6,'Tech'),
249
+ (3,'Thinking Fast and Slow','Daniel Kahneman',2011,4.5,'Psychology'),
250
+ (4,'Atomic Habits','James Clear',2018,4.8,'Self-Help'),
251
+ (5,'Deep Work','Cal Newport',2016,4.4,'Self-Help'),
252
+ (6,'The Lean Startup','Eric Ries',2011,4.2,'Business'),
253
+ (7,'Zero to One','Peter Thiel',2014,4.3,'Business'),
254
+ (8,'Dune','Frank Herbert',1965,4.7,'Fiction'),
255
+ (9,'Project Hail Mary','Andy Weir',2021,4.9,'Fiction'),
256
+ (10,'The Midnight Library','Matt Haig',2020,4.2,'Fiction');""",
257
+ solution_sql="SELECT title, author, rating FROM books WHERE year > 2010 AND rating > 4.0 ORDER BY rating DESC;",
258
+ schema_description="books(book_id, title, author, year, rating, genre)",
259
+ ),
260
+
261
+ SQLTask(
262
+ task_id="easy_009",
263
+ difficulty="easy",
264
+ question="Count how many employees were hired each year. Show hire_year and count, ordered by year.",
265
+ schema_sql="""
266
+ CREATE TABLE employees (
267
+ id INTEGER PRIMARY KEY,
268
+ name TEXT NOT NULL,
269
+ department TEXT,
270
+ salary REAL,
271
+ hire_date TEXT
272
+ );""",
273
+ seed_sql="""
274
+ INSERT INTO employees VALUES
275
+ (1,'Alice','Engineering',95000,'2020-01-15'),
276
+ (2,'Bob','Marketing',55000,'2019-03-20'),
277
+ (3,'Charlie','Engineering',82000,'2021-06-01'),
278
+ (4,'Diana','HR',47000,'2018-11-10'),
279
+ (5,'Eve','Engineering',110000,'2017-08-25'),
280
+ (6,'Frank','Marketing',72000,'2022-02-14'),
281
+ (7,'Grace','HR',68000,'2020-09-30'),
282
+ (8,'Hank','Engineering',78000,'2019-12-05'),
283
+ (9,'Ivy','Marketing',61000,'2021-03-15'),
284
+ (10,'Jack','HR',52000,'2022-07-01');""",
285
+ solution_sql="SELECT SUBSTR(hire_date,1,4) as hire_year, COUNT(*) as count FROM employees GROUP BY hire_year ORDER BY hire_year;",
286
+ schema_description="employees(id, name, department, salary, hire_date β€” format YYYY-MM-DD)",
287
+ ),
288
+
289
+ SQLTask(
290
+ task_id="easy_010",
291
+ difficulty="easy",
292
+ question="Find the minimum and maximum price for each product category.",
293
+ schema_sql="""
294
+ CREATE TABLE products (
295
+ product_id INTEGER PRIMARY KEY,
296
+ name TEXT,
297
+ category TEXT,
298
+ price REAL,
299
+ stock INTEGER
300
+ );""",
301
+ seed_sql="""
302
+ INSERT INTO products VALUES
303
+ (1,'Laptop','Electronics',999.99,50),
304
+ (2,'Phone','Electronics',699.99,120),
305
+ (3,'Desk','Furniture',249.99,30),
306
+ (4,'Chair','Furniture',189.99,75),
307
+ (5,'Tablet','Electronics',449.99,60),
308
+ (6,'Bookshelf','Furniture',129.99,40),
309
+ (7,'Headphones','Electronics',149.99,200),
310
+ (8,'Lamp','Furniture',59.99,90),
311
+ (9,'Monitor','Electronics',399.99,45),
312
+ (10,'Keyboard','Electronics',79.99,150);""",
313
+ solution_sql="SELECT category, MIN(price) as min_price, MAX(price) as max_price FROM products GROUP BY category ORDER BY category;",
314
+ schema_description="products(product_id, name, category, price, stock)",
315
+ ),
316
+ ]
317
+
318
+
319
+ # ─────────────────────────────────────────────
320
+ # MEDIUM TASKS β€” JOINs, GROUP BY, HAVING
321
+ # ─────────────────────────────────────────────
322
+
323
+ MEDIUM_SCHEMA_ECOMMERCE = """
324
+ CREATE TABLE customers (
325
+ customer_id INTEGER PRIMARY KEY,
326
+ name TEXT,
327
+ email TEXT,
328
+ city TEXT,
329
+ joined_date TEXT
330
+ );
331
+ CREATE TABLE orders (
332
+ order_id INTEGER PRIMARY KEY,
333
+ customer_id INTEGER,
334
+ order_date TEXT,
335
+ status TEXT,
336
+ FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
337
+ );
338
+ CREATE TABLE order_items (
339
+ item_id INTEGER PRIMARY KEY,
340
+ order_id INTEGER,
341
+ product_name TEXT,
342
+ quantity INTEGER,
343
+ unit_price REAL,
344
+ FOREIGN KEY (order_id) REFERENCES orders(order_id)
345
+ );"""
346
+
347
+ MEDIUM_SEED_ECOMMERCE = """
348
+ INSERT INTO customers VALUES
349
+ (1,'Arjun Sharma','arjun@email.com','Mumbai','2022-01-15'),
350
+ (2,'Priya Patel','priya@email.com','Delhi','2022-03-20'),
351
+ (3,'Rahul Gupta','rahul@email.com','Bangalore','2021-11-05'),
352
+ (4,'Sneha Reddy','sneha@email.com','Hyderabad','2023-02-10'),
353
+ (5,'Vikram Singh','vikram@email.com','Chennai','2021-08-25'),
354
+ (6,'Ananya Joshi','ananya@email.com','Pune','2022-07-14'),
355
+ (7,'Karan Mehta','karan@email.com','Mumbai','2023-01-30'),
356
+ (8,'Divya Nair','divya@email.com','Bangalore','2022-05-18');
357
+
358
+ INSERT INTO orders VALUES
359
+ (101,1,'2024-01-10','completed'),
360
+ (102,1,'2024-02-15','completed'),
361
+ (103,2,'2024-01-20','completed'),
362
+ (104,3,'2024-01-25','pending'),
363
+ (105,3,'2024-02-28','completed'),
364
+ (106,4,'2024-03-05','completed'),
365
+ (107,5,'2024-01-08','cancelled'),
366
+ (108,5,'2024-03-12','completed'),
367
+ (109,6,'2024-02-20','completed'),
368
+ (110,7,'2024-03-01','pending'),
369
+ (111,8,'2024-01-30','completed'),
370
+ (112,8,'2024-02-25','completed');
371
+
372
+ INSERT INTO order_items VALUES
373
+ (1,101,'Laptop',1,999.99),
374
+ (2,101,'Mouse',2,29.99),
375
+ (3,102,'Phone',1,699.99),
376
+ (4,103,'Tablet',2,449.99),
377
+ (5,104,'Headphones',3,149.99),
378
+ (6,105,'Monitor',1,399.99),
379
+ (7,106,'Keyboard',2,79.99),
380
+ (8,107,'Laptop',1,999.99),
381
+ (9,108,'Phone',1,699.99),
382
+ (10,109,'Desk',1,249.99),
383
+ (11,110,'Chair',2,189.99),
384
+ (12,111,'Laptop',1,999.99),
385
+ (13,111,'Tablet',1,449.99),
386
+ (14,112,'Headphones',2,149.99);"""
387
+
388
+ MEDIUM_SCHEMA_HR = """
389
+ CREATE TABLE departments (
390
+ dept_id INTEGER PRIMARY KEY,
391
+ dept_name TEXT,
392
+ manager_id INTEGER,
393
+ budget REAL
394
+ );
395
+ CREATE TABLE employees (
396
+ emp_id INTEGER PRIMARY KEY,
397
+ name TEXT,
398
+ dept_id INTEGER,
399
+ salary REAL,
400
+ hire_date TEXT,
401
+ manager_id INTEGER,
402
+ FOREIGN KEY (dept_id) REFERENCES departments(dept_id)
403
+ );"""
404
+
405
+ MEDIUM_SEED_HR = """
406
+ INSERT INTO departments VALUES
407
+ (1,'Engineering',5,5000000),
408
+ (2,'Marketing',8,2000000),
409
+ (3,'HR',12,1500000),
410
+ (4,'Finance',15,3000000),
411
+ (5,'Operations',18,2500000);
412
+
413
+ INSERT INTO employees VALUES
414
+ (1,'Alice Chen',1,95000,'2020-01-15',5),
415
+ (2,'Bob Kumar',1,82000,'2021-06-01',5),
416
+ (3,'Charlie Roy',1,78000,'2019-12-05',5),
417
+ (4,'Diana Shah',2,72000,'2022-02-14',8),
418
+ (5,'Eve Patel',1,110000,'2017-08-25',NULL),
419
+ (6,'Frank Joshi',2,55000,'2019-03-20',8),
420
+ (7,'Grace Singh',3,68000,'2020-09-30',12),
421
+ (8,'Hank Mehta',2,88000,'2018-06-15',NULL),
422
+ (9,'Ivy Reddy',3,52000,'2022-07-01',12),
423
+ (10,'Jack Nair',3,47000,'2018-11-10',12),
424
+ (11,'Kara Gupta',4,91000,'2019-04-22',15),
425
+ (12,'Leo Sharma',3,75000,'2016-03-10',NULL),
426
+ (13,'Mia Verma',4,83000,'2020-08-14',15),
427
+ (14,'Nia Bose',5,69000,'2021-01-20',18),
428
+ (15,'Omar Das',4,102000,'2015-11-30',NULL),
429
+ (16,'Pia Iyer',5,61000,'2022-03-25',18),
430
+ (17,'Quinn Rao',1,74000,'2021-09-08',5),
431
+ (18,'Rita Pillai',5,95000,'2016-07-12',NULL);"""
432
+
433
+ MEDIUM_TASKS = [
434
+ SQLTask(
435
+ task_id="medium_001",
436
+ difficulty="medium",
437
+ question="Find customers who have placed more than 1 completed order. Show customer name and order count.",
438
+ schema_sql=MEDIUM_SCHEMA_ECOMMERCE,
439
+ seed_sql=MEDIUM_SEED_ECOMMERCE,
440
+ solution_sql="""
441
+ SELECT c.name, COUNT(o.order_id) as order_count
442
+ FROM customers c
443
+ JOIN orders o ON c.customer_id = o.customer_id
444
+ WHERE o.status = 'completed'
445
+ GROUP BY c.customer_id, c.name
446
+ HAVING COUNT(o.order_id) > 1
447
+ ORDER BY order_count DESC;""",
448
+ schema_description="customers(customer_id, name, email, city, joined_date), orders(order_id, customer_id, order_date, status), order_items(item_id, order_id, product_name, quantity, unit_price)",
449
+ ),
450
+
451
+ SQLTask(
452
+ task_id="medium_002",
453
+ difficulty="medium",
454
+ question="Calculate total spending per customer (only completed orders). Show customer name and total_spent, ordered by total_spent descending.",
455
+ schema_sql=MEDIUM_SCHEMA_ECOMMERCE,
456
+ seed_sql=MEDIUM_SEED_ECOMMERCE,
457
+ solution_sql="""
458
+ SELECT c.name, ROUND(SUM(oi.quantity * oi.unit_price), 2) as total_spent
459
+ FROM customers c
460
+ JOIN orders o ON c.customer_id = o.customer_id
461
+ JOIN order_items oi ON o.order_id = oi.order_id
462
+ WHERE o.status = 'completed'
463
+ GROUP BY c.customer_id, c.name
464
+ ORDER BY total_spent DESC;""",
465
+ schema_description="customers(customer_id, name, email, city, joined_date), orders(order_id, customer_id, order_date, status), order_items(item_id, order_id, product_name, quantity, unit_price)",
466
+ ),
467
+
468
+ SQLTask(
469
+ task_id="medium_003",
470
+ difficulty="medium",
471
+ question="Find the most popular product (by total quantity sold across all completed orders).",
472
+ schema_sql=MEDIUM_SCHEMA_ECOMMERCE,
473
+ seed_sql=MEDIUM_SEED_ECOMMERCE,
474
+ solution_sql="""
475
+ SELECT oi.product_name, SUM(oi.quantity) as total_sold
476
+ FROM order_items oi
477
+ JOIN orders o ON oi.order_id = o.order_id
478
+ WHERE o.status = 'completed'
479
+ GROUP BY oi.product_name
480
+ ORDER BY total_sold DESC
481
+ LIMIT 1;""",
482
+ schema_description="customers(customer_id, name, email, city, joined_date), orders(order_id, customer_id, order_date, status), order_items(item_id, order_id, product_name, quantity, unit_price)",
483
+ ),
484
+
485
+ SQLTask(
486
+ task_id="medium_004",
487
+ difficulty="medium",
488
+ question="Find departments where average salary exceeds 80000. Show dept_name and avg_salary.",
489
+ schema_sql=MEDIUM_SCHEMA_HR,
490
+ seed_sql=MEDIUM_SEED_HR,
491
+ solution_sql="""
492
+ SELECT d.dept_name, ROUND(AVG(e.salary), 2) as avg_salary
493
+ FROM departments d
494
+ JOIN employees e ON d.dept_id = e.dept_id
495
+ GROUP BY d.dept_id, d.dept_name
496
+ HAVING AVG(e.salary) > 80000
497
+ ORDER BY avg_salary DESC;""",
498
+ schema_description="departments(dept_id, dept_name, manager_id, budget), employees(emp_id, name, dept_id, salary, hire_date, manager_id)",
499
+ ),
500
+
501
+ SQLTask(
502
+ task_id="medium_005",
503
+ difficulty="medium",
504
+ question="For each department, find the highest-paid employee. Show dept_name, employee name, and salary.",
505
+ schema_sql=MEDIUM_SCHEMA_HR,
506
+ seed_sql=MEDIUM_SEED_HR,
507
+ solution_sql="""
508
+ SELECT d.dept_name, e.name, e.salary
509
+ FROM employees e
510
+ JOIN departments d ON e.dept_id = d.dept_id
511
+ WHERE e.salary = (
512
+ SELECT MAX(e2.salary) FROM employees e2 WHERE e2.dept_id = e.dept_id
513
+ )
514
+ ORDER BY e.salary DESC;""",
515
+ schema_description="departments(dept_id, dept_name, manager_id, budget), employees(emp_id, name, dept_id, salary, hire_date, manager_id)",
516
+ ),
517
+
518
+ SQLTask(
519
+ task_id="medium_006",
520
+ difficulty="medium",
521
+ question="List customers from Mumbai or Bangalore who have at least one order. Show name and city.",
522
+ schema_sql=MEDIUM_SCHEMA_ECOMMERCE,
523
+ seed_sql=MEDIUM_SEED_ECOMMERCE,
524
+ solution_sql="""
525
+ SELECT DISTINCT c.name, c.city
526
+ FROM customers c
527
+ JOIN orders o ON c.customer_id = o.customer_id
528
+ WHERE c.city IN ('Mumbai','Bangalore')
529
+ ORDER BY c.city, c.name;""",
530
+ schema_description="customers(customer_id, name, email, city, joined_date), orders(order_id, customer_id, order_date, status), order_items(item_id, order_id, product_name, quantity, unit_price)",
531
+ ),
532
+
533
+ SQLTask(
534
+ task_id="medium_007",
535
+ difficulty="medium",
536
+ question="Find employees who earn more than their department's average salary. Show name, salary, and dept_name.",
537
+ schema_sql=MEDIUM_SCHEMA_HR,
538
+ seed_sql=MEDIUM_SEED_HR,
539
+ solution_sql="""
540
+ SELECT e.name, e.salary, d.dept_name
541
+ FROM employees e
542
+ JOIN departments d ON e.dept_id = d.dept_id
543
+ WHERE e.salary > (
544
+ SELECT AVG(e2.salary) FROM employees e2 WHERE e2.dept_id = e.dept_id
545
+ )
546
+ ORDER BY d.dept_name, e.salary DESC;""",
547
+ schema_description="departments(dept_id, dept_name, manager_id, budget), employees(emp_id, name, dept_id, salary, hire_date, manager_id)",
548
+ ),
549
+
550
+ SQLTask(
551
+ task_id="medium_008",
552
+ difficulty="medium",
553
+ question="Calculate the cancellation rate per customer (cancelled orders / total orders). Show only customers with cancellation rate > 0. Show name and cancellation_rate rounded to 2 decimals.",
554
+ schema_sql=MEDIUM_SCHEMA_ECOMMERCE,
555
+ seed_sql=MEDIUM_SEED_ECOMMERCE,
556
+ solution_sql="""
557
+ SELECT c.name,
558
+ ROUND(CAST(SUM(CASE WHEN o.status='cancelled' THEN 1 ELSE 0 END) AS REAL) / COUNT(o.order_id), 2) as cancellation_rate
559
+ FROM customers c
560
+ JOIN orders o ON c.customer_id = o.customer_id
561
+ GROUP BY c.customer_id, c.name
562
+ HAVING SUM(CASE WHEN o.status='cancelled' THEN 1 ELSE 0 END) > 0
563
+ ORDER BY cancellation_rate DESC;""",
564
+ schema_description="customers(customer_id, name, email, city, joined_date), orders(order_id, customer_id, order_date, status), order_items(item_id, order_id, product_name, quantity, unit_price)",
565
+ ),
566
+
567
+ SQLTask(
568
+ task_id="medium_009",
569
+ difficulty="medium",
570
+ question="Find total salary cost per department and what percentage of total company salary each dept represents. Show dept_name, total_salary, salary_pct rounded to 1 decimal.",
571
+ schema_sql=MEDIUM_SCHEMA_HR,
572
+ seed_sql=MEDIUM_SEED_HR,
573
+ solution_sql="""
574
+ SELECT d.dept_name,
575
+ SUM(e.salary) as total_salary,
576
+ ROUND(SUM(e.salary) * 100.0 / (SELECT SUM(salary) FROM employees), 1) as salary_pct
577
+ FROM employees e
578
+ JOIN departments d ON e.dept_id = d.dept_id
579
+ GROUP BY d.dept_id, d.dept_name
580
+ ORDER BY total_salary DESC;""",
581
+ schema_description="departments(dept_id, dept_name, manager_id, budget), employees(emp_id, name, dept_id, salary, hire_date, manager_id)",
582
+ ),
583
+
584
+ SQLTask(
585
+ task_id="medium_010",
586
+ difficulty="medium",
587
+ question="Find orders placed in January 2024. Show customer name, order_date, and order total (sum of quantity*unit_price).",
588
+ schema_sql=MEDIUM_SCHEMA_ECOMMERCE,
589
+ seed_sql=MEDIUM_SEED_ECOMMERCE,
590
+ solution_sql="""
591
+ SELECT c.name, o.order_date, ROUND(SUM(oi.quantity * oi.unit_price),2) as order_total
592
+ FROM orders o
593
+ JOIN customers c ON o.customer_id = c.customer_id
594
+ JOIN order_items oi ON o.order_id = oi.order_id
595
+ WHERE o.order_date LIKE '2024-01%'
596
+ GROUP BY o.order_id, c.name, o.order_date
597
+ ORDER BY o.order_date;""",
598
+ schema_description="customers(customer_id, name, email, city, joined_date), orders(order_id, customer_id, order_date, status), order_items(item_id, order_id, product_name, quantity, unit_price)",
599
+ ),
600
+
601
+ SQLTask(
602
+ task_id="medium_011",
603
+ difficulty="medium",
604
+ question="Which product has generated the most revenue across all completed orders? Show product_name and total_revenue.",
605
+ schema_sql=MEDIUM_SCHEMA_ECOMMERCE,
606
+ seed_sql=MEDIUM_SEED_ECOMMERCE,
607
+ solution_sql="""
608
+ SELECT oi.product_name, ROUND(SUM(oi.quantity * oi.unit_price),2) as total_revenue
609
+ FROM order_items oi
610
+ JOIN orders o ON oi.order_id = o.order_id
611
+ WHERE o.status = 'completed'
612
+ GROUP BY oi.product_name
613
+ ORDER BY total_revenue DESC
614
+ LIMIT 1;""",
615
+ schema_description="customers(customer_id, name, email, city, joined_date), orders(order_id, customer_id, order_date, status), order_items(item_id, order_id, product_name, quantity, unit_price)",
616
+ ),
617
+
618
+ SQLTask(
619
+ task_id="medium_012",
620
+ difficulty="medium",
621
+ question="Find employees who have been at the company for more than 4 years (hired before 2021). Show name, hire_date, and dept_name.",
622
+ schema_sql=MEDIUM_SCHEMA_HR,
623
+ seed_sql=MEDIUM_SEED_HR,
624
+ solution_sql="""
625
+ SELECT e.name, e.hire_date, d.dept_name
626
+ FROM employees e
627
+ JOIN departments d ON e.dept_id = d.dept_id
628
+ WHERE e.hire_date < '2021-01-01'
629
+ ORDER BY e.hire_date;""",
630
+ schema_description="departments(dept_id, dept_name, manager_id, budget), employees(emp_id, name, dept_id, salary, hire_date, manager_id)",
631
+ ),
632
+
633
+ SQLTask(
634
+ task_id="medium_013",
635
+ difficulty="medium",
636
+ question="Find the second highest salary in the company (single value, call it second_highest_salary).",
637
+ schema_sql=MEDIUM_SCHEMA_HR,
638
+ seed_sql=MEDIUM_SEED_HR,
639
+ solution_sql="""
640
+ SELECT MAX(salary) as second_highest_salary
641
+ FROM employees
642
+ WHERE salary < (SELECT MAX(salary) FROM employees);""",
643
+ schema_description="departments(dept_id, dept_name, manager_id, budget), employees(emp_id, name, dept_id, salary, hire_date, manager_id)",
644
+ ),
645
+
646
+ SQLTask(
647
+ task_id="medium_014",
648
+ difficulty="medium",
649
+ question="Count the number of employees per manager (only show managers with 2+ direct reports). Show manager name and report_count.",
650
+ schema_sql=MEDIUM_SCHEMA_HR,
651
+ seed_sql=MEDIUM_SEED_HR,
652
+ solution_sql="""
653
+ SELECT m.name as manager_name, COUNT(e.emp_id) as report_count
654
+ FROM employees e
655
+ JOIN employees m ON e.manager_id = m.emp_id
656
+ GROUP BY m.emp_id, m.name
657
+ HAVING COUNT(e.emp_id) >= 2
658
+ ORDER BY report_count DESC;""",
659
+ schema_description="departments(dept_id, dept_name, manager_id, budget), employees(emp_id, name, dept_id, salary, hire_date, manager_id) β€” manager_id refers to emp_id of the manager",
660
+ ),
661
+
662
+ SQLTask(
663
+ task_id="medium_015",
664
+ difficulty="medium",
665
+ question="Find customers who have never placed an order. Show their name and email.",
666
+ schema_sql=MEDIUM_SCHEMA_ECOMMERCE,
667
+ seed_sql=MEDIUM_SEED_ECOMMERCE,
668
+ solution_sql="""
669
+ SELECT c.name, c.email
670
+ FROM customers c
671
+ LEFT JOIN orders o ON c.customer_id = o.customer_id
672
+ WHERE o.order_id IS NULL
673
+ ORDER BY c.name;""",
674
+ schema_description="customers(customer_id, name, email, city, joined_date), orders(order_id, customer_id, order_date, status), order_items(item_id, order_id, product_name, quantity, unit_price)",
675
+ ),
676
+ ]
677
+
678
+
679
+ # ─────────────────────────────────────────────
680
+ # HARD TASKS β€” CTEs, Window Functions
681
+ # ─────────────────────────────────────────────
682
+
683
+ HARD_SCHEMA = """
684
+ CREATE TABLE customers (
685
+ customer_id INTEGER PRIMARY KEY,
686
+ name TEXT, city TEXT, segment TEXT
687
+ );
688
+ CREATE TABLE orders (
689
+ order_id INTEGER PRIMARY KEY,
690
+ customer_id INTEGER,
691
+ order_date TEXT,
692
+ ship_date TEXT,
693
+ category TEXT,
694
+ amount REAL
695
+ );
696
+ CREATE TABLE returns (
697
+ return_id INTEGER PRIMARY KEY,
698
+ order_id INTEGER,
699
+ return_date TEXT,
700
+ reason TEXT
701
+ );"""
702
+
703
+ HARD_SEED = """
704
+ INSERT INTO customers VALUES
705
+ (1,'Arjun','Mumbai','Consumer'),
706
+ (2,'Priya','Delhi','Corporate'),
707
+ (3,'Rahul','Bangalore','Consumer'),
708
+ (4,'Sneha','Hyderabad','Corporate'),
709
+ (5,'Vikram','Chennai','Home Office'),
710
+ (6,'Ananya','Pune','Consumer'),
711
+ (7,'Karan','Mumbai','Corporate'),
712
+ (8,'Divya','Bangalore','Consumer');
713
+
714
+ INSERT INTO orders VALUES
715
+ (1001,1,'2023-01-10','2023-01-15','Technology',2500.00),
716
+ (1002,1,'2023-03-20','2023-03-25','Furniture',1200.00),
717
+ (1003,1,'2023-06-15','2023-06-20','Technology',3800.00),
718
+ (1004,2,'2023-02-05','2023-02-10','Office Supplies',450.00),
719
+ (1005,2,'2023-04-18','2023-04-23','Technology',5200.00),
720
+ (1006,2,'2023-07-30','2023-08-04','Furniture',890.00),
721
+ (1007,3,'2023-01-25','2023-01-30','Technology',1100.00),
722
+ (1008,3,'2023-05-12','2023-05-17','Office Supplies',320.00),
723
+ (1009,4,'2023-03-08','2023-03-13','Furniture',2200.00),
724
+ (1010,4,'2023-08-22','2023-08-27','Technology',4100.00),
725
+ (1011,5,'2023-02-14','2023-02-19','Office Supplies',180.00),
726
+ (1012,5,'2023-06-28','2023-07-03','Technology',2900.00),
727
+ (1013,6,'2023-01-05','2023-01-10','Furniture',750.00),
728
+ (1014,6,'2023-04-25','2023-04-30','Office Supplies',290.00),
729
+ (1015,7,'2023-03-15','2023-03-20','Technology',6800.00),
730
+ (1016,7,'2023-07-10','2023-07-15','Furniture',1500.00),
731
+ (1017,8,'2023-02-28','2023-03-05','Technology',3200.00),
732
+ (1018,8,'2023-05-20','2023-05-25','Office Supplies',410.00);
733
+
734
+ INSERT INTO returns VALUES
735
+ (1,1002,'2023-04-01','Defective'),
736
+ (2,1004,'2023-02-20','Wrong item'),
737
+ (3,1008,'2023-05-25','Not needed'),
738
+ (4,1011,'2023-02-25','Defective'),
739
+ (5,1013,'2023-01-20','Wrong item');"""
740
+
741
+ HARD_TASKS = [
742
+ SQLTask(
743
+ task_id="hard_001",
744
+ difficulty="hard",
745
+ question="Using a window function, rank customers by their total order amount within each city segment. Show customer name, city, total_amount, and their rank within city.",
746
+ schema_sql=HARD_SCHEMA,
747
+ seed_sql=HARD_SEED,
748
+ solution_sql="""
749
+ SELECT c.name, c.city,
750
+ SUM(o.amount) as total_amount,
751
+ RANK() OVER (PARTITION BY c.city ORDER BY SUM(o.amount) DESC) as city_rank
752
+ FROM customers c
753
+ JOIN orders o ON c.customer_id = o.customer_id
754
+ GROUP BY c.customer_id, c.name, c.city
755
+ ORDER BY c.city, city_rank;""",
756
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
757
+ ),
758
+
759
+ SQLTask(
760
+ task_id="hard_002",
761
+ difficulty="hard",
762
+ question="Using a CTE, find the running total of order amounts for each customer ordered by order_date. Show customer name, order_date, amount, and running_total.",
763
+ schema_sql=HARD_SCHEMA,
764
+ seed_sql=HARD_SEED,
765
+ solution_sql="""
766
+ WITH customer_orders AS (
767
+ SELECT c.name, o.order_date, o.amount,
768
+ SUM(o.amount) OVER (PARTITION BY c.customer_id ORDER BY o.order_date) as running_total
769
+ FROM customers c
770
+ JOIN orders o ON c.customer_id = o.customer_id
771
+ )
772
+ SELECT name, order_date, amount, running_total
773
+ FROM customer_orders
774
+ ORDER BY name, order_date;""",
775
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
776
+ ),
777
+
778
+ SQLTask(
779
+ task_id="hard_003",
780
+ difficulty="hard",
781
+ question="Find the return rate by category (returned orders / total orders). Show category and return_rate as a percentage rounded to 1 decimal.",
782
+ schema_sql=HARD_SCHEMA,
783
+ seed_sql=HARD_SEED,
784
+ solution_sql="""
785
+ SELECT o.category,
786
+ ROUND(COUNT(r.return_id) * 100.0 / COUNT(o.order_id), 1) as return_rate
787
+ FROM orders o
788
+ LEFT JOIN returns r ON o.order_id = r.order_id
789
+ GROUP BY o.category
790
+ ORDER BY return_rate DESC;""",
791
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
792
+ ),
793
+
794
+ SQLTask(
795
+ task_id="hard_004",
796
+ difficulty="hard",
797
+ question="For each customer, find the month with their highest single order amount. Show customer name, best_month (YYYY-MM format), and max_amount.",
798
+ schema_sql=HARD_SCHEMA,
799
+ seed_sql=HARD_SEED,
800
+ solution_sql="""
801
+ WITH monthly_max AS (
802
+ SELECT c.name, SUBSTR(o.order_date,1,7) as month, MAX(o.amount) as max_amount,
803
+ RANK() OVER (PARTITION BY c.customer_id ORDER BY MAX(o.amount) DESC) as rnk
804
+ FROM customers c
805
+ JOIN orders o ON c.customer_id = o.customer_id
806
+ GROUP BY c.customer_id, c.name, month
807
+ )
808
+ SELECT name, month as best_month, max_amount
809
+ FROM monthly_max
810
+ WHERE rnk = 1
811
+ ORDER BY max_amount DESC;""",
812
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
813
+ ),
814
+
815
+ SQLTask(
816
+ task_id="hard_005",
817
+ difficulty="hard",
818
+ question="Calculate average shipping time (days between order_date and ship_date) per category. Show category and avg_ship_days rounded to 1 decimal.",
819
+ schema_sql=HARD_SCHEMA,
820
+ seed_sql=HARD_SEED,
821
+ solution_sql="""
822
+ SELECT category,
823
+ ROUND(AVG(julianday(ship_date) - julianday(order_date)), 1) as avg_ship_days
824
+ FROM orders
825
+ GROUP BY category
826
+ ORDER BY avg_ship_days;""",
827
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
828
+ ),
829
+
830
+ SQLTask(
831
+ task_id="hard_006",
832
+ difficulty="hard",
833
+ question="Find customers whose total order value in Technology exceeds their total in all other categories combined. Show customer name, tech_total, and other_total.",
834
+ schema_sql=HARD_SCHEMA,
835
+ seed_sql=HARD_SEED,
836
+ solution_sql="""
837
+ WITH category_totals AS (
838
+ SELECT c.customer_id, c.name,
839
+ SUM(CASE WHEN o.category='Technology' THEN o.amount ELSE 0 END) as tech_total,
840
+ SUM(CASE WHEN o.category!='Technology' THEN o.amount ELSE 0 END) as other_total
841
+ FROM customers c
842
+ JOIN orders o ON c.customer_id = o.customer_id
843
+ GROUP BY c.customer_id, c.name
844
+ )
845
+ SELECT name, tech_total, other_total
846
+ FROM category_totals
847
+ WHERE tech_total > other_total
848
+ ORDER BY tech_total DESC;""",
849
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
850
+ ),
851
+
852
+ SQLTask(
853
+ task_id="hard_007",
854
+ difficulty="hard",
855
+ question="Using LAG window function, for each customer show each order amount and the difference from their previous order amount. Show name, order_date, amount, prev_amount, and amount_change.",
856
+ schema_sql=HARD_SCHEMA,
857
+ seed_sql=HARD_SEED,
858
+ solution_sql="""
859
+ SELECT c.name, o.order_date, o.amount,
860
+ LAG(o.amount) OVER (PARTITION BY c.customer_id ORDER BY o.order_date) as prev_amount,
861
+ o.amount - LAG(o.amount) OVER (PARTITION BY c.customer_id ORDER BY o.order_date) as amount_change
862
+ FROM customers c
863
+ JOIN orders o ON c.customer_id = o.customer_id
864
+ ORDER BY c.name, o.order_date;""",
865
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
866
+ ),
867
+
868
+ SQLTask(
869
+ task_id="hard_008",
870
+ difficulty="hard",
871
+ question="Identify the top 2 customers by total order amount in each customer segment. Show segment, customer name, total_amount, and rank within segment.",
872
+ schema_sql=HARD_SCHEMA,
873
+ seed_sql=HARD_SEED,
874
+ solution_sql="""
875
+ WITH ranked AS (
876
+ SELECT c.segment, c.name,
877
+ SUM(o.amount) as total_amount,
878
+ RANK() OVER (PARTITION BY c.segment ORDER BY SUM(o.amount) DESC) as rnk
879
+ FROM customers c
880
+ JOIN orders o ON c.customer_id = o.customer_id
881
+ GROUP BY c.customer_id, c.segment, c.name
882
+ )
883
+ SELECT segment, name, total_amount, rnk
884
+ FROM ranked
885
+ WHERE rnk <= 2
886
+ ORDER BY segment, rnk;""",
887
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
888
+ ),
889
+
890
+ SQLTask(
891
+ task_id="hard_009",
892
+ difficulty="hard",
893
+ question="Find the percentage of total revenue contributed by each customer. Show name and revenue_pct rounded to 2 decimals, ordered by pct descending.",
894
+ schema_sql=HARD_SCHEMA,
895
+ seed_sql=HARD_SEED,
896
+ solution_sql="""
897
+ SELECT c.name,
898
+ ROUND(SUM(o.amount) * 100.0 / (SELECT SUM(amount) FROM orders), 2) as revenue_pct
899
+ FROM customers c
900
+ JOIN orders o ON c.customer_id = o.customer_id
901
+ GROUP BY c.customer_id, c.name
902
+ ORDER BY revenue_pct DESC;""",
903
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
904
+ ),
905
+
906
+ SQLTask(
907
+ task_id="hard_010",
908
+ difficulty="hard",
909
+ question="For each category, show total revenue, average order size, and count of unique customers who ordered in that category.",
910
+ schema_sql=HARD_SCHEMA,
911
+ seed_sql=HARD_SEED,
912
+ solution_sql="""
913
+ SELECT o.category,
914
+ ROUND(SUM(o.amount), 2) as total_revenue,
915
+ ROUND(AVG(o.amount), 2) as avg_order_size,
916
+ COUNT(DISTINCT o.customer_id) as unique_customers
917
+ FROM orders o
918
+ GROUP BY o.category
919
+ ORDER BY total_revenue DESC;""",
920
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
921
+ ),
922
+
923
+ SQLTask(
924
+ task_id="hard_011",
925
+ difficulty="hard",
926
+ question="Find customers who have ordered in ALL three categories (Technology, Furniture, Office Supplies). Show customer name.",
927
+ schema_sql=HARD_SCHEMA,
928
+ seed_sql=HARD_SEED,
929
+ solution_sql="""
930
+ SELECT c.name
931
+ FROM customers c
932
+ JOIN orders o ON c.customer_id = o.customer_id
933
+ WHERE o.category IN ('Technology','Furniture','Office Supplies')
934
+ GROUP BY c.customer_id, c.name
935
+ HAVING COUNT(DISTINCT o.category) = 3
936
+ ORDER BY c.name;""",
937
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
938
+ ),
939
+
940
+ SQLTask(
941
+ task_id="hard_012",
942
+ difficulty="hard",
943
+ question="For each order, show the order amount as a percentile rank (0 to 1) among all orders. Show order_id, amount, and percentile_rank rounded to 2 decimals.",
944
+ schema_sql=HARD_SCHEMA,
945
+ seed_sql=HARD_SEED,
946
+ solution_sql="""
947
+ SELECT order_id, amount,
948
+ ROUND(PERCENT_RANK() OVER (ORDER BY amount), 2) as percentile_rank
949
+ FROM orders
950
+ ORDER BY amount;""",
951
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
952
+ ),
953
+
954
+ SQLTask(
955
+ task_id="hard_013",
956
+ difficulty="hard",
957
+ question="Find orders where the shipping time was above the average shipping time for their category. Show order_id, category, ship_days, and category_avg_days (both rounded to 1 decimal).",
958
+ schema_sql=HARD_SCHEMA,
959
+ seed_sql=HARD_SEED,
960
+ solution_sql="""
961
+ WITH ship_times AS (
962
+ SELECT order_id, category,
963
+ ROUND(julianday(ship_date) - julianday(order_date), 1) as ship_days,
964
+ ROUND(AVG(julianday(ship_date) - julianday(order_date)) OVER (PARTITION BY category), 1) as category_avg_days
965
+ FROM orders
966
+ )
967
+ SELECT order_id, category, ship_days, category_avg_days
968
+ FROM ship_times
969
+ WHERE ship_days > category_avg_days
970
+ ORDER BY ship_days DESC;""",
971
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
972
+ ),
973
+
974
+ SQLTask(
975
+ task_id="hard_014",
976
+ difficulty="hard",
977
+ question="Find the first and last order date for each customer, and number of days between them (customer tenure in days). Show name, first_order, last_order, tenure_days.",
978
+ schema_sql=HARD_SCHEMA,
979
+ seed_sql=HARD_SEED,
980
+ solution_sql="""
981
+ SELECT c.name,
982
+ MIN(o.order_date) as first_order,
983
+ MAX(o.order_date) as last_order,
984
+ CAST(julianday(MAX(o.order_date)) - julianday(MIN(o.order_date)) AS INTEGER) as tenure_days
985
+ FROM customers c
986
+ JOIN orders o ON c.customer_id = o.customer_id
987
+ GROUP BY c.customer_id, c.name
988
+ ORDER BY tenure_days DESC;""",
989
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
990
+ ),
991
+
992
+ SQLTask(
993
+ task_id="hard_015",
994
+ difficulty="hard",
995
+ question="Using a CTE, find customers with above-average order count AND above-average total spend. Show name, order_count, and total_spend.",
996
+ schema_sql=HARD_SCHEMA,
997
+ seed_sql=HARD_SEED,
998
+ solution_sql="""
999
+ WITH customer_stats AS (
1000
+ SELECT c.customer_id, c.name,
1001
+ COUNT(o.order_id) as order_count,
1002
+ SUM(o.amount) as total_spend
1003
+ FROM customers c
1004
+ JOIN orders o ON c.customer_id = o.customer_id
1005
+ GROUP BY c.customer_id, c.name
1006
+ ),
1007
+ averages AS (
1008
+ SELECT AVG(order_count) as avg_orders, AVG(total_spend) as avg_spend
1009
+ FROM customer_stats
1010
+ )
1011
+ SELECT cs.name, cs.order_count, cs.total_spend
1012
+ FROM customer_stats cs, averages
1013
+ WHERE cs.order_count > averages.avg_orders
1014
+ AND cs.total_spend > averages.avg_spend
1015
+ ORDER BY cs.total_spend DESC;""",
1016
+ schema_description="customers(customer_id, name, city, segment), orders(order_id, customer_id, order_date, ship_date, category, amount), returns(return_id, order_id, return_date, reason)",
1017
+ ),
1018
+ ]
1019
+
1020
+
1021
+ # ─────────────────────────────────────────────
1022
+ # EXPERT TASKS β€” Correlated subqueries, complex analytics
1023
+ # ─────────────────────────────────────────────
1024
+
1025
+ EXPERT_SCHEMA = """
1026
+ CREATE TABLE accounts (
1027
+ account_id INTEGER PRIMARY KEY,
1028
+ holder_name TEXT,
1029
+ account_type TEXT,
1030
+ city TEXT,
1031
+ opened_date TEXT,
1032
+ balance REAL
1033
+ );
1034
+ CREATE TABLE transactions (
1035
+ txn_id INTEGER PRIMARY KEY,
1036
+ account_id INTEGER,
1037
+ txn_date TEXT,
1038
+ txn_type TEXT,
1039
+ amount REAL,
1040
+ category TEXT,
1041
+ FOREIGN KEY (account_id) REFERENCES accounts(account_id)
1042
+ );
1043
+ CREATE TABLE loans (
1044
+ loan_id INTEGER PRIMARY KEY,
1045
+ account_id INTEGER,
1046
+ loan_type TEXT,
1047
+ principal REAL,
1048
+ interest_rate REAL,
1049
+ start_date TEXT,
1050
+ status TEXT,
1051
+ FOREIGN KEY (account_id) REFERENCES accounts(account_id)
1052
+ );"""
1053
+
1054
+ EXPERT_SEED = """
1055
+ INSERT INTO accounts VALUES
1056
+ (1,'Arjun Sharma','Savings','Mumbai','2018-03-15',125000.00),
1057
+ (2,'Priya Patel','Current','Delhi','2016-07-22',450000.00),
1058
+ (3,'Rahul Gupta','Savings','Bangalore','2020-01-10',78000.00),
1059
+ (4,'Sneha Reddy','Current','Hyderabad','2015-11-30',890000.00),
1060
+ (5,'Vikram Singh','Savings','Chennai','2019-05-14',42000.00),
1061
+ (6,'Ananya Joshi','Savings','Pune','2021-08-09',195000.00),
1062
+ (7,'Karan Mehta','Current','Mumbai','2017-02-28',320000.00),
1063
+ (8,'Divya Nair','Savings','Bangalore','2022-04-17',55000.00);
1064
+
1065
+ INSERT INTO transactions VALUES
1066
+ (1,1,'2024-01-05','credit',50000,'salary'),
1067
+ (2,1,'2024-01-12','debit',15000,'rent'),
1068
+ (3,1,'2024-01-20','debit',8000,'groceries'),
1069
+ (4,1,'2024-02-05','credit',50000,'salary'),
1070
+ (5,1,'2024-02-18','debit',22000,'utilities'),
1071
+ (6,2,'2024-01-08','credit',200000,'business'),
1072
+ (7,2,'2024-01-15','debit',80000,'supplier'),
1073
+ (8,2,'2024-01-28','debit',45000,'rent'),
1074
+ (9,2,'2024-02-10','credit',175000,'business'),
1075
+ (10,2,'2024-02-22','debit',60000,'supplier'),
1076
+ (11,3,'2024-01-10','credit',45000,'salary'),
1077
+ (12,3,'2024-01-25','debit',12000,'rent'),
1078
+ (13,3,'2024-02-10','credit',45000,'salary'),
1079
+ (14,3,'2024-02-20','debit',18000,'utilities'),
1080
+ (15,4,'2024-01-03','credit',500000,'business'),
1081
+ (16,4,'2024-01-20','debit',200000,'investment'),
1082
+ (17,4,'2024-02-05','credit',480000,'business'),
1083
+ (18,4,'2024-02-25','debit',150000,'supplier'),
1084
+ (19,5,'2024-01-15','credit',38000,'salary'),
1085
+ (20,5,'2024-01-30','debit',15000,'rent'),
1086
+ (21,6,'2024-01-08','credit',85000,'salary'),
1087
+ (22,6,'2024-01-22','debit',25000,'rent'),
1088
+ (23,6,'2024-02-08','credit',85000,'salary'),
1089
+ (24,6,'2024-02-20','debit',30000,'investment'),
1090
+ (25,7,'2024-01-12','credit',150000,'business'),
1091
+ (26,7,'2024-01-28','debit',75000,'supplier'),
1092
+ (27,7,'2024-02-15','credit',160000,'business'),
1093
+ (28,8,'2024-01-18','credit',52000,'salary'),
1094
+ (29,8,'2024-02-18','credit',52000,'salary'),
1095
+ (30,8,'2024-02-25','debit',20000,'rent');
1096
+
1097
+ INSERT INTO loans VALUES
1098
+ (1,1,'Home Loan',2500000,8.5,'2020-06-15','active'),
1099
+ (2,2,'Business Loan',5000000,10.2,'2019-03-20','active'),
1100
+ (3,3,'Personal Loan',150000,14.5,'2022-08-10','active'),
1101
+ (4,5,'Vehicle Loan',800000,9.8,'2021-12-05','active'),
1102
+ (5,6,'Home Loan',3500000,8.2,'2022-09-15','active'),
1103
+ (6,7,'Business Loan',2000000,11.5,'2018-04-22','closed'),
1104
+ (7,8,'Personal Loan',100000,15.2,'2023-01-30','active');"""
1105
+
1106
+ EXPERT_TASKS = [
1107
+ SQLTask(
1108
+ task_id="expert_001",
1109
+ difficulty="expert",
1110
+ question="For each account, calculate the net cash flow (total credits minus total debits) per month in 2024. Show holder_name, month (YYYY-MM), and net_flow. Order by holder_name, month.",
1111
+ schema_sql=EXPERT_SCHEMA,
1112
+ seed_sql=EXPERT_SEED,
1113
+ solution_sql="""
1114
+ SELECT a.holder_name,
1115
+ SUBSTR(t.txn_date,1,7) as month,
1116
+ ROUND(SUM(CASE WHEN t.txn_type='credit' THEN t.amount ELSE -t.amount END), 2) as net_flow
1117
+ FROM accounts a
1118
+ JOIN transactions t ON a.account_id = t.account_id
1119
+ WHERE t.txn_date LIKE '2024%'
1120
+ GROUP BY a.account_id, a.holder_name, month
1121
+ ORDER BY a.holder_name, month;""",
1122
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1123
+ ),
1124
+
1125
+ SQLTask(
1126
+ task_id="expert_002",
1127
+ difficulty="expert",
1128
+ question="Find accounts where the total loan principal exceeds 5x their current account balance. Show holder_name, balance, total_loan_principal, and the ratio (rounded to 2 decimals).",
1129
+ schema_sql=EXPERT_SCHEMA,
1130
+ seed_sql=EXPERT_SEED,
1131
+ solution_sql="""
1132
+ SELECT a.holder_name, a.balance,
1133
+ SUM(l.principal) as total_loan_principal,
1134
+ ROUND(SUM(l.principal) / a.balance, 2) as debt_to_balance_ratio
1135
+ FROM accounts a
1136
+ JOIN loans l ON a.account_id = l.account_id
1137
+ WHERE l.status = 'active'
1138
+ GROUP BY a.account_id, a.holder_name, a.balance
1139
+ HAVING SUM(l.principal) > 5 * a.balance
1140
+ ORDER BY debt_to_balance_ratio DESC;""",
1141
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1142
+ ),
1143
+
1144
+ SQLTask(
1145
+ task_id="expert_003",
1146
+ difficulty="expert",
1147
+ question="Using a CTE chain, find the top spending category per account (by total debit amount). Show holder_name, top_category, and category_spend.",
1148
+ schema_sql=EXPERT_SCHEMA,
1149
+ seed_sql=EXPERT_SEED,
1150
+ solution_sql="""
1151
+ WITH category_spend AS (
1152
+ SELECT a.account_id, a.holder_name, t.category,
1153
+ SUM(t.amount) as spend,
1154
+ RANK() OVER (PARTITION BY a.account_id ORDER BY SUM(t.amount) DESC) as rnk
1155
+ FROM accounts a
1156
+ JOIN transactions t ON a.account_id = t.account_id
1157
+ WHERE t.txn_type = 'debit'
1158
+ GROUP BY a.account_id, a.holder_name, t.category
1159
+ )
1160
+ SELECT holder_name, category as top_category, spend as category_spend
1161
+ FROM category_spend
1162
+ WHERE rnk = 1
1163
+ ORDER BY category_spend DESC;""",
1164
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1165
+ ),
1166
+
1167
+ SQLTask(
1168
+ task_id="expert_004",
1169
+ difficulty="expert",
1170
+ question="Calculate the estimated annual interest cost for each active loan. Show holder_name, loan_type, principal, interest_rate, and annual_interest_cost (principal * interest_rate / 100), ordered by annual_interest_cost descending.",
1171
+ schema_sql=EXPERT_SCHEMA,
1172
+ seed_sql=EXPERT_SEED,
1173
+ solution_sql="""
1174
+ SELECT a.holder_name, l.loan_type, l.principal, l.interest_rate,
1175
+ ROUND(l.principal * l.interest_rate / 100, 2) as annual_interest_cost
1176
+ FROM accounts a
1177
+ JOIN loans l ON a.account_id = l.account_id
1178
+ WHERE l.status = 'active'
1179
+ ORDER BY annual_interest_cost DESC;""",
1180
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1181
+ ),
1182
+
1183
+ SQLTask(
1184
+ task_id="expert_005",
1185
+ difficulty="expert",
1186
+ question="For each city, calculate the average account balance, total transaction volume, and number of active loans. Show city, avg_balance, total_txn_volume, and active_loan_count.",
1187
+ schema_sql=EXPERT_SCHEMA,
1188
+ seed_sql=EXPERT_SEED,
1189
+ solution_sql="""
1190
+ SELECT a.city,
1191
+ ROUND(AVG(a.balance), 2) as avg_balance,
1192
+ ROUND(SUM(t.amount), 2) as total_txn_volume,
1193
+ COUNT(DISTINCT l.loan_id) as active_loan_count
1194
+ FROM accounts a
1195
+ LEFT JOIN transactions t ON a.account_id = t.account_id
1196
+ LEFT JOIN loans l ON a.account_id = l.account_id AND l.status = 'active'
1197
+ GROUP BY a.city
1198
+ ORDER BY avg_balance DESC;""",
1199
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1200
+ ),
1201
+
1202
+ SQLTask(
1203
+ task_id="expert_006",
1204
+ difficulty="expert",
1205
+ question="Find accounts that received salary credits in both January and February 2024. Show holder_name.",
1206
+ schema_sql=EXPERT_SCHEMA,
1207
+ seed_sql=EXPERT_SEED,
1208
+ solution_sql="""
1209
+ SELECT a.holder_name
1210
+ FROM accounts a
1211
+ WHERE EXISTS (
1212
+ SELECT 1 FROM transactions t
1213
+ WHERE t.account_id = a.account_id
1214
+ AND t.txn_type = 'credit'
1215
+ AND t.category = 'salary'
1216
+ AND t.txn_date LIKE '2024-01%'
1217
+ )
1218
+ AND EXISTS (
1219
+ SELECT 1 FROM transactions t
1220
+ WHERE t.account_id = a.account_id
1221
+ AND t.txn_type = 'credit'
1222
+ AND t.category = 'salary'
1223
+ AND t.txn_date LIKE '2024-02%'
1224
+ )
1225
+ ORDER BY a.holder_name;""",
1226
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1227
+ ),
1228
+
1229
+ SQLTask(
1230
+ task_id="expert_007",
1231
+ difficulty="expert",
1232
+ question="Calculate savings rate for each salary earner: (total_credits - total_debits) / total_credits. Show holder_name and savings_rate as percentage rounded to 1 decimal. Only include accounts with salary transactions.",
1233
+ schema_sql=EXPERT_SCHEMA,
1234
+ seed_sql=EXPERT_SEED,
1235
+ solution_sql="""
1236
+ WITH flows AS (
1237
+ SELECT a.account_id, a.holder_name,
1238
+ SUM(CASE WHEN t.txn_type='credit' THEN t.amount ELSE 0 END) as total_credits,
1239
+ SUM(CASE WHEN t.txn_type='debit' THEN t.amount ELSE 0 END) as total_debits
1240
+ FROM accounts a
1241
+ JOIN transactions t ON a.account_id = t.account_id
1242
+ GROUP BY a.account_id, a.holder_name
1243
+ )
1244
+ SELECT f.holder_name,
1245
+ ROUND((f.total_credits - f.total_debits) * 100.0 / f.total_credits, 1) as savings_rate
1246
+ FROM flows f
1247
+ WHERE f.account_id IN (
1248
+ SELECT DISTINCT account_id FROM transactions WHERE category = 'salary'
1249
+ )
1250
+ ORDER BY savings_rate DESC;""",
1251
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1252
+ ),
1253
+
1254
+ SQLTask(
1255
+ task_id="expert_008",
1256
+ difficulty="expert",
1257
+ question="Find the account with the highest transaction frequency (most transactions per month on average). Show holder_name, total_transactions, months_active (distinct months with transactions), and avg_txn_per_month rounded to 2 decimals.",
1258
+ schema_sql=EXPERT_SCHEMA,
1259
+ seed_sql=EXPERT_SEED,
1260
+ solution_sql="""
1261
+ SELECT a.holder_name,
1262
+ COUNT(t.txn_id) as total_transactions,
1263
+ COUNT(DISTINCT SUBSTR(t.txn_date,1,7)) as months_active,
1264
+ ROUND(CAST(COUNT(t.txn_id) AS REAL) / COUNT(DISTINCT SUBSTR(t.txn_date,1,7)), 2) as avg_txn_per_month
1265
+ FROM accounts a
1266
+ JOIN transactions t ON a.account_id = t.account_id
1267
+ GROUP BY a.account_id, a.holder_name
1268
+ ORDER BY avg_txn_per_month DESC
1269
+ LIMIT 1;""",
1270
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1271
+ ),
1272
+
1273
+ SQLTask(
1274
+ task_id="expert_009",
1275
+ difficulty="expert",
1276
+ question="Identify accounts that have both an active loan AND whose total debits in 2024 exceed their total credits in 2024 (cash flow negative). Show holder_name, net_flow_2024, and total_active_loan_principal.",
1277
+ schema_sql=EXPERT_SCHEMA,
1278
+ seed_sql=EXPERT_SEED,
1279
+ solution_sql="""
1280
+ WITH cash_flow AS (
1281
+ SELECT account_id,
1282
+ SUM(CASE WHEN txn_type='credit' THEN amount ELSE -amount END) as net_flow
1283
+ FROM transactions
1284
+ WHERE txn_date LIKE '2024%'
1285
+ GROUP BY account_id
1286
+ ),
1287
+ active_loans AS (
1288
+ SELECT account_id, SUM(principal) as total_principal
1289
+ FROM loans WHERE status='active'
1290
+ GROUP BY account_id
1291
+ )
1292
+ SELECT a.holder_name, ROUND(cf.net_flow, 2) as net_flow_2024, al.total_principal
1293
+ FROM accounts a
1294
+ JOIN cash_flow cf ON a.account_id = cf.account_id
1295
+ JOIN active_loans al ON a.account_id = al.account_id
1296
+ WHERE cf.net_flow < 0
1297
+ ORDER BY cf.net_flow;""",
1298
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1299
+ ),
1300
+
1301
+ SQLTask(
1302
+ task_id="expert_010",
1303
+ difficulty="expert",
1304
+ question="Create a financial health score for each account: score = (balance/100000)*40 + (net_credits_2024/50000)*40 - (active_loan_principal/1000000)*20, capped between 0 and 100. Show holder_name and health_score rounded to 1 decimal, ordered by score descending.",
1305
+ schema_sql=EXPERT_SCHEMA,
1306
+ seed_sql=EXPERT_SEED,
1307
+ solution_sql="""
1308
+ WITH metrics AS (
1309
+ SELECT a.account_id, a.holder_name, a.balance,
1310
+ COALESCE(SUM(CASE WHEN t.txn_type='credit' THEN t.amount ELSE 0 END), 0) as net_credits,
1311
+ COALESCE((SELECT SUM(principal) FROM loans l WHERE l.account_id=a.account_id AND l.status='active'), 0) as loan_principal
1312
+ FROM accounts a
1313
+ LEFT JOIN transactions t ON a.account_id = t.account_id AND t.txn_date LIKE '2024%'
1314
+ GROUP BY a.account_id, a.holder_name, a.balance
1315
+ )
1316
+ SELECT holder_name,
1317
+ ROUND(MIN(100, MAX(0,
1318
+ (balance/100000.0)*40 +
1319
+ (net_credits/50000.0)*40 -
1320
+ (loan_principal/1000000.0)*20
1321
+ )), 1) as health_score
1322
+ FROM metrics
1323
+ ORDER BY health_score DESC;""",
1324
+ schema_description="accounts(account_id, holder_name, account_type, city, opened_date, balance), transactions(txn_id, account_id, txn_date, txn_type[credit/debit], amount, category), loans(loan_id, account_id, loan_type, principal, interest_rate, start_date, status)",
1325
+ ),
1326
+ ]
1327
+
1328
+
1329
+ # ─────────────────────────────────────────────
1330
+ # Task registry
1331
+ # ─────────────────────────────────────────────
1332
+
1333
+ ALL_TASKS: Dict[str, SQLTask] = {}
1334
+ for _t in EASY_TASKS + MEDIUM_TASKS + HARD_TASKS + EXPERT_TASKS:
1335
+ ALL_TASKS[_t.task_id] = _t
1336
+
1337
+ TASKS_BY_DIFFICULTY: Dict[str, List[SQLTask]] = {
1338
+ "easy": EASY_TASKS,
1339
+ "medium": MEDIUM_TASKS,
1340
+ "hard": HARD_TASKS,
1341
+ "expert": EXPERT_TASKS,
1342
+ }
1343
+
1344
+ def get_task(task_id: str) -> Optional[SQLTask]:
1345
+ return ALL_TASKS.get(task_id)
1346
+
1347
+ def get_tasks_by_difficulty(difficulty: str) -> List[SQLTask]:
1348
+ return TASKS_BY_DIFFICULTY.get(difficulty, [])