dataforge-07 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. dataforge/__init__.py +204 -0
  2. dataforge/__main__.py +5 -0
  3. dataforge/agent/__init__.py +16 -0
  4. dataforge/agent/providers.py +259 -0
  5. dataforge/agent/scratchpad.py +183 -0
  6. dataforge/agent/tool_actions.py +343 -0
  7. dataforge/bench/__init__.py +31 -0
  8. dataforge/bench/core.py +426 -0
  9. dataforge/bench/groq_client.py +386 -0
  10. dataforge/bench/methods.py +443 -0
  11. dataforge/bench/report.py +309 -0
  12. dataforge/bench/runner.py +247 -0
  13. dataforge/causal/__init__.py +21 -0
  14. dataforge/causal/dag.py +174 -0
  15. dataforge/causal/pc.py +232 -0
  16. dataforge/causal/root_cause.py +193 -0
  17. dataforge/cli/__init__.py +50 -0
  18. dataforge/cli/audit.py +70 -0
  19. dataforge/cli/bench.py +154 -0
  20. dataforge/cli/common.py +267 -0
  21. dataforge/cli/constraints.py +407 -0
  22. dataforge/cli/profile.py +147 -0
  23. dataforge/cli/release.py +166 -0
  24. dataforge/cli/repair.py +407 -0
  25. dataforge/cli/revert.py +139 -0
  26. dataforge/cli/watch.py +144 -0
  27. dataforge/datasets/__init__.py +25 -0
  28. dataforge/datasets/embedded/hospital/clean.csv +11 -0
  29. dataforge/datasets/embedded/hospital/dirty.csv +11 -0
  30. dataforge/datasets/real_world.py +290 -0
  31. dataforge/datasets/registry.py +103 -0
  32. dataforge/detectors/__init__.py +80 -0
  33. dataforge/detectors/base.py +145 -0
  34. dataforge/detectors/decimal_shift.py +166 -0
  35. dataforge/detectors/fd_violation.py +157 -0
  36. dataforge/detectors/type_mismatch.py +173 -0
  37. dataforge/engine/__init__.py +39 -0
  38. dataforge/engine/repair.py +905 -0
  39. dataforge/env/__init__.py +22 -0
  40. dataforge/env/environment.py +883 -0
  41. dataforge/env/observation.py +61 -0
  42. dataforge/env/openenv_core.py +161 -0
  43. dataforge/env/reward.py +128 -0
  44. dataforge/env/server.py +176 -0
  45. dataforge/evaluation_contract.py +76 -0
  46. dataforge/fixtures/hospital_10rows.csv +11 -0
  47. dataforge/fixtures/hospital_schema.yaml +17 -0
  48. dataforge/http/__init__.py +1 -0
  49. dataforge/http/problem.py +103 -0
  50. dataforge/integrations/__init__.py +1 -0
  51. dataforge/integrations/dbt.py +164 -0
  52. dataforge/observability.py +76 -0
  53. dataforge/py.typed +1 -0
  54. dataforge/release/__init__.py +1 -0
  55. dataforge/release/doctor.py +367 -0
  56. dataforge/release/full_vision.py +702 -0
  57. dataforge/release/gate.py +861 -0
  58. dataforge/release/playground_check.py +411 -0
  59. dataforge/repair_contract.py +468 -0
  60. dataforge/repairers/__init__.py +88 -0
  61. dataforge/repairers/base.py +77 -0
  62. dataforge/repairers/decimal_shift.py +43 -0
  63. dataforge/repairers/fd_violation.py +225 -0
  64. dataforge/repairers/type_mismatch.py +73 -0
  65. dataforge/safety/__init__.py +5 -0
  66. dataforge/safety/adversarial/attack_01_phone_pii.yaml +8 -0
  67. dataforge/safety/adversarial/attack_02_phone_pii.yaml +8 -0
  68. dataforge/safety/adversarial/attack_03_phone_pii.yaml +8 -0
  69. dataforge/safety/adversarial/attack_04_phone_pii.yaml +8 -0
  70. dataforge/safety/adversarial/attack_05_phone_pii.yaml +8 -0
  71. dataforge/safety/adversarial/attack_06_phone_pii.yaml +8 -0
  72. dataforge/safety/adversarial/attack_07_phone_pii.yaml +8 -0
  73. dataforge/safety/adversarial/attack_08_phone_pii.yaml +8 -0
  74. dataforge/safety/adversarial/attack_09_phone_pii.yaml +8 -0
  75. dataforge/safety/adversarial/attack_10_phone_pii.yaml +8 -0
  76. dataforge/safety/adversarial/attack_11_ssn_pii.yaml +8 -0
  77. dataforge/safety/adversarial/attack_12_ssn_pii.yaml +8 -0
  78. dataforge/safety/adversarial/attack_13_ssn_pii.yaml +8 -0
  79. dataforge/safety/adversarial/attack_14_ssn_pii.yaml +8 -0
  80. dataforge/safety/adversarial/attack_15_ssn_pii.yaml +8 -0
  81. dataforge/safety/adversarial/attack_16_ssn_pii.yaml +8 -0
  82. dataforge/safety/adversarial/attack_17_ssn_pii.yaml +8 -0
  83. dataforge/safety/adversarial/attack_18_ssn_pii.yaml +8 -0
  84. dataforge/safety/adversarial/attack_19_ssn_pii.yaml +8 -0
  85. dataforge/safety/adversarial/attack_20_ssn_pii.yaml +8 -0
  86. dataforge/safety/adversarial/attack_21_email_pii.yaml +8 -0
  87. dataforge/safety/adversarial/attack_22_email_pii.yaml +8 -0
  88. dataforge/safety/adversarial/attack_23_email_pii.yaml +8 -0
  89. dataforge/safety/adversarial/attack_24_email_pii.yaml +8 -0
  90. dataforge/safety/adversarial/attack_25_email_pii.yaml +8 -0
  91. dataforge/safety/adversarial/attack_26_email_pii.yaml +8 -0
  92. dataforge/safety/adversarial/attack_27_email_pii.yaml +8 -0
  93. dataforge/safety/adversarial/attack_28_email_pii.yaml +8 -0
  94. dataforge/safety/adversarial/attack_29_email_pii.yaml +8 -0
  95. dataforge/safety/adversarial/attack_30_email_pii.yaml +8 -0
  96. dataforge/safety/adversarial/attack_31_row_delete.yaml +7 -0
  97. dataforge/safety/adversarial/attack_32_row_delete.yaml +8 -0
  98. dataforge/safety/adversarial/attack_33_row_delete.yaml +7 -0
  99. dataforge/safety/adversarial/attack_34_row_delete.yaml +7 -0
  100. dataforge/safety/adversarial/attack_35_row_delete.yaml +7 -0
  101. dataforge/safety/adversarial/attack_36_row_delete.yaml +11 -0
  102. dataforge/safety/adversarial/attack_37_row_delete.yaml +7 -0
  103. dataforge/safety/adversarial/attack_38_row_delete.yaml +7 -0
  104. dataforge/safety/adversarial/attack_39_row_delete.yaml +8 -0
  105. dataforge/safety/adversarial/attack_40_row_delete.yaml +7 -0
  106. dataforge/safety/adversarial/attack_41_row_delete.yaml +7 -0
  107. dataforge/safety/adversarial/attack_42_row_delete.yaml +7 -0
  108. dataforge/safety/adversarial/attack_43_row_delete.yaml +7 -0
  109. dataforge/safety/adversarial/attack_44_row_delete.yaml +7 -0
  110. dataforge/safety/adversarial/attack_45_row_delete.yaml +8 -0
  111. dataforge/safety/adversarial/attack_46_row_delete.yaml +8 -0
  112. dataforge/safety/adversarial/attack_47_row_delete.yaml +7 -0
  113. dataforge/safety/adversarial/attack_48_row_delete.yaml +7 -0
  114. dataforge/safety/adversarial/attack_49_row_delete.yaml +8 -0
  115. dataforge/safety/adversarial/attack_50_row_delete.yaml +7 -0
  116. dataforge/safety/constitution.py +307 -0
  117. dataforge/safety/constitutions/default.yaml +40 -0
  118. dataforge/safety/filter.py +134 -0
  119. dataforge/schema_inference.py +620 -0
  120. dataforge/stores/__init__.py +46 -0
  121. dataforge/stores/base.py +73 -0
  122. dataforge/stores/cloud.py +78 -0
  123. dataforge/stores/csv.py +94 -0
  124. dataforge/stores/duckdb.py +313 -0
  125. dataforge/stores/patch_plan.py +178 -0
  126. dataforge/stores/registry.py +82 -0
  127. dataforge/stores/repair.py +121 -0
  128. dataforge/stores/revert.py +22 -0
  129. dataforge/stores/sql.py +27 -0
  130. dataforge/table.py +228 -0
  131. dataforge/transactions/__init__.py +34 -0
  132. dataforge/transactions/files.py +96 -0
  133. dataforge/transactions/log.py +613 -0
  134. dataforge/transactions/revert.py +102 -0
  135. dataforge/transactions/txn.py +104 -0
  136. dataforge/ui/__init__.py +1 -0
  137. dataforge/ui/profile_view.py +136 -0
  138. dataforge/ui/repair_diff.py +91 -0
  139. dataforge/verifier/__init__.py +55 -0
  140. dataforge/verifier/constraint_ir.py +155 -0
  141. dataforge/verifier/explain.py +47 -0
  142. dataforge/verifier/gate.py +5 -0
  143. dataforge/verifier/schema.py +111 -0
  144. dataforge/verifier/smt.py +433 -0
  145. dataforge_07-0.1.0.dist-info/METADATA +436 -0
  146. dataforge_07-0.1.0.dist-info/RECORD +150 -0
  147. dataforge_07-0.1.0.dist-info/WHEEL +5 -0
  148. dataforge_07-0.1.0.dist-info/entry_points.txt +3 -0
  149. dataforge_07-0.1.0.dist-info/licenses/LICENSE +176 -0
  150. dataforge_07-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,883 @@
1
+ """OpenEnv-compatible DataForge RL environment.
2
+
3
+ Core environment implementing reset/step/state/close for data-quality
4
+ detection, diagnosis, and repair with typed tool-use actions.
5
+
6
+ No LLM calls. No disk writes. Dataset state is in-memory per episode.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import random
13
+ import re
14
+ import uuid
15
+ from difflib import SequenceMatcher
16
+ from pathlib import Path
17
+ from typing import Any, cast
18
+
19
+ import duckdb
20
+ import pandas as pd
21
+ import sqlglot
22
+ import sqlglot.expressions as sqlglot_exp
23
+ from pydantic import BaseModel, Field
24
+
25
+ from dataforge.agent.scratchpad import Scratchpad
26
+ from dataforge.agent.tool_actions import (
27
+ Action,
28
+ Diagnose,
29
+ Fix,
30
+ Hypothesis,
31
+ InspectRows,
32
+ PatternMatch,
33
+ RootCause,
34
+ SqlQuery,
35
+ StatTest,
36
+ parse_action,
37
+ )
38
+ from dataforge.cli.common import load_schema
39
+ from dataforge.detectors import run_all_detectors
40
+ from dataforge.detectors.base import Issue, Schema
41
+ from dataforge.env.observation import DataForgeObservation, ToolResult
42
+ from dataforge.env.reward import (
43
+ P_FALSE_POS,
44
+ P_INVALID,
45
+ P_WRONG_FIX,
46
+ R_EXPLORE,
47
+ R_ROOT_CAUSE,
48
+ EpisodeMetrics,
49
+ RewardEngine,
50
+ )
51
+
52
+ logger = logging.getLogger("dataforge.env")
53
+
54
+ __all__ = [
55
+ "DataForgeEnv",
56
+ "EnvState",
57
+ "ResetResult",
58
+ "StepResult",
59
+ ]
60
+
61
+ _FIXTURES_DIR = Path(__file__).resolve().parents[1].parent / "fixtures"
62
+ _DEFAULT_CSV = _FIXTURES_DIR / "hospital_10rows.csv"
63
+ _DEFAULT_SCHEMA = _FIXTURES_DIR / "hospital_schema.yaml"
64
+ _MAX_STEPS = 30
65
+ _MAX_RESULT_ROWS = 20
66
+ _TOOL_HISTORY_LIMIT = 5
67
+ _NOISE_EPSILON = 0.15
68
+ _BLOCKED_SQL_FRAGMENTS = (
69
+ "attach",
70
+ "call ",
71
+ "copy ",
72
+ "detach",
73
+ "duckdb_extensions",
74
+ "filename",
75
+ "from_csv_auto",
76
+ "glob(",
77
+ "http://",
78
+ "https://",
79
+ "httpfs",
80
+ "install",
81
+ "load ",
82
+ "mysql_scan",
83
+ "parquet_scan",
84
+ "postgres_scan",
85
+ "pragma",
86
+ "read_csv",
87
+ "read_json",
88
+ "read_parquet",
89
+ "s3://",
90
+ "sqlite_scan",
91
+ )
92
+
93
+
94
+ # ═══════════════════════════════════════════════════════════════════════════
95
+ # Result models
96
+ # ═══════════════════════════════════════════════════════════════════════════
97
+
98
+
99
+ class ResetResult(BaseModel):
100
+ """Result of env.reset()."""
101
+
102
+ observation: DataForgeObservation
103
+ info: dict[str, Any] = Field(default_factory=dict)
104
+
105
+
106
+ class StepResult(BaseModel):
107
+ """Result of env.step()."""
108
+
109
+ observation: DataForgeObservation
110
+ reward: float = 0.0
111
+ done: bool = False
112
+ info: dict[str, Any] = Field(default_factory=dict)
113
+
114
+
115
+ class EnvState(BaseModel):
116
+ """Internal environment state snapshot."""
117
+
118
+ episode_id: str = ""
119
+ step_count: int = 0
120
+ task_id: str = ""
121
+ issues_detected: int = 0
122
+ issues_fixed: int = 0
123
+ false_positives: int = 0
124
+ total_issues: int = 0
125
+ is_done: bool = False
126
+
127
+
128
+ # ═══════════════════════════════════════════════════════════════════════════
129
+ # Environment
130
+ # ═══════════════════════════════════════════════════════════════════════════
131
+
132
+
133
+ class DataForgeEnv:
134
+ """OpenEnv-compatible RL environment for data quality repair.
135
+
136
+ Core API: ``reset()``, ``step()``, ``state()``, ``close()`` (no-op).
137
+
138
+ Example::
139
+
140
+ >>> env = DataForgeEnv()
141
+ >>> result = env.reset(seed=42)
142
+ >>> result.observation.done
143
+ False
144
+ """
145
+
146
+ def __init__(self, max_steps: int = _MAX_STEPS) -> None:
147
+ self._max_steps = max_steps
148
+ self._episode_id = ""
149
+ self._step_count = 0
150
+ self._df: pd.DataFrame = pd.DataFrame()
151
+ self._ground_truth: list[Issue] = []
152
+ self._found_issues: list[dict[str, Any]] = []
153
+ self._fixed_issues: list[dict[str, Any]] = []
154
+ self._false_positives = 0
155
+ self._cumulative_reward = 0.0
156
+ self._is_done = False
157
+ self._inspected_rows: set[int] = set()
158
+ self._noisy = False
159
+ self._noise_rng: random.Random | None = None
160
+ self._scratchpad = Scratchpad()
161
+ self._tool_history: list[ToolResult] = []
162
+ self._reward_engine = RewardEngine()
163
+ self._schema_info: dict[str, str] = {}
164
+ self._schema: Schema | None = None
165
+ self._causal_dag_cache: Any = None
166
+ self._root_cause_labels: set[int] = set()
167
+
168
+ # ── Core API ──────────────────────────────────────────────────────────
169
+
170
+ def reset(self, seed: int | None = None, *, noisy: bool = False) -> ResetResult:
171
+ """Reset the environment for a new episode.
172
+
173
+ Args:
174
+ seed: Optional RNG seed for deterministic episodes.
175
+ noisy: If True, enable observation noise (epsilon=0.15).
176
+
177
+ Returns:
178
+ ResetResult with initial observation.
179
+ """
180
+ self._episode_id = str(uuid.uuid4())
181
+ self._step_count = 0
182
+ self._found_issues = []
183
+ self._fixed_issues = []
184
+ self._false_positives = 0
185
+ self._cumulative_reward = 0.0
186
+ self._is_done = False
187
+ self._inspected_rows = set()
188
+ self._scratchpad.reset()
189
+ self._tool_history = []
190
+ self._causal_dag_cache = None
191
+ self._root_cause_labels = set()
192
+ self._noisy = noisy
193
+ self._noise_rng = random.Random(seed if seed is not None else 0) if noisy else None
194
+
195
+ # Load fixture dataset
196
+ self._df = pd.read_csv(_DEFAULT_CSV, dtype=str)
197
+ self._schema_info = dict.fromkeys(self._df.columns, "str")
198
+ self._schema = None
199
+ if _DEFAULT_SCHEMA.exists():
200
+ self._schema = load_schema(_DEFAULT_SCHEMA)
201
+ self._schema_info = dict(self._schema.columns)
202
+
203
+ # Run detectors for hidden ground truth
204
+ self._ground_truth = run_all_detectors(self._df, self._schema)
205
+ logger.info(
206
+ "Episode %s: %d rows, %d ground-truth issues",
207
+ self._episode_id[:8],
208
+ len(self._df),
209
+ len(self._ground_truth),
210
+ )
211
+
212
+ # Initial observation with first 5 rows
213
+ initial_rows = cast(list[dict[str, Any]], self._df.head(5).to_dict(orient="records"))
214
+ obs = DataForgeObservation(
215
+ visible_rows=initial_rows,
216
+ step_budget_remaining=self._max_steps,
217
+ scratchpad_summary=self._scratchpad.summary(),
218
+ metadata={
219
+ "episode_id": self._episode_id,
220
+ "total_rows": len(self._df),
221
+ "total_columns": len(self._df.columns),
222
+ "schema": self._schema_info,
223
+ },
224
+ )
225
+ return ResetResult(observation=obs, info={"episode_id": self._episode_id})
226
+
227
+ def step(self, action: Action | dict[str, Any]) -> StepResult:
228
+ """Execute one agent action and return the result.
229
+
230
+ Args:
231
+ action: A typed Action model or raw dict to be parsed.
232
+
233
+ Returns:
234
+ StepResult with observation, reward, and done flag.
235
+ """
236
+ if self._is_done:
237
+ return self._terminal_result(0.0)
238
+
239
+ self._step_count += 1
240
+
241
+ # Parse if raw dict
242
+ if isinstance(action, dict):
243
+ try:
244
+ action = parse_action(action)
245
+ except Exception as exc:
246
+ return self._error_step(str(exc))
247
+
248
+ # Dispatch
249
+ try:
250
+ tool_result, reward = self._dispatch(action)
251
+ except Exception as exc:
252
+ logger.exception("Action dispatch error at step %d", self._step_count)
253
+ return self._error_step(str(exc))
254
+
255
+ # Late-step penalty
256
+ reward += self._reward_engine.compute_late_penalty(self._step_count, self._max_steps)
257
+
258
+ # Accumulate
259
+ self._cumulative_reward += reward
260
+
261
+ # Record in history
262
+ self._tool_history.append(tool_result)
263
+ if len(self._tool_history) > _TOOL_HISTORY_LIMIT:
264
+ self._tool_history = self._tool_history[-_TOOL_HISTORY_LIMIT:]
265
+
266
+ # Check termination
267
+ done = self._step_count >= self._max_steps
268
+ if done:
269
+ self._is_done = True
270
+ terminal = self._compute_terminal()
271
+ self._cumulative_reward = max(self._cumulative_reward, terminal)
272
+
273
+ obs = DataForgeObservation(
274
+ visible_rows=tool_result.data
275
+ if tool_result.action_type == "INSPECT_ROWS" and tool_result.success
276
+ else None,
277
+ scratchpad_summary=self._scratchpad.summary(),
278
+ step_budget_remaining=max(0, self._max_steps - self._step_count),
279
+ tool_usage_history=list(self._tool_history),
280
+ latest_result=tool_result,
281
+ done=done,
282
+ reward=reward,
283
+ cumulative_reward=self._cumulative_reward,
284
+ )
285
+ return StepResult(observation=obs, reward=reward, done=done)
286
+
287
+ def state(self) -> EnvState:
288
+ """Return current internal state snapshot."""
289
+ return EnvState(
290
+ episode_id=self._episode_id,
291
+ step_count=self._step_count,
292
+ issues_detected=len(self._found_issues),
293
+ issues_fixed=len(self._fixed_issues),
294
+ false_positives=self._false_positives,
295
+ total_issues=len(self._ground_truth),
296
+ is_done=self._is_done,
297
+ )
298
+
299
+ def close(self) -> None:
300
+ """No-op. Retained for OpenEnv container compatibility."""
301
+
302
+ # ── Dispatch ──────────────────────────────────────────────────────────
303
+
304
+ def _dispatch(self, action: Action) -> tuple[ToolResult, float]:
305
+ """Route action to handler. Returns (tool_result, step_reward)."""
306
+ if isinstance(action, InspectRows):
307
+ return self._handle_inspect(action)
308
+ if isinstance(action, SqlQuery):
309
+ return self._handle_sql(action)
310
+ if isinstance(action, StatTest):
311
+ return self._handle_stat(action)
312
+ if isinstance(action, PatternMatch):
313
+ return self._handle_pattern(action)
314
+ if isinstance(action, Hypothesis):
315
+ return self._handle_hypothesis(action)
316
+ if isinstance(action, RootCause):
317
+ return self._handle_root_cause(action)
318
+ if isinstance(action, Diagnose):
319
+ return self._handle_diagnose(action)
320
+ if isinstance(action, Fix):
321
+ return self._handle_fix(action)
322
+ return ToolResult(
323
+ action_type="UNKNOWN",
324
+ success=False,
325
+ error={"verdict": "error", "reason": "Unknown action type"},
326
+ ), P_INVALID
327
+
328
+ # ── Action handlers ───────────────────────────────────────────────────
329
+
330
+ def _handle_inspect(self, action: InspectRows) -> tuple[ToolResult, float]:
331
+ """Handle INSPECT_ROWS: return dataset rows."""
332
+ valid_indices = [i for i in action.row_indices if 0 <= i < len(self._df)]
333
+ if not valid_indices:
334
+ return ToolResult(
335
+ action_type="INSPECT_ROWS",
336
+ success=False,
337
+ error={"verdict": "error", "reason": "No valid row indices"},
338
+ ), P_INVALID
339
+
340
+ # Apply 20-row cap
341
+ valid_indices = valid_indices[:20]
342
+ rows = self._df.iloc[valid_indices]
343
+ if action.column_names:
344
+ valid_cols = [c for c in action.column_names if c in self._df.columns]
345
+ if valid_cols:
346
+ rows = rows[valid_cols]
347
+
348
+ row_dicts = cast(list[dict[str, Any]], rows.to_dict(orient="records"))
349
+ for i, idx in enumerate(valid_indices[: len(row_dicts)]):
350
+ row_dicts[i]["_row_index"] = idx
351
+
352
+ # Noise injection
353
+ if self._noisy and self._noise_rng:
354
+ row_dicts = self._inject_noise(row_dicts)
355
+
356
+ # Exploration bonus
357
+ new_indices = set(valid_indices) - self._inspected_rows
358
+ self._inspected_rows.update(valid_indices)
359
+ gt_rows = {issue.row for issue in self._ground_truth}
360
+ found_rows = {f["row"] for f in self._found_issues}
361
+ bonus = self._reward_engine.compute_exploration_bonus(
362
+ new_indices,
363
+ self._inspected_rows,
364
+ len(self._df),
365
+ gt_rows,
366
+ found_rows,
367
+ )
368
+ return ToolResult(action_type="INSPECT_ROWS", success=True, data=row_dicts), bonus
369
+
370
+ def _handle_sql(self, action: SqlQuery) -> tuple[ToolResult, float]:
371
+ """Handle SQL_QUERY: execute read-only SQL via DuckDB."""
372
+ # Validate read-only
373
+ try:
374
+ parsed = [stmt for stmt in sqlglot.parse(action.query) if stmt is not None]
375
+ except sqlglot.errors.ParseError as exc:
376
+ return ToolResult(
377
+ action_type="SQL_QUERY",
378
+ success=False,
379
+ error={
380
+ "verdict": "error",
381
+ "reason": str(exc),
382
+ "suggested_constraint": "Use valid SQL syntax",
383
+ },
384
+ ), P_INVALID
385
+
386
+ if len(parsed) != 1:
387
+ return ToolResult(
388
+ action_type="SQL_QUERY",
389
+ success=False,
390
+ error={
391
+ "verdict": "rejected",
392
+ "reason": "Exactly one SELECT statement is allowed.",
393
+ "suggested_constraint": "Use a single read-only SELECT statement.",
394
+ },
395
+ ), P_INVALID
396
+
397
+ normalized_query = f" {action.query.lower()} "
398
+ blocked = next(
399
+ (fragment for fragment in _BLOCKED_SQL_FRAGMENTS if fragment in normalized_query),
400
+ None,
401
+ )
402
+ if blocked is not None:
403
+ return ToolResult(
404
+ action_type="SQL_QUERY",
405
+ success=False,
406
+ error={
407
+ "verdict": "rejected",
408
+ "reason": "SQL_QUERY may only read from the registered data relation.",
409
+ "suggested_constraint": "Query the in-memory data table without file, network, extension, or table functions.",
410
+ },
411
+ ), P_INVALID
412
+
413
+ for stmt in parsed:
414
+ if stmt.key not in ("select",):
415
+ return ToolResult(
416
+ action_type="SQL_QUERY",
417
+ success=False,
418
+ error={
419
+ "verdict": "rejected",
420
+ "reason": f"Only SELECT queries allowed, got {stmt.key.upper()}",
421
+ "suggested_constraint": "Use SELECT statements only",
422
+ },
423
+ ), P_INVALID
424
+
425
+ for table in stmt.find_all(sqlglot_exp.Table):
426
+ if table.name.lower() != "data":
427
+ return ToolResult(
428
+ action_type="SQL_QUERY",
429
+ success=False,
430
+ error={
431
+ "verdict": "rejected",
432
+ "reason": (
433
+ "SQL_QUERY may only reference the registered data relation; "
434
+ f"got '{table.name}'."
435
+ ),
436
+ "suggested_constraint": "Use FROM data for tabular queries.",
437
+ },
438
+ ), P_INVALID
439
+
440
+ try:
441
+ conn = duckdb.connect(":memory:")
442
+ conn.register("data", self._df)
443
+ result_df = conn.execute(action.query).fetchdf()
444
+ conn.close()
445
+ rows = result_df.head(_MAX_RESULT_ROWS).to_dict(orient="records")
446
+ return ToolResult(action_type="SQL_QUERY", success=True, data=rows), 0.0
447
+ except duckdb.Error as exc:
448
+ return ToolResult(
449
+ action_type="SQL_QUERY",
450
+ success=False,
451
+ error={"verdict": "error", "reason": str(exc)},
452
+ ), P_INVALID
453
+
454
+ def _handle_stat(self, action: StatTest) -> tuple[ToolResult, float]:
455
+ """Handle STAT_TEST: run zscore/iqr/ks on a column."""
456
+ if action.column not in self._df.columns:
457
+ return ToolResult(
458
+ action_type="STAT_TEST",
459
+ success=False,
460
+ error={"verdict": "error", "reason": f"Column '{action.column}' not found"},
461
+ ), P_INVALID
462
+
463
+ try:
464
+ col = pd.to_numeric(self._df[action.column], errors="coerce").dropna()
465
+ if len(col) == 0:
466
+ return ToolResult(
467
+ action_type="STAT_TEST",
468
+ success=False,
469
+ error={
470
+ "verdict": "error",
471
+ "reason": f"No numeric values in column '{action.column}'",
472
+ },
473
+ ), P_INVALID
474
+ except Exception as exc:
475
+ return ToolResult(
476
+ action_type="STAT_TEST",
477
+ success=False,
478
+ error={"verdict": "error", "reason": str(exc)},
479
+ ), P_INVALID
480
+
481
+ from scipy import stats as scipy_stats # type: ignore[import-untyped]
482
+
483
+ if action.test_type == "zscore":
484
+ zscores = scipy_stats.zscore(col)
485
+ threshold = action.threshold or 3.0
486
+ outliers = col.index[abs(zscores) > threshold].tolist()
487
+ data = {
488
+ "test": "zscore",
489
+ "threshold": threshold,
490
+ "outlier_indices": outliers,
491
+ "n_outliers": len(outliers),
492
+ "mean": float(col.mean()),
493
+ "std": float(col.std()),
494
+ }
495
+ elif action.test_type == "iqr":
496
+ q1, q3 = float(col.quantile(0.25)), float(col.quantile(0.75))
497
+ iqr_val = q3 - q1
498
+ factor = action.threshold or 1.5
499
+ lower, upper = q1 - factor * iqr_val, q3 + factor * iqr_val
500
+ outliers = col.index[(col < lower) | (col > upper)].tolist()
501
+ data = {
502
+ "test": "iqr",
503
+ "q1": q1,
504
+ "q3": q3,
505
+ "iqr": iqr_val,
506
+ "lower": lower,
507
+ "upper": upper,
508
+ "outlier_indices": outliers,
509
+ }
510
+ elif action.test_type == "ks":
511
+ stat_val, p_val = scipy_stats.kstest(
512
+ col, "norm", args=(float(col.mean()), float(col.std()))
513
+ )
514
+ data = {
515
+ "test": "ks",
516
+ "statistic": float(stat_val),
517
+ "p_value": float(p_val),
518
+ "normal": p_val > 0.05,
519
+ }
520
+ else:
521
+ return ToolResult(
522
+ action_type="STAT_TEST",
523
+ success=False,
524
+ error={"verdict": "error", "reason": f"Unknown test type: {action.test_type}"},
525
+ ), P_INVALID
526
+
527
+ return ToolResult(action_type="STAT_TEST", success=True, data=data), 0.0
528
+
529
+ def _handle_pattern(self, action: PatternMatch) -> tuple[ToolResult, float]:
530
+ """Handle PATTERN_MATCH: evaluate regex against column values."""
531
+ if action.column not in self._df.columns:
532
+ return ToolResult(
533
+ action_type="PATTERN_MATCH",
534
+ success=False,
535
+ error={"verdict": "error", "reason": f"Column '{action.column}' not found"},
536
+ ), P_INVALID
537
+
538
+ try:
539
+ compiled = re.compile(action.pattern)
540
+ except re.error as exc:
541
+ return ToolResult(
542
+ action_type="PATTERN_MATCH",
543
+ success=False,
544
+ error={"verdict": "error", "reason": f"Invalid regex: {exc}"},
545
+ ), P_INVALID
546
+
547
+ matches: list[dict[str, Any]] = []
548
+ for idx, val in enumerate(self._df[action.column].astype(str)):
549
+ is_match = bool(compiled.search(val))
550
+ if is_match == action.expect_match:
551
+ matches.append({"row": idx, "column": action.column, "value": val})
552
+ return ToolResult(
553
+ action_type="PATTERN_MATCH",
554
+ success=True,
555
+ data={"matches": matches[:_MAX_RESULT_ROWS], "total_matches": len(matches)},
556
+ ), 0.0
557
+
558
+ def _handle_hypothesis(self, action: Hypothesis) -> tuple[ToolResult, float]:
559
+ """Handle HYPOTHESIS: record claim and award root-cause credit."""
560
+ self._scratchpad.add_hypothesis(
561
+ action.claim,
562
+ action.affected_rows,
563
+ action.affected_columns,
564
+ action.root_cause_type,
565
+ )
566
+ # Check for root-cause match against ground truth
567
+ credit = 0.0
568
+ for issue in self._ground_truth:
569
+ if (
570
+ issue.row in action.affected_rows
571
+ and issue.column in action.affected_columns
572
+ and issue.issue_type == action.root_cause_type
573
+ ):
574
+ credit += R_EXPLORE
575
+ data = {"recorded": True, "root_cause_credit": credit}
576
+ return ToolResult(action_type="HYPOTHESIS", success=True, data=data), credit
577
+
578
+ def _handle_root_cause(self, action: RootCause) -> tuple[ToolResult, float]:
579
+ """Handle ROOT_CAUSE: analyze detected issues for minimal roots."""
580
+ if not self._found_issues:
581
+ return ToolResult(
582
+ action_type="ROOT_CAUSE",
583
+ success=False,
584
+ error={"verdict": "error", "reason": "No detected issues are available"},
585
+ ), P_INVALID
586
+
587
+ invalid = [idx for idx in action.error_indices if idx >= len(self._found_issues)]
588
+ if invalid:
589
+ return ToolResult(
590
+ action_type="ROOT_CAUSE",
591
+ success=False,
592
+ error={
593
+ "verdict": "error",
594
+ "reason": f"Detected issue indices out of range: {invalid}",
595
+ },
596
+ ), P_INVALID
597
+
598
+ from dataforge.causal.pc import discover_causal_dag
599
+ from dataforge.causal.root_cause import CausalRootCauseAnalyzer, evidence_from_issue
600
+
601
+ if self._causal_dag_cache is None:
602
+ self._causal_dag_cache = discover_causal_dag(self._df).dag
603
+
604
+ selected = [
605
+ evidence_from_issue(index, self._found_issues[index]) for index in action.error_indices
606
+ ]
607
+ result = CausalRootCauseAnalyzer(self._causal_dag_cache).analyze(selected)
608
+ data = result.model_dump(mode="json")
609
+ reward = self._root_cause_reward(set(result.root_indices))
610
+ return ToolResult(action_type="ROOT_CAUSE", success=True, data=data), reward
611
+
612
+ def _handle_diagnose(self, action: Diagnose) -> tuple[ToolResult, float]:
613
+ """Handle DIAGNOSE: score against ground truth."""
614
+ if action.row < 0 or action.row >= len(self._df):
615
+ return ToolResult(
616
+ action_type="DIAGNOSE",
617
+ success=False,
618
+ error={"verdict": "error", "reason": f"Row {action.row} out of bounds"},
619
+ ), P_INVALID
620
+ if action.column not in self._df.columns:
621
+ return ToolResult(
622
+ action_type="DIAGNOSE",
623
+ success=False,
624
+ error={"verdict": "error", "reason": f"Column '{action.column}' not found"},
625
+ ), P_INVALID
626
+
627
+ # Already reported?
628
+ for found in self._found_issues:
629
+ if found["row"] == action.row and found["column"] == action.column:
630
+ return ToolResult(
631
+ action_type="DIAGNOSE", success=True, data={"result": "already_found"}
632
+ ), 0.0
633
+
634
+ # Match ground truth
635
+ for issue in self._ground_truth:
636
+ if issue.row == action.row and issue.column == action.column:
637
+ type_match = action.issue_type == issue.issue_type
638
+ reward = self._reward_engine.diagnose_reward(type_match)
639
+ self._found_issues.append(
640
+ {"row": action.row, "column": action.column, "type": action.issue_type}
641
+ )
642
+ self._scratchpad.confirm_issue(action.row, action.column, action.issue_type)
643
+ return ToolResult(
644
+ action_type="DIAGNOSE",
645
+ success=True,
646
+ data={"result": "correct", "type_match": type_match},
647
+ ), reward
648
+
649
+ # False positive
650
+ self._false_positives += 1
651
+ return ToolResult(
652
+ action_type="DIAGNOSE", success=True, data={"result": "false_positive"}
653
+ ), P_FALSE_POS
654
+
655
+ def _root_cause_reward(self, root_indices: set[int]) -> float:
656
+ """Return root-cause bonus only when task labels are available."""
657
+ if not self._root_cause_labels:
658
+ return 0.0
659
+ return R_ROOT_CAUSE if root_indices == self._root_cause_labels else 0.0
660
+
661
+ def _handle_fix(self, action: Fix) -> tuple[ToolResult, float]:
662
+ """Handle FIX: validate through safety/SMT, then score."""
663
+ if action.row < 0 or action.row >= len(self._df):
664
+ return ToolResult(
665
+ action_type="FIX",
666
+ success=False,
667
+ error={"verdict": "error", "reason": f"Row {action.row} out of bounds"},
668
+ ), P_INVALID
669
+ if action.column not in self._df.columns:
670
+ return ToolResult(
671
+ action_type="FIX",
672
+ success=False,
673
+ error={"verdict": "error", "reason": f"Column '{action.column}' not found"},
674
+ ), P_INVALID
675
+
676
+ # Already fixed?
677
+ for fixed in self._fixed_issues:
678
+ if fixed["row"] == action.row and fixed["column"] == action.column:
679
+ return ToolResult(
680
+ action_type="FIX", success=True, data={"result": "already_fixed"}
681
+ ), 0.0
682
+
683
+ # Safety filter + SMT verifier (best-effort, no crash on import failure)
684
+ try:
685
+ safety_ok, safety_msg = self._check_safety(action)
686
+ except Exception as exc:
687
+ logger.warning("Safety pipeline failed closed: %s", exc)
688
+ safety_ok = False
689
+ safety_msg = f"Safety pipeline failed closed: {exc}"
690
+ if not safety_ok:
691
+ return ToolResult(
692
+ action_type="FIX",
693
+ success=False,
694
+ error={"verdict": "rejected", "reason": safety_msg},
695
+ ), P_INVALID
696
+
697
+ # Match ground truth
698
+ for issue in self._ground_truth:
699
+ if issue.row == action.row and issue.column == action.column:
700
+ if issue.expected is None:
701
+ return ToolResult(
702
+ action_type="FIX", success=True, data={"result": "detection_only"}
703
+ ), 0.0
704
+
705
+ # Exact match (case-insensitive)
706
+ if action.new_value.strip().lower() == str(issue.expected).lower():
707
+ reward = self._reward_engine.fix_reward(
708
+ exact=True, has_justification=bool(action.justification)
709
+ )
710
+ self._fixed_issues.append(
711
+ {"row": action.row, "column": action.column, "value": action.new_value}
712
+ )
713
+ self._auto_diagnose(action, issue)
714
+ return ToolResult(
715
+ action_type="FIX", success=True, data={"result": "correct"}
716
+ ), reward
717
+
718
+ # Partial: numeric within 1%
719
+ try:
720
+ prov = float(action.new_value.strip())
721
+ exp = float(str(issue.expected))
722
+ rel_err = abs(prov - exp) / abs(exp) if exp != 0 else abs(prov)
723
+ if rel_err < 0.01:
724
+ reward = self._reward_engine.fix_reward(
725
+ exact=False, has_justification=bool(action.justification)
726
+ )
727
+ self._fixed_issues.append(
728
+ {"row": action.row, "column": action.column, "value": action.new_value}
729
+ )
730
+ self._auto_diagnose(action, issue)
731
+ return ToolResult(
732
+ action_type="FIX", success=True, data={"result": "partial_numeric"}
733
+ ), reward
734
+ except (ValueError, TypeError):
735
+ pass
736
+
737
+ # Partial: string similarity >= 85%
738
+ sim = SequenceMatcher(
739
+ None, action.new_value.lower(), str(issue.expected).lower()
740
+ ).ratio()
741
+ if sim >= 0.85:
742
+ reward = self._reward_engine.fix_reward(
743
+ exact=False, has_justification=bool(action.justification)
744
+ )
745
+ self._fixed_issues.append(
746
+ {"row": action.row, "column": action.column, "value": action.new_value}
747
+ )
748
+ self._auto_diagnose(action, issue)
749
+ return ToolResult(
750
+ action_type="FIX", success=True, data={"result": "partial_string"}
751
+ ), reward
752
+
753
+ return ToolResult(
754
+ action_type="FIX", success=True, data={"result": "wrong_value"}
755
+ ), P_WRONG_FIX
756
+
757
+ return ToolResult(
758
+ action_type="FIX", success=True, data={"result": "no_issue_at_location"}
759
+ ), P_WRONG_FIX
760
+
761
+ # ── Helpers ────────────────────────────────────────────────────────────
762
+
763
+ def _check_safety(self, action: Fix) -> tuple[bool, str]:
764
+ """Run SafetyFilter + SMTVerifier. Returns (ok, message)."""
765
+ try:
766
+ from dataforge.repairers.base import ProposedFix
767
+ from dataforge.safety.filter import SafetyContext, SafetyFilter, SafetyVerdict
768
+ from dataforge.transactions.txn import CellFix
769
+ from dataforge.verifier.smt import SMTVerifier, VerificationVerdict
770
+
771
+ old_val = str(self._df.at[action.row, action.column])
772
+ cell_fix = CellFix(
773
+ row=action.row,
774
+ column=action.column,
775
+ old_value=old_val,
776
+ new_value=action.new_value,
777
+ detector_id="agent",
778
+ )
779
+ proposed = ProposedFix(
780
+ fix=cell_fix,
781
+ reason=action.justification,
782
+ confidence=0.8,
783
+ provenance="deterministic",
784
+ )
785
+
786
+ sf = SafetyFilter()
787
+ ctx = SafetyContext()
788
+ sr = sf.evaluate(proposed, self._schema, ctx)
789
+ if sr.verdict == SafetyVerdict.DENY:
790
+ return False, f"Safety filter denied: {sr.reason}"
791
+
792
+ verifier = SMTVerifier()
793
+ vr = verifier.verify(self._df, [proposed], self._schema)
794
+ if vr.verdict == VerificationVerdict.REJECT:
795
+ return False, f"SMT verifier rejected: {vr.reason}"
796
+ if vr.verdict == VerificationVerdict.UNKNOWN:
797
+ return False, f"SMT verifier returned unknown: {vr.reason}"
798
+
799
+ return True, "Passed safety and verification"
800
+ except ImportError as exc:
801
+ return False, f"Safety/verifier dependency unavailable: {exc}"
802
+
803
+ def _auto_diagnose(self, action: Fix, issue: Issue) -> None:
804
+ """Auto-credit diagnosis when agent fixes without diagnosing first."""
805
+ already = any(
806
+ f["row"] == action.row and f["column"] == action.column for f in self._found_issues
807
+ )
808
+ if not already:
809
+ self._found_issues.append(
810
+ {"row": action.row, "column": action.column, "type": issue.issue_type}
811
+ )
812
+
813
+ def _inject_noise(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
814
+ """Apply deterministic observation noise (epsilon=0.15)."""
815
+ if not self._noise_rng:
816
+ return rows
817
+ noisy = []
818
+ for row in rows:
819
+ row_copy = dict(row)
820
+ if self._noise_rng.random() < _NOISE_EPSILON:
821
+ cols = [k for k in row_copy if k != "_row_index"]
822
+ if cols:
823
+ col = self._noise_rng.choice(cols)
824
+ val = row_copy[col]
825
+ if isinstance(val, str) and len(val) > 3:
826
+ row_copy[col] = (
827
+ val[: -(self._noise_rng.randint(1, 3))]
828
+ if self._noise_rng.random() < 0.5
829
+ else val.swapcase()
830
+ )
831
+ noisy.append(row_copy)
832
+ return noisy
833
+
834
+ def _compute_terminal(self) -> float:
835
+ """Compute terminal score."""
836
+ fixable = [i for i in self._ground_truth if i.expected is not None]
837
+ metrics = EpisodeMetrics(
838
+ found_issues=len(self._found_issues),
839
+ total_issues=len(self._ground_truth),
840
+ fixed_issues=len(self._fixed_issues),
841
+ fixable_issues=len(fixable),
842
+ false_positives=self._false_positives,
843
+ )
844
+ return self._reward_engine.compute_terminal_score(metrics)
845
+
846
+ def _error_step(self, message: str) -> StepResult:
847
+ """Build error StepResult."""
848
+ tr = ToolResult(
849
+ action_type="ERROR", success=False, error={"verdict": "error", "reason": message}
850
+ )
851
+ self._tool_history.append(tr)
852
+ self._cumulative_reward += P_INVALID
853
+ done = self._step_count >= self._max_steps
854
+ if done:
855
+ self._is_done = True
856
+ return StepResult(
857
+ observation=DataForgeObservation(
858
+ step_budget_remaining=max(0, self._max_steps - self._step_count),
859
+ tool_usage_history=list(self._tool_history[-_TOOL_HISTORY_LIMIT:]),
860
+ latest_result=tr,
861
+ done=done,
862
+ reward=P_INVALID,
863
+ cumulative_reward=self._cumulative_reward,
864
+ scratchpad_summary=self._scratchpad.summary(),
865
+ ),
866
+ reward=P_INVALID,
867
+ done=done,
868
+ )
869
+
870
+ def _terminal_result(self, reward: float) -> StepResult:
871
+ """Build terminal StepResult for already-done episodes."""
872
+ return StepResult(
873
+ observation=DataForgeObservation(
874
+ step_budget_remaining=0,
875
+ done=True,
876
+ reward=reward,
877
+ cumulative_reward=self._cumulative_reward,
878
+ scratchpad_summary=self._scratchpad.summary(),
879
+ tool_usage_history=list(self._tool_history[-_TOOL_HISTORY_LIMIT:]),
880
+ ),
881
+ reward=reward,
882
+ done=True,
883
+ )