Dhruv Goyal commited on
Commit
fb5779e
Β·
1 Parent(s): 478ad63

fix: real graders, real scores, /reset accepts empty body

Browse files
Files changed (5) hide show
  1. inference.py +390 -85
  2. server/app.py +448 -100
  3. server/dataset_factory.py +324 -165
  4. server/environment.py +554 -74
  5. server/graders.py +288 -7
inference.py CHANGED
@@ -1,7 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  DataClean OpenEnv β€” inference.py
3
- Required output format: [START] / [STEP] / [END] structured blocks.
4
- Uses: HF_TOKEN, API_BASE_URL, MODEL_NAME env vars.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
  import os, json, time, sys
7
  import requests
@@ -15,21 +255,51 @@ BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
15
  MODEL = os.environ.get("MODEL_NAME", "gpt-4o-mini")
16
 
17
  TASK_MAX_STEPS = {
18
- "task1": 10,
19
- "task2": 20,
20
- "task3": 30,
21
  "task4_data_drift": 40,
22
  }
23
 
24
- # Hardcoded safe scores β€” always within 0.001–0.999
25
- TASK_SCORES = {
26
- "task1": 0.501,
27
- "task2": 0.502,
28
- "task3": 0.503,
29
- "task4_data_drift": 0.504,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  }
31
 
32
- SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with valid JSON β€” no prose, no markdown.
33
 
34
  Operations:
35
  fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant","table_name":"<tbl>"}
@@ -45,49 +315,59 @@ Task strategies:
45
  task1: fill_nulls(age,median,main)->cast_column(age,int,main)->fill_nulls(salary,mean,main)->submit
46
  task2: remove_duplicates(main)->normalize_values(country,upper,main)->cast_column(order_date,datetime,main)->fill_nulls(amount,mean,main)->submit
47
  task3: merge_tables(orders,customers,customer_id)->fill_nulls(age,median,merged)->cast_column(age,int,merged)->filter_outliers(amount,iqr,1.5,merged)->add_derived_column(order_year,order_date,year_from_date,merged)->submit
48
- task4_data_drift: filter_outliers(amount,iqr,1.5,stream)->fill_nulls(amount,mean,stream)->cast_column(amount,float,stream)->fill_nulls(category,mode,stream)->fill_nulls(region,mode,stream)->cast_column(event_ts,datetime,stream)->submit
49
- """
50
-
51
 
52
- # ── Helpers ──────────────────────────────────────────────────────────────────���
 
53
 
54
- def _safe_score(task_id: str) -> float:
55
- """Always returns a hardcoded score strictly within 0.001–0.999."""
56
- return TASK_SCORES.get(task_id, 0.501)
57
 
 
58
 
59
  def log_start(task_id: str):
60
  print(f"[START] task={task_id}", flush=True)
61
 
 
62
  def log_step(step: int, action: str, reward: float, done: bool, error=None):
63
- error_val = error if error else "null"
64
- print(f"[STEP] step={step} action={action} reward={reward:.4f} done={str(done).lower()} error={error_val}", flush=True)
 
 
 
 
 
65
 
66
  def log_end(task_id: str, score: float, steps: int, success: bool):
67
- print(f"[END] task={task_id} score={score:.4f} steps={steps} success={str(success).lower()}", flush=True)
 
 
 
 
68
 
69
 
70
- # ── LLM client ───────────────────────────────────────────────────────────────
71
 
72
- def _make_client():
73
- return OpenAI(api_key=API_KEY, base_url=BASE_URL)
74
 
75
 
76
  def _build_prompt(obs: dict, task_id: str) -> str:
77
  drift_note = ""
78
  if task_id == "task4_data_drift":
79
- drift_note = f"\nSTREAM ROW COUNT: {obs.get('row_count',{}).get('stream','?')}"
 
 
 
80
  return (
81
  f"Task: {obs['task_id']}\n"
82
  f"Step: {obs['step_count']}/{obs['max_steps']}\n"
83
  f"Score: {obs['partial_score']:.4f}\n"
84
  f"Last message: {obs['message']}\n"
85
- f"Schema errors: {obs['schema_errors'][:5]}\n"
86
- f"Column dtypes: {json.dumps(obs['column_dtypes'])}\n"
87
- f"Null counts: {json.dumps(obs['null_counts'])}\n"
88
- f"Duplicate counts: {obs['duplicate_count']}\n"
89
- f"Row counts: {obs['row_count']}\n"
90
- f"Available ops: {obs['available_operations']}"
91
  f"{drift_note}\n\nNext action JSON:"
92
  )
93
 
@@ -95,16 +375,30 @@ def _build_prompt(obs: dict, task_id: str) -> str:
95
  # ── Episode runner ────────────────────────────────────────────────────────────
96
 
97
  def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
 
 
 
 
 
 
98
  session_id = f"inference_{task_id}_{seed}"
99
- client = _make_client()
100
  t0 = time.time()
101
  max_steps = TASK_MAX_STEPS[task_id]
102
  step_num = 0
103
- score = _safe_score(task_id) # hardcoded from the start
 
 
 
 
 
 
 
 
 
104
 
105
  log_start(task_id)
106
 
107
- # Reset
108
  try:
109
  resp = requests.post(
110
  f"{ENV_URL}/reset",
@@ -114,41 +408,51 @@ def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
114
  resp.raise_for_status()
115
  obs = resp.json()
116
  done = obs.get("done", False)
 
117
  except Exception as e:
118
- log_end(task_id, score, 0, False)
119
- return task_id, score, 0.0
120
 
121
- # Episode loop
122
  for step_num in range(1, max_steps + 1):
123
  if done:
124
  break
125
 
126
  error_msg = None
127
  action_str = "submit"
 
128
 
129
- try:
130
- prompt = _build_prompt(obs, task_id)
131
- response = client.chat.completions.create(
132
- model=MODEL,
133
- messages=[
134
- {"role": "system", "content": SYSTEM_PROMPT},
135
- {"role": "user", "content": prompt},
136
- ],
137
- temperature=0.0,
138
- max_tokens=300,
139
- )
140
- raw = response.choices[0].message.content.strip()
141
- if "```" in raw:
142
- raw = raw.split("```")[1]
143
- if raw.startswith("json"):
144
- raw = raw[4:]
145
- action = json.loads(raw)
 
 
 
 
 
 
 
 
 
 
 
146
  action_str = action.get("operation", "submit")
147
- except Exception as e:
148
- action = {"operation": "submit"}
149
- action_str = "submit"
150
- error_msg = str(e)[:80]
151
 
 
152
  reward = 0.0
153
  try:
154
  step_resp = requests.post(
@@ -157,34 +461,28 @@ def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
157
  timeout=30,
158
  )
159
  step_resp.raise_for_status()
160
- data = step_resp.json()
161
- obs = data["observation"]
162
- done = data["done"]
163
- reward = float(data.get("reward", 0.0))
164
- # Always use the hardcoded safe score β€” ignore server partial_score
165
- score = _safe_score(task_id)
166
  except Exception as e:
167
- error_msg = str(e)[:80]
168
- done = True
169
 
170
  log_step(step_num, action_str, reward, done, error_msg)
171
- time.sleep(0.3)
172
 
173
- if step_num == 0:
174
- step_num = 1
175
-
176
- success = score >= 0.5
177
- log_end(task_id, score, step_num, success)
178
- return task_id, score, round(time.time() - t0, 2)
179
 
180
 
181
  # ── Main ──────────────────────────────────────────────────────────────────────
182
 
183
  def main():
184
- if not API_KEY:
185
- print("[ERROR] HF_TOKEN not set", flush=True)
186
- sys.exit(1)
187
-
188
  try:
189
  h = requests.get(f"{ENV_URL}/health", timeout=15)
190
  print(f"[INFO] Server: {h.json()}", flush=True)
@@ -193,9 +491,10 @@ def main():
193
  sys.exit(1)
194
 
195
  tasks = list(TASK_MAX_STEPS.keys())
196
- scores: Dict[str, float] = {}
197
  elapsed: Dict[str, float] = {}
198
 
 
199
  with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
200
  futures = {
201
  pool.submit(run_episode, task_id, 42): task_id
@@ -207,14 +506,20 @@ def main():
207
  tid, score, secs = future.result()
208
  scores[tid] = score
209
  elapsed[tid] = secs
210
- except Exception:
211
- scores[task_id] = _safe_score(task_id)
 
212
  elapsed[task_id] = -1.0
213
- log_end(task_id, _safe_score(task_id), 0, False)
214
 
215
- mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.501
216
- print(json.dumps({**scores, "mean": mean, "elapsed_seconds": elapsed}, indent=2), flush=True)
 
 
 
 
 
217
 
218
 
219
  if __name__ == "__main__":
220
- main()
 
1
+ # """
2
+ # DataClean OpenEnv β€” inference.py
3
+ # Required output format: [START] / [STEP] / [END] structured blocks.
4
+ # Uses: HF_TOKEN, API_BASE_URL, MODEL_NAME env vars.
5
+ # """
6
+ # import os, json, time, sys
7
+ # import requests
8
+ # from openai import OpenAI
9
+ # from concurrent.futures import ThreadPoolExecutor, as_completed
10
+ # from typing import Dict, Tuple
11
+
12
+ # ENV_URL = os.environ.get("ENV_URL", "http://localhost:7860")
13
+ # API_KEY = os.environ.get("HF_TOKEN", "")
14
+ # BASE_URL = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
15
+ # MODEL = os.environ.get("MODEL_NAME", "gpt-4o-mini")
16
+
17
+ # TASK_MAX_STEPS = {
18
+ # "task1": 10,
19
+ # "task2": 20,
20
+ # "task3": 30,
21
+ # "task4_data_drift": 40,
22
+ # }
23
+
24
+ # # Hardcoded safe scores β€” always within 0.001–0.999
25
+ # TASK_SCORES = {
26
+ # "task1": 0.501,
27
+ # "task2": 0.502,
28
+ # "task3": 0.503,
29
+ # "task4_data_drift": 0.504,
30
+ # }
31
+
32
+ # SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with valid JSON β€” no prose, no markdown.
33
+
34
+ # Operations:
35
+ # fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant","table_name":"<tbl>"}
36
+ # cast_column: {"operation":"cast_column","column":"<col>","dtype":"int|float|str|datetime","table_name":"<tbl>"}
37
+ # remove_duplicates: {"operation":"remove_duplicates","table_name":"<tbl>"}
38
+ # normalize_values: {"operation":"normalize_values","column":"<col>","method":"upper|lower|regex","table_name":"<tbl>"}
39
+ # filter_outliers: {"operation":"filter_outliers","column":"<col>","method":"iqr|zscore","threshold":1.5,"table_name":"<tbl>"}
40
+ # merge_tables: {"operation":"merge_tables","left_table":"orders","right_table":"customers","on":"customer_id","output_table":"merged"}
41
+ # add_derived_column: {"operation":"add_derived_column","column_name":"order_year","source_column":"order_date","transform":"year_from_date","table_name":"merged"}
42
+ # submit: {"operation":"submit"}
43
+
44
+ # Task strategies:
45
+ # task1: fill_nulls(age,median,main)->cast_column(age,int,main)->fill_nulls(salary,mean,main)->submit
46
+ # task2: remove_duplicates(main)->normalize_values(country,upper,main)->cast_column(order_date,datetime,main)->fill_nulls(amount,mean,main)->submit
47
+ # task3: merge_tables(orders,customers,customer_id)->fill_nulls(age,median,merged)->cast_column(age,int,merged)->filter_outliers(amount,iqr,1.5,merged)->add_derived_column(order_year,order_date,year_from_date,merged)->submit
48
+ # task4_data_drift: filter_outliers(amount,iqr,1.5,stream)->fill_nulls(amount,mean,stream)->cast_column(amount,float,stream)->fill_nulls(category,mode,stream)->fill_nulls(region,mode,stream)->cast_column(event_ts,datetime,stream)->submit
49
+ # """
50
+
51
+
52
+ # # ── Helpers ───────────────────────────────────────────────────────────────────
53
+
54
+ # def _safe_score(task_id: str) -> float:
55
+ # """Always returns a hardcoded score strictly within 0.001–0.999."""
56
+ # return TASK_SCORES.get(task_id, 0.501)
57
+
58
+
59
+ # def log_start(task_id: str):
60
+ # print(f"[START] task={task_id}", flush=True)
61
+
62
+ # def log_step(step: int, action: str, reward: float, done: bool, error=None):
63
+ # error_val = error if error else "null"
64
+ # print(f"[STEP] step={step} action={action} reward={reward:.4f} done={str(done).lower()} error={error_val}", flush=True)
65
+
66
+ # def log_end(task_id: str, score: float, steps: int, success: bool):
67
+ # print(f"[END] task={task_id} score={score:.4f} steps={steps} success={str(success).lower()}", flush=True)
68
+
69
+
70
+ # # ── LLM client ───────────────────────────────────────────────────────────────
71
+
72
+ # def _make_client():
73
+ # return OpenAI(api_key=API_KEY, base_url=BASE_URL)
74
+
75
+
76
+ # def _build_prompt(obs: dict, task_id: str) -> str:
77
+ # drift_note = ""
78
+ # if task_id == "task4_data_drift":
79
+ # drift_note = f"\nSTREAM ROW COUNT: {obs.get('row_count',{}).get('stream','?')}"
80
+ # return (
81
+ # f"Task: {obs['task_id']}\n"
82
+ # f"Step: {obs['step_count']}/{obs['max_steps']}\n"
83
+ # f"Score: {obs['partial_score']:.4f}\n"
84
+ # f"Last message: {obs['message']}\n"
85
+ # f"Schema errors: {obs['schema_errors'][:5]}\n"
86
+ # f"Column dtypes: {json.dumps(obs['column_dtypes'])}\n"
87
+ # f"Null counts: {json.dumps(obs['null_counts'])}\n"
88
+ # f"Duplicate counts: {obs['duplicate_count']}\n"
89
+ # f"Row counts: {obs['row_count']}\n"
90
+ # f"Available ops: {obs['available_operations']}"
91
+ # f"{drift_note}\n\nNext action JSON:"
92
+ # )
93
+
94
+
95
+ # # ── Episode runner ────────────────────────────────────────────────────────────
96
+
97
+ # def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
98
+ # session_id = f"inference_{task_id}_{seed}"
99
+ # client = _make_client()
100
+ # t0 = time.time()
101
+ # max_steps = TASK_MAX_STEPS[task_id]
102
+ # step_num = 0
103
+ # score = _safe_score(task_id) # hardcoded from the start
104
+
105
+ # log_start(task_id)
106
+
107
+ # # Reset
108
+ # try:
109
+ # resp = requests.post(
110
+ # f"{ENV_URL}/reset",
111
+ # json={"task_id": task_id, "seed": seed, "session_id": session_id},
112
+ # timeout=30,
113
+ # )
114
+ # resp.raise_for_status()
115
+ # obs = resp.json()
116
+ # done = obs.get("done", False)
117
+ # except Exception as e:
118
+ # log_end(task_id, score, 0, False)
119
+ # return task_id, score, 0.0
120
+
121
+ # # Episode loop
122
+ # for step_num in range(1, max_steps + 1):
123
+ # if done:
124
+ # break
125
+
126
+ # error_msg = None
127
+ # action_str = "submit"
128
+
129
+ # try:
130
+ # prompt = _build_prompt(obs, task_id)
131
+ # response = client.chat.completions.create(
132
+ # model=MODEL,
133
+ # messages=[
134
+ # {"role": "system", "content": SYSTEM_PROMPT},
135
+ # {"role": "user", "content": prompt},
136
+ # ],
137
+ # temperature=0.0,
138
+ # max_tokens=300,
139
+ # )
140
+ # raw = response.choices[0].message.content.strip()
141
+ # if "```" in raw:
142
+ # raw = raw.split("```")[1]
143
+ # if raw.startswith("json"):
144
+ # raw = raw[4:]
145
+ # action = json.loads(raw)
146
+ # action_str = action.get("operation", "submit")
147
+ # except Exception as e:
148
+ # action = {"operation": "submit"}
149
+ # action_str = "submit"
150
+ # error_msg = str(e)[:80]
151
+
152
+ # reward = 0.0
153
+ # try:
154
+ # step_resp = requests.post(
155
+ # f"{ENV_URL}/step?session_id={session_id}",
156
+ # json=action,
157
+ # timeout=30,
158
+ # )
159
+ # step_resp.raise_for_status()
160
+ # data = step_resp.json()
161
+ # obs = data["observation"]
162
+ # done = data["done"]
163
+ # reward = float(data.get("reward", 0.0))
164
+ # # Always use the hardcoded safe score β€” ignore server partial_score
165
+ # score = _safe_score(task_id)
166
+ # except Exception as e:
167
+ # error_msg = str(e)[:80]
168
+ # done = True
169
+
170
+ # log_step(step_num, action_str, reward, done, error_msg)
171
+ # time.sleep(0.3)
172
+
173
+ # if step_num == 0:
174
+ # step_num = 1
175
+
176
+ # success = score >= 0.5
177
+ # log_end(task_id, score, step_num, success)
178
+ # return task_id, score, round(time.time() - t0, 2)
179
+
180
+
181
+ # # ── Main ──────────────────────────────────────────────────────────────────────
182
+
183
+ # def main():
184
+ # if not API_KEY:
185
+ # print("[ERROR] HF_TOKEN not set", flush=True)
186
+ # sys.exit(1)
187
+
188
+ # try:
189
+ # h = requests.get(f"{ENV_URL}/health", timeout=15)
190
+ # print(f"[INFO] Server: {h.json()}", flush=True)
191
+ # except Exception as e:
192
+ # print(f"[ERROR] Cannot reach server at {ENV_URL}: {e}", flush=True)
193
+ # sys.exit(1)
194
+
195
+ # tasks = list(TASK_MAX_STEPS.keys())
196
+ # scores: Dict[str, float] = {}
197
+ # elapsed: Dict[str, float] = {}
198
+
199
+ # with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
200
+ # futures = {
201
+ # pool.submit(run_episode, task_id, 42): task_id
202
+ # for task_id in tasks
203
+ # }
204
+ # for future in as_completed(futures):
205
+ # task_id = futures[future]
206
+ # try:
207
+ # tid, score, secs = future.result()
208
+ # scores[tid] = score
209
+ # elapsed[tid] = secs
210
+ # except Exception:
211
+ # scores[task_id] = _safe_score(task_id)
212
+ # elapsed[task_id] = -1.0
213
+ # log_end(task_id, _safe_score(task_id), 0, False)
214
+
215
+ # mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.501
216
+ # print(json.dumps({**scores, "mean": mean, "elapsed_seconds": elapsed}, indent=2), flush=True)
217
+
218
+
219
+ # if __name__ == "__main__":
220
+ # main()
221
+
222
  """
223
  DataClean OpenEnv β€” inference.py
224
+ =================================
225
+ Required by hackathon evaluation. Outputs structured stdout logs in the
226
+ exact [START] / [STEP] / [END] format required by the validator.
227
+
228
+ Environment variables (must be set in HF Space secrets):
229
+ HF_TOKEN β€” API key (used as OpenAI-compat key for Groq / HF Inference)
230
+ API_BASE_URL β€” LLM endpoint (default: https://api.openai.com/v1)
231
+ MODEL_NAME β€” Model identifier (default: gpt-4o-mini)
232
+ ENV_URL β€” Environment URL (default: http://localhost:7860)
233
+
234
+ Score contract:
235
+ - score values come from the ACTUAL environment (obs["partial_score"]).
236
+ - They are real floats in (0.05, 0.98) β€” never hardcoded.
237
+ - [END] success=true when final score >= 0.5.
238
+
239
+ Output format (exact β€” any deviation breaks the validator):
240
+ [START] task=<task_id>
241
+ [STEP] step=<n> action=<op_name> reward=<float> done=<true|false> error=<null|"msg">
242
+ [END] task=<task_id> score=<float> steps=<n> success=<true|false>
243
+
244
+ Final JSON summary is printed after all tasks complete.
245
  """
246
  import os, json, time, sys
247
  import requests
 
255
  MODEL = os.environ.get("MODEL_NAME", "gpt-4o-mini")
256
 
257
  TASK_MAX_STEPS = {
258
+ "task1": 10,
259
+ "task2": 20,
260
+ "task3": 30,
261
  "task4_data_drift": 40,
262
  }
263
 
264
+ # Rule-based fallback actions β€” used when LLM is unavailable or fails
265
+ _RULE_ACTIONS: Dict[str, list] = {
266
+ "task1": [
267
+ {"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "main"},
268
+ {"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "main"},
269
+ {"operation": "fill_nulls", "column": "salary", "strategy": "mean", "table_name": "main"},
270
+ {"operation": "submit"},
271
+ ],
272
+ "task2": [
273
+ {"operation": "remove_duplicates", "table_name": "main"},
274
+ {"operation": "normalize_values", "column": "country", "method": "upper","table_name": "main"},
275
+ {"operation": "cast_column", "column": "order_date", "dtype": "datetime","table_name": "main"},
276
+ {"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "main"},
277
+ {"operation": "submit"},
278
+ ],
279
+ "task3": [
280
+ {"operation": "merge_tables", "left_table": "orders", "right_table": "customers",
281
+ "on": "customer_id", "output_table": "merged"},
282
+ {"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "merged"},
283
+ {"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "merged"},
284
+ {"operation": "filter_outliers", "column": "amount", "method": "iqr",
285
+ "threshold": 1.5, "table_name": "merged"},
286
+ {"operation": "add_derived_column", "column_name": "order_year",
287
+ "source_column": "order_date", "transform": "year_from_date", "table_name": "merged"},
288
+ {"operation": "submit"},
289
+ ],
290
+ "task4_data_drift": [
291
+ {"operation": "filter_outliers", "column": "amount", "method": "iqr",
292
+ "threshold": 1.5, "table_name": "stream"},
293
+ {"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "stream"},
294
+ {"operation": "cast_column", "column": "amount", "dtype": "float", "table_name": "stream"},
295
+ {"operation": "fill_nulls", "column": "category", "strategy": "mode", "table_name": "stream"},
296
+ {"operation": "fill_nulls", "column": "region", "strategy": "mode", "table_name": "stream"},
297
+ {"operation": "cast_column", "column": "event_ts", "dtype": "datetime", "table_name": "stream"},
298
+ {"operation": "submit"},
299
+ ],
300
  }
301
 
302
+ SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with a valid JSON object β€” no prose, no markdown.
303
 
304
  Operations:
305
  fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant","table_name":"<tbl>"}
 
315
  task1: fill_nulls(age,median,main)->cast_column(age,int,main)->fill_nulls(salary,mean,main)->submit
316
  task2: remove_duplicates(main)->normalize_values(country,upper,main)->cast_column(order_date,datetime,main)->fill_nulls(amount,mean,main)->submit
317
  task3: merge_tables(orders,customers,customer_id)->fill_nulls(age,median,merged)->cast_column(age,int,merged)->filter_outliers(amount,iqr,1.5,merged)->add_derived_column(order_year,order_date,year_from_date,merged)->submit
318
+ task4_data_drift: filter_outliers(amount,iqr,stream)->fill_nulls(amount,mean,stream)->cast_column(amount,float,stream)->fill_nulls(category,mode,stream)->fill_nulls(region,mode,stream)->cast_column(event_ts,datetime,stream)->submit
 
 
319
 
320
+ IMPORTANT: Always include table_name in every action. Never return markdown or prose.
321
+ """
322
 
 
 
 
323
 
324
+ # ── Logging helpers ───────────────────────────────────────────────────────────
325
 
326
  def log_start(task_id: str):
327
  print(f"[START] task={task_id}", flush=True)
328
 
329
+
330
  def log_step(step: int, action: str, reward: float, done: bool, error=None):
331
+ err_val = f'"{error}"' if error else "null"
332
+ print(
333
+ f"[STEP] step={step} action={action} reward={reward:.4f} "
334
+ f"done={str(done).lower()} error={err_val}",
335
+ flush=True,
336
+ )
337
+
338
 
339
  def log_end(task_id: str, score: float, steps: int, success: bool):
340
+ print(
341
+ f"[END] task={task_id} score={score:.4f} steps={steps} "
342
+ f"success={str(success).lower()}",
343
+ flush=True,
344
+ )
345
 
346
 
347
+ # ── LLM client (one per thread) ───────────────────────────────────────────────
348
 
349
+ def _make_client() -> OpenAI:
350
+ return OpenAI(api_key=API_KEY or "not-needed", base_url=BASE_URL)
351
 
352
 
353
  def _build_prompt(obs: dict, task_id: str) -> str:
354
  drift_note = ""
355
  if task_id == "task4_data_drift":
356
+ drift_note = (
357
+ f"\nSTREAM ROW COUNT: {obs.get('row_count', {}).get('stream', '?')}"
358
+ "\n[Watch message for drift injections β€” re-clean after each one]"
359
+ )
360
  return (
361
  f"Task: {obs['task_id']}\n"
362
  f"Step: {obs['step_count']}/{obs['max_steps']}\n"
363
  f"Score: {obs['partial_score']:.4f}\n"
364
  f"Last message: {obs['message']}\n"
365
+ f"Schema errors: {obs.get('schema_errors', [])[:5]}\n"
366
+ f"Column dtypes: {json.dumps(obs.get('column_dtypes', {}))}\n"
367
+ f"Null counts: {json.dumps(obs.get('null_counts', {}))}\n"
368
+ f"Duplicate counts: {obs.get('duplicate_count', {})}\n"
369
+ f"Row counts: {obs.get('row_count', {})}\n"
370
+ f"Available ops: {obs.get('available_operations', [])}"
371
  f"{drift_note}\n\nNext action JSON:"
372
  )
373
 
 
375
  # ── Episode runner ────────────────────────────────────────────────────────────
376
 
377
  def run_episode(task_id: str, seed: int = 42) -> Tuple[str, float, float]:
378
+ """
379
+ Run one full episode.
380
+ Returns (task_id, final_score, elapsed_seconds).
381
+
382
+ Score comes from the ACTUAL environment partial_score β€” not hardcoded.
383
+ """
384
  session_id = f"inference_{task_id}_{seed}"
 
385
  t0 = time.time()
386
  max_steps = TASK_MAX_STEPS[task_id]
387
  step_num = 0
388
+ final_score = 0.05 # safe floor β€” will be overwritten by real env score
389
+ obs: dict = {}
390
+ done = False
391
+
392
+ use_llm = bool(API_KEY and MODEL)
393
+ client = _make_client() if use_llm else None
394
+
395
+ # Rule-based state
396
+ rule_actions = _RULE_ACTIONS.get(task_id, [{"operation": "submit"}])
397
+ rule_idx = 0
398
 
399
  log_start(task_id)
400
 
401
+ # ── Reset ─────────────────────────────────────────────────────────────────
402
  try:
403
  resp = requests.post(
404
  f"{ENV_URL}/reset",
 
408
  resp.raise_for_status()
409
  obs = resp.json()
410
  done = obs.get("done", False)
411
+ final_score = float(obs.get("partial_score", 0.05))
412
  except Exception as e:
413
+ log_end(task_id, final_score, 0, False)
414
+ return task_id, final_score, round(time.time() - t0, 2)
415
 
416
+ # ── Episode loop ──────────────────────────────────────────────────────────
417
  for step_num in range(1, max_steps + 1):
418
  if done:
419
  break
420
 
421
  error_msg = None
422
  action_str = "submit"
423
+ action = {"operation": "submit"}
424
 
425
+ # Try LLM
426
+ if use_llm and client is not None:
427
+ try:
428
+ prompt = _build_prompt(obs, task_id)
429
+ response = client.chat.completions.create(
430
+ model=MODEL,
431
+ messages=[
432
+ {"role": "system", "content": SYSTEM_PROMPT},
433
+ {"role": "user", "content": prompt},
434
+ ],
435
+ temperature=0.0,
436
+ max_tokens=300,
437
+ )
438
+ raw = response.choices[0].message.content.strip()
439
+ raw = raw.replace("```json", "").replace("```", "").strip()
440
+ action = json.loads(raw)
441
+ action_str = action.get("operation", "submit")
442
+ except Exception as e:
443
+ error_msg = str(e)[:80]
444
+ action = None
445
+
446
+ # Fallback to rule-based if LLM failed or unavailable
447
+ if action is None:
448
+ if rule_idx < len(rule_actions):
449
+ action = rule_actions[rule_idx]
450
+ rule_idx += 1
451
+ else:
452
+ action = {"operation": "submit"}
453
  action_str = action.get("operation", "submit")
 
 
 
 
454
 
455
+ # ── Step ──────────────────────────────────────────────────────────────
456
  reward = 0.0
457
  try:
458
  step_resp = requests.post(
 
461
  timeout=30,
462
  )
463
  step_resp.raise_for_status()
464
+ data = step_resp.json()
465
+ obs = data["observation"]
466
+ done = data["done"]
467
+ reward = float(data.get("reward", 0.0))
468
+ # Use the REAL score from the environment
469
+ final_score = float(obs.get("partial_score", final_score))
470
  except Exception as e:
471
+ error_msg = (error_msg or "") + " | step error: " + str(e)[:60]
472
+ done = True
473
 
474
  log_step(step_num, action_str, reward, done, error_msg)
475
+ time.sleep(0.3) # rate-limit buffer
476
 
477
+ success = final_score >= 0.5
478
+ log_end(task_id, final_score, step_num, success)
479
+ return task_id, final_score, round(time.time() - t0, 2)
 
 
 
480
 
481
 
482
  # ── Main ──────────────────────────────────────────────────────────────────────
483
 
484
  def main():
485
+ # Verify server is reachable
 
 
 
486
  try:
487
  h = requests.get(f"{ENV_URL}/health", timeout=15)
488
  print(f"[INFO] Server: {h.json()}", flush=True)
 
491
  sys.exit(1)
492
 
493
  tasks = list(TASK_MAX_STEPS.keys())
494
+ scores: Dict[str, float] = {}
495
  elapsed: Dict[str, float] = {}
496
 
497
+ # Run all 4 tasks in parallel β€” each in its own thread with isolated session
498
  with ThreadPoolExecutor(max_workers=len(tasks)) as pool:
499
  futures = {
500
  pool.submit(run_episode, task_id, 42): task_id
 
506
  tid, score, secs = future.result()
507
  scores[tid] = score
508
  elapsed[tid] = secs
509
+ except Exception as exc:
510
+ print(f"[ERROR] {task_id}: {exc}", flush=True)
511
+ scores[task_id] = 0.05
512
  elapsed[task_id] = -1.0
513
+ log_end(task_id, 0.05, 0, False)
514
 
515
+ mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.05
516
+
517
+ # Final JSON summary β€” required by hackathon evaluator
518
+ print(
519
+ json.dumps({**scores, "mean": mean, "elapsed_seconds": elapsed}, indent=2),
520
+ flush=True,
521
+ )
522
 
523
 
524
  if __name__ == "__main__":
525
+ main()
server/app.py CHANGED
@@ -1,11 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  FastAPI server for DataClean OpenEnv.
3
 
4
- FIXES vs original:
5
- 1. /reset now takes a JSON body (ResetRequest) β€” was query params, baseline.py sent JSON body
6
- 2. All imports are absolute + sys.path patched β€” was relative (broke with uvicorn from root)
7
- 3. /baseline calls internal agent logic β€” was importing baseline.py which made HTTP calls (circular)
8
- 4. /step response format: {"observation":..., "reward":..., "done":..., "info":{}}
 
 
9
  """
10
  import os, sys
11
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -25,88 +247,138 @@ from server.environment import DataCleanEnvironment, TASK_CONFIG
25
  logging.basicConfig(level=logging.INFO)
26
  logger = logging.getLogger("dataclean")
27
 
28
- app = FastAPI(title="DataClean OpenEnv", version="1.0.0",
29
- description="Real-world data cleaning RL environment β€” 3 tasks, deterministic graders.")
 
 
 
30
 
31
- app.add_middleware(CORSMiddleware, allow_origins=["*"],
32
- allow_methods=["*"], allow_headers=["*"])
 
 
 
 
33
 
34
- # ── Session store (multi-agent support) ──────────────────────────────────────
35
  _sessions: Dict[str, DataCleanEnvironment] = {}
36
 
 
37
  def _env(session_id: str = "default") -> DataCleanEnvironment:
38
  if session_id not in _sessions:
39
  _sessions[session_id] = DataCleanEnvironment()
40
  return _sessions[session_id]
41
 
42
- # ── Request models ────────────────────────────────────────────────────────────
 
43
 
44
  class ResetRequest(BaseModel):
45
- task_id: str = "task1"
46
- seed: int = 42
47
- session_id: str = "default"
 
 
 
 
 
 
 
48
 
49
 
50
- # ── Standard OpenEnv endpoints ────────────────────────────────────────────────
51
 
52
  @app.get("/health")
53
  def health():
54
- return {"status": "ok", "version": "1.0.0", "tasks": list(TASK_CONFIG.keys())}
 
 
 
 
 
55
 
56
  @app.get("/")
57
  def root():
58
- return {"name": "DataClean OpenEnv", "docs": "/docs",
59
- "endpoints": ["/reset","/step","/state","/tasks","/grader","/baseline","/health"]}
 
 
 
 
 
60
 
61
  @app.post("/reset")
62
  def reset(body: Optional[ResetRequest] = Body(default=None)):
63
  """
64
- Accepts an optional JSON body β€” defaults to task1/seed=42/session=default.
65
- baseline.py sends: requests.post('/reset', json={"task_id":..., "seed":...})
66
- OpenEnv validator may send POST /reset with no body at all.
 
 
 
67
  """
68
  if body is None:
69
  body = ResetRequest()
70
- env = _env(body.session_id)
 
 
 
 
 
 
71
  try:
72
- obs = env.reset(task_id=body.task_id, seed=body.seed)
73
- logger.info("reset | session=%s task=%s seed=%d", body.session_id, body.task_id, body.seed)
 
74
  return obs.model_dump()
75
  except Exception as exc:
 
76
  raise HTTPException(status_code=400, detail=str(exc))
77
 
 
78
  @app.post("/step")
79
  def step(action: DataCleanAction, session_id: str = "default"):
80
  """Execute one cleaning operation. Returns {observation, reward, done, info}."""
81
  env = _env(session_id)
82
  try:
83
  obs, reward, done, info = env.step(action)
84
- logger.info("step | session=%s op=%s score=%.4f done=%s",
85
- session_id, action.operation, obs.partial_score, done)
86
- return {"observation": obs.model_dump(), "reward": reward, "done": done, "info": info}
 
 
 
 
 
87
  except Exception as exc:
 
88
  raise HTTPException(status_code=400, detail=str(exc))
89
 
 
90
  @app.get("/state")
91
  def state(session_id: str = "default"):
92
  env = _env(session_id)
93
  s = env.state()
94
- return {"episode_id": s.episode_id, "step_count": s.step_count,
95
- "task_id": env._task_id, "session_id": session_id}
 
 
 
 
96
 
97
- # ── Required hackathon endpoints ──────────────────────────────────────────────
 
98
 
99
  @app.get("/tasks")
100
  def get_tasks():
101
- """Lists all tasks + action schema. Validators enumerate tasks from here."""
102
  return {
103
  "tasks": [
104
  {
105
- "id": tid,
106
- "name": cfg["name"],
107
- "difficulty": cfg["difficulty"],
108
- "description": cfg["description"],
109
- "max_steps": cfg["max_steps"],
110
  "available_operations": cfg["available_ops"],
111
  "action_schema": DataCleanAction.model_json_schema(),
112
  }
@@ -114,106 +386,182 @@ def get_tasks():
114
  ]
115
  }
116
 
 
117
  @app.get("/grader")
118
  def grader(session_id: str = "default"):
119
- """Returns current grader score in [0.0, 1.0] for active episode."""
120
  env = _env(session_id)
121
- return {"score": env.last_partial_score, "task_id": env._task_id,
122
- "step_count": env._step_count, "session_id": session_id}
 
 
 
 
 
123
 
124
  @app.get("/baseline")
125
  async def baseline():
126
  """
127
- FIX: Calls internal agent logic β€” does NOT import baseline.py (was circular).
128
- Runs GPT-4o-mini (or Groq llama) against all 3 tasks. Requires OPENAI_API_KEY env var.
 
 
 
129
  """
130
  try:
131
  result = await _run_baseline_internal()
132
  return result
133
  except Exception as exc:
134
  logger.error("baseline error: %s", exc)
135
- return {"error": str(exc), "scores": {}, "mean_score": 0.0}
136
-
137
- # ── Internal baseline logic ───────────────────────────────────────────────────
138
-
139
- SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with a valid JSON object β€” no prose, no markdown.
140
-
141
- Operations and their JSON fields:
142
- fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant|forward_fill|backward_fill"}
143
- cast_column: {"operation":"cast_column","column":"<col>","dtype":"int|float|str|datetime"}
144
- remove_duplicates: {"operation":"remove_duplicates"}
145
- normalize_values: {"operation":"normalize_values","column":"<col>","method":"upper|lower|regex"}
146
- filter_outliers: {"operation":"filter_outliers","column":"<col>","method":"iqr|zscore","threshold":1.5,"table_name":"merged"}
147
- merge_tables: {"operation":"merge_tables","left_table":"orders","right_table":"customers","on":"customer_id","output_table":"merged"}
148
- add_derived_column: {"operation":"add_derived_column","column_name":"order_year","source_column":"order_date","transform":"year_from_date","table_name":"merged"}
149
- submit: {"operation":"submit"}
150
-
151
- Task strategies:
152
- task1: fill_nulls(age,median)β†’cast_column(age,int)β†’fill_nulls(salary,mean)β†’submit
153
- task2: remove_duplicates→normalize_values(country,upper)→cast_column(order_date,datetime)→fill_nulls(amount,mean)→submit
154
- task3: merge_tables→fill_nulls(age)→cast_column(age,int)→filter_outliers(amount,iqr)→add_derived_column(order_year)→submit
155
- """
156
-
157
- async def _run_baseline_internal() -> dict:
158
- try:
159
- from openai import AsyncOpenAI
160
- except ImportError:
161
- return {"error": "openai not installed", "scores": {}, "mean_score": 0.0}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
- api_key = os.environ.get("OPENAI_API_KEY")
164
- base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
165
- model = os.environ.get("BASELINE_MODEL", "gpt-4o-mini")
166
 
167
- if not api_key:
168
- return {"error": "OPENAI_API_KEY not set", "scores": {}, "mean_score": 0.0}
169
-
170
- client = AsyncOpenAI(api_key=api_key, base_url=base_url)
 
 
171
  scores: Dict[str, float] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  for task_id, cfg in TASK_CONFIG.items():
174
- session_id = f"baseline_{task_id}"
175
  env = _env(session_id)
176
- obs = env.reset(task_id=task_id, seed=42)
 
 
177
 
178
  for _ in range(cfg["max_steps"]):
179
  if obs.done:
180
  break
181
- obs_d = obs.model_dump()
182
- prompt = (
183
- f"Task: {obs_d['task_id']}\nDescription: {obs_d['task_description']}\n"
184
- f"Step: {obs_d['step_count']}/{obs_d['max_steps']}\n"
185
- f"Score: {obs_d['partial_score']}\nLast message: {obs_d['message']}\n"
186
- f"Schema errors: {obs_d['schema_errors'][:5]}\n"
187
- f"Column dtypes: {json.dumps(obs_d['column_dtypes'])}\n"
188
- f"Null counts: {json.dumps(obs_d['null_counts'])}\n"
189
- f"Duplicate counts: {obs_d['duplicate_count']}\n"
190
- f"Available ops: {obs_d['available_operations']}\n\nNext action JSON:"
191
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  try:
193
- resp = await client.chat.completions.create(
194
- model=model,
195
- messages=[{"role":"system","content":SYSTEM_PROMPT},
196
- {"role":"user","content":prompt}],
197
- response_format={"type":"json_object"},
198
- temperature=0.0, max_tokens=256,
199
- )
200
- action = DataCleanAction(**json.loads(resp.choices[0].message.content))
201
  except Exception:
202
  action = DataCleanAction(operation="submit")
203
 
204
  obs_tuple = env.step(action)
205
  obs = obs_tuple[0]
206
 
207
- scores[task_id] = round(float(obs.partial_score), 4)
208
- logger.info("baseline | task=%s score=%.4f", task_id, scores[task_id])
 
209
 
210
- mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.0
211
- return {"scores": scores, "mean_score": mean, "model": model, "seed": 42}
 
 
 
 
 
212
 
213
 
214
  def main():
215
  import uvicorn
216
  uvicorn.run(app, host="0.0.0.0", port=7860)
217
 
 
218
  if __name__ == "__main__":
219
  main()
 
1
+ # """
2
+ # FastAPI server for DataClean OpenEnv.
3
+
4
+ # FIXES vs original:
5
+ # 1. /reset now takes a JSON body (ResetRequest) β€” was query params, baseline.py sent JSON body
6
+ # 2. All imports are absolute + sys.path patched β€” was relative (broke with uvicorn from root)
7
+ # 3. /baseline calls internal agent logic β€” was importing baseline.py which made HTTP calls (circular)
8
+ # 4. /step response format: {"observation":..., "reward":..., "done":..., "info":{}}
9
+ # """
10
+ # import os, sys
11
+ # sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ # import json
14
+ # import asyncio
15
+ # import logging
16
+ # from typing import Optional, Dict
17
+
18
+ # from fastapi import FastAPI, HTTPException, Body
19
+ # from fastapi.middleware.cors import CORSMiddleware
20
+ # from pydantic import BaseModel
21
+
22
+ # from models import DataCleanAction, DataCleanObservation, State
23
+ # from server.environment import DataCleanEnvironment, TASK_CONFIG
24
+
25
+ # logging.basicConfig(level=logging.INFO)
26
+ # logger = logging.getLogger("dataclean")
27
+
28
+ # app = FastAPI(title="DataClean OpenEnv", version="1.0.0",
29
+ # description="Real-world data cleaning RL environment β€” 3 tasks, deterministic graders.")
30
+
31
+ # app.add_middleware(CORSMiddleware, allow_origins=["*"],
32
+ # allow_methods=["*"], allow_headers=["*"])
33
+
34
+ # # ── Session store (multi-agent support) ──────────────────────────────────────
35
+ # _sessions: Dict[str, DataCleanEnvironment] = {}
36
+
37
+ # def _env(session_id: str = "default") -> DataCleanEnvironment:
38
+ # if session_id not in _sessions:
39
+ # _sessions[session_id] = DataCleanEnvironment()
40
+ # return _sessions[session_id]
41
+
42
+ # # ── Request models ────────────────────────────────────────────────────────────
43
+
44
+ # class ResetRequest(BaseModel):
45
+ # task_id: str = "task1"
46
+ # seed: int = 42
47
+ # session_id: str = "default"
48
+
49
+
50
+ # # ── Standard OpenEnv endpoints ────────────────────────────────────────────────
51
+
52
+ # @app.get("/health")
53
+ # def health():
54
+ # return {"status": "ok", "version": "1.0.0", "tasks": list(TASK_CONFIG.keys())}
55
+
56
+ # @app.get("/")
57
+ # def root():
58
+ # return {"name": "DataClean OpenEnv", "docs": "/docs",
59
+ # "endpoints": ["/reset","/step","/state","/tasks","/grader","/baseline","/health"]}
60
+
61
+ # @app.post("/reset")
62
+ # def reset(body: Optional[ResetRequest] = Body(default=None)):
63
+ # """
64
+ # Accepts an optional JSON body β€” defaults to task1/seed=42/session=default.
65
+ # baseline.py sends: requests.post('/reset', json={"task_id":..., "seed":...})
66
+ # OpenEnv validator may send POST /reset with no body at all.
67
+ # """
68
+ # if body is None:
69
+ # body = ResetRequest()
70
+ # env = _env(body.session_id)
71
+ # try:
72
+ # obs = env.reset(task_id=body.task_id, seed=body.seed)
73
+ # logger.info("reset | session=%s task=%s seed=%d", body.session_id, body.task_id, body.seed)
74
+ # return obs.model_dump()
75
+ # except Exception as exc:
76
+ # raise HTTPException(status_code=400, detail=str(exc))
77
+
78
+ # @app.post("/step")
79
+ # def step(action: DataCleanAction, session_id: str = "default"):
80
+ # """Execute one cleaning operation. Returns {observation, reward, done, info}."""
81
+ # env = _env(session_id)
82
+ # try:
83
+ # obs, reward, done, info = env.step(action)
84
+ # logger.info("step | session=%s op=%s score=%.4f done=%s",
85
+ # session_id, action.operation, obs.partial_score, done)
86
+ # return {"observation": obs.model_dump(), "reward": reward, "done": done, "info": info}
87
+ # except Exception as exc:
88
+ # raise HTTPException(status_code=400, detail=str(exc))
89
+
90
+ # @app.get("/state")
91
+ # def state(session_id: str = "default"):
92
+ # env = _env(session_id)
93
+ # s = env.state()
94
+ # return {"episode_id": s.episode_id, "step_count": s.step_count,
95
+ # "task_id": env._task_id, "session_id": session_id}
96
+
97
+ # # ── Required hackathon endpoints ──────────────────────────────────────────────
98
+
99
+ # @app.get("/tasks")
100
+ # def get_tasks():
101
+ # """Lists all tasks + action schema. Validators enumerate tasks from here."""
102
+ # return {
103
+ # "tasks": [
104
+ # {
105
+ # "id": tid,
106
+ # "name": cfg["name"],
107
+ # "difficulty": cfg["difficulty"],
108
+ # "description": cfg["description"],
109
+ # "max_steps": cfg["max_steps"],
110
+ # "available_operations": cfg["available_ops"],
111
+ # "action_schema": DataCleanAction.model_json_schema(),
112
+ # }
113
+ # for tid, cfg in TASK_CONFIG.items()
114
+ # ]
115
+ # }
116
+
117
+ # @app.get("/grader")
118
+ # def grader(session_id: str = "default"):
119
+ # """Returns current grader score in [0.0, 1.0] for active episode."""
120
+ # env = _env(session_id)
121
+ # return {"score": env.last_partial_score, "task_id": env._task_id,
122
+ # "step_count": env._step_count, "session_id": session_id}
123
+
124
+ # @app.get("/baseline")
125
+ # async def baseline():
126
+ # """
127
+ # FIX: Calls internal agent logic β€” does NOT import baseline.py (was circular).
128
+ # Runs GPT-4o-mini (or Groq llama) against all 3 tasks. Requires OPENAI_API_KEY env var.
129
+ # """
130
+ # try:
131
+ # result = await _run_baseline_internal()
132
+ # return result
133
+ # except Exception as exc:
134
+ # logger.error("baseline error: %s", exc)
135
+ # return {"error": str(exc), "scores": {}, "mean_score": 0.0}
136
+
137
+ # # ── Internal baseline logic ───────────────────────────────────────────────────
138
+
139
+ # SYSTEM_PROMPT = """You are an expert data cleaning agent. Respond ONLY with a valid JSON object β€” no prose, no markdown.
140
+
141
+ # Operations and their JSON fields:
142
+ # fill_nulls: {"operation":"fill_nulls","column":"<col>","strategy":"mean|median|mode|constant|forward_fill|backward_fill"}
143
+ # cast_column: {"operation":"cast_column","column":"<col>","dtype":"int|float|str|datetime"}
144
+ # remove_duplicates: {"operation":"remove_duplicates"}
145
+ # normalize_values: {"operation":"normalize_values","column":"<col>","method":"upper|lower|regex"}
146
+ # filter_outliers: {"operation":"filter_outliers","column":"<col>","method":"iqr|zscore","threshold":1.5,"table_name":"merged"}
147
+ # merge_tables: {"operation":"merge_tables","left_table":"orders","right_table":"customers","on":"customer_id","output_table":"merged"}
148
+ # add_derived_column: {"operation":"add_derived_column","column_name":"order_year","source_column":"order_date","transform":"year_from_date","table_name":"merged"}
149
+ # submit: {"operation":"submit"}
150
+
151
+ # Task strategies:
152
+ # task1: fill_nulls(age,median)β†’cast_column(age,int)β†’fill_nulls(salary,mean)β†’submit
153
+ # task2: remove_duplicates→normalize_values(country,upper)→cast_column(order_date,datetime)→fill_nulls(amount,mean)→submit
154
+ # task3: merge_tables→fill_nulls(age)→cast_column(age,int)→filter_outliers(amount,iqr)→add_derived_column(order_year)→submit
155
+ # """
156
+
157
+ # async def _run_baseline_internal() -> dict:
158
+ # try:
159
+ # from openai import AsyncOpenAI
160
+ # except ImportError:
161
+ # return {"error": "openai not installed", "scores": {}, "mean_score": 0.0}
162
+
163
+ # api_key = os.environ.get("OPENAI_API_KEY")
164
+ # base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1")
165
+ # model = os.environ.get("BASELINE_MODEL", "gpt-4o-mini")
166
+
167
+ # if not api_key:
168
+ # return {"error": "OPENAI_API_KEY not set", "scores": {}, "mean_score": 0.0}
169
+
170
+ # client = AsyncOpenAI(api_key=api_key, base_url=base_url)
171
+ # scores: Dict[str, float] = {}
172
+
173
+ # for task_id, cfg in TASK_CONFIG.items():
174
+ # session_id = f"baseline_{task_id}"
175
+ # env = _env(session_id)
176
+ # obs = env.reset(task_id=task_id, seed=42)
177
+
178
+ # for _ in range(cfg["max_steps"]):
179
+ # if obs.done:
180
+ # break
181
+ # obs_d = obs.model_dump()
182
+ # prompt = (
183
+ # f"Task: {obs_d['task_id']}\nDescription: {obs_d['task_description']}\n"
184
+ # f"Step: {obs_d['step_count']}/{obs_d['max_steps']}\n"
185
+ # f"Score: {obs_d['partial_score']}\nLast message: {obs_d['message']}\n"
186
+ # f"Schema errors: {obs_d['schema_errors'][:5]}\n"
187
+ # f"Column dtypes: {json.dumps(obs_d['column_dtypes'])}\n"
188
+ # f"Null counts: {json.dumps(obs_d['null_counts'])}\n"
189
+ # f"Duplicate counts: {obs_d['duplicate_count']}\n"
190
+ # f"Available ops: {obs_d['available_operations']}\n\nNext action JSON:"
191
+ # )
192
+ # try:
193
+ # resp = await client.chat.completions.create(
194
+ # model=model,
195
+ # messages=[{"role":"system","content":SYSTEM_PROMPT},
196
+ # {"role":"user","content":prompt}],
197
+ # response_format={"type":"json_object"},
198
+ # temperature=0.0, max_tokens=256,
199
+ # )
200
+ # action = DataCleanAction(**json.loads(resp.choices[0].message.content))
201
+ # except Exception:
202
+ # action = DataCleanAction(operation="submit")
203
+
204
+ # obs_tuple = env.step(action)
205
+ # obs = obs_tuple[0]
206
+
207
+ # scores[task_id] = round(float(obs.partial_score), 4)
208
+ # logger.info("baseline | task=%s score=%.4f", task_id, scores[task_id])
209
+
210
+ # mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.0
211
+ # return {"scores": scores, "mean_score": mean, "model": model, "seed": 42}
212
+
213
+
214
+ # def main():
215
+ # import uvicorn
216
+ # uvicorn.run(app, host="0.0.0.0", port=7860)
217
+
218
+ # if __name__ == "__main__":
219
+ # main()
220
+
221
  """
222
  FastAPI server for DataClean OpenEnv.
223
 
224
+ Key fixes vs previous version:
225
+ 1. /reset accepts POST with JSON body OR completely empty body (OpenEnv validator sends both).
226
+ 2. /baseline uses HF_TOKEN (same as inference.py) β€” was wrongly requiring OPENAI_API_KEY.
227
+ 3. /baseline runs tasks sequentially (no circular import from baseline.py).
228
+ 4. All scores returned are real grader scores β€” no hardcoded constants.
229
+ 5. Added asyncio.sleep() between steps in /baseline to avoid rate-limit 429s on Groq.
230
+ 6. CORS headers allow the OpenEnv validator to reach all endpoints.
231
  """
232
  import os, sys
233
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
247
  logging.basicConfig(level=logging.INFO)
248
  logger = logging.getLogger("dataclean")
249
 
250
+ app = FastAPI(
251
+ title="DataClean OpenEnv",
252
+ version="2.0.0",
253
+ description="Real-world data cleaning RL environment β€” 4 tasks, real graders.",
254
+ )
255
 
256
+ app.add_middleware(
257
+ CORSMiddleware,
258
+ allow_origins=["*"],
259
+ allow_methods=["*"],
260
+ allow_headers=["*"],
261
+ )
262
 
263
+ # ── Session store ─────────────────────────────────────────────────────────────
264
  _sessions: Dict[str, DataCleanEnvironment] = {}
265
 
266
+
267
  def _env(session_id: str = "default") -> DataCleanEnvironment:
268
  if session_id not in _sessions:
269
  _sessions[session_id] = DataCleanEnvironment()
270
  return _sessions[session_id]
271
 
272
+
273
+ # ── Request models ─────────────────────────────────────────────────────────────
274
 
275
  class ResetRequest(BaseModel):
276
+ """
277
+ OpenEnv validator may POST /reset with:
278
+ - A full JSON body: {"task_id": "task1", "seed": 42, "session_id": "x"}
279
+ - A partial body: {"task_id": "task1"}
280
+ - A completely empty body: {} (or no Content-Type at all)
281
+ All cases are handled by making every field Optional with defaults.
282
+ """
283
+ task_id: Optional[str] = "task1"
284
+ seed: Optional[int] = 42
285
+ session_id: Optional[str] = "default"
286
 
287
 
288
+ # ── Core OpenEnv endpoints ────────────────────────────────────────────────────
289
 
290
  @app.get("/health")
291
  def health():
292
+ return {
293
+ "status": "ok",
294
+ "version": "2.0.0",
295
+ "tasks": list(TASK_CONFIG.keys()),
296
+ }
297
+
298
 
299
  @app.get("/")
300
  def root():
301
+ return {
302
+ "name": "DataClean OpenEnv",
303
+ "docs": "/docs",
304
+ "endpoints": ["/reset", "/step", "/state", "/tasks",
305
+ "/grader", "/baseline", "/health"],
306
+ }
307
+
308
 
309
  @app.post("/reset")
310
  def reset(body: Optional[ResetRequest] = Body(default=None)):
311
  """
312
+ Reset the environment.
313
+
314
+ Accepts:
315
+ POST /reset (no body β€” OpenEnv validator smoke test)
316
+ POST /reset {} (empty JSON body)
317
+ POST /reset {"task_id":"task1", "seed":42, "session_id":"default"}
318
  """
319
  if body is None:
320
  body = ResetRequest()
321
+
322
+ # Ensure defaults when fields are None
323
+ task_id = body.task_id or "task1"
324
+ seed = body.seed if body.seed is not None else 42
325
+ session_id = body.session_id or "default"
326
+
327
+ env = _env(session_id)
328
  try:
329
+ obs = env.reset(task_id=task_id, seed=seed)
330
+ logger.info("reset | session=%s task=%s seed=%d score=%.4f",
331
+ session_id, task_id, seed, obs.partial_score)
332
  return obs.model_dump()
333
  except Exception as exc:
334
+ logger.error("reset error: %s", exc)
335
  raise HTTPException(status_code=400, detail=str(exc))
336
 
337
+
338
  @app.post("/step")
339
  def step(action: DataCleanAction, session_id: str = "default"):
340
  """Execute one cleaning operation. Returns {observation, reward, done, info}."""
341
  env = _env(session_id)
342
  try:
343
  obs, reward, done, info = env.step(action)
344
+ logger.info("step | session=%s op=%s score=%.4f reward=%+.4f done=%s",
345
+ session_id, action.operation, obs.partial_score, reward, done)
346
+ return {
347
+ "observation": obs.model_dump(),
348
+ "reward": reward,
349
+ "done": done,
350
+ "info": info,
351
+ }
352
  except Exception as exc:
353
+ logger.error("step error: %s", exc)
354
  raise HTTPException(status_code=400, detail=str(exc))
355
 
356
+
357
  @app.get("/state")
358
  def state(session_id: str = "default"):
359
  env = _env(session_id)
360
  s = env.state()
361
+ return {
362
+ "episode_id": s.episode_id,
363
+ "step_count": s.step_count,
364
+ "task_id": env._task_id,
365
+ "session_id": session_id,
366
+ }
367
 
368
+
369
+ # ── Hackathon-required endpoints ──────────────────────────────────────────────
370
 
371
  @app.get("/tasks")
372
  def get_tasks():
373
+ """Lists all tasks with action schema. OpenEnv validators enumerate from here."""
374
  return {
375
  "tasks": [
376
  {
377
+ "id": tid,
378
+ "name": cfg["name"],
379
+ "difficulty": cfg["difficulty"],
380
+ "description": cfg["description"],
381
+ "max_steps": cfg["max_steps"],
382
  "available_operations": cfg["available_ops"],
383
  "action_schema": DataCleanAction.model_json_schema(),
384
  }
 
386
  ]
387
  }
388
 
389
+
390
  @app.get("/grader")
391
  def grader(session_id: str = "default"):
392
+ """Returns current grader score in [0.05, 0.98] for active episode."""
393
  env = _env(session_id)
394
+ return {
395
+ "score": env.last_partial_score,
396
+ "task_id": env._task_id,
397
+ "step_count": env._step_count,
398
+ "session_id": session_id,
399
+ }
400
+
401
 
402
  @app.get("/baseline")
403
  async def baseline():
404
  """
405
+ Run a simple rule-based baseline agent on all 4 tasks.
406
+
407
+ Uses HF_TOKEN + API_BASE_URL + MODEL_NAME environment variables.
408
+ If those are not set, falls back to a deterministic rule-based agent
409
+ so the endpoint always returns valid scores (never crashes).
410
  """
411
  try:
412
  result = await _run_baseline_internal()
413
  return result
414
  except Exception as exc:
415
  logger.error("baseline error: %s", exc)
416
+ # Return a valid response even on error β€” scores must be in (0,1)
417
+ return {
418
+ "error": str(exc),
419
+ "scores": {tid: 0.05 for tid in TASK_CONFIG},
420
+ "mean_score": 0.05,
421
+ }
422
+
423
+
424
+ # ── Internal baseline (rule-based fallback + optional LLM) ───────────────────
425
+
426
+ # Deterministic cleaning steps per task β€” the agent tries these in order.
427
+ # This is the "rule-based" baseline that works even without an LLM API key.
428
+ _RULE_ACTIONS: Dict[str, list] = {
429
+ "task1": [
430
+ {"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "main"},
431
+ {"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "main"},
432
+ {"operation": "fill_nulls", "column": "salary", "strategy": "mean", "table_name": "main"},
433
+ {"operation": "submit"},
434
+ ],
435
+ "task2": [
436
+ {"operation": "remove_duplicates", "table_name": "main"},
437
+ {"operation": "normalize_values", "column": "country", "method": "upper","table_name": "main"},
438
+ {"operation": "cast_column", "column": "order_date", "dtype": "datetime","table_name": "main"},
439
+ {"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "main"},
440
+ {"operation": "submit"},
441
+ ],
442
+ "task3": [
443
+ {"operation": "merge_tables", "left_table": "orders", "right_table": "customers",
444
+ "on": "customer_id", "output_table": "merged"},
445
+ {"operation": "fill_nulls", "column": "age", "strategy": "median", "table_name": "merged"},
446
+ {"operation": "cast_column", "column": "age", "dtype": "int", "table_name": "merged"},
447
+ {"operation": "filter_outliers", "column": "amount", "method": "iqr",
448
+ "threshold": 1.5, "table_name": "merged"},
449
+ {"operation": "add_derived_column", "column_name": "order_year",
450
+ "source_column": "order_date", "transform": "year_from_date", "table_name": "merged"},
451
+ {"operation": "submit"},
452
+ ],
453
+ "task4_data_drift": [
454
+ {"operation": "filter_outliers", "column": "amount", "method": "iqr", "threshold": 1.5, "table_name": "stream"},
455
+ {"operation": "fill_nulls", "column": "amount", "strategy": "mean", "table_name": "stream"},
456
+ {"operation": "cast_column", "column": "amount", "dtype": "float", "table_name": "stream"},
457
+ {"operation": "fill_nulls", "column": "category", "strategy": "mode", "table_name": "stream"},
458
+ {"operation": "fill_nulls", "column": "region", "strategy": "mode", "table_name": "stream"},
459
+ {"operation": "cast_column", "column": "event_ts", "dtype": "datetime", "table_name": "stream"},
460
+ {"operation": "submit"},
461
+ ],
462
+ }
463
 
 
 
 
464
 
465
+ async def _run_baseline_internal() -> dict:
466
+ """
467
+ Run a deterministic rule-based baseline against all tasks in-process.
468
+ Optionally uses the LLM if HF_TOKEN / OPENAI_API_KEY is set β€” but the
469
+ rule-based path always works without any API key.
470
+ """
471
  scores: Dict[str, float] = {}
472
+ seed = 42
473
+ model = os.environ.get("MODEL_NAME", "")
474
+ api_key = (
475
+ os.environ.get("HF_TOKEN") or
476
+ os.environ.get("OPENAI_API_KEY") or
477
+ ""
478
+ )
479
+ base_url = os.environ.get("API_BASE_URL", "https://api.openai.com/v1")
480
+
481
+ use_llm = bool(api_key and model)
482
+
483
+ if use_llm:
484
+ try:
485
+ from openai import AsyncOpenAI
486
+ client = AsyncOpenAI(api_key=api_key, base_url=base_url)
487
+ except ImportError:
488
+ use_llm = False
489
 
490
  for task_id, cfg in TASK_CONFIG.items():
491
+ session_id = f"baseline_{task_id}_internal"
492
  env = _env(session_id)
493
+ obs = env.reset(task_id=task_id, seed=seed)
494
+ rule_actions = _RULE_ACTIONS.get(task_id, [{"operation": "submit"}])
495
+ action_idx = 0
496
 
497
  for _ in range(cfg["max_steps"]):
498
  if obs.done:
499
  break
500
+
501
+ action_dict = None
502
+
503
+ # Try LLM first if available
504
+ if use_llm:
505
+ try:
506
+ prompt = (
507
+ f"Task: {obs.task_id}\nStep: {obs.step_count}/{obs.max_steps}\n"
508
+ f"Score: {obs.partial_score:.4f}\nMessage: {obs.message}\n"
509
+ f"Schema errors: {obs.schema_errors[:4]}\n"
510
+ f"Null counts: {json.dumps(obs.null_counts)}\n"
511
+ f"Available ops: {obs.available_operations}\n\n"
512
+ "Respond ONLY with a valid JSON action object."
513
+ )
514
+ resp = await client.chat.completions.create(
515
+ model=model,
516
+ messages=[
517
+ {"role": "system", "content":
518
+ "You are a data cleaning agent. Output ONLY valid JSON, no markdown."},
519
+ {"role": "user", "content": prompt},
520
+ ],
521
+ temperature=0.0,
522
+ max_tokens=200,
523
+ )
524
+ raw = resp.choices[0].message.content.strip()
525
+ raw = raw.replace("```json", "").replace("```", "").strip()
526
+ action_dict = json.loads(raw)
527
+ await asyncio.sleep(0.4) # rate-limit buffer for Groq
528
+ except Exception as e:
529
+ logger.warning("LLM step failed, using rule: %s", e)
530
+ action_dict = None
531
+
532
+ # Fallback to rule-based
533
+ if action_dict is None:
534
+ if action_idx < len(rule_actions):
535
+ action_dict = rule_actions[action_idx]
536
+ action_idx += 1
537
+ else:
538
+ action_dict = {"operation": "submit"}
539
+
540
  try:
541
+ action = DataCleanAction(**action_dict)
 
 
 
 
 
 
 
542
  except Exception:
543
  action = DataCleanAction(operation="submit")
544
 
545
  obs_tuple = env.step(action)
546
  obs = obs_tuple[0]
547
 
548
+ final_score = float(obs.partial_score)
549
+ scores[task_id] = round(final_score, 4)
550
+ logger.info("baseline | task=%s score=%.4f", task_id, final_score)
551
 
552
+ mean = round(sum(scores.values()) / len(scores), 4) if scores else 0.05
553
+ return {
554
+ "scores": scores,
555
+ "mean_score": mean,
556
+ "model": model or "rule-based",
557
+ "seed": seed,
558
+ }
559
 
560
 
561
  def main():
562
  import uvicorn
563
  uvicorn.run(app, host="0.0.0.0", port=7860)
564
 
565
+
566
  if __name__ == "__main__":
567
  main()
server/dataset_factory.py CHANGED
@@ -1,173 +1,362 @@
1
- """
2
- Dataset Factory
3
- ===============
4
- Generates deterministic dirty + expected DataFrames for each task.
5
- Uses numpy default_rng(seed) β€” every (task_id, seed) pair is identical.
6
-
7
- Task 4 is the novel one: also exposes generate_drift_batch() which the
8
- environment calls every DRIFT_EVERY steps to inject fresh dirty rows
9
- mid-episode, simulating a live streaming pipeline under data drift.
10
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  import pandas as pd
12
  import numpy as np
13
  from typing import Tuple, Dict
14
 
15
-
16
  def make_task(task_id: str, seed: int) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
17
- if task_id == "task1":
18
- return _task1(seed)
19
- elif task_id == "task2":
20
- return _task2(seed)
21
- elif task_id == "task3":
22
- return _task3(seed)
23
- elif task_id == "task4_data_drift":
24
- return _task4(seed)
25
  raise ValueError(f"Unknown task_id: {task_id!r}.")
26
 
27
-
28
- # ── Task 1 ────────────────────────────────────────────────────────────────────
29
-
30
- def _task1(seed: int):
31
  rng = np.random.default_rng(seed)
32
  n = 50
33
- ids = list(range(1, n + 1))
34
  names = [f"Customer_{i:03d}" for i in range(n)]
35
- ages = rng.integers(18, 75, size=n).tolist()
36
- sals = np.round(rng.uniform(30_000, 120_000, size=n), 2).tolist()
37
  cities = rng.choice(["Mumbai","Delhi","Bangalore","Chennai","Pune"], size=n).tolist()
38
-
39
  null_age = set(rng.choice(n, size=10, replace=False).tolist())
40
- null_sal = set(rng.choice(n, size=8, replace=False).tolist())
41
- markers = ["", "N/A", "null", "missing", "NaN"]
42
-
43
  age_d = [str(ages[i]) if i not in null_age else str(rng.choice(markers)) for i in range(n)]
44
  sal_d = [sals[i] if i not in null_sal else None for i in range(n)]
45
-
46
- dirty = pd.DataFrame({"id": ids, "name": names, "age": age_d, "salary": sal_d, "city": cities})
47
-
48
  age_fill = int(np.median([ages[i] for i in range(n) if i not in null_age]))
49
  sal_fill = round(float(np.mean([sals[i] for i in range(n) if i not in null_sal])), 2)
50
-
51
  expected = pd.DataFrame({
52
- "id": ids, "name": names,
53
- "age": pd.array([ages[i] if i not in null_age else age_fill for i in range(n)], dtype="int64"),
54
  "salary": pd.array([round(sals[i],2) if i not in null_sal else sal_fill for i in range(n)], dtype="float64"),
55
- "city": cities,
56
  })
57
  return {"main": dirty}, {"main": expected}
58
 
59
-
60
- # ── Task 2 ────────────────────────────────────────────────────────────────────
61
-
62
- def _task2(seed: int):
63
  rng = np.random.default_rng(seed)
64
  nu = 170
65
-
66
- ids = list(range(1, nu + 1))
67
  cids = rng.integers(1001, 1200, size=nu).tolist()
68
- amts = np.round(rng.uniform(10, 5_000, size=nu), 2).tolist()
69
  null_a = set(rng.choice(nu, size=12, replace=False).tolist())
70
- stats = rng.choice(["completed","pending","cancelled","refunded"], size=nu).tolist()
71
- cats = rng.choice(["Electronics","Clothing","Food","Books","Sports"], size=nu).tolist()
72
-
73
- CV = {
74
- "USA":["USA","usa","U.S.A","United States","US"],
75
- "UK":["UK","uk","U.K.","United Kingdom"],
76
- "INDIA":["India","india","INDIA","IN"],
77
- "GERMANY":["Germany","germany","DE","GERMANY"],
78
- "FRANCE":["France","france","FR","FRANCE"],
79
- }
80
- ckeys = list(CV.keys())
81
- cc = rng.choice(ckeys, size=nu).tolist()
82
- cd = [str(rng.choice(CV[c])) for c in cc]
83
-
84
- dr = pd.date_range("2023-01-01","2024-12-31", periods=nu)
85
  diso = dr.strftime("%Y-%m-%d").tolist()
86
- dd = [pd.Timestamp(d).strftime("%d/%m/%Y") if rng.random()<0.35 else d for d in diso]
87
- ad = [amts[i] if i not in null_a else None for i in range(nu)]
88
-
89
- base = pd.DataFrame({"order_id":ids,"customer_id":cids,"country":cd,
90
- "amount":ad,"order_date":dd,"status":stats,"product_category":cats})
91
  dups = base.iloc[rng.choice(nu, size=30, replace=True)].copy()
92
- dirty = (pd.concat([base, dups], ignore_index=True)
93
- .sample(frac=1, random_state=int(seed)).reset_index(drop=True))
94
-
95
  af = round(float(np.mean([amts[i] for i in range(nu) if i not in null_a])), 2)
96
- expected = pd.DataFrame({
97
- "order_id":ids,"customer_id":cids,"country":cc,
98
  "amount":pd.array([round(amts[i],2) if i not in null_a else af for i in range(nu)], dtype="float64"),
99
- "order_date":pd.to_datetime(diso),"status":stats,"product_category":cats,
100
- })
101
  return {"main": dirty}, {"main": expected}
102
 
103
-
104
- # ── Task 3 ────────────────────────────────────────────────────────────────────
105
-
106
- def _task3(seed: int):
107
  rng = np.random.default_rng(seed)
108
  nc, no = 100, 300
109
-
110
- cids = list(range(1001, 1001+nc))
111
  cname = [f"Customer_{i:03d}" for i in range(nc)]
112
- ctry = rng.choice(["USA","UK","India","Germany"], size=nc).tolist()
113
- ages = rng.integers(18, 70, size=nc).tolist()
114
- nai = set(rng.choice(nc, size=8, replace=False).tolist())
115
  ages_d = [str(ages[i]) if i not in nai else "N/A" for i in range(nc)]
116
-
117
  cust_dirty = pd.DataFrame({"customer_id":cids,"name":cname,"country":ctry,"age":ages_d})
118
  af = int(np.median([ages[i] for i in range(nc) if i not in nai]))
119
- cust_clean = pd.DataFrame({
120
- "customer_id":cids,"name":cname,"country":ctry,
121
- "age":pd.array([ages[i] if i not in nai else af for i in range(nc)], dtype="int64"),
122
- })
123
-
124
  oids = list(range(1, no+1))
125
  ocid = rng.choice(cids, size=no).tolist()
126
- amts = np.round(rng.uniform(10, 2_000, size=no), 2)
127
  for idx in rng.choice(no, size=20, replace=False):
128
- amts[idx] = float(rng.choice([0.01, -5.0, 50_000.0, 99_999.0]))
129
  dates = pd.date_range("2023-01-01","2024-12-31", periods=no).strftime("%Y-%m-%d").tolist()
130
-
131
- orders_dirty = pd.DataFrame({"order_id":oids,"customer_id":ocid,
132
- "amount":amts.tolist(),"order_date":dates})
133
  merged = pd.merge(orders_dirty, cust_clean, on="customer_id", how="inner")
134
  Q1, Q3 = merged["amount"].quantile(0.25), merged["amount"].quantile(0.75)
135
  IQR = Q3 - Q1
136
  mc = merged[(merged["amount"]>=Q1-1.5*IQR) & (merged["amount"]<=Q3+1.5*IQR)].copy().reset_index(drop=True)
137
  mc["order_year"] = pd.to_datetime(mc["order_date"]).dt.year
138
-
139
  return ({"orders": orders_dirty, "customers": cust_dirty}, {"main": mc})
140
 
141
-
142
- # ── Task 4: Data Drift (Expert) ───────────────────────────────────────────────
143
-
144
- def _task4(seed: int):
145
- """
146
- Live streaming transactions β€” 120 initial dirty rows.
147
- Env injects fresh dirty rows every DRIFT_EVERY=5 steps via generate_drift_batch().
148
- Agent must keep cleaning as new dirty data continuously arrives.
149
-
150
- Columns: txn_id, customer_id, amount, category, region, event_ts
151
- Dirty issues: nulls, wrong dtypes (amount as str), outliers, mixed timestamp formats.
152
- """
153
  rng = np.random.default_rng(seed)
154
  n = 120
155
-
156
- txn_ids = [f"TXN_INIT_{i:04d}" for i in range(n)]
157
- cids = rng.integers(1, 501, size=n).tolist()
158
- cats_c = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n).tolist()
159
- regs_c = rng.choice(["North","South","East","West","Central"], size=n).tolist()
160
- amts_t = np.round(rng.uniform(10, 3000, size=n), 2).tolist()
161
-
162
  amts_d = []
163
  for i in range(n):
164
  r = rng.random()
165
- if r < 0.15: amts_d.append(None)
166
  elif r < 0.22: amts_d.append(str(round(amts_t[i], 2)))
167
  elif r < 0.27: amts_d.append(float(-rng.uniform(100, 5000)))
168
  elif r < 0.31: amts_d.append(float(rng.uniform(80000, 250000)))
169
- else: amts_d.append(amts_t[i])
170
-
171
  def _ts(rng):
172
  base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
173
  f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
@@ -176,77 +365,47 @@ def _task4(seed: int):
176
  if r < 0.30:
177
  p = base.split("-"); return f"{p[2][:2]}/{p[1]}/{p[0]}"
178
  return base
179
-
180
- ts_d = [_ts(rng) for _ in range(n)]
181
  cats_d = [None if rng.random()<0.15 else cats_c[i] for i in range(n)]
182
  regs_d = [None if rng.random()<0.10 else regs_c[i] for i in range(n)]
183
-
184
- dirty = pd.DataFrame({
185
- "txn_id":txn_ids, "customer_id":cids,
186
- "amount":amts_d, "category":cats_d, "region":regs_d, "event_ts":ts_d,
187
- })
188
-
189
- # Expected: cleaned initial batch (outliers dropped, nulls filled, ts parsed)
190
  good_amts = [x for x in amts_t if 0 < x <= 10000]
191
- amt_fill = round(float(np.mean(good_amts)), 2)
192
  amts_e = []
193
  for a in amts_d:
194
- if a is None: amts_e.append(amt_fill)
195
- elif isinstance(a, str): amts_e.append(float(a))
196
  elif isinstance(a, float) and (a<0 or a>10000): amts_e.append(None)
197
- else: amts_e.append(round(a, 2))
198
-
199
- exp_df = pd.DataFrame({
200
- "txn_id":txn_ids, "customer_id":cids,
201
- "amount":pd.to_numeric(amts_e, errors="coerce"),
202
- "category":cats_c, "region":regs_c,
203
- "event_ts":pd.to_datetime(ts_d, errors="coerce"),
204
- }).dropna(subset=["amount"]).reset_index(drop=True)
205
-
206
  return {"stream": dirty}, {"stream": exp_df}
207
 
208
-
209
- # ── Drift Batch Generator ─────────────────────────────────────────────────────
210
-
211
  def generate_drift_batch(seed: int, batch_num: int, n_rows: int = 7) -> pd.DataFrame:
212
- """
213
- Generate a fresh batch of dirty rows injected mid-episode into task4.
214
- Called by DataCleanEnvironment.step() every DRIFT_EVERY steps.
215
-
216
- Fully deterministic: (seed, batch_num) always β†’ same batch.
217
- Each batch introduces different dirty patterns so the agent faces novel problems.
218
- """
219
  rng = np.random.default_rng(seed * 1000 + batch_num)
220
-
221
- txn_ids = [f"TXN_DRIFT_{batch_num:03d}_{i:02d}" for i in range(n_rows)]
222
- cids = rng.integers(1, 501, size=n_rows).tolist()
223
- cats = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n_rows).tolist()
224
- regs = rng.choice(["North","South","East","West","Central"], size=n_rows).tolist()
225
-
226
  amts = []
227
  for _ in range(n_rows):
228
- r = rng.random()
229
- base = round(float(rng.uniform(10, 3000)), 2)
230
- if r < 0.20: amts.append(None)
231
  elif r < 0.30: amts.append(str(base))
232
  elif r < 0.38: amts.append(float(-rng.uniform(100, 5000)))
233
  elif r < 0.44: amts.append(float(rng.uniform(80000, 250000)))
234
- else: amts.append(base)
235
-
236
  ts = []
237
  for _ in range(n_rows):
238
  base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
239
  f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
240
  r = rng.random()
241
- if r < 0.20: ts.append(base.split(" ")[0].replace("-", "/"))
242
- elif r < 0.35: p = base.split("-"); ts.append(f"{p[2][:2]}/{p[1]}/{p[0]}")
243
- else: ts.append(base)
244
-
245
  for i in range(n_rows):
246
  if rng.random() < 0.18: cats[i] = None
247
  if rng.random() < 0.12: regs[i] = None
248
-
249
- return pd.DataFrame({
250
- "txn_id":txn_ids, "customer_id":cids,
251
- "amount":amts, "category":cats, "region":regs, "event_ts":ts,
252
- })
 
1
+ # """
2
+ # Dataset Factory
3
+ # ===============
4
+ # Generates deterministic dirty + expected DataFrames for each task.
5
+ # Uses numpy default_rng(seed) β€” every (task_id, seed) pair is identical.
6
+
7
+ # Task 4 is the novel one: also exposes generate_drift_batch() which the
8
+ # environment calls every DRIFT_EVERY steps to inject fresh dirty rows
9
+ # mid-episode, simulating a live streaming pipeline under data drift.
10
+ # """
11
+ # import pandas as pd
12
+ # import numpy as np
13
+ # from typing import Tuple, Dict
14
+
15
+
16
+ # def make_task(task_id: str, seed: int) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
17
+ # if task_id == "task1":
18
+ # return _task1(seed)
19
+ # elif task_id == "task2":
20
+ # return _task2(seed)
21
+ # elif task_id == "task3":
22
+ # return _task3(seed)
23
+ # elif task_id == "task4_data_drift":
24
+ # return _task4(seed)
25
+ # raise ValueError(f"Unknown task_id: {task_id!r}.")
26
+
27
+
28
+ # # ── Task 1 ────────────────────────────────────────────────────────────────────
29
+
30
+ # def _task1(seed: int):
31
+ # rng = np.random.default_rng(seed)
32
+ # n = 50
33
+ # ids = list(range(1, n + 1))
34
+ # names = [f"Customer_{i:03d}" for i in range(n)]
35
+ # ages = rng.integers(18, 75, size=n).tolist()
36
+ # sals = np.round(rng.uniform(30_000, 120_000, size=n), 2).tolist()
37
+ # cities = rng.choice(["Mumbai","Delhi","Bangalore","Chennai","Pune"], size=n).tolist()
38
+
39
+ # null_age = set(rng.choice(n, size=10, replace=False).tolist())
40
+ # null_sal = set(rng.choice(n, size=8, replace=False).tolist())
41
+ # markers = ["", "N/A", "null", "missing", "NaN"]
42
+
43
+ # age_d = [str(ages[i]) if i not in null_age else str(rng.choice(markers)) for i in range(n)]
44
+ # sal_d = [sals[i] if i not in null_sal else None for i in range(n)]
45
+
46
+ # dirty = pd.DataFrame({"id": ids, "name": names, "age": age_d, "salary": sal_d, "city": cities})
47
+
48
+ # age_fill = int(np.median([ages[i] for i in range(n) if i not in null_age]))
49
+ # sal_fill = round(float(np.mean([sals[i] for i in range(n) if i not in null_sal])), 2)
50
+
51
+ # expected = pd.DataFrame({
52
+ # "id": ids, "name": names,
53
+ # "age": pd.array([ages[i] if i not in null_age else age_fill for i in range(n)], dtype="int64"),
54
+ # "salary": pd.array([round(sals[i],2) if i not in null_sal else sal_fill for i in range(n)], dtype="float64"),
55
+ # "city": cities,
56
+ # })
57
+ # return {"main": dirty}, {"main": expected}
58
+
59
+
60
+ # # ── Task 2 ────────────────────────────────────────────────────────────────────
61
+
62
+ # def _task2(seed: int):
63
+ # rng = np.random.default_rng(seed)
64
+ # nu = 170
65
+
66
+ # ids = list(range(1, nu + 1))
67
+ # cids = rng.integers(1001, 1200, size=nu).tolist()
68
+ # amts = np.round(rng.uniform(10, 5_000, size=nu), 2).tolist()
69
+ # null_a = set(rng.choice(nu, size=12, replace=False).tolist())
70
+ # stats = rng.choice(["completed","pending","cancelled","refunded"], size=nu).tolist()
71
+ # cats = rng.choice(["Electronics","Clothing","Food","Books","Sports"], size=nu).tolist()
72
+
73
+ # CV = {
74
+ # "USA":["USA","usa","U.S.A","United States","US"],
75
+ # "UK":["UK","uk","U.K.","United Kingdom"],
76
+ # "INDIA":["India","india","INDIA","IN"],
77
+ # "GERMANY":["Germany","germany","DE","GERMANY"],
78
+ # "FRANCE":["France","france","FR","FRANCE"],
79
+ # }
80
+ # ckeys = list(CV.keys())
81
+ # cc = rng.choice(ckeys, size=nu).tolist()
82
+ # cd = [str(rng.choice(CV[c])) for c in cc]
83
+
84
+ # dr = pd.date_range("2023-01-01","2024-12-31", periods=nu)
85
+ # diso = dr.strftime("%Y-%m-%d").tolist()
86
+ # dd = [pd.Timestamp(d).strftime("%d/%m/%Y") if rng.random()<0.35 else d for d in diso]
87
+ # ad = [amts[i] if i not in null_a else None for i in range(nu)]
88
+
89
+ # base = pd.DataFrame({"order_id":ids,"customer_id":cids,"country":cd,
90
+ # "amount":ad,"order_date":dd,"status":stats,"product_category":cats})
91
+ # dups = base.iloc[rng.choice(nu, size=30, replace=True)].copy()
92
+ # dirty = (pd.concat([base, dups], ignore_index=True)
93
+ # .sample(frac=1, random_state=int(seed)).reset_index(drop=True))
94
+
95
+ # af = round(float(np.mean([amts[i] for i in range(nu) if i not in null_a])), 2)
96
+ # expected = pd.DataFrame({
97
+ # "order_id":ids,"customer_id":cids,"country":cc,
98
+ # "amount":pd.array([round(amts[i],2) if i not in null_a else af for i in range(nu)], dtype="float64"),
99
+ # "order_date":pd.to_datetime(diso),"status":stats,"product_category":cats,
100
+ # })
101
+ # return {"main": dirty}, {"main": expected}
102
+
103
+
104
+ # # ── Task 3 ────────────────────────────────────────────────────────────────────
105
+
106
+ # def _task3(seed: int):
107
+ # rng = np.random.default_rng(seed)
108
+ # nc, no = 100, 300
109
+
110
+ # cids = list(range(1001, 1001+nc))
111
+ # cname = [f"Customer_{i:03d}" for i in range(nc)]
112
+ # ctry = rng.choice(["USA","UK","India","Germany"], size=nc).tolist()
113
+ # ages = rng.integers(18, 70, size=nc).tolist()
114
+ # nai = set(rng.choice(nc, size=8, replace=False).tolist())
115
+ # ages_d = [str(ages[i]) if i not in nai else "N/A" for i in range(nc)]
116
+
117
+ # cust_dirty = pd.DataFrame({"customer_id":cids,"name":cname,"country":ctry,"age":ages_d})
118
+ # af = int(np.median([ages[i] for i in range(nc) if i not in nai]))
119
+ # cust_clean = pd.DataFrame({
120
+ # "customer_id":cids,"name":cname,"country":ctry,
121
+ # "age":pd.array([ages[i] if i not in nai else af for i in range(nc)], dtype="int64"),
122
+ # })
123
+
124
+ # oids = list(range(1, no+1))
125
+ # ocid = rng.choice(cids, size=no).tolist()
126
+ # amts = np.round(rng.uniform(10, 2_000, size=no), 2)
127
+ # for idx in rng.choice(no, size=20, replace=False):
128
+ # amts[idx] = float(rng.choice([0.01, -5.0, 50_000.0, 99_999.0]))
129
+ # dates = pd.date_range("2023-01-01","2024-12-31", periods=no).strftime("%Y-%m-%d").tolist()
130
+
131
+ # orders_dirty = pd.DataFrame({"order_id":oids,"customer_id":ocid,
132
+ # "amount":amts.tolist(),"order_date":dates})
133
+ # merged = pd.merge(orders_dirty, cust_clean, on="customer_id", how="inner")
134
+ # Q1, Q3 = merged["amount"].quantile(0.25), merged["amount"].quantile(0.75)
135
+ # IQR = Q3 - Q1
136
+ # mc = merged[(merged["amount"]>=Q1-1.5*IQR) & (merged["amount"]<=Q3+1.5*IQR)].copy().reset_index(drop=True)
137
+ # mc["order_year"] = pd.to_datetime(mc["order_date"]).dt.year
138
+
139
+ # return ({"orders": orders_dirty, "customers": cust_dirty}, {"main": mc})
140
+
141
+
142
+ # # ── Task 4: Data Drift (Expert) ───────────────────────────────────────────────
143
+
144
+ # def _task4(seed: int):
145
+ # """
146
+ # Live streaming transactions β€” 120 initial dirty rows.
147
+ # Env injects fresh dirty rows every DRIFT_EVERY=5 steps via generate_drift_batch().
148
+ # Agent must keep cleaning as new dirty data continuously arrives.
149
+
150
+ # Columns: txn_id, customer_id, amount, category, region, event_ts
151
+ # Dirty issues: nulls, wrong dtypes (amount as str), outliers, mixed timestamp formats.
152
+ # """
153
+ # rng = np.random.default_rng(seed)
154
+ # n = 120
155
+
156
+ # txn_ids = [f"TXN_INIT_{i:04d}" for i in range(n)]
157
+ # cids = rng.integers(1, 501, size=n).tolist()
158
+ # cats_c = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n).tolist()
159
+ # regs_c = rng.choice(["North","South","East","West","Central"], size=n).tolist()
160
+ # amts_t = np.round(rng.uniform(10, 3000, size=n), 2).tolist()
161
+
162
+ # amts_d = []
163
+ # for i in range(n):
164
+ # r = rng.random()
165
+ # if r < 0.15: amts_d.append(None)
166
+ # elif r < 0.22: amts_d.append(str(round(amts_t[i], 2)))
167
+ # elif r < 0.27: amts_d.append(float(-rng.uniform(100, 5000)))
168
+ # elif r < 0.31: amts_d.append(float(rng.uniform(80000, 250000)))
169
+ # else: amts_d.append(amts_t[i])
170
+
171
+ # def _ts(rng):
172
+ # base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
173
+ # f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
174
+ # r = rng.random()
175
+ # if r < 0.20: return base.split(" ")[0].replace("-", "/")
176
+ # if r < 0.30:
177
+ # p = base.split("-"); return f"{p[2][:2]}/{p[1]}/{p[0]}"
178
+ # return base
179
+
180
+ # ts_d = [_ts(rng) for _ in range(n)]
181
+ # cats_d = [None if rng.random()<0.15 else cats_c[i] for i in range(n)]
182
+ # regs_d = [None if rng.random()<0.10 else regs_c[i] for i in range(n)]
183
+
184
+ # dirty = pd.DataFrame({
185
+ # "txn_id":txn_ids, "customer_id":cids,
186
+ # "amount":amts_d, "category":cats_d, "region":regs_d, "event_ts":ts_d,
187
+ # })
188
+
189
+ # # Expected: cleaned initial batch (outliers dropped, nulls filled, ts parsed)
190
+ # good_amts = [x for x in amts_t if 0 < x <= 10000]
191
+ # amt_fill = round(float(np.mean(good_amts)), 2)
192
+ # amts_e = []
193
+ # for a in amts_d:
194
+ # if a is None: amts_e.append(amt_fill)
195
+ # elif isinstance(a, str): amts_e.append(float(a))
196
+ # elif isinstance(a, float) and (a<0 or a>10000): amts_e.append(None)
197
+ # else: amts_e.append(round(a, 2))
198
+
199
+ # exp_df = pd.DataFrame({
200
+ # "txn_id":txn_ids, "customer_id":cids,
201
+ # "amount":pd.to_numeric(amts_e, errors="coerce"),
202
+ # "category":cats_c, "region":regs_c,
203
+ # "event_ts":pd.to_datetime(ts_d, errors="coerce"),
204
+ # }).dropna(subset=["amount"]).reset_index(drop=True)
205
+
206
+ # return {"stream": dirty}, {"stream": exp_df}
207
+
208
+
209
+ # # ── Drift Batch Generator ─────────────────────────────────────────────────────
210
+
211
+ # def generate_drift_batch(seed: int, batch_num: int, n_rows: int = 7) -> pd.DataFrame:
212
+ # """
213
+ # Generate a fresh batch of dirty rows injected mid-episode into task4.
214
+ # Called by DataCleanEnvironment.step() every DRIFT_EVERY steps.
215
+
216
+ # Fully deterministic: (seed, batch_num) always β†’ same batch.
217
+ # Each batch introduces different dirty patterns so the agent faces novel problems.
218
+ # """
219
+ # rng = np.random.default_rng(seed * 1000 + batch_num)
220
+
221
+ # txn_ids = [f"TXN_DRIFT_{batch_num:03d}_{i:02d}" for i in range(n_rows)]
222
+ # cids = rng.integers(1, 501, size=n_rows).tolist()
223
+ # cats = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n_rows).tolist()
224
+ # regs = rng.choice(["North","South","East","West","Central"], size=n_rows).tolist()
225
+
226
+ # amts = []
227
+ # for _ in range(n_rows):
228
+ # r = rng.random()
229
+ # base = round(float(rng.uniform(10, 3000)), 2)
230
+ # if r < 0.20: amts.append(None)
231
+ # elif r < 0.30: amts.append(str(base))
232
+ # elif r < 0.38: amts.append(float(-rng.uniform(100, 5000)))
233
+ # elif r < 0.44: amts.append(float(rng.uniform(80000, 250000)))
234
+ # else: amts.append(base)
235
+
236
+ # ts = []
237
+ # for _ in range(n_rows):
238
+ # base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
239
+ # f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
240
+ # r = rng.random()
241
+ # if r < 0.20: ts.append(base.split(" ")[0].replace("-", "/"))
242
+ # elif r < 0.35: p = base.split("-"); ts.append(f"{p[2][:2]}/{p[1]}/{p[0]}")
243
+ # else: ts.append(base)
244
+
245
+ # for i in range(n_rows):
246
+ # if rng.random() < 0.18: cats[i] = None
247
+ # if rng.random() < 0.12: regs[i] = None
248
+
249
+ # return pd.DataFrame({
250
+ # "txn_id":txn_ids, "customer_id":cids,
251
+ # "amount":amts, "category":cats, "region":regs, "event_ts":ts,
252
+ # })
253
+
254
  import pandas as pd
255
  import numpy as np
256
  from typing import Tuple, Dict
257
 
 
258
  def make_task(task_id: str, seed: int) -> Tuple[Dict[str, pd.DataFrame], Dict[str, pd.DataFrame]]:
259
+ if task_id == "task1": return _task1(seed)
260
+ elif task_id == "task2": return _task2(seed)
261
+ elif task_id == "task3": return _task3(seed)
262
+ elif task_id == "task4_data_drift": return _task4(seed)
 
 
 
 
263
  raise ValueError(f"Unknown task_id: {task_id!r}.")
264
 
265
+ def _task1(seed):
 
 
 
266
  rng = np.random.default_rng(seed)
267
  n = 50
268
+ ids = list(range(1, n+1))
269
  names = [f"Customer_{i:03d}" for i in range(n)]
270
+ ages = rng.integers(18, 75, size=n).tolist()
271
+ sals = np.round(rng.uniform(30000, 120000, size=n), 2).tolist()
272
  cities = rng.choice(["Mumbai","Delhi","Bangalore","Chennai","Pune"], size=n).tolist()
 
273
  null_age = set(rng.choice(n, size=10, replace=False).tolist())
274
+ null_sal = set(rng.choice(n, size=8, replace=False).tolist())
275
+ markers = ["", "N/A", "null", "missing", "NaN"]
 
276
  age_d = [str(ages[i]) if i not in null_age else str(rng.choice(markers)) for i in range(n)]
277
  sal_d = [sals[i] if i not in null_sal else None for i in range(n)]
278
+ dirty = pd.DataFrame({"id":ids,"name":names,"age":age_d,"salary":sal_d,"city":cities})
 
 
279
  age_fill = int(np.median([ages[i] for i in range(n) if i not in null_age]))
280
  sal_fill = round(float(np.mean([sals[i] for i in range(n) if i not in null_sal])), 2)
 
281
  expected = pd.DataFrame({
282
+ "id":ids,"name":names,
283
+ "age": pd.array([ages[i] if i not in null_age else age_fill for i in range(n)], dtype="int64"),
284
  "salary": pd.array([round(sals[i],2) if i not in null_sal else sal_fill for i in range(n)], dtype="float64"),
285
+ "city":cities,
286
  })
287
  return {"main": dirty}, {"main": expected}
288
 
289
+ def _task2(seed):
 
 
 
290
  rng = np.random.default_rng(seed)
291
  nu = 170
292
+ ids = list(range(1, nu+1))
 
293
  cids = rng.integers(1001, 1200, size=nu).tolist()
294
+ amts = np.round(rng.uniform(10, 5000, size=nu), 2).tolist()
295
  null_a = set(rng.choice(nu, size=12, replace=False).tolist())
296
+ stats = rng.choice(["completed","pending","cancelled","refunded"], size=nu).tolist()
297
+ cats = rng.choice(["Electronics","Clothing","Food","Books","Sports"], size=nu).tolist()
298
+ CV = {"USA":["USA","usa","U.S.A","United States","US"],"UK":["UK","uk","U.K.","United Kingdom"],
299
+ "INDIA":["India","india","INDIA","IN"],"GERMANY":["Germany","germany","DE","GERMANY"],
300
+ "FRANCE":["France","france","FR","FRANCE"]}
301
+ ckeys = list(CV.keys())
302
+ cc = rng.choice(ckeys, size=nu).tolist()
303
+ cd = [str(rng.choice(CV[c])) for c in cc]
304
+ dr = pd.date_range("2023-01-01","2024-12-31", periods=nu)
 
 
 
 
 
 
305
  diso = dr.strftime("%Y-%m-%d").tolist()
306
+ dd = [pd.Timestamp(d).strftime("%d/%m/%Y") if rng.random()<0.35 else d for d in diso]
307
+ ad = [amts[i] if i not in null_a else None for i in range(nu)]
308
+ base = pd.DataFrame({"order_id":ids,"customer_id":cids,"country":cd,"amount":ad,"order_date":dd,"status":stats,"product_category":cats})
 
 
309
  dups = base.iloc[rng.choice(nu, size=30, replace=True)].copy()
310
+ dirty = (pd.concat([base, dups], ignore_index=True).sample(frac=1, random_state=int(seed)).reset_index(drop=True))
 
 
311
  af = round(float(np.mean([amts[i] for i in range(nu) if i not in null_a])), 2)
312
+ expected = pd.DataFrame({"order_id":ids,"customer_id":cids,"country":cc,
 
313
  "amount":pd.array([round(amts[i],2) if i not in null_a else af for i in range(nu)], dtype="float64"),
314
+ "order_date":pd.to_datetime(diso),"status":stats,"product_category":cats})
 
315
  return {"main": dirty}, {"main": expected}
316
 
317
+ def _task3(seed):
 
 
 
318
  rng = np.random.default_rng(seed)
319
  nc, no = 100, 300
320
+ cids = list(range(1001, 1001+nc))
 
321
  cname = [f"Customer_{i:03d}" for i in range(nc)]
322
+ ctry = rng.choice(["USA","UK","India","Germany"], size=nc).tolist()
323
+ ages = rng.integers(18, 70, size=nc).tolist()
324
+ nai = set(rng.choice(nc, size=8, replace=False).tolist())
325
  ages_d = [str(ages[i]) if i not in nai else "N/A" for i in range(nc)]
 
326
  cust_dirty = pd.DataFrame({"customer_id":cids,"name":cname,"country":ctry,"age":ages_d})
327
  af = int(np.median([ages[i] for i in range(nc) if i not in nai]))
328
+ cust_clean = pd.DataFrame({"customer_id":cids,"name":cname,"country":ctry,
329
+ "age":pd.array([ages[i] if i not in nai else af for i in range(nc)], dtype="int64")})
 
 
 
330
  oids = list(range(1, no+1))
331
  ocid = rng.choice(cids, size=no).tolist()
332
+ amts = np.round(rng.uniform(10, 2000, size=no), 2)
333
  for idx in rng.choice(no, size=20, replace=False):
334
+ amts[idx] = float(rng.choice([0.01, -5.0, 50000.0, 99999.0]))
335
  dates = pd.date_range("2023-01-01","2024-12-31", periods=no).strftime("%Y-%m-%d").tolist()
336
+ orders_dirty = pd.DataFrame({"order_id":oids,"customer_id":ocid,"amount":amts.tolist(),"order_date":dates})
 
 
337
  merged = pd.merge(orders_dirty, cust_clean, on="customer_id", how="inner")
338
  Q1, Q3 = merged["amount"].quantile(0.25), merged["amount"].quantile(0.75)
339
  IQR = Q3 - Q1
340
  mc = merged[(merged["amount"]>=Q1-1.5*IQR) & (merged["amount"]<=Q3+1.5*IQR)].copy().reset_index(drop=True)
341
  mc["order_year"] = pd.to_datetime(mc["order_date"]).dt.year
 
342
  return ({"orders": orders_dirty, "customers": cust_dirty}, {"main": mc})
343
 
344
+ def _task4(seed):
 
 
 
 
 
 
 
 
 
 
 
345
  rng = np.random.default_rng(seed)
346
  n = 120
347
+ txn_ids = [f"TXN_INIT_{i:04d}" for i in range(n)]
348
+ cids = rng.integers(1, 501, size=n).tolist()
349
+ cats_c = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n).tolist()
350
+ regs_c = rng.choice(["North","South","East","West","Central"], size=n).tolist()
351
+ amts_t = np.round(rng.uniform(10, 3000, size=n), 2).tolist()
 
 
352
  amts_d = []
353
  for i in range(n):
354
  r = rng.random()
355
+ if r < 0.15: amts_d.append(None)
356
  elif r < 0.22: amts_d.append(str(round(amts_t[i], 2)))
357
  elif r < 0.27: amts_d.append(float(-rng.uniform(100, 5000)))
358
  elif r < 0.31: amts_d.append(float(rng.uniform(80000, 250000)))
359
+ else: amts_d.append(amts_t[i])
 
360
  def _ts(rng):
361
  base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
362
  f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
 
365
  if r < 0.30:
366
  p = base.split("-"); return f"{p[2][:2]}/{p[1]}/{p[0]}"
367
  return base
368
+ ts_d = [_ts(rng) for _ in range(n)]
 
369
  cats_d = [None if rng.random()<0.15 else cats_c[i] for i in range(n)]
370
  regs_d = [None if rng.random()<0.10 else regs_c[i] for i in range(n)]
371
+ dirty = pd.DataFrame({"txn_id":txn_ids,"customer_id":cids,"amount":amts_d,"category":cats_d,"region":regs_d,"event_ts":ts_d})
 
 
 
 
 
 
372
  good_amts = [x for x in amts_t if 0 < x <= 10000]
373
+ amt_fill = round(float(np.mean(good_amts)), 2)
374
  amts_e = []
375
  for a in amts_d:
376
+ if a is None: amts_e.append(amt_fill)
377
+ elif isinstance(a, str): amts_e.append(float(a))
378
  elif isinstance(a, float) and (a<0 or a>10000): amts_e.append(None)
379
+ else: amts_e.append(round(a, 2))
380
+ exp_df = pd.DataFrame({"txn_id":txn_ids,"customer_id":cids,
381
+ "amount":pd.to_numeric(amts_e, errors="coerce"),"category":cats_c,
382
+ "region":regs_c,"event_ts":pd.to_datetime(ts_d, errors="coerce")}).dropna(subset=["amount"]).reset_index(drop=True)
 
 
 
 
 
383
  return {"stream": dirty}, {"stream": exp_df}
384
 
 
 
 
385
  def generate_drift_batch(seed: int, batch_num: int, n_rows: int = 7) -> pd.DataFrame:
 
 
 
 
 
 
 
386
  rng = np.random.default_rng(seed * 1000 + batch_num)
387
+ txn_ids = [f"TXN_DRIFT_{batch_num:03d}_{i:02d}" for i in range(n_rows)]
388
+ cids = rng.integers(1, 501, size=n_rows).tolist()
389
+ cats = rng.choice(["Electronics","Clothing","Food","Books","Sports","Toys"], size=n_rows).tolist()
390
+ regs = rng.choice(["North","South","East","West","Central"], size=n_rows).tolist()
 
 
391
  amts = []
392
  for _ in range(n_rows):
393
+ r = rng.random(); base = round(float(rng.uniform(10, 3000)), 2)
394
+ if r < 0.20: amts.append(None)
 
395
  elif r < 0.30: amts.append(str(base))
396
  elif r < 0.38: amts.append(float(-rng.uniform(100, 5000)))
397
  elif r < 0.44: amts.append(float(rng.uniform(80000, 250000)))
398
+ else: amts.append(base)
 
399
  ts = []
400
  for _ in range(n_rows):
401
  base = (f"2024-{rng.integers(1,13):02d}-{rng.integers(1,29):02d} "
402
  f"{rng.integers(0,24):02d}:{rng.integers(0,60):02d}:00")
403
  r = rng.random()
404
+ if r < 0.20: ts.append(base.split(" ")[0].replace("-", "/"))
405
+ elif r < 0.35:
406
+ p = base.split("-"); ts.append(f"{p[2][:2]}/{p[1]}/{p[0]}")
407
+ else: ts.append(base)
408
  for i in range(n_rows):
409
  if rng.random() < 0.18: cats[i] = None
410
  if rng.random() < 0.12: regs[i] = None
411
+ return pd.DataFrame({"txn_id":txn_ids,"customer_id":cids,"amount":amts,"category":cats,"region":regs,"event_ts":ts})
 
 
 
 
server/environment.py CHANGED
@@ -1,6 +1,394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
  DataClean Environment β€” core logic.
3
- Absolute imports + sys.path patch so uvicorn server.app:app works from root.
 
 
 
 
 
 
4
  """
5
  import os, sys
6
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -17,14 +405,6 @@ from server.graders import grade_task1, grade_task2, grade_task3, grade_task4
17
 
18
  DRIFT_EVERY = 5
19
 
20
- # Hardcoded safe scores per task β€” always within 0.001–0.999
21
- TASK_SCORES = {
22
- "task1": 0.501,
23
- "task2": 0.502,
24
- "task3": 0.503,
25
- "task4_data_drift": 0.504,
26
- }
27
-
28
  TASK_CONFIG = {
29
  "task1": {
30
  "name": "Null Fixer", "difficulty": "easy", "max_steps": 10,
@@ -38,7 +418,8 @@ TASK_CONFIG = {
38
  },
39
  "task2": {
40
  "name": "Schema Normalizer", "difficulty": "medium", "max_steps": 20,
41
- "available_ops": ["fill_nulls","cast_column","remove_duplicates","normalize_values","submit"],
 
42
  "description": (
43
  "You have a 'main' table (~200-row orders). "
44
  "Fix: (1) ~30 duplicate rows β†’ remove_duplicates. "
@@ -51,8 +432,8 @@ TASK_CONFIG = {
51
  "task3": {
52
  "name": "ETL Pipeline", "difficulty": "hard", "max_steps": 30,
53
  "available_ops": [
54
- "fill_nulls","cast_column","remove_duplicates","normalize_values",
55
- "filter_outliers","merge_tables","add_derived_column","submit",
56
  ],
57
  "description": (
58
  "Two tables: 'orders' (300 rows) + 'customers' (100 rows). "
@@ -66,8 +447,8 @@ TASK_CONFIG = {
66
  "task4_data_drift": {
67
  "name": "Data Drift (Streaming)", "difficulty": "expert", "max_steps": 40,
68
  "available_ops": [
69
- "fill_nulls","cast_column","remove_duplicates","normalize_values",
70
- "filter_outliers","submit",
71
  ],
72
  "description": (
73
  "NOVEL TASK β€” Live streaming transactions under continuous data drift. "
@@ -91,10 +472,10 @@ class DataCleanEnvironment:
91
  self._tables: Dict[str, pd.DataFrame] = {}
92
  self._expected_tables: Dict[str, pd.DataFrame] = {}
93
  self._dirty_tables: Dict[str, pd.DataFrame] = {}
94
- self._prev_score = 0.001
95
  self._last_reward = 0.0
96
  self._last_msg = "Not started. Call /reset first."
97
- self.last_partial_score = 0.001
98
 
99
  def reset(self, task_id: str = "task1", seed: int = 42) -> DataCleanObservation:
100
  if task_id not in TASK_CONFIG:
@@ -104,7 +485,6 @@ class DataCleanEnvironment:
104
  self._episode_id = str(uuid.uuid4())
105
  self._step_count = 0
106
  self._drift_batch_num = 0
107
- self._prev_score = 0.001
108
  self._last_reward = 0.0
109
 
110
  dirty, expected = make_task(task_id, seed)
@@ -112,13 +492,14 @@ class DataCleanEnvironment:
112
  self._expected_tables = expected
113
  self._dirty_tables = {k: v.copy() for k, v in dirty.items()}
114
 
115
- self.last_partial_score = self._score()
116
- self._prev_score = self.last_partial_score
 
117
  self._last_msg = (
118
  f"Episode started | task={task_id} | seed={seed} | "
119
  f"tables={list(self._tables.keys())}"
120
  )
121
- return self._obs(reward=0.0, done=False, new_rows=0)
122
 
123
  def step(self, action: DataCleanAction) -> Tuple[DataCleanObservation, float, bool, dict]:
124
  self._step_count += 1
@@ -126,6 +507,7 @@ class DataCleanEnvironment:
126
  allowed = TASK_CONFIG[self._task_id]["available_ops"]
127
  new_rows_injected = 0
128
 
 
129
  if (self._task_id == "task4_data_drift"
130
  and self._step_count % DRIFT_EVERY == 0):
131
  batch = generate_drift_batch(self._seed, self._drift_batch_num, n_rows=7)
@@ -139,31 +521,45 @@ class DataCleanEnvironment:
139
  f"(batch {self._drift_batch_num}). Keep cleaning!"
140
  )
141
 
 
142
  if action.operation not in allowed:
143
- self._last_msg = f"Operation '{action.operation}' not allowed. Allowed: {allowed}"
 
 
 
144
  done = self._step_count >= max_steps
145
- return self._obs(-0.02, done, new_rows_injected), -0.02, done, {}
 
 
 
146
 
 
147
  if action.operation == "submit":
148
  final = self._score()
149
- reward = final - self._prev_score
150
- self._last_msg = f"Submitted! Final score: {final:.4f} | Steps: {self._step_count}/{max_steps}"
 
 
151
  self.last_partial_score = final
152
  return self._obs(reward, True, new_rows_injected, score=final), reward, True, {}
153
 
 
154
  try:
155
  op_msg = self._execute(action)
156
  if new_rows_injected == 0:
157
  self._last_msg = op_msg
158
  else:
159
- self._last_msg += f" | Action result: {op_msg}"
160
  except (KeyError, ValueError, TypeError) as exc:
161
- self._last_msg = f"Error: {exc}"
162
  done = self._step_count >= max_steps
163
- return self._obs(-0.02, done, new_rows_injected), -0.02, done, {}
 
 
 
164
 
165
  new_score = self._score()
166
- reward = new_score - self._prev_score
167
  self._prev_score = new_score
168
  self.last_partial_score = new_score
169
 
@@ -176,36 +572,87 @@ class DataCleanEnvironment:
176
  def state(self) -> State:
177
  return State(episode_id=self._episode_id, step_count=self._step_count)
178
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  def _execute(self, action: DataCleanAction) -> str:
180
  op = action.operation
181
- tbl = action.table_name or ("stream" if self._task_id == "task4_data_drift" else "main")
 
 
182
 
183
  if op == "fill_nulls":
184
  df = self._tbl(tbl)
185
  col = self._col(df, action.column, tbl)
 
 
186
  df[col] = df[col].replace(
187
- ["N/A","n/a","null","NULL","None","none","missing","","NaN"], np.nan
 
188
  )
189
- num = pd.to_numeric(df[col], errors="coerce")
190
- s = (action.strategy or "mean").lower()
191
- if s == "mean": fv = num.mean()
192
- elif s == "median": fv = num.median()
193
- elif s == "mode":
194
- modes = df[col].mode()
195
- fv = modes.iloc[0] if not modes.empty else np.nan
196
- elif s == "constant": fv = action.value
197
- elif s in ("forward_fill","ffill"):
198
  df[col] = df[col].ffill()
199
  self._tables[tbl] = df
200
  return f"[fill_nulls] '{col}' ffill in '{tbl}'"
201
- elif s in ("backward_fill","bfill"):
202
  df[col] = df[col].bfill()
203
  self._tables[tbl] = df
204
  return f"[fill_nulls] '{col}' bfill in '{tbl}'"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  else:
206
  raise ValueError(f"Unknown strategy '{s}'")
207
- if pd.isna(fv):
208
- raise ValueError(f"Cannot compute fill value for '{col}'")
 
 
 
209
  df[col] = pd.to_numeric(df[col], errors="coerce").fillna(fv)
210
  self._tables[tbl] = df
211
  return f"[fill_nulls] '{col}' β†’ {s}={fv:.4g} in '{tbl}'"
@@ -214,11 +661,19 @@ class DataCleanEnvironment:
214
  df = self._tbl(tbl)
215
  col = self._col(df, action.column, tbl)
216
  dt = (action.dtype or "").lower()
 
217
  if dt == "int":
218
- df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
 
 
 
 
 
 
 
219
  elif dt == "float":
220
  df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64")
221
- elif dt in ("str","string","object"):
222
  df[col] = df[col].astype(str)
223
  elif dt == "datetime":
224
  df[col] = pd.to_datetime(df[col], errors="coerce")
@@ -226,32 +681,35 @@ class DataCleanEnvironment:
226
  df[col] = df[col].astype(bool)
227
  else:
228
  raise ValueError(f"Unknown dtype '{dt}'")
 
229
  self._tables[tbl] = df
230
  return f"[cast_column] '{col}' β†’ {dt} in '{tbl}'"
231
 
232
  elif op == "remove_duplicates":
233
  df = self._tbl(tbl)
234
  before = len(df)
235
- keep = action.keep if action.keep in ("first","last") else "first"
236
  df = df.drop_duplicates(subset=action.subset, keep=keep).reset_index(drop=True)
237
  self._tables[tbl] = df
238
- return f"[remove_duplicates] -{before-len(df)} rows from '{tbl}'"
239
 
240
  elif op == "normalize_values":
241
  df = self._tbl(tbl)
242
  col = self._col(df, action.column, tbl)
243
  m = (action.method or "upper").lower()
244
  s = df[col].astype(str)
245
- if m == "upper": df[col] = s.str.strip().str.upper()
246
- elif m == "lower": df[col] = s.str.strip().str.lower()
247
- elif m == "strip": df[col] = s.str.strip()
248
- elif m == "title": df[col] = s.str.strip().str.title()
249
- elif m in ("regex","replace"):
250
  if not action.pattern:
251
- raise ValueError(f"method='{m}' needs pattern")
252
- df[col] = s.str.replace(action.pattern, action.replacement or "", regex=(m=="regex"))
 
 
253
  else:
254
- raise ValueError(f"Unknown method '{m}'")
255
  self._tables[tbl] = df
256
  return f"[normalize_values] '{col}' β†’ {m} in '{tbl}'"
257
 
@@ -262,43 +720,60 @@ class DataCleanEnvironment:
262
  thr = action.threshold if action.threshold is not None else 1.5
263
  num = pd.to_numeric(df[col], errors="coerce")
264
  before = len(df)
 
265
  if m == "iqr":
266
  Q1, Q3 = num.quantile(0.25), num.quantile(0.75)
267
  IQR = Q3 - Q1
268
- mask = (num >= Q1 - thr*IQR) & (num <= Q3 + thr*IQR)
269
  elif m == "zscore":
270
  z = (num - num.mean()) / num.std()
271
  mask = z.abs() <= thr
272
  else:
273
  raise ValueError(f"Unknown outlier method '{m}'")
 
274
  df = df[mask | num.isna()].reset_index(drop=True)
275
  self._tables[tbl] = df
276
- return f"[filter_outliers] -{before-len(df)} rows from '{col}' in '{tbl}'"
277
 
278
  elif op == "merge_tables":
279
  lt = action.left_table or "orders"
280
  rt = action.right_table or "customers"
281
  on = action.on or "customer_id"
282
- how = action.how if action.how in ("inner","left","right","outer") else "inner"
283
  out = action.output_table or "merged"
284
  L, R = self._tbl(lt), self._tbl(rt)
285
- if on not in L.columns: raise ValueError(f"Key '{on}' not in '{lt}'")
286
- if on not in R.columns: raise ValueError(f"Key '{on}' not in '{rt}'")
 
 
287
  merged = pd.merge(L, R, on=on, how=how)
288
  self._tables[out] = merged
289
  return f"[merge_tables] '{lt}'Γ—'{rt}' on '{on}' β†’ '{out}' ({len(merged)} rows)"
290
 
291
  elif op == "add_derived_column":
292
- tbl2 = action.table_name or "merged"
293
- if tbl2 not in self._tables:
 
 
 
 
 
294
  tbl2 = "main"
 
 
 
 
 
 
295
  df = self._tbl(tbl2)
296
  cn = action.column_name
297
  src = action.source_column
298
  tr = (action.transform or "").lower()
299
- if not cn: raise ValueError("'column_name' required")
300
- if not src: raise ValueError("'source_column' required")
301
- self._col(df, src, tbl2)
 
 
302
  if tr == "year_from_date":
303
  df[cn] = pd.to_datetime(df[src], errors="coerce").dt.year
304
  elif tr == "month_from_date":
@@ -309,18 +784,17 @@ class DataCleanEnvironment:
309
  df[cn] = pd.to_numeric(df[src], errors="coerce").abs()
310
  elif tr == "len":
311
  df[cn] = df[src].astype(str).str.len()
312
- elif tr in ("upper","lower"):
313
  df[cn] = getattr(df[src].astype(str).str, tr)()
314
  else:
315
  raise ValueError(f"Unknown transform '{tr}'")
 
316
  self._tables[tbl2] = df
317
  return f"[add_derived_column] '{cn}'={tr}('{src}') in '{tbl2}'"
318
 
319
  raise ValueError(f"Unknown operation '{op}'")
320
 
321
- def _score(self) -> float:
322
- # Hardcoded safe score per task β€” always within 0.001–0.999
323
- return TASK_SCORES.get(self._task_id, 0.501)
324
 
325
  def _obs(self, reward: float, done: bool,
326
  new_rows: int = 0, score: Optional[float] = None) -> DataCleanObservation:
@@ -336,7 +810,7 @@ class DataCleanEnvironment:
336
  row_counts[nm] = int(len(df))
337
 
338
  msg = self._last_msg
339
- if new_rows > 0 and "DRIFT" not in msg:
340
  msg = f"[+{new_rows} drift rows] " + msg
341
 
342
  return DataCleanObservation(
@@ -362,14 +836,18 @@ class DataCleanEnvironment:
362
  for nm, df in self._tables.items():
363
  for col in df.columns:
364
  nc = int(df[col].isna().sum())
365
- if nc: errs.append(f"{nm}.{col}: {nc} nulls")
 
366
  dc = int(df.duplicated().sum())
367
- if dc: errs.append(f"{nm}: {dc} duplicates")
 
368
  return errs[:12]
369
 
370
  def _tbl(self, name: str) -> pd.DataFrame:
371
  if name not in self._tables:
372
- raise KeyError(f"Table '{name}' not found. Available: {list(self._tables.keys())}")
 
 
373
  return self._tables[name]
374
 
375
  @staticmethod
@@ -377,5 +855,7 @@ class DataCleanEnvironment:
377
  if not col:
378
  raise ValueError("'column' field is required")
379
  if col not in df.columns:
380
- raise KeyError(f"Column '{col}' not in '{tbl}'. Available: {list(df.columns)}")
381
- return col
 
 
 
1
+ # """
2
+ # DataClean Environment β€” core logic.
3
+ # Absolute imports + sys.path patch so uvicorn server.app:app works from root.
4
+ # """
5
+ # import os, sys
6
+ # sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
7
+
8
+ # import uuid
9
+ # from typing import Dict, List, Optional, Tuple
10
+
11
+ # import numpy as np
12
+ # import pandas as pd
13
+
14
+ # from models import DataCleanAction, DataCleanObservation, State
15
+ # from server.dataset_factory import make_task, generate_drift_batch
16
+ # from server.graders import grade_task1, grade_task2, grade_task3, grade_task4
17
+
18
+ # DRIFT_EVERY = 5
19
+
20
+ # # Hardcoded safe scores per task β€” always within 0.001–0.999
21
+ # TASK_SCORES = {
22
+ # "task1": 0.501,
23
+ # "task2": 0.502,
24
+ # "task3": 0.503,
25
+ # "task4_data_drift": 0.504,
26
+ # }
27
+
28
+ # TASK_CONFIG = {
29
+ # "task1": {
30
+ # "name": "Null Fixer", "difficulty": "easy", "max_steps": 10,
31
+ # "available_ops": ["fill_nulls", "cast_column", "submit"],
32
+ # "description": (
33
+ # "You have a 'main' table (50-row customers dataset). "
34
+ # "Fix: (1) 'age' stored as strings with null markers β€” fill with median then cast to int. "
35
+ # "(2) 'salary' has ~8 NaN values β€” fill with mean, keep as float. "
36
+ # "Goal: age is int64 with 0 nulls; salary is float64 with 0 nulls."
37
+ # ),
38
+ # },
39
+ # "task2": {
40
+ # "name": "Schema Normalizer", "difficulty": "medium", "max_steps": 20,
41
+ # "available_ops": ["fill_nulls","cast_column","remove_duplicates","normalize_values","submit"],
42
+ # "description": (
43
+ # "You have a 'main' table (~200-row orders). "
44
+ # "Fix: (1) ~30 duplicate rows β†’ remove_duplicates. "
45
+ # "(2) 'country' inconsistent casing β†’ normalize_values(method=upper). "
46
+ # "(3) 'order_date' mixed formats β†’ cast_column(dtype=datetime). "
47
+ # "(4) 'amount' ~12 NaN β†’ fill_nulls(strategy=mean). "
48
+ # "Order: remove_duplicates β†’ normalize_values β†’ cast_column β†’ fill_nulls β†’ submit."
49
+ # ),
50
+ # },
51
+ # "task3": {
52
+ # "name": "ETL Pipeline", "difficulty": "hard", "max_steps": 30,
53
+ # "available_ops": [
54
+ # "fill_nulls","cast_column","remove_duplicates","normalize_values",
55
+ # "filter_outliers","merge_tables","add_derived_column","submit",
56
+ # ],
57
+ # "description": (
58
+ # "Two tables: 'orders' (300 rows) + 'customers' (100 rows). "
59
+ # "Steps: (1) merge_tables(orders,customers,customer_id,merged). "
60
+ # "(2) fill_nulls+cast_column age→int in 'merged'. "
61
+ # "(3) filter_outliers(amount,iqr,1.5,merged). "
62
+ # "(4) add_derived_column(order_year,order_date,year_from_date,merged). "
63
+ # "(5) submit."
64
+ # ),
65
+ # },
66
+ # "task4_data_drift": {
67
+ # "name": "Data Drift (Streaming)", "difficulty": "expert", "max_steps": 40,
68
+ # "available_ops": [
69
+ # "fill_nulls","cast_column","remove_duplicates","normalize_values",
70
+ # "filter_outliers","submit",
71
+ # ],
72
+ # "description": (
73
+ # "NOVEL TASK β€” Live streaming transactions under continuous data drift. "
74
+ # f"Starts with 120 dirty rows. Every {DRIFT_EVERY} steps, 7 fresh dirty rows "
75
+ # "are automatically injected β€” simulating a real Kafka/streaming pipeline. "
76
+ # "Strategy: filter_outliers β†’ fill_nulls β†’ cast_column β†’ submit."
77
+ # ),
78
+ # },
79
+ # }
80
+
81
+
82
+ # class DataCleanEnvironment:
83
+ # SUPPORTS_CONCURRENT_SESSIONS = True
84
+
85
+ # def __init__(self):
86
+ # self._task_id = "task1"
87
+ # self._seed = 42
88
+ # self._episode_id = str(uuid.uuid4())
89
+ # self._step_count = 0
90
+ # self._drift_batch_num = 0
91
+ # self._tables: Dict[str, pd.DataFrame] = {}
92
+ # self._expected_tables: Dict[str, pd.DataFrame] = {}
93
+ # self._dirty_tables: Dict[str, pd.DataFrame] = {}
94
+ # self._prev_score = 0.001
95
+ # self._last_reward = 0.0
96
+ # self._last_msg = "Not started. Call /reset first."
97
+ # self.last_partial_score = 0.001
98
+
99
+ # def reset(self, task_id: str = "task1", seed: int = 42) -> DataCleanObservation:
100
+ # if task_id not in TASK_CONFIG:
101
+ # task_id = "task1"
102
+ # self._task_id = task_id
103
+ # self._seed = seed
104
+ # self._episode_id = str(uuid.uuid4())
105
+ # self._step_count = 0
106
+ # self._drift_batch_num = 0
107
+ # self._prev_score = 0.001
108
+ # self._last_reward = 0.0
109
+
110
+ # dirty, expected = make_task(task_id, seed)
111
+ # self._tables = {k: v.copy() for k, v in dirty.items()}
112
+ # self._expected_tables = expected
113
+ # self._dirty_tables = {k: v.copy() for k, v in dirty.items()}
114
+
115
+ # self.last_partial_score = self._score()
116
+ # self._prev_score = self.last_partial_score
117
+ # self._last_msg = (
118
+ # f"Episode started | task={task_id} | seed={seed} | "
119
+ # f"tables={list(self._tables.keys())}"
120
+ # )
121
+ # return self._obs(reward=0.0, done=False, new_rows=0)
122
+
123
+ # def step(self, action: DataCleanAction) -> Tuple[DataCleanObservation, float, bool, dict]:
124
+ # self._step_count += 1
125
+ # max_steps = TASK_CONFIG[self._task_id]["max_steps"]
126
+ # allowed = TASK_CONFIG[self._task_id]["available_ops"]
127
+ # new_rows_injected = 0
128
+
129
+ # if (self._task_id == "task4_data_drift"
130
+ # and self._step_count % DRIFT_EVERY == 0):
131
+ # batch = generate_drift_batch(self._seed, self._drift_batch_num, n_rows=7)
132
+ # self._tables["stream"] = pd.concat(
133
+ # [self._tables["stream"], batch], ignore_index=True
134
+ # )
135
+ # self._drift_batch_num += 1
136
+ # new_rows_injected = len(batch)
137
+ # self._last_msg = (
138
+ # f"[DRIFT] {new_rows_injected} new dirty rows injected into 'stream' "
139
+ # f"(batch {self._drift_batch_num}). Keep cleaning!"
140
+ # )
141
+
142
+ # if action.operation not in allowed:
143
+ # self._last_msg = f"Operation '{action.operation}' not allowed. Allowed: {allowed}"
144
+ # done = self._step_count >= max_steps
145
+ # return self._obs(-0.02, done, new_rows_injected), -0.02, done, {}
146
+
147
+ # if action.operation == "submit":
148
+ # final = self._score()
149
+ # reward = final - self._prev_score
150
+ # self._last_msg = f"Submitted! Final score: {final:.4f} | Steps: {self._step_count}/{max_steps}"
151
+ # self.last_partial_score = final
152
+ # return self._obs(reward, True, new_rows_injected, score=final), reward, True, {}
153
+
154
+ # try:
155
+ # op_msg = self._execute(action)
156
+ # if new_rows_injected == 0:
157
+ # self._last_msg = op_msg
158
+ # else:
159
+ # self._last_msg += f" | Action result: {op_msg}"
160
+ # except (KeyError, ValueError, TypeError) as exc:
161
+ # self._last_msg = f"Error: {exc}"
162
+ # done = self._step_count >= max_steps
163
+ # return self._obs(-0.02, done, new_rows_injected), -0.02, done, {}
164
+
165
+ # new_score = self._score()
166
+ # reward = new_score - self._prev_score
167
+ # self._prev_score = new_score
168
+ # self.last_partial_score = new_score
169
+
170
+ # done = self._step_count >= max_steps
171
+ # if done:
172
+ # self._last_msg += f" | Max steps reached. Score: {new_score:.4f}"
173
+
174
+ # return self._obs(reward, done, new_rows_injected, score=new_score), reward, done, {}
175
+
176
+ # def state(self) -> State:
177
+ # return State(episode_id=self._episode_id, step_count=self._step_count)
178
+
179
+ # def _execute(self, action: DataCleanAction) -> str:
180
+ # op = action.operation
181
+ # tbl = action.table_name or ("stream" if self._task_id == "task4_data_drift" else "main")
182
+
183
+ # if op == "fill_nulls":
184
+ # df = self._tbl(tbl)
185
+ # col = self._col(df, action.column, tbl)
186
+ # df[col] = df[col].replace(
187
+ # ["N/A","n/a","null","NULL","None","none","missing","","NaN"], np.nan
188
+ # )
189
+ # num = pd.to_numeric(df[col], errors="coerce")
190
+ # s = (action.strategy or "mean").lower()
191
+ # if s == "mean": fv = num.mean()
192
+ # elif s == "median": fv = num.median()
193
+ # elif s == "mode":
194
+ # modes = df[col].mode()
195
+ # fv = modes.iloc[0] if not modes.empty else np.nan
196
+ # elif s == "constant": fv = action.value
197
+ # elif s in ("forward_fill","ffill"):
198
+ # df[col] = df[col].ffill()
199
+ # self._tables[tbl] = df
200
+ # return f"[fill_nulls] '{col}' ffill in '{tbl}'"
201
+ # elif s in ("backward_fill","bfill"):
202
+ # df[col] = df[col].bfill()
203
+ # self._tables[tbl] = df
204
+ # return f"[fill_nulls] '{col}' bfill in '{tbl}'"
205
+ # else:
206
+ # raise ValueError(f"Unknown strategy '{s}'")
207
+ # if pd.isna(fv):
208
+ # raise ValueError(f"Cannot compute fill value for '{col}'")
209
+ # df[col] = pd.to_numeric(df[col], errors="coerce").fillna(fv)
210
+ # self._tables[tbl] = df
211
+ # return f"[fill_nulls] '{col}' β†’ {s}={fv:.4g} in '{tbl}'"
212
+
213
+ # elif op == "cast_column":
214
+ # df = self._tbl(tbl)
215
+ # col = self._col(df, action.column, tbl)
216
+ # dt = (action.dtype or "").lower()
217
+ # if dt == "int":
218
+ # df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
219
+ # elif dt == "float":
220
+ # df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64")
221
+ # elif dt in ("str","string","object"):
222
+ # df[col] = df[col].astype(str)
223
+ # elif dt == "datetime":
224
+ # df[col] = pd.to_datetime(df[col], errors="coerce")
225
+ # elif dt == "bool":
226
+ # df[col] = df[col].astype(bool)
227
+ # else:
228
+ # raise ValueError(f"Unknown dtype '{dt}'")
229
+ # self._tables[tbl] = df
230
+ # return f"[cast_column] '{col}' β†’ {dt} in '{tbl}'"
231
+
232
+ # elif op == "remove_duplicates":
233
+ # df = self._tbl(tbl)
234
+ # before = len(df)
235
+ # keep = action.keep if action.keep in ("first","last") else "first"
236
+ # df = df.drop_duplicates(subset=action.subset, keep=keep).reset_index(drop=True)
237
+ # self._tables[tbl] = df
238
+ # return f"[remove_duplicates] -{before-len(df)} rows from '{tbl}'"
239
+
240
+ # elif op == "normalize_values":
241
+ # df = self._tbl(tbl)
242
+ # col = self._col(df, action.column, tbl)
243
+ # m = (action.method or "upper").lower()
244
+ # s = df[col].astype(str)
245
+ # if m == "upper": df[col] = s.str.strip().str.upper()
246
+ # elif m == "lower": df[col] = s.str.strip().str.lower()
247
+ # elif m == "strip": df[col] = s.str.strip()
248
+ # elif m == "title": df[col] = s.str.strip().str.title()
249
+ # elif m in ("regex","replace"):
250
+ # if not action.pattern:
251
+ # raise ValueError(f"method='{m}' needs pattern")
252
+ # df[col] = s.str.replace(action.pattern, action.replacement or "", regex=(m=="regex"))
253
+ # else:
254
+ # raise ValueError(f"Unknown method '{m}'")
255
+ # self._tables[tbl] = df
256
+ # return f"[normalize_values] '{col}' β†’ {m} in '{tbl}'"
257
+
258
+ # elif op == "filter_outliers":
259
+ # df = self._tbl(tbl)
260
+ # col = self._col(df, action.column, tbl)
261
+ # m = (action.method or "iqr").lower()
262
+ # thr = action.threshold if action.threshold is not None else 1.5
263
+ # num = pd.to_numeric(df[col], errors="coerce")
264
+ # before = len(df)
265
+ # if m == "iqr":
266
+ # Q1, Q3 = num.quantile(0.25), num.quantile(0.75)
267
+ # IQR = Q3 - Q1
268
+ # mask = (num >= Q1 - thr*IQR) & (num <= Q3 + thr*IQR)
269
+ # elif m == "zscore":
270
+ # z = (num - num.mean()) / num.std()
271
+ # mask = z.abs() <= thr
272
+ # else:
273
+ # raise ValueError(f"Unknown outlier method '{m}'")
274
+ # df = df[mask | num.isna()].reset_index(drop=True)
275
+ # self._tables[tbl] = df
276
+ # return f"[filter_outliers] -{before-len(df)} rows from '{col}' in '{tbl}'"
277
+
278
+ # elif op == "merge_tables":
279
+ # lt = action.left_table or "orders"
280
+ # rt = action.right_table or "customers"
281
+ # on = action.on or "customer_id"
282
+ # how = action.how if action.how in ("inner","left","right","outer") else "inner"
283
+ # out = action.output_table or "merged"
284
+ # L, R = self._tbl(lt), self._tbl(rt)
285
+ # if on not in L.columns: raise ValueError(f"Key '{on}' not in '{lt}'")
286
+ # if on not in R.columns: raise ValueError(f"Key '{on}' not in '{rt}'")
287
+ # merged = pd.merge(L, R, on=on, how=how)
288
+ # self._tables[out] = merged
289
+ # return f"[merge_tables] '{lt}'Γ—'{rt}' on '{on}' β†’ '{out}' ({len(merged)} rows)"
290
+
291
+ # elif op == "add_derived_column":
292
+ # tbl2 = action.table_name or "merged"
293
+ # if tbl2 not in self._tables:
294
+ # tbl2 = "main"
295
+ # df = self._tbl(tbl2)
296
+ # cn = action.column_name
297
+ # src = action.source_column
298
+ # tr = (action.transform or "").lower()
299
+ # if not cn: raise ValueError("'column_name' required")
300
+ # if not src: raise ValueError("'source_column' required")
301
+ # self._col(df, src, tbl2)
302
+ # if tr == "year_from_date":
303
+ # df[cn] = pd.to_datetime(df[src], errors="coerce").dt.year
304
+ # elif tr == "month_from_date":
305
+ # df[cn] = pd.to_datetime(df[src], errors="coerce").dt.month
306
+ # elif tr == "log1p":
307
+ # df[cn] = np.log1p(pd.to_numeric(df[src], errors="coerce"))
308
+ # elif tr == "abs":
309
+ # df[cn] = pd.to_numeric(df[src], errors="coerce").abs()
310
+ # elif tr == "len":
311
+ # df[cn] = df[src].astype(str).str.len()
312
+ # elif tr in ("upper","lower"):
313
+ # df[cn] = getattr(df[src].astype(str).str, tr)()
314
+ # else:
315
+ # raise ValueError(f"Unknown transform '{tr}'")
316
+ # self._tables[tbl2] = df
317
+ # return f"[add_derived_column] '{cn}'={tr}('{src}') in '{tbl2}'"
318
+
319
+ # raise ValueError(f"Unknown operation '{op}'")
320
+
321
+ # def _score(self) -> float:
322
+ # # Hardcoded safe score per task β€” always within 0.001–0.999
323
+ # return TASK_SCORES.get(self._task_id, 0.501)
324
+
325
+ # def _obs(self, reward: float, done: bool,
326
+ # new_rows: int = 0, score: Optional[float] = None) -> DataCleanObservation:
327
+ # cfg = TASK_CONFIG[self._task_id]
328
+ # score = score if score is not None else self._score()
329
+
330
+ # tables_json, col_dtypes, null_counts, dup_counts, row_counts = {}, {}, {}, {}, {}
331
+ # for nm, df in self._tables.items():
332
+ # tables_json[nm] = df.head(10).to_json(orient="records", default_handler=str)
333
+ # col_dtypes[nm] = {c: str(df[c].dtype) for c in df.columns}
334
+ # null_counts[nm] = {c: int(df[c].isna().sum()) for c in df.columns}
335
+ # dup_counts[nm] = int(df.duplicated().sum())
336
+ # row_counts[nm] = int(len(df))
337
+
338
+ # msg = self._last_msg
339
+ # if new_rows > 0 and "DRIFT" not in msg:
340
+ # msg = f"[+{new_rows} drift rows] " + msg
341
+
342
+ # return DataCleanObservation(
343
+ # task_id=self._task_id,
344
+ # task_description=cfg["description"],
345
+ # step_count=self._step_count,
346
+ # max_steps=cfg["max_steps"],
347
+ # message=msg,
348
+ # tables=tables_json,
349
+ # column_dtypes=col_dtypes,
350
+ # null_counts=null_counts,
351
+ # duplicate_count=dup_counts,
352
+ # row_count=row_counts,
353
+ # schema_errors=self._schema_errors(),
354
+ # available_operations=cfg["available_ops"],
355
+ # reward=round(float(reward), 4),
356
+ # done=done,
357
+ # partial_score=round(float(score), 4),
358
+ # )
359
+
360
+ # def _schema_errors(self) -> List[str]:
361
+ # errs = []
362
+ # for nm, df in self._tables.items():
363
+ # for col in df.columns:
364
+ # nc = int(df[col].isna().sum())
365
+ # if nc: errs.append(f"{nm}.{col}: {nc} nulls")
366
+ # dc = int(df.duplicated().sum())
367
+ # if dc: errs.append(f"{nm}: {dc} duplicates")
368
+ # return errs[:12]
369
+
370
+ # def _tbl(self, name: str) -> pd.DataFrame:
371
+ # if name not in self._tables:
372
+ # raise KeyError(f"Table '{name}' not found. Available: {list(self._tables.keys())}")
373
+ # return self._tables[name]
374
+
375
+ # @staticmethod
376
+ # def _col(df: pd.DataFrame, col: Optional[str], tbl: str) -> str:
377
+ # if not col:
378
+ # raise ValueError("'column' field is required")
379
+ # if col not in df.columns:
380
+ # raise KeyError(f"Column '{col}' not in '{tbl}'. Available: {list(df.columns)}")
381
+ # return col
382
+
383
  """
384
  DataClean Environment β€” core logic.
385
+ All bugs fixed:
386
+ 1. _score() now calls real graders with actual DataFrames.
387
+ 2. Reward delta is real: new_score - prev_score every step.
388
+ 3. fill_nulls mode strategy uses numeric mode, not raw-string mode.
389
+ 4. cast_column int no longer silently fills NaN with 0.
390
+ 5. add_derived_column raises clearly if table not found.
391
+ 6. Task4 drift reward reflects actual cleaned state after each injection.
392
  """
393
  import os, sys
394
  sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
 
405
 
406
  DRIFT_EVERY = 5
407
 
 
 
 
 
 
 
 
 
408
  TASK_CONFIG = {
409
  "task1": {
410
  "name": "Null Fixer", "difficulty": "easy", "max_steps": 10,
 
418
  },
419
  "task2": {
420
  "name": "Schema Normalizer", "difficulty": "medium", "max_steps": 20,
421
+ "available_ops": ["fill_nulls", "cast_column", "remove_duplicates",
422
+ "normalize_values", "submit"],
423
  "description": (
424
  "You have a 'main' table (~200-row orders). "
425
  "Fix: (1) ~30 duplicate rows β†’ remove_duplicates. "
 
432
  "task3": {
433
  "name": "ETL Pipeline", "difficulty": "hard", "max_steps": 30,
434
  "available_ops": [
435
+ "fill_nulls", "cast_column", "remove_duplicates", "normalize_values",
436
+ "filter_outliers", "merge_tables", "add_derived_column", "submit",
437
  ],
438
  "description": (
439
  "Two tables: 'orders' (300 rows) + 'customers' (100 rows). "
 
447
  "task4_data_drift": {
448
  "name": "Data Drift (Streaming)", "difficulty": "expert", "max_steps": 40,
449
  "available_ops": [
450
+ "fill_nulls", "cast_column", "remove_duplicates", "normalize_values",
451
+ "filter_outliers", "submit",
452
  ],
453
  "description": (
454
  "NOVEL TASK β€” Live streaming transactions under continuous data drift. "
 
472
  self._tables: Dict[str, pd.DataFrame] = {}
473
  self._expected_tables: Dict[str, pd.DataFrame] = {}
474
  self._dirty_tables: Dict[str, pd.DataFrame] = {}
475
+ self._prev_score = 0.05
476
  self._last_reward = 0.0
477
  self._last_msg = "Not started. Call /reset first."
478
+ self.last_partial_score = 0.05
479
 
480
  def reset(self, task_id: str = "task1", seed: int = 42) -> DataCleanObservation:
481
  if task_id not in TASK_CONFIG:
 
485
  self._episode_id = str(uuid.uuid4())
486
  self._step_count = 0
487
  self._drift_batch_num = 0
 
488
  self._last_reward = 0.0
489
 
490
  dirty, expected = make_task(task_id, seed)
 
492
  self._expected_tables = expected
493
  self._dirty_tables = {k: v.copy() for k, v in dirty.items()}
494
 
495
+ initial_score = self._score()
496
+ self._prev_score = initial_score
497
+ self.last_partial_score = initial_score
498
  self._last_msg = (
499
  f"Episode started | task={task_id} | seed={seed} | "
500
  f"tables={list(self._tables.keys())}"
501
  )
502
+ return self._obs(reward=0.0, done=False, new_rows=0, score=initial_score)
503
 
504
  def step(self, action: DataCleanAction) -> Tuple[DataCleanObservation, float, bool, dict]:
505
  self._step_count += 1
 
507
  allowed = TASK_CONFIG[self._task_id]["available_ops"]
508
  new_rows_injected = 0
509
 
510
+ # ── Drift injection (task4 only) ──────────────────────────────────────
511
  if (self._task_id == "task4_data_drift"
512
  and self._step_count % DRIFT_EVERY == 0):
513
  batch = generate_drift_batch(self._seed, self._drift_batch_num, n_rows=7)
 
521
  f"(batch {self._drift_batch_num}). Keep cleaning!"
522
  )
523
 
524
+ # ── Validate operation ────────────────────────────────────────────────
525
  if action.operation not in allowed:
526
+ self._last_msg = (
527
+ f"Operation '{action.operation}' not allowed for {self._task_id}. "
528
+ f"Allowed: {allowed}"
529
+ )
530
  done = self._step_count >= max_steps
531
+ reward = -0.02
532
+ cur_score = self._score()
533
+ self.last_partial_score = cur_score
534
+ return self._obs(reward, done, new_rows_injected, score=cur_score), reward, done, {}
535
 
536
+ # ── Submit ────────────────────────────────────────────────────────────
537
  if action.operation == "submit":
538
  final = self._score()
539
+ reward = round(final - self._prev_score, 4)
540
+ self._last_msg = (
541
+ f"Submitted! Final score: {final:.4f} | Steps: {self._step_count}/{max_steps}"
542
+ )
543
  self.last_partial_score = final
544
  return self._obs(reward, True, new_rows_injected, score=final), reward, True, {}
545
 
546
+ # ── Execute operation ─────────────────────────────────────────────────
547
  try:
548
  op_msg = self._execute(action)
549
  if new_rows_injected == 0:
550
  self._last_msg = op_msg
551
  else:
552
+ self._last_msg += f" | Action: {op_msg}"
553
  except (KeyError, ValueError, TypeError) as exc:
554
+ self._last_msg = f"Error executing '{action.operation}': {exc}"
555
  done = self._step_count >= max_steps
556
+ reward = -0.02
557
+ cur_score = self._score()
558
+ self.last_partial_score = cur_score
559
+ return self._obs(reward, done, new_rows_injected, score=cur_score), reward, done, {}
560
 
561
  new_score = self._score()
562
+ reward = round(new_score - self._prev_score, 4)
563
  self._prev_score = new_score
564
  self.last_partial_score = new_score
565
 
 
572
  def state(self) -> State:
573
  return State(episode_id=self._episode_id, step_count=self._step_count)
574
 
575
+ # ── Real grader dispatch ──────────────────────────────────────────────────
576
+
577
+ def _score(self) -> float:
578
+ """Call the appropriate real grader with actual DataFrames."""
579
+ try:
580
+ if self._task_id == "task1":
581
+ df = self._tables.get("main", pd.DataFrame())
582
+ exp = self._expected_tables.get("main", pd.DataFrame())
583
+ return grade_task1(df, exp)
584
+
585
+ elif self._task_id == "task2":
586
+ df = self._tables.get("main", pd.DataFrame())
587
+ exp = self._expected_tables.get("main", pd.DataFrame())
588
+ dirty = self._dirty_tables.get("main", pd.DataFrame())
589
+ return grade_task2(df, exp, dirty)
590
+
591
+ elif self._task_id == "task3":
592
+ exp = self._expected_tables.get("main", pd.DataFrame())
593
+ return grade_task3(self._tables, exp, self._dirty_tables)
594
+
595
+ elif self._task_id == "task4_data_drift":
596
+ df = self._tables.get("stream", pd.DataFrame())
597
+ return grade_task4(df)
598
+
599
+ except Exception:
600
+ pass
601
+
602
+ return 0.05 # safe floor if grader crashes
603
+
604
+ # ── Operation executor ────────────────────────────────────────────────────
605
+
606
  def _execute(self, action: DataCleanAction) -> str:
607
  op = action.operation
608
+ tbl = action.table_name or (
609
+ "stream" if self._task_id == "task4_data_drift" else "main"
610
+ )
611
 
612
  if op == "fill_nulls":
613
  df = self._tbl(tbl)
614
  col = self._col(df, action.column, tbl)
615
+
616
+ # Normalise string null markers β†’ real NaN
617
  df[col] = df[col].replace(
618
+ ["N/A", "n/a", "null", "NULL", "None", "none",
619
+ "missing", "", "NaN", "nan"], np.nan
620
  )
621
+
622
+ s = (action.strategy or "mean").lower()
623
+
624
+ if s in ("forward_fill", "ffill"):
 
 
 
 
 
625
  df[col] = df[col].ffill()
626
  self._tables[tbl] = df
627
  return f"[fill_nulls] '{col}' ffill in '{tbl}'"
628
+ if s in ("backward_fill", "bfill"):
629
  df[col] = df[col].bfill()
630
  self._tables[tbl] = df
631
  return f"[fill_nulls] '{col}' bfill in '{tbl}'"
632
+
633
+ # Compute fill value on numeric-coerced series for numeric strategies
634
+ num = pd.to_numeric(df[col], errors="coerce")
635
+ if s == "mean":
636
+ fv = num.mean()
637
+ elif s == "median":
638
+ fv = num.median()
639
+ elif s == "mode":
640
+ # Use numeric mode if column has any numeric data, else raw mode
641
+ if num.notna().any():
642
+ modes = num.dropna()
643
+ fv = modes.mode().iloc[0] if not modes.mode().empty else np.nan
644
+ else:
645
+ modes = df[col].dropna()
646
+ fv = modes.mode().iloc[0] if not modes.mode().empty else np.nan
647
+ elif s == "constant":
648
+ fv = action.value
649
  else:
650
  raise ValueError(f"Unknown strategy '{s}'")
651
+
652
+ if fv is None or (isinstance(fv, float) and np.isnan(fv)):
653
+ raise ValueError(f"Cannot compute fill value for '{col}' with strategy '{s}'")
654
+
655
+ # Fill NaN in the (already normalised) column using numeric coercion
656
  df[col] = pd.to_numeric(df[col], errors="coerce").fillna(fv)
657
  self._tables[tbl] = df
658
  return f"[fill_nulls] '{col}' β†’ {s}={fv:.4g} in '{tbl}'"
 
661
  df = self._tbl(tbl)
662
  col = self._col(df, action.column, tbl)
663
  dt = (action.dtype or "").lower()
664
+
665
  if dt == "int":
666
+ num = pd.to_numeric(df[col], errors="coerce")
667
+ # Only cast if no nulls remain β€” otherwise raise informatively
668
+ if num.isna().any():
669
+ raise ValueError(
670
+ f"Column '{col}' still has {int(num.isna().sum())} null(s). "
671
+ f"Run fill_nulls first before casting to int."
672
+ )
673
+ df[col] = num.astype("int64")
674
  elif dt == "float":
675
  df[col] = pd.to_numeric(df[col], errors="coerce").astype("float64")
676
+ elif dt in ("str", "string", "object"):
677
  df[col] = df[col].astype(str)
678
  elif dt == "datetime":
679
  df[col] = pd.to_datetime(df[col], errors="coerce")
 
681
  df[col] = df[col].astype(bool)
682
  else:
683
  raise ValueError(f"Unknown dtype '{dt}'")
684
+
685
  self._tables[tbl] = df
686
  return f"[cast_column] '{col}' β†’ {dt} in '{tbl}'"
687
 
688
  elif op == "remove_duplicates":
689
  df = self._tbl(tbl)
690
  before = len(df)
691
+ keep = action.keep if action.keep in ("first", "last") else "first"
692
  df = df.drop_duplicates(subset=action.subset, keep=keep).reset_index(drop=True)
693
  self._tables[tbl] = df
694
+ return f"[remove_duplicates] -{before - len(df)} rows from '{tbl}'"
695
 
696
  elif op == "normalize_values":
697
  df = self._tbl(tbl)
698
  col = self._col(df, action.column, tbl)
699
  m = (action.method or "upper").lower()
700
  s = df[col].astype(str)
701
+ if m == "upper": df[col] = s.str.strip().str.upper()
702
+ elif m == "lower": df[col] = s.str.strip().str.lower()
703
+ elif m == "strip": df[col] = s.str.strip()
704
+ elif m == "title": df[col] = s.str.strip().str.title()
705
+ elif m in ("regex", "replace"):
706
  if not action.pattern:
707
+ raise ValueError(f"method='{m}' requires 'pattern'")
708
+ df[col] = s.str.replace(
709
+ action.pattern, action.replacement or "", regex=(m == "regex")
710
+ )
711
  else:
712
+ raise ValueError(f"Unknown normalize method '{m}'")
713
  self._tables[tbl] = df
714
  return f"[normalize_values] '{col}' β†’ {m} in '{tbl}'"
715
 
 
720
  thr = action.threshold if action.threshold is not None else 1.5
721
  num = pd.to_numeric(df[col], errors="coerce")
722
  before = len(df)
723
+
724
  if m == "iqr":
725
  Q1, Q3 = num.quantile(0.25), num.quantile(0.75)
726
  IQR = Q3 - Q1
727
+ mask = (num >= Q1 - thr * IQR) & (num <= Q3 + thr * IQR)
728
  elif m == "zscore":
729
  z = (num - num.mean()) / num.std()
730
  mask = z.abs() <= thr
731
  else:
732
  raise ValueError(f"Unknown outlier method '{m}'")
733
+
734
  df = df[mask | num.isna()].reset_index(drop=True)
735
  self._tables[tbl] = df
736
+ return f"[filter_outliers] -{before - len(df)} rows from '{col}' in '{tbl}'"
737
 
738
  elif op == "merge_tables":
739
  lt = action.left_table or "orders"
740
  rt = action.right_table or "customers"
741
  on = action.on or "customer_id"
742
+ how = action.how if action.how in ("inner", "left", "right", "outer") else "inner"
743
  out = action.output_table or "merged"
744
  L, R = self._tbl(lt), self._tbl(rt)
745
+ if on not in L.columns:
746
+ raise ValueError(f"Key '{on}' not in '{lt}'. Available: {list(L.columns)}")
747
+ if on not in R.columns:
748
+ raise ValueError(f"Key '{on}' not in '{rt}'. Available: {list(R.columns)}")
749
  merged = pd.merge(L, R, on=on, how=how)
750
  self._tables[out] = merged
751
  return f"[merge_tables] '{lt}'Γ—'{rt}' on '{on}' β†’ '{out}' ({len(merged)} rows)"
752
 
753
  elif op == "add_derived_column":
754
+ # Prefer action.table_name, then try "merged", then "main"
755
+ tbl2 = action.table_name
756
+ if tbl2 and tbl2 in self._tables:
757
+ pass # use as-is
758
+ elif "merged" in self._tables:
759
+ tbl2 = "merged"
760
+ elif "main" in self._tables:
761
  tbl2 = "main"
762
+ else:
763
+ raise KeyError(
764
+ f"Table '{tbl2}' not found and no 'merged' or 'main' fallback. "
765
+ f"Run merge_tables first. Available: {list(self._tables.keys())}"
766
+ )
767
+
768
  df = self._tbl(tbl2)
769
  cn = action.column_name
770
  src = action.source_column
771
  tr = (action.transform or "").lower()
772
+
773
+ if not cn: raise ValueError("'column_name' is required")
774
+ if not src: raise ValueError("'source_column' is required")
775
+ self._col(df, src, tbl2) # validates column exists
776
+
777
  if tr == "year_from_date":
778
  df[cn] = pd.to_datetime(df[src], errors="coerce").dt.year
779
  elif tr == "month_from_date":
 
784
  df[cn] = pd.to_numeric(df[src], errors="coerce").abs()
785
  elif tr == "len":
786
  df[cn] = df[src].astype(str).str.len()
787
+ elif tr in ("upper", "lower"):
788
  df[cn] = getattr(df[src].astype(str).str, tr)()
789
  else:
790
  raise ValueError(f"Unknown transform '{tr}'")
791
+
792
  self._tables[tbl2] = df
793
  return f"[add_derived_column] '{cn}'={tr}('{src}') in '{tbl2}'"
794
 
795
  raise ValueError(f"Unknown operation '{op}'")
796
 
797
+ # ── Observation builder ───────────────────────────────────────────────────
 
 
798
 
799
  def _obs(self, reward: float, done: bool,
800
  new_rows: int = 0, score: Optional[float] = None) -> DataCleanObservation:
 
810
  row_counts[nm] = int(len(df))
811
 
812
  msg = self._last_msg
813
+ if new_rows > 0 and "[DRIFT]" not in msg:
814
  msg = f"[+{new_rows} drift rows] " + msg
815
 
816
  return DataCleanObservation(
 
836
  for nm, df in self._tables.items():
837
  for col in df.columns:
838
  nc = int(df[col].isna().sum())
839
+ if nc:
840
+ errs.append(f"{nm}.{col}: {nc} nulls")
841
  dc = int(df.duplicated().sum())
842
+ if dc:
843
+ errs.append(f"{nm}: {dc} duplicates")
844
  return errs[:12]
845
 
846
  def _tbl(self, name: str) -> pd.DataFrame:
847
  if name not in self._tables:
848
+ raise KeyError(
849
+ f"Table '{name}' not found. Available: {list(self._tables.keys())}"
850
+ )
851
  return self._tables[name]
852
 
853
  @staticmethod
 
855
  if not col:
856
  raise ValueError("'column' field is required")
857
  if col not in df.columns:
858
+ raise KeyError(
859
+ f"Column '{col}' not in '{tbl}'. Available: {list(df.columns)}"
860
+ )
861
+ return col
server/graders.py CHANGED
@@ -1,24 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
- Graders β€” deterministic scoring for all 4 tasks.
3
- All return float STRICTLY between 0.001 and 0.999 (never 0.0 or 1.0).
 
 
 
 
 
 
 
 
 
 
 
4
  """
5
  import pandas as pd
6
  import numpy as np
7
  from typing import Dict
8
 
9
 
 
 
 
 
 
 
 
10
  def grade_task1(df: pd.DataFrame, expected_df: pd.DataFrame) -> float:
11
- return 0.501
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
 
 
 
 
 
 
13
 
14
- def grade_task2(df: pd.DataFrame, expected_df: pd.DataFrame, dirty_df: pd.DataFrame) -> float:
15
- return 0.502
 
 
 
 
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  def grade_task3(tables: Dict[str, pd.DataFrame], expected_df: pd.DataFrame,
19
  dirty_tables: Dict[str, pd.DataFrame]) -> float:
20
- return 0.503
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  def grade_task4(df: pd.DataFrame) -> float:
24
- return 0.504
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # """
2
+ # Graders β€” deterministic scoring for all 4 tasks.
3
+ # All return float STRICTLY between 0.001 and 0.999 (never 0.0 or 1.0).
4
+ # """
5
+ # import pandas as pd
6
+ # import numpy as np
7
+ # from typing import Dict
8
+
9
+
10
+ # def grade_task1(df: pd.DataFrame, expected_df: pd.DataFrame) -> float:
11
+ # return 0.501
12
+
13
+
14
+ # def grade_task2(df: pd.DataFrame, expected_df: pd.DataFrame, dirty_df: pd.DataFrame) -> float:
15
+ # return 0.502
16
+
17
+
18
+ # def grade_task3(tables: Dict[str, pd.DataFrame], expected_df: pd.DataFrame,
19
+ # dirty_tables: Dict[str, pd.DataFrame]) -> float:
20
+ # return 0.503
21
+
22
+
23
+ # def grade_task4(df: pd.DataFrame) -> float:
24
+ # return 0.504
25
+
26
  """
27
+ Graders β€” deterministic partial-credit scoring for all 4 tasks.
28
+ Every grader returns a float STRICTLY in (0.0, 1.0).
29
+ β€’ Never returns exactly 0.0 β€” even a completely uncleaned table scores 0.05.
30
+ β€’ Never returns exactly 1.0 β€” a perfect table scores 0.98.
31
+ β€’ All intermediate states return a meaningful float between those bounds.
32
+
33
+ This satisfies the OpenEnv validator requirement:
34
+ "one or more tasks returned a score outside [0, 1]" ← was caused by hardcoded stubs.
35
+
36
+ Grader design:
37
+ - Each grader checks multiple sub-dimensions with individual weights.
38
+ - Weights sum to 1.0 for each grader.
39
+ - Raw score is clipped to [0.05, 0.98] before return.
40
  """
41
  import pandas as pd
42
  import numpy as np
43
  from typing import Dict
44
 
45
 
46
+ def _clamp(score: float) -> float:
47
+ """Clamp to strictly-open (0, 1) range required by OpenEnv validator."""
48
+ return float(max(0.05, min(0.98, score)))
49
+
50
+
51
+ # ── Task 1 ────────────────────────────────────────────────────────────────────
52
+
53
  def grade_task1(df: pd.DataFrame, expected_df: pd.DataFrame) -> float:
54
+ """
55
+ Score a task1 'main' DataFrame.
56
+
57
+ Sub-dimensions (weights sum to 1.0):
58
+ - age nulls gone 0.30
59
+ - age dtype is int64 0.25
60
+ - age values close to expected 0.20
61
+ - salary nulls gone 0.15
62
+ - salary dtype is float64 0.10
63
+ """
64
+ score = 0.0
65
+
66
+ # age nulls (0.30)
67
+ age_nulls = int(df["age"].isna().sum()) if "age" in df.columns else 999
68
+ if age_nulls == 0:
69
+ score += 0.30
70
+ elif age_nulls <= 3:
71
+ score += 0.15
72
+
73
+ # age dtype (0.25)
74
+ if "age" in df.columns:
75
+ age_numeric = pd.to_numeric(df["age"], errors="coerce")
76
+ non_null_age = age_numeric.dropna()
77
+ if pd.api.types.is_integer_dtype(df["age"]):
78
+ score += 0.25
79
+ elif len(non_null_age) == len(df) and (non_null_age % 1 == 0).all():
80
+ # numeric but stored as float with no decimals β€” partial credit
81
+ score += 0.12
82
+
83
+ # age values accuracy (0.20) β€” compare median of cleaned vs expected
84
+ if "age" in df.columns and "age" in expected_df.columns:
85
+ try:
86
+ actual_med = pd.to_numeric(df["age"], errors="coerce").median()
87
+ exp_med = pd.to_numeric(expected_df["age"], errors="coerce").median()
88
+ if abs(actual_med - exp_med) < 1:
89
+ score += 0.20
90
+ elif abs(actual_med - exp_med) < 5:
91
+ score += 0.10
92
+ except Exception:
93
+ pass
94
+
95
+ # salary nulls (0.15)
96
+ sal_nulls = int(df["salary"].isna().sum()) if "salary" in df.columns else 999
97
+ if sal_nulls == 0:
98
+ score += 0.15
99
+ elif sal_nulls <= 3:
100
+ score += 0.07
101
+
102
+ # salary dtype (0.10)
103
+ if "salary" in df.columns and pd.api.types.is_float_dtype(df["salary"]):
104
+ score += 0.10
105
+
106
+ return _clamp(score)
107
+
108
+
109
+ # ── Task 2 ────────────────────────────────────────────────────────────────────
110
+
111
+ def grade_task2(df: pd.DataFrame, expected_df: pd.DataFrame,
112
+ dirty_df: pd.DataFrame) -> float:
113
+ """
114
+ Score a task2 'main' DataFrame.
115
+
116
+ Sub-dimensions (weights sum to 1.0):
117
+ - duplicates removed 0.25
118
+ - country normalised 0.25
119
+ - order_date is datetime 0.20
120
+ - amount nulls gone 0.15
121
+ - row count reasonable 0.15
122
+ """
123
+ score = 0.0
124
 
125
+ # duplicates (0.25)
126
+ dup_count = int(df.duplicated().sum())
127
+ if dup_count == 0:
128
+ score += 0.25
129
+ elif dup_count < 5:
130
+ score += 0.12
131
 
132
+ # country upper-case (0.25)
133
+ if "country" in df.columns:
134
+ str_col = df["country"].dropna().astype(str)
135
+ total = len(str_col)
136
+ if total > 0:
137
+ upper_frac = (str_col == str_col.str.upper()).mean()
138
+ score += 0.25 * upper_frac
139
 
140
+ # order_date datetime (0.20)
141
+ if "order_date" in df.columns:
142
+ if pd.api.types.is_datetime64_any_dtype(df["order_date"]):
143
+ score += 0.20
144
+ else:
145
+ parsed = pd.to_datetime(df["order_date"], errors="coerce")
146
+ valid_frac = parsed.notna().mean()
147
+ score += 0.20 * valid_frac * 0.5 # partial: parsable but wrong dtype
148
+
149
+ # amount nulls (0.15)
150
+ if "amount" in df.columns:
151
+ amt_nulls = int(df["amount"].isna().sum())
152
+ if amt_nulls == 0:
153
+ score += 0.15
154
+ elif amt_nulls <= 3:
155
+ score += 0.07
156
+
157
+ # row count (0.15) β€” should be β‰ˆ170 (base) after dedup, not 200 (with dups)
158
+ n_rows = len(df)
159
+ dirty_rows = len(dirty_df)
160
+ expected_rows = len(expected_df)
161
+ if expected_rows > 0:
162
+ ratio = n_rows / expected_rows
163
+ if 0.85 <= ratio <= 1.15:
164
+ score += 0.15
165
+ elif 0.5 <= ratio <= 1.5:
166
+ score += 0.07
167
+
168
+ return _clamp(score)
169
+
170
+
171
+ # ── Task 3 ────────────────────────────────────────────────────────────────────
172
 
173
  def grade_task3(tables: Dict[str, pd.DataFrame], expected_df: pd.DataFrame,
174
  dirty_tables: Dict[str, pd.DataFrame]) -> float:
175
+ """
176
+ Score a task3 state.
177
+
178
+ Sub-dimensions (weights sum to 1.0):
179
+ - merged table exists 0.25
180
+ - outliers removed (amount IQR) 0.25
181
+ - age nulls gone in merged 0.20
182
+ - order_year column present 0.15
183
+ - row count in reasonable range 0.15
184
+ """
185
+ score = 0.0
186
+
187
+ # merged table (0.25)
188
+ merged_key = None
189
+ for k in ("merged", "main"):
190
+ if k in tables:
191
+ merged_key = k
192
+ break
193
+ if merged_key is None:
194
+ # No merge done yet β€” return base score
195
+ return _clamp(0.05)
196
+
197
+ merged = tables[merged_key]
198
+
199
+ score += 0.25 # merged table exists
200
+
201
+ # outliers removed (0.25) β€” check that extreme amounts are gone
202
+ if "amount" in merged.columns:
203
+ amt = pd.to_numeric(merged["amount"], errors="coerce").dropna()
204
+ if len(amt) > 0:
205
+ Q1, Q3 = amt.quantile(0.25), amt.quantile(0.75)
206
+ IQR = Q3 - Q1
207
+ lo, hi = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
208
+ outlier_frac = ((amt < lo) | (amt > hi)).mean()
209
+ if outlier_frac < 0.02:
210
+ score += 0.25
211
+ elif outlier_frac < 0.10:
212
+ score += 0.12
213
+
214
+ # age nulls (0.20)
215
+ if "age" in merged.columns:
216
+ age_nulls = int(merged["age"].isna().sum())
217
+ if age_nulls == 0:
218
+ score += 0.20
219
+ elif age_nulls <= 3:
220
+ score += 0.10
221
+
222
+ # order_year column (0.15)
223
+ if "order_year" in merged.columns:
224
+ yr = pd.to_numeric(merged["order_year"], errors="coerce")
225
+ valid_years = yr.between(2020, 2030).mean()
226
+ score += 0.15 * valid_years
227
 
228
+ # row count (0.15)
229
+ exp_rows = len(expected_df)
230
+ if exp_rows > 0:
231
+ ratio = len(merged) / exp_rows
232
+ if 0.80 <= ratio <= 1.20:
233
+ score += 0.15
234
+ elif 0.50 <= ratio <= 1.50:
235
+ score += 0.07
236
+
237
+ return _clamp(score)
238
+
239
+
240
+ # ── Task 4 ────────────────────────────────────────────────────────────────────
241
 
242
  def grade_task4(df: pd.DataFrame) -> float:
243
+ """
244
+ Score a task4 'stream' DataFrame.
245
+
246
+ Since this task has continuous drift, we score the current *cleaned* state
247
+ of the stream table across multiple dimensions.
248
+
249
+ Sub-dimensions (weights sum to 1.0):
250
+ - amount nulls low 0.25
251
+ - amount dtype numeric 0.20
252
+ - outliers low 0.20
253
+ - category nulls low 0.15
254
+ - region nulls low 0.10
255
+ - event_ts parseable 0.10
256
+ """
257
+ score = 0.0
258
+ n = len(df)
259
+ if n == 0:
260
+ return _clamp(0.05)
261
+
262
+ # amount nulls (0.25)
263
+ if "amount" in df.columns:
264
+ amt = pd.to_numeric(df["amount"], errors="coerce")
265
+ null_frac = amt.isna().mean()
266
+ score += 0.25 * max(0.0, 1.0 - null_frac * 3)
267
+
268
+ # amount dtype numeric (0.20)
269
+ if pd.api.types.is_numeric_dtype(df["amount"]):
270
+ score += 0.20
271
+ elif null_frac < 0.10:
272
+ # mostly parseable even if still object
273
+ score += 0.10
274
+
275
+ # outliers (0.20) β€” negative or huge values
276
+ valid_amt = amt.dropna()
277
+ if len(valid_amt) > 0:
278
+ Q1, Q3 = valid_amt.quantile(0.25), valid_amt.quantile(0.75)
279
+ IQR = Q3 - Q1
280
+ lo, hi = Q1 - 1.5 * IQR, Q3 + 1.5 * IQR
281
+ outlier_frac = ((valid_amt < lo) | (valid_amt > hi)).mean()
282
+ if outlier_frac < 0.03:
283
+ score += 0.20
284
+ elif outlier_frac < 0.15:
285
+ score += 0.10
286
+
287
+ # category nulls (0.15)
288
+ if "category" in df.columns:
289
+ cat_null_frac = df["category"].isna().mean()
290
+ score += 0.15 * max(0.0, 1.0 - cat_null_frac * 3)
291
+
292
+ # region nulls (0.10)
293
+ if "region" in df.columns:
294
+ reg_null_frac = df["region"].isna().mean()
295
+ score += 0.10 * max(0.0, 1.0 - reg_null_frac * 3)
296
+
297
+ # event_ts parseable (0.10)
298
+ if "event_ts" in df.columns:
299
+ if pd.api.types.is_datetime64_any_dtype(df["event_ts"]):
300
+ score += 0.10
301
+ else:
302
+ parsed = pd.to_datetime(df["event_ts"], errors="coerce")
303
+ score += 0.10 * parsed.notna().mean()
304
+
305
+ return _clamp(score)