vedastra commited on
Commit
24c085e
·
verified ·
1 Parent(s): 660951b

Upload folder using huggingface_hub

Browse files
Files changed (6) hide show
  1. baseline.py +0 -4
  2. client.py +9 -4
  3. inference.py +36 -24
  4. models.py +0 -2
  5. server/app.py +25 -21
  6. server/data_cleaning_env_environment.py +49 -42
baseline.py CHANGED
@@ -62,7 +62,6 @@ RULE_POLICIES = {
62
  ],
63
  }
64
 
65
-
66
  def run_rule_baseline(base_url: str) -> dict[str, float]:
67
  """Run deterministic rule-based baseline — no LLM required."""
68
  scores = {}
@@ -91,7 +90,6 @@ def run_rule_baseline(base_url: str) -> dict[str, float]:
91
 
92
  return scores
93
 
94
-
95
  # ---------------------------------------------------------------------------
96
  # LLM agent (uses OpenAI API)
97
  # ---------------------------------------------------------------------------
@@ -116,7 +114,6 @@ or with an optional column:
116
 
117
  No explanation. JSON only."""
118
 
119
-
120
  def run_llm_baseline(base_url: str, api_key: str, max_steps: int = 10) -> dict[str, float]:
121
  """Run an LLM agent (GPT-4o-mini) against the environment."""
122
  client = OpenAI(api_key=api_key)
@@ -184,7 +181,6 @@ def run_llm_baseline(base_url: str, api_key: str, max_steps: int = 10) -> dict[s
184
 
185
  return scores
186
 
187
-
188
  # ---------------------------------------------------------------------------
189
  # Main
190
  # ---------------------------------------------------------------------------
 
62
  ],
63
  }
64
 
 
65
  def run_rule_baseline(base_url: str) -> dict[str, float]:
66
  """Run deterministic rule-based baseline — no LLM required."""
67
  scores = {}
 
90
 
91
  return scores
92
 
 
93
  # ---------------------------------------------------------------------------
94
  # LLM agent (uses OpenAI API)
95
  # ---------------------------------------------------------------------------
 
114
 
115
  No explanation. JSON only."""
116
 
 
117
  def run_llm_baseline(base_url: str, api_key: str, max_steps: int = 10) -> dict[str, float]:
118
  """Run an LLM agent (GPT-4o-mini) against the environment."""
119
  client = OpenAI(api_key=api_key)
 
181
 
182
  return scores
183
 
 
184
  # ---------------------------------------------------------------------------
185
  # Main
186
  # ---------------------------------------------------------------------------
client.py CHANGED
@@ -1,6 +1,6 @@
1
  """Data Cleaning Env Environment Client."""
2
 
3
- from typing import Dict
4
 
5
  from openenv.core import EnvClient
6
  from openenv.core.client_types import StepResult
@@ -8,7 +8,6 @@ from openenv.core.env_server.types import State
8
 
9
  from .models import DataCleaningAction, DataCleaningObservation
10
 
11
-
12
  class DataCleaningEnv(
13
  EnvClient[DataCleaningAction, DataCleaningObservation, State]
14
  ):
@@ -34,19 +33,25 @@ class DataCleaningEnv(
34
  def _parse_result(self, payload: Dict) -> StepResult[DataCleaningObservation]:
35
  """Parse server response into StepResult[DataCleaningObservation]."""
36
  obs_data = payload.get("observation", {})
 
 
 
 
37
 
38
  observation = DataCleaningObservation(
39
  current_text=obs_data.get("current_text", ""),
40
  is_normalized=obs_data.get("is_normalized", False),
41
  html_found=obs_data.get("html_found", False),
42
  remaining_typos=obs_data.get("remaining_typos", 0),
 
 
43
  metadata=obs_data.get("metadata", {}),
44
  )
45
 
46
  return StepResult(
47
  observation=observation,
48
- reward=payload.get("reward"),
49
- done=payload.get("done", False),
50
  )
51
 
52
  def _parse_state(self, payload: Dict) -> State:
 
1
  """Data Cleaning Env Environment Client."""
2
 
3
+ from typing import Dict, Optional
4
 
5
  from openenv.core import EnvClient
6
  from openenv.core.client_types import StepResult
 
8
 
9
  from .models import DataCleaningAction, DataCleaningObservation
10
 
 
11
  class DataCleaningEnv(
12
  EnvClient[DataCleaningAction, DataCleaningObservation, State]
13
  ):
 
33
  def _parse_result(self, payload: Dict) -> StepResult[DataCleaningObservation]:
34
  """Parse server response into StepResult[DataCleaningObservation]."""
35
  obs_data = payload.get("observation", {})
36
+
37
+ # done and reward live at top-level in the server response
38
+ done = payload.get("done", obs_data.get("done", False))
39
+ reward = payload.get("reward", obs_data.get("reward"))
40
 
41
  observation = DataCleaningObservation(
42
  current_text=obs_data.get("current_text", ""),
43
  is_normalized=obs_data.get("is_normalized", False),
44
  html_found=obs_data.get("html_found", False),
45
  remaining_typos=obs_data.get("remaining_typos", 0),
46
+ done=done,
47
+ reward=reward,
48
  metadata=obs_data.get("metadata", {}),
49
  )
50
 
51
  return StepResult(
52
  observation=observation,
53
+ reward=reward,
54
+ done=done,
55
  )
56
 
57
  def _parse_state(self, payload: Dict) -> State:
inference.py CHANGED
@@ -61,7 +61,7 @@ RULE_POLICIES = {
61
  ],
62
  }
63
 
64
- # ── System prompt — fact-driven, no hint dependency ────────────────────────
65
  SYSTEM_PROMPT = """\
66
  You are a data cleaning agent. Pick ONE operation per turn.
67
 
@@ -71,14 +71,14 @@ OUTPUT RULE: Respond with ONLY a JSON object. No explanation. No markdown. No ot
71
  Format: {"operation": "operation_name"}
72
 
73
  SELECTION RULES — apply in order based on PROBLEMS DETECTED:
74
- 1. If missing values > 0 and quantity column is affected -> fill_quantity_mean
75
- 2. If missing values > 0 and numeric columns affected -> impute_mean
76
- 3. If missing values > 0 and text columns affected -> impute_mode
77
- 4. If has_duplicates is true -> remove_duplicates
78
- 5. If has_outliers is true -> remove_outliers
79
- 6. If non-numeric values exist in numeric columns -> fix_type_errors
80
- 7. If text columns have inconsistent casing/whitespace -> normalize_text
81
- 8. If any rows still have missing values -> drop_missing_rows
82
  9. Pick the first operation from AVAILABLE that makes sense.
83
 
84
  You MUST pick from the AVAILABLE list only — operations not listed are already done.
@@ -136,20 +136,25 @@ def _sanitize(text: str) -> str:
136
  # ── Pick next unused op from a policy list ─────────────────────────────────
137
 
138
  def _next_unused(policy: List[str], applied: List[str]) -> Optional[str]:
139
- """Return the first op in policy not already applied, or None."""
140
  applied_set = set(applied)
141
  for op in policy:
142
  if op not in applied_set:
143
  return op
144
  return None
145
 
146
- # ── Robust JSON parser ──────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
147
 
148
  def parse_llm_response(raw: str, task: str, applied: List[str]) -> dict:
149
- """
150
- 3-layer fallback parser. Hard-enforces that the chosen operation
151
- has not already been applied.
152
- """
153
  if not raw:
154
  return _fallback(task, applied)
155
 
@@ -158,7 +163,6 @@ def parse_llm_response(raw: str, task: str, applied: List[str]) -> dict:
158
 
159
  candidate = None
160
 
161
- # Layer 1: direct JSON parse
162
  try:
163
  result = json.loads(text)
164
  if "operation" in result and result["operation"] in VALID_OPS:
@@ -166,7 +170,6 @@ def parse_llm_response(raw: str, task: str, applied: List[str]) -> dict:
166
  except Exception:
167
  pass
168
 
169
- # Layer 2: find first {...} in string
170
  if not candidate:
171
  match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
172
  if match:
@@ -177,7 +180,6 @@ def parse_llm_response(raw: str, task: str, applied: List[str]) -> dict:
177
  except Exception:
178
  pass
179
 
180
- # Layer 3: op name mentioned anywhere in raw text
181
  if not candidate:
182
  for op in VALID_OPS:
183
  if op in raw:
@@ -215,10 +217,6 @@ def _fallback(task: str, applied: List[str]) -> dict:
215
  # ── LLM call ───────────────────────────────────────────────────────────────
216
 
217
  def get_llm_action(client: OpenAI, obs: dict, task: str, applied: List[str]) -> dict:
218
- """
219
- Build prompt from observed facts only (no hint field).
220
- Inject available ops explicitly so model cannot repeat a done op.
221
- """
222
  metadata = obs.get("metadata", {})
223
  quality = metadata.get("quality_score", "?")
224
  missing = metadata.get("missing_count", 0)
@@ -254,7 +252,7 @@ def get_llm_action(client: OpenAI, obs: dict, task: str, applied: List[str]) ->
254
  {"role": "system", "content": SYSTEM_PROMPT},
255
  {"role": "user", "content": user_msg},
256
  ],
257
- temperature=0.0, # fully deterministic
258
  max_tokens=50,
259
  )
260
  raw = (completion.choices[0].message.content or "").strip()
@@ -265,7 +263,21 @@ def get_llm_action(client: OpenAI, obs: dict, task: str, applied: List[str]) ->
265
  print(f"[DEBUG] LLM call failed: {exc}", flush=True)
266
  return _fallback(task, applied)
267
 
268
- # ── Episode runner ─────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
269
 
270
  def run_episode(base_url: str, task: str, mode: str, client=None) -> None:
271
  import requests
 
61
  ],
62
  }
63
 
64
+ # ── System prompt ─────────────────
65
  SYSTEM_PROMPT = """\
66
  You are a data cleaning agent. Pick ONE operation per turn.
67
 
 
71
  Format: {"operation": "operation_name"}
72
 
73
  SELECTION RULES — apply in order based on PROBLEMS DETECTED:
74
+ 1. If missing values > 0 and quantity column affected -> fill_quantity_mean
75
+ 2. If missing values > 0 and numeric columns affected -> impute_mean
76
+ 3. If missing values > 0 and text columns affected -> impute_mode
77
+ 4. If has_duplicates is true -> remove_duplicates
78
+ 5. If has_outliers is true -> remove_outliers
79
+ 6. If non-numeric values in numeric columns -> fix_type_errors
80
+ 7. If text columns have inconsistent casing/whitespace -> normalize_text
81
+ 8. If rows still have missing values -> drop_missing_rows
82
  9. Pick the first operation from AVAILABLE that makes sense.
83
 
84
  You MUST pick from the AVAILABLE list only — operations not listed are already done.
 
136
  # ── Pick next unused op from a policy list ─────────────────────────────────
137
 
138
  def _next_unused(policy: List[str], applied: List[str]) -> Optional[str]:
 
139
  applied_set = set(applied)
140
  for op in policy:
141
  if op not in applied_set:
142
  return op
143
  return None
144
 
145
+ def _fallback(task: str, applied: List[str]) -> dict:
146
+ policy = RULE_POLICIES.get(task, RULE_POLICIES["easy"])
147
+ op = _next_unused(policy, applied)
148
+ if op:
149
+ return {"operation": op}
150
+ op = _next_unused(VALID_OPS, applied)
151
+ if op:
152
+ print(f"[DEBUG] Policy exhausted, global fallback: {op}", flush=True)
153
+ return {"operation": op}
154
+ print("[DEBUG] All ops exhausted — repeating first policy op.", flush=True)
155
+ return {"operation": policy[0]}
156
 
157
  def parse_llm_response(raw: str, task: str, applied: List[str]) -> dict:
 
 
 
 
158
  if not raw:
159
  return _fallback(task, applied)
160
 
 
163
 
164
  candidate = None
165
 
 
166
  try:
167
  result = json.loads(text)
168
  if "operation" in result and result["operation"] in VALID_OPS:
 
170
  except Exception:
171
  pass
172
 
 
173
  if not candidate:
174
  match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
175
  if match:
 
180
  except Exception:
181
  pass
182
 
 
183
  if not candidate:
184
  for op in VALID_OPS:
185
  if op in raw:
 
217
  # ── LLM call ───────────────────────────────────────────────────────────────
218
 
219
  def get_llm_action(client: OpenAI, obs: dict, task: str, applied: List[str]) -> dict:
 
 
 
 
220
  metadata = obs.get("metadata", {})
221
  quality = metadata.get("quality_score", "?")
222
  missing = metadata.get("missing_count", 0)
 
252
  {"role": "system", "content": SYSTEM_PROMPT},
253
  {"role": "user", "content": user_msg},
254
  ],
255
+ temperature=0.3,
256
  max_tokens=50,
257
  )
258
  raw = (completion.choices[0].message.content or "").strip()
 
263
  print(f"[DEBUG] LLM call failed: {exc}", flush=True)
264
  return _fallback(task, applied)
265
 
266
+ def _extract_reward(result: dict, obs: dict) -> float:
267
+ # Top-level first (standard)
268
+ r = result.get("reward")
269
+ if r is not None:
270
+ return float(r)
271
+ # Inside observation object
272
+ r = obs.get("reward")
273
+ if r is not None:
274
+ return float(r)
275
+ # Inside metadata as quality delta (last resort)
276
+ meta = obs.get("metadata", {})
277
+ r = meta.get("last_reward")
278
+ if r is not None:
279
+ return float(r)
280
+ return 0.0
281
 
282
  def run_episode(base_url: str, task: str, mode: str, client=None) -> None:
283
  import requests
models.py CHANGED
@@ -2,7 +2,6 @@ from typing import Any, Dict, Optional
2
  from openenv.core.env_server.types import Action, Observation
3
  from pydantic import Field
4
 
5
-
6
  class DataCleaningAction(Action):
7
  """Actions the agent can take to clean a dirty dataset."""
8
 
@@ -20,7 +19,6 @@ class DataCleaningAction(Action):
20
  description="Target column (optional). If omitted the op applies to all relevant columns.",
21
  )
22
 
23
-
24
  class DataCleaningObservation(Observation):
25
  """The dataset state observed after each cleaning step."""
26
 
 
2
  from openenv.core.env_server.types import Action, Observation
3
  from pydantic import Field
4
 
 
5
  class DataCleaningAction(Action):
6
  """Actions the agent can take to clean a dirty dataset."""
7
 
 
19
  description="Target column (optional). If omitted the op applies to all relevant columns.",
20
  )
21
 
 
22
  class DataCleaningObservation(Observation):
23
  """The dataset state observed after each cleaning step."""
24
 
server/app.py CHANGED
@@ -18,12 +18,14 @@ Additional hackathon-required endpoints:
18
  try:
19
  from openenv.core.env_server.http_server import create_app
20
  except Exception as e:
21
- raise ImportError(
22
- "openenv is required. Install dependencies with: uv sync"
23
- ) from e
24
 
25
- from models import DataCleaningAction, DataCleaningObservation
26
- from server.data_cleaning_env_environment import DataCleaningEnvironment
 
 
 
 
27
 
28
  from fastapi import HTTPException
29
  from fastapi.responses import JSONResponse
@@ -39,17 +41,25 @@ app = create_app(
39
  max_concurrent_envs=1,
40
  )
41
 
42
- # ---------------------------------------------------------------------------
43
- # Shared environment instance for grader/baseline
44
- # ---------------------------------------------------------------------------
45
- _env: DataCleaningEnvironment | None = None
46
-
47
  def _get_env() -> DataCleaningEnvironment:
48
- global _env
49
- if _env is None:
50
- _env = DataCleaningEnvironment()
51
- return _env
52
-
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  # ---------------------------------------------------------------------------
54
  # GET /health
55
  # ---------------------------------------------------------------------------
@@ -57,7 +67,6 @@ def _get_env() -> DataCleaningEnvironment:
57
  def health():
58
  """Simple health check endpoint."""
59
  return {"status": "ok"}
60
-
61
  # ---------------------------------------------------------------------------
62
  # GET /tasks
63
  # ---------------------------------------------------------------------------
@@ -86,8 +95,6 @@ def run_grader():
86
 
87
  if isinstance(result, dict) and "score" in result:
88
  return JSONResponse(content=result)
89
-
90
- # fallback safety
91
  return JSONResponse(content={"score": float(result)})
92
 
93
  except Exception as e:
@@ -98,9 +105,6 @@ def run_grader():
98
  # ---------------------------------------------------------------------------
99
  @app.post("/baseline")
100
  def run_baseline():
101
- """
102
- Run deterministic baseline agent across all tasks.
103
- """
104
  try:
105
  result = _get_env().run_baseline()
106
  return JSONResponse(content=result)
 
18
  try:
19
  from openenv.core.env_server.http_server import create_app
20
  except Exception as e:
21
+ raise ImportError("openenv is required. Install with: uv sync") from e
 
 
22
 
23
+ try:
24
+ from ..models import DataCleaningAction, DataCleaningObservation
25
+ from .data_cleaning_env_environment import DataCleaningEnvironment
26
+ except ModuleNotFoundError:
27
+ from models import DataCleaningAction, DataCleaningObservation
28
+ from server.data_cleaning_env_environment import DataCleaningEnvironment
29
 
30
  from fastapi import HTTPException
31
  from fastapi.responses import JSONResponse
 
41
  max_concurrent_envs=1,
42
  )
43
 
 
 
 
 
 
44
  def _get_env() -> DataCleaningEnvironment:
45
+ """
46
+ openenv stores the managed env at app.state.env or app.state.server.env.
47
+ Fall back to a fresh instance only if neither attribute exists.
48
+ """
49
+ state = getattr(app, "state", None)
50
+ if state:
51
+ env = getattr(state, "env", None)
52
+ if env:
53
+ return env
54
+ server = getattr(state, "server", None)
55
+ if server:
56
+ env = getattr(server, "env", None)
57
+ if env:
58
+ return env
59
+ print("[WARN] Could not find openenv internal env — grader uses fresh instance", flush=True)
60
+ e = DataCleaningEnvironment()
61
+ e.reset(task="easy")
62
+ return e
63
  # ---------------------------------------------------------------------------
64
  # GET /health
65
  # ---------------------------------------------------------------------------
 
67
  def health():
68
  """Simple health check endpoint."""
69
  return {"status": "ok"}
 
70
  # ---------------------------------------------------------------------------
71
  # GET /tasks
72
  # ---------------------------------------------------------------------------
 
95
 
96
  if isinstance(result, dict) and "score" in result:
97
  return JSONResponse(content=result)
 
 
98
  return JSONResponse(content={"score": float(result)})
99
 
100
  except Exception as e:
 
105
  # ---------------------------------------------------------------------------
106
  @app.post("/baseline")
107
  def run_baseline():
 
 
 
108
  try:
109
  result = _get_env().run_baseline()
110
  return JSONResponse(content=result)
server/data_cleaning_env_environment.py CHANGED
@@ -112,12 +112,14 @@ def _grade_medium(rows: list[dict]) -> float:
112
  dupes += 1
113
  seen.add(key)
114
  dedup_score = 1.0 if dupes == 0 else max(0.0, 1.0 - dupes * 0.5)
 
115
  # Type-fix check: all 'age' values must be int or float
116
  type_errors = sum(1 for r in rows if not isinstance(r.get("age"), (int, float)))
117
  type_score = 1.0 if type_errors == 0 else max(0.0, 1.0 - type_errors * 0.5)
118
 
119
  return round((dedup_score + type_score) / 2, 4)
120
 
 
121
  def _grade_hard(rows: list[dict]) -> float:
122
  """Score 0-1 across 4 sub-criteria."""
123
  if not rows:
@@ -161,6 +163,7 @@ DATASETS = {
161
  "hard": _make_hard_dataset,
162
  }
163
 
 
164
  # ---------------------------------------------------------------------------
165
  # Environment
166
  # ---------------------------------------------------------------------------
@@ -202,7 +205,9 @@ class DataCleaningEnvironment(Environment):
202
  Each task presents a different level of difficulty and requires
203
  different cleaning operations.
204
  """
 
205
  SUPPORTS_CONCURRENT_SESSIONS: bool = True
 
206
  # -----------------------------------------------------------------------
207
  # Lifecycle
208
  # -----------------------------------------------------------------------
@@ -274,7 +279,7 @@ class DataCleaningEnvironment(Environment):
274
  reward = -0.05
275
  self._last_reward = reward
276
  obs = self._make_observation(reward=reward, done=False)
277
- obs.metadata["error"] = f"Unknown operation '{op}'. Valid: {sorted(valid_ops)}"
278
  return obs
279
 
280
  before_score = GRADERS[self._task](self._rows)
@@ -283,9 +288,8 @@ class DataCleaningEnvironment(Environment):
283
 
284
  # Reward = improvement in quality score (partial progress signal)
285
  improvement = after_score - before_score
286
- # 0.01
287
  if improvement > 0:
288
- reward = round(improvement + 0.02, 4) # bonus for improvement
289
  elif improvement < 0:
290
  reward = round(improvement - 0.02, 4) # penalty for harming dataset
291
  else:
@@ -301,7 +305,6 @@ class DataCleaningEnvironment(Environment):
301
  max_steps = MAX_STEPS[self._task]
302
  done = (after_score >= 1.0) or (self._step_count >= max_steps)
303
  self._done = done
304
-
305
  return self._make_observation(reward=reward, done=done)
306
 
307
  # -----------------------------------------------------------------------
@@ -392,7 +395,7 @@ class DataCleaningEnvironment(Environment):
392
  def run_baseline(self) -> dict:
393
  """
394
  Run a deterministic rule-based baseline agent on all 3 tasks.
395
- Returns scores dict compatible with the hackathon /baseline endpoint.
396
  """
397
  results = {}
398
  for task_id in ["easy", "medium", "hard"]:
@@ -431,6 +434,9 @@ class DataCleaningEnvironment(Environment):
431
  "quality_score": score,
432
  "valid_operations": sorted(valid_ops),
433
  "ops_already_applied": list(self._applied_ops),
 
 
 
434
  },
435
  )
436
 
@@ -540,8 +546,8 @@ _BASELINE_POLICIES: dict[str, list[str]] = {
540
  "easy": ["impute_mean", "impute_mode", "drop_missing_rows"],
541
  "medium": ["remove_duplicates", "fix_type_errors", "drop_missing_rows"],
542
  "hard": [
543
- "drop_missing_rows",
544
  "fill_quantity_mean",
 
545
  "remove_duplicates",
546
  "fix_type_errors",
547
  "remove_outliers",
@@ -553,42 +559,43 @@ _BASELINE_POLICIES: dict[str, list[str]] = {
553
  # Recommendation helper (guides the LLM toward correct next op)
554
  # ---------------------------------------------------------------------------
555
 
556
- # def _recommend_next(
557
- # task: str,
558
- # missing_count: int,
559
- # has_dupes: bool,
560
- # has_outliers: bool,
561
- # applied_ops: list,
562
- # ) -> str:
563
- # """Return a plain-English hint for the LLM about the best next operation."""
564
- # applied = set(applied_ops)
565
-
566
- # if task == "easy":
567
- # if missing_count > 0:
568
- # return "There are missing values. Use impute_mean (numeric) or impute_mode (text)."
569
- # return "No issues remain. Episode should be complete."
570
-
571
- # if task == "medium":
572
- # if has_dupes and "remove_duplicates" not in applied:
573
- # return "Duplicate rows exist. Use remove_duplicates."
574
- # if "fix_type_errors" not in applied:
575
- # return "Non-numeric values in numeric columns. Use fix_type_errors."
576
- # if missing_count > 0 and "drop_missing_rows" not in applied:
577
- # return "Some values still missing after type fix. Use drop_missing_rows."
578
- # return "No issues remain. Episode should be complete."
579
-
580
- # # hard
581
- # if missing_count > 0 and "fill_quantity_mean" not in applied:
582
- # return "Missing quantity values. Use fill_quantity_mean first."
583
- # if missing_count > 0 and "drop_missing_rows" not in applied:
584
- # return "Missing product/category values. Use drop_missing_rows."
585
- # if has_outliers and "remove_outliers" not in applied:
586
- # return "Price outliers detected (price<=0 or price>=500). Use remove_outliers."
587
- # if "normalize_text" not in applied:
588
- # return "String columns have inconsistent casing/whitespace. Use normalize_text."
589
- # if has_dupes and "remove_duplicates" not in applied:
590
- # return "Duplicate rows remain. Use remove_duplicates."
591
- # return "All issues fixed. Episode should be complete."
 
592
 
593
  # ---------------------------------------------------------------------------
594
  # Column utility helpers
 
112
  dupes += 1
113
  seen.add(key)
114
  dedup_score = 1.0 if dupes == 0 else max(0.0, 1.0 - dupes * 0.5)
115
+
116
  # Type-fix check: all 'age' values must be int or float
117
  type_errors = sum(1 for r in rows if not isinstance(r.get("age"), (int, float)))
118
  type_score = 1.0 if type_errors == 0 else max(0.0, 1.0 - type_errors * 0.5)
119
 
120
  return round((dedup_score + type_score) / 2, 4)
121
 
122
+
123
  def _grade_hard(rows: list[dict]) -> float:
124
  """Score 0-1 across 4 sub-criteria."""
125
  if not rows:
 
163
  "hard": _make_hard_dataset,
164
  }
165
 
166
+
167
  # ---------------------------------------------------------------------------
168
  # Environment
169
  # ---------------------------------------------------------------------------
 
205
  Each task presents a different level of difficulty and requires
206
  different cleaning operations.
207
  """
208
+
209
  SUPPORTS_CONCURRENT_SESSIONS: bool = True
210
+
211
  # -----------------------------------------------------------------------
212
  # Lifecycle
213
  # -----------------------------------------------------------------------
 
279
  reward = -0.05
280
  self._last_reward = reward
281
  obs = self._make_observation(reward=reward, done=False)
282
+ obs.metadata["error"] = f"Unknown operation '{op}'. Valid: {sorted(VALID_OPERATIONS)}"
283
  return obs
284
 
285
  before_score = GRADERS[self._task](self._rows)
 
288
 
289
  # Reward = improvement in quality score (partial progress signal)
290
  improvement = after_score - before_score
 
291
  if improvement > 0:
292
+ reward = round(improvement + 0.02, 4) # bonus for any improvement
293
  elif improvement < 0:
294
  reward = round(improvement - 0.02, 4) # penalty for harming dataset
295
  else:
 
305
  max_steps = MAX_STEPS[self._task]
306
  done = (after_score >= 1.0) or (self._step_count >= max_steps)
307
  self._done = done
 
308
  return self._make_observation(reward=reward, done=done)
309
 
310
  # -----------------------------------------------------------------------
 
395
  def run_baseline(self) -> dict:
396
  """
397
  Run a deterministic rule-based baseline agent on all 3 tasks.
398
+ Returns scores dict compatible with the /baseline endpoint.
399
  """
400
  results = {}
401
  for task_id in ["easy", "medium", "hard"]:
 
434
  "quality_score": score,
435
  "valid_operations": sorted(valid_ops),
436
  "ops_already_applied": list(self._applied_ops),
437
+ "recommended_next": _recommend_next(
438
+ self._task, missing_count, has_dupes, has_outliers, self._applied_ops
439
+ ),
440
  },
441
  )
442
 
 
546
  "easy": ["impute_mean", "impute_mode", "drop_missing_rows"],
547
  "medium": ["remove_duplicates", "fix_type_errors", "drop_missing_rows"],
548
  "hard": [
 
549
  "fill_quantity_mean",
550
+ "drop_missing_rows",
551
  "remove_duplicates",
552
  "fix_type_errors",
553
  "remove_outliers",
 
559
  # Recommendation helper (guides the LLM toward correct next op)
560
  # ---------------------------------------------------------------------------
561
 
562
+ def _recommend_next(
563
+ task: str,
564
+ missing_count: int,
565
+ has_dupes: bool,
566
+ has_outliers: bool,
567
+ applied_ops: list,
568
+ ) -> str:
569
+ """Return a plain-English hint for the LLM about the best next operation."""
570
+ applied = set(applied_ops)
571
+
572
+ if task == "easy":
573
+ if missing_count > 0:
574
+ return "There are missing values. Use impute_mean (numeric) or impute_mode (text)."
575
+ return "No issues remain. Episode should be complete."
576
+
577
+ if task == "medium":
578
+ if has_dupes and "remove_duplicates" not in applied:
579
+ return "Duplicate rows exist. Use remove_duplicates."
580
+ if "fix_type_errors" not in applied:
581
+ return "Non-numeric values in numeric columns. Use fix_type_errors."
582
+ if missing_count > 0 and "drop_missing_rows" not in applied:
583
+ return "Some values still missing after type fix. Use drop_missing_rows."
584
+ return "No issues remain. Episode should be complete."
585
+
586
+ # hard
587
+ if missing_count > 0 and "fill_quantity_mean" not in applied:
588
+ return "Missing quantity values. Use fill_quantity_mean first."
589
+ if missing_count > 0 and "drop_missing_rows" not in applied:
590
+ return "Missing product/category values. Use drop_missing_rows."
591
+ if has_outliers and "remove_outliers" not in applied:
592
+ return "Price outliers detected (price<=0 or price>=500). Use remove_outliers."
593
+ if "normalize_text" not in applied:
594
+ return "String columns have inconsistent casing/whitespace. Use normalize_text."
595
+ if has_dupes and "remove_duplicates" not in applied:
596
+ return "Duplicate rows remain. Use remove_duplicates."
597
+ return "All issues fixed. Episode should be complete."
598
+
599
 
600
  # ---------------------------------------------------------------------------
601
  # Column utility helpers