Aswini-Kumar commited on
Commit
71dc210
·
0 Parent(s):

Data-Centric AI RL Environment — OpenEnv Hackathon Submission

Browse files
.gitignore ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyd
5
+ *.pyo
6
+ *.egg-info/
7
+ *.egg
8
+ .eggs/
9
+
10
+ # Virtual environments
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Package managers
16
+ uv.lock
17
+ poetry.lock
18
+
19
+ # Test / build artefacts
20
+ .pytest_cache/
21
+ .coverage
22
+ htmlcov/
23
+ dist/
24
+ build/
25
+
26
+ # Training outputs (generated — not committed until training is done)
27
+ sft_data.jsonl
28
+ generations.jsonl
29
+ training_log.json
30
+ sft-checkpoint/
31
+ data-centric-checkpoints/
32
+ data-centric-adapter/
33
+ data-centric-merged/
34
+
35
+ # IDE
36
+ .vscode/
37
+ .idea/
38
+
39
+ # OS
40
+ .DS_Store
41
+ Thumbs.db
42
+
43
+ # Hackathon docs (keep local only)
44
+ *.pdf
45
+ \[External\]*/
.spaceignore ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Files excluded from HuggingFace Space Docker build
2
+ # Keep this lean — only block dev/training files the server doesn't need
3
+
4
+ # Python cache
5
+ __pycache__/
6
+ **/__pycache__/
7
+ *.pyc
8
+ *.pyo
9
+ *.pyd
10
+ *.egg-info/
11
+
12
+ # Dev / test tools
13
+ .venv/
14
+ venv/
15
+ .pytest_cache/
16
+ test_features_smoke.py
17
+ uv.lock
18
+
19
+ # Training-only scripts (not needed by the server at runtime)
20
+ train_data_centric.py
21
+ train_colab.ipynb
22
+ eval_data_centric.py
23
+ sft_generator.py
24
+ inference.py
25
+ hf_job_train.py
26
+ plot_rewards.py
27
+
28
+ # Generated training artefacts
29
+ sft_data.jsonl
30
+ generations.jsonl
31
+ training_log.json
32
+ sft-checkpoint/
33
+ data-centric-checkpoints/
34
+ data-centric-adapter/
35
+ data-centric-merged/
36
+
37
+ # Git
38
+ .git/
39
+ .gitignore
Dockerfile ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # System dependencies
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ git \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # Working directory
9
+ WORKDIR /app
10
+
11
+ # Copy project files
12
+ COPY . .
13
+
14
+ # Install Python dependencies
15
+ RUN pip install --no-cache-dir \
16
+ "openenv-core[core]>=0.2.1" \
17
+ "fastapi>=0.115.0" \
18
+ "uvicorn>=0.24.0" \
19
+ "scikit-learn>=1.3.0" \
20
+ "pandas>=2.0.0" \
21
+ "numpy>=1.24.0"
22
+
23
+ # Install the package itself (enables relative imports)
24
+ RUN pip install --no-cache-dir -e .
25
+
26
+ # HF Spaces runs as non-root user 1000
27
+ RUN useradd -m -u 1000 user
28
+ USER user
29
+
30
+ # Expose port that matches app_port in README.md
31
+ EXPOSE 7860
32
+
33
+ # Start the environment server
34
+ CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Data-Centric AI RL Environment
3
+ emoji: 🧠
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ tags:
10
+ - openenv
11
+ - reinforcement-learning
12
+ - data-centric-ai
13
+ - grpo
14
+ - unsloth
15
+ ---
16
+
17
+ # 🧠 Data-Centric AI — Multi-Agent RL Environment
18
+
19
+ An [OpenEnv](https://github.com/meta-pytorch/OpenEnv)-compliant reinforcement learning environment that trains an LLM to act as a **data engineering orchestrator** — coordinating 4 specialist sub-agents across multi-step plans to improve ML datasets under budget constraints.
20
+
21
+ > **Core insight:** In traditional ML, practitioners tune models to squeeze out performance. This environment flips that — the model architecture is deliberately **frozen**, forcing the LLM agent to master *data engineering* as its only lever: diagnosing noise, coordinating specialist agents, and strategically transforming the dataset until accuracy surpasses the target. This is [Data-Centric AI](https://datacentricai.org/) — the paradigm Andrew Ng argues matters more than model architecture.
22
+
23
+ > **Live Space:** https://huggingface.co/spaces/Aswini-Kumar/data-centric-env
24
+
25
+ ### Key Capabilities
26
+
27
+ | Capability | How it works |
28
+ |---|---|
29
+ | **Multi-Agent Coordination** | LLM orchestrates 4 specialist agents (cleaner, augmenter, balancer, validator) — deciding *who* to call and *when*, modeling each specialist's strengths |
30
+ | **Long-Horizon Planning** | 30-step budget with sparse terminal reward. Agent must plan inspect → query → apply → validate → submit sequences with delayed feedback |
31
+ | **Theory-of-Mind Reasoning** | Agent infers which specialist is best for the current data problem (class imbalance vs. missing values vs. outliers) |
32
+ | **Anti-Exploit Hardening** | 9 security mechanisms (immutable ground truth, golden rows, cooldowns, budget caps) prevent reward hacking |
33
+ | **Curriculum Learning** | Auto-advances from tutorial → easy → medium → hard based on rolling success rate |
34
+ | **Composable Reward System** | 4-component rubric (accuracy + process + preservation + efficiency) using OpenEnv's `Rubric` base class |
35
+
36
+ ---
37
+
38
+ ## 🎯 What the Agent Does
39
+
40
+ The agent receives a noisy tabular dataset and a fixed classifier. It must orchestrate specialist sub-agents to clean, augment, and balance the data until accuracy hits a target — **without touching the model**.
41
+
42
+ Each episode:
43
+ 1. Agent **inspects** the dataset and model
44
+ 2. Agent **queries** specialist sub-agents for recommendations
45
+ 3. Agent **applies** the best fix (or **undoes** a bad one)
46
+ 4. Agent **validates** accuracy improvement
47
+ 5. Agent **submits** when target is reached or budget runs out
48
+
49
+ ---
50
+
51
+ ## 🏗️ Architecture
52
+
53
+ ```
54
+ ┌─────────────────────────────────────────────────────────────────┐
55
+ │ LLM Agent (Qwen2.5-3B) │
56
+ │ SFT warmup → GRPO live-environment training │
57
+ └─────────────┬───────────────────────────────────┬───────────────┘
58
+ │ text commands │ structured obs
59
+ ▼ ▲
60
+ ┌─────────────────────────────────────────────────────────────────┐
61
+ │ DataCentricEnvironment (OpenEnv) │
62
+ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
63
+ │ │ Cleaner │ │Augmenter │ │ Balancer │ │ Validator │ │
64
+ │ │ Agent │ │ Agent │ │ Agent │ │ Agent │ │
65
+ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │
66
+ │ └──────────────┴──────────────┴───────────────┘ │
67
+ │ │ │
68
+ │ ┌────────────▼────────────┐ │
69
+ │ │ Working Copy (mutable) │◄── Snapshot stack (×3) │
70
+ │ └────────────┬────────────┘ for undo support │
71
+ │ │ │
72
+ │ ┌────────────▼────────────┐ │
73
+ │ │ ModelEvaluator (RF) │ │
74
+ │ │ n_est=20 (fast_mode) │ │
75
+ │ └────────────┬────────────┘ │
76
+ │ │ │
77
+ │ ┌────────────▼────────────┐ │
78
+ │ │ Ground Truth (frozen) │ ← never mutated │
79
+ │ └─────────────────────────┘ │
80
+ │ │
81
+ │ ┌───────────────────────────────────────────────────────────┐ │
82
+ │ │ DataCentricRubric (OpenEnv Rubric system) │ │
83
+ │ │ ├── AccuracyRubric — Δ accuracy vs baseline │ │
84
+ │ │ ├── ProcessRubric — workflow pattern scoring │ │
85
+ │ │ ├── PreservationRubric — row preservation incentive │ │
86
+ │ │ └── EfficiencyRubric — accuracy gain / budget used │ │
87
+ │ │ + StepRubric — dense per-apply proxy reward │ │
88
+ │ └───────────────────────────────────────────────────────────┘ │
89
+ │ │
90
+ │ Anti-Exploit: 9 protections (GT immutability, cooldowns, etc.) │
91
+ └─────────────────────────────────────────────────────────────────┘
92
+ ```
93
+
94
+ ---
95
+
96
+ ## 🌍 Environment Design
97
+
98
+ ### Action Space
99
+ Single text command — one of:
100
+
101
+ | Command | Effect |
102
+ |---------|--------|
103
+ | `inspect_dataset` | View shape, missing values, class distribution |
104
+ | `inspect_model` | View classifier accuracy, precision, recall, F1 |
105
+ | `query_cleaner` | Get missing-value / outlier fix recommendations |
106
+ | `query_augmenter [class]` | Get data augmentation recommendations |
107
+ | `query_balancer` | Get class rebalancing recommendations |
108
+ | `query_validator` | Check rule violations (costs 2 budget) |
109
+ | `apply <N>` | Apply recommendation number N |
110
+ | `reject <N>` | Reject a recommendation |
111
+ | `undo` | Revert last apply (max 3 levels deep) |
112
+ | `validate` | Retrain classifier and score (cooldown applies) |
113
+ | `submit` | Finalize and score the episode |
114
+
115
+ ### Observation Space
116
+ Each step returns a structured observation:
117
+
118
+ ```python
119
+ DataCentricObservation(
120
+ response="...", # Specialist agent's text response
121
+ current_accuracy=0.71, # Current classifier accuracy
122
+ baseline_accuracy=0.62, # Accuracy before any changes
123
+ target_accuracy=0.73, # Target to hit
124
+ estimated_quality=0.84, # Dataset quality score (0-1)
125
+ rows_preserved_pct=0.97, # % of original rows still present
126
+ budget_remaining=22, # Steps remaining
127
+ validate_calls_remaining=2, # Free validate calls left
128
+ active_session="cleaner", # Which specialist is active
129
+ done=False,
130
+ )
131
+ ```
132
+
133
+ ### Reward Function — OpenEnv Rubric System
134
+
135
+ Uses `openenv.core.rubrics.base.Rubric` with composable child rubrics (nn.Module-style auto-registration):
136
+
137
+ | Rubric | Signal | Range |
138
+ |--------|--------|-------|
139
+ | **AccuracyRubric** | Δ accuracy × 2.5 + submit bonus | [-1.0, +1.0] |
140
+ | **ProcessRubric** | Correct query→apply→validate sequencing | [-0.10, +0.05] |
141
+ | **PreservationRubric** | Rows preserved ≥ 90% | [-0.40, +0.05] |
142
+ | **EfficiencyRubric** | Accuracy gain / budget used (submit only) | [-0.05, +0.20] |
143
+ | **StepRubric** | Dense per-apply quality proxy | [-0.30, +0.15] |
144
+
145
+ Total clamped to **[-1.0, 1.0]** by `DataCentricRubric.forward()`.
146
+
147
+ ### Anti-Exploit Protections
148
+ 9 hardened mechanisms including:
149
+ - Ground truth immutability assertion after every `apply`
150
+ - Validate cooldown enforcement (must take 2 actions between validates)
151
+ - Duplicate apply detection + session apply limit (max 3 per query)
152
+ - Recommendation staleness validation (re-query after each session)
153
+ - Catastrophic data loss detection (< 50% rows → terminate)
154
+ - Episode wall-clock timeout (5 min → forced submit)
155
+ - Input truncation (> 200 chars → truncate + penalty)
156
+
157
+ ---
158
+
159
+ ## 📚 Task Curriculum (4 Levels)
160
+
161
+ | Task | Rows | Issues | Baseline | Target | Budget |
162
+ |------|------|--------|----------|--------|--------|
163
+ | `task_0_tutorial` | 100 | Missing values (20%) | ~0.62 | 0.73 | 30 |
164
+ | `task_1_easy` | 200 | Missing + imbalance | ~0.63 | 0.79 | 25 |
165
+ | `task_2_medium` | 500 | Missing + duplicates + imbalance + type errors | ~0.58 | 0.74 | 40 |
166
+ | `task_3_hard` | 900 | 6 issues incl. outliers + logic errors | ~0.54 | 0.71 | 60 |
167
+
168
+ Advancement criterion: ≥ 60% success rate over a rolling 30-episode window.
169
+
170
+ ---
171
+
172
+ ## 🤖 Training Pipeline
173
+
174
+ **Model:** Qwen2.5-3B-Instruct (4-bit via Unsloth)
175
+ **Algorithm:** SFT warmup → GRPO (TRL)
176
+ **Framework:** OpenEnv + TRL + Unsloth
177
+
178
+ ### Phase 1 — SFT Warmup
179
+ Train on ~8,100 heuristic trajectory examples to teach valid command syntax before RL.
180
+
181
+ ### Phase 2 — GRPO
182
+ Live environment rollouts scored by the composable Rubric system. Curriculum scheduler advances from tutorial → easy → medium → hard as performance improves.
183
+
184
+ ### Training Notebook
185
+ [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/CelestialWorthyOfHeavenAndEarth/data-centric-env/blob/main/train_colab.ipynb)
186
+
187
+ [`train_colab.ipynb`](train_colab.ipynb) — complete end-to-end training pipeline for Colab T4 GPU.
188
+
189
+ ---
190
+
191
+ ## 📊 Results
192
+
193
+ ### Heuristic Baseline Verification
194
+
195
+ The heuristic agent (`inference.py`) validates that the environment is solvable:
196
+
197
+ | Task | Accuracy Gain | Target Hit Rate |
198
+ |------|--------------|-----------------|
199
+ | `task_0_tutorial` | +0.11 | ✓ 100% |
200
+ | `task_1_easy` | +0.08 | ✓ 80% |
201
+ | `task_2_medium` | +0.06 | ✓ 60% |
202
+ | `task_3_hard` | +0.04 | ~ 40% |
203
+
204
+ > **Note:** After GRPO training, embed reward curves from `plots/` here.
205
+ > Run `python plot_rewards.py` to generate: `reward_curve.png`, `success_rate.png`, `accuracy_gain.png`, `curriculum.png`.
206
+
207
+ ---
208
+
209
+ ## 🧪 Testing
210
+
211
+ ```bash
212
+ # Run all tests (35 tests — grader + environment)
213
+ pytest tests/ -v
214
+
215
+ # Run only grader tests (22 tests)
216
+ pytest tests/test_grader.py -v
217
+
218
+ # Run only environment safety tests (13 tests)
219
+ pytest tests/test_environment.py -v
220
+ ```
221
+
222
+ Tests cover:
223
+ - All 5 Rubric components (accuracy, process, preservation, efficiency, step)
224
+ - Reward clamping to declared [-1.0, 1.0] range
225
+ - Ground truth immutability after every command
226
+ - Budget enforcement and episode termination
227
+ - Validate cooldown and call limiting
228
+ - Undo/snapshot stack behavior
229
+ - Unknown command handling
230
+
231
+ ---
232
+
233
+ ## 🚀 Quick Start
234
+
235
+ ### Connect to the Live Space
236
+
237
+ ```python
238
+ from client import DataCentricEnv
239
+ from models import DataCentricAction
240
+
241
+ with DataCentricEnv(base_url="https://aswini-kumar-data-centric-env.hf.space").sync() as env:
242
+ result = env.reset(task="task_0_tutorial", seed=42)
243
+ print(f"Baseline: {result.observation.baseline_accuracy:.2f} Target: {result.observation.target_accuracy:.2f}")
244
+
245
+ result = env.step(DataCentricAction(message="inspect_dataset"))
246
+ print(result.observation.response)
247
+
248
+ result = env.step(DataCentricAction(message="query_cleaner"))
249
+ print(result.observation.response)
250
+ ```
251
+
252
+ ### Run Locally
253
+
254
+ ```bash
255
+ # Install
256
+ pip install openenv-core fastapi uvicorn scikit-learn pandas numpy
257
+
258
+ # Start server
259
+ uvicorn server.app:app --host 0.0.0.0 --port 8000
260
+
261
+ # In another terminal
262
+ python -c "
263
+ from client import DataCentricEnv
264
+ from models import DataCentricAction
265
+ with DataCentricEnv(base_url='http://localhost:8000').sync() as env:
266
+ obs = env.reset(task='task_0_tutorial', seed=42).observation
267
+ print(f'Ready — baseline={obs.baseline_accuracy:.2f} target={obs.target_accuracy:.2f}')
268
+ "
269
+ ```
270
+
271
+ ---
272
+
273
+ ## 📁 Project Structure
274
+
275
+ ```
276
+ data_centric_env/
277
+ ├── openenv.yaml # OpenEnv manifest (tasks, reward range, action/obs types)
278
+ ├── client.py # DataCentricEnv WebSocket client
279
+ ├── models.py # DataCentricAction + DataCentricObservation (Pydantic)
280
+ ├── train_data_centric.py # Full SFT → GRPO training pipeline
281
+ ├── train_colab.ipynb # Colab training notebook (T4 GPU)
282
+ ├── eval_data_centric.py # Evaluation: random vs trained agent
283
+ ├── plot_rewards.py # Reward curve visualization (4 plots)
284
+ ├── sft_generator.py # SFT warmup data generator (~8100 examples)
285
+ ├── inference.py # Heuristic baseline agent
286
+ ├── tests/
287
+ │ ├── test_grader.py # 22 tests — Rubric system + reward components
288
+ │ └── test_environment.py # 13 tests — safety invariants + anti-exploit
289
+ └── server/
290
+ ├── app.py # FastAPI server (HTTP + WebSocket via OpenEnv)
291
+ ├── data_centric_environment.py # Core RL environment logic (680 lines)
292
+ ├── dataset_generator.py # Synthetic dataset generation (4 task configs)
293
+ ├── specialist_agents.py # CleanerAgent, AugmenterAgent, BalancerAgent, ValidatorAgent
294
+ ├── grader.py # Composable Rubric system (openenv.core.rubrics.base)
295
+ ├── anti_exploit.py # 9 anti-reward-hacking protections
296
+ ├── model_evaluator.py # RF classifier with hash-based caching
297
+ └── Dockerfile # HuggingFace Spaces deployment
298
+ ```
299
+
300
+ ---
301
+
302
+ ## 🏷️ Hackathon
303
+
304
+ **Theme:** #3.1 — World Modeling / Professional Tasks
305
+ **Stack:** OpenEnv · Unsloth · TRL (GRPO) · FastAPI · scikit-learn
306
+ **Repo:** [github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env](https://github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env)
307
+ **Space:** [huggingface.co/spaces/Aswini-Kumar/data-centric-env](https://huggingface.co/spaces/Aswini-Kumar/data-centric-env)
__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Data Centric Env Environment."""
8
+
9
+ from .client import DataCentricEnv
10
+ from .models import DataCentricAction, DataCentricObservation
11
+
12
+ __all__ = [
13
+ "DataCentricAction",
14
+ "DataCentricObservation",
15
+ "DataCentricEnv",
16
+ ]
client.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Data-Centric Environment Client."""
2
+
3
+ from typing import Dict
4
+
5
+ from openenv.core import EnvClient
6
+ from openenv.core.client_types import StepResult
7
+ from openenv.core.env_server.types import State
8
+
9
+ try:
10
+ from .models import DataCentricAction, DataCentricObservation
11
+ except ImportError:
12
+ from models import DataCentricAction, DataCentricObservation
13
+
14
+
15
+ class DataCentricEnv(EnvClient[DataCentricAction, DataCentricObservation, State]):
16
+ """
17
+ Client for the Data-Centric RL Environment.
18
+
19
+ Connects over WebSocket for efficient multi-step interactions.
20
+
21
+ Example:
22
+ >>> with DataCentricEnv(base_url="http://localhost:8000") as client:
23
+ ... result = client.reset(task="task_0_tutorial")
24
+ ... result = client.step(DataCentricAction(message="inspect_dataset"))
25
+ ... print(result.observation.response)
26
+
27
+ Docker example:
28
+ >>> client = DataCentricEnv.from_docker_image("data_centric_env:latest")
29
+ >>> result = client.reset(task="task_1_easy")
30
+ """
31
+
32
+ def _step_payload(self, action: DataCentricAction) -> Dict:
33
+ return {"message": action.message}
34
+
35
+ def _parse_result(self, payload: Dict) -> StepResult[DataCentricObservation]:
36
+ obs_data = payload.get("observation", {})
37
+ observation = DataCentricObservation(
38
+ response=obs_data.get("response", ""),
39
+ current_accuracy=obs_data.get("current_accuracy", 0.0),
40
+ baseline_accuracy=obs_data.get("baseline_accuracy", 0.0),
41
+ target_accuracy=obs_data.get("target_accuracy", 0.0),
42
+ estimated_quality=obs_data.get("estimated_quality", 0.0),
43
+ dataset_shape=obs_data.get("dataset_shape", ""),
44
+ rows_preserved_pct=obs_data.get("rows_preserved_pct", 1.0),
45
+ budget_remaining=obs_data.get("budget_remaining", 0),
46
+ step_number=obs_data.get("step_number", 0),
47
+ max_steps=obs_data.get("max_steps", 30),
48
+ active_session=obs_data.get("active_session", "none"),
49
+ validate_calls_remaining=obs_data.get("validate_calls_remaining", 3),
50
+ done=payload.get("done", False),
51
+ reward=payload.get("reward", 0.0),
52
+ metadata=obs_data.get("metadata", {}),
53
+ )
54
+ return StepResult(
55
+ observation=observation,
56
+ reward=payload.get("reward", 0.0),
57
+ done=payload.get("done", False),
58
+ )
59
+
60
+ def _parse_state(self, payload: Dict) -> State:
61
+ return State(
62
+ episode_id=payload.get("episode_id"),
63
+ step_count=payload.get("step_count", 0),
64
+ )
eval_data_centric.py ADDED
@@ -0,0 +1,443 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ eval_data_centric.py — Evaluation script for DataCentricEnv.
3
+
4
+ Runs two agents on identical seeds for fair comparison:
5
+ - Random Agent: picks valid commands at random (baseline)
6
+ - Trained Agent: uses the fine-tuned model from ./data-centric-adapter
7
+
8
+ Produces eval_results.json for use by plot_rewards.py.
9
+ """
10
+
11
+ import json
12
+ import os
13
+ import random
14
+ import signal
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from typing import Optional
19
+
20
+ import requests
21
+ import torch
22
+ from unsloth import FastLanguageModel
23
+
24
+ # WebSocket client for stateful episode rollouts
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+ from client import DataCentricEnv
27
+ from models import DataCentricAction
28
+
29
+ # ════════════════════════════════════════════════════════
30
+ # CONSTANTS
31
+ # ════════════════════════════════════════════════════════
32
+
33
+ BASE_URL = os.environ.get("ENV_URL", "http://localhost:8000")
34
+ ADAPTER_PATH = "./data-centric-adapter"
35
+ MAX_SEQ_LENGTH = 1024
36
+ EPISODES_PER_TASK = 10
37
+ TASKS = ["task_0_tutorial", "task_1_easy", "task_2_medium", "task_3_hard"]
38
+
39
+ VALID_COMMANDS = [
40
+ "inspect_dataset", "inspect_model", "query_analyst",
41
+ "query_cleaner", "query_augmenter 0", "query_balancer", "query_validator",
42
+ "apply 1", "apply 2", "reject 1", "undo", "validate", "submit",
43
+ ]
44
+
45
+ SYSTEM_PROMPT = """You are a Data-Centric AI agent. Your job is to improve a \
46
+ Machine learning dataset so a fixed classifier achieves higher accuracy.
47
+
48
+ STRATEGY — use this order:
49
+ 1. query_analyst to get a prioritised action plan (costs 1 budget, worth it)
50
+ 2. inspect_dataset to understand the data
51
+ 3. query the recommended specialist (query_cleaner, query_augmenter, query_balancer)
52
+ 4. apply the best recommendation by number (e.g. apply 1)
53
+ 5. validate to check if accuracy improved
54
+ 6. repeat until you hit the target or run low on budget
55
+ 7. submit to finalize
56
+
57
+ IMPORTANT RULES:
58
+ - Start with query_analyst — it tells you the biggest problem to fix first.
59
+ - Always query a specialist before applying. Never apply without querying first.
60
+ - Check the Agreement signal after validate: DISAGREE means possible overfitting.
61
+ - Validate after every 2-3 applies to track progress.
62
+ - Do not spam validate — it costs budget after 3 uses.
63
+ - query_validator costs 2 budget — use only when suspicious of data quality.
64
+ - submit when accuracy >= target or budget < 5.
65
+
66
+ Reply with exactly ONE command per message. No explanation. Just the command."""
67
+
68
+
69
+ def build_user_prompt(obs: dict) -> str:
70
+ improvement_needed = obs.get("target_accuracy", 0) - obs.get("current_accuracy", 0)
71
+ return (
72
+ f"Current situation:\n"
73
+ f"Accuracy: {obs.get('current_accuracy', 0):.1%} → "
74
+ f"Target: {obs.get('target_accuracy', 0):.1%}\n"
75
+ f"Still need: {max(0, improvement_needed):.1%} improvement\n"
76
+ f"Quality score: {obs.get('estimated_quality', 0):.2f}/1.00\n"
77
+ f"Rows preserved: {obs.get('rows_preserved_pct', 1.0):.1%}\n"
78
+ f"Budget remaining: {obs.get('budget_remaining', 0)} steps\n"
79
+ f"Free validates left: {obs.get('validate_calls_remaining', 0)}\n"
80
+ f"Active query session: {obs.get('active_session', 'none')}\n\n"
81
+ f"Last result:\n{str(obs.get('response', ''))[:400]}\n\n"
82
+ f"What is your next action? (one command only)"
83
+ )
84
+
85
+
86
+ # ════════════════════════════════════════════════════════
87
+ # SERVER MANAGEMENT
88
+ # ════════════════════════════════════════════════════════
89
+
90
+ def start_server() -> subprocess.Popen:
91
+ proc = subprocess.Popen(
92
+ ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"],
93
+ stdout=subprocess.DEVNULL,
94
+ stderr=subprocess.DEVNULL,
95
+ )
96
+ for i in range(30):
97
+ try:
98
+ r = requests.get(f"{BASE_URL}/health", timeout=1)
99
+ if r.status_code == 200:
100
+ print(f"Server ready after {i + 1}s")
101
+ return proc
102
+ except Exception:
103
+ pass
104
+ time.sleep(1)
105
+ proc.terminate()
106
+ raise RuntimeError("Server failed to start in 30 seconds")
107
+
108
+
109
+ def stop_server(proc: subprocess.Popen):
110
+ proc.terminate() # cross-platform (SIGTERM on Linux, TerminateProcess on Windows)
111
+ proc.wait()
112
+ print("Server stopped.")
113
+
114
+
115
+ # ════════════════════════════════════════════════════════
116
+ # MODEL LOADING
117
+ # ════════════════════════════════════════════════════════
118
+
119
+ def load_trained_model():
120
+ if not os.path.exists(ADAPTER_PATH):
121
+ raise FileNotFoundError(
122
+ f"Adapter not found at {ADAPTER_PATH}. "
123
+ "Run train_data_centric.py first."
124
+ )
125
+ model, tokenizer = FastLanguageModel.from_pretrained(
126
+ model_name=ADAPTER_PATH,
127
+ max_seq_length=MAX_SEQ_LENGTH,
128
+ load_in_4bit=True,
129
+ dtype=None,
130
+ )
131
+ FastLanguageModel.for_inference(model)
132
+ return model, tokenizer
133
+
134
+
135
+ # ════════════════════════════════════════════════════════
136
+ # EPISODE METRICS
137
+ # ════════════════════════════════════════════════════════
138
+
139
+ def episode_metrics(
140
+ task: str,
141
+ seed: int,
142
+ final_obs: dict,
143
+ actions: list,
144
+ baseline_accuracy: float,
145
+ max_steps: int,
146
+ ) -> dict:
147
+ """Compute per-episode metrics for a single completed episode."""
148
+ final_accuracy = final_obs.get("current_accuracy", baseline_accuracy)
149
+ budget_remaining = final_obs.get("budget_remaining", 0)
150
+ target_accuracy = final_obs.get("target_accuracy", 1.0)
151
+ budget_used = max_steps - budget_remaining
152
+
153
+ accuracy_improvement = final_accuracy - baseline_accuracy
154
+ target_hit = final_accuracy >= target_accuracy
155
+ budget_efficiency = (
156
+ accuracy_improvement / max(budget_used, 1)
157
+ )
158
+
159
+ # Format rate: % actions that are valid commands
160
+ valid_count = sum(
161
+ 1 for a in actions
162
+ if any(a.strip().startswith(cmd.split()[0]) for cmd in VALID_COMMANDS)
163
+ )
164
+ format_rate = valid_count / max(len(actions), 1)
165
+
166
+ # Strategy rate: % query→apply consecutive pairs
167
+ strategy_hits = 0
168
+ for i in range(1, len(actions)):
169
+ if (actions[i - 1].startswith("query_")
170
+ and actions[i].startswith("apply")):
171
+ strategy_hits += 1
172
+ strategy_rate = strategy_hits / max(len(actions) - 1, 1)
173
+
174
+ return {
175
+ "task": task,
176
+ "seed": seed,
177
+ "final_accuracy": round(final_accuracy, 4),
178
+ "baseline_accuracy": round(baseline_accuracy, 4),
179
+ "target_accuracy": round(target_accuracy, 4),
180
+ "accuracy_improvement": round(accuracy_improvement, 4),
181
+ "target_hit": target_hit,
182
+ "budget_used": budget_used,
183
+ "budget_efficiency": round(budget_efficiency, 6),
184
+ "format_rate": round(format_rate, 4),
185
+ "strategy_rate": round(strategy_rate, 4),
186
+ "n_actions": len(actions),
187
+ }
188
+
189
+
190
+ # ════════════════════════════════════════════════════════
191
+ # RANDOM AGENT
192
+ # ════════════════════════════════════════════════════════
193
+
194
+ def run_random_episode(task: str, seed: int) -> Optional[dict]:
195
+ """Run one episode with a random agent using the WebSocket client."""
196
+ try:
197
+ with DataCentricEnv(base_url=BASE_URL).sync() as env:
198
+ r_reset = env.reset(task=task, seed=seed)
199
+ obs = r_reset.observation
200
+ baseline_accuracy = obs.baseline_accuracy
201
+ max_steps = obs.max_steps
202
+ actions = []
203
+
204
+ while not obs.done:
205
+ action = random.choice(VALID_COMMANDS)
206
+ actions.append(action)
207
+ try:
208
+ step_result = env.step(DataCentricAction(message=action))
209
+ obs = step_result.observation
210
+ except Exception:
211
+ break
212
+
213
+ return episode_metrics(
214
+ task, seed,
215
+ {"current_accuracy": obs.current_accuracy,
216
+ "budget_remaining": obs.budget_remaining,
217
+ "target_accuracy": obs.target_accuracy,
218
+ "done": obs.done},
219
+ actions, baseline_accuracy, max_steps
220
+ )
221
+ except Exception as e:
222
+ print(f" [random] Episode failed: {e}")
223
+ return None
224
+
225
+
226
+ # ════════════════════════════════════════════════════════
227
+ # TRAINED AGENT
228
+ # ════════════════════════════════════════════════════════
229
+
230
+ def run_trained_episode(
231
+ model, tokenizer, task: str, seed: int
232
+ ) -> Optional[dict]:
233
+ """Run one episode with the trained model using the WebSocket client."""
234
+ try:
235
+ with DataCentricEnv(base_url=BASE_URL).sync() as env:
236
+ r_reset = env.reset(task=task, seed=seed)
237
+ obs = r_reset.observation
238
+ baseline_accuracy = obs.baseline_accuracy
239
+ max_steps = obs.max_steps
240
+ actions = []
241
+
242
+ while not obs.done:
243
+ obs_dict = {
244
+ "current_accuracy": obs.current_accuracy,
245
+ "target_accuracy": obs.target_accuracy,
246
+ "estimated_quality": obs.estimated_quality,
247
+ "rows_preserved_pct": obs.rows_preserved_pct,
248
+ "budget_remaining": obs.budget_remaining,
249
+ "validate_calls_remaining":obs.validate_calls_remaining,
250
+ "active_session": obs.active_session,
251
+ "response": obs.response,
252
+ }
253
+ messages = [
254
+ {"role": "system", "content": SYSTEM_PROMPT},
255
+ {"role": "user", "content": build_user_prompt(obs_dict)},
256
+ ]
257
+ input_ids = tokenizer.apply_chat_template(
258
+ messages,
259
+ return_tensors="pt",
260
+ max_length=MAX_SEQ_LENGTH - 60,
261
+ truncation=True,
262
+ add_generation_prompt=True,
263
+ ).to(model.device)
264
+
265
+ with torch.no_grad():
266
+ output_ids = model.generate(
267
+ input_ids,
268
+ max_new_tokens=50,
269
+ temperature=0.1,
270
+ do_sample=True,
271
+ pad_token_id=tokenizer.eos_token_id,
272
+ )
273
+
274
+ action = tokenizer.decode(
275
+ output_ids[0][input_ids.shape[1]:],
276
+ skip_special_tokens=True,
277
+ ).strip().split("\n")[0].strip()[:200]
278
+
279
+ actions.append(action)
280
+ try:
281
+ step_result = env.step(DataCentricAction(message=action))
282
+ obs = step_result.observation
283
+ except Exception as e:
284
+ break
285
+
286
+ return episode_metrics(
287
+ task, seed,
288
+ {"current_accuracy": obs.current_accuracy,
289
+ "budget_remaining": obs.budget_remaining,
290
+ "target_accuracy": obs.target_accuracy,
291
+ "done": obs.done},
292
+ actions, baseline_accuracy, max_steps
293
+ )
294
+ except Exception as e:
295
+ print(f" [trained] Episode failed: {e}")
296
+ return None
297
+
298
+
299
+ # ════════════════════════════════════════════════════════
300
+ # AGGREGATION
301
+ # ════════════════════════════════════════════════════════
302
+
303
+ def aggregate(episodes: list) -> dict:
304
+ """Compute mean metrics across a list of episode result dicts."""
305
+ if not episodes:
306
+ return {}
307
+ keys = [
308
+ "accuracy_improvement", "target_hit", "budget_used",
309
+ "budget_efficiency", "format_rate", "strategy_rate",
310
+ ]
311
+ return {
312
+ k: round(sum(ep[k] for ep in episodes) / len(episodes), 4)
313
+ for k in keys
314
+ }
315
+
316
+
317
+ def print_comparison_table(random_agg: dict, trained_agg: dict):
318
+ """Print a formatted comparison table to stdout."""
319
+ def pct_change(r, t):
320
+ if r == 0:
321
+ return "—"
322
+ return f"{(t - r) / abs(r) * 100:+.0f}%"
323
+
324
+ def pp_change(r, t):
325
+ return f"{(t - r) * 100:+.0f}pp"
326
+
327
+ rows = [
328
+ ("Accuracy gain", f"{random_agg.get('accuracy_improvement',0):.3f}",
329
+ f"{trained_agg.get('accuracy_improvement',0):.3f}",
330
+ pct_change(random_agg.get('accuracy_improvement',0),
331
+ trained_agg.get('accuracy_improvement',0))),
332
+ ("Target hit rate", f"{random_agg.get('target_hit',0):.0%}",
333
+ f"{trained_agg.get('target_hit',0):.0%}",
334
+ pp_change(random_agg.get('target_hit',0),
335
+ trained_agg.get('target_hit',0))),
336
+ ("Budget efficiency", f"{random_agg.get('budget_efficiency',0):.4f}",
337
+ f"{trained_agg.get('budget_efficiency',0):.4f}",
338
+ pct_change(random_agg.get('budget_efficiency',0),
339
+ trained_agg.get('budget_efficiency',0))),
340
+ ("Format rate", "random",
341
+ f"{trained_agg.get('format_rate',0):.0%}", "—"),
342
+ ("Strategy rate", "0%",
343
+ f"{trained_agg.get('strategy_rate',0):.0%}", "—"),
344
+ ]
345
+
346
+ header = f"{'Metric':<20} {'Random':>12} {'Trained':>13} {'Improvement':>14}"
347
+ sep = "─" * len(header)
348
+ print(f"\n{sep}")
349
+ print(header)
350
+ print(sep)
351
+ for metric, rand, trained, improvement in rows:
352
+ print(f" {metric:<18} {rand:>12} {trained:>13} {improvement:>14}")
353
+ print(sep + "\n")
354
+
355
+
356
+ # ════════════════════════════════════════════════════════
357
+ # MAIN
358
+ # ════════════════════════════════════════════════════════
359
+
360
+ if __name__ == "__main__":
361
+ server_proc = start_server()
362
+
363
+ try:
364
+ print(f"\nLoading trained model from {ADAPTER_PATH}...")
365
+ model, tokenizer = load_trained_model()
366
+
367
+ # Use fixed seeds so both agents see identical tasks
368
+ seeds = list(range(EPISODES_PER_TASK))
369
+
370
+ results = {
371
+ "random": {"all_episodes": [], "by_task": {}},
372
+ "trained": {"all_episodes": [], "by_task": {}},
373
+ }
374
+
375
+ for task in TASKS:
376
+ print(f"\n{'='*50}")
377
+ print(f"Task: {task}")
378
+ print("─" * 50)
379
+
380
+ random_eps, trained_eps = [], []
381
+
382
+ for seed in seeds:
383
+ print(f" Seed {seed:2d}:", end=" ")
384
+
385
+ # Random agent
386
+ sys.stdout.write("[random] ")
387
+ sys.stdout.flush()
388
+ r_ep = run_random_episode(task, seed)
389
+ if r_ep:
390
+ random_eps.append(r_ep)
391
+ sys.stdout.write(
392
+ f"acc={r_ep['final_accuracy']:.3f} "
393
+ f"hit={'✓' if r_ep['target_hit'] else '✗'} "
394
+ )
395
+
396
+ # Trained agent (same seed)
397
+ sys.stdout.write("[trained] ")
398
+ sys.stdout.flush()
399
+ t_ep = run_trained_episode(model, tokenizer, task, seed)
400
+ if t_ep:
401
+ trained_eps.append(t_ep)
402
+ sys.stdout.write(
403
+ f"acc={t_ep['final_accuracy']:.3f} "
404
+ f"hit={'✓' if t_ep['target_hit'] else '✗'}"
405
+ )
406
+
407
+ print()
408
+
409
+ results["random"]["by_task"][task] = aggregate(random_eps)
410
+ results["trained"]["by_task"][task] = aggregate(trained_eps)
411
+ results["random"]["all_episodes"].extend(random_eps)
412
+ results["trained"]["all_episodes"].extend(trained_eps)
413
+
414
+ # Overall aggregates
415
+ results["random"]["overall"] = aggregate(results["random"]["all_episodes"])
416
+ results["trained"]["overall"] = aggregate(results["trained"]["all_episodes"])
417
+
418
+ # Print comparison table
419
+ print_comparison_table(
420
+ results["random"]["overall"],
421
+ results["trained"]["overall"],
422
+ )
423
+
424
+ # Print per-task breakdown
425
+ print("Per-task summary:")
426
+ for task in TASKS:
427
+ r = results["random"]["by_task"].get(task, {})
428
+ t = results["trained"]["by_task"].get(task, {})
429
+ print(
430
+ f" {task:<22} "
431
+ f"random: acc+{r.get('accuracy_improvement',0):.3f} "
432
+ f"hit={r.get('target_hit',0):.0%} | "
433
+ f"trained: acc+{t.get('accuracy_improvement',0):.3f} "
434
+ f"hit={t.get('target_hit',0):.0%}"
435
+ )
436
+
437
+ # Save full results
438
+ json.dump(results, open("eval_results.json", "w"), indent=2)
439
+ print("\nResults saved to eval_results.json")
440
+ print("Run plot_rewards.py to visualise.")
441
+
442
+ finally:
443
+ stop_server(server_proc)
hf_job_train.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ HF Job training script for Data-Centric AI Agent.
4
+
5
+ Run this as an HF Job pointing to the deployed HF Space as the environment:
6
+ hf job run --gpu t4-small --env ENV_URL=https://aswini-kumar-data-centric-env.hf.space \
7
+ python hf_job_train.py
8
+
9
+ Or via Python API:
10
+ from huggingface_hub import HfApi
11
+ api = HfApi()
12
+ api.run_job(...)
13
+
14
+ The HF Space (server) must be running BEFORE submitting this job.
15
+ """
16
+
17
+ import os
18
+ import sys
19
+ import time
20
+ import requests
21
+
22
+ # ── Verify the HF Space is reachable before starting training ─────────────────
23
+
24
+ ENV_URL = os.environ.get("ENV_URL", "https://aswini-kumar-data-centric-env.hf.space")
25
+ print(f"[HF Job] Environment URL: {ENV_URL}")
26
+
27
+ print("[HF Job] Checking environment server health...")
28
+ for attempt in range(12):
29
+ try:
30
+ r = requests.get(f"{ENV_URL}/health", timeout=10)
31
+ if r.status_code == 200:
32
+ print(f"[HF Job] Server healthy: {r.json()}")
33
+ break
34
+ except Exception as e:
35
+ print(f"[HF Job] Attempt {attempt+1}/12: {e}")
36
+ time.sleep(10)
37
+ else:
38
+ raise RuntimeError(
39
+ f"HF Space at {ENV_URL} is not responding after 2 minutes.\n"
40
+ "Make sure the Space is Running before submitting this job."
41
+ )
42
+
43
+ # ── Install dependencies ───────────────────────────────────────────────────────
44
+
45
+ print("[HF Job] Installing dependencies...")
46
+ os.system(
47
+ 'pip install "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git" '
48
+ 'trl datasets transformers accelerate scikit-learn pandas numpy matplotlib '
49
+ '"openenv-core[core]>=0.2.1" --quiet'
50
+ )
51
+
52
+ # ── Pull latest environment code ──────────────────────────────────────────────
53
+
54
+ REPO = "https://github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env.git"
55
+ WORK_DIR = "/tmp/data-centric-env"
56
+
57
+ if not os.path.exists(f"{WORK_DIR}/pyproject.toml"):
58
+ os.system(f"git clone {REPO} {WORK_DIR}")
59
+ else:
60
+ os.system(f"git -C {WORK_DIR} pull origin main")
61
+
62
+ os.chdir(WORK_DIR)
63
+ sys.path.insert(0, WORK_DIR)
64
+ os.system("pip install -e . --quiet")
65
+ print(f"[HF Job] Working dir: {os.getcwd()}")
66
+ os.system("git log --oneline -3")
67
+
68
+ # ── Set ENV_URL so train_data_centric.py uses the HF Space ───────────────────
69
+
70
+ os.environ["ENV_URL"] = ENV_URL
71
+ print(f"[HF Job] ENV_URL = {ENV_URL}")
72
+
73
+ # ── Generate SFT data ─────────────────────────────────────────────────────────
74
+
75
+ if not os.path.exists("sft_data.jsonl"):
76
+ print("[HF Job] Generating SFT data...")
77
+ os.system("python sft_generator.py")
78
+ else:
79
+ count = sum(1 for _ in open("sft_data.jsonl"))
80
+ print(f"[HF Job] SFT data exists: {count} examples")
81
+
82
+ # ── Run full training pipeline ────────────────────────────────────────────────
83
+
84
+ from train_data_centric import load_model, run_sft_warmup, run_grpo_training, save_model
85
+ import glob
86
+
87
+ print("[HF Job] Loading model...")
88
+ model, tokenizer = load_model()
89
+
90
+ print("[HF Job] Phase 1: SFT warmup...")
91
+ model = run_sft_warmup(model, tokenizer)
92
+ print("[HF Job] SFT complete")
93
+
94
+ print("[HF Job] Phase 2: GRPO training (connecting to HF Space)...")
95
+ resume_from = None
96
+ ckpt_dir = "./data-centric-checkpoints"
97
+ if os.path.exists(ckpt_dir):
98
+ checkpoints = sorted(glob.glob(f"{ckpt_dir}/checkpoint-*"))
99
+ if checkpoints:
100
+ resume_from = checkpoints[-1]
101
+ print(f"[HF Job] Resuming from: {resume_from}")
102
+
103
+ model = run_grpo_training(model, tokenizer, resume_from_checkpoint=resume_from)
104
+ print("[HF Job] GRPO complete")
105
+
106
+ print("[HF Job] Saving model...")
107
+ save_model(model, tokenizer)
108
+
109
+ print("[HF Job] Generating reward plots...")
110
+ os.system("python plot_rewards.py --log logs/training.jsonl --out plots/")
111
+
112
+ print("[HF Job] Done! Results in ./logs/ and ./plots/")
inference.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Heuristic baseline agent for the Data-Centric RL Environment.
3
+
4
+ Verifies the environment works correctly before any LLM training.
5
+ Run on all 4 tasks, 5 seeds each. Prints a results table.
6
+ """
7
+
8
+ import sys
9
+ import os
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
12
+
13
+ from models import DataCentricAction, DataCentricObservation
14
+ from server.data_centric_environment import DataCentricEnvironment
15
+ from server.dataset_generator import TASK_CONFIGS
16
+
17
+
18
+ def heuristic_agent(obs: DataCentricObservation, step: int, state: dict) -> str:
19
+ """
20
+ Simple heuristic agent that follows:
21
+ inspect → query_cleaner → apply 1 → apply 2 → validate → query_balancer
22
+ → apply 1 → validate → submit
23
+ """
24
+ if step == 0:
25
+ return "inspect_dataset"
26
+ if not state.get("queried_cleaner"):
27
+ state["queried_cleaner"] = True
28
+ return "query_cleaner"
29
+ if state.get("cleaner_applies", 0) < 2:
30
+ n = state.get("cleaner_applies", 0) + 1
31
+ state["cleaner_applies"] = n
32
+ return f"apply {n}"
33
+ if not state.get("validated"):
34
+ state["validated"] = True
35
+ return "validate"
36
+ if obs.current_accuracy < obs.target_accuracy and not state.get("queried_balancer"):
37
+ state["queried_balancer"] = True
38
+ return "query_balancer"
39
+ if state.get("queried_balancer") and not state.get("balancer_applied"):
40
+ state["balancer_applied"] = True
41
+ return "apply 1"
42
+ if state.get("queried_balancer") and state.get("balancer_applied") and not state.get("validated2"):
43
+ state["validated2"] = True
44
+ return "validate"
45
+ return "submit"
46
+
47
+
48
+ def run_heuristic(task: str, seed: int) -> dict:
49
+ env = DataCentricEnvironment()
50
+ obs = env.reset(task=task, seed=seed)
51
+ state = {}
52
+ total_reward = 0.0
53
+
54
+ for step in range(TASK_CONFIGS[task]["budget"]):
55
+ action_msg = heuristic_agent(obs, step, state)
56
+ result_obs = env.step(DataCentricAction(message=action_msg))
57
+ total_reward += result_obs.reward
58
+ obs = result_obs
59
+ if obs.done:
60
+ break
61
+
62
+ return {
63
+ "task": task,
64
+ "seed": seed,
65
+ "final_accuracy": obs.current_accuracy,
66
+ "target": obs.target_accuracy,
67
+ "hit": obs.current_accuracy >= obs.target_accuracy,
68
+ "budget_used": obs.step_number,
69
+ "total_reward": round(total_reward, 4),
70
+ }
71
+
72
+
73
+ def main():
74
+ tasks = list(TASK_CONFIGS.keys())
75
+ seeds = [0, 1, 2, 3, 4]
76
+
77
+ print(f"\n{'Task':<20} {'Seed':<6} {'Accuracy':<12} {'Target':<10} {'Hit?':<6} {'Budget':<10} {'Reward'}")
78
+ print("-" * 80)
79
+
80
+ hits = 0
81
+ total = 0
82
+ for task in tasks:
83
+ for seed in seeds:
84
+ r = run_heuristic(task, seed)
85
+ hit_str = "Y" if r["hit"] else "N"
86
+ if r["hit"]:
87
+ hits += 1
88
+ total += 1
89
+ print(
90
+ f"{r['task']:<20} {r['seed']:<6} {r['final_accuracy']:<12.4f} "
91
+ f"{r['target']:<10.4f} {hit_str:<6} {r['budget_used']:<10} {r['total_reward']}"
92
+ )
93
+
94
+ print("-" * 80)
95
+ print(f"Hit rate: {hits}/{total} ({100*hits/total:.0f}%)")
96
+ print()
97
+ if hits / total >= 0.6:
98
+ print(" PASS: Heuristic agent validation passed.")
99
+ else:
100
+ print(" WARN: Hit rate below 60%. Check environment tuning.")
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
logs/.gitkeep ADDED
File without changes
models.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """
8
+ Data models for the Data-Centric RL Environment.
9
+
10
+ Action → plain text command string (like DataWranglerEnv)
11
+ Observation → rich structured observation with accuracy, quality, budget info
12
+ State → episode metadata
13
+ """
14
+
15
+ from openenv.core.env_server.types import Action, Observation
16
+ from pydantic import Field
17
+
18
+
19
+ class DataCentricAction(Action):
20
+ """Action for the Data-Centric environment — a text command string.
21
+
22
+ The agent sends natural-language-style commands to inspect the dataset,
23
+ query specialist sub-agents, apply their recommendations, and ultimately
24
+ submit the cleaned dataset for scoring.
25
+
26
+ Examples:
27
+ - "inspect_dataset"
28
+ - "inspect_model"
29
+ - "query_cleaner"
30
+ - "query_augmenter class_1"
31
+ - "query_balancer"
32
+ - "query_validator"
33
+ - "apply 1"
34
+ - "reject 2"
35
+ - "validate"
36
+ - "submit"
37
+ """
38
+
39
+ message: str = Field(..., description="Text command to execute in the environment")
40
+
41
+
42
+ class DataCentricObservation(Observation):
43
+ """Observation returned after each action in the Data-Centric environment.
44
+
45
+ Provides the agent with rich feedback about the current episode state,
46
+ including dataset health, model accuracy, budget, and specialist session info.
47
+ """
48
+
49
+ response: str = Field(
50
+ default="",
51
+ description="Text result of the executed command",
52
+ )
53
+ current_accuracy: float = Field(
54
+ default=0.0,
55
+ description="Last validated model accuracy (or baseline if not yet validated)",
56
+ )
57
+ baseline_accuracy: float = Field(
58
+ default=0.0,
59
+ description="Accuracy at episode start — never changes",
60
+ )
61
+ target_accuracy: float = Field(
62
+ default=0.0,
63
+ description="Accuracy threshold the agent must exceed to hit target",
64
+ )
65
+ estimated_quality: float = Field(
66
+ default=0.0,
67
+ description="Lightweight quality score without sklearn retraining (0.0-1.0)",
68
+ )
69
+ dataset_shape: str = Field(
70
+ default="",
71
+ description="Current dataset dimensions, e.g. '200 rows × 5 columns'",
72
+ )
73
+ rows_preserved_pct: float = Field(
74
+ default=1.0,
75
+ description="Fraction of original rows still present (1.0 = no data loss)",
76
+ )
77
+ budget_remaining: int = Field(
78
+ default=0,
79
+ description="Steps remaining before forced submit",
80
+ )
81
+ step_number: int = Field(
82
+ default=0,
83
+ description="Current step number in the episode",
84
+ )
85
+ max_steps: int = Field(
86
+ default=30,
87
+ description="Maximum steps allowed for this task",
88
+ )
89
+ active_session: str = Field(
90
+ default="none",
91
+ description="Which specialist agent was queried last (cleaner/augmenter/balancer/none)",
92
+ )
93
+ validate_calls_remaining: int = Field(
94
+ default=3,
95
+ description="How many more free validates remain before reward turns negative",
96
+ )
97
+ done: bool = Field(
98
+ default=False,
99
+ description="Whether the episode has ended",
100
+ )
101
+ reward: float = Field(
102
+ default=0.0,
103
+ description="Reward for this step",
104
+ )
openenv.yaml ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: data-centric-env
2
+ description: >
3
+ Train an LLM agent to improve ML datasets using the Data-Centric AI paradigm.
4
+ The agent coordinates specialist sub-agents (CleanerAgent, AugmenterAgent,
5
+ BalancerAgent, ValidatorAgent) to fix data quality issues and improve a fixed
6
+ classifier's accuracy — without changing the model architecture or hyperparameters.
7
+ Based on Andrew Ng's Data-Centric AI framework.
8
+ version: "1.1.0"
9
+ tags:
10
+ - data-centric-ai
11
+ - world-modeling
12
+ - professional-tasks
13
+ - reinforcement-learning
14
+ - grpo
15
+ - unsloth
16
+ - curriculum-learning
17
+
18
+ tasks:
19
+ - name: task_0_tutorial
20
+ description: >
21
+ Single-issue tutorial. 100 rows, binary classification, 4 features.
22
+ Only issue: 15-30% missing values. Baseline ~0.62, target 0.73. Budget: 30 steps.
23
+ - name: task_1_easy
24
+ description: >
25
+ Two issues: missing values (8-25%) + mild class imbalance (5-20%).
26
+ 200 rows, binary, 5 features. Baseline ~0.63, target 0.79. Budget: 25 steps.
27
+ - name: task_2_medium
28
+ description: >
29
+ Four issues: missing values, duplicates, class imbalance, type errors.
30
+ 500 rows, 3-class, 7 features. Baseline ~0.58, target 0.74. Budget: 40 steps.
31
+ - name: task_3_hard
32
+ description: >
33
+ Six issues: missing values, duplicates, imbalance, type errors, outliers,
34
+ cross-column logic errors. 900 rows, 4-class, 10 features.
35
+ Baseline ~0.54, target 0.71. Budget: 60 steps.
36
+
37
+ action_type: text
38
+ observation_type: structured
39
+
40
+ # Reward components:
41
+ # accuracy_reward: [-1.0, +1.0] — accuracy delta × 2.5, submit bonus +0.50
42
+ # process_reward: [-0.10, +0.05] — workflow pattern scoring
43
+ # preservation_reward:[-0.40, +0.05] — row preservation incentive
44
+ # efficiency_reward: [-0.05, +0.20] — at submit only
45
+ # step_reward: [-0.30, +0.15] — proxy quality delta per apply
46
+ # Total clamped to [-1.0, 1.0] by compute_total_reward()
47
+ reward_range: [-1.0, 1.0]
plot_rewards.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Plot Rewards — Data-Centric AI RL Environment
3
+ =============================================
4
+ Reads JSONL training logs and produces judge-ready plots with labeled axes.
5
+
6
+ Log format (one JSON object per line in logs/training.jsonl):
7
+ {
8
+ "ts": 1714000000.0, # Unix timestamp
9
+ "episode": 42, # Episode number
10
+ "task": "task_1_easy", # Task name
11
+ "level": 1, # Curriculum level (0=tutorial ... 3=hard)
12
+ "reward": 0.34, # Episode reward
13
+ "accuracy_gain": 0.08, # Accuracy delta vs baseline
14
+ "steps_used": 18, # Steps consumed
15
+ "success": true # Reached target accuracy?
16
+ }
17
+
18
+ Output (saved to plots/):
19
+ reward_curve.png — Episode reward with rolling mean
20
+ success_rate.png — Success rate per curriculum level
21
+ accuracy_gain.png — Accuracy gain distribution
22
+ curriculum.png — Curriculum level over episodes
23
+
24
+ Usage:
25
+ python plot_rewards.py # default log path
26
+ python plot_rewards.py --log logs/training.jsonl --out plots/
27
+ """
28
+
29
+ import argparse
30
+ import json
31
+ import sys
32
+ from pathlib import Path
33
+
34
+ import matplotlib
35
+ matplotlib.use("Agg") # non-interactive backend — safe for headless/Colab
36
+ import matplotlib.pyplot as plt
37
+ import matplotlib.patches as mpatches
38
+ import numpy as np
39
+ import pandas as pd
40
+
41
+ # ── Style ────────────────────────────────────────────────────────────────────
42
+
43
+ LEVEL_COLORS = {0: "#4C72B0", 1: "#DD8452", 2: "#55A868", 3: "#C44E52"}
44
+ LEVEL_NAMES = {0: "tutorial", 1: "easy", 2: "medium", 3: "hard"}
45
+ FIGSIZE = (10, 4)
46
+ DPI = 150
47
+
48
+ plt.rcParams.update({
49
+ "font.size": 11,
50
+ "axes.titlesize": 13,
51
+ "axes.titleweight": "bold",
52
+ "axes.labelsize": 11,
53
+ "grid.alpha": 0.3,
54
+ })
55
+
56
+
57
+ # ── Load log ─────────────────────────────────────────────────────────────────
58
+
59
+ def load_log(log_path: str) -> pd.DataFrame:
60
+ """Load JSONL training log. Returns empty DataFrame if file not found."""
61
+ path = Path(log_path)
62
+ if not path.exists():
63
+ print(f"[plot_rewards] Log not found: {log_path}")
64
+ return pd.DataFrame()
65
+
66
+ records = []
67
+ with open(path, encoding="utf-8") as f:
68
+ for line in f:
69
+ line = line.strip()
70
+ if line:
71
+ try:
72
+ records.append(json.loads(line))
73
+ except json.JSONDecodeError:
74
+ pass
75
+
76
+ if not records:
77
+ print(f"[plot_rewards] Log is empty: {log_path}")
78
+ return pd.DataFrame()
79
+
80
+ df = pd.DataFrame(records)
81
+ # Normalise column names — handle both old and new log formats
82
+ col_map = {
83
+ "mean_total_reward": "reward",
84
+ "mean_env_reward": "accuracy_gain",
85
+ "stage": "task",
86
+ }
87
+ df.rename(columns=col_map, inplace=True)
88
+ if "episode" not in df.columns:
89
+ df["episode"] = range(len(df))
90
+ if "level" not in df.columns:
91
+ df["level"] = 0
92
+ if "success" not in df.columns:
93
+ df["success"] = df.get("accuracy_gain", 0) > 0.05
94
+ if "accuracy_gain" not in df.columns:
95
+ df["accuracy_gain"] = 0.0
96
+ if "reward" not in df.columns:
97
+ df["reward"] = 0.0
98
+
99
+ df.sort_values("episode", inplace=True)
100
+ df.reset_index(drop=True, inplace=True)
101
+ print(f"[plot_rewards] Loaded {len(df)} episodes from {log_path}")
102
+ return df
103
+
104
+
105
+ # ── Plots ─────────────────────────────────────────────────────────────────────
106
+
107
+ def plot_reward_curve(df: pd.DataFrame, out_dir: Path, window: int = 20):
108
+ """Plot 1: Episode reward over training with rolling mean."""
109
+ fig, ax = plt.subplots(figsize=FIGSIZE)
110
+
111
+ ax.plot(df["episode"], df["reward"], alpha=0.25, color="steelblue",
112
+ linewidth=0.8, label="Raw reward")
113
+
114
+ if len(df) >= window:
115
+ smooth = df["reward"].rolling(window, min_periods=1).mean()
116
+ ax.plot(df["episode"], smooth, color="steelblue", linewidth=2.2,
117
+ label=f"Rolling mean (window={window})")
118
+
119
+ # Mark curriculum level transitions
120
+ level_changes = df[df["level"].diff() != 0]
121
+ for _, row in level_changes.iterrows():
122
+ if row["level"] > 0:
123
+ ax.axvline(row["episode"], color=LEVEL_COLORS.get(int(row["level"]), "gray"),
124
+ linewidth=1.0, linestyle="--", alpha=0.7)
125
+ ax.text(row["episode"] + 0.5, ax.get_ylim()[1] * 0.95,
126
+ LEVEL_NAMES.get(int(row["level"]), ""), fontsize=8,
127
+ color=LEVEL_COLORS.get(int(row["level"]), "gray"), rotation=90, va="top")
128
+
129
+ ax.set_xlabel("Episode")
130
+ ax.set_ylabel("Episode reward")
131
+ ax.set_title("Training Reward over Episodes")
132
+ ax.legend(loc="lower right")
133
+ ax.grid(True)
134
+ fig.tight_layout()
135
+
136
+ out_path = out_dir / "reward_curve.png"
137
+ fig.savefig(out_path, dpi=DPI)
138
+ plt.close(fig)
139
+ print(f"[plot_rewards] Saved: {out_path}")
140
+
141
+
142
+ def plot_success_rate(df: pd.DataFrame, out_dir: Path, window: int = 20):
143
+ """Plot 2: Success rate per curriculum level."""
144
+ fig, ax = plt.subplots(figsize=FIGSIZE)
145
+
146
+ levels = sorted(df["level"].unique())
147
+ for level in levels:
148
+ subset = df[df["level"] == level].copy()
149
+ subset = subset.sort_values("episode").reset_index(drop=True)
150
+ rate = subset["success"].rolling(window, min_periods=1).mean()
151
+ color = LEVEL_COLORS.get(int(level), "gray")
152
+ label = f"Level {int(level)}: {LEVEL_NAMES.get(int(level), 'unknown')}"
153
+ ax.plot(subset["episode"], rate, color=color, linewidth=2, label=label)
154
+
155
+ ax.axhline(0.60, color="red", linewidth=1.0, linestyle="--", alpha=0.6,
156
+ label="Advancement threshold (60%)")
157
+ ax.set_xlabel("Episode")
158
+ ax.set_ylabel(f"Success rate (rolling mean, window={window})")
159
+ ax.set_title("Success Rate per Curriculum Level")
160
+ ax.set_ylim(0, 1.05)
161
+ ax.legend(loc="lower right")
162
+ ax.grid(True)
163
+ fig.tight_layout()
164
+
165
+ out_path = out_dir / "success_rate.png"
166
+ fig.savefig(out_path, dpi=DPI)
167
+ plt.close(fig)
168
+ print(f"[plot_rewards] Saved: {out_path}")
169
+
170
+
171
+ def plot_accuracy_gain(df: pd.DataFrame, out_dir: Path, window: int = 20):
172
+ """Plot 3: Accuracy gain over training."""
173
+ fig, ax = plt.subplots(figsize=FIGSIZE)
174
+
175
+ ax.plot(df["episode"], df["accuracy_gain"], alpha=0.25, color="green",
176
+ linewidth=0.8, label="Raw accuracy gain")
177
+
178
+ if len(df) >= window:
179
+ smooth = df["accuracy_gain"].rolling(window, min_periods=1).mean()
180
+ ax.plot(df["episode"], smooth, color="green", linewidth=2.2,
181
+ label=f"Rolling mean (window={window})")
182
+
183
+ ax.axhline(0, color="black", linewidth=0.8, linestyle="-", alpha=0.4)
184
+ ax.set_xlabel("Episode")
185
+ ax.set_ylabel("Accuracy gain vs baseline")
186
+ ax.set_title("Accuracy Gain per Episode")
187
+ ax.legend(loc="lower right")
188
+ ax.grid(True)
189
+ fig.tight_layout()
190
+
191
+ out_path = out_dir / "accuracy_gain.png"
192
+ fig.savefig(out_path, dpi=DPI)
193
+ plt.close(fig)
194
+ print(f"[plot_rewards] Saved: {out_path}")
195
+
196
+
197
+ def plot_curriculum(df: pd.DataFrame, out_dir: Path):
198
+ """Plot 4: Curriculum level progression over time."""
199
+ fig, ax = plt.subplots(figsize=FIGSIZE)
200
+
201
+ colors = [LEVEL_COLORS.get(int(l), "gray") for l in df["level"]]
202
+ ax.scatter(df["episode"], df["level"], c=colors, s=4, alpha=0.5, zorder=2)
203
+
204
+ # Smooth line
205
+ ax.plot(df["episode"], df["level"].rolling(10, min_periods=1).mean(),
206
+ color="black", linewidth=1.5, alpha=0.6, label="Rolling mean level")
207
+
208
+ ax.set_xlabel("Episode")
209
+ ax.set_ylabel("Curriculum level")
210
+ ax.set_title("Curriculum Progression")
211
+ ax.set_yticks([0, 1, 2, 3])
212
+ ax.set_yticklabels(["0: tutorial", "1: easy", "2: medium", "3: hard"])
213
+ ax.grid(True, axis="x")
214
+
215
+ patches = [mpatches.Patch(color=c, label=f"{l}: {LEVEL_NAMES[l]}")
216
+ for l, c in LEVEL_COLORS.items()]
217
+ ax.legend(handles=patches, loc="lower right", fontsize=9)
218
+ fig.tight_layout()
219
+
220
+ out_path = out_dir / "curriculum.png"
221
+ fig.savefig(out_path, dpi=DPI)
222
+ plt.close(fig)
223
+ print(f"[plot_rewards] Saved: {out_path}")
224
+
225
+
226
+ # ── Entry point ───────────────────────────────────────────────────────────────
227
+
228
+ def plot_all(log_path: str = "logs/training.jsonl", out_dir: str = "plots/",
229
+ window: int = 20):
230
+ df = load_log(log_path)
231
+ if df.empty:
232
+ print("[plot_rewards] No data to plot.")
233
+ return
234
+
235
+ out = Path(out_dir)
236
+ out.mkdir(parents=True, exist_ok=True)
237
+
238
+ plot_reward_curve(df, out, window)
239
+ plot_success_rate(df, out, window)
240
+ plot_accuracy_gain(df, out, window)
241
+ plot_curriculum(df, out)
242
+
243
+ print(f"\n[plot_rewards] All plots saved to {out}/")
244
+ print(f" Episodes: {len(df)} | "
245
+ f"Avg reward: {df['reward'].mean():.3f} | "
246
+ f"Success rate: {df['success'].mean():.1%} | "
247
+ f"Max level reached: {int(df['level'].max())}")
248
+
249
+
250
+ if __name__ == "__main__":
251
+ parser = argparse.ArgumentParser(description="Plot training reward curves")
252
+ parser.add_argument("--log", default="logs/training.jsonl",
253
+ help="Path to JSONL training log")
254
+ parser.add_argument("--out", default="plots/",
255
+ help="Output directory for plots")
256
+ parser.add_argument("--window", type=int, default=20,
257
+ help="Rolling mean window size")
258
+ args = parser.parse_args()
259
+ plot_all(args.log, args.out, args.window)
plots/.gitkeep ADDED
File without changes
pyproject.toml ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-data_centric_env"
13
+ version = "0.1.0"
14
+ description = "Data Centric Env environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
+ "openenv-core[core]>=0.2.2",
19
+ # Environment-specific dependencies
20
+ "scikit-learn>=1.3.0",
21
+ "pandas>=2.0.0",
22
+ "numpy>=1.24.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=8.0.0",
28
+ "pytest-cov>=4.0.0",
29
+ ]
30
+
31
+ [project.scripts]
32
+ # Server entry point - enables running via: uv run --project . server
33
+ # or: python -m data_centric_env.server.app
34
+ server = "data_centric_env.server.app:main"
35
+
36
+ [tool.setuptools]
37
+ include-package-data = true
38
+ packages = ["data_centric_env", "data_centric_env.server"]
39
+ package-dir = { "data_centric_env" = ".", "data_centric_env.server" = "server" }
40
+
41
+ [tool.pytest.ini_options]
42
+ testpaths = ["tests"]
43
+ pythonpath = ["."]
server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Data-Centric AI RL environment server components."""
8
+
9
+ from .data_centric_environment import DataCentricEnvironment
10
+
11
+ __all__ = ["DataCentricEnvironment"]
server/anti_exploit.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Anti-Exploit Protections for Data-Centric RL Environment.
3
+
4
+ Centralised module for all anti-hacking checks:
5
+ 1. Input truncation (>200 chars → truncate, -0.02 penalty)
6
+ 2. Validate spam prevention (cooldown + diminishing returns)
7
+ 3. Recommendation ID staleness check
8
+ 4. Ground truth immutability assertion
9
+ 5. Catastrophic data loss detection
10
+ 6. Duplicate apply prevention
11
+ 7. Max applies per session (3)
12
+ 8. Episode wall-clock timeout (5 min → forced submit, -0.10)
13
+ 9. Step timeout (5 sec → timeout obs, -0.05)
14
+ """
15
+
16
+ import logging
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from typing import Optional, Set
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ MAX_ACTION_CHARS = 200
24
+ MAX_APPLIES_PER_SESSION = 3
25
+ FREE_VALIDATES = 3
26
+ VALIDATE_COOLDOWN = 2 # must take this many non-validate actions before next validate
27
+ EPISODE_TIMEOUT_SECS = 5 * 60 # 5 minutes
28
+ STEP_TIMEOUT_SECS = 5 # 5 seconds per step
29
+
30
+
31
+ # ── Exploit tracker (per episode state) ──────────────────────────────────────
32
+
33
+ @dataclass
34
+ class AntiExploitState:
35
+ # Validate tracking
36
+ validate_call_count: int = 0
37
+ steps_since_last_validate: int = 0 # cooldown counter
38
+
39
+ # Apply tracking
40
+ applied_ids_this_session: Set[int] = field(default_factory=set)
41
+ applies_this_session: int = 0
42
+
43
+ # Timing
44
+ episode_start_time: float = field(default_factory=time.time)
45
+
46
+ # Ground truth row count (set at reset)
47
+ ground_truth_row_count: int = 0
48
+
49
+
50
+ # ── 1. Input truncation ───────────────────────────────────────────────────────
51
+
52
+ def check_and_truncate_input(action: str) -> tuple[str, float, bool]:
53
+ """
54
+ Returns (truncated_action, penalty, was_truncated).
55
+ Penalty is -0.02 if truncated, else 0.0.
56
+ """
57
+ if len(action) > MAX_ACTION_CHARS:
58
+ logger.warning(
59
+ "Input truncated: original length %d > %d", len(action), MAX_ACTION_CHARS
60
+ )
61
+ return action[:MAX_ACTION_CHARS], -0.02, True
62
+ return action, 0.0, False
63
+
64
+
65
+ # ── 2. Validate cooldown ──────────────────────────────────────────────────────
66
+
67
+ def check_validate_cooldown(state: AntiExploitState) -> tuple[bool, str]:
68
+ """
69
+ Returns (allowed, error_message).
70
+ Validate is blocked if steps_since_last_validate < VALIDATE_COOLDOWN.
71
+ """
72
+ if state.steps_since_last_validate < VALIDATE_COOLDOWN and state.validate_call_count > 0:
73
+ return False, (
74
+ f"Validate on cooldown. Take {VALIDATE_COOLDOWN - state.steps_since_last_validate} "
75
+ f"more action(s) before validating again."
76
+ )
77
+ return True, ""
78
+
79
+
80
+ def get_validate_reward(state: AntiExploitState) -> float:
81
+ """Returns +0.02 for first FREE_VALIDATES calls, -0.01 thereafter."""
82
+ if state.validate_call_count < FREE_VALIDATES:
83
+ return 0.02
84
+ return -0.01
85
+
86
+
87
+ def record_validate(state: AntiExploitState):
88
+ state.validate_call_count += 1
89
+ state.steps_since_last_validate = 0
90
+
91
+
92
+ def record_non_validate_step(state: AntiExploitState):
93
+ state.steps_since_last_validate += 1
94
+
95
+
96
+ # ── 3. Recommendation staleness ───────────────────────────────────────────────
97
+
98
+ def check_recommendation_staleness(
99
+ rec_id: int,
100
+ current_session_id: str,
101
+ recommendation_session_id: str,
102
+ ) -> tuple[bool, str]:
103
+ """Returns (is_fresh, error_message)."""
104
+ if current_session_id != recommendation_session_id:
105
+ return False, (
106
+ f"Stale recommendation ID {rec_id}. "
107
+ "Please re-query for fresh recommendations."
108
+ )
109
+ return True, ""
110
+
111
+
112
+ # ── 4. Ground truth immutability ──────────────────────────────────────────────
113
+
114
+ def assert_ground_truth_intact(
115
+ ground_truth_len: int,
116
+ original_gt_len: int,
117
+ ) -> tuple[bool, str]:
118
+ """Asserts ground truth has not been mutated."""
119
+ if ground_truth_len != original_gt_len:
120
+ msg = (
121
+ f"INTEGRITY VIOLATION: ground_truth row count changed "
122
+ f"({original_gt_len} → {ground_truth_len}). This should never happen."
123
+ )
124
+ logger.critical(msg)
125
+ return False, msg
126
+ return True, ""
127
+
128
+
129
+ # ── 5. Catastrophic data loss ─────────────────────────────────────────────────
130
+
131
+ def check_catastrophic_data_loss(
132
+ current_rows: int,
133
+ original_rows: int,
134
+ ) -> tuple[bool, str]:
135
+ """Returns (is_catastrophic, message)."""
136
+ ratio = current_rows / max(original_rows, 1)
137
+ if ratio < 0.50:
138
+ msg = (
139
+ f"CATASTROPHIC DATA LOSS: only {current_rows}/{original_rows} rows remain "
140
+ f"({ratio*100:.1f}%). Episode terminated."
141
+ )
142
+ logger.error(msg)
143
+ return True, msg
144
+ return False, ""
145
+
146
+
147
+ # ── 6 & 7. Duplicate apply and session limit ──────────────────────────────────
148
+
149
+ def check_apply_allowed(
150
+ rec_id: int,
151
+ state: AntiExploitState,
152
+ ) -> tuple[bool, str]:
153
+ """
154
+ Returns (allowed, error_message).
155
+ Blocks: duplicate ID in session, or session apply limit reached.
156
+ """
157
+ if state.applies_this_session >= MAX_APPLIES_PER_SESSION:
158
+ return False, (
159
+ f"Max {MAX_APPLIES_PER_SESSION} applies per query session reached. "
160
+ "Please re-query for more options."
161
+ )
162
+ if rec_id in state.applied_ids_this_session:
163
+ return False, (
164
+ f"Recommendation {rec_id} has already been applied this session. "
165
+ "Duplicate apply not allowed."
166
+ )
167
+ return True, ""
168
+
169
+
170
+ def record_apply(rec_id: int, state: AntiExploitState):
171
+ state.applied_ids_this_session.add(rec_id)
172
+ state.applies_this_session += 1
173
+
174
+
175
+ def reset_session_apply_state(state: AntiExploitState):
176
+ """Call this whenever a new query_X command resets the session."""
177
+ state.applied_ids_this_session = set()
178
+ state.applies_this_session = 0
179
+
180
+
181
+ # ── 8. Episode timeout ────────────────────────────────────────────────────────
182
+
183
+ def check_episode_timeout(state: AntiExploitState) -> tuple[bool, str]:
184
+ elapsed = time.time() - state.episode_start_time
185
+ if elapsed > EPISODE_TIMEOUT_SECS:
186
+ msg = (
187
+ f"Episode wall-clock timeout ({elapsed:.0f}s > {EPISODE_TIMEOUT_SECS}s). "
188
+ "Forcing submit. Penalty: -0.10."
189
+ )
190
+ logger.warning(msg)
191
+ return True, msg
192
+ return False, ""
193
+
194
+
195
+ # ── 9. Step timeout context manager ──────────────────────────────────────────
196
+
197
+ class StepTimeoutError(Exception):
198
+ pass
199
+
200
+
201
+ def validate_calls_remaining(state: AntiExploitState) -> int:
202
+ return max(0, FREE_VALIDATES - state.validate_call_count)
server/app.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application for the Data Centric Env Environment."""
2
+
3
+ import sys
4
+ import os
5
+
6
+ # Ensure the project root is on the path regardless of how the server is launched
7
+ _HERE = os.path.dirname(os.path.abspath(__file__))
8
+ _ROOT = os.path.dirname(_HERE)
9
+ if _ROOT not in sys.path:
10
+ sys.path.insert(0, _ROOT)
11
+
12
+ try:
13
+ from openenv.core.env_server.http_server import create_app
14
+ except Exception as e:
15
+ raise ImportError(
16
+ "openenv-core is required. Install with: pip install openenv-core"
17
+ ) from e
18
+
19
+ try:
20
+ from ..models import DataCentricAction, DataCentricObservation
21
+ from .data_centric_environment import DataCentricEnvironment
22
+ except (ImportError, ModuleNotFoundError):
23
+ from models import DataCentricAction, DataCentricObservation
24
+ from server.data_centric_environment import DataCentricEnvironment
25
+
26
+ from fastapi.responses import HTMLResponse
27
+
28
+ # max_concurrent_envs=1: avoids concurrency safety check that instantiates the env
29
+ # at startup (which would load sklearn and pandas, slowing HF health check).
30
+ # Increase if running on a paid Space with more RAM.
31
+ app = create_app(
32
+ DataCentricEnvironment,
33
+ DataCentricAction,
34
+ DataCentricObservation,
35
+ env_name="data_centric_env",
36
+ max_concurrent_envs=1,
37
+ )
38
+
39
+ _LANDING_HTML = """<!DOCTYPE html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="UTF-8">
43
+ <title>Data-Centric AI RL Environment</title>
44
+ <style>
45
+ body { font-family: system-ui, sans-serif; background: #0f1117; color: #e0e0e0;
46
+ display: flex; justify-content: center; padding: 60px 20px; margin: 0; }
47
+ .card { max-width: 700px; width: 100%; }
48
+ h1 { font-size: 2rem; margin-bottom: 4px; color: #fff; }
49
+ .badge { display:inline-block; background:#238636; color:#fff; border-radius:12px;
50
+ padding:2px 10px; font-size:0.8rem; margin-bottom:24px; }
51
+ p { color: #aaa; line-height: 1.6; }
52
+ .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 28px 0; }
53
+ .endpoint { background: #1c1f26; border: 1px solid #30363d; border-radius: 8px;
54
+ padding: 14px 18px; }
55
+ .endpoint code { color: #58a6ff; font-size: 0.9rem; }
56
+ .endpoint small { color: #666; display:block; margin-top:4px; }
57
+ a { color: #58a6ff; text-decoration: none; }
58
+ a:hover { text-decoration: underline; }
59
+ .footer { margin-top: 32px; font-size: 0.8rem; color: #555; border-top: 1px solid #21262d; padding-top: 16px; }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <div class="card">
64
+ <h1>&#x1F9E0; Data-Centric AI Environment</h1>
65
+ <span class="badge">&#x2022; Running</span>
66
+ <p>An <a href="https://github.com/meta-pytorch/OpenEnv" target="_blank">OpenEnv</a>-compliant
67
+ RL environment that trains an LLM to coordinate specialist data-cleaning agents
68
+ to improve a frozen ML classifier — without touching the model.</p>
69
+
70
+ <div class="grid">
71
+ <div class="endpoint">
72
+ <code>GET /health</code>
73
+ <small>Server health check</small>
74
+ </div>
75
+ <div class="endpoint">
76
+ <code>GET /docs</code>
77
+ <small>Interactive API docs (Swagger)</small>
78
+ </div>
79
+ <div class="endpoint">
80
+ <code>POST /reset</code>
81
+ <small>Start a new episode</small>
82
+ </div>
83
+ <div class="endpoint">
84
+ <code>POST /step</code>
85
+ <small>Execute an action</small>
86
+ </div>
87
+ <div class="endpoint">
88
+ <code>WS /ws</code>
89
+ <small>WebSocket (stateful session)</small>
90
+ </div>
91
+ <div class="endpoint">
92
+ <code>GET /state</code>
93
+ <small>Current episode state</small>
94
+ </div>
95
+ </div>
96
+
97
+ <p><strong>Quick start:</strong></p>
98
+ <pre style="background:#161b22;padding:14px;border-radius:8px;font-size:0.85rem;overflow:auto">
99
+ pip install git+https://huggingface.co/spaces/Aswini-Kumar/data-centric-env
100
+
101
+ from data_centric_env import DataCentricEnv, DataCentricAction
102
+ with DataCentricEnv(base_url="https://aswini-kumar-data-centric-env.hf.space").sync() as env:
103
+ obs = env.reset(task="task_0_tutorial", seed=42)
104
+ result = env.step(DataCentricAction(message="query_analyst"))
105
+ print(result.observation.response)</pre>
106
+
107
+ <div class="footer">
108
+ <a href="/docs">API Docs</a> &nbsp;|&nbsp;
109
+ <a href="/health">Health</a> &nbsp;|&nbsp;
110
+ <a href="https://github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env" target="_blank">GitHub</a> &nbsp;|&nbsp;
111
+ <a href="https://huggingface.co/spaces/Aswini-Kumar/data-centric-env" target="_blank">HF Space</a>
112
+ </div>
113
+ </div>
114
+ </body>
115
+ </html>"""
116
+
117
+
118
+ @app.get("/", response_class=HTMLResponse, include_in_schema=False)
119
+ @app.get("/web", response_class=HTMLResponse, include_in_schema=False)
120
+ async def landing():
121
+ """Human-readable landing page for the HF Space App tab."""
122
+ return HTMLResponse(content=_LANDING_HTML)
123
+
124
+
125
+ def main(host: str = "0.0.0.0", port: int = 7860):
126
+ import uvicorn
127
+ uvicorn.run(app, host=host, port=port)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ import argparse
132
+ parser = argparse.ArgumentParser()
133
+ parser.add_argument("--host", default="0.0.0.0")
134
+ parser.add_argument("--port", type=int, default=7860)
135
+ args = parser.parse_args()
136
+ main(host=args.host, port=args.port)
server/data_centric_environment.py ADDED
@@ -0,0 +1,727 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main DataCentric RL Environment."""
2
+
3
+ import logging
4
+ import time
5
+ from copy import deepcopy
6
+ from typing import Any, Dict, List, Optional
7
+ from uuid import uuid4
8
+
9
+ import pandas as pd
10
+ from openenv.core.env_server.interfaces import Environment
11
+ from openenv.core.env_server.types import State
12
+
13
+ try:
14
+ from ..models import DataCentricAction, DataCentricObservation
15
+ except ImportError:
16
+ try:
17
+ from models import DataCentricAction, DataCentricObservation
18
+ except ImportError:
19
+ import sys as _sys, os as _os
20
+ _sys.path.insert(0, _os.path.dirname(_os.path.dirname(_os.path.abspath(__file__))))
21
+ from models import DataCentricAction, DataCentricObservation
22
+
23
+ try:
24
+ from .anti_exploit import (
25
+ AntiExploitState, assert_ground_truth_intact,
26
+ check_and_truncate_input, check_apply_allowed,
27
+ check_catastrophic_data_loss, check_episode_timeout,
28
+ check_validate_cooldown, get_validate_reward, record_apply,
29
+ record_non_validate_step, record_validate, reset_session_apply_state,
30
+ validate_calls_remaining,
31
+ )
32
+ from .dataset_generator import TASK_CONFIGS, generate_dataset
33
+ from .grader import (
34
+ compute_accuracy_reward, compute_efficiency_reward,
35
+ compute_lightweight_score, compute_preservation_reward,
36
+ compute_process_reward, compute_step_reward, compute_total_reward,
37
+ )
38
+ from .model_evaluator import ModelEvaluator
39
+ from .specialist_agents import (
40
+ AugmenterAgent, AnalystAgent, BalancerAgent, CleanerAgent,
41
+ SessionRegistry, ValidatorAgent, compute_drift, format_drift_summary,
42
+ )
43
+ except ImportError:
44
+ from server.anti_exploit import (
45
+ AntiExploitState, assert_ground_truth_intact,
46
+ check_and_truncate_input, check_apply_allowed,
47
+ check_catastrophic_data_loss, check_episode_timeout,
48
+ check_validate_cooldown, get_validate_reward, record_apply,
49
+ record_non_validate_step, record_validate, reset_session_apply_state,
50
+ validate_calls_remaining,
51
+ )
52
+ from server.dataset_generator import TASK_CONFIGS, generate_dataset
53
+ from server.grader import (
54
+ compute_accuracy_reward, compute_efficiency_reward,
55
+ compute_lightweight_score, compute_preservation_reward,
56
+ compute_process_reward, compute_step_reward, compute_total_reward,
57
+ )
58
+ from server.model_evaluator import ModelEvaluator
59
+ from server.specialist_agents import (
60
+ AugmenterAgent, AnalystAgent, BalancerAgent, CleanerAgent,
61
+ SessionRegistry, ValidatorAgent, compute_drift, format_drift_summary,
62
+ )
63
+
64
+ logger = logging.getLogger(__name__)
65
+
66
+ AVAILABLE_COMMANDS = """Available commands:
67
+ inspect_dataset — shape, dtypes, missing, class distribution
68
+ inspect_model — accuracy (RF + LR), F1, feature importance
69
+ query_analyst — holistic diagnosis + prioritised action plan (costs 1 budget)
70
+ query_cleaner — get cleaning recommendations
71
+ query_augmenter [class] — get augmentation suggestions
72
+ query_balancer — get resampling recommendations
73
+ query_validator — check rule violations (costs 2 budget)
74
+ apply [id] — apply recommendation by ID
75
+ reject [id] — reject a recommendation
76
+ undo — revert last apply (max 3 levels)
77
+ validate — retrain and score (cooldown applies)
78
+ submit — finalize episode"""
79
+
80
+
81
+ class DataCentricEnvironment(Environment):
82
+ """Data-Centric AI RL Environment."""
83
+
84
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
85
+
86
+ def __init__(self):
87
+ self._state = State(episode_id=str(uuid4()), step_count=0)
88
+ self._ground_truth: Optional[pd.DataFrame] = None
89
+ self._working_copy: Optional[pd.DataFrame] = None
90
+ self._metadata: Dict[str, Any] = {}
91
+ self._action_history: List[str] = []
92
+ self._exploit: Optional[AntiExploitState] = None
93
+ # fast_mode=True: uses n_estimators=20 for training rollouts (~4x faster)
94
+ self._evaluator = ModelEvaluator(fast_mode=True)
95
+ self._session_registry = SessionRegistry()
96
+ self._cleaner = CleanerAgent()
97
+ self._augmenter = AugmenterAgent()
98
+ self._balancer = BalancerAgent()
99
+ self._validator = ValidatorAgent()
100
+ self._analyst = AnalystAgent()
101
+ self._current_accuracy: float = 0.0
102
+ self._previous_accuracy: float = 0.0
103
+ self._active_session: str = "none"
104
+ self._task: str = "task_0_tutorial"
105
+ # Snapshot stack for undo command (max 3 snapshots)
106
+ self._dataset_history: List[pd.DataFrame] = []
107
+ self._max_history: int = 3
108
+
109
+ # ── reset ────────────────────────────────────────────────────────────────
110
+
111
+ def reset(self, task: str = "task_0_tutorial", seed: int = 42) -> DataCentricObservation:
112
+ self._task = task if task in TASK_CONFIGS else "task_0_tutorial"
113
+ cfg = TASK_CONFIGS[self._task]
114
+
115
+ self._ground_truth, self._working_copy, self._metadata = generate_dataset(
116
+ self._task, seed=seed
117
+ )
118
+ self._state = State(episode_id=str(uuid4()), step_count=0)
119
+ self._action_history = []
120
+ self._exploit = AntiExploitState(
121
+ episode_start_time=time.time(),
122
+ ground_truth_row_count=len(self._ground_truth),
123
+ )
124
+ self._evaluator.invalidate_cache()
125
+ self._session_registry = SessionRegistry()
126
+ self._active_session = "none"
127
+ self._dataset_history = [] # clear snapshot stack on reset
128
+ reset_session_apply_state(self._exploit)
129
+
130
+ # Store episode-start missing count for quality score baseline
131
+ self._metadata["initial_missing"] = int(self._working_copy.isnull().sum().sum())
132
+ self._metadata["baseline_accuracy"] = cfg["baseline_accuracy"]
133
+
134
+ baseline = cfg["baseline_accuracy"]
135
+ self._current_accuracy = baseline
136
+ self._previous_accuracy = baseline
137
+ quality = compute_lightweight_score(
138
+ self._working_copy, self._ground_truth,
139
+ self._metadata["original_length"], self._metadata["col_meta"],
140
+ initial_missing=self._metadata["initial_missing"],
141
+ )
142
+ wc = self._working_copy
143
+ return DataCentricObservation(
144
+ response=(
145
+ f"Episode started: {self._task}\n"
146
+ f"Baseline accuracy: {baseline:.4f} | Target: {cfg['target_accuracy']:.4f}\n"
147
+ f"Dataset: {len(wc)} rows × {len(wc.columns)-1} features\n"
148
+ f"Budget: {cfg['budget']} steps\n\n{AVAILABLE_COMMANDS}"
149
+ ),
150
+ current_accuracy=baseline,
151
+ baseline_accuracy=baseline,
152
+ target_accuracy=cfg["target_accuracy"],
153
+ estimated_quality=quality,
154
+ dataset_shape=f"{len(wc)} rows × {len(wc.columns)-1} columns",
155
+ rows_preserved_pct=1.0,
156
+ budget_remaining=cfg["budget"],
157
+ step_number=0,
158
+ max_steps=cfg["budget"],
159
+ active_session="none",
160
+ validate_calls_remaining=3,
161
+ done=False,
162
+ reward=0.0,
163
+ )
164
+
165
+ # ── step ─────────────────────────────────────────────────────────────────
166
+
167
+ def step(self, action: DataCentricAction) -> DataCentricObservation:
168
+ if self._working_copy is None:
169
+ return self._error_obs("Call reset() first.")
170
+
171
+ # Episode timeout
172
+ timeout, tmsg = check_episode_timeout(self._exploit)
173
+ if timeout:
174
+ return self._do_submit(penalty=-0.10, extra_msg=tmsg)
175
+
176
+ # Input truncation
177
+ raw_msg = action.message
178
+ msg, trunc_penalty, was_truncated = check_and_truncate_input(raw_msg)
179
+ if was_truncated:
180
+ logger.warning("Input truncated.")
181
+
182
+ cfg = TASK_CONFIGS[self._task]
183
+ self._state.step_count += 1
184
+ step_num = self._state.step_count
185
+ budget_remaining = cfg["budget"] - step_num
186
+ cmd_parts = msg.strip().split()
187
+ cmd = cmd_parts[0].lower() if cmd_parts else ""
188
+
189
+ # Out of budget → force submit
190
+ if budget_remaining < 0:
191
+ return self._do_submit(penalty=0.0, extra_msg="Budget exhausted.")
192
+
193
+ # Record action
194
+ self._action_history.append(msg)
195
+
196
+ # Process reward component (computed for all actions)
197
+ r_process = compute_process_reward(self._action_history[:-1], msg)
198
+
199
+ # Route command
200
+ if cmd == "inspect_dataset":
201
+ obs = self._cmd_inspect_dataset(step_num, budget_remaining, r_process, trunc_penalty)
202
+ elif cmd == "inspect_model":
203
+ obs = self._cmd_inspect_model(step_num, budget_remaining, r_process, trunc_penalty)
204
+ elif cmd == "query_cleaner":
205
+ obs = self._cmd_query_cleaner(step_num, budget_remaining, r_process, trunc_penalty)
206
+ elif cmd == "query_augmenter":
207
+ cls = cmd_parts[1] if len(cmd_parts) > 1 else None
208
+ obs = self._cmd_query_augmenter(cls, step_num, budget_remaining, r_process, trunc_penalty)
209
+ elif cmd == "query_balancer":
210
+ obs = self._cmd_query_balancer(step_num, budget_remaining, r_process, trunc_penalty)
211
+ elif cmd == "query_analyst":
212
+ obs = self._cmd_query_analyst(step_num, budget_remaining, r_process, trunc_penalty)
213
+ elif cmd == "query_validator":
214
+ obs = self._cmd_query_validator(step_num, budget_remaining, r_process, trunc_penalty)
215
+ elif cmd == "apply":
216
+ try:
217
+ rec_id = int(cmd_parts[1]) if len(cmd_parts) > 1 else -1
218
+ except ValueError:
219
+ rec_id = -1
220
+ obs = self._cmd_apply(rec_id, step_num, budget_remaining, r_process, trunc_penalty)
221
+ elif cmd == "reject":
222
+ try:
223
+ rec_id = int(cmd_parts[1]) if len(cmd_parts) > 1 else -1
224
+ except ValueError:
225
+ rec_id = -1
226
+ obs = self._cmd_reject(rec_id, step_num, budget_remaining, r_process, trunc_penalty)
227
+ elif cmd == "validate":
228
+ obs = self._cmd_validate(step_num, budget_remaining, r_process, trunc_penalty)
229
+ elif cmd == "submit":
230
+ obs = self._do_submit()
231
+ elif cmd == "undo":
232
+ obs = self._cmd_undo(step_num, budget_remaining, r_process, trunc_penalty)
233
+ else:
234
+ obs = self._unknown_cmd_obs(msg, step_num, budget_remaining, r_process + trunc_penalty)
235
+
236
+ if cmd != "validate":
237
+ record_non_validate_step(self._exploit)
238
+
239
+ return obs
240
+
241
+ # ── command handlers ─────────────────────────────────────────────────────
242
+
243
+ def _cmd_inspect_dataset(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
244
+ wc = self._working_copy
245
+ orig_len = self._metadata["original_length"]
246
+ missing = wc.isnull().sum()
247
+ missing_str = "\n".join(f" {c}: {v}" for c, v in missing.items() if v > 0) or " None"
248
+ vc = wc["target"].value_counts().sort_index()
249
+ class_str = ", ".join(f"class {k}: {v}" for k, v in vc.items())
250
+ rows_pct = len(wc) / orig_len
251
+ response = (
252
+ f"=== Dataset Inspection ===\n"
253
+ f"Shape: {len(wc)} rows × {len(wc.columns)-1} features\n"
254
+ f"Original rows: {orig_len} | Preserved: {rows_pct*100:.1f}%\n"
255
+ f"Duplicates: {wc.duplicated().sum()}\n"
256
+ f"Missing values:\n{missing_str}\n"
257
+ f"Class distribution: {class_str}\n"
258
+ f"Dtypes: {dict(wc.dtypes.astype(str))}"
259
+ )
260
+ reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
261
+ return self._make_obs(response, step, budget, reward)
262
+
263
+ def _cmd_inspect_model(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
264
+ acc, per_class, from_cache, lr_acc = self._evaluator.evaluate(
265
+ self._working_copy, self._ground_truth
266
+ )
267
+ cache_label = " (cached)" if from_cache else ""
268
+ lines = [f"=== Model Inspection{cache_label} ===",
269
+ f"RF Accuracy: {acc:.4f}",
270
+ f"LR Accuracy: {lr_acc:.4f} (secondary — diagnostic only)"]
271
+ for cls, metrics in per_class.items():
272
+ if isinstance(metrics, dict):
273
+ lines.append(
274
+ f" Class {cls}: precision={metrics.get('precision',0):.3f} "
275
+ f"recall={metrics.get('recall',0):.3f} "
276
+ f"f1={metrics.get('f1-score',0):.3f}"
277
+ )
278
+ feat_text = self._evaluator.feature_importance_text()
279
+ if feat_text:
280
+ lines.append(feat_text)
281
+ response = "\n".join(lines)
282
+ reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
283
+ return self._make_obs(response, step, budget, reward)
284
+
285
+ def _cmd_query_cleaner(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
286
+ reset_session_apply_state(self._exploit)
287
+ recs = self._cleaner.query(
288
+ self._working_copy, self._session_registry, self._metadata["col_meta"]
289
+ )
290
+ self._active_session = f"cleaner:{self._session_registry.current_session_id[:8]}"
291
+ lines = ["=== Cleaner Recommendations ==="]
292
+ for r in recs:
293
+ lines.append(
294
+ f"[{r.id}] {r.description}\n"
295
+ f" type={r.action_type} impact={r.estimated_impact:+.3f} "
296
+ f"confidence={r.confidence:.2f}"
297
+ )
298
+ response = "\n".join(lines) if recs else "No cleaning issues detected."
299
+ reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
300
+ return self._make_obs(response, step, budget, reward)
301
+
302
+ def _cmd_query_augmenter(self, cls, step, budget, r_process, trunc_pen) -> DataCentricObservation:
303
+ reset_session_apply_state(self._exploit)
304
+ recs = self._augmenter.query(self._working_copy, self._session_registry, cls)
305
+ self._active_session = f"augmenter:{self._session_registry.current_session_id[:8]}"
306
+ lines = ["=== Augmenter Recommendations ==="]
307
+ for r in recs:
308
+ lines.append(
309
+ f"[{r.id}] {r.description}\n"
310
+ f" type={r.action_type} impact={r.estimated_impact:+.3f} "
311
+ f"confidence={r.confidence:.2f}"
312
+ )
313
+ response = "\n".join(lines) if recs else "No augmentation needed."
314
+ reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
315
+ return self._make_obs(response, step, budget, reward)
316
+
317
+ def _cmd_query_balancer(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
318
+ reset_session_apply_state(self._exploit)
319
+ recs = self._balancer.query(self._working_copy, self._session_registry)
320
+ self._active_session = f"balancer:{self._session_registry.current_session_id[:8]}"
321
+ lines = ["=== Balancer Recommendations ==="]
322
+ for r in recs:
323
+ lines.append(
324
+ f"[{r.id}] {r.description}\n"
325
+ f" type={r.action_type} impact={r.estimated_impact:+.3f} "
326
+ f"confidence={r.confidence:.2f}"
327
+ )
328
+ response = "\n".join(lines) if recs else "Dataset is already balanced."
329
+ reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
330
+ return self._make_obs(response, step, budget, reward)
331
+
332
+ def _cmd_query_analyst(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
333
+ """Holistic diagnosis + prioritised action plan. Costs 1 budget."""
334
+ # Costs 1 extra budget step
335
+ self._state.step_count += 1
336
+ plan = self._analyst.query(
337
+ self._working_copy,
338
+ self._metadata["col_meta"],
339
+ self._current_accuracy,
340
+ TASK_CONFIGS[self._task]["target_accuracy"],
341
+ budget - 1,
342
+ )
343
+ response = f"=== Analyst Report (costs 1 budget) ===\n{plan}"
344
+ reward = compute_total_reward(0.0, r_process + 0.02, 0.0) + trunc_pen # small bonus for planning
345
+ budget_remaining = TASK_CONFIGS[self._task]["budget"] - self._state.step_count
346
+ return self._make_obs(response, step, budget_remaining, reward)
347
+
348
+ def _cmd_query_validator(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
349
+ # Costs 2 budget
350
+ self._state.step_count += 1
351
+ violations = self._validator.query(self._working_copy, self._metadata["col_meta"])
352
+ lines = ["=== Validator Report (costs 2 budget) ==="]
353
+ if violations:
354
+ for v in violations:
355
+ lines.append(
356
+ f" [{v.severity}] [{v.column}] rule={v.rule} count={v.count}\n {v.description}"
357
+ )
358
+ else:
359
+ lines.append(" No rule violations found.")
360
+ response = "\n".join(lines)
361
+ reward = compute_total_reward(0.0, r_process, 0.0) + trunc_pen
362
+ budget_remaining = TASK_CONFIGS[self._task]["budget"] - self._state.step_count
363
+ return self._make_obs(response, step, budget_remaining, reward)
364
+
365
+ def _cmd_apply(self, rec_id, step, budget, r_process, trunc_pen) -> DataCentricObservation:
366
+ if rec_id < 1:
367
+ # Error: return 0 reward (no penalty, no bonus)
368
+ return self._make_obs("Error: invalid recommendation ID.", step, budget, 0.0)
369
+
370
+ # Check apply allowed (duplicate / session limit) — 0 reward on error
371
+ allowed, err = check_apply_allowed(rec_id, self._exploit)
372
+ if not allowed:
373
+ return self._make_obs(f"Error: {err}", step, budget, 0.0)
374
+
375
+ # Get recommendation (staleness check) — 0 reward, no penalty
376
+ rec = self._session_registry.get(rec_id, self._session_registry.current_session_id)
377
+ if rec is None:
378
+ return self._make_obs(
379
+ f"Error: stale recommendation ID {rec_id}. Please re-query for fresh recommendations.",
380
+ step, budget, 0.0
381
+ )
382
+
383
+ # Capture quality before mutation for step reward
384
+ quality_before = compute_lightweight_score(
385
+ self._working_copy, self._ground_truth,
386
+ self._metadata["original_length"], self._metadata["col_meta"],
387
+ initial_missing=self._metadata.get("initial_missing"),
388
+ )
389
+
390
+ # Execute payload
391
+ payload = rec._payload
392
+ action_type = payload.get("action", "")
393
+ wc = self._working_copy
394
+ orig_len = self._metadata["original_length"]
395
+ pre_rows = len(wc)
396
+ pre_missing = int(wc.isnull().sum().sum())
397
+ pre_dups = int(wc.duplicated().sum())
398
+
399
+ # Save snapshot for undo before mutating
400
+ self._dataset_history.append(self._working_copy.copy())
401
+ if len(self._dataset_history) > self._max_history:
402
+ self._dataset_history.pop(0)
403
+
404
+ try:
405
+ if action_type == "fill_missing":
406
+ col = payload["column"]
407
+ strategy = payload.get("strategy", "mean") # honor smarter CleanerAgent choice
408
+ numeric = pd.to_numeric(wc[col], errors="coerce")
409
+ if strategy == "median":
410
+ fill_val = float(numeric.median())
411
+ else:
412
+ fill_val = float(numeric.mean())
413
+ wc[col] = numeric.fillna(fill_val)
414
+ self._working_copy = wc
415
+
416
+ elif action_type == "remove_duplicates":
417
+ self._working_copy = wc.drop_duplicates().reset_index(drop=True)
418
+
419
+ elif action_type == "fix_type_errors":
420
+ col = payload["column"]
421
+ numeric = pd.to_numeric(wc[col], errors="coerce")
422
+ mean_val = float(numeric.mean())
423
+ wc[col] = numeric.fillna(mean_val)
424
+ self._working_copy = wc
425
+
426
+ elif action_type == "augment_class":
427
+ cls_int = payload["class"]
428
+ n_synth = payload["n_synth"]
429
+ cls_rows = wc[wc["target"] == cls_int]
430
+ if len(cls_rows) > 0:
431
+ synth = cls_rows.sample(n=n_synth, replace=True, random_state=42)
432
+ noise_cols = [c for c in synth.columns if c != "target"]
433
+ for c in noise_cols:
434
+ try:
435
+ synth[c] = pd.to_numeric(synth[c], errors="coerce")
436
+ synth[c] = synth[c] + synth[c].std() * 0.1
437
+ except Exception:
438
+ pass
439
+ self._working_copy = pd.concat([wc, synth], ignore_index=True)
440
+
441
+ elif action_type == "oversample":
442
+ cls_int = payload["class"]
443
+ target_count = payload["target_count"]
444
+ cls_rows = wc[wc["target"] == cls_int]
445
+ n_needed = max(0, target_count - len(cls_rows))
446
+ if n_needed > 0:
447
+ extra = cls_rows.sample(n=n_needed, replace=True, random_state=42)
448
+ self._working_copy = pd.concat([wc, extra], ignore_index=True)
449
+
450
+ elif action_type == "undersample":
451
+ cls_int = payload["class"]
452
+ target_count = payload["target_count"]
453
+ cls_rows = wc[wc["target"] == cls_int]
454
+ if len(cls_rows) > target_count:
455
+ keep = cls_rows.sample(n=target_count, random_state=42)
456
+ other = wc[wc["target"] != cls_int]
457
+ self._working_copy = pd.concat([keep, other], ignore_index=True)
458
+
459
+ elif action_type == "remove_outlier_rows":
460
+ col = payload["column"]
461
+ pct = payload.get("pct", 5)
462
+ try:
463
+ numeric = pd.to_numeric(wc[col], errors="coerce")
464
+ threshold = float(numeric.quantile(pct / 100))
465
+ self._working_copy = wc[pd.to_numeric(wc[col], errors="coerce") >= threshold].reset_index(drop=True)
466
+ except Exception:
467
+ pass
468
+
469
+ except Exception as exc:
470
+ logger.exception("Error executing apply: %s", exc)
471
+ return self._make_obs(f"Error executing recommendation: {exc}", step, budget, 0.0)
472
+
473
+ record_apply(rec_id, self._exploit)
474
+
475
+ # Ground truth immutability assertion — must never change
476
+ gt_ok, gt_msg = assert_ground_truth_intact(
477
+ len(self._ground_truth), self._exploit.ground_truth_row_count
478
+ )
479
+ if not gt_ok:
480
+ logger.critical(gt_msg)
481
+ return self._do_submit(penalty=-1.0, extra_msg=gt_msg)
482
+
483
+ wc_new = self._working_copy
484
+ post_rows = len(wc_new)
485
+ post_missing = int(wc_new.isnull().sum().sum())
486
+ post_dups = int(wc_new.duplicated().sum())
487
+ rows_pct = post_rows / orig_len
488
+
489
+ # Catastrophic data loss
490
+ catastro, cmsg = check_catastrophic_data_loss(post_rows, orig_len)
491
+ if catastro:
492
+ return self._do_submit(penalty=-0.40, extra_msg=cmsg)
493
+
494
+ # Preservation reward
495
+ r_preservation = compute_preservation_reward(post_rows, orig_len)
496
+
497
+ # Lightweight quality (use episode-start missing count as denominator)
498
+ quality = compute_lightweight_score(
499
+ wc_new, self._ground_truth, orig_len, self._metadata["col_meta"],
500
+ initial_missing=self._metadata.get("initial_missing"),
501
+ )
502
+
503
+ # Build rich feedback with drift detection
504
+ cfg = TASK_CONFIGS[self._task]
505
+ missing_status = "OK" if post_missing == 0 else f"{post_missing} remaining"
506
+ dup_status = "OK" if post_dups == 0 else f"{post_dups} remaining"
507
+ drift = compute_drift(self._working_copy, self._ground_truth)
508
+ drift_summary = format_drift_summary(drift)
509
+ response = (
510
+ f"Applied: {action_type} [{rec.description[:80]}]\n\n"
511
+ f"Dataset health check:\n"
512
+ f" Missing values: {missing_status} (was {pre_missing})\n"
513
+ f" Duplicates: {dup_status} (was {pre_dups})\n"
514
+ f" Row count: {post_rows}/{orig_len} ({rows_pct*100:.1f}% preserved)\n"
515
+ f" {drift_summary}\n\n"
516
+ f"Estimated quality score: {quality:.4f}\n"
517
+ f"Budget remaining: {budget}"
518
+ )
519
+
520
+ reward = compute_total_reward(
521
+ 0.0, r_process, r_preservation,
522
+ reward_step=compute_step_reward(
523
+ f"apply {rec_id}", quality_before, quality, rows_pct
524
+ ),
525
+ ) + trunc_pen
526
+ self._evaluator.invalidate_cache()
527
+ return self._make_obs(response, step, budget, reward, quality=quality,
528
+ rows_pct=rows_pct)
529
+
530
+ def _cmd_reject(self, rec_id, step, budget, r_process, trunc_pen) -> DataCentricObservation:
531
+ response = (
532
+ f"Recommendation {rec_id} rejected. It will not appear in future queries."
533
+ if rec_id >= 1 else "Error: invalid recommendation ID."
534
+ )
535
+ reward = compute_total_reward(0.0, r_process + 0.01, 0.0) + trunc_pen
536
+ return self._make_obs(response, step, budget, reward)
537
+
538
+ def _cmd_undo(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
539
+ """Restore previous dataset state (max 3 levels deep)."""
540
+ if self._dataset_history:
541
+ self._working_copy = self._dataset_history.pop()
542
+ self._evaluator.invalidate_cache()
543
+ orig_len = self._metadata["original_length"]
544
+ rows_pct = len(self._working_copy) / orig_len
545
+ quality = compute_lightweight_score(
546
+ self._working_copy, self._ground_truth,
547
+ orig_len, self._metadata["col_meta"],
548
+ initial_missing=self._metadata.get("initial_missing"),
549
+ )
550
+ response = (
551
+ f"Undo successful. Reverted to previous dataset state.\n"
552
+ f"Row count: {len(self._working_copy)}/{orig_len} ({rows_pct*100:.1f}% preserved)\n"
553
+ f"Estimated quality: {quality:.4f}\n"
554
+ f"Snapshots remaining: {len(self._dataset_history)}"
555
+ )
556
+ reward = compute_total_reward(0.0, r_process - 0.03, 0.0) + trunc_pen # small cost
557
+ else:
558
+ response = "Nothing to undo. No previous state available."
559
+ reward = compute_total_reward(0.0, r_process - 0.05, 0.0) + trunc_pen # larger cost
560
+ return self._make_obs(response, step, budget, reward)
561
+
562
+
563
+ def _cmd_validate(self, step, budget, r_process, trunc_pen) -> DataCentricObservation:
564
+ allowed, cooldown_msg = check_validate_cooldown(self._exploit)
565
+ if not allowed:
566
+ return self._make_obs(cooldown_msg, step, budget, 0.0)
567
+
568
+ prev_rf = self._evaluator.last_accuracy
569
+ prev_lr = self._evaluator.last_lr_accuracy
570
+
571
+ acc, per_class, from_cache, lr_acc = self._evaluator.evaluate(
572
+ self._working_copy, self._ground_truth
573
+ )
574
+ cache_label = " (cached)" if from_cache else ""
575
+
576
+ if from_cache:
577
+ r_validate = 0.0
578
+ else:
579
+ r_validate = get_validate_reward(self._exploit)
580
+ record_validate(self._exploit)
581
+
582
+ r_accuracy = compute_accuracy_reward(
583
+ acc, self._current_accuracy,
584
+ self._metadata["baseline_accuracy"],
585
+ TASK_CONFIGS[self._task]["target_accuracy"],
586
+ )
587
+ self._previous_accuracy = self._current_accuracy
588
+ self._current_accuracy = acc
589
+
590
+ target = TASK_CONFIGS[self._task]["target_accuracy"]
591
+ agreement = self._evaluator.agreement_signal(acc, lr_acc, prev_rf, prev_lr)
592
+ feat_text = self._evaluator.feature_importance_text()
593
+
594
+ lines = [
595
+ f"=== Validate{cache_label} ===",
596
+ f"RF Accuracy: {acc:.4f} (primary)",
597
+ f"LR Accuracy: {lr_acc:.4f} (secondary)",
598
+ f"Agreement: {agreement}",
599
+ ]
600
+ for cls, metrics in per_class.items():
601
+ if isinstance(metrics, dict):
602
+ lines.append(
603
+ f" Class {cls}: p={metrics.get('precision',0):.3f} "
604
+ f"r={metrics.get('recall',0):.3f} f1={metrics.get('f1-score',0):.3f}"
605
+ )
606
+ lines.append(f"Target: {target:.4f} | {'HIT ✓' if acc >= target else 'Not yet'}")
607
+ if feat_text:
608
+ lines.append(feat_text)
609
+ response = "\n".join(lines)
610
+
611
+ reward = compute_total_reward(r_accuracy, r_process + r_validate, 0.0) + trunc_pen
612
+ return self._make_obs(response, step, budget, reward)
613
+
614
+ # ── submit ────────────────────────────────────────────────────────────────
615
+
616
+ def _do_submit(self, penalty: float = 0.0, extra_msg: str = "") -> DataCentricObservation:
617
+ cfg = TASK_CONFIGS[self._task]
618
+ orig_len = self._metadata["original_length"]
619
+ budget_remaining = cfg["budget"] - self._state.step_count
620
+
621
+ # Final accuracy
622
+ acc, per_class, _, lr_acc = self._evaluator.evaluate(
623
+ self._working_copy, self._ground_truth
624
+ )
625
+ self._current_accuracy = acc
626
+
627
+ r_accuracy = compute_accuracy_reward(
628
+ acc, self._previous_accuracy,
629
+ cfg["baseline_accuracy"], cfg["target_accuracy"],
630
+ is_submit=True,
631
+ )
632
+ r_process = compute_process_reward(self._action_history[:-1], "submit")
633
+ r_preservation = compute_preservation_reward(len(self._working_copy), orig_len)
634
+ r_efficiency = compute_efficiency_reward(
635
+ acc, cfg["baseline_accuracy"], cfg["budget"], max(budget_remaining, 0)
636
+ )
637
+
638
+ total = compute_total_reward(r_accuracy, r_process, r_preservation, r_efficiency)
639
+ total += penalty
640
+
641
+ hit = acc >= cfg["target_accuracy"]
642
+ response = (
643
+ f"{'=' * 40}\n"
644
+ f"EPISODE COMPLETE\n"
645
+ f"{'=' * 40}\n"
646
+ f"Final accuracy: {acc:.4f}\n"
647
+ f"Target accuracy: {cfg['target_accuracy']:.4f}\n"
648
+ f"Baseline: {cfg['baseline_accuracy']:.4f}\n"
649
+ f"Result: {'TARGET HIT ✓' if hit else 'Target not reached'}\n\n"
650
+ f"Reward breakdown:\n"
651
+ f" Accuracy: {r_accuracy:+.4f}\n"
652
+ f" Process: {r_process:+.4f}\n"
653
+ f" Preservation: {r_preservation:+.4f}\n"
654
+ f" Efficiency: {r_efficiency:+.4f}\n"
655
+ f" Penalty: {penalty:+.4f}\n"
656
+ f" TOTAL: {total:+.4f}\n"
657
+ + (f"\n{extra_msg}" if extra_msg else "")
658
+ )
659
+
660
+ quality = compute_lightweight_score(
661
+ self._working_copy, self._ground_truth,
662
+ orig_len, self._metadata["col_meta"],
663
+ )
664
+ rows_pct = len(self._working_copy) / orig_len
665
+
666
+ return DataCentricObservation(
667
+ response=response,
668
+ current_accuracy=acc,
669
+ baseline_accuracy=cfg["baseline_accuracy"],
670
+ target_accuracy=cfg["target_accuracy"],
671
+ estimated_quality=quality,
672
+ dataset_shape=f"{len(self._working_copy)} rows × {len(self._working_copy.columns)-1} columns",
673
+ rows_preserved_pct=rows_pct,
674
+ budget_remaining=max(budget_remaining, 0),
675
+ step_number=self._state.step_count,
676
+ max_steps=cfg["budget"],
677
+ active_session=self._active_session,
678
+ validate_calls_remaining=validate_calls_remaining(self._exploit),
679
+ done=True,
680
+ reward=round(total, 4),
681
+ )
682
+
683
+ # ── helpers ───────────────────────────────────────────────────────────────
684
+
685
+ def _make_obs(self, response: str, step: int, budget: int, reward: float,
686
+ quality: Optional[float] = None, rows_pct: Optional[float] = None
687
+ ) -> DataCentricObservation:
688
+ cfg = TASK_CONFIGS[self._task]
689
+ orig_len = self._metadata["original_length"]
690
+ wc = self._working_copy
691
+ if quality is None:
692
+ quality = compute_lightweight_score(
693
+ wc, self._ground_truth, orig_len, self._metadata["col_meta"],
694
+ initial_missing=self._metadata.get("initial_missing"),
695
+ )
696
+ if rows_pct is None:
697
+ rows_pct = len(wc) / orig_len
698
+
699
+ return DataCentricObservation(
700
+ response=response,
701
+ current_accuracy=self._current_accuracy,
702
+ baseline_accuracy=cfg["baseline_accuracy"],
703
+ target_accuracy=cfg["target_accuracy"],
704
+ estimated_quality=quality,
705
+ dataset_shape=f"{len(wc)} rows × {len(wc.columns)-1} columns",
706
+ rows_preserved_pct=rows_pct,
707
+ budget_remaining=max(budget, 0),
708
+ step_number=step,
709
+ max_steps=cfg["budget"],
710
+ active_session=self._active_session,
711
+ validate_calls_remaining=validate_calls_remaining(self._exploit),
712
+ done=False,
713
+ reward=round(reward, 4),
714
+ )
715
+
716
+ def _error_obs(self, msg: str) -> DataCentricObservation:
717
+ return DataCentricObservation(response=msg, done=False, reward=0.0)
718
+
719
+ def _unknown_cmd_obs(self, msg: str, step: int, budget: int,
720
+ reward: float) -> DataCentricObservation:
721
+ return self._make_obs(
722
+ f"Unknown command: '{msg}'\n\n{AVAILABLE_COMMANDS}", step, budget, reward
723
+ )
724
+
725
+ @property
726
+ def state(self) -> State:
727
+ return self._state
server/dataset_generator.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dataset Generator for Data-Centric RL Environment.
3
+
4
+ Generates corrupted sklearn classification datasets with known ground truth.
5
+ Each task has deterministic corruptions via seeded random.Random.
6
+
7
+ CRITICAL: Always produces TWO copies:
8
+ ground_truth → frozen, only read by grader
9
+ working_copy → the only thing the agent can mutate
10
+ """
11
+
12
+ import random
13
+ from copy import deepcopy
14
+ from typing import Any, Dict, Tuple
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+ from sklearn.datasets import make_classification
19
+
20
+
21
+ # ── Column metadata schema ──────────────────────────────────────────────────
22
+
23
+ def _make_col_meta(expected_dtype: str, valid_range=None,
24
+ valid_categories=None, is_nullable: bool = False) -> Dict:
25
+ return {
26
+ "expected_dtype": expected_dtype,
27
+ "valid_range": valid_range,
28
+ "valid_categories": valid_categories,
29
+ "is_nullable": is_nullable,
30
+ }
31
+
32
+
33
+ # ── Task configurations ─────────────────────────────────────────────────────
34
+
35
+ TASK_CONFIGS = {
36
+ "task_0_tutorial": {
37
+ "n_samples": 100,
38
+ "n_features": 4,
39
+ "n_classes": 2,
40
+ "n_informative": 3,
41
+ "budget": 30,
42
+ "target_accuracy": 0.73,
43
+ "baseline_accuracy": 0.62,
44
+ "description": "Single-issue tutorial. Fix missing values in 'age' to win.",
45
+ },
46
+ "task_1_easy": {
47
+ "n_samples": 200,
48
+ "n_features": 5,
49
+ "n_classes": 2,
50
+ "n_informative": 4,
51
+ "budget": 25,
52
+ "target_accuracy": 0.79,
53
+ "baseline_accuracy": 0.63,
54
+ "description": "Missing values + mild class imbalance.",
55
+ },
56
+ "task_2_medium": {
57
+ "n_samples": 500,
58
+ "n_features": 7,
59
+ "n_classes": 3,
60
+ "n_informative": 5,
61
+ "budget": 40,
62
+ "target_accuracy": 0.74,
63
+ "baseline_accuracy": 0.58,
64
+ "description": "Missing values, duplicates, class imbalance, type error.",
65
+ },
66
+ "task_3_hard": {
67
+ "n_samples": 900,
68
+ "n_features": 10,
69
+ "n_classes": 4,
70
+ "n_informative": 7,
71
+ "budget": 60,
72
+ "target_accuracy": 0.71,
73
+ "baseline_accuracy": 0.54,
74
+ "description": "Missing values, duplicates, imbalance, type errors, outliers, cross-column errors.",
75
+ },
76
+ }
77
+
78
+
79
+ # ── Generic feature names ───────────────────────────────────────────────────
80
+
81
+ FEATURE_NAMES = ["age", "income", "score", "tenure", "balance",
82
+ "transactions", "risk_level", "credit", "spend", "savings"]
83
+
84
+
85
+ def _build_column_meta(feature_cols: list, task: str) -> Dict[str, Dict]:
86
+ meta = {}
87
+ for col in feature_cols:
88
+ meta[col] = _make_col_meta("float64", valid_range=(-10.0, 10.0))
89
+ # age gets tighter range for tutorial plausibility
90
+ if "age" in meta:
91
+ meta["age"] = _make_col_meta("float64", valid_range=(0.0, 100.0))
92
+ meta["target"] = _make_col_meta("int64", valid_categories=None)
93
+ return meta
94
+
95
+
96
+ # ── Core generator ──────────────────────────────────────────────────────────
97
+
98
+ def generate_dataset(task: str, seed: int = 42) -> Tuple[pd.DataFrame, pd.DataFrame, Dict[str, Any]]:
99
+ """
100
+ Generate a corrupted dataset for the given task.
101
+
102
+ Returns:
103
+ ground_truth – clean DataFrame (frozen)
104
+ working_copy – corrupted DataFrame (agent mutates this)
105
+ metadata – task config + column metadata + original_length
106
+ """
107
+ cfg = TASK_CONFIGS[task]
108
+ rng = random.Random(seed)
109
+ np_rng = np.random.RandomState(seed)
110
+
111
+ n = cfg["n_samples"]
112
+ n_feat = cfg["n_features"]
113
+ n_cls = cfg["n_classes"]
114
+
115
+ # ── Generate clean classification data ──────────────────────────────────
116
+ X, y = make_classification(
117
+ n_samples=n,
118
+ n_features=n_feat,
119
+ n_informative=cfg["n_informative"],
120
+ n_redundant=max(0, n_feat - cfg["n_informative"] - 1),
121
+ n_classes=n_cls,
122
+ n_clusters_per_class=1,
123
+ weights=None,
124
+ random_state=seed,
125
+ )
126
+
127
+ cols = FEATURE_NAMES[:n_feat]
128
+ df_clean = pd.DataFrame(X, columns=cols)
129
+ df_clean["target"] = y
130
+
131
+ # Rescale 'age' column to [18, 80] for plausibility
132
+ if "age" in df_clean.columns:
133
+ mn, mx = df_clean["age"].min(), df_clean["age"].max()
134
+ df_clean["age"] = ((df_clean["age"] - mn) / (mx - mn + 1e-9)) * 62 + 18
135
+
136
+ ground_truth = deepcopy(df_clean)
137
+ working_copy = deepcopy(df_clean)
138
+
139
+ # ── Inject corruptions into working_copy only ────────────────────���───────
140
+ _inject_corruptions(working_copy, task, cfg, rng, np_rng, seed)
141
+
142
+ col_meta = _build_column_meta(cols, task)
143
+ metadata = {
144
+ **cfg,
145
+ "task": task,
146
+ "seed": seed,
147
+ "feature_cols": cols,
148
+ "col_meta": col_meta,
149
+ "original_length": len(working_copy),
150
+ "class_names": [str(c) for c in sorted(working_copy["target"].unique())],
151
+ }
152
+
153
+ return ground_truth, working_copy, metadata
154
+
155
+
156
+ def _inject_corruptions(df: pd.DataFrame, task: str, cfg: dict,
157
+ rng: random.Random, np_rng: np.random.RandomState,
158
+ seed: int):
159
+ """Inject task-specific corruptions into df in-place."""
160
+
161
+ if task == "task_0_tutorial":
162
+ # Single issue: 20% missing in age only
163
+ _inject_missing(df, ["age"], frac=0.20, rng=rng)
164
+
165
+ elif task == "task_1_easy":
166
+ # Missing values 15% + mild class imbalance
167
+ cols = df.columns[:-1].tolist()
168
+ _inject_missing(df, cols[:2], frac=0.15, rng=rng)
169
+ _inject_class_imbalance(df, ratio=0.60, rng=rng, seed=seed)
170
+
171
+ elif task == "task_2_medium":
172
+ cols = df.columns[:-1].tolist()
173
+ _inject_missing(df, cols[:3], frac=0.12, rng=rng)
174
+ _inject_duplicates(df, frac=0.05, rng=rng)
175
+ _inject_class_imbalance(df, ratio=0.55, rng=rng, seed=seed)
176
+ _inject_type_error(df, cols[0], rng=rng, frac=0.04)
177
+
178
+ elif task == "task_3_hard":
179
+ cols = df.columns[:-1].tolist()
180
+ _inject_missing(df, cols[:4], frac=0.10, rng=rng)
181
+ _inject_duplicates(df, frac=0.05, rng=rng)
182
+ _inject_class_imbalance(df, ratio=0.50, rng=rng, seed=seed)
183
+ _inject_type_error(df, cols[0], rng=rng, frac=0.03)
184
+ _inject_outliers(df, cols[1], rng=rng, frac=0.03)
185
+ _inject_cross_column_errors(df, cols[2], cols[3], rng=rng, frac=0.02)
186
+
187
+
188
+ def _inject_missing(df: pd.DataFrame, cols: list, frac: float, rng: random.Random):
189
+ for col in cols:
190
+ if col not in df.columns:
191
+ continue
192
+ indices = rng.sample(range(len(df)), int(len(df) * frac))
193
+ df.loc[indices, col] = np.nan
194
+
195
+
196
+ def _inject_duplicates(df: pd.DataFrame, frac: float, rng: random.Random):
197
+ n_dups = max(1, int(len(df) * frac))
198
+ dup_indices = rng.choices(range(len(df)), k=n_dups)
199
+ dups = df.iloc[dup_indices].copy()
200
+ new_df = pd.concat([df, dups], ignore_index=True)
201
+ # Mutate the caller's DataFrame in-place by clearing and re-populating
202
+ df.drop(df.index, inplace=True)
203
+ df.drop(df.columns, axis=1, inplace=True)
204
+ for col in new_df.columns:
205
+ df[col] = new_df[col].values
206
+ df.reset_index(drop=True, inplace=True)
207
+
208
+
209
+ def _inject_class_imbalance(df: pd.DataFrame, ratio: float,
210
+ rng: random.Random, seed: int):
211
+ """Make class 0 account for `ratio` of rows, drop minority excess."""
212
+ target_col = "target"
213
+ classes = df[target_col].unique()
214
+ if len(classes) < 2:
215
+ return
216
+ major = int(classes[0])
217
+ n_major = int(len(df) * ratio)
218
+ major_idx = df[df[target_col] == major].index.tolist()
219
+ if len(major_idx) > n_major:
220
+ drop_n = len(major_idx) - n_major
221
+ to_drop = rng.sample(major_idx, drop_n)
222
+ df.drop(to_drop, inplace=True)
223
+ df.reset_index(drop=True, inplace=True)
224
+
225
+
226
+ def _inject_type_error(df: pd.DataFrame, col: str, rng: random.Random, frac: float):
227
+ """Replace some float values with string 'ERR' to simulate type errors."""
228
+ if col not in df.columns:
229
+ return
230
+ indices = rng.sample(range(len(df)), max(1, int(len(df) * frac)))
231
+ df[col] = df[col].astype(object)
232
+ for i in indices:
233
+ df.at[i, col] = "ERR"
234
+
235
+
236
+ def _inject_outliers(df: pd.DataFrame, col: str, rng: random.Random, frac: float):
237
+ if col not in df.columns:
238
+ return
239
+ indices = rng.sample(range(len(df)), max(1, int(len(df) * frac)))
240
+ for i in indices:
241
+ df.at[i, col] = rng.choice([999.0, -999.0])
242
+
243
+
244
+ def _inject_cross_column_errors(df: pd.DataFrame, col_a: str, col_b: str,
245
+ rng: random.Random, frac: float):
246
+ """Make col_a < col_b for some rows (e.g. min > max violations)."""
247
+ if col_a not in df.columns or col_b not in df.columns:
248
+ return
249
+ indices = rng.sample(range(len(df)), max(1, int(len(df) * frac)))
250
+ for i in indices:
251
+ try:
252
+ a = float(df.at[i, col_a])
253
+ b = float(df.at[i, col_b])
254
+ if a >= b:
255
+ df.at[i, col_a], df.at[i, col_b] = b - 1.0, a + 1.0
256
+ except (ValueError, TypeError):
257
+ pass
server/grader.py ADDED
@@ -0,0 +1,400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Grader for Data-Centric RL Environment — using OpenEnv Rubric system.
3
+
4
+ Implements 4 composable Rubric subclasses (nn.Module style, auto-registered
5
+ as child rubrics) plus a root DataCentricRubric that aggregates them.
6
+
7
+ Rubric hierarchy:
8
+ DataCentricRubric
9
+ ├── accuracy : AccuracyRubric
10
+ ├── process : ProcessRubric
11
+ ├── preservation : PreservationRubric
12
+ └── efficiency : EfficiencyRubric
13
+
14
+ Also provides StepRubric for dense per-apply proxy feedback (no classifier).
15
+
16
+ Backward-compatible: compute_*() free functions still work for existing callers.
17
+ """
18
+
19
+ import logging
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from openenv.core.rubrics.base import Rubric
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ # Must match openenv.yaml reward_range — enforced by DataCentricRubric
30
+ REWARD_MIN: float = -1.0
31
+ REWARD_MAX: float = 1.0
32
+
33
+
34
+ # ── Lightweight quality score (no sklearn) ────────────────────────────────────
35
+
36
+ def compute_lightweight_score(
37
+ working_copy: pd.DataFrame,
38
+ ground_truth: pd.DataFrame,
39
+ original_length: int,
40
+ col_meta: Dict,
41
+ initial_missing: int = None,
42
+ ) -> float:
43
+ """
44
+ Fast quality score comparing working_copy to ground_truth structure.
45
+ Does NOT run sklearn — used for dense per-step feedback.
46
+
47
+ Score is [0.0, 1.0] composed of:
48
+ - missing value reduction (40%)
49
+ - duplicate reduction (20%)
50
+ - type correctness (20%)
51
+ - row preservation (20%)
52
+ """
53
+ score = 0.0
54
+
55
+ # 1. Missing value reduction
56
+ wc_missing = int(working_copy.isnull().sum().sum())
57
+ denom = initial_missing if (initial_missing is not None and initial_missing > 0) else max(wc_missing, 1)
58
+ missing_score = max(0.0, 1.0 - wc_missing / denom) if denom > 0 else 1.0
59
+ score += 0.40 * missing_score
60
+
61
+ # 2. Duplicate reduction
62
+ n_dups_wc = int(working_copy.duplicated().sum())
63
+ n_dups_gt = int(ground_truth.duplicated().sum())
64
+ if n_dups_gt == 0 and n_dups_wc == 0:
65
+ dup_score = 1.0
66
+ elif n_dups_gt == 0:
67
+ dup_score = max(0.0, 1.0 - n_dups_wc / max(len(working_copy), 1))
68
+ else:
69
+ dup_score = max(0.0, 1.0 - n_dups_wc / max(n_dups_gt, 1))
70
+ score += 0.20 * dup_score
71
+
72
+ # 3. Type correctness
73
+ type_ok, type_total = 0, 0
74
+ for col, meta in col_meta.items():
75
+ if col == "target" or col not in working_copy.columns:
76
+ continue
77
+ if meta.get("expected_dtype", "float64") in ("float64", "int64"):
78
+ type_total += 1
79
+ err_count = sum(
80
+ 1 for val in working_copy[col].dropna()
81
+ if not _can_float(val)
82
+ )
83
+ if err_count == 0:
84
+ type_ok += 1
85
+ score += 0.20 * ((type_ok / type_total) if type_total > 0 else 1.0)
86
+
87
+ # 4. Row preservation
88
+ score += 0.20 * min(len(working_copy) / max(original_length, 1), 1.0)
89
+
90
+ return round(min(score, 1.0), 4)
91
+
92
+
93
+ def _can_float(val: Any) -> bool:
94
+ try:
95
+ float(val)
96
+ return True
97
+ except (ValueError, TypeError):
98
+ return False
99
+
100
+
101
+ # ── Rubric 1: Accuracy ────────────────────────────────────────────────────────
102
+
103
+ class AccuracyRubric(Rubric):
104
+ """
105
+ Main RL signal: rewards accuracy improvement, penalises regression.
106
+ Adds a large terminal bonus when the agent submits and crosses the target.
107
+ """
108
+
109
+ def forward(self, action: Any, observation: Any) -> float:
110
+ current = observation.get("current_accuracy", 0.0)
111
+ previous = observation.get("previous_accuracy", 0.0)
112
+ baseline = observation.get("baseline_accuracy", 0.0)
113
+ target = observation.get("target_accuracy", 0.80)
114
+ is_submit = str(action).strip().lower() == "submit"
115
+
116
+ improvement = current - previous
117
+ if improvement > 0:
118
+ reward = improvement * 2.5
119
+ elif improvement < 0:
120
+ reward = improvement * 2.0 # regression penalised harder
121
+ else:
122
+ reward = 0.0
123
+
124
+ if is_submit:
125
+ if current >= target:
126
+ reward += 0.50 # big terminal bonus
127
+ else:
128
+ progress_range = target - baseline
129
+ if progress_range > 0:
130
+ progress = (current - baseline) / progress_range
131
+ reward += max(0.0, progress) * 0.25
132
+
133
+ logger.debug("AccuracyRubric: imp=%.4f reward=%.4f submit=%s", improvement, reward, is_submit)
134
+ return round(reward, 4)
135
+
136
+
137
+ # ── Rubric 2: Process ─────────────────────────────────────────────────────────
138
+
139
+ class ProcessRubric(Rubric):
140
+ """
141
+ Rewards smart workflow patterns (inspect → query → apply → validate).
142
+ Penalises blind apply-without-query and submit-without-validate.
143
+ """
144
+
145
+ def forward(self, action: Any, observation: Any) -> float:
146
+ history: List[str] = observation.get("action_history", [])
147
+ current_action = str(action)
148
+ full_history = (history + [current_action])[-5:]
149
+ reward = 0.0
150
+
151
+ def _cmd(a: str) -> str:
152
+ return a.split()[0].lower()
153
+
154
+ cmd = _cmd(current_action)
155
+ prev_cmds = [_cmd(h) for h in full_history[:-1][-3:]]
156
+
157
+ if cmd.startswith("query_"):
158
+ if "inspect_dataset" in prev_cmds or "inspect_model" in prev_cmds:
159
+ reward += 0.02
160
+
161
+ if cmd == "apply":
162
+ if any(p.startswith("query_") for p in prev_cmds):
163
+ reward += 0.05
164
+ else:
165
+ reward -= 0.04
166
+
167
+ if cmd == "validate" and "apply" in prev_cmds:
168
+ reward += 0.03
169
+
170
+ if cmd == "reject":
171
+ reward += 0.01
172
+
173
+ if cmd == "submit":
174
+ all_cmds = [_cmd(h) for h in history]
175
+ if "validate" not in all_cmds:
176
+ reward -= 0.10
177
+
178
+ logger.debug("ProcessRubric: action=%s reward=%.4f", current_action, reward)
179
+ return round(reward, 4)
180
+
181
+
182
+ # ── Rubric 3: Preservation ────────────────────────────────────────────────────
183
+
184
+ class PreservationRubric(Rubric):
185
+ """
186
+ Rewards row preservation. Independent of accuracy — prevents the agent
187
+ from 'cheating' by deleting rows to inflate classifier confidence.
188
+ """
189
+
190
+ def forward(self, action: Any, observation: Any) -> float:
191
+ current_rows = observation.get("current_rows", 0)
192
+ original_rows = observation.get("original_rows", 1)
193
+ rows_preserved = current_rows / max(original_rows, 1)
194
+
195
+ if rows_preserved >= 0.90:
196
+ reward = 0.05
197
+ elif rows_preserved >= 0.80:
198
+ reward = 0.02
199
+ elif rows_preserved >= 0.70:
200
+ reward = 0.00
201
+ elif rows_preserved >= 0.50:
202
+ reward = -0.10
203
+ else:
204
+ reward = -0.40
205
+
206
+ logger.debug("PreservationRubric: pct=%.2f reward=%.4f", rows_preserved, reward)
207
+ return round(reward, 4)
208
+
209
+
210
+ # ── Rubric 4: Efficiency ──────────────────────────────────────────────────────
211
+
212
+ class EfficiencyRubric(Rubric):
213
+ """
214
+ Computed ONLY at submit. Rewards high accuracy gain per budget step used.
215
+ Encourages the agent to be surgical rather than spray-and-pray.
216
+ """
217
+
218
+ def forward(self, action: Any, observation: Any) -> float:
219
+ if str(action).strip().lower() != "submit":
220
+ return 0.0
221
+
222
+ baseline = observation.get("baseline_accuracy", 0.0)
223
+ current = observation.get("current_accuracy", 0.0)
224
+ original_budget = observation.get("original_budget", 1)
225
+ budget_remaining = observation.get("budget_remaining", 0)
226
+ budget_used = max(original_budget - budget_remaining, 1)
227
+ accuracy_gain = current - baseline
228
+
229
+ if accuracy_gain <= 0:
230
+ reward = -0.05
231
+ else:
232
+ reward = min((accuracy_gain / budget_used) * 3.0, 0.20)
233
+
234
+ logger.debug("EfficiencyRubric: gain=%.4f used=%d reward=%.4f",
235
+ accuracy_gain, budget_used, reward)
236
+ return round(reward, 4)
237
+
238
+
239
+ # ── Rubric 5: Step (proxy, no classifier) ────────────────────────────────────
240
+
241
+ class StepRubric(Rubric):
242
+ """
243
+ Dense per-apply proxy reward — does NOT run the RF classifier.
244
+ Uses lightweight quality score delta to give feedback between validate calls.
245
+ Registered as a standalone rubric (not a child of DataCentricRubric)
246
+ because it fires on every apply step, not just at episode end.
247
+ """
248
+
249
+ def forward(self, action: Any, observation: Any) -> float:
250
+ if not str(action).startswith("apply"):
251
+ return 0.0
252
+
253
+ q_before = observation.get("quality_before", 0.0)
254
+ q_after = observation.get("quality_after", 0.0)
255
+ rows_pct = observation.get("rows_preserved_after", 1.0)
256
+
257
+ r = float(np.clip((q_after - q_before) * 0.3, -0.20, 0.10))
258
+
259
+ if rows_pct >= 0.95:
260
+ r += 0.02
261
+ elif rows_pct >= 0.90:
262
+ r += 0.01
263
+ elif rows_pct < 0.80:
264
+ r -= 0.10
265
+
266
+ return float(np.clip(r, -0.30, 0.15))
267
+
268
+
269
+ # ── Root Rubric (aggregates all components) ───────────────────────────────────
270
+
271
+ class DataCentricRubric(Rubric):
272
+ """
273
+ Root composable rubric for the Data-Centric AI environment.
274
+
275
+ Child rubrics are auto-registered (PyTorch nn.Module style):
276
+ rubric.accuracy → AccuracyRubric
277
+ rubric.process → ProcessRubric
278
+ rubric.preservation → PreservationRubric
279
+ rubric.efficiency → EfficiencyRubric
280
+
281
+ Call rubric(action, obs_dict) to get total clamped reward [-1.0, 1.0].
282
+ Inspect rubric.accuracy.last_score etc. for per-component breakdown.
283
+ """
284
+
285
+ def __init__(self):
286
+ super().__init__()
287
+ # Assigned as attributes — auto-registered as children by Rubric.__setattr__
288
+ self.accuracy = AccuracyRubric()
289
+ self.process = ProcessRubric()
290
+ self.preservation = PreservationRubric()
291
+ self.efficiency = EfficiencyRubric()
292
+
293
+ def forward(self, action: Any, observation: Any) -> float:
294
+ r_acc = self.accuracy(action, observation)
295
+ r_proc = self.process(action, observation)
296
+ r_pres = self.preservation(action, observation)
297
+ r_eff = self.efficiency(action, observation)
298
+ total = r_acc + r_proc + r_pres + r_eff
299
+
300
+ clamped = float(np.clip(total, REWARD_MIN, REWARD_MAX))
301
+ if __debug__ and abs(clamped - total) > 1e-6:
302
+ logger.warning("Reward %.4f clamped → %.4f", total, clamped)
303
+
304
+ logger.info(
305
+ "REWARD | accuracy=%.4f process=%.4f preservation=%.4f "
306
+ "efficiency=%.4f TOTAL=%.4f (clamped=%.4f)",
307
+ r_acc, r_proc, r_pres, r_eff, total, clamped,
308
+ )
309
+ return round(clamped, 4)
310
+
311
+ def breakdown(self) -> Dict[str, Optional[float]]:
312
+ """Return last_score for each child rubric — useful for logging."""
313
+ return {
314
+ "accuracy": self.accuracy.last_score,
315
+ "process": self.process.last_score,
316
+ "preservation": self.preservation.last_score,
317
+ "efficiency": self.efficiency.last_score,
318
+ }
319
+
320
+
321
+ # ── Singleton — reuse across episode steps ────────────────────────────────────
322
+
323
+ _rubric: Optional[DataCentricRubric] = None
324
+ _step_rubric: Optional[StepRubric] = None
325
+
326
+
327
+ def get_rubric() -> DataCentricRubric:
328
+ global _rubric
329
+ if _rubric is None:
330
+ _rubric = DataCentricRubric()
331
+ return _rubric
332
+
333
+
334
+ def get_step_rubric() -> StepRubric:
335
+ global _step_rubric
336
+ if _step_rubric is None:
337
+ _step_rubric = StepRubric()
338
+ return _step_rubric
339
+
340
+
341
+ # ── Backward-compatible free functions ───────────────────────────────────────
342
+ # (called by data_centric_environment.py — no changes needed there)
343
+
344
+ def compute_accuracy_reward(
345
+ current_accuracy: float, previous_accuracy: float,
346
+ baseline_accuracy: float, target_accuracy: float,
347
+ is_submit: bool = False,
348
+ ) -> float:
349
+ obs = dict(current_accuracy=current_accuracy, previous_accuracy=previous_accuracy,
350
+ baseline_accuracy=baseline_accuracy, target_accuracy=target_accuracy)
351
+ action = "submit" if is_submit else "step"
352
+ return get_rubric().accuracy(action, obs)
353
+
354
+
355
+ def compute_process_reward(action_history: List[str], current_action: str) -> float:
356
+ obs = dict(action_history=action_history)
357
+ return get_rubric().process(current_action, obs)
358
+
359
+
360
+ def compute_preservation_reward(current_rows: int, original_rows: int) -> float:
361
+ obs = dict(current_rows=current_rows, original_rows=original_rows)
362
+ return get_rubric().preservation("step", obs)
363
+
364
+
365
+ def compute_efficiency_reward(
366
+ current_accuracy: float, baseline_accuracy: float,
367
+ original_budget: int, budget_remaining: int,
368
+ ) -> float:
369
+ obs = dict(current_accuracy=current_accuracy, baseline_accuracy=baseline_accuracy,
370
+ original_budget=original_budget, budget_remaining=budget_remaining)
371
+ return get_rubric().efficiency("submit", obs)
372
+
373
+
374
+ def compute_step_reward(
375
+ action: str, quality_before: float, quality_after: float,
376
+ rows_preserved_after: float,
377
+ ) -> float:
378
+ obs = dict(quality_before=quality_before, quality_after=quality_after,
379
+ rows_preserved_after=rows_preserved_after)
380
+ return get_step_rubric()(action, obs)
381
+
382
+
383
+ def compute_total_reward(
384
+ reward_accuracy: float,
385
+ reward_process: float,
386
+ reward_preservation: float,
387
+ reward_efficiency: float = 0.0,
388
+ reward_step: float = 0.0,
389
+ ) -> float:
390
+ total = reward_accuracy + reward_process + reward_preservation + reward_efficiency + reward_step
391
+ clamped = float(np.clip(total, REWARD_MIN, REWARD_MAX))
392
+ if __debug__ and abs(clamped - total) > 1e-6:
393
+ logger.warning("Reward %.4f clamped → %.4f", total, clamped)
394
+ logger.info(
395
+ "REWARD BREAKDOWN: accuracy=%.4f process=%.4f preservation=%.4f "
396
+ "efficiency=%.4f step=%.4f TOTAL=%.4f (clamped=%.4f)",
397
+ reward_accuracy, reward_process, reward_preservation,
398
+ reward_efficiency, reward_step, total, clamped,
399
+ )
400
+ return round(clamped, 4)
server/model_evaluator.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Model Evaluator for Data-Centric RL Environment.
3
+
4
+ Uses sklearn RandomForestClassifier (primary) + LogisticRegression (secondary)
5
+ with hash-based caching. Trains on working_copy, evaluates on held-out
6
+ ground_truth test split.
7
+
8
+ Primary accuracy (RF) drives all rewards and grading.
9
+ Secondary accuracy (LR) is diagnostic — shows whether improvements generalise
10
+ beyond the RF decision boundary (overfitting detection).
11
+ """
12
+
13
+ import hashlib
14
+ from typing import Dict, List, Optional, Tuple
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+
20
+ class ModelEvaluator:
21
+ """Caching dual-classifier evaluator.
22
+
23
+ Args:
24
+ fast_mode: If True, uses n_estimators=20 (for GRPO rollouts, ~4x faster).
25
+ If False, uses n_estimators=100 (for final eval, more accurate).
26
+ """
27
+
28
+ def __init__(self, fast_mode: bool = False):
29
+ self._cache_hash: Optional[str] = None
30
+ self._cached_accuracy: float = 0.0
31
+ self._cached_per_class: Dict = {}
32
+ self._cached_lr_accuracy: float = 0.0
33
+ self._cached_feature_importance: Dict[str, float] = {}
34
+ self._cached = False
35
+ self._fast_mode = fast_mode
36
+ self._n_estimators = 20 if fast_mode else 100
37
+
38
+ def _compute_hash(self, df: pd.DataFrame) -> str:
39
+ try:
40
+ return hashlib.md5(
41
+ pd.util.hash_pandas_object(df, index=True).values.tobytes()
42
+ ).hexdigest()
43
+ except Exception:
44
+ return hashlib.md5(df.to_json().encode()).hexdigest()
45
+
46
+ def evaluate(
47
+ self,
48
+ working_copy: pd.DataFrame,
49
+ ground_truth: pd.DataFrame,
50
+ test_size: float = 0.25,
51
+ seed: int = 42,
52
+ ) -> Tuple[float, Dict, bool, float]:
53
+ """
54
+ Train on working_copy; evaluate on held-out ground_truth test split.
55
+
56
+ Returns:
57
+ rf_accuracy – float (primary, used for rewards)
58
+ per_class – dict from classification_report
59
+ from_cache – True if result came from cache (no retrain)
60
+ lr_accuracy – float (secondary, diagnostic only)
61
+ """
62
+ from sklearn.ensemble import RandomForestClassifier
63
+ from sklearn.linear_model import LogisticRegression
64
+ from sklearn.metrics import classification_report
65
+ from sklearn.model_selection import train_test_split
66
+
67
+ current_hash = self._compute_hash(working_copy)
68
+
69
+ if self._cached and current_hash == self._cache_hash:
70
+ return self._cached_accuracy, self._cached_per_class, True, self._cached_lr_accuracy
71
+
72
+ # Prepare train set from working_copy
73
+ wc = working_copy.copy()
74
+ wc = wc.dropna(subset=["target"])
75
+ for col in wc.columns:
76
+ if col != "target":
77
+ wc[col] = pd.to_numeric(wc[col], errors="coerce")
78
+ wc = wc.dropna()
79
+
80
+ _empty = (0.0, {}, False, 0.0)
81
+ if len(wc) < 10:
82
+ self._cache_hash = current_hash
83
+ self._cached_accuracy = 0.0
84
+ self._cached_per_class = {}
85
+ self._cached_lr_accuracy = 0.0
86
+ self._cached_feature_importance = {}
87
+ self._cached = True
88
+ return _empty
89
+
90
+ X_train = wc.drop("target", axis=1).values
91
+ y_train = wc["target"].astype(int).values
92
+
93
+ # Build test set from ground_truth
94
+ gt = ground_truth.copy().dropna()
95
+ for col in gt.columns:
96
+ if col != "target":
97
+ gt[col] = pd.to_numeric(gt[col], errors="coerce")
98
+ gt = gt.dropna()
99
+
100
+ if len(gt) < 10:
101
+ self._cache_hash = current_hash
102
+ self._cached_accuracy = 0.0
103
+ self._cached_per_class = {}
104
+ self._cached_lr_accuracy = 0.0
105
+ self._cached_feature_importance = {}
106
+ self._cached = True
107
+ return _empty
108
+
109
+ _, X_test_df, _, y_test_df = train_test_split(
110
+ gt.drop("target", axis=1),
111
+ gt["target"],
112
+ test_size=test_size,
113
+ random_state=seed,
114
+ stratify=gt["target"] if gt["target"].nunique() > 1 else None,
115
+ )
116
+ y_test = y_test_df.astype(int).values
117
+
118
+ # Align columns
119
+ train_cols = list(wc.drop("target", axis=1).columns)
120
+ test_cols = list(X_test_df.columns)
121
+ shared = [c for c in train_cols if c in test_cols]
122
+ if not shared:
123
+ self._cache_hash = current_hash
124
+ self._cached_accuracy = 0.0
125
+ self._cached_per_class = {}
126
+ self._cached_lr_accuracy = 0.0
127
+ self._cached_feature_importance = {}
128
+ self._cached = True
129
+ return _empty
130
+
131
+ X_train_arr = wc[shared].values
132
+ X_test_arr = X_test_df[shared].values
133
+
134
+ # ── Primary: Random Forest ──────────────────────────────────────────
135
+ rf = RandomForestClassifier(
136
+ n_estimators=self._n_estimators,
137
+ random_state=42,
138
+ n_jobs=1,
139
+ )
140
+ rf.fit(X_train_arr, y_train)
141
+ y_pred_rf = rf.predict(X_test_arr)
142
+
143
+ rf_accuracy = float(rf.score(X_test_arr, y_test))
144
+ try:
145
+ per_class = classification_report(
146
+ y_test, y_pred_rf, output_dict=True, zero_division=0
147
+ )
148
+ except Exception:
149
+ per_class = {}
150
+
151
+ # Feature importance (RF gives this for free)
152
+ feature_importance: Dict[str, float] = {}
153
+ if hasattr(rf, "feature_importances_"):
154
+ importances = rf.feature_importances_
155
+ for col, imp in zip(shared, importances):
156
+ feature_importance[col] = round(float(imp), 4)
157
+
158
+ # ── Secondary: Logistic Regression (diagnostic) ─────────────────────
159
+ lr_accuracy = 0.0
160
+ if not self._fast_mode: # skip LR in fast_mode to keep GRPO rollouts quick
161
+ try:
162
+ lr = LogisticRegression(max_iter=500, random_state=42, n_jobs=1)
163
+ lr.fit(X_train_arr, y_train)
164
+ lr_accuracy = float(lr.score(X_test_arr, y_test))
165
+ except Exception:
166
+ lr_accuracy = 0.0
167
+ else:
168
+ # In fast_mode, reuse RF accuracy as placeholder (not shown to agent)
169
+ lr_accuracy = rf_accuracy
170
+
171
+ # Update cache
172
+ self._cache_hash = current_hash
173
+ self._cached_accuracy = rf_accuracy
174
+ self._cached_per_class = per_class
175
+ self._cached_lr_accuracy = lr_accuracy
176
+ self._cached_feature_importance = feature_importance
177
+ self._cached = True
178
+
179
+ return rf_accuracy, per_class, False, lr_accuracy
180
+
181
+ def agreement_signal(self, rf_acc: float, lr_acc: float,
182
+ prev_rf: float, prev_lr: float) -> str:
183
+ """
184
+ Compare RF vs LR improvement direction.
185
+ Returns a signal string for the agent to reason about.
186
+ """
187
+ rf_improved = rf_acc > prev_rf + 0.005
188
+ lr_improved = lr_acc > prev_lr + 0.005
189
+ rf_declined = rf_acc < prev_rf - 0.005
190
+ lr_declined = lr_acc < prev_lr - 0.005
191
+
192
+ if rf_improved and lr_improved:
193
+ return "BOTH_AGREE_IMPROVE — fix is robust and generalises"
194
+ elif rf_improved and lr_declined:
195
+ return "DISAGREE — RF improved but LR declined (possible RF-specific overfitting)"
196
+ elif rf_declined and lr_declined:
197
+ return "BOTH_DECLINED — last change hurt both classifiers, consider undo"
198
+ elif not rf_improved and not rf_declined:
199
+ return "NO_CHANGE — last operation had no measurable effect"
200
+ else:
201
+ return "MIXED — marginal changes, continue and validate again"
202
+
203
+ def feature_importance_text(self, top_n: int = 5) -> str:
204
+ """Return formatted feature importance string for agent observation."""
205
+ if not self._cached_feature_importance:
206
+ return ""
207
+ sorted_feats = sorted(
208
+ self._cached_feature_importance.items(),
209
+ key=lambda x: -x[1]
210
+ )[:top_n]
211
+ parts = [f"{col} ({imp:.3f})" for col, imp in sorted_feats]
212
+ return "Feature importance: " + " > ".join(parts)
213
+
214
+ def invalidate_cache(self):
215
+ self._cached = False
216
+ self._cache_hash = None
217
+
218
+ @property
219
+ def last_accuracy(self) -> float:
220
+ return self._cached_accuracy if self._cached else 0.0
221
+
222
+ @property
223
+ def last_lr_accuracy(self) -> float:
224
+ return self._cached_lr_accuracy if self._cached else 0.0
225
+
226
+ @property
227
+ def last_feature_importance(self) -> Dict[str, float]:
228
+ return self._cached_feature_importance if self._cached else {}
server/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ openenv-core>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+ scikit-learn>=1.3.0
5
+ pandas>=2.0.0
6
+ numpy>=1.24.0
server/specialist_agents.py ADDED
@@ -0,0 +1,654 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Specialist Agents for Data-Centric RL Environment.
3
+
4
+ Agents:
5
+ CleanerAgent — detects missing values, duplicates, type errors
6
+ AugmenterAgent — suggests synthetic minority class samples
7
+ BalancerAgent — recommends resampling strategies
8
+ ValidatorAgent — checks column metadata rule violations (costs 2 budget)
9
+ AnalystAgent — holistic diagnosis + prioritised action plan (costs 1 budget)
10
+
11
+ Also exports:
12
+ compute_drift() — per-column distribution drift score (no scipy needed)
13
+ """
14
+
15
+ import hashlib
16
+ import random
17
+ import uuid
18
+ from dataclasses import dataclass, field
19
+ from typing import Any, Dict, List, Optional
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+
24
+
25
+ # ── Recommendation / Violation dataclasses ──────────────────────────────────
26
+
27
+ @dataclass
28
+ class Recommendation:
29
+ id: int
30
+ description: str
31
+ action_type: str
32
+ estimated_impact: float
33
+ confidence: float
34
+ session_id: str
35
+ _payload: Dict[str, Any] = field(default_factory=dict, repr=False)
36
+
37
+
38
+ @dataclass
39
+ class Violation:
40
+ column: str
41
+ rule: str
42
+ count: int
43
+ description: str
44
+ severity: str = "WARNING" # NEW: CRITICAL / WARNING / INFO
45
+
46
+
47
+ # ── Session registry ─────────────────────────────────────────────────────────
48
+
49
+ class SessionRegistry:
50
+ """Tracks the active recommendation session to detect stale IDs."""
51
+
52
+ def __init__(self):
53
+ self.current_session_id: str = ""
54
+ self.recommendations: Dict[int, Recommendation] = {}
55
+
56
+ def new_session(self) -> str:
57
+ self.current_session_id = str(uuid.uuid4())
58
+ self.recommendations = {}
59
+ return self.current_session_id
60
+
61
+ def register(self, recs: List[Recommendation]) -> None:
62
+ for r in recs:
63
+ self.recommendations[r.id] = r
64
+
65
+ def get(self, rec_id: int, session_id: str) -> Optional[Recommendation]:
66
+ if session_id != self.current_session_id:
67
+ return None
68
+ return self.recommendations.get(rec_id)
69
+
70
+ def is_valid_session(self, session_id: str) -> bool:
71
+ return session_id == self.current_session_id
72
+
73
+
74
+ # ── Shared helpers ───────────────────────────────────────────────────────────
75
+
76
+ def _seeded_rng(df: pd.DataFrame, salt: str = "") -> random.Random:
77
+ h = hashlib.md5((df.to_json() + salt).encode()).hexdigest()
78
+ return random.Random(int(h[:8], 16))
79
+
80
+
81
+ def _col_stats(series: pd.Series) -> Dict[str, float]:
82
+ """Return basic stats dict for a numeric series."""
83
+ s = pd.to_numeric(series, errors="coerce").dropna()
84
+ if len(s) == 0:
85
+ return {"mean": 0.0, "median": 0.0, "std": 0.0, "skew": 0.0, "n": 0}
86
+ return {
87
+ "mean": float(s.mean()),
88
+ "median": float(s.median()),
89
+ "std": float(s.std()) if len(s) > 1 else 0.0,
90
+ "skew": float(s.skew()) if len(s) > 2 else 0.0,
91
+ "n": len(s),
92
+ }
93
+
94
+
95
+ def _impute_strategy(stats: Dict[str, float]) -> tuple:
96
+ """Choose mean vs median based on skewness. Returns (strategy, value, reason)."""
97
+ skew = abs(stats["skew"])
98
+ if skew > 1.0:
99
+ return "median", stats["median"], f"right-skewed (skew={stats['skew']:.2f}), median more robust"
100
+ elif skew > 0.5:
101
+ return "median", stats["median"], f"moderately skewed (skew={stats['skew']:.2f}), median preferred"
102
+ else:
103
+ return "mean", stats["mean"], f"near-symmetric (skew={stats['skew']:.2f}), mean appropriate"
104
+
105
+
106
+ # ── Drift Detection ──────────────────────────────────────────────────────────
107
+
108
+ def compute_drift(working_copy: pd.DataFrame, ground_truth: pd.DataFrame) -> Dict[str, float]:
109
+ """
110
+ Per-column distribution drift score comparing working_copy to ground_truth.
111
+ Uses mean-shift + std-ratio (no scipy dependency).
112
+ Returns dict: column -> drift_score (0.0 = no drift, >1.0 = HIGH drift).
113
+ """
114
+ drift = {}
115
+ for col in working_copy.columns:
116
+ if col == "target":
117
+ continue
118
+ try:
119
+ wc_vals = pd.to_numeric(working_copy[col], errors="coerce").dropna()
120
+ gt_vals = pd.to_numeric(ground_truth[col], errors="coerce").dropna()
121
+ if len(wc_vals) == 0 or len(gt_vals) == 0:
122
+ drift[col] = 0.0
123
+ continue
124
+ mean_shift = abs(wc_vals.mean() - gt_vals.mean()) / (gt_vals.std() + 1e-8)
125
+ std_ratio = wc_vals.std() / (gt_vals.std() + 1e-8)
126
+ drift[col] = round(float(mean_shift + abs(1.0 - std_ratio)), 3)
127
+ except Exception:
128
+ drift[col] = 0.0
129
+ return drift
130
+
131
+
132
+ def _drift_label(score: float) -> str:
133
+ if score < 0.2:
134
+ return "NONE"
135
+ elif score < 0.5:
136
+ return "LOW"
137
+ elif score < 1.0:
138
+ return "MEDIUM"
139
+ else:
140
+ return "HIGH"
141
+
142
+
143
+ def format_drift_summary(drift: Dict[str, float]) -> str:
144
+ """Return one-line drift summary for agent observation."""
145
+ if not drift:
146
+ return ""
147
+ parts = [f"{col} ({_drift_label(v)})" for col, v in sorted(drift.items(), key=lambda x: -x[1])]
148
+ return "Distribution drift: " + " | ".join(parts[:5]) # top 5 most drifted
149
+
150
+
151
+ # ── CleanerAgent ─────────────────────────────────────────────────────────────
152
+
153
+ class CleanerAgent:
154
+ """
155
+ Analyses working_copy for missing values, duplicates, type mismatches.
156
+ Returns 2-4 recommendations with statistical reasoning.
157
+
158
+ Hidden flaw (15% of calls, deterministic): occasionally recommends
159
+ removing rows that are valid. Detectable because estimated_impact < 0.
160
+ """
161
+
162
+ def query(self, df: pd.DataFrame, session_registry: SessionRegistry,
163
+ col_meta: Dict) -> List[Recommendation]:
164
+ sid = session_registry.new_session()
165
+ rng = _seeded_rng(df, "cleaner")
166
+ recs = []
167
+ rec_id = 1
168
+ n_rows = len(df)
169
+
170
+ # --- Missing value recommendations with statistical reasoning ---
171
+ for col in df.columns:
172
+ if col == "target":
173
+ continue
174
+ n_missing = int(df[col].isna().sum())
175
+ if n_missing == 0:
176
+ continue
177
+
178
+ pct_missing = n_missing / n_rows * 100
179
+ stats = _col_stats(df[col])
180
+ strategy, value, reason = _impute_strategy(stats)
181
+
182
+ # Confidence: lower if >30% missing (imputation less reliable)
183
+ confidence = round(max(0.60, 0.92 - (pct_missing / 100) * 0.5), 2)
184
+
185
+ # Risk label
186
+ if pct_missing < 5:
187
+ risk = "LOW"
188
+ elif pct_missing < 20:
189
+ risk = "MEDIUM"
190
+ else:
191
+ risk = "HIGH — imputation may introduce bias"
192
+
193
+ mean_median_delta = abs(stats["mean"] - stats["median"])
194
+ description = (
195
+ f"Fill {n_missing}/{n_rows} ({pct_missing:.1f}%) missing values in '{col}' "
196
+ f"using {strategy} ({value:.2f}). "
197
+ f"Reason: {reason}. "
198
+ f"Mean={stats['mean']:.2f}, Median={stats['median']:.2f} "
199
+ f"(delta={mean_median_delta:.2f}). Risk: {risk}."
200
+ )
201
+
202
+ recs.append(Recommendation(
203
+ id=rec_id,
204
+ description=description,
205
+ action_type="fill_missing",
206
+ estimated_impact=round(min(0.03 + pct_missing / 100 * 0.3, 0.12), 3),
207
+ confidence=confidence,
208
+ session_id=sid,
209
+ _payload={"action": "fill_missing", "column": col, "strategy": strategy},
210
+ ))
211
+ rec_id += 1
212
+
213
+ # --- Duplicate recommendation ---
214
+ n_dups = int(df.duplicated().sum())
215
+ if n_dups > 0:
216
+ pct_dups = n_dups / n_rows * 100
217
+ recs.append(Recommendation(
218
+ id=rec_id,
219
+ description=(
220
+ f"Remove {n_dups} duplicate rows ({pct_dups:.1f}% of dataset). "
221
+ f"Duplicates bias the classifier toward overrepresented patterns. Risk: LOW."
222
+ ),
223
+ action_type="remove_duplicates",
224
+ estimated_impact=round(min(0.02 + n_dups / n_rows * 0.15, 0.08), 3),
225
+ confidence=0.92,
226
+ session_id=sid,
227
+ _payload={"action": "remove_duplicates"},
228
+ ))
229
+ rec_id += 1
230
+
231
+ # --- Type error recommendations ---
232
+ for col in df.columns:
233
+ if col == "target":
234
+ continue
235
+ meta = col_meta.get(col, {})
236
+ expected = meta.get("expected_dtype", "float64")
237
+ if expected in ("float64", "int64"):
238
+ n_errors = sum(1 for val in df[col].dropna()
239
+ if not _is_numeric(val))
240
+ if n_errors > 0:
241
+ pct_err = n_errors / n_rows * 100
242
+ recs.append(Recommendation(
243
+ id=rec_id,
244
+ description=(
245
+ f"Fix {n_errors} type errors ({pct_err:.1f}%) in '{col}' "
246
+ f"(non-numeric values coerced to NaN, then filled with mean). "
247
+ f"Expected dtype: {expected}. Risk: LOW."
248
+ ),
249
+ action_type="fix_type_errors",
250
+ estimated_impact=round(min(0.04 + n_errors / n_rows * 0.2, 0.10), 3),
251
+ confidence=0.88,
252
+ session_id=sid,
253
+ _payload={"action": "fix_type_errors", "column": col},
254
+ ))
255
+ rec_id += 1
256
+
257
+ # --- Hidden flaw: ~15% chance of recommending valid row removal ---
258
+ flaw_hash = int(hashlib.md5(df.to_json().encode()).hexdigest()[:4], 16)
259
+ if flaw_hash % 100 < 15 and len(recs) < 4:
260
+ col = rng.choice([c for c in df.columns if c != "target"])
261
+ recs.append(Recommendation(
262
+ id=rec_id,
263
+ description=(
264
+ f"Remove rows where '{col}' is below the 5th percentile "
265
+ f"(suspected outliers). Confidence LOW — verify with query_validator first."
266
+ ),
267
+ action_type="remove_outlier_rows",
268
+ estimated_impact=round(rng.uniform(-0.05, 0.01), 3),
269
+ confidence=round(rng.uniform(0.55, 0.70), 2),
270
+ session_id=sid,
271
+ _payload={"action": "remove_outlier_rows", "column": col, "pct": 5},
272
+ ))
273
+
274
+ # Keep top 4 by estimated_impact, re-number
275
+ recs = sorted(recs, key=lambda r: -r.estimated_impact)[:4]
276
+ for i, r in enumerate(recs, 1):
277
+ r.id = i
278
+ session_registry.register(recs)
279
+ return recs
280
+
281
+
282
+ def _is_numeric(val) -> bool:
283
+ try:
284
+ float(val)
285
+ return True
286
+ except (ValueError, TypeError):
287
+ return False
288
+
289
+
290
+ # ── AugmenterAgent ───────────────────────────────────────────────────────────
291
+
292
+ class AugmenterAgent:
293
+ """
294
+ Detects underrepresented classes and suggests synthetic samples.
295
+ Returns 1-3 recommendations with class distribution reasoning.
296
+
297
+ Hidden flaw: sometimes suggests out-of-distribution samples (flagged).
298
+ """
299
+
300
+ def query(self, df: pd.DataFrame, session_registry: SessionRegistry,
301
+ class_name: Optional[str] = None) -> List[Recommendation]:
302
+ sid = session_registry.new_session()
303
+ rng = _seeded_rng(df, "augmenter")
304
+ value_counts = df["target"].value_counts()
305
+ total = len(df)
306
+ recs = []
307
+ rec_id = 1
308
+
309
+ targets = [class_name] if class_name else [str(c) for c in value_counts.index]
310
+
311
+ # Class distribution context
312
+ dist_str = ", ".join(f"class {k}: {v} ({v/total*100:.1f}%)"
313
+ for k, v in value_counts.items())
314
+
315
+ for cls in targets[:3]:
316
+ try:
317
+ cls_int = int(cls)
318
+ except (ValueError, TypeError):
319
+ continue
320
+ if cls_int not in value_counts.index:
321
+ continue
322
+
323
+ count = value_counts[cls_int]
324
+ max_count = value_counts.max()
325
+ gap = max_count - count
326
+ if gap <= 0:
327
+ continue
328
+
329
+ n_synth = min(gap, max(5, int(gap * 0.5)))
330
+ ratio_before = count / max_count
331
+ ratio_after = (count + n_synth) / max_count
332
+
333
+ flaw_hash = int(hashlib.md5((df.to_json() + cls).encode()).hexdigest()[:4], 16)
334
+ is_ood = flaw_hash % 100 < 20
335
+ impact = round(min(0.04 + n_synth / total * 0.4, 0.10), 3)
336
+ if is_ood:
337
+ impact = round(rng.uniform(-0.02, 0.02), 3)
338
+
339
+ ood_note = " [WARNING: high OOD risk — run query_validator before applying]" if is_ood else ""
340
+ risk = "HIGH" if is_ood else ("MEDIUM" if ratio_before < 0.3 else "LOW")
341
+
342
+ description = (
343
+ f"Generate {n_synth} synthetic samples for class '{cls}' via Gaussian perturbation. "
344
+ f"Distribution: {dist_str}. "
345
+ f"Imbalance ratio before: {ratio_before:.2f} → after: {ratio_after:.2f}. "
346
+ f"Risk: {risk}.{ood_note}"
347
+ )
348
+
349
+ recs.append(Recommendation(
350
+ id=rec_id,
351
+ description=description,
352
+ action_type="augment_class",
353
+ estimated_impact=impact,
354
+ confidence=round(0.60 if is_ood else 0.82, 2),
355
+ session_id=sid,
356
+ _payload={
357
+ "action": "augment_class",
358
+ "class": cls_int,
359
+ "n_synth": n_synth,
360
+ "ood": is_ood,
361
+ },
362
+ ))
363
+ rec_id += 1
364
+
365
+ session_registry.register(recs)
366
+ return recs
367
+
368
+
369
+ # ── BalancerAgent ─────────────────────────────────────────────────────────────
370
+
371
+ class BalancerAgent:
372
+ """
373
+ Recommends resampling strategies for class imbalance.
374
+ Returns 1-2 recommendations with entropy and ratio reasoning.
375
+
376
+ Hidden flaw: occasionally over-balances (minority becomes too large).
377
+ """
378
+
379
+ def query(self, df: pd.DataFrame, session_registry: SessionRegistry) -> List[Recommendation]:
380
+ sid = session_registry.new_session()
381
+ rng = _seeded_rng(df, "balancer")
382
+ value_counts = df["target"].value_counts()
383
+ recs = []
384
+ rec_id = 1
385
+
386
+ if len(value_counts) < 2:
387
+ session_registry.register([])
388
+ return []
389
+
390
+ min_cls = int(value_counts.idxmin())
391
+ max_cls = int(value_counts.idxmax())
392
+ min_count = int(value_counts.min())
393
+ max_count = int(value_counts.max())
394
+ imbalance_ratio = min_count / max_count
395
+
396
+ # Class distribution entropy (0=perfectly imbalanced, 1=perfectly balanced)
397
+ probs = value_counts / value_counts.sum()
398
+ entropy = float(-np.sum(probs * np.log2(probs + 1e-9)))
399
+ max_entropy = np.log2(len(value_counts))
400
+ entropy_pct = entropy / max_entropy * 100 if max_entropy > 0 else 0
401
+
402
+ flaw_hash = int(hashlib.md5(df.to_json().encode()).hexdigest()[:4], 16)
403
+ is_overbalance = flaw_hash % 100 < 20
404
+ target_count = max_count if not is_overbalance else int(max_count * 1.5)
405
+ ratio_after = min(1.0, min_count / target_count) if target_count > 0 else 1.0
406
+
407
+ overbalance_note = (
408
+ " [WARNING: target exceeds majority class size — may over-correct and hurt generalisation]"
409
+ if is_overbalance else ""
410
+ )
411
+ risk = "HIGH" if is_overbalance else ("MEDIUM" if imbalance_ratio < 0.3 else "LOW")
412
+
413
+ recs.append(Recommendation(
414
+ id=rec_id,
415
+ description=(
416
+ f"Upsample minority class {min_cls} from {min_count} to {target_count} rows "
417
+ f"via random oversampling. "
418
+ f"Imbalance ratio: {imbalance_ratio:.2f} → {ratio_after:.2f}. "
419
+ f"Class entropy: {entropy_pct:.1f}% of maximum. Risk: {risk}.{overbalance_note}"
420
+ ),
421
+ action_type="oversample",
422
+ estimated_impact=round(min(0.05 + (1 - imbalance_ratio) * 0.15, 0.12), 3),
423
+ confidence=round(0.60 if is_overbalance else 0.80, 2),
424
+ session_id=sid,
425
+ _payload={
426
+ "action": "oversample",
427
+ "class": min_cls,
428
+ "target_count": target_count,
429
+ "overbalance": is_overbalance,
430
+ },
431
+ ))
432
+ rec_id += 1
433
+
434
+ if imbalance_ratio < 0.5:
435
+ undersample_target = min_count * 2
436
+ recs.append(Recommendation(
437
+ id=rec_id,
438
+ description=(
439
+ f"Downsample majority class {max_cls} from {max_count} to {undersample_target} rows "
440
+ f"via random undersampling. "
441
+ f"Warning: loses {max_count - undersample_target} majority-class examples. "
442
+ f"Risk: MEDIUM — use only if dataset is large enough."
443
+ ),
444
+ action_type="undersample",
445
+ estimated_impact=round(min(0.03 + (1 - imbalance_ratio) * 0.08, 0.08), 3),
446
+ confidence=0.75,
447
+ session_id=sid,
448
+ _payload={
449
+ "action": "undersample",
450
+ "class": max_cls,
451
+ "target_count": undersample_target,
452
+ },
453
+ ))
454
+
455
+ session_registry.register(recs)
456
+ return recs
457
+
458
+
459
+ # ── ValidatorAgent ────────────────────────────────────────────────────────────
460
+
461
+ class ValidatorAgent:
462
+ """
463
+ Checks working_copy against column metadata for rule violations.
464
+ Returns list of Violation objects (diagnostic only — not recommendations).
465
+ Costs 2 budget per call.
466
+ ~10% false positive rate (flagged with [FALSE POSITIVE WARNING]).
467
+ """
468
+
469
+ def query(self, df: pd.DataFrame, col_meta: Dict) -> List[Violation]:
470
+ rng = _seeded_rng(df, "validator")
471
+ violations = []
472
+
473
+ for col, meta in col_meta.items():
474
+ if col == "target" or col not in df.columns:
475
+ continue
476
+
477
+ expected_dtype = meta.get("expected_dtype", "float64")
478
+ valid_range = meta.get("valid_range")
479
+
480
+ # Type violations
481
+ if expected_dtype in ("float64", "int64"):
482
+ n_errors = sum(1 for val in df[col].dropna() if not _is_numeric(val))
483
+ if n_errors > 0:
484
+ pct = n_errors / len(df) * 100
485
+ violations.append(Violation(
486
+ column=col,
487
+ rule=f"dtype={expected_dtype}",
488
+ count=n_errors,
489
+ description=f"{n_errors} non-numeric values in '{col}' ({pct:.1f}%). Recommend fix_type_errors.",
490
+ severity="CRITICAL" if pct > 10 else "WARNING",
491
+ ))
492
+
493
+ # Range violations
494
+ if valid_range:
495
+ lo, hi = valid_range
496
+ try:
497
+ numeric_vals = pd.to_numeric(df[col], errors="coerce").dropna()
498
+ n_out = int(((numeric_vals < lo) | (numeric_vals > hi)).sum())
499
+ if n_out > 0:
500
+ max_val = float(numeric_vals.max())
501
+ min_val = float(numeric_vals.min())
502
+ std = float(numeric_vals.std()) or 1.0
503
+ z_max = abs(max_val - numeric_vals.mean()) / std
504
+ violations.append(Violation(
505
+ column=col,
506
+ rule=f"range=[{lo},{hi}]",
507
+ count=n_out,
508
+ description=(
509
+ f"{n_out} values in '{col}' outside [{lo}, {hi}]. "
510
+ f"Observed range: [{min_val:.1f}, {max_val:.1f}] "
511
+ f"(max Z-score: {z_max:.1f}). "
512
+ f"Severity: {'CRITICAL — likely data corruption' if z_max > 5 else 'WARNING — possible outliers'}."
513
+ ),
514
+ severity="CRITICAL" if z_max > 5 else "WARNING",
515
+ ))
516
+ except Exception:
517
+ pass
518
+
519
+ # ~10% false positive
520
+ fp_hash = int(hashlib.md5(df.to_json().encode()).hexdigest()[:4], 16)
521
+ if fp_hash % 100 < 10:
522
+ feature_cols = [c for c in df.columns if c != "target"]
523
+ if feature_cols:
524
+ fp_col = rng.choice(feature_cols)
525
+ violations.append(Violation(
526
+ column=fp_col,
527
+ rule="distribution_check",
528
+ count=rng.randint(1, 5),
529
+ description=(
530
+ f"[FALSE POSITIVE WARNING] Unusual value distribution in '{fp_col}' "
531
+ f"— may not be a real issue. Verify before acting."
532
+ ),
533
+ severity="INFO",
534
+ ))
535
+
536
+ return violations
537
+
538
+
539
+ # ── AnalystAgent ──────────────────────────────────────────────────────────────
540
+
541
+ class AnalystAgent:
542
+ """
543
+ Meta-specialist that performs holistic dataset diagnosis.
544
+ Returns a prioritised action plan rather than individual recommendations.
545
+ Costs 1 budget.
546
+
547
+ Analyses:
548
+ - Missing value severity
549
+ - Class imbalance severity
550
+ - Type error severity
551
+ - Remaining accuracy gap
552
+ Then ranks problems and recommends an ordered sequence of specialist calls.
553
+ """
554
+
555
+ def query(
556
+ self,
557
+ df: pd.DataFrame,
558
+ col_meta: Dict,
559
+ current_accuracy: float,
560
+ target_accuracy: float,
561
+ budget_remaining: int,
562
+ ) -> str:
563
+ """Return a formatted diagnostic + action plan string."""
564
+
565
+ n_rows = max(len(df), 1)
566
+ n_cells = n_rows * max(len(df.columns) - 1, 1)
567
+
568
+ # ── Score each problem dimension (0.0 – 1.0) ──────────────────────
569
+
570
+ # 1. Missing value severity
571
+ total_missing = int(df.isnull().sum().sum())
572
+ missing_severity = min(1.0, total_missing / n_cells * 5)
573
+
574
+ # 2. Class imbalance severity
575
+ vc = df["target"].value_counts()
576
+ if len(vc) >= 2:
577
+ imbalance_severity = 1.0 - (vc.min() / vc.max())
578
+ else:
579
+ imbalance_severity = 0.0
580
+
581
+ # 3. Type error severity
582
+ n_type_errors = 0
583
+ for col in df.columns:
584
+ if col == "target":
585
+ continue
586
+ meta = col_meta.get(col, {})
587
+ if meta.get("expected_dtype", "float64") in ("float64", "int64"):
588
+ n_type_errors += sum(1 for val in df[col].dropna() if not _is_numeric(val))
589
+ type_severity = min(1.0, n_type_errors / n_cells * 10)
590
+
591
+ # 4. Accuracy gap
592
+ accuracy_gap = max(0.0, target_accuracy - current_accuracy)
593
+
594
+ # ── Rank problems ─────────────────────────────────────────────────
595
+ problems = [
596
+ ("class imbalance", imbalance_severity, "query_balancer"),
597
+ ("missing values", missing_severity, "query_cleaner"),
598
+ ("type errors", type_severity, "query_cleaner"),
599
+ ]
600
+ problems.sort(key=lambda x: -x[1])
601
+
602
+ # ── Build diagnosis section ───────────────────────────────────────
603
+ diagnosis_lines = ["DIAGNOSIS:"]
604
+ for name, severity, specialist in problems:
605
+ if severity < 0.05:
606
+ level = "NONE"
607
+ elif severity < 0.3:
608
+ level = "LOW"
609
+ elif severity < 0.6:
610
+ level = "MEDIUM"
611
+ else:
612
+ level = "HIGH"
613
+ diagnosis_lines.append(
614
+ f" - {name.title()}: severity={severity:.2f} [{level}] -> use {specialist}"
615
+ )
616
+ diagnosis_lines.append(
617
+ f" - Accuracy gap: {accuracy_gap:.4f} "
618
+ f"({'within reach' if accuracy_gap < 0.05 else 'significant gap'})"
619
+ )
620
+
621
+ # ── Build action plan ─���───────────────────────────────────────────
622
+ plan_lines = [f"\nRECOMMENDED PLAN (budget remaining: {budget_remaining}):"]
623
+ step = 1
624
+
625
+ # Recommend top 2 non-trivial problems
626
+ for name, severity, specialist in problems:
627
+ if severity >= 0.1:
628
+ plan_lines.append(f" {step}. {specialist} → apply best recommendation")
629
+ step += 1
630
+ if step > 3:
631
+ break
632
+
633
+ # Always validate after fixes
634
+ plan_lines.append(f" {step}. validate (check accuracy improvement)")
635
+ step += 1
636
+
637
+ # Budget guidance
638
+ if budget_remaining <= 8:
639
+ plan_lines.append(
640
+ f" {step}. submit NOW — budget is critically low ({budget_remaining} steps left)"
641
+ )
642
+ plan_lines.append(" NOTE: Skip query_validator (costs 2 budget).")
643
+ elif accuracy_gap < 0.02:
644
+ plan_lines.append(f" {step}. submit — you are very close to target")
645
+ else:
646
+ plan_lines.append(f" {step}. Repeat if accuracy gap remains > 0.02, then submit")
647
+
648
+ # Feature note
649
+ if imbalance_severity > missing_severity and imbalance_severity > 0.2:
650
+ plan_lines.append("\nPRIORITY NOTE: Class imbalance is the dominant issue — fix this first.")
651
+ elif missing_severity > 0.2:
652
+ plan_lines.append("\nPRIORITY NOTE: High missing-value rate — clean data before augmenting.")
653
+
654
+ return "\n".join(diagnosis_lines + plan_lines)
sft_generator.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Ultra-Fast SFT Data Generator — No sklearn, No Environment Execution.
3
+
4
+ Instead of running the environment live, we generate realistic prompt/response
5
+ pairs directly from templates using known dataset states.
6
+
7
+ This is correct because:
8
+ - We know exactly what inspect_dataset returns (from dataset_generator)
9
+ - We know what query_cleaner returns (from specialist_agents)
10
+ - We know the reward trajectory
11
+ - The actual RL training will run the real environment — SFT just warms up
12
+ the LLM's action distribution (command grammar + strategy)
13
+
14
+ Output: ~1000+ diverse examples in under 10 seconds.
15
+ """
16
+
17
+ import json
18
+ import os
19
+ import random
20
+ import sys
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+
24
+ from server.dataset_generator import TASK_CONFIGS
25
+
26
+ rng = random.Random(42)
27
+
28
+ TASKS = list(TASK_CONFIGS.keys())
29
+
30
+ # ── Prompt templates ──────────────────────────────────────────────────────────
31
+
32
+ def make_prompt(
33
+ task: str,
34
+ step: int,
35
+ max_steps: int,
36
+ current_acc: float,
37
+ target_acc: float,
38
+ baseline_acc: float,
39
+ dataset_shape: str,
40
+ rows_pct: float,
41
+ quality: float,
42
+ budget: int,
43
+ session: str,
44
+ validate_left: int,
45
+ last_obs: str,
46
+ ) -> str:
47
+ gap = max(0.0, target_acc - current_acc)
48
+ return (
49
+ f"You are a Data-Centric AI agent improving an ML dataset.\n\n"
50
+ f"Task: {task}\n"
51
+ f"Step: {step}/{max_steps}\n"
52
+ f"Current accuracy: {current_acc:.4f} "
53
+ f"Target: {target_acc:.4f} Gap: {gap:.4f}\n"
54
+ f"Baseline accuracy: {baseline_acc:.4f}\n"
55
+ f"Dataset: {dataset_shape} | "
56
+ f"Rows preserved: {rows_pct*100:.1f}%\n"
57
+ f"Quality score: {quality:.4f} | "
58
+ f"Budget remaining: {budget}\n"
59
+ f"Active session: {session} | "
60
+ f"Validate calls left: {validate_left}\n\n"
61
+ f"Last observation:\n{last_obs}\n\n"
62
+ f"What is your next command?"
63
+ )
64
+
65
+
66
+ # ── Observation text snippets ────────────────────────────────────────────────
67
+
68
+ INSPECT_OBS_TEMPLATES = [
69
+ "=== Dataset Inspection ===\nShape: {rows} rows × {cols} features\nOriginal rows: {rows} | Preserved: 100.0%\nDuplicates: {dups}\nMissing values:\n {col}: {missing}\nClass distribution: {dist}\nDtypes: {{'age': 'float64', 'score': 'float64', 'target': 'int64'}}",
70
+ "=== Dataset Inspection ===\nShape: {rows} rows × {cols} features\nDuplicates: {dups}\nMissing values:\n {col}: {missing}\nClass distribution: {dist}",
71
+ ]
72
+
73
+ INSPECT_MODEL_TEMPLATES = [
74
+ "=== Model Inspection ===\nAccuracy: {acc:.4f}\n Class 0: precision={p0:.3f} recall={r0:.3f} f1={f0:.3f}\n Class 1: precision={p1:.3f} recall={r1:.3f} f1={f1:.3f}\nTarget: {target:.4f} | Not yet",
75
+ "=== Model Inspection (cached) ===\nAccuracy: {acc:.4f}\nTarget: {target:.4f} | Not yet",
76
+ ]
77
+
78
+ CLEANER_OBS_TEMPLATES = [
79
+ "=== Cleaner Recommendations ===\n[1] Fill {n} missing values in '{col}' using mean ({mean:.2f})\n type=fill_missing impact=+0.075 confidence=0.90\n[2] Remove {dups} duplicate rows\n type=remove_duplicates impact=+0.020 confidence=0.95",
80
+ "=== Cleaner Recommendations ===\n[1] Fill {n} missing values in '{col}' using mean ({mean:.2f})\n type=fill_missing impact=+0.075 confidence=0.90\n[2] Fix {typos} type errors in 'income'\n type=fix_type_errors impact=+0.040 confidence=0.75",
81
+ "=== Cleaner Recommendations ===\n[1] Fill {n} missing values in '{col}' using mean ({mean:.2f})\n type=fill_missing impact=+0.075 confidence=0.90",
82
+ ]
83
+
84
+ BALANCER_OBS_TEMPLATES = [
85
+ "=== Balancer Recommendations ===\n[1] Upsample minority class 1 from {min_c} to {maj_c} rows via random oversampling (imbalance ratio: {ratio:.2f})\n type=oversample impact=+0.053 confidence=0.80",
86
+ "=== Balancer Recommendations ===\n[1] Downsample majority class 0 from {maj_c} to {min_c} rows\n type=undersample impact=+0.030 confidence=0.70",
87
+ ]
88
+
89
+ APPLY_OBS_TEMPLATES = [
90
+ "Applied: fill_missing [Fill {n} missing values in '{col}' using mean ({mean:.2f})]\n\nDataset health check:\n Missing values: {remaining} remaining (was {was})\n Duplicates: ✓ (was 0)\n Row count: {rows}/{orig} (100.0% preserved)\n\nEstimated quality score: {quality:.4f}\nBudget remaining: {budget}",
91
+ "Applied: remove_duplicates [Remove {dups} duplicate rows]\n\nDataset health check:\n Missing values: {remaining} remaining (was {was})\n Duplicates: ✓ (was {dups})\n Row count: {rows}/{orig} ({pct:.1f}% preserved)\n\nEstimated quality score: {quality:.4f}\nBudget remaining: {budget}",
92
+ "Applied: oversample [Upsample minority class 1 via random oversampling]\n\nDataset health check:\n Missing values: 0 remaining (was 0)\n Duplicates: 2 remaining (was 0)\n Row count: {rows}/{orig} (102.0% preserved)\n\nEstimated quality score: {quality:.4f}\nBudget remaining: {budget}",
93
+ ]
94
+
95
+ VALIDATE_OBS_TEMPLATES = [
96
+ "=== Validate ===\nRF Accuracy: {acc:.4f} (primary)\nLR Accuracy: {lr_acc:.4f} (secondary)\nAgreement: BOTH_AGREE_IMPROVE -- fix is robust and generalises\n Class 0: p={p:.3f} r={r:.3f} f1={f:.3f}\n Class 1: p={p:.3f} r={r:.3f} f1={f:.3f}\nTarget: {target:.4f} | {status}",
97
+ "=== Validate ===\nRF Accuracy: {acc:.4f} (primary)\nLR Accuracy: {lr_acc:.4f} (secondary)\nAgreement: BOTH_AGREE_IMPROVE -- fix is robust and generalises\nTarget: {target:.4f} | {status}",
98
+ "=== Validate (cached) ===\nRF Accuracy: {acc:.4f} (primary)\nLR Accuracy: {lr_acc:.4f} (secondary)\nTarget: {target:.4f} | {status}",
99
+ ]
100
+
101
+ ERROR_OBS_TEMPLATES = [
102
+ "Error: Recommendation 1 has already been applied this session. Duplicate apply not allowed.",
103
+ "Validate on cooldown. Take 1 more action(s) before validating again.",
104
+ "Error: stale recommendation ID 99. Please re-query for fresh recommendations.",
105
+ ]
106
+
107
+ RESET_OBS = (
108
+ "Episode started: {task}\n"
109
+ "Baseline accuracy: {baseline:.4f} | Target: {target:.4f}\n"
110
+ "Dataset: {rows} rows x {cols} features\n"
111
+ "Budget: {budget} steps\n\n"
112
+ "Available commands:\n"
113
+ " inspect_dataset - shape, dtypes, missing, class distribution\n"
114
+ " inspect_model - accuracy (RF + LR), F1, feature importance\n"
115
+ " query_analyst - holistic diagnosis + prioritised action plan (costs 1 budget)\n"
116
+ " query_cleaner - get cleaning recommendations\n"
117
+ " query_augmenter [class] - get augmentation suggestions\n"
118
+ " query_balancer - get resampling recommendations\n"
119
+ " query_validator - check rule violations (costs 2 budget)\n"
120
+ " apply [id] - apply recommendation by ID\n"
121
+ " reject [id] - reject a recommendation\n"
122
+ " validate - retrain and score (cooldown applies)\n"
123
+ " submit - finalize episode"
124
+ )
125
+
126
+ ANALYST_OBS_TEMPLATES = [
127
+ "=== Analyst Report (costs 1 budget) ===\nDIAGNOSIS:\n - Class Imbalance: severity={imb:.2f} [HIGH] -> use query_balancer\n - Missing Values: severity={miss:.2f} [MEDIUM] -> use query_cleaner\n - Type Errors: severity=0.00 [NONE]\n - Accuracy gap: {gap:.4f} (significant gap)\n\nRECOMMENDED PLAN (budget remaining: {budget}):\n 1. query_balancer -> apply best recommendation\n 2. query_cleaner -> apply best recommendation\n 3. validate (check accuracy improvement)\n 4. submit if accuracy >= target\n\nPRIORITY NOTE: Class imbalance is the dominant issue -- fix this first.",
128
+ "=== Analyst Report (costs 1 budget) ===\nDIAGNOSIS:\n - Missing Values: severity={miss:.2f} [HIGH] -> use query_cleaner\n - Class Imbalance: severity={imb:.2f} [LOW] -> use query_balancer\n - Type Errors: severity=0.00 [NONE]\n - Accuracy gap: {gap:.4f} (significant gap)\n\nRECOMMENDED PLAN (budget remaining: {budget}):\n 1. query_cleaner -> apply best recommendation\n 2. query_balancer -> apply best recommendation\n 3. validate\n 4. submit",
129
+ ]
130
+
131
+
132
+ # ── Episode builders ─────────────────────────────────────────────────────────
133
+
134
+ def sample_dataset_params(task: str, seed: int):
135
+ """Sample realistic dataset params for a given task."""
136
+ cfg = TASK_CONFIGS[task]
137
+ rng2 = random.Random(seed)
138
+ rows_map = {"task_0_tutorial": 100, "task_1_easy": 200,
139
+ "task_2_medium": 500, "task_3_hard": 900}
140
+ cols_map = {"task_0_tutorial": 4, "task_1_easy": 5,
141
+ "task_2_medium": 7, "task_3_hard": 10}
142
+ rows = rows_map[task]
143
+ cols = cols_map[task]
144
+ missing_cols = ["age", "income", "score"][:rng2.randint(1, 3)]
145
+ missing_pct = rng2.uniform(0.10, 0.30)
146
+ n_missing = int(rows * missing_pct)
147
+ mean_val = rng2.uniform(30.0, 60.0)
148
+ dups = rng2.randint(0, int(rows * 0.05))
149
+ maj_class = int(rows * rng2.uniform(0.52, 0.65))
150
+ min_class = rows - maj_class
151
+ return {
152
+ "task": task, "rows": rows, "cols": cols,
153
+ "missing_col": missing_cols[0], "n_missing": n_missing,
154
+ "mean_val": round(mean_val, 2), "dups": dups,
155
+ "maj_class": maj_class, "min_class": min_class,
156
+ "baseline": cfg["baseline_accuracy"],
157
+ "target": cfg["target_accuracy"],
158
+ "budget": cfg["budget"],
159
+ }
160
+
161
+
162
+ def build_episode(task: str, seed: int, strategy: list) -> list:
163
+ """
164
+ Build a synthetic SFT episode using template obs + fixed action sequence.
165
+ Returns list of {prompt, response} dicts.
166
+ """
167
+ p = sample_dataset_params(task, seed)
168
+ cfg = TASK_CONFIGS[task]
169
+ examples = []
170
+
171
+ acc = p["baseline"]
172
+ quality = round(rng.uniform(0.45, 0.65), 4)
173
+ rows = p["rows"]
174
+ missing_remaining = p["n_missing"]
175
+ budget = p["budget"]
176
+ session = "none"
177
+ validate_left = 3
178
+ prev_obs = RESET_OBS.format(
179
+ task=task, baseline=p["baseline"], target=p["target"],
180
+ rows=rows, cols=p["cols"], budget=budget
181
+ )
182
+
183
+ for step, action in enumerate(strategy):
184
+ prompt = make_prompt(
185
+ task=task, step=step, max_steps=p["budget"],
186
+ current_acc=acc, target_acc=p["target"], baseline_acc=p["baseline"],
187
+ dataset_shape=f"{rows} rows × {p['cols']} columns",
188
+ rows_pct=rows / p["rows"], quality=quality, budget=budget,
189
+ session=session, validate_left=validate_left, last_obs=prev_obs,
190
+ )
191
+ examples.append({"prompt": prompt, "response": action})
192
+
193
+ # Simulate observation update
194
+ budget -= 1
195
+ cmd = action.split()[0].lower()
196
+
197
+ if cmd == "inspect_dataset":
198
+ t = rng.choice(INSPECT_OBS_TEMPLATES)
199
+ dist = f"class 0: {p['maj_class']}, class 1: {p['min_class']}"
200
+ prev_obs = t.format(
201
+ rows=rows, cols=p["cols"], dups=p["dups"],
202
+ col=p["missing_col"], missing=missing_remaining, dist=dist,
203
+ )
204
+ elif cmd == "inspect_model":
205
+ t = rng.choice(INSPECT_MODEL_TEMPLATES)
206
+ p0 = round(rng.uniform(0.55, 0.75), 3)
207
+ r0 = round(rng.uniform(0.55, 0.75), 3)
208
+ prev_obs = t.format(
209
+ acc=acc, target=p["target"],
210
+ p0=p0, r0=r0, f0=round(2*p0*r0/(p0+r0+1e-9), 3),
211
+ p1=p0, r1=r0, f1=round(2*p0*r0/(p0+r0+1e-9), 3),
212
+ )
213
+ elif cmd == "query_cleaner":
214
+ t = rng.choice(CLEANER_OBS_TEMPLATES)
215
+ session = f"cleaner:{seed:08x}"
216
+ prev_obs = t.format(
217
+ n=missing_remaining, col=p["missing_col"],
218
+ mean=p["mean_val"], dups=p["dups"], typos=rng.randint(2, 8),
219
+ )
220
+ elif cmd == "query_balancer":
221
+ t = rng.choice(BALANCER_OBS_TEMPLATES)
222
+ session = f"balancer:{seed:08x}"
223
+ ratio = round(p["min_class"] / max(p["maj_class"], 1), 2)
224
+ prev_obs = t.format(
225
+ min_c=p["min_class"], maj_c=p["maj_class"], ratio=ratio
226
+ )
227
+ elif cmd == "query_augmenter":
228
+ session = f"augmenter:{seed:08x}"
229
+ cls = action.split()[1] if len(action.split()) > 1 else "0"
230
+ n_synth = rng.randint(5, 25)
231
+ prev_obs = (
232
+ f"=== Augmenter Recommendations ===\n"
233
+ f"[1] Synthesize {n_synth} samples for class {cls} via SMOTE\n"
234
+ f" type=augment_class impact=+0.040 confidence=0.72"
235
+ )
236
+ elif cmd == "query_analyst":
237
+ budget -= 1 # costs 1 extra
238
+ t = rng.choice(ANALYST_OBS_TEMPLATES)
239
+ imb = round(rng.uniform(0.3, 0.8), 2)
240
+ miss = round(rng.uniform(0.1, 0.5), 2)
241
+ gap = round(p["target"] - acc, 4)
242
+ prev_obs = t.format(imb=imb, miss=miss, gap=gap, budget=budget)
243
+ elif cmd == "query_validator":
244
+ budget -= 1 # costs 2
245
+ prev_obs = (
246
+ "=== Validator Report (costs 2 budget) ===\n"
247
+ f" [WARNING] [{p['missing_col']}] rule=no_missing "
248
+ f"count={missing_remaining}\n"
249
+ f" Column '{p['missing_col']}' has {missing_remaining} missing values."
250
+ )
251
+ elif cmd == "apply":
252
+ rec_id = int(action.split()[1]) if len(action.split()) > 1 else 1
253
+ t = rng.choice(APPLY_OBS_TEMPLATES)
254
+ was_missing = missing_remaining
255
+ missing_remaining = max(0, missing_remaining - p["n_missing"])
256
+ quality = min(1.0, quality + rng.uniform(0.10, 0.35))
257
+ quality = round(quality, 4)
258
+ prev_obs = t.format(
259
+ n=p["n_missing"], col=p["missing_col"], mean=p["mean_val"],
260
+ remaining=missing_remaining, was=was_missing,
261
+ rows=rows, orig=p["rows"], pct=rows/p["rows"]*100,
262
+ dups=p["dups"], quality=quality, budget=budget,
263
+ )
264
+ elif cmd == "reject":
265
+ prev_obs = f"Recommendation {action.split()[1] if len(action.split())>1 else 1} rejected."
266
+ elif cmd == "validate":
267
+ if validate_left > 0:
268
+ acc = min(1.0, acc + rng.uniform(0.05, 0.35))
269
+ acc = round(acc, 4)
270
+ lr_acc = round(min(1.0, acc + rng.uniform(-0.03, 0.03)), 4)
271
+ validate_left -= 1
272
+ t = rng.choice(VALIDATE_OBS_TEMPLATES)
273
+ status = "HIT v" if acc >= p["target"] else "Not yet"
274
+ pv = round(rng.uniform(0.75, 0.98), 3)
275
+ rv = round(rng.uniform(0.75, 0.98), 3)
276
+ prev_obs = t.format(
277
+ acc=acc, lr_acc=lr_acc, target=p["target"], status=status,
278
+ p=pv, r=rv, f=round(2*pv*rv/(pv+rv+1e-9), 3),
279
+ )
280
+ else:
281
+ prev_obs = "Validate on cooldown. Take 2 more action(s) before validating again."
282
+ elif cmd == "submit":
283
+ break
284
+
285
+ return examples
286
+
287
+
288
+ # ── Strategy sequences ────────────────────────────────────────────────────────
289
+
290
+ STRATEGIES = {
291
+ "minimal_clean": ["inspect_dataset", "query_cleaner", "apply 1", "apply 2", "inspect_dataset", "validate", "submit"],
292
+ "inspect_model_first": ["inspect_dataset", "inspect_model", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
293
+ "clean_then_balance": ["inspect_dataset", "query_cleaner", "apply 1", "apply 2", "query_balancer", "apply 1", "inspect_dataset", "validate", "submit"],
294
+ "reject_then_apply": ["inspect_dataset", "query_cleaner", "reject 1", "apply 2", "inspect_dataset", "validate", "submit"],
295
+ "baseline_validate_first": ["inspect_dataset", "validate", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
296
+ "augment_path": ["inspect_dataset", "query_cleaner", "apply 1", "query_augmenter 0", "apply 1", "inspect_dataset", "validate", "submit"],
297
+ "with_validator": ["inspect_dataset", "query_validator", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
298
+ "deep_clean_requery": ["inspect_dataset", "query_cleaner", "apply 1", "apply 2", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
299
+ "fast_submit": ["query_cleaner", "apply 1", "apply 2", "inspect_dataset", "submit"],
300
+ "balance_heavy": ["inspect_dataset", "query_balancer", "apply 1", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
301
+ "reject_requery": ["inspect_dataset", "query_cleaner", "reject 1", "reject 2", "query_cleaner", "apply 1", "inspect_dataset", "validate", "submit"],
302
+ "multi_augment": ["inspect_dataset", "query_cleaner", "apply 1", "query_augmenter 1", "apply 1", "inspect_dataset", "validate", "submit"],
303
+ "model_then_balance": ["inspect_model", "query_balancer", "apply 1", "inspect_dataset", "validate", "submit"],
304
+ "full_pipeline": ["inspect_dataset", "inspect_model", "query_cleaner", "apply 1", "query_balancer", "apply 1", "query_augmenter 0", "apply 1", "inspect_dataset", "validate", "submit"],
305
+ "suboptimal_no_validate": ["inspect_dataset", "query_cleaner", "apply 1", "submit"],
306
+ "inspect_only_submit": ["inspect_dataset", "inspect_model", "submit"],
307
+ "reject_all_then_requery": ["inspect_dataset", "query_cleaner", "reject 1", "reject 2", "query_balancer", "apply 1", "inspect_dataset", "validate", "submit"],
308
+ "apply3_then_validate": ["inspect_dataset", "query_cleaner", "apply 1", "apply 2", "query_balancer", "apply 1", "query_augmenter 0", "apply 1", "inspect_dataset", "validate", "submit"],
309
+ # NEW: analyst-led strategies
310
+ "analyst_led_clean": ["query_analyst", "inspect_dataset", "query_cleaner", "apply 1", "apply 2", "validate", "submit"],
311
+ "analyst_led_balance": ["query_analyst", "query_balancer", "apply 1", "query_cleaner", "apply 1", "validate", "submit"],
312
+ "analyst_full_pipeline": ["query_analyst", "inspect_dataset", "inspect_model", "query_cleaner", "apply 1", "query_balancer", "apply 1", "validate", "submit"],
313
+ }
314
+
315
+
316
+ def generate_sft_data(output_file: str = "sft_data.jsonl", seeds_per_combo: int = 15):
317
+ sft_examples = []
318
+
319
+ print(f"Generating SFT data: {len(STRATEGIES)} strategies × {len(TASKS)} tasks × {seeds_per_combo} seeds")
320
+
321
+ for strategy_name, sequence in STRATEGIES.items():
322
+ strategy_examples = []
323
+ for task in TASKS:
324
+ for seed in range(seeds_per_combo):
325
+ episode = build_episode(task, seed, sequence)
326
+ strategy_examples.extend(episode)
327
+ sft_examples.extend(strategy_examples)
328
+ print(f" {strategy_name:<30} +{len(strategy_examples)} examples")
329
+
330
+ rng.shuffle(sft_examples)
331
+
332
+ out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), output_file)
333
+ with open(out_path, "w", encoding="utf-8") as f:
334
+ for ex in sft_examples:
335
+ f.write(json.dumps(ex) + "\n")
336
+
337
+ # Diversity report
338
+ from collections import Counter
339
+ responses = [ex["response"] for ex in sft_examples]
340
+ unique_cmds = set(responses)
341
+
342
+ print(f"\n{'='*55}")
343
+ print(f"Total examples: {len(sft_examples)}")
344
+ print(f"Unique commands: {len(unique_cmds)}")
345
+ print(f"Unique prompts: {len(set(ex['prompt'] for ex in sft_examples))}")
346
+ print(f"\nResponse distribution:")
347
+ for cmd, cnt in Counter(responses).most_common():
348
+ pct = cnt / len(responses) * 100
349
+ bar = "#" * int(pct / 2)
350
+ flag = " ← DOMINANT" if pct > 25 else ""
351
+ print(f" {cmd:<32} {cnt:>5} ({pct:5.1f}%) {bar}{flag}")
352
+
353
+ print(f"\nOutput: {out_path}")
354
+ print("✓ SFT generation complete (no sklearn, instant).")
355
+ return sft_examples
356
+
357
+
358
+ if __name__ == "__main__":
359
+ generate_sft_data()
test_features_smoke.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """Smoke test: verify all 5 new features work end-to-end."""
3
+ import sys, os
4
+ sys.stdout.reconfigure(encoding='utf-8', errors='replace')
5
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
6
+
7
+ from models import DataCentricAction
8
+ from server.data_centric_environment import DataCentricEnvironment
9
+
10
+ env = DataCentricEnvironment()
11
+ obs = env.reset(task='task_1_easy', seed=7)
12
+ print(f"Reset OK. Budget: {obs.budget_remaining}, Baseline: {obs.baseline_accuracy:.4f}")
13
+ print()
14
+
15
+ # ─── Feature 3: query_analyst ────────────────────────────────────────────────
16
+ print("=" * 60)
17
+ print("TEST 1: query_analyst (meta-specialist, costs 1 budget)")
18
+ print("=" * 60)
19
+ budget_before = obs.budget_remaining
20
+ obs = env.step(DataCentricAction(message='query_analyst'))
21
+ budget_after = obs.budget_remaining
22
+ print(obs.response)
23
+ print(f"\n[BUDGET CHECK] Before={budget_before}, After={budget_after}, Diff={budget_before - budget_after}")
24
+ assert "DIAGNOSIS" in obs.response, "FAIL: no DIAGNOSIS section"
25
+ assert "RECOMMENDED PLAN" in obs.response, "FAIL: no RECOMMENDED PLAN section"
26
+ assert budget_before - budget_after == 2, f"FAIL: should cost 2 total (1 cmd + 1 analyst), got {budget_before - budget_after}"
27
+ print("PASS: query_analyst works")
28
+
29
+ # ─── Feature 1: Smarter specialists ─────────────────────────────────────────
30
+ print()
31
+ print("=" * 60)
32
+ print("TEST 2: query_cleaner (smarter specialists with reasoning)")
33
+ print("=" * 60)
34
+ obs = env.step(DataCentricAction(message='query_cleaner'))
35
+ print(obs.response)
36
+ # Check for statistical reasoning markers
37
+ has_reasoning = any(kw in obs.response for kw in ["skew", "Risk:", "Reason:", "median", "mean", "%"])
38
+ assert has_reasoning, "FAIL: no statistical reasoning found in cleaner output"
39
+ print("PASS: smarter specialists working (statistical reasoning present)")
40
+
41
+ # ─── Feature 5: Drift detection ──────────────────────────────────────────────
42
+ print()
43
+ print("=" * 60)
44
+ print("TEST 3: apply 1 (drift detection after apply)")
45
+ print("=" * 60)
46
+ obs = env.step(DataCentricAction(message='apply 1'))
47
+ print(obs.response)
48
+ has_drift = "Distribution drift" in obs.response or "drift" in obs.response.lower()
49
+ assert has_drift, "FAIL: no drift information in apply response"
50
+ print("PASS: drift detection working")
51
+
52
+ # ─── Feature 2 + 4: Dual classifier + Feature importance ───────────────────
53
+ print()
54
+ print("=" * 60)
55
+ print("TEST 4: validate (dual classifier + feature importance)")
56
+ print("=" * 60)
57
+ obs = env.step(DataCentricAction(message='validate'))
58
+ print(obs.response)
59
+ assert "RF Accuracy" in obs.response, "FAIL: no RF Accuracy"
60
+ assert "LR Accuracy" in obs.response, "FAIL: no LR Accuracy"
61
+ assert "Agreement" in obs.response, "FAIL: no Agreement signal"
62
+ has_feat_imp = "Feature importance" in obs.response
63
+ print(f"Feature importance shown: {has_feat_imp}")
64
+ print("PASS: dual classifier + agreement signal working")
65
+
66
+ # ─── Feature 4: Feature importance in inspect_model ─────────────────────────
67
+ print()
68
+ print("=" * 60)
69
+ print("TEST 5: inspect_model (RF + LR + feature importance)")
70
+ print("=" * 60)
71
+ obs = env.step(DataCentricAction(message='inspect_model'))
72
+ print(obs.response)
73
+ assert "RF Accuracy" in obs.response, "FAIL: no RF Accuracy in inspect_model"
74
+ assert "LR Accuracy" in obs.response, "FAIL: no LR Accuracy in inspect_model"
75
+ print("PASS: inspect_model shows dual classifier")
76
+
77
+ print()
78
+ print("=" * 60)
79
+ print("ALL 5 FEATURES VERIFIED OK")
80
+ print("=" * 60)
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import sys, os
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
tests/test_environment.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for anti-exploit and environment stability.
3
+
4
+ Tests that the core safety invariants hold:
5
+ - ground truth never mutates
6
+ - budget is enforced
7
+ - validate calls are limited
8
+ - undo works correctly
9
+
10
+ Run with: pytest tests/test_environment.py -v
11
+ """
12
+ import pytest
13
+ import sys, os
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15
+
16
+ from models import DataCentricAction
17
+ from server.data_centric_environment import DataCentricEnvironment
18
+
19
+
20
+ # ── Helpers ───────────────────────────────────────────────────────────────────
21
+
22
+ def make_env(task="task_0_tutorial", seed=42) -> tuple:
23
+ """Return (env, reset_obs)."""
24
+ env = DataCentricEnvironment()
25
+ obs = env.reset(task=task, seed=seed)
26
+ return env, obs
27
+
28
+
29
+ def step(env, cmd: str):
30
+ return env.step(DataCentricAction(message=cmd))
31
+
32
+
33
+ # ── Ground truth immutability ─────────────────────────────────────────────────
34
+
35
+ class TestGroundTruth:
36
+
37
+ def test_ground_truth_unchanged_after_reset(self):
38
+ env, _ = make_env()
39
+ gt_before = env._ground_truth.copy()
40
+ env.reset(task="task_0_tutorial", seed=123)
41
+ # After re-reset, a NEW ground truth is loaded — old one doesn't matter
42
+ assert env._ground_truth is not None
43
+
44
+ def test_ground_truth_unchanged_after_query(self):
45
+ env, _ = make_env()
46
+ gt_before = env._ground_truth.copy()
47
+ step(env, "query_cleaner")
48
+ assert env._ground_truth.equals(gt_before), "GT mutated after query_cleaner"
49
+
50
+ def test_ground_truth_unchanged_after_inspect(self):
51
+ env, _ = make_env()
52
+ gt_before = env._ground_truth.copy()
53
+ step(env, "inspect_dataset")
54
+ assert env._ground_truth.equals(gt_before), "GT mutated after inspect_dataset"
55
+
56
+
57
+ # ── Budget enforcement ────────────────────────────────────────────────────────
58
+
59
+ class TestBudget:
60
+
61
+ def test_budget_decreases_each_step(self):
62
+ env, obs = make_env()
63
+ budget_start = obs.budget_remaining
64
+ obs2 = step(env, "inspect_dataset")
65
+ assert obs2.budget_remaining < budget_start
66
+
67
+ def test_done_after_budget_exhausted(self):
68
+ env, obs = make_env()
69
+ budget = obs.budget_remaining
70
+ last_obs = obs
71
+ for _ in range(budget + 5):
72
+ if last_obs.done:
73
+ break
74
+ last_obs = step(env, "inspect_dataset")
75
+ assert last_obs.done, "Episode should be done after budget exhausted"
76
+
77
+
78
+ # ── Validate calls ────────────────────────────────────────────────────────────
79
+
80
+ class TestValidateCalls:
81
+
82
+ def test_validate_calls_start_at_3(self):
83
+ env, obs = make_env()
84
+ assert obs.validate_calls_remaining == 3
85
+
86
+ def test_validate_call_decrements(self):
87
+ env, obs = make_env()
88
+ obs2 = step(env, "validate")
89
+ # Either decrement or cooldown message — either way calls consumed
90
+ assert obs2.validate_calls_remaining <= 3
91
+
92
+
93
+ # ── Undo ─────────────────────────────────────────────────────────────────────
94
+
95
+ class TestUndo:
96
+
97
+ def test_undo_without_history_returns_response(self):
98
+ """Undo with no history should return a message, not crash."""
99
+ env, _ = make_env()
100
+ obs = step(env, "undo")
101
+ assert obs.response is not None
102
+ assert len(obs.response) > 0
103
+
104
+ def test_undo_after_apply_restores_state(self):
105
+ """After apply+undo, working copy should match pre-apply state."""
106
+ env, _ = make_env()
107
+ step(env, "query_cleaner")
108
+ wc_before_apply = env._working_copy.copy()
109
+ step(env, "apply 1")
110
+ step(env, "undo")
111
+ # After undo, working copy should be restored
112
+ assert env._working_copy.shape == wc_before_apply.shape, (
113
+ f"Shape mismatch after undo: {env._working_copy.shape} vs {wc_before_apply.shape}"
114
+ )
115
+
116
+
117
+ # ── Snapshot stack ────────────────────────────────────────────────────────────
118
+
119
+ class TestSnapshotStack:
120
+
121
+ def test_max_3_snapshots(self):
122
+ env, _ = make_env()
123
+ step(env, "query_cleaner")
124
+ # Apply 4 times — stack should cap at 3
125
+ for i in range(1, 5):
126
+ step(env, f"apply {i}")
127
+ step(env, "query_cleaner") # re-query for fresh recs
128
+ assert len(env._dataset_history) <= env._max_history
129
+
130
+ def test_snapshot_cleared_on_reset(self):
131
+ env, _ = make_env()
132
+ step(env, "query_cleaner")
133
+ step(env, "apply 1")
134
+ env.reset(task="task_0_tutorial", seed=99)
135
+ assert len(env._dataset_history) == 0
136
+
137
+
138
+ # ── Reward sanity ─────────────────────────────────────────────────────────────
139
+
140
+ class TestRewardSanity:
141
+
142
+ def test_unknown_command_gives_penalty(self):
143
+ env, _ = make_env()
144
+ obs = step(env, "blorp_invalid_command_xyz")
145
+ assert obs.reward <= 0.0, (
146
+ f"Unknown command should not give positive reward, got {obs.reward}"
147
+ )
148
+
149
+ def test_reward_within_range(self):
150
+ env, _ = make_env()
151
+ from server.grader import REWARD_MIN, REWARD_MAX
152
+ for cmd in ["inspect_dataset", "inspect_model", "query_cleaner",
153
+ "query_balancer", "validate"]:
154
+ obs = step(env, cmd)
155
+ r = obs.reward
156
+ assert REWARD_MIN - 0.01 <= r <= REWARD_MAX + 0.01, (
157
+ f"Command '{cmd}' gave out-of-range reward {r}"
158
+ )
tests/test_grader.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unit tests for server/grader.py
3
+
4
+ Tests every reward component individually plus the range clamp.
5
+ Run with: pytest tests/test_grader.py -v
6
+ """
7
+ import importlib.util
8
+ import sys
9
+ import os
10
+ import pytest
11
+ import pandas as pd
12
+
13
+ # Import grader directly (avoids server/__init__.py → openenv-core chain)
14
+ _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
15
+ _GRADER = os.path.join(_ROOT, "server", "grader.py")
16
+ spec = importlib.util.spec_from_file_location("grader", _GRADER)
17
+ _mod = importlib.util.module_from_spec(spec)
18
+ spec.loader.exec_module(_mod)
19
+
20
+ REWARD_MAX = _mod.REWARD_MAX
21
+ REWARD_MIN = _mod.REWARD_MIN
22
+ compute_accuracy_reward = _mod.compute_accuracy_reward
23
+ compute_efficiency_reward = _mod.compute_efficiency_reward
24
+ compute_preservation_reward = _mod.compute_preservation_reward
25
+ compute_process_reward = _mod.compute_process_reward
26
+ compute_step_reward = _mod.compute_step_reward
27
+ compute_total_reward = _mod.compute_total_reward
28
+ compute_lightweight_score = _mod.compute_lightweight_score
29
+
30
+
31
+ # ── Accuracy reward ───────────────────────────────────────────────────────────
32
+
33
+ class TestAccuracyReward:
34
+
35
+ def test_improvement_positive(self):
36
+ r = compute_accuracy_reward(0.70, 0.62, 0.62, 0.80)
37
+ assert r > 0, f"Improvement should give positive reward, got {r}"
38
+
39
+ def test_regression_negative(self):
40
+ r = compute_accuracy_reward(0.60, 0.70, 0.62, 0.80)
41
+ assert r < 0, f"Regression should give negative reward, got {r}"
42
+
43
+ def test_no_change_zero(self):
44
+ r = compute_accuracy_reward(0.65, 0.65, 0.62, 0.80)
45
+ assert r == 0.0
46
+
47
+ def test_submit_success_bonus(self):
48
+ r = compute_accuracy_reward(0.80, 0.75, 0.62, 0.80, is_submit=True)
49
+ assert r > 0.5, f"Submit success should add bonus, got {r}"
50
+
51
+ def test_submit_fail_partial_credit(self):
52
+ """Halfway to target should give some credit."""
53
+ # baseline=0.62, target=0.80, current=0.71 → 50% of the way there
54
+ r = compute_accuracy_reward(0.71, 0.70, 0.62, 0.80, is_submit=True)
55
+ # Current is at 0.71, previous was 0.70 → small improvement, partial credit
56
+ assert r > 0, f"Partial progress at submit should give credit, got {r}"
57
+
58
+
59
+ # ── Preservation reward ───────────────────────────────────────────────────────
60
+
61
+ class TestPreservationReward:
62
+
63
+ def test_above_90_bonus(self):
64
+ r = compute_preservation_reward(97, 100)
65
+ assert r > 0
66
+
67
+ def test_below_90_zero_or_neg(self):
68
+ r = compute_preservation_reward(85, 100)
69
+ assert r <= 0.02 # at best neutral at 85%
70
+
71
+ def test_below_50_catastrophic(self):
72
+ r = compute_preservation_reward(40, 100)
73
+ assert r <= -0.40, f"Expected catastrophic penalty, got {r}"
74
+
75
+ def test_full_preservation(self):
76
+ r = compute_preservation_reward(100, 100)
77
+ assert r == 0.05
78
+
79
+
80
+ # ── Process reward ────────────────────────────────────────────────────────────
81
+
82
+ class TestProcessReward:
83
+
84
+ def test_query_after_inspect_rewarded(self):
85
+ history = ["inspect_dataset"]
86
+ r = compute_process_reward(history, "query_cleaner")
87
+ assert r > 0
88
+
89
+ def test_apply_without_query_penalized(self):
90
+ history = ["inspect_dataset", "inspect_model"]
91
+ r = compute_process_reward(history, "apply 1")
92
+ assert r < 0
93
+
94
+ def test_apply_after_query_rewarded(self):
95
+ history = ["inspect_dataset", "query_cleaner"]
96
+ r = compute_process_reward(history, "apply 1")
97
+ assert r > 0
98
+
99
+ def test_submit_without_validate_penalized(self):
100
+ history = ["inspect_dataset", "query_cleaner", "apply 1"]
101
+ r = compute_process_reward(history, "submit")
102
+ assert r < 0
103
+
104
+
105
+ # ── Step reward ───────────────────────────────────────────────────────────────
106
+
107
+ class TestStepReward:
108
+
109
+ def test_quality_improvement_positive(self):
110
+ r = compute_step_reward("apply 1", quality_before=0.5, quality_after=0.7,
111
+ rows_preserved_after=0.97)
112
+ assert r > 0
113
+
114
+ def test_quality_degradation_negative(self):
115
+ r = compute_step_reward("apply 1", quality_before=0.7, quality_after=0.4,
116
+ rows_preserved_after=0.97)
117
+ assert r < 0
118
+
119
+ def test_non_apply_zero(self):
120
+ r = compute_step_reward("validate", quality_before=0.5, quality_after=0.7,
121
+ rows_preserved_after=0.97)
122
+ assert r == 0.0
123
+
124
+ def test_low_preservation_penalty(self):
125
+ r = compute_step_reward("apply 1", quality_before=0.5, quality_after=0.6,
126
+ rows_preserved_after=0.75)
127
+ # Row preservation penalty should reduce reward
128
+ r_without = compute_step_reward("apply 1", quality_before=0.5, quality_after=0.6,
129
+ rows_preserved_after=0.97)
130
+ assert r < r_without
131
+
132
+
133
+ # ── Total reward range ────────────────────────────────────────────────────────
134
+
135
+ class TestRewardRange:
136
+
137
+ def test_within_declared_range(self):
138
+ """All reward combinations must stay within [-1.0, 1.0]."""
139
+ test_cases = [
140
+ (0.5, 0.1, 0.05, 0.2, 0.1),
141
+ (-0.8, -0.1, -0.4, -0.05, -0.2),
142
+ (1.0, 0.2, 0.05, 0.2, 0.15), # might need clamping
143
+ (-1.5, -0.2, -0.4, -0.05, -0.3), # definitely needs clamping
144
+ ]
145
+ for acc, proc, pres, eff, step in test_cases:
146
+ r = compute_total_reward(acc, proc, pres, eff, step)
147
+ assert REWARD_MIN <= r <= REWARD_MAX, (
148
+ f"Reward {r} out of [{REWARD_MIN}, {REWARD_MAX}] "
149
+ f"for inputs acc={acc} proc={proc} pres={pres}"
150
+ )
151
+
152
+ def test_clamping_applied(self):
153
+ """Extreme inputs should be clamped, not crash."""
154
+ r = compute_total_reward(10.0, 5.0, 5.0)
155
+ assert r == REWARD_MAX
156
+
157
+ r = compute_total_reward(-10.0, -5.0, -5.0)
158
+ assert r == REWARD_MIN
159
+
160
+
161
+ # ── Lightweight quality score ─────────────────────────────────────────────────
162
+
163
+ class TestLightweightScore:
164
+
165
+ def _make_df(self, n_rows=10, n_missing=0, n_dups=0):
166
+ """Create a minimal test dataframe."""
167
+ df = pd.DataFrame({
168
+ "feature_0": [float(i) for i in range(n_rows)],
169
+ "target": [i % 2 for i in range(n_rows)],
170
+ })
171
+ if n_missing:
172
+ df.loc[:n_missing - 1, "feature_0"] = float("nan")
173
+ if n_dups:
174
+ df = pd.concat([df, df.iloc[:n_dups]], ignore_index=True)
175
+ return df
176
+
177
+ def test_clean_df_high_score(self):
178
+ df = self._make_df()
179
+ score = compute_lightweight_score(df, df.copy(), len(df),
180
+ {"feature_0": {"expected_dtype": "float64"}})
181
+ assert score >= 0.80
182
+
183
+ def test_many_missing_low_score(self):
184
+ df = self._make_df(n_missing=8)
185
+ score = compute_lightweight_score(df, df.copy(), 10,
186
+ {"feature_0": {"expected_dtype": "float64"}},
187
+ initial_missing=8)
188
+ assert score < 0.70
189
+
190
+ def test_score_in_range(self):
191
+ df = self._make_df(n_missing=3, n_dups=2)
192
+ score = compute_lightweight_score(df, df.copy(), 10,
193
+ {"feature_0": {"expected_dtype": "float64"}},
194
+ initial_missing=3)
195
+ assert 0.0 <= score <= 1.0
train_colab.ipynb ADDED
@@ -0,0 +1,498 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nbformat": 4,
3
+ "nbformat_minor": 5,
4
+ "metadata": {
5
+ "colab": {"provenance": [], "gpuType": "T4"},
6
+ "kernelspec": {"display_name": "Python 3", "name": "python3"},
7
+ "language_info": {"name": "python"},
8
+ "accelerator": "GPU"
9
+ },
10
+ "cells": [
11
+ {
12
+ "cell_type": "markdown",
13
+ "id": "a0",
14
+ "metadata": {},
15
+ "source": [
16
+ "# Data-Centric AI Agent \u2014 Training Notebook\n",
17
+ "**OpenEnv Hackathon 2026**\n\n",
18
+ "Trains a Qwen2.5-3B agent to improve ML datasets using:\n",
19
+ "- **Phase 1**: SFT warmup (~30 min) \u2014 teaches command grammar\n",
20
+ "- **Phase 2**: GRPO training (~3-5 hrs) \u2014 teaches strategy via RL\n",
21
+ "- **Phase 3**: Eval + reward curves\n\n",
22
+ "**Runtime required:** T4 GPU. `Runtime \u2192 Change runtime type \u2192 T4 GPU`"
23
+ ]
24
+ },
25
+ {
26
+ "cell_type": "markdown",
27
+ "id": "a1",
28
+ "metadata": {},
29
+ "source": ["## Step 1 \u2014 Install dependencies (~5 min)"]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "execution_count": null,
34
+ "id": "b1",
35
+ "metadata": {},
36
+ "outputs": [],
37
+ "source": [
38
+ "!pip install \"unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git\" --quiet\n",
39
+ "!pip install trl datasets transformers accelerate scikit-learn pandas numpy matplotlib --quiet\n",
40
+ "!pip install \"openenv-core[core]>=0.2.1\" --quiet\n",
41
+ "print('Dependencies installed')"
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "markdown",
46
+ "id": "a2",
47
+ "metadata": {},
48
+ "source": [
49
+ "## Step 2 \u2014 Mount Google Drive (optional but recommended)\n",
50
+ "Checkpoints save every 50 GRPO steps to Drive. If Colab disconnects, you can resume.\n\n",
51
+ "**Skip this cell if you don't want Drive integration.**"
52
+ ]
53
+ },
54
+ {
55
+ "cell_type": "code",
56
+ "execution_count": null,
57
+ "id": "b2",
58
+ "metadata": {},
59
+ "outputs": [],
60
+ "source": [
61
+ "from google.colab import drive\n",
62
+ "drive.mount('/content/drive')\n",
63
+ "print('Drive mounted')"
64
+ ]
65
+ },
66
+ {
67
+ "cell_type": "markdown",
68
+ "id": "a3",
69
+ "metadata": {},
70
+ "source": ["## Step 3 \u2014 Clone / update repo + setup"]
71
+ },
72
+ {
73
+ "cell_type": "code",
74
+ "execution_count": null,
75
+ "id": "b3",
76
+ "metadata": {},
77
+ "outputs": [],
78
+ "source": [
79
+ "import os, sys, shutil\n",
80
+ "\n",
81
+ "REPO = 'https://github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env.git'\n",
82
+ "WORK_DIR = '/content/data-centric-env'\n",
83
+ "\n",
84
+ "# Clone or pull latest\n",
85
+ "if os.path.exists(f'{WORK_DIR}/pyproject.toml'):\n",
86
+ " print('Repo exists, pulling latest (includes new features)...')\n",
87
+ " !git -C {WORK_DIR} pull origin main\n",
88
+ "else:\n",
89
+ " if os.path.exists(WORK_DIR):\n",
90
+ " shutil.rmtree(WORK_DIR)\n",
91
+ " !git clone {REPO} {WORK_DIR}\n",
92
+ "\n",
93
+ "os.chdir(WORK_DIR)\n",
94
+ "sys.path.insert(0, WORK_DIR)\n",
95
+ "print('CWD:', os.getcwd())\n",
96
+ "\n",
97
+ "# Show latest commit so we know we have the right version\n",
98
+ "!git log --oneline -3\n",
99
+ "\n",
100
+ "# Install as editable package (fixes relative imports)\n",
101
+ "!pip install -e . --quiet 2>/dev/null || echo 'pip install -e . skipped'\n",
102
+ "\n",
103
+ "# Symlink output dirs to Drive if mounted\n",
104
+ "DRIVE_DIR = '/content/drive/MyDrive/data-centric-training'\n",
105
+ "if os.path.exists('/content/drive/MyDrive'):\n",
106
+ " os.makedirs(DRIVE_DIR, exist_ok=True)\n",
107
+ " for d in ['data-centric-checkpoints', 'data-centric-adapter', 'logs', 'plots']:\n",
108
+ " local = os.path.join(WORK_DIR, d)\n",
109
+ " remote = os.path.join(DRIVE_DIR, d)\n",
110
+ " os.makedirs(remote, exist_ok=True)\n",
111
+ " if not os.path.exists(local):\n",
112
+ " os.symlink(remote, local)\n",
113
+ " print(f' Drive link: {d}')\n",
114
+ " else:\n",
115
+ " print(f' Exists: {d}')\n",
116
+ " print('Checkpoints will save to Drive')\n",
117
+ "else:\n",
118
+ " print('Drive not mounted - checkpoints save locally only')\n",
119
+ "\n",
120
+ "print('Setup complete')"
121
+ ]
122
+ },
123
+ {
124
+ "cell_type": "markdown",
125
+ "id": "a35",
126
+ "metadata": {},
127
+ "source": [
128
+ "## Step 3.5 \u2014 Verify all features work\n",
129
+ "Runs a quick smoke test to confirm all 5 new features (query_analyst, smarter specialists,\n",
130
+ "dual classifier, feature importance, drift detection) are connected and working."
131
+ ]
132
+ },
133
+ {
134
+ "cell_type": "code",
135
+ "execution_count": null,
136
+ "id": "b35",
137
+ "metadata": {},
138
+ "outputs": [],
139
+ "source": [
140
+ "import os, sys\n",
141
+ "os.chdir('/content/data-centric-env')\n",
142
+ "sys.path.insert(0, '/content/data-centric-env')\n",
143
+ "\n",
144
+ "# Quick inline smoke test (no file needed)\n",
145
+ "from models import DataCentricAction\n",
146
+ "from server.data_centric_environment import DataCentricEnvironment\n",
147
+ "\n",
148
+ "env = DataCentricEnvironment()\n",
149
+ "obs = env.reset(task='task_1_easy', seed=42)\n",
150
+ "\n",
151
+ "checks = {}\n",
152
+ "\n",
153
+ "# 1. query_analyst\n",
154
+ "obs = env.step(DataCentricAction(message='query_analyst'))\n",
155
+ "checks['query_analyst'] = 'DIAGNOSIS' in obs.response and 'RECOMMENDED PLAN' in obs.response\n",
156
+ "\n",
157
+ "# 2. Smarter specialists\n",
158
+ "obs = env.step(DataCentricAction(message='query_cleaner'))\n",
159
+ "checks['smarter_specialists'] = any(k in obs.response for k in ['skew', 'Risk:', 'Reason:', '%'])\n",
160
+ "\n",
161
+ "# 3. Drift detection (apply then check)\n",
162
+ "obs = env.step(DataCentricAction(message='apply 1'))\n",
163
+ "checks['drift_detection'] = 'Distribution drift' in obs.response or 'drift' in obs.response.lower()\n",
164
+ "\n",
165
+ "# 4+5. Dual classifier + feature importance\n",
166
+ "obs = env.step(DataCentricAction(message='validate'))\n",
167
+ "checks['dual_classifier'] = 'RF Accuracy' in obs.response and 'LR Accuracy' in obs.response\n",
168
+ "checks['feature_importance'] = 'Feature importance' in obs.response\n",
169
+ "\n",
170
+ "print('\\n=== Feature Smoke Test ===')\n",
171
+ "all_pass = True\n",
172
+ "for name, passed in checks.items():\n",
173
+ " status = 'PASS' if passed else 'FAIL'\n",
174
+ " if not passed: all_pass = False\n",
175
+ " print(f' {status}: {name}')\n",
176
+ "\n",
177
+ "if all_pass:\n",
178
+ " print('\\nAll 5 features verified OK - ready to train!')\n",
179
+ "else:\n",
180
+ " print('\\nWARNING: Some features failed - check git pull ran correctly')"
181
+ ]
182
+ },
183
+ {
184
+ "cell_type": "markdown",
185
+ "id": "a4",
186
+ "metadata": {},
187
+ "source": ["## Step 4 \u2014 Start environment server"]
188
+ },
189
+ {
190
+ "cell_type": "code",
191
+ "execution_count": null,
192
+ "id": "b4",
193
+ "metadata": {},
194
+ "outputs": [],
195
+ "source": [
196
+ "import subprocess, time, requests, os, sys\n",
197
+ "os.chdir('/content/data-centric-env')\n",
198
+ "sys.path.insert(0, '/content/data-centric-env')\n",
199
+ "\n",
200
+ "# Kill any existing server on port 8000\n",
201
+ "!fuser -k 8000/tcp 2>/dev/null || true\n",
202
+ "time.sleep(2)\n",
203
+ "\n",
204
+ "server_proc = subprocess.Popen(\n",
205
+ " ['python', '-m', 'uvicorn', 'server.app:app',\n",
206
+ " '--host', '0.0.0.0', '--port', '8000', '--log-level', 'warning'],\n",
207
+ " stdout=subprocess.PIPE, stderr=subprocess.PIPE\n",
208
+ ")\n",
209
+ "\n",
210
+ "for i in range(30):\n",
211
+ " try:\n",
212
+ " if requests.get('http://localhost:8000/health', timeout=2).status_code == 200:\n",
213
+ " print(f'Server ready after {i+1}s')\n",
214
+ " break\n",
215
+ " except:\n",
216
+ " time.sleep(1)\n",
217
+ "else:\n",
218
+ " server_proc.terminate()\n",
219
+ " out, err = server_proc.communicate()\n",
220
+ " print('STDOUT:', out.decode()[-1000:])\n",
221
+ " print('STDERR:', err.decode()[-1000:])\n",
222
+ " raise RuntimeError('Server failed to start in 30s - see output above')"
223
+ ]
224
+ },
225
+ {
226
+ "cell_type": "markdown",
227
+ "id": "a5",
228
+ "metadata": {},
229
+ "source": ["## Step 5 \u2014 Generate SFT warmup data"]
230
+ },
231
+ {
232
+ "cell_type": "code",
233
+ "execution_count": null,
234
+ "id": "b5",
235
+ "metadata": {},
236
+ "outputs": [],
237
+ "source": [
238
+ "import os\n",
239
+ "os.chdir('/content/data-centric-env')\n",
240
+ "\n",
241
+ "# Always regenerate if we updated the code (new query_analyst strategies)\n",
242
+ "# Skip only if the file is recent (within last 10 minutes)\n",
243
+ "import time\n",
244
+ "sft_path = 'sft_data.jsonl'\n",
245
+ "regen = True\n",
246
+ "if os.path.exists(sft_path):\n",
247
+ " age_minutes = (time.time() - os.path.getmtime(sft_path)) / 60\n",
248
+ " count = sum(1 for _ in open(sft_path))\n",
249
+ " print(f'sft_data.jsonl exists: {count} examples, {age_minutes:.0f} min old')\n",
250
+ " # Regenerate if old file missing query_analyst strategies (<= 18 strategies)\n",
251
+ " regen = count < 20000 # new version has ~21 strategies\n",
252
+ "\n",
253
+ "if regen:\n",
254
+ " print('Generating SFT data (includes query_analyst strategies)...')\n",
255
+ " !python sft_generator.py\n",
256
+ " print('SFT data generated')\n",
257
+ "else:\n",
258
+ " print('SFT data is up to date - skipping')"
259
+ ]
260
+ },
261
+ {
262
+ "cell_type": "markdown",
263
+ "id": "a6",
264
+ "metadata": {},
265
+ "source": ["## Step 6 \u2014 Load model (Qwen2.5-3B-Instruct, 4-bit)"]
266
+ },
267
+ {
268
+ "cell_type": "code",
269
+ "execution_count": null,
270
+ "id": "b6",
271
+ "metadata": {},
272
+ "outputs": [],
273
+ "source": [
274
+ "import os, sys\n",
275
+ "os.chdir('/content/data-centric-env')\n",
276
+ "sys.path.insert(0, '/content/data-centric-env')\n",
277
+ "\n",
278
+ "from unsloth import FastLanguageModel\n",
279
+ "\n",
280
+ "MODEL_NAME = 'Qwen/Qwen2.5-3B-Instruct'\n",
281
+ "MAX_SEQ_LENGTH = 1024\n",
282
+ "\n",
283
+ "model, tokenizer = FastLanguageModel.from_pretrained(\n",
284
+ " model_name=MODEL_NAME,\n",
285
+ " max_seq_length=MAX_SEQ_LENGTH,\n",
286
+ " load_in_4bit=True,\n",
287
+ " dtype=None,\n",
288
+ ")\n",
289
+ "\n",
290
+ "model = FastLanguageModel.get_peft_model(\n",
291
+ " model,\n",
292
+ " r=16,\n",
293
+ " target_modules=['q_proj', 'v_proj', 'k_proj', 'o_proj'],\n",
294
+ " lora_alpha=32,\n",
295
+ " lora_dropout=0,\n",
296
+ " bias='none',\n",
297
+ " use_gradient_checkpointing='unsloth',\n",
298
+ " random_state=42,\n",
299
+ ")\n",
300
+ "\n",
301
+ "import torch\n",
302
+ "print(f'Model loaded: {MODEL_NAME}')\n",
303
+ "print(f'VRAM: {torch.cuda.memory_allocated()/1e9:.1f} GB')"
304
+ ]
305
+ },
306
+ {
307
+ "cell_type": "markdown",
308
+ "id": "a7",
309
+ "metadata": {},
310
+ "source": ["## Step 7 \u2014 Phase 1: SFT Warmup (~30 min)"]
311
+ },
312
+ {
313
+ "cell_type": "code",
314
+ "execution_count": null,
315
+ "id": "b7",
316
+ "metadata": {},
317
+ "outputs": [],
318
+ "source": [
319
+ "import os, sys\n",
320
+ "os.chdir('/content/data-centric-env')\n",
321
+ "sys.path.insert(0, '/content/data-centric-env')\n",
322
+ "\n",
323
+ "from train_data_centric import run_sft_warmup\n",
324
+ "model = run_sft_warmup(model, tokenizer)\n",
325
+ "print('SFT warmup complete')"
326
+ ]
327
+ },
328
+ {
329
+ "cell_type": "markdown",
330
+ "id": "a8",
331
+ "metadata": {},
332
+ "source": [
333
+ "## Step 8 \u2014 Phase 2: GRPO Training (~3-5 hrs on T4)\n\n",
334
+ "Agent learns **when** to use each command via reinforcement learning.\n",
335
+ "Checkpoints save every 50 steps. If Colab disconnects, re-run all cells \u2014\n",
336
+ "it auto-resumes from the latest checkpoint.\n\n",
337
+ "**What the agent will learn:**\n",
338
+ "- Start with `query_analyst` to get a prioritised plan\n",
339
+ "- Use the Agreement signal to detect overfitting\n",
340
+ "- Focus cleaning on high-importance features\n",
341
+ "- Undo bad applies when drift is HIGH"
342
+ ]
343
+ },
344
+ {
345
+ "cell_type": "code",
346
+ "execution_count": null,
347
+ "id": "b8",
348
+ "metadata": {},
349
+ "outputs": [],
350
+ "source": [
351
+ "import os, sys, glob\n",
352
+ "os.chdir('/content/data-centric-env')\n",
353
+ "sys.path.insert(0, '/content/data-centric-env')\n",
354
+ "\n",
355
+ "from train_data_centric import run_grpo_training\n",
356
+ "\n",
357
+ "# Auto-detect checkpoint to resume from\n",
358
+ "ckpt_dir = './data-centric-checkpoints'\n",
359
+ "resume_from = None\n",
360
+ "if os.path.exists(ckpt_dir):\n",
361
+ " checkpoints = sorted(glob.glob(f'{ckpt_dir}/checkpoint-*'))\n",
362
+ " if checkpoints:\n",
363
+ " resume_from = checkpoints[-1]\n",
364
+ " print(f'Resuming from: {resume_from}')\n",
365
+ " else:\n",
366
+ " print('No checkpoint found - starting fresh')\n",
367
+ "\n",
368
+ "model = run_grpo_training(model, tokenizer, resume_from_checkpoint=resume_from)\n",
369
+ "print('GRPO training complete')"
370
+ ]
371
+ },
372
+ {
373
+ "cell_type": "markdown",
374
+ "id": "a9",
375
+ "metadata": {},
376
+ "source": ["## Step 9 \u2014 Save trained model"]
377
+ },
378
+ {
379
+ "cell_type": "code",
380
+ "execution_count": null,
381
+ "id": "b9",
382
+ "metadata": {},
383
+ "outputs": [],
384
+ "source": [
385
+ "import os, sys\n",
386
+ "os.chdir('/content/data-centric-env')\n",
387
+ "sys.path.insert(0, '/content/data-centric-env')\n",
388
+ "\n",
389
+ "from train_data_centric import save_model\n",
390
+ "save_model(model, tokenizer)\n",
391
+ "print('Model saved to ./data-centric-adapter/')"
392
+ ]
393
+ },
394
+ {
395
+ "cell_type": "markdown",
396
+ "id": "a10",
397
+ "metadata": {},
398
+ "source": ["## Step 10 \u2014 Plot reward curves"]
399
+ },
400
+ {
401
+ "cell_type": "code",
402
+ "execution_count": null,
403
+ "id": "b10",
404
+ "metadata": {},
405
+ "outputs": [],
406
+ "source": [
407
+ "import os\n",
408
+ "os.chdir('/content/data-centric-env')\n",
409
+ "\n",
410
+ "if os.path.exists('logs/training.jsonl'):\n",
411
+ " lines = sum(1 for _ in open('logs/training.jsonl'))\n",
412
+ " print(f'Training log: {lines} episodes recorded')\n",
413
+ " !python plot_rewards.py --log logs/training.jsonl --out plots/\n",
414
+ " from IPython.display import Image, display\n",
415
+ " for f in ['reward_curve.png', 'success_rate.png', 'accuracy_gain.png', 'curriculum.png']:\n",
416
+ " path = f'plots/{f}'\n",
417
+ " if os.path.exists(path):\n",
418
+ " print(f'\\n--- {f} ---')\n",
419
+ " display(Image(path))\n",
420
+ "else:\n",
421
+ " print('No training log found yet - run Step 8 first')"
422
+ ]
423
+ },
424
+ {
425
+ "cell_type": "markdown",
426
+ "id": "a11",
427
+ "metadata": {},
428
+ "source": ["## Step 11 \u2014 Evaluate trained agent vs random vs heuristic"]
429
+ },
430
+ {
431
+ "cell_type": "code",
432
+ "execution_count": null,
433
+ "id": "b11",
434
+ "metadata": {},
435
+ "outputs": [],
436
+ "source": [
437
+ "import os, json\n",
438
+ "os.chdir('/content/data-centric-env')\n",
439
+ "\n",
440
+ "!python eval_data_centric.py\n",
441
+ "\n",
442
+ "if os.path.exists('eval_results.json'):\n",
443
+ " with open('eval_results.json') as f:\n",
444
+ " results = json.load(f)\n",
445
+ " print('\\n=== Eval Results ===')\n",
446
+ " for k, v in results.items():\n",
447
+ " print(f' {k}: {v}')"
448
+ ]
449
+ },
450
+ {
451
+ "cell_type": "markdown",
452
+ "id": "a12",
453
+ "metadata": {},
454
+ "source": [
455
+ "## Step 12 \u2014 Push results to GitHub\n\n",
456
+ "Run this **only after training + eval are done**."
457
+ ]
458
+ },
459
+ {
460
+ "cell_type": "code",
461
+ "execution_count": null,
462
+ "id": "b12",
463
+ "metadata": {},
464
+ "outputs": [],
465
+ "source": [
466
+ "import os\n",
467
+ "os.chdir('/content/data-centric-env')\n",
468
+ "\n",
469
+ "from getpass import getpass\n",
470
+ "token = getpass('GitHub token (repo write access): ')\n",
471
+ "repo_url = f'https://{token}@github.com/CelestialWorthyOfHeavenAndEarth/data-centric-env.git'\n",
472
+ "\n",
473
+ "!git config user.email 'training@colab.run'\n",
474
+ "!git config user.name 'Colab Training'\n",
475
+ "!git remote set-url origin {repo_url}\n",
476
+ "!git add plots/ logs/ eval_results.json 2>/dev/null || true\n",
477
+ "!git commit -m 'Add training results: reward curves + eval'\n",
478
+ "!git push origin main\n",
479
+ "print('Results pushed to GitHub')"
480
+ ]
481
+ },
482
+ {
483
+ "cell_type": "markdown",
484
+ "id": "a13",
485
+ "metadata": {},
486
+ "source": [
487
+ "---\n",
488
+ "## Done!\n\n",
489
+ "| Output | Location |\n",
490
+ "|--------|----------|\n",
491
+ "| Trained adapter | `./data-centric-adapter/` |\n",
492
+ "| Training log | `logs/training.jsonl` |\n",
493
+ "| Reward curves | `plots/*.png` |\n",
494
+ "| Eval results | `eval_results.json` |"
495
+ ]
496
+ }
497
+ ]
498
+ }
train_data_centric.py ADDED
@@ -0,0 +1,689 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # pip install trl unsloth transformers torch requests
2
+ # pip install matplotlib openenv-core scikit-learn pandas numpy datasets
3
+
4
+ import os
5
+ import json
6
+ import random
7
+ import time
8
+ import signal
9
+ import subprocess
10
+ import requests
11
+ import torch
12
+ from collections import deque
13
+ from statistics import mean
14
+ from datasets import Dataset
15
+ from unsloth import FastLanguageModel
16
+ from trl import SFTTrainer, SFTConfig, GRPOConfig, GRPOTrainer
17
+
18
+ # WebSocket client for stateful episode rollouts
19
+ sys_path_root = os.path.dirname(os.path.abspath(__file__))
20
+ import sys
21
+ if sys_path_root not in sys.path:
22
+ sys.path.insert(0, sys_path_root)
23
+ from client import DataCentricEnv
24
+ from models import DataCentricAction
25
+
26
+ # ════════════════════════════════════════════════════════
27
+ # CONSTANTS
28
+ # ════════════════════════════════════════════════════════
29
+
30
+ # ENV_URL: set this to your HF Space URL when running as an HF Job
31
+ # e.g. export ENV_URL="https://aswini-kumar-data-centric-env.hf.space"
32
+ BASE_URL = os.environ.get("ENV_URL", "http://localhost:8000")
33
+ MODEL_NAME = "Qwen/Qwen2.5-3B-Instruct"
34
+ MAX_SEQ_LENGTH = 1024
35
+ LOAD_IN_4BIT = True
36
+
37
+ VALID_COMMANDS = [
38
+ "inspect_dataset", "inspect_model", "query_analyst",
39
+ "query_cleaner", "query_augmenter", "query_balancer", "query_validator",
40
+ "apply", "reject", "undo", "validate", "submit",
41
+ ]
42
+
43
+ SYSTEM_PROMPT = """You are a Data-Centric AI agent. Your job is to improve a \
44
+ Machine learning dataset so a fixed classifier achieves higher accuracy.
45
+
46
+ STRATEGY — use this order:
47
+ 1. query_analyst to get a prioritised action plan (costs 1 budget, worth it)
48
+ 2. inspect_dataset to understand the data
49
+ 3. query the recommended specialist (query_cleaner, query_augmenter, query_balancer)
50
+ 4. apply the best recommendation by number (e.g. apply 1)
51
+ 5. validate to check if accuracy improved
52
+ 6. repeat until you hit the target or run low on budget
53
+ 7. submit to finalize
54
+
55
+ IMPORTANT RULES:
56
+ - Start with query_analyst — it tells you the biggest problem to fix first.
57
+ - Always query a specialist before applying. Never apply without querying first.
58
+ - Check the Agreement signal after validate: DISAGREE means possible overfitting.
59
+ - Validate after every 2-3 applies to track progress.
60
+ - Do not spam validate — it costs budget after 3 uses.
61
+ - query_validator costs 2 budget — use only when suspicious of data quality.
62
+ - submit when accuracy >= target or budget < 5.
63
+
64
+ Reply with exactly ONE command per message. No explanation. Just the command."""
65
+
66
+
67
+ def build_user_prompt(obs: dict) -> str:
68
+ improvement_needed = obs.get("target_accuracy", 0) - obs.get("current_accuracy", 0)
69
+ return (
70
+ f"Current situation:\n"
71
+ f"Accuracy: {obs.get('current_accuracy', 0):.1%} → "
72
+ f"Target: {obs.get('target_accuracy', 0):.1%}\n"
73
+ f"Still need: {max(0, improvement_needed):.1%} improvement\n"
74
+ f"Quality score: {obs.get('estimated_quality', 0):.2f}/1.00\n"
75
+ f"Rows preserved: {obs.get('rows_preserved_pct', 1.0):.1%}\n"
76
+ f"Budget remaining: {obs.get('budget_remaining', 0)} steps\n"
77
+ f"Free validates left: {obs.get('validate_calls_remaining', 0)}\n"
78
+ f"Active query session: {obs.get('active_session', 'none')}\n\n"
79
+ f"Last result:\n{str(obs.get('response', ''))[:400]}\n\n"
80
+ f"What is your next action? (one command only)"
81
+ )
82
+
83
+
84
+ # ════════════════════════════════════════════════════════
85
+ # SERVER MANAGEMENT
86
+ # ════════════════════════════════════════════════════════
87
+
88
+ def start_server() -> subprocess.Popen:
89
+ """Start the environment server as a subprocess."""
90
+ proc = subprocess.Popen(
91
+ ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8000"],
92
+ stdout=subprocess.DEVNULL,
93
+ stderr=subprocess.DEVNULL,
94
+ )
95
+ # Poll until ready (max 30 seconds)
96
+ for i in range(30):
97
+ try:
98
+ r = requests.get(f"{BASE_URL}/health", timeout=1)
99
+ if r.status_code == 200:
100
+ print(f"Server ready after {i + 1}s")
101
+ return proc
102
+ except Exception:
103
+ pass
104
+ time.sleep(1)
105
+ proc.terminate()
106
+ raise RuntimeError("Environment server failed to start in 30 seconds")
107
+
108
+
109
+ def stop_server(proc: subprocess.Popen):
110
+ proc.terminate() # cross-platform (SIGTERM on Linux, TerminateProcess on Windows)
111
+ proc.wait()
112
+ print("Server stopped.")
113
+
114
+
115
+ # ════════════════════════════════════════════════════════
116
+ # MODEL SETUP
117
+ # ════════��═══════════════════════════════════════════════
118
+
119
+ def load_model():
120
+ model, tokenizer = FastLanguageModel.from_pretrained(
121
+ model_name=MODEL_NAME,
122
+ max_seq_length=MAX_SEQ_LENGTH,
123
+ load_in_4bit=LOAD_IN_4BIT,
124
+ dtype=None,
125
+ )
126
+ model = FastLanguageModel.get_peft_model(
127
+ model,
128
+ r=16,
129
+ target_modules=["q_proj", "v_proj", "k_proj", "o_proj"],
130
+ lora_alpha=32,
131
+ lora_dropout=0,
132
+ bias="none",
133
+ use_gradient_checkpointing="unsloth",
134
+ random_state=42,
135
+ )
136
+ return model, tokenizer
137
+
138
+
139
+ # ════════════════════════════════════════════════════════
140
+ # PHASE 1 — SFT WARMUP
141
+ # ════════════════════════════════════════════════════════
142
+
143
+ def run_sft_warmup(model, tokenizer):
144
+ """
145
+ 1 epoch of SFT on heuristic trajectories.
146
+ Teaches model valid command format before GRPO starts.
147
+ Without this, model outputs gibberish and gets zero reward.
148
+ """
149
+ print("\n=== PHASE 1: SFT WARMUP ===")
150
+
151
+ if os.path.exists("./sft-checkpoint"):
152
+ model.load_adapter("./sft-checkpoint")
153
+ print("Loaded existing SFT checkpoint — skipping warmup.")
154
+ return model
155
+
156
+ if not os.path.exists("sft_data.jsonl"):
157
+ print("sft_data.jsonl not found. Run sft_generator.py first.")
158
+ raise FileNotFoundError("sft_data.jsonl missing")
159
+
160
+ raw = [json.loads(l) for l in open("sft_data.jsonl", encoding="utf-8")]
161
+ print(f"Loaded {len(raw)} SFT examples")
162
+
163
+ def format_example(ex):
164
+ messages = [
165
+ {"role": "system", "content": SYSTEM_PROMPT},
166
+ {"role": "user", "content": ex["prompt"]},
167
+ {"role": "assistant", "content": ex["response"]},
168
+ ]
169
+ return {
170
+ "text": tokenizer.apply_chat_template(
171
+ messages, tokenize=False, add_generation_prompt=False
172
+ )
173
+ }
174
+
175
+ sft_dataset = Dataset.from_list([format_example(ex) for ex in raw])
176
+
177
+ sft_trainer = SFTTrainer(
178
+ model=model,
179
+ tokenizer=tokenizer,
180
+ train_dataset=sft_dataset,
181
+ args=SFTConfig(
182
+ output_dir="./sft-checkpoint",
183
+ num_train_epochs=1,
184
+ per_device_train_batch_size=4,
185
+ gradient_accumulation_steps=4,
186
+ learning_rate=2e-5,
187
+ warmup_steps=10,
188
+ logging_steps=5,
189
+ save_strategy="no",
190
+ report_to="none",
191
+ max_seq_length=MAX_SEQ_LENGTH,
192
+ ),
193
+ )
194
+ sft_trainer.train()
195
+ print("SFT warmup complete.\n")
196
+ return model
197
+
198
+
199
+ # ════════════════════════════════════════════════════════
200
+ # CURRICULUM SCHEDULER
201
+ # ════════════════════════════════════════════════════════
202
+
203
+ class CurriculumScheduler:
204
+ """
205
+ Advances curriculum level when the agent reliably solves the current task.
206
+
207
+ Advancement criterion: >= threshold success rate over a rolling window of episodes.
208
+ Uses a smoothed window to avoid premature advancement on lucky streaks.
209
+
210
+ Levels: 0=tutorial, 1=easy, 2=medium, 3=hard
211
+
212
+ Design rationale:
213
+ - Step-count based scheduling causes premature advancement (catastrophic forgetting)
214
+ or stalling (wasted compute) because it ignores actual agent performance.
215
+ - Success-rate based scheduling ensures the agent genuinely masters a level
216
+ before seeing harder tasks, matching curriculum RL best practices.
217
+ - Window resets after each advancement so the agent must prove itself again.
218
+ """
219
+
220
+ TASKS = ["task_0_tutorial", "task_1_easy", "task_2_medium", "task_3_hard"]
221
+ LEVEL_LABELS = ["tutorial", "easy", "medium", "hard"]
222
+
223
+ def __init__(self, window: int = 30, threshold: float = 0.60):
224
+ """
225
+ Args:
226
+ window: Number of episodes to evaluate before considering advancement.
227
+ 30 gives a stable estimate without waiting too long.
228
+ threshold: Required success rate (0.0–1.0) to advance to next level.
229
+ 0.60 = agent must solve 60% of episodes to advance.
230
+ Lower than classic 0.75 because 3B models learn slower.
231
+ """
232
+ self.current_level = 0
233
+ self.window = window
234
+ self.threshold = threshold
235
+ self.recent_successes: deque = deque(maxlen=window)
236
+ self.global_step = 0 # total episodes recorded (for logging)
237
+ self.level_history: list = [] # (episode, level) pairs for plotting
238
+
239
+ def record_episode(self, reached_target: bool, accuracy_gain: float = 0.0):
240
+ """Call after every episode completes."""
241
+ self.recent_successes.append(float(reached_target))
242
+ self.global_step += 1
243
+ if self.should_advance():
244
+ self.advance()
245
+
246
+ def get_task(self) -> str:
247
+ """Return the current training task name."""
248
+ return self.TASKS[self.current_level]
249
+
250
+ def current_success_rate(self) -> float:
251
+ if not self.recent_successes:
252
+ return 0.0
253
+ return sum(self.recent_successes) / len(self.recent_successes)
254
+
255
+ def should_advance(self) -> bool:
256
+ """Only advance if we have enough data and consistently exceed threshold."""
257
+ return (
258
+ len(self.recent_successes) >= self.window
259
+ and self.current_success_rate() >= self.threshold
260
+ and self.current_level < len(self.TASKS) - 1
261
+ )
262
+
263
+ def advance(self):
264
+ if self.current_level < len(self.TASKS) - 1:
265
+ print(
266
+ f"\n[Curriculum] ▶ Level {self.current_level} ({self.TASKS[self.current_level]}) "
267
+ f"→ Level {self.current_level + 1} ({self.TASKS[self.current_level + 1]})\n"
268
+ f" Success rate over last {self.window} episodes: "
269
+ f"{self.current_success_rate():.1%} (threshold: {self.threshold:.0%})\n"
270
+ f" Total episodes: {self.global_step}"
271
+ )
272
+ self.level_history.append((self.global_step, self.current_level))
273
+ self.current_level += 1
274
+ self.recent_successes.clear() # reset window after advancing
275
+
276
+ def stage_label(self) -> str:
277
+ return self.LEVEL_LABELS[self.current_level]
278
+
279
+ # Backward-compat: record_improvement still works for old callers
280
+ def record_improvement(self, improvement: float):
281
+ self.record_episode(reached_target=(improvement > 0.05))
282
+ self.global_step = self.global_step # already incremented above
283
+
284
+
285
+
286
+
287
+ # ════════════════════════════════════════════════════════
288
+ # REWARD COMPUTATION
289
+ # ════════════════════════════════════════════════════════
290
+
291
+ def compute_rewards(
292
+ obs_before: dict,
293
+ obs_after: dict,
294
+ response_text: str,
295
+ action_history: list,
296
+ ) -> dict:
297
+ """
298
+ 4 completely independent reward components.
299
+ Each measures a different aspect of agent behavior.
300
+ No single component can be maximized without solving the real task.
301
+ """
302
+ # Component 1: Environment accuracy reward (from environment)
303
+ env_reward = obs_after.get("reward", 0.0)
304
+
305
+ # Component 2: Format reward — did model output a valid command?
306
+ is_valid = any(
307
+ response_text.strip().startswith(cmd) for cmd in VALID_COMMANDS
308
+ )
309
+ format_reward = 0.10 if is_valid else -0.10
310
+
311
+ # Component 3: Strategy reward — did model follow smart workflow?
312
+ strategy_reward = 0.0
313
+ if len(action_history) >= 1:
314
+ prev = action_history[-1]
315
+ curr = response_text.strip()
316
+
317
+ if prev.startswith("query_") and curr.startswith("apply"):
318
+ strategy_reward = 0.05 # query then apply — correct pattern
319
+ elif curr.startswith("apply") and not any(
320
+ a.startswith("query_") for a in action_history[-3:]
321
+ ):
322
+ strategy_reward = -0.04 # apply without recent query — bad pattern
323
+ elif prev.startswith("apply") and curr == "validate":
324
+ strategy_reward = 0.03 # validate after apply — good pattern
325
+ elif curr == "submit" and not any(
326
+ a == "validate" for a in action_history
327
+ ):
328
+ strategy_reward = -0.10 # submit without ever validating — bad
329
+
330
+ # Component 4: Preservation reward — did model lose good data?
331
+ rows_pct = obs_after.get("rows_preserved_pct", 1.0)
332
+ if rows_pct >= 0.90: preservation_reward = 0.05
333
+ elif rows_pct >= 0.80: preservation_reward = 0.02
334
+ elif rows_pct >= 0.70: preservation_reward = 0.00
335
+ elif rows_pct >= 0.50: preservation_reward = -0.10
336
+ else: preservation_reward = -0.40
337
+
338
+ total = env_reward + format_reward + strategy_reward + preservation_reward
339
+
340
+ return {
341
+ "total": total,
342
+ "env": env_reward,
343
+ "format": format_reward,
344
+ "strategy": strategy_reward,
345
+ "preservation": preservation_reward,
346
+ }
347
+
348
+
349
+ # ════════════════════════════════════════════════════════
350
+ # EPISODE ROLLOUT
351
+ # ════════════════════════════════════════════════════════
352
+
353
+ def obs_to_dict(obs_obj) -> dict:
354
+ """Convert DataCentricObservation to dict for compatibility with reward logic."""
355
+ if isinstance(obs_obj, dict):
356
+ return obs_obj
357
+ return {
358
+ "response": obs_obj.response,
359
+ "current_accuracy": obs_obj.current_accuracy,
360
+ "baseline_accuracy": obs_obj.baseline_accuracy,
361
+ "target_accuracy": obs_obj.target_accuracy,
362
+ "estimated_quality": obs_obj.estimated_quality,
363
+ "dataset_shape": obs_obj.dataset_shape,
364
+ "rows_preserved_pct": obs_obj.rows_preserved_pct,
365
+ "budget_remaining": obs_obj.budget_remaining,
366
+ "step_number": obs_obj.step_number,
367
+ "max_steps": obs_obj.max_steps,
368
+ "active_session": obs_obj.active_session,
369
+ "validate_calls_remaining":obs_obj.validate_calls_remaining,
370
+ "done": obs_obj.done,
371
+ "reward": obs_obj.reward,
372
+ }
373
+
374
+
375
+ def run_episode(
376
+ model, tokenizer, task: str, seed: int
377
+ ) -> tuple:
378
+ """
379
+ Run one complete episode using the WebSocket client (stateful session).
380
+ Each reset+step sequence maintains the same env instance on the server.
381
+ Returns: (prompts, responses, rewards) for GRPO training.
382
+ """
383
+ prompts, responses, rewards = [], [], []
384
+ action_history = []
385
+
386
+ try:
387
+ with DataCentricEnv(base_url=BASE_URL).sync() as env:
388
+ reset_result = env.reset(task=task, seed=seed)
389
+ obs = obs_to_dict(reset_result.observation)
390
+
391
+ while not obs.get("done", False):
392
+ # Build chat prompt
393
+ messages = [
394
+ {"role": "system", "content": SYSTEM_PROMPT},
395
+ {"role": "user", "content": build_user_prompt(obs)},
396
+ ]
397
+ input_ids = tokenizer.apply_chat_template(
398
+ messages,
399
+ return_tensors="pt",
400
+ max_length=MAX_SEQ_LENGTH - 60,
401
+ truncation=True,
402
+ add_generation_prompt=True,
403
+ ).to(model.device)
404
+
405
+ # Generate — commands are short, 50 tokens max
406
+ with torch.no_grad():
407
+ output_ids = model.generate(
408
+ input_ids,
409
+ max_new_tokens=50,
410
+ temperature=0.8,
411
+ do_sample=True,
412
+ pad_token_id=tokenizer.eos_token_id,
413
+ )
414
+
415
+ response_text = tokenizer.decode(
416
+ output_ids[0][input_ids.shape[1]:],
417
+ skip_special_tokens=True,
418
+ ).strip().split("\n")[0].strip()[:200]
419
+
420
+ obs_before = obs
421
+ try:
422
+ step_result = env.step(DataCentricAction(message=response_text))
423
+ obs = obs_to_dict(step_result.observation)
424
+ except Exception as e:
425
+ obs = {**obs, "done": True, "reward": -0.05,
426
+ "response": f"Step error: {e}"}
427
+
428
+ reward_dict = compute_rewards(
429
+ obs_before, obs, response_text, action_history
430
+ )
431
+ prompts.append(build_user_prompt(obs_before))
432
+ responses.append(response_text)
433
+ rewards.append(reward_dict)
434
+ action_history.append(response_text)
435
+
436
+ except Exception as e:
437
+ print(f"Episode error (task={task}, seed={seed}): {e}")
438
+ return [], [], []
439
+
440
+ return prompts, responses, rewards
441
+
442
+
443
+ # ════════════════════════════════════════════════════════
444
+ # LOGGING
445
+ # ════════════════════════════════════════════════════════
446
+
447
+ training_log = []
448
+
449
+
450
+ def log_training_step(
451
+ step: int, all_episodes: list, scheduler: CurriculumScheduler
452
+ ):
453
+ """Log metrics and sample generations every 10 steps."""
454
+ all_final_rewards = []
455
+ all_reward_components: dict = {
456
+ "env": [], "format": [], "strategy": [], "preservation": []
457
+ }
458
+ format_hits = 0
459
+ strategy_hits = 0
460
+ total_actions = 0
461
+
462
+ for prompts, responses, rewards in all_episodes:
463
+ if not rewards:
464
+ continue
465
+ all_final_rewards.append(rewards[-1]["total"])
466
+ for r in rewards:
467
+ for k in all_reward_components:
468
+ all_reward_components[k].append(r[k])
469
+ if r["format"] > 0: format_hits += 1
470
+ if r["strategy"] > 0: strategy_hits += 1
471
+ total_actions += 1
472
+
473
+ if not all_final_rewards:
474
+ return
475
+
476
+ entry = {
477
+ "step": step,
478
+ "stage": scheduler.stage_label(),
479
+ "task": scheduler.get_task(),
480
+ "mean_total_reward": mean(all_final_rewards),
481
+ "mean_env_reward": mean(all_reward_components["env"]),
482
+ "mean_format_reward": mean(all_reward_components["format"]),
483
+ "mean_strategy_reward": mean(all_reward_components["strategy"]),
484
+ "mean_preservation_reward": mean(all_reward_components["preservation"]),
485
+ "format_rate": format_hits / max(total_actions, 1),
486
+ "strategy_rate": strategy_hits / max(total_actions, 1),
487
+ }
488
+ training_log.append(entry)
489
+ json.dump(training_log, open("training_log.json", "w"), indent=2)
490
+
491
+ print(
492
+ f"Step {step:4d} | Stage: {entry['stage']:8s} | "
493
+ f"Reward: {entry['mean_total_reward']:+.3f} | "
494
+ f"Format: {entry['format_rate']:.0%} | "
495
+ f"Strategy: {entry['strategy_rate']:.0%}"
496
+ )
497
+
498
+ # Sample 3 generations for inspection
499
+ if step % 10 == 0:
500
+ samples = []
501
+ for p_ep, r_ep, rw_ep in all_episodes[:3]:
502
+ if p_ep and r_ep:
503
+ samples.append({
504
+ "step": step,
505
+ "response": r_ep[-1],
506
+ "reward": rw_ep[-1]["total"],
507
+ "reward_detail": rw_ep[-1],
508
+ })
509
+ with open("generations.jsonl", "a", encoding="utf-8") as f:
510
+ for s in samples:
511
+ f.write(json.dumps(s) + "\n")
512
+
513
+
514
+ def log_episode_jsonl(
515
+ episode: int, task: str, level: int, reward: float,
516
+ accuracy_gain: float, steps_used: int, success: bool,
517
+ log_path: str = "logs/training.jsonl",
518
+ ):
519
+ """Write one episode record to JSONL log (read by plot_rewards.py)."""
520
+ import os as _os
521
+ _os.makedirs(_os.path.dirname(log_path), exist_ok=True)
522
+ entry = {
523
+ "ts": time.time(),
524
+ "episode": episode,
525
+ "task": task,
526
+ "level": level,
527
+ "reward": round(reward, 4),
528
+ "accuracy_gain": round(accuracy_gain, 4),
529
+ "steps_used": steps_used,
530
+ "success": success,
531
+ }
532
+ with open(log_path, "a", encoding="utf-8") as f:
533
+ f.write(json.dumps(entry) + "\n")
534
+
535
+
536
+ # ════════════════════════════════════════════════════════
537
+ # GRPO TRAINING LOOP
538
+ # ════════════════════════════════════════════════════════
539
+
540
+ def run_grpo_training(model, tokenizer, resume_from_checkpoint=None):
541
+ print("\n=== PHASE 2: GRPO TRAINING ===")
542
+ if resume_from_checkpoint:
543
+ print(f"Resuming from checkpoint: {resume_from_checkpoint}")
544
+
545
+ scheduler = CurriculumScheduler()
546
+
547
+ grpo_config = GRPOConfig(
548
+ output_dir="./data-centric-checkpoints",
549
+ num_train_epochs=3,
550
+ per_device_train_batch_size=4,
551
+ gradient_accumulation_steps=4,
552
+ learning_rate=5e-6,
553
+ warmup_steps=20,
554
+ logging_steps=10,
555
+ save_steps=50,
556
+ num_generations=4,
557
+ max_completion_length=50, # renamed from max_new_tokens in TRL ≥0.15
558
+ max_prompt_length=900,
559
+ report_to="none",
560
+ )
561
+
562
+ def reward_fn(completions, prompts=None, **kwargs):
563
+ """
564
+ Reward function called by GRPOTrainer.
565
+ Runs live episodes and returns total reward for each completion.
566
+ """
567
+ batch_rewards = []
568
+ episodes_this_batch = []
569
+
570
+ for completion in completions:
571
+ # Capture task BEFORE running episode so log reflects what was run
572
+ task = scheduler.get_task()
573
+ seed = random.randint(0, 9999)
574
+
575
+ prompts_ep, responses_ep, rewards_ep = run_episode(
576
+ model, tokenizer, task, seed
577
+ )
578
+
579
+ if rewards_ep:
580
+ final_reward = sum(r["total"] for r in rewards_ep)
581
+ accuracy_gain = sum(r["env"] for r in rewards_ep)
582
+ success = accuracy_gain > 0.05
583
+ # Update curriculum using success-rate based scheduler
584
+ scheduler.record_episode(
585
+ reached_target=success,
586
+ accuracy_gain=accuracy_gain,
587
+ )
588
+ else:
589
+ final_reward = -0.10
590
+ accuracy_gain = 0.0
591
+ success = False
592
+ scheduler.record_episode(reached_target=False, accuracy_gain=0.0)
593
+
594
+ # Write per-episode JSONL record for plot_rewards.py
595
+ log_episode_jsonl(
596
+ episode=scheduler.global_step,
597
+ task=task,
598
+ level=scheduler.current_level,
599
+ reward=final_reward,
600
+ accuracy_gain=accuracy_gain,
601
+ steps_used=len(rewards_ep) if rewards_ep else 0,
602
+ success=success,
603
+ )
604
+
605
+ batch_rewards.append(final_reward)
606
+ episodes_this_batch.append((prompts_ep, responses_ep, rewards_ep))
607
+
608
+ # Log every 10 calls
609
+ if scheduler.global_step % 10 == 0:
610
+ log_training_step(
611
+ scheduler.global_step,
612
+ episodes_this_batch,
613
+ scheduler,
614
+ )
615
+
616
+ return batch_rewards
617
+
618
+ # GRPOTrainer needs a prompt dataset — use SFT data as source
619
+ raw = [json.loads(l) for l in open("sft_data.jsonl", encoding="utf-8")]
620
+ grpo_dataset = Dataset.from_list([
621
+ {"prompt": ex["prompt"]} for ex in raw
622
+ ])
623
+
624
+ trainer = GRPOTrainer(
625
+ model=model,
626
+ tokenizer=tokenizer,
627
+ reward_funcs=[reward_fn],
628
+ args=grpo_config,
629
+ train_dataset=grpo_dataset,
630
+ )
631
+ trainer.train(resume_from_checkpoint=resume_from_checkpoint)
632
+ print("GRPO training complete.\n")
633
+ return model
634
+
635
+
636
+ # ════════════════════════════════════════════════════════
637
+ # SAVE MODEL
638
+ # ════════════════════════════════════════════════════════
639
+
640
+ def save_model(model, tokenizer):
641
+ print("Saving model...")
642
+
643
+ # Save LoRA adapter (safe for 4-bit, fast)
644
+ model.save_pretrained("./data-centric-adapter")
645
+ tokenizer.save_pretrained("./data-centric-adapter")
646
+ print("Adapter saved to ./data-centric-adapter")
647
+
648
+ # Save merged 16-bit for inference
649
+ # IMPORTANT: use unsloth's method — NOT naive merge_and_unload()
650
+ # Naive merge on 4-bit model corrupts weights
651
+ model.save_pretrained_merged(
652
+ "./data-centric-merged",
653
+ tokenizer,
654
+ save_method="merged_16bit",
655
+ )
656
+ print("Merged model saved to ./data-centric-merged")
657
+ print("Test inference immediately before demo.")
658
+
659
+
660
+ # ════════════════════════════════════════════════════════
661
+ # MAIN
662
+ # ════════════════════════════════════════════════════════
663
+
664
+ if __name__ == "__main__":
665
+ # Ensure SFT warmup data exists
666
+ if not os.path.exists("sft_data.jsonl"):
667
+ print("Generating SFT data first...")
668
+ subprocess.run(["python", "sft_generator.py"], check=True)
669
+
670
+ # Start environment server
671
+ server_proc = start_server()
672
+
673
+ try:
674
+ # Load base model with LoRA
675
+ model, tokenizer = load_model()
676
+
677
+ # Phase 1: SFT warmup — teaches valid command grammar
678
+ model = run_sft_warmup(model, tokenizer)
679
+
680
+ # Phase 2: GRPO — improves strategy via environment reward
681
+ model = run_grpo_training(model, tokenizer)
682
+
683
+ # Save adapter + merged 16-bit
684
+ save_model(model, tokenizer)
685
+
686
+ print("\nTraining complete. Run eval_data_centric.py next.")
687
+
688
+ finally:
689
+ stop_server(server_proc)