tns Claude Opus 4.6 commited on
Commit
5cf727a
·
1 Parent(s): 25a7dc2

Initial commit: DataCleanEnv core environment

Browse files

- models.py: Action, Observation, State extending OpenEnv base types
- server/tasks.py: 3 hardcoded tasks (easy/medium/hard) with issue registries
- server/graders.py: Validation-based grading functions
- server/action_parser.py: Robust string command parser
- server/environment.py: Core environment logic (reset/step/state)
- server/app.py: FastAPI server wiring
- client.py: EnvClient subclass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .eggs/
8
+ *.egg
9
+ .env
10
+ .venv
11
+ venv/
12
+ ENV/
13
+ .claude/
14
+ *.lock
15
+ .DS_Store
16
+ Thumbs.db
data_clean_env/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from .models import DataCleanAction, DataCleanObservation, DataCleanState
data_clean_env/client.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DataClean Environment Client."""
2
+
3
+ from openenv.core.env_client import EnvClient
4
+ from openenv.core.client_types import StepResult
5
+
6
+ from .models import DataCleanAction, DataCleanObservation, DataCleanState
7
+
8
+
9
+ class DataCleanEnv(EnvClient[DataCleanAction, DataCleanObservation, DataCleanState]):
10
+ """Client for interacting with a DataClean environment server.
11
+
12
+ Example:
13
+ >>> with DataCleanEnv(base_url="http://localhost:8000").sync() as env:
14
+ ... result = env.reset(task_id="customer_contacts")
15
+ ... print(result.observation.data_preview)
16
+ ... result = env.step(DataCleanAction(command='inspect("email")'))
17
+ ... result = env.step(DataCleanAction(command='fix(3, "email", "test@example.com")'))
18
+ ... result = env.step(DataCleanAction(command='submit()'))
19
+ ... print(f"Score: {result.observation.current_score}")
20
+ """
21
+
22
+ def _step_payload(self, action: DataCleanAction) -> dict:
23
+ return action.model_dump()
24
+
25
+ def _parse_result(self, payload: dict) -> StepResult[DataCleanObservation]:
26
+ obs = DataCleanObservation(**payload.get("observation", payload))
27
+ return StepResult(
28
+ observation=obs,
29
+ reward=obs.reward,
30
+ done=obs.done,
31
+ )
32
+
33
+ def _parse_state(self, payload: dict) -> DataCleanState:
34
+ return DataCleanState(**payload)
data_clean_env/models.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, List, Optional
2
+
3
+ from pydantic import Field
4
+
5
+ from openenv.core.env_server.types import Action, Observation, State
6
+
7
+
8
+ class DataCleanAction(Action):
9
+ """A string command for data cleaning operations.
10
+
11
+ Supported commands:
12
+ inspect("column_name") - View column statistics and issue hints
13
+ fix(row, "column", "new_value") - Correct a cell value
14
+ delete(row) - Remove a duplicate/invalid row
15
+ submit() - Finalize and get scored
16
+ """
17
+
18
+ command: str = Field(
19
+ ..., min_length=1, description="Command string to execute"
20
+ )
21
+
22
+
23
+ class DataCleanObservation(Observation):
24
+ """Observation returned after each step."""
25
+
26
+ task_id: str = Field(default="")
27
+ task_description: str = Field(default="")
28
+ difficulty: str = Field(default="easy")
29
+ data_preview: str = Field(default="", description="Formatted text table of current data")
30
+ column_info: str = Field(default="", description="Column names, types, and stats")
31
+ feedback: str = Field(default="", description="Result of last action")
32
+ actions_remaining: int = Field(default=0)
33
+ issues_found: int = Field(default=0)
34
+ issues_fixed: int = Field(default=0)
35
+ total_issues: int = Field(default=0)
36
+ current_score: float = Field(default=0.0)
37
+ action_history: List[str] = Field(default_factory=list)
38
+
39
+
40
+ class DataCleanState(State):
41
+ """Full environment state."""
42
+
43
+ task_id: str = Field(default="")
44
+ difficulty: str = Field(default="easy")
45
+ total_issues: int = Field(default=0)
46
+ fixed_issues: int = Field(default=0)
47
+ damaged_cells: int = Field(default=0)
48
+ max_steps: int = Field(default=15)
49
+ score: float = Field(default=0.0)
data_clean_env/server/__init__.py ADDED
File without changes
data_clean_env/server/action_parser.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Robust parser for data cleaning commands."""
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class ParsedAction:
10
+ command_type: str # "inspect", "fix", "delete", "submit", "error"
11
+ args: dict
12
+ error_message: Optional[str] = None
13
+
14
+
15
+ # Strip markdown code fences and leading "action:" prefixes
16
+ _PREFIX_RE = re.compile(
17
+ r"^(?:```\w*\s*\n?|action\s*[:\-]\s*|next\s*action\s*[:\-]\s*)",
18
+ re.IGNORECASE,
19
+ )
20
+ _SUFFIX_RE = re.compile(r"\s*```\s*$")
21
+
22
+
23
+ def _strip_quotes(s: str) -> str:
24
+ s = s.strip()
25
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in ("'", '"'):
26
+ return s[1:-1]
27
+ return s
28
+
29
+
30
+ def parse_action(raw: str) -> ParsedAction:
31
+ """Parse a raw command string into a structured ParsedAction."""
32
+ if not raw or not raw.strip():
33
+ return ParsedAction("error", {}, "Empty command. Use inspect/fix/delete/submit.")
34
+
35
+ text = raw.strip()
36
+ text = _PREFIX_RE.sub("", text)
37
+ text = _SUFFIX_RE.sub("", text)
38
+ text = text.strip()
39
+
40
+ # Try each command pattern
41
+ for parser in [_parse_submit, _parse_inspect, _parse_delete, _parse_fix]:
42
+ result = parser(text)
43
+ if result is not None:
44
+ return result
45
+
46
+ return ParsedAction(
47
+ "error",
48
+ {},
49
+ f"Could not parse: '{raw.strip()[:80]}'. "
50
+ "Expected: inspect(\"col\"), fix(row, \"col\", \"val\"), delete(row), or submit()",
51
+ )
52
+
53
+
54
+ def _parse_submit(text: str) -> Optional[ParsedAction]:
55
+ if re.match(r"^submit\s*(\(\s*\))?\s*$", text, re.IGNORECASE):
56
+ return ParsedAction("submit", {})
57
+ return None
58
+
59
+
60
+ def _parse_inspect(text: str) -> Optional[ParsedAction]:
61
+ m = re.match(
62
+ r'^inspect\s*\(\s*(["\']?)(\w+)\1\s*\)$', text, re.IGNORECASE
63
+ )
64
+ if m:
65
+ return ParsedAction("inspect", {"column": m.group(2)})
66
+ return None
67
+
68
+
69
+ def _parse_delete(text: str) -> Optional[ParsedAction]:
70
+ m = re.match(r"^delete\s*\(\s*(\d+)\s*\)$", text, re.IGNORECASE)
71
+ if m:
72
+ return ParsedAction("delete", {"row": int(m.group(1))})
73
+ return None
74
+
75
+
76
+ def _parse_fix(text: str) -> Optional[ParsedAction]:
77
+ # fix(row, "column", "value") — value may contain commas, quotes, parens
78
+ # Strategy: match the row and column greedily, then take everything else as value
79
+ m = re.match(
80
+ r'^fix\s*\(\s*(\d+)\s*,\s*(["\']?)(\w+)\2\s*,\s*(.+)\)$',
81
+ text,
82
+ re.IGNORECASE | re.DOTALL,
83
+ )
84
+ if m:
85
+ row = int(m.group(1))
86
+ column = m.group(3)
87
+ value = _strip_quotes(m.group(4).strip())
88
+ return ParsedAction("fix", {"row": row, "column": column, "value": value})
89
+
90
+ # Fallback: more permissive pattern for LLMs that format differently
91
+ m = re.match(
92
+ r'^fix\s*\(\s*row\s*=\s*(\d+)\s*,\s*(?:column|col)\s*=\s*(["\']?)(\w+)\2\s*,\s*(?:value|val)\s*=\s*(.+)\)$',
93
+ text,
94
+ re.IGNORECASE | re.DOTALL,
95
+ )
96
+ if m:
97
+ row = int(m.group(1))
98
+ column = m.group(3)
99
+ value = _strip_quotes(m.group(4).strip())
100
+ return ParsedAction("fix", {"row": row, "column": column, "value": value})
101
+
102
+ return None
data_clean_env/server/app.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application for the DataClean Environment."""
2
+
3
+ from openenv.core.env_server.http_server import create_app
4
+
5
+ try:
6
+ from .environment import DataCleanEnvironment
7
+ from ..models import DataCleanAction, DataCleanObservation
8
+ except ImportError:
9
+ import sys, os
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+ from server.environment import DataCleanEnvironment
12
+ from models import DataCleanAction, DataCleanObservation
13
+
14
+ app = create_app(
15
+ DataCleanEnvironment,
16
+ DataCleanAction,
17
+ DataCleanObservation,
18
+ env_name="data_clean_env",
19
+ )
20
+
21
+
22
+ def main():
23
+ import uvicorn
24
+ uvicorn.run(app, host="0.0.0.0", port=8000)
25
+
26
+
27
+ if __name__ == "__main__":
28
+ main()
data_clean_env/server/environment.py ADDED
@@ -0,0 +1,488 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DataClean Environment — core logic."""
2
+
3
+ import copy
4
+ from typing import Any, Dict, List, Optional, Set
5
+ from uuid import uuid4
6
+
7
+ from openenv.core.env_server.types import Action, Observation, State
8
+
9
+ try:
10
+ from openenv.core.env_server.interfaces import Environment
11
+ except ImportError:
12
+ from openenv.core.env_server import Environment
13
+
14
+ try:
15
+ from ..models import DataCleanAction, DataCleanObservation, DataCleanState
16
+ from .action_parser import ParsedAction, parse_action
17
+ from .graders import (
18
+ VALIDATORS,
19
+ validate_date_format,
20
+ validate_row_deleted,
21
+ validate_temporal_order,
22
+ )
23
+ from .tasks import Issue, TaskDefinition, get_task
24
+ except ImportError:
25
+ from models import DataCleanAction, DataCleanObservation, DataCleanState
26
+ from server.action_parser import ParsedAction, parse_action
27
+ from server.graders import (
28
+ VALIDATORS,
29
+ validate_date_format,
30
+ validate_row_deleted,
31
+ validate_temporal_order,
32
+ )
33
+ from server.tasks import Issue, TaskDefinition, get_task
34
+
35
+
36
+ def _format_table(data: List[Dict[str, Any]], columns: List[str], max_rows: int = 50) -> str:
37
+ """Format data as a readable text table."""
38
+ if not data:
39
+ return "(empty dataset)"
40
+
41
+ # Calculate column widths
42
+ widths: Dict[str, int] = {}
43
+ for col in columns:
44
+ widths[col] = max(
45
+ len(col),
46
+ *(len(str(row.get(col, ""))) for row in data[:max_rows]),
47
+ )
48
+ widths[col] = min(widths[col], 30) # cap width
49
+
50
+ # Header
51
+ hdr = "| row | " + " | ".join(col.ljust(widths[col]) for col in columns) + " |"
52
+ sep = "|-----|" + "|".join("-" * (widths[col] + 2) for col in columns) + "|"
53
+ lines = [hdr, sep]
54
+
55
+ for i, row in enumerate(data[:max_rows]):
56
+ vals = []
57
+ for col in columns:
58
+ v = str(row.get(col, ""))
59
+ if len(v) > 30:
60
+ v = v[:27] + "..."
61
+ vals.append(v.ljust(widths[col]))
62
+ lines.append(f"| {str(i).rjust(3)} | " + " | ".join(vals) + " |")
63
+
64
+ if len(data) > max_rows:
65
+ lines.append(f"... ({len(data) - max_rows} more rows)")
66
+
67
+ return "\n".join(lines)
68
+
69
+
70
+ def _column_stats(data: List[Dict[str, Any]], column: str) -> str:
71
+ """Generate stats for a single column."""
72
+ values = [row.get(column, "") for row in data]
73
+ total = len(values)
74
+ non_null = sum(1 for v in values if v is not None and str(v).strip() != "")
75
+ unique = len(set(str(v) for v in values))
76
+
77
+ lines = [
78
+ f"Column: {column}",
79
+ f" Total rows: {total}",
80
+ f" Non-empty: {non_null}",
81
+ f" Unique values: {unique}",
82
+ ]
83
+
84
+ # Try numeric stats
85
+ nums = []
86
+ for v in values:
87
+ try:
88
+ nums.append(float(v))
89
+ except (ValueError, TypeError):
90
+ pass
91
+
92
+ if nums and len(nums) > total * 0.5:
93
+ lines.append(f" Min: {min(nums)}")
94
+ lines.append(f" Max: {max(nums)}")
95
+ lines.append(f" Mean: {sum(nums) / len(nums):.2f}")
96
+ else:
97
+ # Show sample values for string columns
98
+ samples = sorted(set(str(v) for v in values if v is not None and str(v).strip()))[:8]
99
+ lines.append(f" Sample values: {samples}")
100
+
101
+ # Flag suspicious values
102
+ suspicious = []
103
+ for i, v in enumerate(values):
104
+ sv = str(v).strip()
105
+ if sv == "" or sv.lower() in ("none", "null", "nan"):
106
+ suspicious.append(f"Row {i}: empty/null")
107
+ elif " " in sv:
108
+ suspicious.append(f"Row {i}: excess whitespace")
109
+ if suspicious:
110
+ lines.append(f" Suspicious: {suspicious[:5]}")
111
+
112
+ return "\n".join(lines)
113
+
114
+
115
+ class DataCleanEnvironment(Environment):
116
+ """Data quality analysis and cleaning environment."""
117
+
118
+ SUPPORTS_CONCURRENT_SESSIONS = True
119
+
120
+ def __init__(self):
121
+ super().__init__()
122
+ self._state = DataCleanState(episode_id=str(uuid4()))
123
+ self._task: Optional[TaskDefinition] = None
124
+ self._current_data: List[Dict[str, Any]] = []
125
+ self._issue_status: Dict[str, bool] = {} # issue_id → resolved
126
+ self._damaged_cells: int = 0
127
+ self._actions_taken: List[str] = []
128
+ self._inspected_columns: Set[str] = set()
129
+ self._score: float = 0.0
130
+ self._done: bool = False
131
+ # Track which cells are known-bad (in issue registry) to detect damage
132
+ self._bad_cells: Set[tuple] = set() # (row, column)
133
+ # For row deletion tracking — map original rows by index
134
+ self._row_index_map: List[int] = [] # current_idx → original_idx
135
+
136
+ def reset(
137
+ self,
138
+ seed: Optional[int] = None,
139
+ episode_id: Optional[str] = None,
140
+ **kwargs: Any,
141
+ ) -> DataCleanObservation:
142
+ task_id = kwargs.get("task_id", "customer_contacts")
143
+ self._task = get_task(task_id)
144
+
145
+ self._current_data = copy.deepcopy(self._task.data)
146
+ self._issue_status = {issue.issue_id: False for issue in self._task.issues}
147
+ self._damaged_cells = 0
148
+ self._actions_taken = []
149
+ self._inspected_columns = set()
150
+ self._score = 0.0
151
+ self._done = False
152
+ self._row_index_map = list(range(len(self._current_data)))
153
+
154
+ # Track which cells are in the issue registry
155
+ self._bad_cells = set()
156
+ for issue in self._task.issues:
157
+ if issue.issue_type != "duplicate_row" and issue.column:
158
+ self._bad_cells.add((issue.row, issue.column))
159
+
160
+ self._state = DataCleanState(
161
+ episode_id=episode_id or str(uuid4()),
162
+ step_count=0,
163
+ task_id=self._task.task_id,
164
+ difficulty=self._task.difficulty,
165
+ total_issues=len(self._task.issues),
166
+ fixed_issues=0,
167
+ damaged_cells=0,
168
+ max_steps=self._task.max_steps,
169
+ score=0.0,
170
+ )
171
+
172
+ return self._build_observation(
173
+ feedback=f"Task '{self._task.task_id}' loaded ({self._task.difficulty}). "
174
+ f"Dataset has {len(self._current_data)} rows, {len(self._task.columns)} columns, "
175
+ f"and {len(self._task.issues)} known issues to fix. "
176
+ f"You have {self._task.max_steps} steps. Use inspect() to examine columns, "
177
+ f"fix() to correct values, delete() for duplicates, submit() when done."
178
+ )
179
+
180
+ def step(
181
+ self,
182
+ action: DataCleanAction,
183
+ timeout_s: Optional[float] = None,
184
+ **kwargs: Any,
185
+ ) -> DataCleanObservation:
186
+ if self._task is None:
187
+ return self._build_observation(
188
+ feedback="Error: No task loaded. Call reset(task_id=...) first.",
189
+ done=True,
190
+ )
191
+
192
+ if self._done:
193
+ return self._build_observation(
194
+ feedback="Episode already finished. Call reset() to start a new one.",
195
+ done=True,
196
+ )
197
+
198
+ self._state.step_count += 1
199
+ self._actions_taken.append(action.command)
200
+
201
+ parsed = parse_action(action.command)
202
+
203
+ if parsed.command_type == "error":
204
+ feedback = f"Parse error: {parsed.error_message}"
205
+ elif parsed.command_type == "submit":
206
+ feedback = self._handle_submit()
207
+ elif parsed.command_type == "inspect":
208
+ feedback = self._handle_inspect(parsed)
209
+ elif parsed.command_type == "fix":
210
+ feedback = self._handle_fix(parsed)
211
+ elif parsed.command_type == "delete":
212
+ feedback = self._handle_delete(parsed)
213
+ else:
214
+ feedback = f"Unknown command type: {parsed.command_type}"
215
+
216
+ # Check step limit
217
+ remaining = self._task.max_steps - self._state.step_count
218
+ if remaining <= 0 and not self._done:
219
+ self._done = True
220
+ self._compute_final_score()
221
+ feedback += f"\n\nMax steps reached. Final score: {self._score:.4f}"
222
+
223
+ self._state.fixed_issues = sum(1 for v in self._issue_status.values() if v)
224
+ self._state.damaged_cells = self._damaged_cells
225
+ self._state.score = self._score
226
+
227
+ return self._build_observation(feedback=feedback)
228
+
229
+ @property
230
+ def state(self) -> DataCleanState:
231
+ return self._state
232
+
233
+ # ------------------------------------------------------------------
234
+ # Command handlers
235
+ # ------------------------------------------------------------------
236
+
237
+ def _handle_submit(self) -> str:
238
+ self._done = True
239
+ self._compute_final_score()
240
+ fixed = sum(1 for v in self._issue_status.values() if v)
241
+ total = len(self._task.issues)
242
+ return (
243
+ f"Submitted! Fixed {fixed}/{total} issues. "
244
+ f"Damaged cells: {self._damaged_cells}. "
245
+ f"Final score: {self._score:.4f}"
246
+ )
247
+
248
+ def _handle_inspect(self, parsed: ParsedAction) -> str:
249
+ col = parsed.args["column"]
250
+ if col not in self._task.columns:
251
+ return (
252
+ f"Column '{col}' not found. "
253
+ f"Available: {self._task.columns}"
254
+ )
255
+
256
+ self._inspected_columns.add(col)
257
+ stats = _column_stats(self._current_data, col)
258
+ desc = self._task.column_descriptions.get(col, "")
259
+
260
+ # Count issues in this column
261
+ col_issues = [
262
+ i for i in self._task.issues
263
+ if i.column == col and not self._issue_status.get(i.issue_id, False)
264
+ ]
265
+ hint = f"\n Issues remaining in this column: {len(col_issues)}"
266
+
267
+ return f"Inspecting '{col}' ({desc}):\n{stats}{hint}"
268
+
269
+ def _handle_fix(self, parsed: ParsedAction) -> str:
270
+ row_idx = parsed.args["row"]
271
+ col = parsed.args["column"]
272
+ new_value = parsed.args["value"]
273
+
274
+ if row_idx < 0 or row_idx >= len(self._current_data):
275
+ return f"Row {row_idx} out of range (0-{len(self._current_data) - 1})"
276
+
277
+ if col not in self._task.columns:
278
+ return f"Column '{col}' not found. Available: {self._task.columns}"
279
+
280
+ # Get the original row index (to match issue registry)
281
+ orig_idx = self._row_index_map[row_idx]
282
+ old_value = self._current_data[row_idx].get(col, "")
283
+
284
+ # Check if this cell is in the issue registry
285
+ cell_in_issue = (orig_idx, col) in self._bad_cells
286
+
287
+ if not cell_in_issue:
288
+ # Agent is modifying a cell that had no known issue — damage
289
+ self._current_data[row_idx][col] = new_value
290
+ self._damaged_cells += 1
291
+ self._score = max(0.0, self._score - 0.05)
292
+ return (
293
+ f"Warning: Row {row_idx}, column '{col}' had no known issue. "
294
+ f"Original value '{old_value}' was overwritten. Penalty applied (-0.05)."
295
+ )
296
+
297
+ # Apply the fix
298
+ # Try to convert to appropriate type
299
+ converted = self._convert_value(new_value, old_value)
300
+ self._current_data[row_idx][col] = converted
301
+
302
+ # Check all issues involving this cell
303
+ reward_delta = 0.0
304
+ fixed_any = False
305
+ for issue in self._task.issues:
306
+ if issue.row != orig_idx or issue.column != col:
307
+ continue
308
+ if self._issue_status.get(issue.issue_id, False):
309
+ continue # already resolved
310
+
311
+ if self._check_issue_resolved(issue):
312
+ self._issue_status[issue.issue_id] = True
313
+ reward_delta += 1.0 / len(self._task.issues)
314
+ fixed_any = True
315
+
316
+ self._score = min(1.0, max(0.0, self._score + reward_delta))
317
+
318
+ if fixed_any:
319
+ return (
320
+ f"Fixed: Row {row_idx}, '{col}' changed from '{old_value}' to '{converted}'. "
321
+ f"Score: {self._score:.4f}"
322
+ )
323
+ else:
324
+ return (
325
+ f"Changed: Row {row_idx}, '{col}' from '{old_value}' to '{converted}', "
326
+ f"but the issue is not yet resolved. Check the expected format. "
327
+ f"Score: {self._score:.4f}"
328
+ )
329
+
330
+ def _handle_delete(self, parsed: ParsedAction) -> str:
331
+ row_idx = parsed.args["row"]
332
+
333
+ if row_idx < 0 or row_idx >= len(self._current_data):
334
+ return f"Row {row_idx} out of range (0-{len(self._current_data) - 1})"
335
+
336
+ orig_idx = self._row_index_map[row_idx]
337
+ deleted_row = self._current_data[row_idx]
338
+
339
+ # Check if this row is a known duplicate
340
+ reward_delta = 0.0
341
+ is_duplicate = False
342
+ for issue in self._task.issues:
343
+ if issue.issue_type != "duplicate_row":
344
+ continue
345
+ if issue.row != orig_idx:
346
+ continue
347
+ if self._issue_status.get(issue.issue_id, False):
348
+ continue
349
+
350
+ # The agent is deleting the row that matches the duplicate issue
351
+ self._issue_status[issue.issue_id] = True
352
+ reward_delta += 1.0 / len(self._task.issues)
353
+ is_duplicate = True
354
+
355
+ if not is_duplicate:
356
+ # Deleting a non-duplicate row — penalty
357
+ self._damaged_cells += 1
358
+ reward_delta = -0.05
359
+
360
+ # Actually remove the row
361
+ self._current_data.pop(row_idx)
362
+ self._row_index_map.pop(row_idx)
363
+
364
+ self._score = min(1.0, max(0.0, self._score + reward_delta))
365
+
366
+ if is_duplicate:
367
+ return (
368
+ f"Deleted duplicate row {row_idx}. Score: {self._score:.4f}"
369
+ )
370
+ else:
371
+ return (
372
+ f"Warning: Row {row_idx} was not a known duplicate. "
373
+ f"Penalty applied (-0.05). Score: {self._score:.4f}"
374
+ )
375
+
376
+ # ------------------------------------------------------------------
377
+ # Helpers
378
+ # ------------------------------------------------------------------
379
+
380
+ def _check_issue_resolved(self, issue: Issue) -> bool:
381
+ """Check if an issue is resolved in the current data."""
382
+ if issue.issue_type == "duplicate_row":
383
+ return validate_row_deleted(self._current_data, issue.original_row_data)
384
+
385
+ if issue.issue_type == "temporal_inconsistency":
386
+ # Find the row by original index
387
+ current_row_idx = self._find_current_row(issue.row)
388
+ if current_row_idx is None:
389
+ return False
390
+ row = self._current_data[current_row_idx]
391
+ hire = str(row.get("hire_date", ""))
392
+ term = str(row.get("termination_date", ""))
393
+ return validate_temporal_order(hire, term)
394
+
395
+ # Standard validation
396
+ validator = VALIDATORS.get(issue.issue_type)
397
+ if validator is None:
398
+ return False
399
+
400
+ current_row_idx = self._find_current_row(issue.row)
401
+ if current_row_idx is None:
402
+ return False
403
+
404
+ val = self._current_data[current_row_idx].get(issue.column, "")
405
+ return validator(val, **issue.validation_params)
406
+
407
+ def _find_current_row(self, original_idx: int) -> Optional[int]:
408
+ """Find current row index from original index."""
409
+ try:
410
+ return self._row_index_map.index(original_idx)
411
+ except ValueError:
412
+ return None # row was deleted
413
+
414
+ def _convert_value(self, new_value: str, old_value: Any) -> Any:
415
+ """Try to convert new_value to match the type of old_value."""
416
+ if isinstance(old_value, int):
417
+ try:
418
+ return int(float(new_value))
419
+ except (ValueError, TypeError):
420
+ return new_value
421
+ if isinstance(old_value, float):
422
+ try:
423
+ return float(new_value)
424
+ except (ValueError, TypeError):
425
+ return new_value
426
+ return new_value
427
+
428
+ def _compute_final_score(self) -> None:
429
+ """Recompute score based on all current issue states."""
430
+ total = len(self._task.issues)
431
+ if total == 0:
432
+ self._score = 1.0
433
+ return
434
+
435
+ resolved = 0
436
+ for issue in self._task.issues:
437
+ if self._issue_status.get(issue.issue_id, False):
438
+ resolved += 1
439
+ continue
440
+ # One last check in case fixes propagated
441
+ if self._check_issue_resolved(issue):
442
+ self._issue_status[issue.issue_id] = True
443
+ resolved += 1
444
+
445
+ self._score = resolved / total
446
+ # Apply damage penalty
447
+ self._score = max(0.0, self._score - self._damaged_cells * 0.05)
448
+ self._score = min(1.0, self._score)
449
+
450
+ def _build_observation(
451
+ self, feedback: str = "", done: Optional[bool] = None,
452
+ ) -> DataCleanObservation:
453
+ if done is None:
454
+ done = self._done
455
+
456
+ task = self._task
457
+ if task is None:
458
+ return DataCleanObservation(
459
+ done=done,
460
+ reward=self._score,
461
+ feedback=feedback,
462
+ )
463
+
464
+ fixed = sum(1 for v in self._issue_status.values() if v)
465
+ remaining = max(0, task.max_steps - self._state.step_count)
466
+
467
+ col_info_parts = []
468
+ for col in task.columns:
469
+ desc = task.column_descriptions.get(col, "")
470
+ col_info_parts.append(f" {col}: {desc}")
471
+ col_info = "\n".join(col_info_parts)
472
+
473
+ return DataCleanObservation(
474
+ done=done,
475
+ reward=self._score if done else None,
476
+ task_id=task.task_id,
477
+ task_description=task.description,
478
+ difficulty=task.difficulty,
479
+ data_preview=_format_table(self._current_data, task.columns),
480
+ column_info=col_info,
481
+ feedback=feedback,
482
+ actions_remaining=remaining,
483
+ issues_found=len(self._inspected_columns),
484
+ issues_fixed=fixed,
485
+ total_issues=len(task.issues),
486
+ current_score=round(self._score, 4),
487
+ action_history=list(self._actions_taken[-10:]),
488
+ )
data_clean_env/server/graders.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Validation-based grading functions for data quality issues."""
2
+
3
+ import re
4
+ from datetime import datetime
5
+ from typing import Any, Dict, List, Set
6
+
7
+
8
+ def validate_email(value: Any) -> bool:
9
+ """Value must be a valid email: user@domain.tld"""
10
+ s = str(value).strip()
11
+ return bool(re.match(r"^[\w.+\-]+@[\w\-]+\.\w{2,}$", s))
12
+
13
+
14
+ def validate_phone(value: Any) -> bool:
15
+ """Value must contain only digits, dashes, spaces, parens, plus. At least 10 digits."""
16
+ s = str(value).strip()
17
+ if not re.match(r"^[\d\-\(\)\s\+\.]+$", s):
18
+ return False
19
+ digits = re.sub(r"\D", "", s)
20
+ return len(digits) >= 10
21
+
22
+
23
+ def validate_date_format(value: Any) -> bool:
24
+ """Value must be a valid YYYY-MM-DD date."""
25
+ s = str(value).strip()
26
+ try:
27
+ datetime.strptime(s, "%Y-%m-%d")
28
+ return True
29
+ except ValueError:
30
+ return False
31
+
32
+
33
+ def validate_non_empty(value: Any) -> bool:
34
+ """Value must not be empty or whitespace-only."""
35
+ if value is None:
36
+ return False
37
+ return str(value).strip() != ""
38
+
39
+
40
+ def validate_positive_number(value: Any) -> bool:
41
+ """Value must be a positive number."""
42
+ try:
43
+ return float(value) > 0
44
+ except (ValueError, TypeError):
45
+ return False
46
+
47
+
48
+ def validate_in_range(value: Any, low: float, high: float) -> bool:
49
+ """Value must be a number within [low, high]."""
50
+ try:
51
+ v = float(value)
52
+ return low <= v <= high
53
+ except (ValueError, TypeError):
54
+ return False
55
+
56
+
57
+ def validate_canonical(value: Any, canonical_set: Set[str]) -> bool:
58
+ """Value must exactly match one of the canonical values."""
59
+ return str(value).strip() in canonical_set
60
+
61
+
62
+ def validate_no_excess_whitespace(value: Any) -> bool:
63
+ """Value must be trimmed with no double spaces."""
64
+ s = str(value)
65
+ return s == s.strip() and " " not in s
66
+
67
+
68
+ def validate_referential_integrity(
69
+ value: Any, valid_ids: Set[str]
70
+ ) -> bool:
71
+ """Value must be an ID that exists in the given set."""
72
+ return str(value).strip() in valid_ids
73
+
74
+
75
+ def validate_temporal_order(
76
+ hire_date: str, termination_date: str
77
+ ) -> bool:
78
+ """Termination date must be after hire date. Empty termination is valid."""
79
+ t = str(termination_date).strip()
80
+ if not t or t.lower() in ("", "none", "null", "nat"):
81
+ return True
82
+ try:
83
+ h = datetime.strptime(str(hire_date).strip(), "%Y-%m-%d")
84
+ td = datetime.strptime(t, "%Y-%m-%d")
85
+ return td > h
86
+ except ValueError:
87
+ return False
88
+
89
+
90
+ def validate_row_deleted(
91
+ current_data: List[Dict[str, Any]], original_row: Dict[str, Any]
92
+ ) -> bool:
93
+ """Check that a specific original row no longer exists in the dataset."""
94
+ for row in current_data:
95
+ if all(
96
+ str(row.get(k, "")) == str(v) for k, v in original_row.items()
97
+ ):
98
+ return False
99
+ return True
100
+
101
+
102
+ # Issue type → validation function mapping
103
+ VALIDATORS = {
104
+ "invalid_email": lambda val, **_: validate_email(val),
105
+ "invalid_phone": lambda val, **_: validate_phone(val),
106
+ "wrong_date_format": lambda val, **_: validate_date_format(val),
107
+ "invalid_date": lambda val, **_: validate_date_format(val),
108
+ "missing_value": lambda val, **_: validate_non_empty(val),
109
+ "negative_number": lambda val, **_: validate_positive_number(val),
110
+ "outlier": lambda val, low=0, high=0, **_: validate_in_range(val, low, high),
111
+ "inconsistent_format": lambda val, canonical_set=frozenset(), **_: validate_canonical(
112
+ val, canonical_set
113
+ ),
114
+ "excess_whitespace": lambda val, **_: validate_no_excess_whitespace(val),
115
+ "referential_integrity": lambda val, valid_ids=frozenset(), **_: validate_referential_integrity(
116
+ val, valid_ids
117
+ ),
118
+ "duplicate_row": None, # Handled separately via validate_row_deleted
119
+ "temporal_inconsistency": None, # Handled separately with two columns
120
+ "score_out_of_range": lambda val, low=0, high=10, **_: validate_in_range(
121
+ val, low, high
122
+ ),
123
+ }
data_clean_env/server/tasks.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hardcoded task definitions with datasets and issue registries."""
2
+
3
+ import copy
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, List, Set
6
+
7
+
8
+ @dataclass
9
+ class Issue:
10
+ issue_id: str
11
+ row: int
12
+ column: str
13
+ issue_type: str
14
+ description: str
15
+ # Extra params for validation (e.g. canonical_set, low/high range)
16
+ validation_params: Dict[str, Any] = field(default_factory=dict)
17
+ # For duplicate_row issues, store the original row data
18
+ original_row_data: Dict[str, Any] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass
22
+ class TaskDefinition:
23
+ task_id: str
24
+ difficulty: str
25
+ description: str
26
+ columns: List[str]
27
+ data: List[Dict[str, Any]]
28
+ issues: List[Issue]
29
+ max_steps: int
30
+ column_descriptions: Dict[str, str]
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # EASY: Customer Contacts
35
+ # ---------------------------------------------------------------------------
36
+ _EASY_DATA = [
37
+ {"name": "John Smith", "email": "john.smith@gmail.com", "phone": "555-012-3401", "city": "New York", "signup_date": "2024-01-15"},
38
+ {"name": "Jane Doe", "email": "jane.doe@outlook.com", "phone": "555-012-3402", "city": "Los Angeles", "signup_date": "2024-02-20"},
39
+ {"name": "Bob Johnson", "email": "bob.j@company.com", "phone": "555-012-3403", "city": "Chicago", "signup_date": "2024-03-10"},
40
+ {"name": "Alice Brown", "email": "alice.brown[at]mail.com", "phone": "555-012-3404", "city": "Houston", "signup_date": "2024-04-05"}, # E1: invalid email
41
+ {"name": "Charlie Wilson", "email": "charlie@example.com", "phone": "555-012-3405", "city": "Phoenix", "signup_date": "2024-05-12"},
42
+ {"name": "Diana Davis", "email": "diana@example.com", "phone": "555-012-3406", "city": "", "signup_date": "2024-06-18"}, # E3: empty city
43
+ {"name": "Eve Martinez", "email": "eve@example.com", "phone": "555-012-3407", "city": "Philadelphia", "signup_date": "2024-07-22"},
44
+ {"name": "Frank Taylor", "email": "frank@example.com", "phone": "55A-0B2-34C8", "city": "San Antonio", "signup_date": "2024-08-30"}, # E2: phone has letters
45
+ {"name": "Grace Lee", "email": "grace@example.com", "phone": "555-012-3409", "city": "San Diego", "signup_date": "2024-09-14"},
46
+ {"name": "Hank Moore", "email": "hank@@domain.com", "phone": "555-012-3410", "city": "Dallas", "signup_date": "2024-10-01"}, # E5: double @@
47
+ {"name": "Ivy Clark", "email": "ivy@example.com", "phone": "555-012-3411", "city": "San Jose", "signup_date": "2024-11-18"},
48
+ {"name": "Jack White", "email": "jack@example.com", "phone": "555-012-3412", "city": "Austin", "signup_date": "03/25/2024"}, # E4: wrong date format
49
+ {"name": "Karen Lewis", "email": "karen@example.com", "phone": "555-012-3413", "city": "Jacksonville", "signup_date": "2025-01-03"},
50
+ {"name": "Leo Walker", "email": "leo@example.com", "phone": "555-012-3414", "city": "Columbus", "signup_date": "2025-02-14"},
51
+ {"name": "John Smith", "email": "john.smith@gmail.com", "phone": "555-012-3401", "city": "New York", "signup_date": "2024-01-15"}, # E6: exact duplicate of row 0
52
+ ]
53
+
54
+ _EASY_ISSUES = [
55
+ Issue("E1", 3, "email", "invalid_email", "Email uses '[at]' instead of '@'"),
56
+ Issue("E2", 7, "phone", "invalid_phone", "Phone number contains letters"),
57
+ Issue("E3", 5, "city", "missing_value", "City is empty"),
58
+ Issue("E4", 11, "signup_date", "wrong_date_format", "Date is MM/DD/YYYY instead of YYYY-MM-DD"),
59
+ Issue("E5", 9, "email", "invalid_email", "Email has double @@ symbol"),
60
+ Issue(
61
+ "E6", 14, "", "duplicate_row", "Exact duplicate of row 0",
62
+ original_row_data={"name": "John Smith", "email": "john.smith@gmail.com", "phone": "555-012-3401", "city": "New York", "signup_date": "2024-01-15"},
63
+ ),
64
+ ]
65
+
66
+ EASY_TASK = TaskDefinition(
67
+ task_id="customer_contacts",
68
+ difficulty="easy",
69
+ description=(
70
+ "You are cleaning a customer contact list for a CRM import. "
71
+ "The data should have valid emails (user@domain.tld), phone numbers "
72
+ "(digits and dashes only, 10+ digits), non-empty cities, dates in "
73
+ "YYYY-MM-DD format, and no duplicate rows. Find and fix all data "
74
+ "quality issues."
75
+ ),
76
+ columns=["name", "email", "phone", "city", "signup_date"],
77
+ data=_EASY_DATA,
78
+ issues=_EASY_ISSUES,
79
+ max_steps=15,
80
+ column_descriptions={
81
+ "name": "Customer full name",
82
+ "email": "Email address (must be user@domain.tld)",
83
+ "phone": "Phone number (digits and dashes, 10+ digits)",
84
+ "city": "City of residence (must not be empty)",
85
+ "signup_date": "Signup date (must be YYYY-MM-DD format)",
86
+ },
87
+ )
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # MEDIUM: Sales Records
92
+ # ---------------------------------------------------------------------------
93
+ _MEDIUM_DATA = [
94
+ {"order_id": "ORD-1001", "customer_name": "Acme Corp", "product": "Widget A", "quantity": 10, "unit_price": 29.99, "order_date": "2024-01-15", "region": "Northeast"},
95
+ {"order_id": "ORD-1002", "customer_name": "Beta Inc", "product": "Widget B", "quantity": 5, "unit_price": 49.99, "order_date": "2024-01-22", "region": "Southeast"},
96
+ {"order_id": "ORD-1003", "customer_name": "Gamma LLC", "product": "Gadget X", "quantity": 20, "unit_price": 15.00, "order_date": "2024-02-03", "region": "Midwest"},
97
+ {"order_id": "ORD-1004", "customer_name": "Delta Co", "product": "Widget A", "quantity": 8, "unit_price": 29.99, "order_date": "2024-02-10", "region": "north-east"}, # M6: inconsistent region
98
+ {"order_id": "ORD-1005", "customer_name": "Epsilon Ltd", "product": "Gadget Y", "quantity": 3, "unit_price": 89.50, "order_date": "Jan 15, 2024", "region": "West"}, # M1: wrong date format
99
+ {"order_id": "ORD-1006", "customer_name": "Zeta Group", "product": "Widget C", "quantity": 12, "unit_price": 35.00, "order_date": "2024-03-01", "region": "Northwest"},
100
+ {"order_id": "", "customer_name": "Eta Partners", "product": "Gadget X", "quantity": 7, "unit_price": 15.00, "order_date": "2024-03-12", "region": "Southeast"}, # M10: missing order_id
101
+ {"order_id": "ORD-1008", "customer_name": "Theta Corp", "product": "Widget B", "quantity": 15, "unit_price": 49.99, "order_date": "2024-03-20", "region": "Midwest"},
102
+ {"order_id": "ORD-1009", "customer_name": "Iota Inc", "product": "Gadget Z", "quantity": -5, "unit_price": 120.00, "order_date": "2024-04-02", "region": "Northeast"}, # M3: negative quantity
103
+ {"order_id": "ORD-1010", "customer_name": "Kappa LLC", "product": "Widget A", "quantity": 25, "unit_price": 29.99, "order_date": "2024-04-15", "region": "West"},
104
+ {"order_id": "ORD-1011", "customer_name": "Lambda Co", "product": "Gadget Y", "quantity": 6, "unit_price": 89.50, "order_date": "2024-04-28", "region": "Southeast"},
105
+ {"order_id": "ORD-1012", "customer_name": "Mu Ltd", "product": "Widget C", "quantity": 30, "unit_price": 35.00, "order_date": "2024-05-05", "region": "Northeast"},
106
+ {"order_id": "ORD-1013", "customer_name": "Nu Group", "product": "Gadget X", "quantity": 4, "unit_price": 15.00, "order_date": "2024/05/18", "region": "Midwest"}, # M2: slash date format
107
+ {"order_id": "ORD-1014", "customer_name": "Xi Partners", "product": "Widget B", "quantity": 9, "unit_price": 49.99, "order_date": "2024-06-01", "region": "Northwest"},
108
+ {"order_id": "ORD-1015", "customer_name": "Omicron Corp", "product": "Gadget Z", "quantity": 2, "unit_price": 29999.99, "order_date": "2024-06-15", "region": "West"}, # M5: price outlier
109
+ {"order_id": "ORD-1016", "customer_name": "Pi Inc", "product": "Widget A", "quantity": 18, "unit_price": 29.99, "order_date": "2024-06-28", "region": "Northeast"},
110
+ {"order_id": "ORD-1017", "customer_name": "Rho LLC", "product": "Gadget Y", "quantity": 11, "unit_price": 89.50, "order_date": "2024-07-10", "region": "Southeast"},
111
+ {"order_id": "ORD-1018", "customer_name": " Sigma Co ", "product": "Widget C", "quantity": 7, "unit_price": 35.00, "order_date": "2024-07-22", "region": "Midwest"}, # M11: excess whitespace
112
+ {"order_id": "ORD-1019", "customer_name": "Tau Group", "product": "Gadget X", "quantity": 14, "unit_price": 15.00, "order_date": "2024-08-05", "region": "Northwest"},
113
+ {"order_id": "ORD-1020", "customer_name": "Upsilon Ltd", "product": "Widget B", "quantity": -12, "unit_price": 49.99, "order_date": "2024-08-18", "region": "Northeast"}, # M4: negative quantity
114
+ {"order_id": "ORD-1021", "customer_name": "Phi Corp", "product": "Gadget Z", "quantity": 8, "unit_price": -15.50, "order_date": "2024-09-01", "region": "West"}, # M12: negative price
115
+ {"order_id": "ORD-1022", "customer_name": "Chi Inc", "product": "Widget A", "quantity": 22, "unit_price": 29.99, "order_date": "2024-09-15", "region": "Southeast"},
116
+ {"order_id": "ORD-1023", "customer_name": "Psi LLC", "product": "Gadget Y", "quantity": 3, "unit_price": 89.50, "order_date": "2024-09-28", "region": "WEST"}, # M7: inconsistent case
117
+ {"order_id": "ORD-1024", "customer_name": "Omega Co", "product": "Widget C", "quantity": 16, "unit_price": 35.00, "order_date": "2024-10-10", "region": "Midwest"},
118
+ {"order_id": "ORD-1025", "customer_name": "Alpha2 Group", "product": "Gadget X", "quantity": 9, "unit_price": 15.00, "order_date": "2024-10-22", "region": "Northwest"},
119
+ {"order_id": "ORD-1011", "customer_name": "Lambda Co", "product": "Gadget Y", "quantity": 6, "unit_price": 89.50, "order_date": "2024-04-28", "region": "Southeast"}, # M9: duplicate of row 10
120
+ {"order_id": "ORD-1027", "customer_name": "Beta2 Inc", "product": "Widget B", "quantity": 13, "unit_price": 49.99, "order_date": "2024-11-15", "region": "Northeast"},
121
+ {"order_id": "ORD-1028", "customer_name": "Gamma2 LLC", "product": "Gadget Z", "quantity": 5, "unit_price": 120.00, "order_date": "2024-11-28", "region": "South East"}, # M8: inconsistent region
122
+ {"order_id": "ORD-1029", "customer_name": "Delta2 Co", "product": "Widget A", "quantity": 20, "unit_price": 29.99, "order_date": "2024-12-10", "region": "West"},
123
+ {"order_id": "ORD-1030", "customer_name": "Epsilon2 Ltd", "product": "Gadget Y", "quantity": 7, "unit_price": 89.50, "order_date": "2024-12-22", "region": "Midwest"},
124
+ ]
125
+
126
+ _VALID_REGIONS: Set[str] = {"Northeast", "Southeast", "Midwest", "West", "Northwest"}
127
+
128
+ _MEDIUM_ISSUES = [
129
+ Issue("M1", 4, "order_date", "wrong_date_format", "Date is 'Jan 15, 2024' instead of YYYY-MM-DD"),
130
+ Issue("M2", 12, "order_date", "wrong_date_format", "Date uses slashes '2024/05/18' instead of YYYY-MM-DD"),
131
+ Issue("M3", 8, "quantity", "negative_number", "Quantity is negative (-5)"),
132
+ Issue("M4", 19, "quantity", "negative_number", "Quantity is negative (-12)"),
133
+ Issue("M5", 14, "unit_price", "outlier", "Price 29999.99 is ~1000x normal range",
134
+ validation_params={"low": 1.0, "high": 500.0}),
135
+ Issue("M6", 3, "region", "inconsistent_format", "Region 'north-east' should match canonical form",
136
+ validation_params={"canonical_set": _VALID_REGIONS}),
137
+ Issue("M7", 22, "region", "inconsistent_format", "Region 'WEST' should match canonical form",
138
+ validation_params={"canonical_set": _VALID_REGIONS}),
139
+ Issue("M8", 27, "region", "inconsistent_format", "Region 'South East' should match canonical form",
140
+ validation_params={"canonical_set": _VALID_REGIONS}),
141
+ Issue(
142
+ "M9", 25, "", "duplicate_row", "Exact duplicate of row 10 (same order_id, customer, product)",
143
+ original_row_data={"order_id": "ORD-1011", "customer_name": "Lambda Co", "product": "Gadget Y", "quantity": 6, "unit_price": 89.50, "order_date": "2024-04-28", "region": "Southeast"},
144
+ ),
145
+ Issue("M10", 6, "order_id", "missing_value", "Order ID is empty"),
146
+ Issue("M11", 17, "customer_name", "excess_whitespace", "Customer name has excess whitespace"),
147
+ Issue("M12", 20, "unit_price", "negative_number", "Price is negative (-15.50)"),
148
+ ]
149
+
150
+ MEDIUM_TASK = TaskDefinition(
151
+ task_id="sales_records",
152
+ difficulty="medium",
153
+ description=(
154
+ "You are cleaning sales transaction records for a quarterly report. "
155
+ "Requirements: order_id must be non-empty (format ORD-XXXX), dates must "
156
+ "be YYYY-MM-DD, quantities and prices must be positive numbers, prices "
157
+ "should be in a reasonable range ($1-$500), regions must be one of: "
158
+ "Northeast, Southeast, Midwest, West, Northwest. Names should have no "
159
+ "excess whitespace. No duplicate orders."
160
+ ),
161
+ columns=["order_id", "customer_name", "product", "quantity", "unit_price", "order_date", "region"],
162
+ data=_MEDIUM_DATA,
163
+ issues=_MEDIUM_ISSUES,
164
+ max_steps=25,
165
+ column_descriptions={
166
+ "order_id": "Unique order identifier (format: ORD-XXXX, must not be empty)",
167
+ "customer_name": "Customer/company name (no excess whitespace)",
168
+ "product": "Product name",
169
+ "quantity": "Order quantity (must be positive)",
170
+ "unit_price": "Price per unit in USD (must be positive, reasonable range $1-$500)",
171
+ "order_date": "Order date (must be YYYY-MM-DD)",
172
+ "region": "Sales region (must be: Northeast, Southeast, Midwest, West, or Northwest)",
173
+ },
174
+ )
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # HARD: Employee Records
179
+ # ---------------------------------------------------------------------------
180
+ _HARD_DATA = [
181
+ {"emp_id": "EMP-001", "name": "Sarah Chen", "email": "sarah.chen@company.com", "department": "Engineering", "hire_date": "2020-03-15", "termination_date": "", "salary": 95000, "manager_id": "EMP-010", "performance_score": 8.5},
182
+ {"emp_id": "EMP-002", "name": "James Wilson", "email": "james.w@company.com", "department": "Marketing", "hire_date": "2019-07-01", "termination_date": "", "salary": 78000, "manager_id": "EMP-010", "performance_score": 7.2},
183
+ {"emp_id": "EMP-003", "name": "Maria Garcia", "email": "maria.g@company.com", "department": "Engineering", "hire_date": "2021-01-10", "termination_date": "", "salary": 15000000, "manager_id": "EMP-001", "performance_score": 9.1}, # H5: salary outlier (15M)
184
+ {"emp_id": "EMP-004", "name": "David Kim", "email": "david.kim@company.com", "department": "Sales", "hire_date": "2020-09-20", "termination_date": "", "salary": 72000, "manager_id": "EMP-010", "performance_score": 6.8},
185
+ {"emp_id": "EMP-005", "name": "Emily Patel", "email": "emily.p@company.com", "department": "HR", "hire_date": "2018-04-05", "termination_date": "", "salary": 82000, "manager_id": "EMP-010", "performance_score": 8.0},
186
+ {"emp_id": "EMP-006", "name": "Michael Brown", "email": "michael.b@company.com", "department": "Engineering", "hire_date": "2022-06-15", "termination_date": "", "salary": 88000, "manager_id": "EMP-099", "performance_score": 7.5}, # H1: manager doesn't exist
187
+ {"emp_id": "EMP-007", "name": " Robert Williams ", "email": "robert.w@company.com", "department": "Finance", "hire_date": "2019-11-01", "termination_date": "", "salary": 91000, "manager_id": "EMP-010", "performance_score": 8.3}, # H17: excess whitespace in name
188
+ {"emp_id": "EMP-008", "name": "Lisa Anderson", "email": "lisa.a@company.com", "department": "Sales", "hire_date": "2024-03-15", "termination_date": "2023-01-10", "salary": 68000, "manager_id": "EMP-004", "performance_score": 5.5}, # H3: termination before hire
189
+ {"emp_id": "EMP-009", "name": "Kevin Taylor", "email": "kevin.t@company.com", "department": "Engineering", "hire_date": "2021-08-20", "termination_date": "", "salary": 93000, "manager_id": "EMP-001", "performance_score": 11.5}, # H9: score > 10
190
+ {"emp_id": "EMP-010", "name": "Jennifer Martinez", "email": "jennifer.m@company.com", "department": "Operations", "hire_date": "2017-01-15", "termination_date": "", "salary": 120000, "manager_id": "", "performance_score": 9.0},
191
+ {"emp_id": "EMP-011", "name": "Alice Jones", "email": "alice.jones@", "department": "Marketing", "hire_date": "2022-02-01", "termination_date": "", "salary": 75000, "manager_id": "EMP-002", "performance_score": 7.8}, # H7: incomplete email
192
+ {"emp_id": "EMP-012", "name": "Thomas Lee", "email": "thomas.l@company.com", "department": "Engineering", "hire_date": "2020-10-10", "termination_date": "", "salary": 97000, "manager_id": "EMP-001", "performance_score": 8.7},
193
+ {"emp_id": "EMP-013", "name": "Rachel Green", "email": "rachel.g@company.com", "department": "Engg", "hire_date": "2023-04-15", "termination_date": "", "salary": 85000, "manager_id": "EMP-001", "performance_score": 7.0}, # H11: department abbreviation
194
+ {"emp_id": "EMP-014", "name": "Daniel White", "email": "daniel.w@company.com", "department": "Finance", "hire_date": "2021-05-20", "termination_date": "", "salary": 89000, "manager_id": "EMP-007", "performance_score": 8.1},
195
+ {"emp_id": "EMP-015", "name": "Sophie Clark", "email": "sophie.c@company.com", "department": "HR", "hire_date": "2023-06-01", "termination_date": "2023-05-15", "salary": 71000, "manager_id": "EMP-005", "performance_score": 6.0}, # H4: termination before hire
196
+ {"emp_id": "EMP-016", "name": "Chris Johnson", "email": "chris.j@company.com", "department": "Sales", "hire_date": "2020-12-01", "termination_date": "", "salary": 76000, "manager_id": "EMP-004", "performance_score": 7.3},
197
+ {"emp_id": "EMP-003", "name": "Maria R. Garcia", "email": "maria.g@company.com", "department": "Engineering", "hire_date": "2021-01-10", "termination_date": "", "salary": 15000000, "manager_id": "EMP-001", "performance_score": 9.1}, # H14: semantic dup of row 2 (same emp_id, slightly different name)
198
+ {"emp_id": "EMP-017", "name": "Amanda Davis", "email": "amanda.d@company.com", "department": "Marketing", "hire_date": "2022-09-15", "termination_date": "", "salary": 73000, "manager_id": "EMP-002", "performance_score": 7.6},
199
+ {"emp_id": "EMP-018", "name": "Ryan Thomas", "email": "ryan.t@company.com", "department": "Engineering", "hire_date": "2023-01-20", "termination_date": "", "salary": 90000, "manager_id": "EMP-088", "performance_score": 8.0}, # H2: manager doesn't exist
200
+ {"emp_id": "EMP-019", "name": "Nicole Brown", "email": "nicole.b@company.com", "department": "Finance", "hire_date": "2021-03-01", "termination_date": "", "salary": 87000, "manager_id": "EMP-007", "performance_score": 8.4},
201
+ {"emp_id": "EMP-020", "name": "Jason Miller", "email": "jason.m@company.com", "department": "Operations", "hire_date": "2024-11-15", "termination_date": "2024-06-30", "salary": 79000, "manager_id": "EMP-010", "performance_score": 6.5}, # H16: termination before hire
202
+ {"emp_id": "EMP-021", "name": "Laura Wilson", "email": "laura.w@company.com", "department": "Sales", "hire_date": "2022-11-01", "termination_date": "", "salary": 74000, "manager_id": "EMP-004", "performance_score": 7.1},
203
+ {"emp_id": "EMP-022", "name": "Mark Thompson", "email": "mark.t@company.com", "department": "Engineering", "hire_date": "2019-05-15", "termination_date": "", "salary": 500, "manager_id": "EMP-001", "performance_score": 8.9}, # H6: salary too low (missing zeros)
204
+ {"emp_id": "EMP-023", "name": "Patricia Moore", "email": "patricia.m@company.com", "department": "HR", "hire_date": "2023-08-01", "termination_date": "", "salary": 70000, "manager_id": "EMP-005", "performance_score": 6.2},
205
+ {"emp_id": "EMP-024", "name": "Steven Harris", "email": "steven.h@company.com", "department": "Marketing", "hire_date": "2021-12-10", "termination_date": "", "salary": 77000, "manager_id": "EMP-002", "performance_score": 7.9},
206
+ {"emp_id": "EMP-025", "name": "Angela Martin", "email": "angela.m@company.com", "department": "Finance", "hire_date": "2020-02-20", "termination_date": "", "salary": 92000, "manager_id": "EMP-007", "performance_score": -2.0}, # H10: negative score
207
+ {"emp_id": "EMP-026", "name": "Brian Lewis", "email": "brian.l@company.com", "department": "Operations", "hire_date": "2022-04-01", "termination_date": "", "salary": 81000, "manager_id": "EMP-010", "performance_score": 7.4},
208
+ {"emp_id": "EMP-027", "name": "Michelle Walker", "email": "michelle.w@company.com", "department": "Sales", "hire_date": "2023-10-15", "termination_date": "", "salary": 69000, "manager_id": "EMP-004", "performance_score": 6.7},
209
+ {"emp_id": "EMP-028", "name": "Paul Robinson", "email": "paul.r@company.com", "department": "marketing", "hire_date": "2021-06-20", "termination_date": "", "salary": 76000, "manager_id": "EMP-002", "performance_score": 7.7}, # H12: lowercase department
210
+ {"emp_id": "EMP-029", "name": "Sandra Hall", "email": "sandra.h@company.com", "department": "Engineering", "hire_date": "2020-08-10", "termination_date": "", "salary": 94000, "manager_id": "EMP-001", "performance_score": 8.6},
211
+ {"emp_id": "EMP-030", "name": "Bob Smith", "email": "bob smith@company.com", "department": "Finance", "hire_date": "2022-12-01", "termination_date": "", "salary": 86000, "manager_id": "EMP-007", "performance_score": 7.0}, # H8: space in email
212
+ {"emp_id": "EMP-031", "name": "Diana Scott", "email": "diana.s@company.com", "department": "HR", "hire_date": "2023-03-15", "termination_date": "", "salary": 72000, "manager_id": "EMP-005", "performance_score": 6.9},
213
+ {"emp_id": "EMP-032", "name": "George Adams", "email": "george.a@company.com", "department": "Operations", "hire_date": "2021-09-01", "termination_date": "", "salary": 83000, "manager_id": "EMP-010", "performance_score": 7.8},
214
+ {"emp_id": "EMP-033", "name": "Kevin Taylor", "email": "kevin.t@company.com", "department": "Engineering", "hire_date": "2021-08-20", "termination_date": "", "salary": 93000, "manager_id": "EMP-001", "performance_score": 8.8}, # H15: semantic dup of row 8 (same name+email, diff score)
215
+ {"emp_id": "EMP-034", "name": "Helen King", "email": "helen.k@company.com", "department": "Sales", "hire_date": "2022-07-10", "termination_date": "", "salary": 71000, "manager_id": "EMP-004", "performance_score": 7.2},
216
+ {"emp_id": "EMP-035", "name": "Richard Wright", "email": "richard.w@company.com", "department": "Human Resources", "hire_date": "2020-11-20", "termination_date": "", "salary": 80000, "manager_id": "EMP-005", "performance_score": 8.0}, # H13: non-canonical dept
217
+ {"emp_id": "EMP-036", "name": "Nancy Lopez", "email": "nancy.l@company.com", "department": "Engineering", "hire_date": "2023-05-01", "termination_date": "", "salary": 87000, "manager_id": "EMP-001", "performance_score": 7.3},
218
+ {"emp_id": "EMP-037", "name": "Carl Hill", "email": "carl.h@company.com", "department": "Marketing", "hire_date": "2021-02-15", "termination_date": "", "salary": 74000, "manager_id": "EMP-002", "performance_score": 7.5},
219
+ {"emp_id": "EMP-038", "name": "Betty Young", "email": "betty.y@company.com", "department": "Finance", "hire_date": "2025-13-01", "termination_date": "", "salary": 88000, "manager_id": "EMP-007", "performance_score": 8.2}, # H18: invalid date (month 13)
220
+ {"emp_id": "EMP-039", "name": "Frank Allen", "email": "frank.a@company.com", "department": "Operations", "hire_date": "2022-01-20", "termination_date": "", "salary": 82000, "manager_id": "EMP-010", "performance_score": 7.6},
221
+ ]
222
+
223
+ _VALID_DEPARTMENTS: Set[str] = {"Engineering", "Marketing", "Sales", "HR", "Finance", "Operations"}
224
+
225
+ # Collect all valid emp_ids (excluding known duplicate rows 16 and 33)
226
+ _VALID_EMP_IDS: Set[str] = {
227
+ row["emp_id"]
228
+ for i, row in enumerate(_HARD_DATA)
229
+ if i not in (16, 33) # exclude duplicate rows
230
+ }
231
+
232
+ _HARD_ISSUES = [
233
+ Issue("H1", 5, "manager_id", "referential_integrity", "Manager EMP-099 does not exist in employee list",
234
+ validation_params={"valid_ids": _VALID_EMP_IDS}),
235
+ Issue("H2", 18, "manager_id", "referential_integrity", "Manager EMP-088 does not exist in employee list",
236
+ validation_params={"valid_ids": _VALID_EMP_IDS}),
237
+ Issue("H3", 7, "termination_date", "temporal_inconsistency", "Termination date 2023-01-10 is before hire date 2024-03-15"),
238
+ Issue("H4", 14, "termination_date", "temporal_inconsistency", "Termination date 2023-05-15 is before hire date 2023-06-01"),
239
+ Issue("H5", 2, "salary", "outlier", "Salary 15,000,000 is unreasonably high (expected $20K-$500K)",
240
+ validation_params={"low": 20000, "high": 500000}),
241
+ Issue("H6", 22, "salary", "outlier", "Salary 500 is unreasonably low (expected $20K-$500K)",
242
+ validation_params={"low": 20000, "high": 500000}),
243
+ Issue("H7", 10, "email", "invalid_email", "Email 'alice.jones@' is missing domain"),
244
+ Issue("H8", 30, "email", "invalid_email", "Email 'bob smith@company.com' contains a space"),
245
+ Issue("H9", 8, "performance_score", "score_out_of_range", "Score 11.5 exceeds the 0-10 scale",
246
+ validation_params={"low": 0.0, "high": 10.0}),
247
+ Issue("H10", 25, "performance_score", "score_out_of_range", "Score -2.0 is negative (scale is 0-10)",
248
+ validation_params={"low": 0.0, "high": 10.0}),
249
+ Issue("H11", 12, "department", "inconsistent_format", "Department 'Engg' should match canonical name",
250
+ validation_params={"canonical_set": _VALID_DEPARTMENTS}),
251
+ Issue("H12", 28, "department", "inconsistent_format", "Department 'marketing' has incorrect casing",
252
+ validation_params={"canonical_set": _VALID_DEPARTMENTS}),
253
+ Issue("H13", 35, "department", "inconsistent_format", "Department 'Human Resources' should be canonical 'HR'",
254
+ validation_params={"canonical_set": _VALID_DEPARTMENTS}),
255
+ Issue(
256
+ "H14", 16, "", "duplicate_row", "Semantic duplicate of row 2 (same emp_id EMP-003, slightly different name)",
257
+ original_row_data={"emp_id": "EMP-003", "name": "Maria R. Garcia", "email": "maria.g@company.com", "department": "Engineering", "hire_date": "2021-01-10", "termination_date": "", "salary": 15000000, "manager_id": "EMP-001", "performance_score": 9.1},
258
+ ),
259
+ Issue(
260
+ "H15", 33, "", "duplicate_row", "Semantic duplicate of row 8 (same name and email as Kevin Taylor)",
261
+ original_row_data={"emp_id": "EMP-033", "name": "Kevin Taylor", "email": "kevin.t@company.com", "department": "Engineering", "hire_date": "2021-08-20", "termination_date": "", "salary": 93000, "manager_id": "EMP-001", "performance_score": 8.8},
262
+ ),
263
+ Issue("H16", 20, "termination_date", "temporal_inconsistency",
264
+ "Termination date 2024-06-30 is before hire date 2024-11-15"),
265
+ Issue("H17", 6, "name", "excess_whitespace", "Name ' Robert Williams ' has excess whitespace"),
266
+ Issue("H18", 38, "hire_date", "invalid_date", "Date '2025-13-01' has invalid month 13"),
267
+ ]
268
+
269
+ HARD_TASK = TaskDefinition(
270
+ task_id="employee_records",
271
+ difficulty="hard",
272
+ description=(
273
+ "You are cleaning employee records for an HR system migration. Requirements: "
274
+ "emp_id must be unique, emails must be valid (user@domain.tld, no spaces), "
275
+ "departments must be one of: Engineering, Marketing, Sales, HR, Finance, Operations. "
276
+ "Dates must be valid YYYY-MM-DD. Termination dates must be after hire dates. "
277
+ "Salaries must be in range $20,000-$500,000. Performance scores must be 0.0-10.0. "
278
+ "Manager IDs must reference existing employees. Names must have no excess whitespace. "
279
+ "Remove duplicate employee entries."
280
+ ),
281
+ columns=["emp_id", "name", "email", "department", "hire_date", "termination_date", "salary", "manager_id", "performance_score"],
282
+ data=_HARD_DATA,
283
+ issues=_HARD_ISSUES,
284
+ max_steps=35,
285
+ column_descriptions={
286
+ "emp_id": "Unique employee ID (format: EMP-XXX)",
287
+ "name": "Full name (no excess whitespace)",
288
+ "email": "Email (valid user@domain.tld, no spaces)",
289
+ "department": "Department (must be: Engineering, Marketing, Sales, HR, Finance, or Operations)",
290
+ "hire_date": "Hire date (valid YYYY-MM-DD)",
291
+ "termination_date": "Termination date (empty if active, must be after hire_date if set)",
292
+ "salary": "Annual salary USD (range: $20,000-$500,000)",
293
+ "manager_id": "Manager's emp_id (must reference existing employee, empty for top-level)",
294
+ "performance_score": "Performance score (0.0 to 10.0)",
295
+ },
296
+ )
297
+
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Task registry
301
+ # ---------------------------------------------------------------------------
302
+ ALL_TASKS: Dict[str, TaskDefinition] = {
303
+ "customer_contacts": EASY_TASK,
304
+ "sales_records": MEDIUM_TASK,
305
+ "employee_records": HARD_TASK,
306
+ }
307
+
308
+
309
+ def get_task(task_id: str) -> TaskDefinition:
310
+ """Get a deep copy of a task definition."""
311
+ if task_id not in ALL_TASKS:
312
+ raise ValueError(
313
+ f"Unknown task_id '{task_id}'. Available: {list(ALL_TASKS.keys())}"
314
+ )
315
+ return copy.deepcopy(ALL_TASKS[task_id])