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,61 @@
1
+ """Observation builder for the DataForge RL environment.
2
+
3
+ Constructs agent-visible observations containing partial data views,
4
+ scratchpad summaries, tool results, and step budget information.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ __all__ = ["DataForgeObservation", "ToolResult"]
14
+
15
+
16
+ class ToolResult(BaseModel):
17
+ """Result of a single tool-use action.
18
+
19
+ Args:
20
+ action_type: The action type that produced this result.
21
+ success: Whether the action succeeded.
22
+ data: Action-specific result data (rows, stats, matches, etc.).
23
+ error: Structured error information if the action failed.
24
+ """
25
+
26
+ action_type: str
27
+ success: bool = True
28
+ data: Any = None
29
+ error: dict[str, Any] | None = None
30
+
31
+ model_config = {"frozen": True}
32
+
33
+
34
+ class DataForgeObservation(BaseModel):
35
+ """Agent-visible observation returned after each environment step.
36
+
37
+ Args:
38
+ visible_rows: Dataset rows returned by INSPECT_ROWS or reset.
39
+ detector_hints: Optional hints from detectors (partial ground truth).
40
+ scratchpad_summary: Compact summary of the agent's scratchpad.
41
+ step_budget_remaining: Steps left before auto-finalize.
42
+ tool_usage_history: Last 5 tool results for context.
43
+ latest_result: Result of the most recent action.
44
+ done: Whether the episode has ended.
45
+ reward: Step reward.
46
+ cumulative_reward: Running total reward for the episode.
47
+ metadata: Additional key-value metadata.
48
+ """
49
+
50
+ visible_rows: list[dict[str, Any]] | None = None
51
+ detector_hints: list[str] | None = None
52
+ scratchpad_summary: str = ""
53
+ step_budget_remaining: int = 0
54
+ tool_usage_history: list[ToolResult] = Field(default_factory=list)
55
+ latest_result: ToolResult | None = None
56
+ done: bool = False
57
+ reward: float = 0.0
58
+ cumulative_reward: float = 0.0
59
+ metadata: dict[str, Any] = Field(default_factory=dict)
60
+
61
+ model_config = {"frozen": True}
@@ -0,0 +1,161 @@
1
+ """OpenEnv-core adapter for the DataForge RL environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+ from dataforge.env.environment import DataForgeEnv
10
+
11
+ if TYPE_CHECKING:
12
+
13
+ class OpenEnvAction(BaseModel):
14
+ """Typed stand-in for openenv-core's action model."""
15
+
16
+ metadata: dict[str, Any] = Field(default_factory=dict)
17
+
18
+ class OpenEnvObservation(BaseModel):
19
+ """Typed stand-in for openenv-core's observation model."""
20
+
21
+ done: bool = False
22
+ reward: float | None = None
23
+ metadata: dict[str, Any] = Field(default_factory=dict)
24
+
25
+ class OpenEnvEnvironment:
26
+ """Typed stand-in for openenv-core's environment base."""
27
+
28
+ def __init__(self) -> None: ...
29
+
30
+ def create_app(*args: Any, **kwargs: Any) -> Any:
31
+ """Typed stand-in for openenv-core's FastAPI app factory."""
32
+ ...
33
+
34
+ else:
35
+ try:
36
+ from openenv.core.env_server import Action as OpenEnvAction
37
+ from openenv.core.env_server import Environment as OpenEnvEnvironment
38
+ from openenv.core.env_server import Observation as OpenEnvObservation
39
+ from openenv.core.env_server import create_app
40
+ except ImportError as exc: # pragma: no cover - exercised only without openenv extra
41
+ raise RuntimeError(
42
+ "The OpenEnv adapter requires the openenv extra: pip install 'dataforge[openenv]'."
43
+ ) from exc
44
+
45
+
46
+ class DataForgeOpenEnvAction(OpenEnvAction):
47
+ """OpenEnv action wrapper for DataForge's typed action payloads."""
48
+
49
+ action_type: str = Field(min_length=1)
50
+ row_indices: list[int] | None = None
51
+ column_names: list[str] | None = None
52
+ query: str | None = None
53
+ sql: str | None = None
54
+ test_type: str | None = None
55
+ test: str | None = None
56
+ column: str | None = None
57
+ threshold: float | None = None
58
+ pattern: str | None = None
59
+ regex: str | None = None
60
+ expect_match: bool | None = None
61
+ claim: str | None = None
62
+ affected_rows: list[int] | None = None
63
+ affected_columns: list[str] | None = None
64
+ root_cause_type: str | None = None
65
+ error_indices: list[int] | None = None
66
+ row: int | None = None
67
+ issue_type: str | None = None
68
+ new_value: str | None = None
69
+ proposed_value: str | None = None
70
+ justification: str | None = None
71
+ fix_type: str | None = None
72
+
73
+ def as_dataforge_payload(self) -> dict[str, Any]:
74
+ """Return the action payload expected by ``DataForgeEnv.step``."""
75
+ payload: dict[str, Any] = self.model_dump(exclude_none=True)
76
+ payload.pop("metadata", None)
77
+ return payload
78
+
79
+
80
+ class DataForgeOpenEnvObservation(OpenEnvObservation):
81
+ """OpenEnv observation model mirroring DataForge's native observation."""
82
+
83
+ visible_rows: list[dict[str, Any]] | None = None
84
+ detector_hints: list[str] | None = None
85
+ scratchpad_summary: str = ""
86
+ step_budget_remaining: int = 0
87
+ tool_usage_history: list[dict[str, Any]] = Field(default_factory=list)
88
+ latest_result: dict[str, Any] | None = None
89
+ cumulative_reward: float = 0.0
90
+
91
+
92
+ def _to_openenv_observation(payload: dict[str, Any]) -> DataForgeOpenEnvObservation:
93
+ """Convert a native DataForge observation dictionary into OpenEnv shape."""
94
+ return DataForgeOpenEnvObservation(
95
+ visible_rows=payload.get("visible_rows"),
96
+ detector_hints=payload.get("detector_hints"),
97
+ scratchpad_summary=str(payload.get("scratchpad_summary", "")),
98
+ step_budget_remaining=int(payload.get("step_budget_remaining", 0)),
99
+ tool_usage_history=list(payload.get("tool_usage_history") or []),
100
+ latest_result=payload.get("latest_result"),
101
+ done=bool(payload.get("done", False)),
102
+ reward=payload.get("reward"),
103
+ cumulative_reward=float(payload.get("cumulative_reward", 0.0)),
104
+ metadata=dict(payload.get("metadata") or {}),
105
+ )
106
+
107
+
108
+ class DataForgeOpenEnv(OpenEnvEnvironment):
109
+ """OpenEnv-native environment wrapper."""
110
+
111
+ SUPPORTS_CONCURRENT_SESSIONS = True
112
+
113
+ def __init__(self) -> None:
114
+ super().__init__()
115
+ self._env = DataForgeEnv()
116
+ self._last_observation: DataForgeOpenEnvObservation | None = None
117
+
118
+ def reset(
119
+ self,
120
+ seed: int | None = None,
121
+ episode_id: str | None = None,
122
+ **kwargs: Any,
123
+ ) -> DataForgeOpenEnvObservation:
124
+ """Reset the wrapped DataForge environment."""
125
+ del episode_id, kwargs
126
+ result = self._env.reset(seed=seed)
127
+ observation = _to_openenv_observation(result.observation.model_dump(mode="json"))
128
+ self._last_observation = observation
129
+ return observation
130
+
131
+ def step(
132
+ self,
133
+ action: DataForgeOpenEnvAction,
134
+ timeout_s: float | None = None,
135
+ **kwargs: Any,
136
+ ) -> DataForgeOpenEnvObservation:
137
+ """Step the wrapped DataForge environment."""
138
+ del timeout_s, kwargs
139
+ result = self._env.step(action.as_dataforge_payload())
140
+ observation = _to_openenv_observation(result.observation.model_dump(mode="json"))
141
+ self._last_observation = observation
142
+ return observation
143
+
144
+ def state(self) -> DataForgeOpenEnvObservation:
145
+ """Return the latest observation or reset lazily."""
146
+ if self._last_observation is None:
147
+ return self.reset()
148
+ return self._last_observation
149
+
150
+ def close(self) -> None:
151
+ """Close the wrapped environment."""
152
+ self._env.close()
153
+
154
+
155
+ app = create_app(
156
+ DataForgeOpenEnv,
157
+ DataForgeOpenEnvAction,
158
+ DataForgeOpenEnvObservation,
159
+ env_name="dataforge-env",
160
+ max_concurrent_envs=64,
161
+ )
@@ -0,0 +1,128 @@
1
+ """Reward engine for the DataForge RL environment.
2
+
3
+ All constants and formulas are derived bit-for-bit from REWARD_DESIGN.md.
4
+
5
+ Terminal score: detection_rate * 0.40 + fix_rate * 0.60 - false_positives * fp_rate
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+ __all__ = [
13
+ "DETECTION_WEIGHT",
14
+ "FALSE_POS_PENALTY_RATE",
15
+ "FIX_WEIGHT",
16
+ "LATE_STEP_THRESHOLD",
17
+ "P_FALSE_POS",
18
+ "P_INVALID",
19
+ "P_LATE_STEP",
20
+ "P_REINSPECT",
21
+ "P_WRONG_FIX",
22
+ "R_DIAGNOSE",
23
+ "R_EXPLORE",
24
+ "R_FIX",
25
+ "R_FIX_PARTIAL",
26
+ "R_JUSTIFY_BONUS",
27
+ "R_ROOT_CAUSE",
28
+ "R_TYPE_BONUS",
29
+ "SPAM_THRESHOLD",
30
+ "EpisodeMetrics",
31
+ "RewardEngine",
32
+ ]
33
+
34
+ # Positive rewards
35
+ R_DIAGNOSE: float = 0.10
36
+ R_TYPE_BONUS: float = 0.05
37
+ R_FIX: float = 0.15
38
+ R_FIX_PARTIAL: float = 0.075
39
+ R_JUSTIFY_BONUS: float = 0.05
40
+ R_EXPLORE: float = 0.01
41
+ R_ROOT_CAUSE: float = 0.10
42
+
43
+ # Negative penalties
44
+ P_FALSE_POS: float = -0.05
45
+ P_WRONG_FIX: float = -0.08
46
+ P_LATE_STEP: float = -0.02
47
+ P_INVALID: float = -0.01
48
+ P_REINSPECT: float = -0.01
49
+
50
+ # Thresholds
51
+ LATE_STEP_THRESHOLD: float = 0.80
52
+ DETECTION_WEIGHT: float = 0.40
53
+ FIX_WEIGHT: float = 0.60
54
+ FALSE_POS_PENALTY_RATE: float = 0.05
55
+ SPAM_THRESHOLD: float = 2.0
56
+
57
+
58
+ @dataclass
59
+ class EpisodeMetrics:
60
+ """Accumulated metrics for terminal score computation."""
61
+
62
+ found_issues: int = 0
63
+ total_issues: int = 0
64
+ fixed_issues: int = 0
65
+ fixable_issues: int = 0
66
+ false_positives: int = 0
67
+
68
+ @property
69
+ def total_diagnoses(self) -> int:
70
+ """Total diagnosis attempts (correct + incorrect)."""
71
+ return self.found_issues + self.false_positives
72
+
73
+
74
+ class RewardEngine:
75
+ """Computes dense per-step and terminal rewards."""
76
+
77
+ def compute_terminal_score(self, metrics: EpisodeMetrics) -> float:
78
+ """Compute terminal score per REWARD_DESIGN.md formula."""
79
+ if metrics.total_issues == 0:
80
+ return 0.0
81
+ detection_rate = metrics.found_issues / metrics.total_issues
82
+ fix_rate = (
83
+ metrics.fixed_issues / metrics.fixable_issues if metrics.fixable_issues > 0 else 0.0
84
+ )
85
+ fp_rate = FALSE_POS_PENALTY_RATE
86
+ if (
87
+ metrics.total_issues > 0
88
+ and metrics.total_diagnoses > SPAM_THRESHOLD * metrics.total_issues
89
+ ):
90
+ fp_rate *= 2.0
91
+ penalty = metrics.false_positives * fp_rate
92
+ raw = detection_rate * DETECTION_WEIGHT + fix_rate * FIX_WEIGHT - penalty
93
+ return round(max(0.0, min(1.0, raw)), 4)
94
+
95
+ def compute_late_penalty(self, step: int, max_steps: int) -> float:
96
+ """Return P_LATE_STEP if past 80% budget, else 0.0."""
97
+ threshold = int(max_steps * LATE_STEP_THRESHOLD)
98
+ return P_LATE_STEP if step > threshold else 0.0
99
+
100
+ def compute_exploration_bonus(
101
+ self,
102
+ new_row_indices: set[int],
103
+ inspected_rows: set[int],
104
+ total_rows: int,
105
+ ground_truth_rows: set[int],
106
+ found_issue_rows: set[int],
107
+ ) -> float:
108
+ """Compute exploration bonus for newly-inspected rows."""
109
+ if not new_row_indices:
110
+ return P_REINSPECT
111
+ undiscovered = sum(
112
+ 1 for r in new_row_indices if r in ground_truth_rows and r not in found_issue_rows
113
+ )
114
+ bonus = undiscovered * R_EXPLORE
115
+ if total_rows > 0:
116
+ all_inspected = inspected_rows | new_row_indices
117
+ coverage_ratio = len(all_inspected) / total_rows
118
+ bonus += len(new_row_indices) * R_EXPLORE * 0.5 * (1.0 - coverage_ratio)
119
+ return bonus
120
+
121
+ def diagnose_reward(self, type_match: bool) -> float:
122
+ """Reward for correct diagnosis."""
123
+ return R_DIAGNOSE + (R_TYPE_BONUS if type_match else 0.0)
124
+
125
+ def fix_reward(self, exact: bool, has_justification: bool) -> float:
126
+ """Reward for correct fix."""
127
+ reward = R_FIX if exact else R_FIX_PARTIAL
128
+ return reward + (R_JUSTIFY_BONUS if has_justification else 0.0)
@@ -0,0 +1,176 @@
1
+ """FastAPI server for the DataForge RL environment.
2
+
3
+ Provides OpenEnv-compatible HTTP endpoints:
4
+ POST /reset — Start a new episode
5
+ POST /step — Execute an action
6
+ GET /state — Return current state snapshot
7
+ POST /close — No-op shutdown
8
+ GET /health — Liveness check
9
+ GET /metadata — Environment metadata
10
+ GET /schema — Action/observation JSON schemas
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import os
17
+ from threading import RLock
18
+ from typing import Any
19
+
20
+ from fastapi import FastAPI, HTTPException, Request
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from pydantic import TypeAdapter
23
+
24
+ from dataforge import __version__
25
+ from dataforge.agent.tool_actions import Action
26
+ from dataforge.env.environment import DataForgeEnv, EnvState
27
+ from dataforge.env.observation import DataForgeObservation
28
+ from dataforge.http.problem import problem_exception_handler
29
+ from dataforge.observability import configure_fastapi_observability
30
+
31
+ logger = logging.getLogger("dataforge.env.server")
32
+
33
+
34
+ def _build_cors_origins() -> list[str]:
35
+ """Build the explicit OpenEnv CORS allowlist from the environment."""
36
+ raw_origins = os.environ.get("DATAFORGE_OPENENV_ORIGINS", "")
37
+ return [origin.strip() for origin in raw_origins.split(",") if origin.strip()]
38
+
39
+
40
+ def _build_cors_origin_regex() -> str | None:
41
+ """Allow local browser development only when explicitly enabled."""
42
+ if os.environ.get("DATAFORGE_OPENENV_DEV") != "1":
43
+ return None
44
+ return r"^http://(?:localhost|127(?:\.\d{1,3}){3})(?::\d+)?$"
45
+
46
+
47
+ app = FastAPI(
48
+ title="DataForge Environment",
49
+ description="OpenEnv-compatible RL environment for data-quality repair.",
50
+ version=__version__,
51
+ )
52
+
53
+ app.add_middleware(
54
+ CORSMiddleware,
55
+ allow_origins=_build_cors_origins(),
56
+ allow_origin_regex=_build_cors_origin_regex(),
57
+ allow_credentials=False,
58
+ allow_methods=["GET", "POST", "OPTIONS"],
59
+ allow_headers=["*"],
60
+ )
61
+ app.add_exception_handler(HTTPException, problem_exception_handler)
62
+ configure_fastapi_observability(app, service_name="dataforge-openenv")
63
+
64
+ _registry_lock = RLock()
65
+ _default_env = DataForgeEnv()
66
+ _sessions: dict[str, DataForgeEnv] = {}
67
+
68
+
69
+ def _get_env(episode_id: str | None) -> DataForgeEnv:
70
+ """Resolve an environment by episode id, preserving legacy no-id behavior."""
71
+ if not episode_id:
72
+ return _default_env
73
+ with _registry_lock:
74
+ try:
75
+ return _sessions[episode_id]
76
+ except KeyError as exc:
77
+ raise HTTPException(
78
+ status_code=404,
79
+ detail={"error": "episode_not_found", "episode_id": episode_id},
80
+ ) from exc
81
+
82
+
83
+ def _remember_env(env: DataForgeEnv, episode_id: str) -> None:
84
+ """Register a session and update the legacy default environment."""
85
+ global _default_env
86
+ with _registry_lock:
87
+ _sessions[episode_id] = env
88
+ _default_env = env
89
+
90
+
91
+ @app.post("/reset")
92
+ async def reset(seed: int | None = None) -> dict[str, Any]:
93
+ """Reset the environment for a new episode."""
94
+ env = DataForgeEnv()
95
+ result = env.reset(seed=seed)
96
+ episode_id = str(result.info["episode_id"])
97
+ _remember_env(env, episode_id)
98
+ return result.model_dump(mode="json")
99
+
100
+
101
+ @app.post("/step")
102
+ async def step(action: dict[str, Any]) -> dict[str, Any]:
103
+ """Execute one agent action."""
104
+ action_payload = dict(action)
105
+ raw_episode_id = action_payload.pop("episode_id", None)
106
+ episode_id = str(raw_episode_id) if raw_episode_id else None
107
+ result = _get_env(episode_id).step(action_payload)
108
+ return result.model_dump(mode="json")
109
+
110
+
111
+ @app.get("/state")
112
+ async def state(episode_id: str | None = None) -> dict[str, Any]:
113
+ """Return current environment state snapshot."""
114
+ result = _get_env(episode_id).state()
115
+ return result.model_dump(mode="json")
116
+
117
+
118
+ @app.post("/close")
119
+ async def close(request: Request, episode_id: str | None = None) -> dict[str, Any]:
120
+ """No-op close endpoint for OpenEnv compatibility."""
121
+ body_episode_id: str | None = None
122
+ if episode_id is None:
123
+ try:
124
+ payload = await request.json()
125
+ except Exception:
126
+ payload = None
127
+ if isinstance(payload, dict) and payload.get("episode_id"):
128
+ body_episode_id = str(payload["episode_id"])
129
+
130
+ target_episode_id = episode_id or body_episode_id
131
+ env = _get_env(target_episode_id)
132
+ env.close()
133
+ if target_episode_id:
134
+ with _registry_lock:
135
+ _sessions.pop(target_episode_id, None)
136
+ return {"status": "closed", "episode_id": target_episode_id}
137
+
138
+
139
+ @app.get("/health")
140
+ async def health() -> dict[str, Any]:
141
+ """Liveness check."""
142
+ return {"status": "healthy", "environment": "dataforge-env"}
143
+
144
+
145
+ @app.get("/metadata")
146
+ async def metadata() -> dict[str, Any]:
147
+ """Environment metadata for OpenEnv discovery."""
148
+ return {
149
+ "name": "dataforge-env",
150
+ "version": __version__,
151
+ "description": (
152
+ "DataForge RL Environment — agents learn to detect, diagnose, "
153
+ "and repair data-quality issues in tabular datasets."
154
+ ),
155
+ "action_types": [
156
+ "INSPECT_ROWS",
157
+ "SQL_QUERY",
158
+ "STAT_TEST",
159
+ "PATTERN_MATCH",
160
+ "HYPOTHESIS",
161
+ "ROOT_CAUSE",
162
+ "DIAGNOSE",
163
+ "FIX",
164
+ ],
165
+ }
166
+
167
+
168
+ @app.get("/schema")
169
+ async def schema() -> dict[str, Any]:
170
+ """Return JSON schemas for action and observation models."""
171
+ action_adapter: TypeAdapter[Action] = TypeAdapter(Action)
172
+ return {
173
+ "action": action_adapter.json_schema(),
174
+ "observation": DataForgeObservation.model_json_schema(),
175
+ "state": EnvState.model_json_schema(),
176
+ }
@@ -0,0 +1,76 @@
1
+ """Public evaluation evidence models for DataForge repair releases."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Any, Literal
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ InferabilityLabel = Literal[
12
+ "deterministic_normalization",
13
+ "context_derivable",
14
+ "external_reference_required",
15
+ "not_inferable_from_prompt",
16
+ ]
17
+ PROMOTION_SLICE: InferabilityLabel = "deterministic_normalization"
18
+ ABSTENTION_SLICES = frozenset({"external_reference_required", "not_inferable_from_prompt"})
19
+ AUXILIARY_SLICES = frozenset({"context_derivable"})
20
+ PromotionStatus = Literal[
21
+ "diagnostic_only",
22
+ "diagnostic_promoted",
23
+ "quality_improved_verified",
24
+ "public_quality_milestone",
25
+ "rejected",
26
+ ]
27
+
28
+
29
+ class EvaluationTaskV2(BaseModel):
30
+ """One auditable, source-stable model grading task.
31
+
32
+ Ground truth is retained for local grading but excluded from normal JSON
33
+ serialization so prompts and public reports cannot accidentally leak labels.
34
+ """
35
+
36
+ schema_version: Literal["evaluation_task_v2"] = "evaluation_task_v2"
37
+ task_id: str = Field(min_length=1)
38
+ prompt_hash: str = Field(min_length=64, max_length=64)
39
+ dataset_sha: str = Field(min_length=1)
40
+ split_id: str = Field(min_length=1)
41
+ inferability: InferabilityLabel
42
+ prompt: dict[str, Any]
43
+ allowed_columns: list[str] = Field(min_length=1)
44
+ valid_rows: list[int] = Field(min_length=1)
45
+ provenance: dict[str, Any]
46
+ hidden_ground_truth: list[dict[str, Any]] = Field(default_factory=list, exclude=True)
47
+
48
+ model_config = {"frozen": True}
49
+
50
+
51
+ class ReleaseEvidenceV2(BaseModel):
52
+ """Serializable release-gate evidence for model and benchmark promotion."""
53
+
54
+ schema_version: Literal["release_evidence_v2"] = "release_evidence_v2"
55
+ model_repo: str = Field(min_length=1)
56
+ model_sha: str = Field(min_length=1)
57
+ dataset_repo: str = Field(min_length=1)
58
+ dataset_sha: str = Field(min_length=1)
59
+ strict_macro_f1: float = Field(ge=0.0, le=1.0)
60
+ canonicalized_macro_f1: float = Field(ge=0.0, le=1.0)
61
+ parse_success_rate: float = Field(ge=0.0, le=1.0)
62
+ schema_case_error_count: int = Field(ge=0)
63
+ promotion_slice: InferabilityLabel = PROMOTION_SLICE
64
+ slice_scores: dict[InferabilityLabel, dict[str, float | int]] = Field(default_factory=dict)
65
+ inferability_slice_scores: dict[InferabilityLabel, float] = Field(default_factory=dict)
66
+ package_versions: dict[str, str] = Field(default_factory=dict)
67
+ promotion_status: PromotionStatus
68
+ gate_failures: list[str] = Field(default_factory=list)
69
+
70
+ model_config = {"frozen": True}
71
+
72
+
73
+ def prompt_sha256(prompt: dict[str, Any]) -> str:
74
+ """Hash a prompt payload with stable JSON serialization."""
75
+ encoded = json.dumps(prompt, sort_keys=True, separators=(",", ":")).encode("utf-8")
76
+ return hashlib.sha256(encoded).hexdigest()
@@ -0,0 +1,11 @@
1
+ provider_number,hospital_name,city,state,zip_code,phone_number,rating,mortality_rate,readmission_rate,er_wait_time
2
+ PRV001,General Hospital,Springfield,IL,62701,2175550101,4.2,0.023,0.145,28
3
+ PRV002,St. Mary Medical Center,Chicago,IL,60601,3125550202,3.8,0.031,0.162,35
4
+ PRV001,Springfield Medical,Springfield,IL,62701,2175550303,4.5,0.019,0.138,22
5
+ PRV003,Mercy Hospital,Peoria,IL,61602,3095550404,3.5,0.028,0.158,31
6
+ PRV004,Northwestern Memorial,Chicago,IL,60611,not available,4.1,0.025,0.149,26
7
+ PRV005,Rush University MC,Chicago,IL,60612,3125550606,45.0,0.022,0.141,29
8
+ PRV006,Advocate Christ,Oak Lawn,IL,60453,7085550707,3.9,0.027,0.155,33
9
+ PRV007,Loyola University MC,Maywood,IL,60153,7085550808,4.3,0.020,0.142,25
10
+ PRV008,Presence St. Joseph,Joliet,IL,60435,8155550909,4.0,0.026,0.151,30
11
+ PRV009,Edward Hospital,Naperville,IL,60540,6305551010,3.7,0.029,0.160,34
@@ -0,0 +1,17 @@
1
+ # Hospital dataset schema for DataForge profile command.
2
+
3
+ columns:
4
+ provider_number: str
5
+ hospital_name: str
6
+ city: str
7
+ state: str
8
+ zip_code: str
9
+ phone_number: str
10
+ rating: float
11
+ mortality_rate: float
12
+ readmission_rate: float
13
+ er_wait_time: int
14
+
15
+ functional_dependencies:
16
+ - determinant: [provider_number]
17
+ dependent: hospital_name
@@ -0,0 +1 @@
1
+ """HTTP helpers shared by DataForge backend surfaces."""