ragavrida Claude Opus 4.6 (1M context) commited on
Commit
74c9d03
·
1 Parent(s): 011679d

feat: major upgrade — multi-step MDP, AST injection, 5-signal reward

Browse files

- Multi-step episodes (up to 5 actions): analyze → flag_line → request_hint → submit_review
Earlier decisions (flagged lines, hints) directly affect final reward
- AST-based bug injection for Python (4 new injectors using ast module)
alongside 5 regex-based injectors for JS/Go — 9 total injectors
- 5-signal shaped reward: bug_detection (0.40), fix_quality (0.25),
line_precision (0.15), comment_quality (0.10), efficiency (0.10)
- 36 clean code snippets across Python/JS/Go with procedural generation
- MCP tool endpoints: get_code_snippet, submit_review, request_hint, get_state
- Concurrent session support (SUPPORTS_CONCURRENT_SESSIONS=True)
- Difficulty tiers: easy (1 bug), medium (1-2), hard (2-3 + 1.5x multiplier)
- 42 tests passing, 17/17 OpenEnv validation checks
- Docker HEALTHCHECK, proper ENV vars, spec-compliant openenv.yaml

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files changed (14) hide show
  1. Dockerfile +15 -7
  2. README.md +108 -601
  3. baseline.py +97 -114
  4. baseline/heuristic_results.json +23 -17
  5. client.py +8 -5
  6. inference.py +174 -471
  7. models.py +66 -50
  8. openenv.yaml +13 -63
  9. reward.py +296 -0
  10. server/app.py +110 -58
  11. server/code_review_environment.py +332 -297
  12. snippet_bank.py +1836 -0
  13. tests/test_code_review_env.py +432 -0
  14. validate.py +137 -220
Dockerfile CHANGED
@@ -1,19 +1,27 @@
1
  FROM python:3.11-slim
2
 
3
- # HuggingFace Spaces expects port 7860
 
4
  WORKDIR /app
5
 
6
- # Upgrade pip first for better retry/timeout handling
7
- RUN pip install --upgrade pip
 
 
 
 
 
8
 
9
- # Install dependencies with retries and longer timeout for slow networks
 
10
  COPY requirements.txt .
11
  RUN pip install --no-cache-dir --timeout=300 --retries=5 -r requirements.txt
12
 
13
  # Copy environment code
14
  COPY . .
15
 
16
- EXPOSE 7860
 
 
17
 
18
- # Use python -m uvicorn to ensure it's found regardless of PATH
19
- CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
 
1
  FROM python:3.11-slim
2
 
3
+ # Configuration
4
+ EXPOSE 7860
5
  WORKDIR /app
6
 
7
+ ENV PYTHONDONTWRITEBYTECODE=1
8
+ ENV PYTHONUNBUFFERED=1
9
+ ENV PYTHONPATH=/app
10
+
11
+ # System dependencies (gcc for any C extensions)
12
+ RUN apt-get update && apt-get install -y --no-install-recommends gcc \
13
+ && rm -rf /var/lib/apt/lists/*
14
 
15
+ # Install Python dependencies (cached layer)
16
+ RUN pip install --upgrade pip
17
  COPY requirements.txt .
18
  RUN pip install --no-cache-dir --timeout=300 --retries=5 -r requirements.txt
19
 
20
  # Copy environment code
21
  COPY . .
22
 
23
+ # Health check for container orchestration
24
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
25
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7860/health')" || exit 1
26
 
27
+ CMD ["python", "-m", "uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
 
README.md CHANGED
@@ -1,675 +1,182 @@
1
  ---
2
  title: CodeReviewEnv
3
- emoji: 🔍
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
- short_description: "First RL benchmark for semantic MBRL over code review"
9
  tags:
10
  - openenv
11
  - reinforcement-learning
12
  - code-review
13
  - mbrl
14
- - knowledge-work
15
  - llm-agents
16
- - semantic-world-model
17
  ---
18
 
19
- # 🔍 CodeReviewEnv
20
 
21
- **An OpenEnv-compliant RL environment for software code review agents.**
22
 
23
- > **CodeReviewEnv is the first RL benchmark for structured knowledge work.** Unlike MuJoCo (continuous physics), Atari (pixel grids), or TextWorld (synthetic narratives), CodeReviewEnv operates over *real-world semantic states* — code diffs, bug categories, and human-calibrated severity labels from actual software engineering practice. Its trajectory export (`export_trajectory()`) provides the first standardized dataset format for training **Knowledge-Work World Models (KW-WM)** — world models over structured professional text.
24
 
25
- Train and evaluate LLM agents on real code review tasks — severity triage, queue prioritization, and actionable feedback generation — with deterministic grading, shaped rewards, and trajectory logging for Knowledge-Work World Model (KW-WM) research.
26
 
27
- [![OpenEnv Spec](https://img.shields.io/badge/OpenEnv-compliant-blue)](https://github.com/openenv)
28
- [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
29
- [![License](https://img.shields.io/badge/license-BSD--3--Clause-green.svg)](LICENSE)
30
 
31
- ---
32
-
33
- ## Table of Contents
34
-
35
- - [Motivation](#motivation)
36
- - [Environment Description](#environment-description)
37
- - [Observation Space](#observation-space)
38
- - [Action Space](#action-space)
39
- - [Reward Design](#reward-design)
40
- - [Tasks](#tasks)
41
- - [Baseline Scores](#baseline-scores)
42
- - [Setup & Installation](#setup--installation)
43
- - [Usage](#usage)
44
- - [Running Inference](#running-inference)
45
- - [Environment Variables](#environment-variables)
46
- - [Pre-Submission Checklist](#pre-submission-checklist)
47
- - [Structured Logging Format](#structured-logging-format)
48
- - [Project Structure](#project-structure)
49
- - [Trajectory Dataset](#trajectory-dataset)
50
- - [Citation](#citation)
51
-
52
- ---
53
-
54
- ## Motivation
55
-
56
- ### The Problem
57
-
58
- Modern AI agents are increasingly deployed for knowledge work — summarizing documents, triaging issues, reviewing code — yet the RL/agent community lacks environments that faithfully model these tasks. Existing benchmarks either live in toy domains (grid worlds, text adventures) or are evaluation-only suites (SWE-bench, WebArena) with no MDP formalism, reward shaping, or trajectory export.
59
-
60
- Prior work on text-based world models (Li et al., 2025 "From Word to World") studies general text games. Prior work on semantic world models (Berg et al., 2025 "SWM") targets embodied robotics. **CodeReviewEnv is the first benchmark for world model training in structured knowledge work** — where state transitions depend on professional judgment rather than physical or game mechanics.
61
-
62
- SWE-bench and its successors explicitly acknowledge they cannot measure code maintainability or professional review quality (Da et al., 2025). CodeReviewEnv is the first environment designed to make these dimensions **learnable via MBRL**.
63
-
64
- ### Why CodeReviewEnv?
65
-
66
- Code review is one of the highest-volume, highest-impact knowledge tasks in software engineering. Every development team does it daily, and quality directly affects shipped software security, reliability, and maintainability. CodeReviewEnv fills a genuine gap:
67
-
68
- | Need | CodeReviewEnv |
69
- |------|---------------|
70
- | Real task that people do daily | ✅ Software code review |
71
- | MDP formalism with R(s,a,s') | ✅ Semantic MDP with shaped rewards |
72
- | Multiple difficulty levels | ✅ Easy / Medium / Hard |
73
- | Deterministic, reproducible grading | ✅ Seed-controlled, no stochastic graders |
74
- | Trajectory export for KW-WM | ✅ JSONL `(s, a, r, s')` per step |
75
- | Deployable as a service | ✅ Docker + HF Space + OpenEnv spec |
76
-
77
- ### Research Gap
78
-
79
- | Benchmark | State Space | Transition | World Model? | Domain |
80
- |-----------|-------------|------------|--------------|--------|
81
- | MuJoCo | ℝⁿ (joints) | Physics sim | ✅ Dreamer | Robotics |
82
- | Atari | Pixels | Game engine | ✅ MuZero | Games |
83
- | TextWorld | Synthetic text | Game rules | ⚠️ Li et al. 2025 | Text games |
84
- | SWM (Berg et al.) | Visual + text | Physics | ✅ Embodied | Robotics |
85
- | SWE-bench | Code | N/A | ❌ Eval only | SE |
86
- | **CodeReviewEnv** | **Structured text** | **Professional judgment** | **✅ KW-WM (this work)** | **Knowledge work** |
87
-
88
- CodeReviewEnv introduces **knowledge-work transitions** — the state is structured professional text (code diffs, bug patterns, author context) and the transition depends on *professional judgment*. This enables training **Knowledge-Work World Models (KW-WM)** — a new class of world models not benchmarked by prior work in games (Li et al., 2025), robotics (Berg et al., 2025), or code generation (Da et al., 2025).
89
-
90
- ---
91
-
92
- ## Environment Description
93
-
94
- CodeReviewEnv models the software code review process as a **Semantic Markov Decision Process (S-MDP)**. An agent receives pull request observations (code diffs, metadata, review history) and must take structured review actions (classify severity, prioritize queues, write feedback). A deterministic grader scores each action and the episode produces a clean trajectory suitable for RL training or world model research.
95
-
96
- ### Episode Flow
97
-
98
- ```
99
- reset(seed) → Observation₀
100
-
101
- step(Action₁) → (Observation₁, Reward₁, done₁, info₁)
102
- step(Action₂) → (Observation₂, Reward₂, done₂, info₂)
103
- ...
104
- step(Actionₙ) → (Observationₙ, Rewardₙ, done=True, infoₙ)
105
-
106
- export_trajectory() → [(s₀, a₁, r₁, s₁), (s₁, a₂, r₂, s₂), ...]
107
- ```
108
-
109
- - **`reset(seed)`** produces a clean initial state — no leakage between episodes
110
- - **`step(action)`** returns the standard `(observation, reward, done, info)` tuple
111
- - **`state()`** exposes the full internal state including trajectory history
112
- - **`export_trajectory()`** outputs `(s, a, r, s')` transitions in JSONL
113
-
114
- ---
115
-
116
- ## Observation Space
117
-
118
- The observation represents the semantic state `s ∈ S` visible to the agent at each timestep.
119
-
120
- | Field | Type | Description |
121
- |-------|------|-------------|
122
- | `pr_id` | `str` | Unique pull request identifier (e.g. `PR-001`) |
123
- | `title` | `str` | Human-readable PR title |
124
- | `description` | `str` | PR description / summary |
125
- | `author_experience` | `str ∈ {junior, mid, senior}` | Experience level of the PR author |
126
- | `files` | `List[PRFile]` | Code diffs per file (see below) |
127
- | `existing_comments` | `List[str]` | Previously submitted review comments |
128
- | `review_queue` | `List[str]` | IDs of pending PRs in queue |
129
- | `step_number` | `int` | Current step in the episode (0-indexed) |
130
- | `episode_budget` | `int` | Steps remaining in this episode |
131
-
132
- ### PRFile Schema
133
-
134
- Each file in `files` contains:
135
-
136
- | Field | Type | Description |
137
- |-------|------|-------------|
138
- | `filename` | `str` | File path (e.g. `UserService.java`) |
139
- | `language` | `str ∈ {python, javascript, java, go, rust, typescript, ruby}` | Programming language |
140
- | `diff` | `str` | Unified diff of changes |
141
- | `lines_changed` | `int` | Number of modified lines |
142
- | `has_tests` | `bool` | Whether the PR includes test coverage |
143
-
144
- ---
145
-
146
- ## Action Space
147
-
148
- The action space is **heterogeneous** — different `action_type` values activate different required fields. This mirrors real code review decisions.
149
-
150
- | `action_type` | Required Fields | Description |
151
- |---------------|----------------|-------------|
152
- | `label_severity` | `severity ∈ {critical, high, medium, low, none}` | Classify the bug severity of the current PR |
153
- | `prioritize` | `priority_order: List[str]` | Order the review queue by urgency (most urgent first) |
154
- | `add_comment` | `comment: str`, `target_file: str`, `target_line: int` | Add a review comment targeting a specific line in a specific file |
155
- | `approve` | — | Approve the PR (no remaining concerns) |
156
- | `request_changes` | — | Request changes (bugs found, feedback given) |
157
-
158
- ### Action Validation
159
-
160
- - Actions with an `action_type` mismatched to the current task receive a penalty reward
161
- - Actions with missing required fields are handled gracefully (no crash, penalty applied)
162
- - The environment never raises on malformed actions — it returns a penalty `Reward` instead
163
-
164
- ---
165
-
166
- ## Reward Design
167
-
168
- Rewards are shaped to provide useful, varying signal — not just sparse terminal feedback. Each reward `R(s, a, s') ∈ [-1.0, 1.0]` includes a `breakdown` dict for component-level analysis.
169
-
170
- | Component | Value | When Applied |
171
- |-----------|-------|--------------|
172
- | `step_reward` | `[0.0, 1.0]` | Per-action quality score from the task-specific grader |
173
- | `efficiency_bonus` | `+0.10` | Complete the episode under budget |
174
- | `coverage_bonus` | `+0.15` | Catch all critical bugs in the PR |
175
- | `consistency_penalty` | `−0.20` | Contradict your own previous severity labels |
176
- | `exploit_penalty` | `−0.50` | Approve a PR with unaddressed critical bugs |
177
-
178
- ### Reward Properties
179
 
180
- - **Bounded**: All rewards clamped to `[-1.0, 1.0]`
181
- - **Shaped**: Non-sparse signal at every step (not just at episode end)
182
- - **Transparent**: `reward.breakdown` exposes all components for reward attribution research
183
- - **Anti-exploit**: Spam comments and blind approvals are penalized (see tests)
184
 
185
- ---
186
-
187
- ## Tasks
188
-
189
- CodeReviewEnv provides **3 tasks** spanning easy hard, with different skills and grading logic:
190
-
191
- ### Task 1: Severity Labeling (Easy)
192
-
193
- | Property | Value |
194
- |----------|-------|
195
- | Difficulty | ⭐ Easy |
196
- | Episode Length | 5 steps (5 PRs) |
197
- | Objective | Classify each PR's bug severity |
198
- | Actions Used | `label_severity` |
199
- | Grader | Ordinal matching — exact match = 1.0, adjacent match = 0.6, off-by-two = 0.2, miss = 0.0. Extra penalties for confusing `critical` with `none`. |
200
- | Expected Score (random) | ~0.21 |
201
- | Expected Score (GPT-4o-mini) | ~0.73 |
202
- | Expected Score (perfect) | 1.00 |
203
-
204
- The agent sees one PR at a time and must label its severity. The grader uses ordinal distance on the severity scale: `none < low < medium < high < critical`.
205
 
206
- ### Task 2: Queue Prioritization (Medium)
207
 
208
- | Property | Value |
209
- |----------|-------|
210
- | Difficulty | ⭐⭐ Medium |
211
- | Episode Length | 3 steps (3 queues of 5 PRs each) |
212
- | Objective | Sort the review queue by urgency |
213
- | Actions Used | `prioritize` |
214
- | Grader | Kendall Tau rank correlation + position penalty for misplacing top-priority items. Perfect ordering = 1.0, random ≈ 0.31, fully reversed ≈ 0.0. |
215
- | Expected Score (random) | ~0.31 |
216
- | Expected Score (GPT-4o-mini) | ~0.94 |
217
- | Expected Score (perfect) | 1.00 |
218
 
219
- The agent sees a queue of PRs with metadata (author level, bug category, test coverage) and must output the optimal priority ordering. Grading uses Kendall Tau correlation to smoothly rank all orderings.
220
-
221
- ### Task 3: Feedback Generation (Hard)
222
-
223
- | Property | Value |
224
- |----------|-------|
225
- | Difficulty | ⭐⭐⭐ Hard |
226
- | Episode Length | Up to 18 steps (3 PRs × ≤6 actions each) |
227
- | Objective | Write actionable review comments, then approve or request changes |
228
- | Actions Used | `add_comment`, `approve`, `request_changes` |
229
- | Grader | 5-component weighted scorer: relevance (line targeting accuracy), specificity (domain keyword matching), actionability (concrete fix suggestions), coverage (% of bugs found), precision (signal-to-noise ratio). |
230
- | Expected Score (random) | ~0.09 |
231
- | Expected Score (GPT-4o-mini) | ~1.00 |
232
- | Expected Score (perfect oracle) | ~0.91 |
233
-
234
- This is the most challenging task. The agent must read code diffs, identify bugs, write specific comments targeting exact lines, use domain-specific terminology, and decide whether to approve or request changes. The multi-component grader ensures that generic, vague, or spammy comments score poorly.
235
-
236
- **Why GPT-4o-mini > perfect oracle on hard**: The "perfect" agent uses template-based comments with known bug lines. GPT-4o-mini generates more natural, specific comments that score higher on the specificity and actionability components.
237
-
238
- ---
239
 
240
- ## Baseline Scores
241
 
242
- Two baseline agents are included: a **heuristic agent** (no LLM, runs instantly via `baseline.py`) and an **LLM agent** (requires API key, via `inference.py`).
 
 
 
 
243
 
244
- ### Heuristic Baseline (`baseline.py`)
245
 
246
- Run `python baseline.py` no API key required.
 
 
 
247
 
248
- | Agent | Easy | Medium | Hard | Composite |
249
- |-------|------|--------|------|-----------|
250
- | Keyword Heuristic | 0.73 ± 0.12 | 0.47 ± 0.10 | 0.59 ± 0.09 | 0.60 |
251
- | Random | ~0.21 | ~0.31 | ~0.05 | ~0.18 |
252
 
253
- ### LLM Baseline (`inference.py`)
 
 
 
 
 
 
254
 
255
- Run `python inference.py` with `API_BASE_URL`, `MODEL_NAME`, and `HF_TOKEN` set.
256
-
257
- | Agent | Easy | Medium | Hard | Composite |
258
- |-------|------|--------|------|-----------|
259
- | GPT-4o-mini (measured) | 1.00 ± 0.00 | 0.68 ± 0.02 | 0.38 ± 0.01 | 0.69 |
260
- | Perfect Oracle | 1.00 | 1.00 | ~0.91 | ~0.97 |
261
-
262
- > **Note:** LLM scores depend on model version and API provider. Run `python inference.py` to generate reproducible results for your setup. Results are saved to `baseline/results.json`.
263
-
264
- **Key observations:**
265
- - **Monotonic difficulty**: Easy (1.00) > Medium (0.68) > Hard (0.38) — validated with real LLM
266
- - **Clear agent separation**: Random (0.18) < Heuristic (0.60) < GPT-4o-mini (0.69) < Perfect (0.97)
267
- - **Large headroom**: GPT-4o-mini at 0.69 vs ceiling 0.97 — significant room for RL-trained improvement
268
- - **Hard is genuinely hard**: Even GPT-4o-mini scores only 0.38 on multi-turn feedback generation
269
- - **Spam-resistant**: Decaying comment rewards prevent trivial exploit loops
270
- - All scores are deterministic given the same seed and model
271
-
272
- ---
273
-
274
- ## Setup & Installation
275
-
276
- ### Requirements
277
-
278
- - **Python 3.11+**
279
- - An OpenAI-compatible API key (OpenRouter, OpenAI, etc.)
280
-
281
- ### Install Dependencies
282
 
283
  ```bash
284
- # Clone the repository
285
- git clone https://huggingface.co/spaces/openenv/code-review-env
286
  cd code-review-env
287
-
288
- # Install core dependencies
289
  pip install -r requirements.txt
290
 
291
- # (Optional) Install research dependencies for world model training
292
- pip install -r requirements-research.txt
293
- ```
294
 
295
- ### Docker
296
-
297
- ```bash
298
- # Build
299
- docker build -t code-review-env .
300
-
301
- # Run (serves on port 7860)
302
- docker run -p 7860:7860 code-review-env
303
-
304
- # Verify
305
- curl http://localhost:7860/health
306
- ```
307
-
308
- ### Validate
309
-
310
- ```bash
311
- # Run the full OpenEnv compliance validation suite
312
- python validate.py
313
-
314
- # Run the test suite (19 tests across 5 categories)
315
  pytest tests/ -v
316
- ```
317
 
318
- ---
 
 
319
 
320
  ## Usage
321
 
322
- ### Direct Python API
323
-
324
  ```python
325
- from env.base import CodeReviewEnv
326
- from env.models import Action
327
-
328
- # Initialize with task and seed
329
- env = CodeReviewEnv(task="easy", seed=42)
330
- obs = env.reset()
331
 
332
- # Take an action
333
- action = Action(action_type="label_severity", severity="high")
334
- obs, reward, done, info = env.step(action)
335
 
336
- print(f"Reward: {reward.value:.3f}")
337
- print(f"Breakdown: {reward.breakdown}")
338
- print(f"Done: {done}")
339
 
340
- # Run full episode
341
- while not done:
342
- action = Action(action_type="label_severity", severity="medium")
343
- obs, reward, done, info = env.step(action)
 
 
344
 
345
- # Export trajectory for MBRL research
346
- trajectory = env.export_trajectory()
 
347
  ```
348
 
349
- ### HTTP API (OpenEnv Server)
350
-
351
- ```bash
352
- # Start the server
353
- uvicorn server.app:app --host 0.0.0.0 --port 7860
354
-
355
- # Health check
356
- curl http://localhost:7860/health
357
-
358
- # Reset (start new episode)
359
- curl -X POST http://localhost:7860/reset \
360
- -H "Content-Type: application/json" \
361
- -d '{"seed": 42}'
362
 
363
- # Step (take action)
364
- curl -X POST http://localhost:7860/step \
365
- -H "Content-Type: application/json" \
366
- -d '{"action": {"action_type": "label_severity", "severity": "high"}}'
367
 
368
- # Get current state
369
- curl http://localhost:7860/state
370
-
371
- # Environment metadata
372
- curl http://localhost:7860/metadata
373
-
374
- # OpenAPI docs: http://localhost:7860/docs
375
  ```
376
-
377
- ### OpenEnv Client (Async/Sync)
378
-
379
- ```python
380
- import asyncio
381
- from code_review_env import CodeReviewEnv, CodeReviewAction
382
-
383
- async def main():
384
- async with CodeReviewEnv(base_url="https://openenv-code-review-env.hf.space") as env:
385
- result = await env.reset(seed=42)
386
- print(result.observation.pr_id)
387
-
388
- result = await env.step(
389
- CodeReviewAction(action_type="label_severity", severity="high")
390
- )
391
- print(f"Reward: {result.reward}, Done: {result.done}")
392
-
393
- asyncio.run(main())
394
-
395
- # Or synchronous:
396
- with CodeReviewEnv(base_url="http://localhost:7860").sync() as env:
397
- result = env.reset(seed=42)
398
- result = env.step(CodeReviewAction(action_type="label_severity", severity="high"))
399
  ```
400
 
401
- ---
402
-
403
- ## Running Inference
404
-
405
- The `inference.py` script is the mandatory evaluation entry point. It runs all 3 tasks, emits structured logs, and saves results.
406
 
407
- ### Environment Variables
408
-
409
- These **must** be set before running inference:
410
-
411
- | Variable | Required | Description |
412
- |----------|----------|-------------|
413
- | `API_BASE_URL` | Yes | The API endpoint for the LLM (default: `https://openrouter.ai/api/v1`) |
414
- | `MODEL_NAME` | Yes | The model identifier (default: `openai/gpt-4o-mini`) |
415
- | `HF_TOKEN` | Yes | Your Hugging Face / API key. Also accepts `OPENAI_API_KEY` or `API_KEY`. |
416
 
417
- ### Run
418
 
419
  ```bash
420
- # Set required environment variables
421
  export API_BASE_URL="https://openrouter.ai/api/v1"
 
422
  export MODEL_NAME="openai/gpt-4o-mini"
423
- export HF_TOKEN="your-api-key-here"
424
-
425
- # Run inference (completes in < 20 minutes)
426
  python inference.py
427
  ```
428
 
429
- ### What It Does
430
-
431
- 1. Initializes the OpenAI client with `API_BASE_URL` and `HF_TOKEN`
432
- 2. Runs 3 episodes per task (easy, medium, hard) with `seed=42`
433
- 3. Emits structured `[START]`, `[STEP]`, `[END]` logs to stdout
434
- 4. Saves results to `baseline/results.json`
435
-
436
- ---
437
-
438
- ## Structured Logging Format
439
-
440
- The inference script emits structured stdout logs in the **mandatory** `[START]`, `[STEP]`, `[END]` format:
441
-
442
- ### `[START]` — emitted once per task
443
-
444
- ```
445
- [START] task=severity-labeling env=code-review-env model=openai/gpt-4o-mini
446
- ```
447
-
448
- ### `[STEP]` — emitted for each action taken
449
-
450
- ```
451
- [STEP] step=1 action=label_severity:high reward=0.50 done=false error=null
452
- ```
453
-
454
- ### `[END]` — emitted once per task at completion
455
-
456
- ```
457
- [END] success=true steps=5 score=0.767 rewards=0.50,1.00,0.80,0.60,0.93
458
- ```
459
-
460
- ---
461
-
462
- ## Pre-Submission Checklist
463
-
464
- | Check | Command / Verification | Status |
465
- |-------|----------------------|--------|
466
- | HF Space deploys | `curl https://ragavrida-code-review-env.hf.space/health` returns 200 | ✅ |
467
- | OpenEnv spec compliance | `python validate.py` — all 17 checks pass | ✅ |
468
- | Dockerfile builds | `docker build -t code-review-env .` | ✅ |
469
- | Baseline reproduces | `python baseline.py` completes end-to-end without errors | ✅ |
470
- | LLM inference | `python inference.py` completes with API key, saves `baseline/results.json` | ✅ |
471
- | 3+ tasks with graders | `easy`, `medium`, `hard` — all graders produce scores in `[0.0, 1.0]` | ✅ |
472
- | Tests pass | `pytest tests/ -v` — 21 tests across 6 categories | ✅ |
473
- | `API_BASE_URL` defined | Used by `inference.py` | ✅ |
474
- | `MODEL_NAME` defined | Used by `inference.py` | ✅ |
475
- | `HF_TOKEN` defined | Used by `inference.py` | ✅ |
476
- | `inference.py` in root | Located at `./inference.py` | ✅ |
477
- | Uses OpenAI Client | `from openai import OpenAI` | ✅ |
478
- | Structured stdout logs | `[START]`, `[STEP]`, `[END]` format | ✅ |
479
- | Runtime < 20 min | ~200 seconds | ✅ |
480
- | Runs on vcpu=2, memory=8GB | No GPU dependencies, lightweight CPU inference | ✅ |
481
-
482
- ---
483
-
484
  ## Project Structure
485
 
486
  ```
487
  code-review-env/
488
- ├── inference.py # Mandatory LLM evaluation script (root)
489
- ├── baseline.py # Heuristic baseline agent (no API key needed)
490
- ├── openenv.yaml # OpenEnv manifest
491
- ├── Dockerfile # Python 3.11 + uvicorn on :7860
492
- ├── requirements.txt # Core dependencies
493
- ├── requirements-research.txt # Optional: torch, sentence-transformers
494
- ├── pyproject.toml # Package configuration
495
- ├── validate.py # OpenEnv spec compliance validator
496
- ├── models.py # OpenEnv Action/Observation/State subclasses
497
- ├── client.py # CodeReviewEnv(EnvClient) — async/sync client
498
- ├── dataset.py # SemanticTransitionDataset (PyTorch-compatible)
499
- ├── __init__.py # Package exports
500
-
501
- ├── trajectories/ # MBRL trajectory data (JSONL)
502
- │ └── sample_trajectory.jsonl # 13 sample transitions from all 3 tasks
503
-
504
- ├── env/ # Core environment logic
505
- │ ├── base.py # CodeReviewEnv main class (S-MDP)
506
- │ ├── models.py # Internal Pydantic models (Action, Observation, Reward, State)
507
- │ ├── data_generator.py # 50 PR templates with real code diffs
508
- │ └── trajectory_logger.py # JSONL trajectory logging for MBRL
509
-
510
- ├── server/ # OpenEnv-compliant server
511
- │ ├── code_review_environment.py # Environment(OpenEnv base class)
512
- │ └── app.py # create_app() — FastAPI + WebSocket
513
-
514
- ├── tasks/ # Three difficulty levels
515
- │ ├── task_easy.py # Severity labeling (5 PRs/episode)
516
- │ ├── task_medium.py # Queue prioritization (3 queues/episode)
517
- │ └── task_hard.py # Feedback generation (3 PRs, multi-action)
518
-
519
- ├── graders/ # Deterministic graders
520
- │ ├── grader_easy.py # Ordinal matching + critical penalties
521
- │ ├── grader_medium.py # Kendall Tau + position penalties
522
- │ ├── grader_hard.py # 5-component weighted scorer
523
- │ └── reliability.py # Cohen's Kappa, Krippendorff's Alpha
524
-
525
- ├── benchmark/ # Baseline evaluation
526
- │ ├── protocol.py # BenchmarkRunner, LaTeX tables
527
- │ └── agents.py # RandomAgent, PerfectAgent
528
-
529
- ├── baseline/ # Saved results
530
- │ └── results.json # GPT-4o-mini baseline scores
531
-
532
- ├── world_model/ # MBRL research scaffold
533
- │ └── scaffold.py # SemanticTransitionDataset, WorldModelTrainer
534
-
535
- └── tests/ # 19 tests across 5 categories
536
- └── test_env.py # Core, grader, variance, reproducibility, exploit tests
537
- ```
538
-
539
- ---
540
-
541
- ## Using CodeReviewEnv for MBRL Research
542
-
543
- Standard MBRL benchmarks (Dreamer, MBPO, MuZero) assume vector state spaces with physics-based transitions. Text-based world models (Li et al., 2025) study synthetic text games; embodied semantic world models (Berg et al., 2025) target robotics. No prior work addresses **knowledge-work state spaces** where T(s,a)→s' depends on professional judgment rather than physics or game rules. CodeReviewEnv is the first environment designed for training **Knowledge-Work World Models (KW-WM)**.
544
-
545
- ### Step 1: Collect Trajectories
546
-
547
- ```bash
548
- # Run inference to generate trajectory data
549
- python inference.py # generates trajectories/*.jsonl
550
-
551
- # Or collect from the server API
552
- curl "https://ragavrida-code-review-env.hf.space/export_trajectory?session_id=latest"
553
  ```
554
 
555
- ### Step 2: Load Dataset
556
-
557
- ```python
558
- from dataset import SemanticTransitionDataset
559
-
560
- ds = SemanticTransitionDataset("trajectories/")
561
- print(f"{len(ds)} transitions collected")
562
- print(ds.stats())
563
-
564
- # Filter by task difficulty
565
- hard_ds = SemanticTransitionDataset("trajectories/", task_filter="hard")
566
-
567
- # Each transition:
568
- t = ds[0]
569
- print(t["state_text"]) # "PR PR-020: Refactor StringUtils | ..."
570
- print(t["action_text"]) # "label_severity:high"
571
- print(t["reward"]) # 0.5
572
- print(t["next_state_text"]) # "PR PR-006: Add rate limiter | ..."
573
- print(t["done"]) # False
574
- ```
575
-
576
- ### Step 3: Train Knowledge-Work World Model (KW-WM)
577
-
578
- ```python
579
- from sentence_transformers import SentenceTransformer
580
- import torch
581
-
582
- encoder = SentenceTransformer("all-MiniLM-L6-v2")
583
-
584
- # Encode states
585
- states = [ds[i]["state_text"] for i in range(len(ds))]
586
- actions = [ds[i]["action_text"] for i in range(len(ds))]
587
- s_enc = encoder.encode(states) # (N, 384) embeddings
588
- a_enc = encoder.encode(actions) # (N, 384) embeddings
589
-
590
- # Train MLP transition head: (s_enc, a_enc) → (s'_enc, r)
591
- # Then use Dyna-Q for sample-efficient planning
592
- # See world_model/scaffold.py for infrastructure
593
- ```
594
-
595
- #### Proof-of-Concept Results
596
-
597
- We include `train_world_model.py` — a self-contained KW-WM trainer (no PyTorch required):
598
-
599
- ```
600
- python train_world_model.py
601
- ```
602
-
603
- Results on 727 transitions from 50 PR templates (581 train, 146 test):
604
-
605
- **Reward Prediction g(s,a) → r** — *Can the model predict review quality from (state, action)?*
606
-
607
- | Model | Test MSE | vs Mean-pred | vs Random |
608
- |-------|----------|-------------|-----------|
609
- | Random | 0.225 | — | — |
610
- | Mean-pred (always predict mean) | 0.104 | — | — |
611
- | **KW-WM (MLP)** | **0.064** | **+38.8% ✅** | **+71.7% ✅** |
612
-
613
- Direction accuracy: **75.3%** — the model correctly predicts whether an action scores above or below 0.5 three-quarters of the time. This enables model-based planning: simulate different review strategies, pick the highest-predicted-reward action.
614
-
615
- **State Prediction f(s,a) → s'** — *Can the model predict review state transitions?*
616
-
617
- | Model | Test MSE | Notes |
618
- |-------|----------|-------|
619
- | Random | 0.039 | No structure captured |
620
- | Copy baseline (s' = s) | 0.024 | Strong — states change incrementally |
621
- | **KW-WM (MLP)** | **0.037** | **Beats random (+6.0%)**, approaching copy baseline |
622
-
623
- The copy baseline is naturally strong in knowledge-work domains because states evolve incrementally (unlike Atari where frames change dramatically). **The key takeaway is reward prediction** — the model learns which actions yield good reviews, enabling MBRL planning without environment interaction.
624
-
625
- ### Step 4: PyTorch DataLoader
626
-
627
- ```python
628
- # Direct PyTorch integration
629
- torch_ds = ds.to_pytorch()
630
- from torch.utils.data import DataLoader
631
- loader = DataLoader(torch_ds, batch_size=32, shuffle=True)
632
-
633
- for batch in loader:
634
- s_text = batch["state_text"] # list of state strings
635
- a_text = batch["action_text"] # list of action strings
636
- rewards = batch["reward"] # (B,) tensor
637
- done = batch["done"] # (B,) tensor
638
- break
639
- ```
640
-
641
- ### Sample Trajectory
642
-
643
- A `trajectories/sample_trajectory.jsonl` file is included with 13 transitions from all 3 tasks (seed=42). Each line:
644
-
645
- ```json
646
- {"episode_id": "sample_easy_seed42", "task": "easy", "step": 0, "state": {"pr_id": "PR-020", "title": "Refactor StringUtils"}, "action_text": "label_severity:high", "reward": 0.0, "done": false}
647
- ```
648
-
649
- ### Open Research Questions
650
-
651
- 1. **Error compounding**: Does prediction error compound exponentially in knowledge-work spaces like in continuous spaces (Janner et al., 2019)?
652
- 2. **Natural error correction**: Does structured text provide error correction that physics-based transitions lack (cf. Berg et al., 2025), enabling longer model-based rollouts?
653
- 3. **Cross-domain transfer**: Can a KW-WM trained on code review transfer to email triage, bug prioritization, or document summarization?
654
- 4. **Representation learning**: What embedding dimension is sufficient for knowledge-work state spaces — 384 (MiniLM) vs 768 (BERT) vs 4096 (code-specific)?
655
- 5. **Learnability of review quality**: Can KW-WM learn the dimensions that SWE-bench cannot measure — code maintainability and professional review quality (Da et al., 2025)?
656
-
657
- ---
658
-
659
  ## Citation
660
 
661
  ```bibtex
662
- @misc{codereviewenv2026,
663
- title={CodeReviewEnv: A Knowledge-Work World Model Benchmark for Model-Based Reinforcement Learning},
664
- author={Raghav Rida},
665
- year={2026},
666
- note={OpenEnv Hackathon — First RL benchmark for knowledge-work world models (KW-WM)},
667
- url={https://huggingface.co/spaces/ragavrida/code-review-env}
668
  }
669
  ```
670
-
671
- ---
672
-
673
- ## License
674
-
675
- BSD-3-Clause
 
1
  ---
2
  title: CodeReviewEnv
3
+ emoji: "\U0001F50D"
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: docker
7
  pinned: false
8
+ short_description: "RL benchmark for automated code review with procedural generation"
9
  tags:
10
  - openenv
11
  - reinforcement-learning
12
  - code-review
13
  - mbrl
14
+ - semantic-mdp
15
  - llm-agents
 
16
  ---
17
 
18
+ # CodeReviewEnv
19
 
20
+ **An OpenEnv-compliant RL environment for automated code review.**
21
 
22
+ CodeReviewEnv formalizes software code review as a Semantic Markov Decision Process (S-MDP). Agents receive buggy code snippets, identify bugs, flag line numbers, suggest fixes, and write review comments. A 5-signal shaped reward provides rich training signal for reinforcement learning.
23
 
24
+ ## Why Code Review as RL?
25
 
26
+ Code review is a core knowledge-work task that requires understanding program semantics, identifying patterns across languages, and producing actionable feedback. Unlike toy text environments, CodeReviewEnv uses real code patterns and deterministic grading --- no LLM-in-the-loop evaluation.
 
 
27
 
28
+ | Benchmark | Domain | State Space | Reward | World Model? |
29
+ |-----------|--------|-------------|--------|--------------|
30
+ | MuJoCo | Robotics | R^n | Physics | Yes |
31
+ | Atari | Games | Pixels | Score | Yes |
32
+ | SWE-bench | Code | Text | Pass/Fail | No |
33
+ | **CodeReviewEnv** | **Code Review** | **Source code** | **5-signal shaped** | **Yes** |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
+ ## MDP Formulation
 
 
 
36
 
37
+ | Component | Description |
38
+ |-----------|-------------|
39
+ | **State (S)** | Buggy source code + language + difficulty metadata |
40
+ | **Action (A)** | `{issues, flagged_lines, suggestion, comment}` |
41
+ | **Transition (T)** | Deterministic: episode ends after review submission |
42
+ | **Reward (R)** | Weighted combination of 5 normalized signals |
43
+ | **Discount** | 1.0 (single-step episodes) |
 
 
 
 
 
 
 
 
 
 
 
 
 
44
 
45
+ ## Reward Breakdown
46
 
47
+ | Signal | Weight | Description |
48
+ |--------|--------|-------------|
49
+ | `bug_detection` | 0.40 | Fraction of gold bugs identified via keyword matching |
50
+ | `fix_quality` | 0.25 | Similarity of suggested fix to gold fix (difflib + keyword overlap) |
51
+ | `line_precision` | 0.15 | F1 score of flagged lines vs gold lines (+-3 line tolerance) |
52
+ | `comment_quality` | 0.10 | Length, actionability keywords, specificity heuristics |
53
+ | `efficiency` | 0.10 | Penalty for excessive steps and hint usage |
 
 
 
54
 
55
+ Hard difficulty gives a 1.5x multiplier on `bug_detection`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
 
57
+ ## Difficulty Tiers
58
 
59
+ | Tier | Bugs | Snippet Size | Bug Types |
60
+ |------|------|-------------|-----------|
61
+ | `easy` | 1 | < 20 lines | Common patterns |
62
+ | `medium` | 1-2 | 20-50 lines | Logic errors, mixed |
63
+ | `hard` | 2-3 | 50+ lines | Interacting bugs, subtle |
64
 
65
+ ## Procedural Generation
66
 
67
+ Every `reset()` produces a unique episode:
68
+ 1. Picks a clean snippet from a bank of 35+ functions (Python/JS/Go)
69
+ 2. Applies 1-3 bug injectors based on difficulty
70
+ 3. Stores gold answers in State (never exposed to agent)
71
 
72
+ ### Bug Injectors
 
 
 
73
 
74
+ | Injector | What it does |
75
+ |----------|-------------|
76
+ | `off_by_one` | Flips `<` to `<=`, adjusts `range()` bounds |
77
+ | `null_deref` | Removes a null/None/nil guard |
78
+ | `wrong_operator` | Swaps `+` / `-`, `*` / `/` |
79
+ | `unused_var` | Inserts dead variable shadowing a live one |
80
+ | `logic_inversion` | Flips `True`/`False`, `and`/`or`, `==`/`!=` |
81
 
82
+ ## Quick Start
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
  ```bash
85
+ # Install
 
86
  cd code-review-env
 
 
87
  pip install -r requirements.txt
88
 
89
+ # Run baseline (no API key needed)
90
+ python baseline.py
 
91
 
92
+ # Run tests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  pytest tests/ -v
 
94
 
95
+ # Start server
96
+ uvicorn server.app:app --host 0.0.0.0 --port 7860
97
+ ```
98
 
99
  ## Usage
100
 
 
 
101
  ```python
102
+ from server.code_review_environment import CodeReviewEnvironment
103
+ from models import CodeReviewAction
 
 
 
 
104
 
105
+ env = CodeReviewEnvironment()
106
+ obs = env.reset(seed=42, difficulty="easy")
 
107
 
108
+ print(obs.code) # Buggy code to review
109
+ print(obs.language) # python, javascript, or go
110
+ print(obs.difficulty) # easy, medium, or hard
111
 
112
+ action = CodeReviewAction(
113
+ issues=["Off-by-one error in loop boundary"],
114
+ flagged_lines=[3],
115
+ suggestion="Change < to <= on line 3",
116
+ comment="The loop exits too early, missing the last element.",
117
+ )
118
 
119
+ result = env.step(action)
120
+ print(f"Reward: {result.reward:.3f}")
121
+ print(f"Breakdown: {result.reward_breakdown}")
122
  ```
123
 
124
+ ## MCP Tools
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
+ For tool-calling agents, CodeReviewEnv exposes MCP endpoints:
 
 
 
127
 
 
 
 
 
 
 
 
128
  ```
129
+ POST /mcp/reset - Start a new episode
130
+ POST /mcp/get_code_snippet - Get current buggy code
131
+ POST /mcp/submit_review - Submit review, get reward
132
+ POST /mcp/request_hint - Get a hint (-0.05 reward penalty)
133
+ GET /mcp/get_state - Get episode state summary
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  ```
135
 
136
+ ## Docker
 
 
 
 
137
 
138
+ ```bash
139
+ docker build -t code-review-env .
140
+ docker run -p 7860:7860 code-review-env
141
+ ```
 
 
 
 
 
142
 
143
+ ## Inference
144
 
145
  ```bash
 
146
  export API_BASE_URL="https://openrouter.ai/api/v1"
147
+ export API_KEY="your-key"
148
  export MODEL_NAME="openai/gpt-4o-mini"
 
 
 
149
  python inference.py
150
  ```
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  ## Project Structure
153
 
154
  ```
155
  code-review-env/
156
+ models.py # Pydantic Action/Observation/State
157
+ snippet_bank.py # 35+ snippets + 5 bug injectors
158
+ reward.py # 5-signal shaped reward
159
+ client.py # EnvClient for WebSocket
160
+ inference.py # LLM evaluation (mandatory)
161
+ baseline.py # Heuristic agent (no API key)
162
+ server/
163
+ app.py # FastAPI + MCP endpoints
164
+ code_review_environment.py # Core Environment class
165
+ tests/
166
+ test_code_review_env.py
167
+ openenv.yaml # OpenEnv manifest
168
+ Dockerfile # Production container
169
+ requirements.txt
170
+ README.md
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
171
  ```
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  ## Citation
174
 
175
  ```bibtex
176
+ @software{codereviewenv2024,
177
+ title={CodeReviewEnv: A Semantic MDP for Automated Code Review},
178
+ author={CodeReviewEnv Team},
179
+ year={2024},
180
+ url={https://github.com/ragavrida/code-review-env}
 
181
  }
182
  ```
 
 
 
 
 
 
baseline.py CHANGED
@@ -1,10 +1,10 @@
1
  #!/usr/bin/env python3
2
  """
3
- CodeReviewEnv Baseline Agent Script
4
  ======================================
5
- Runs a simple heuristic agent (no LLM) against all three tasks
6
- and reports per-task scores. This verifies the environment works
7
- end-to-end without requiring any API keys.
8
 
9
  For LLM-based evaluation, use inference.py instead.
10
 
@@ -14,168 +14,151 @@ Usage:
14
 
15
  import json
16
  import os
 
17
  import sys
18
  import statistics
19
  import time
20
  from typing import Dict, List, Tuple
21
 
22
- from env.base import CodeReviewEnv
23
- from env.models import Action
24
-
25
-
26
- # ── Heuristic Agent ────────────────────────────────────────────────────────
27
-
28
- def heuristic_easy_action(obs) -> Action:
29
- """Simple heuristic: guess severity based on keywords in diff."""
30
- diff_text = ""
31
- for f in obs.files:
32
- diff_text += f.diff.lower()
33
-
34
- if any(kw in diff_text for kw in ["injection", "secret", "hardcoded", "plaintext", "md5"]):
35
- severity = "critical"
36
- elif any(kw in diff_text for kw in ["null", "none", "nil", "race", "mutex", "lock"]):
37
- severity = "high"
38
- elif any(kw in diff_text for kw in ["bug", "error", "exception", "off-by-one", "boundary"]):
39
- severity = "medium"
40
- elif any(kw in diff_text for kw in ["o(n)", "performance", "loop", "cache", "index"]):
41
- severity = "low"
42
- else:
43
- severity = "none"
44
-
45
- return Action(action_type="label_severity", severity=severity)
46
-
47
-
48
- def heuristic_medium_action(obs) -> Action:
49
- """Simple heuristic: return queue in original order (no reordering)."""
50
- return Action(action_type="prioritize", priority_order=list(obs.review_queue))
51
-
52
-
53
- def heuristic_hard_action(obs, step_in_pr: int) -> Action:
54
- """Simple heuristic: add one generic comment then request_changes."""
55
- if step_in_pr == 0 and obs.files:
56
- f = obs.files[0]
57
- return Action(
58
- action_type="add_comment",
59
- comment="Consider reviewing this section for potential issues.",
60
- target_file=f.filename,
61
- target_line=10,
62
- )
63
- return Action(action_type="request_changes")
64
-
65
-
66
- # ─── Task Runners ───────────────────────────────────────────────────────────
67
-
68
- def run_easy_episode(seed: int) -> Tuple[float, int]:
69
- """Run one easy episode. Returns (mean_reward, steps)."""
70
- env = CodeReviewEnv(task="easy", seed=seed)
71
- obs = env.reset()
72
- rewards = []
73
- done = False
74
-
75
- while not done:
76
- action = heuristic_easy_action(obs)
77
- obs, reward, done, info = env.step(action)
78
- rewards.append(reward.value)
79
-
80
- mean = statistics.mean(rewards) if rewards else 0.0
81
- return mean, len(rewards)
82
-
83
-
84
- def run_medium_episode(seed: int) -> Tuple[float, int]:
85
- """Run one medium episode. Returns (mean_reward, steps)."""
86
- env = CodeReviewEnv(task="medium", seed=seed)
87
- obs = env.reset()
88
- rewards = []
89
- done = False
90
-
91
- while not done:
92
- action = heuristic_medium_action(obs)
93
- obs, reward, done, info = env.step(action)
94
- rewards.append(reward.value)
95
 
96
- mean = statistics.mean(rewards) if rewards else 0.0
97
- return mean, len(rewards)
98
 
 
99
 
100
- def run_hard_episode(seed: int) -> Tuple[float, int]:
101
- """Run one hard episode. Returns (mean_reward, steps)."""
102
- env = CodeReviewEnv(task="hard", seed=seed)
103
- obs = env.reset()
104
- rewards = []
105
- done = False
106
- step_in_pr = 0
107
 
108
- while not done:
109
- action = heuristic_hard_action(obs, step_in_pr)
110
- obs, reward, done, info = env.step(action)
111
- rewards.append(reward.value)
112
 
113
- if action.action_type in ("approve", "request_changes"):
114
- step_in_pr = 0
115
- else:
116
- step_in_pr += 1
 
 
 
 
117
 
118
- # Filter out comment acks for PR-level scoring
119
- pr_rewards = [r for r in rewards if abs(r - 0.05) > 0.01]
120
- mean = statistics.mean(pr_rewards) if pr_rewards else 0.0
121
- return mean, len(rewards)
 
122
 
123
 
124
- # ── Main ────────────────────────────────────────────────────────────────────
125
 
126
  def main():
127
  SEED = 42
128
- N_EPISODES = 3
129
 
130
  print("=" * 60)
131
- print("CodeReviewEnv Baseline Heuristic Agent")
132
  print("=" * 60)
133
 
134
  start_time = time.time()
135
  all_results: Dict[str, Dict] = {}
136
 
137
- for task_name, runner in [("easy", run_easy_episode), ("medium", run_medium_episode), ("hard", run_hard_episode)]:
138
- print(f"\n--- {task_name.upper()} Task ---")
139
  scores = []
140
 
141
  for ep in range(N_EPISODES):
142
  ep_seed = SEED + ep
143
- score, steps = runner(ep_seed)
144
  scores.append(score)
145
- print(f" Episode {ep + 1}: score={score:.4f} ({steps} steps)")
146
 
147
  mean = statistics.mean(scores)
148
  std = statistics.stdev(scores) if len(scores) > 1 else 0.0
149
- all_results[task_name] = {
150
  "mean": round(mean, 4),
151
  "std": round(std, 4),
152
  "scores": [round(s, 4) for s in scores],
153
  }
154
- print(f" Mean: {mean:.4f} ± {std:.4f}")
155
 
156
  elapsed = time.time() - start_time
157
  composite = round(
158
  sum(r["mean"] for r in all_results.values()) / len(all_results), 4
159
  )
160
 
161
- # ── Summary Table ────────────────────────────────────────────────
162
  print(f"\n{'=' * 60}")
163
- print(f"{'Task':<10} | {'Mean':>8} | {'Std':>8} | {'Scores'}")
164
  print(f"{'-' * 10}-+-{'-' * 8}-+-{'-' * 8}-+-{'-' * 20}")
165
- for task in ["easy", "medium", "hard"]:
166
- r = all_results[task]
167
  scores_str = ", ".join(f"{s:.3f}" for s in r["scores"])
168
- print(f"{task:<10} | {r['mean']:>8.4f} | {r['std']:>8.4f} | [{scores_str}]")
169
  print(f"{'=' * 60}")
170
  print(f"Composite Score: {composite:.4f}")
171
  print(f"Elapsed: {elapsed:.1f}s")
172
 
173
- # ── Save Results ─────────────────────────────────────────────────
174
  output = {
175
  "agent": "heuristic_baseline",
176
  "composite": composite,
177
  "seed": SEED,
178
- "episodes_per_task": N_EPISODES,
179
  **all_results,
180
  "elapsed_seconds": round(elapsed, 1),
181
  }
 
1
  #!/usr/bin/env python3
2
  """
3
+ CodeReviewEnv -- Baseline Agent Script
4
  ======================================
5
+ Runs a simple heuristic agent (no LLM) against all three difficulty tiers
6
+ and reports per-tier scores. Verifies the environment works end-to-end
7
+ without requiring any API keys.
8
 
9
  For LLM-based evaluation, use inference.py instead.
10
 
 
14
 
15
  import json
16
  import os
17
+ import re
18
  import sys
19
  import statistics
20
  import time
21
  from typing import Dict, List, Tuple
22
 
23
+ from server.code_review_environment import CodeReviewEnvironment
24
+ from models import CodeReviewAction
25
+
26
+
27
+ # ── Heuristic Agent ──────────────────────────────────────────────────────────
28
+
29
+ def heuristic_review(code: str, language: str) -> CodeReviewAction:
30
+ """Simple keyword-based heuristic reviewer. No LLM needed."""
31
+ issues = []
32
+ flagged_lines = []
33
+ suggestions = []
34
+ lines = code.split('\n')
35
+
36
+ for i, line in enumerate(lines, 1):
37
+ line_lower = line.lower().strip()
38
+
39
+ # Skip comments
40
+ if line_lower.startswith('#') or line_lower.startswith('//'):
41
+ continue
42
+
43
+ # Check for common bug patterns
44
+ if re.search(r'<=\s*len\b', line) or re.search(r'<\s*len\b.*-\s*1', line):
45
+ issues.append(f"Potential off-by-one error on line {i}")
46
+ flagged_lines.append(i)
47
+ suggestions.append(f"Check boundary condition on line {i}")
48
+
49
+ # Look for missing null guards (dereference without prior check)
50
+ if re.search(r'\.\w+', line) and 'none' not in line_lower and 'null' not in line_lower:
51
+ if i > 1 and 'if' not in lines[i-2].lower():
52
+ pass # Could flag but too many false positives
53
+
54
+ # Look for suspicious operator patterns
55
+ if re.search(r'\w\s*-\s*\w', line) and 'range(' in line:
56
+ issues.append(f"Suspicious range bound on line {i}")
57
+ flagged_lines.append(i)
58
+
59
+ if not issues:
60
+ # Fallback: flag first non-trivial line
61
+ for i, line in enumerate(lines, 1):
62
+ if line.strip() and not line.strip().startswith(('#', '//', 'def ', 'func ', 'function ')):
63
+ issues.append(f"Potential issue on line {i}")
64
+ flagged_lines.append(i)
65
+ break
66
+
67
+ suggestion = "; ".join(suggestions) if suggestions else "Review the code for potential bugs."
68
+ comment = f"Found {len(issues)} potential issue(s). " + (suggestions[0] if suggestions else "Please review carefully.")
69
+
70
+ return CodeReviewAction(
71
+ issues=issues,
72
+ flagged_lines=flagged_lines,
73
+ suggestion=suggestion,
74
+ comment=comment,
75
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
 
 
 
77
 
78
+ # ── Task Runner ──────────────────────────────────────────────────────────────
79
 
80
+ def run_episode(difficulty: str, seed: int) -> Tuple[float, int]:
81
+ """Run one multi-step episode with heuristic agent. Returns (reward, steps)."""
82
+ env = CodeReviewEnvironment()
83
+ obs = env.reset(seed=seed, difficulty=difficulty)
84
+ steps = 0
 
 
85
 
86
+ # Step 1: Analyze
87
+ analyze = CodeReviewAction(action_type="analyze")
88
+ obs = env.step(analyze)
89
+ steps += 1
90
 
91
+ # Step 2: Flag suspicious lines
92
+ review = heuristic_review(obs.code, obs.language)
93
+ for line in review.flagged_lines[:2]:
94
+ flag = CodeReviewAction(action_type="flag_line", line=line)
95
+ obs = env.step(flag)
96
+ steps += 1
97
+ if obs.done:
98
+ return obs.reward or 0.0, steps
99
 
100
+ # Step 3: Submit full review
101
+ review.action_type = "submit_review"
102
+ result = env.step(review)
103
+ steps += 1
104
+ return result.reward or 0.0, steps
105
 
106
 
107
+ # ── Main ──────────────────────────────────────────────────────────────────────
108
 
109
  def main():
110
  SEED = 42
111
+ N_EPISODES = 5
112
 
113
  print("=" * 60)
114
+ print("CodeReviewEnv -- Baseline Heuristic Agent")
115
  print("=" * 60)
116
 
117
  start_time = time.time()
118
  all_results: Dict[str, Dict] = {}
119
 
120
+ for difficulty in ["easy", "medium", "hard"]:
121
+ print(f"\n--- {difficulty.upper()} ---")
122
  scores = []
123
 
124
  for ep in range(N_EPISODES):
125
  ep_seed = SEED + ep
126
+ score, steps = run_episode(difficulty, ep_seed)
127
  scores.append(score)
128
+ print(f" Episode {ep + 1}: score={score:.4f} ({steps} step)")
129
 
130
  mean = statistics.mean(scores)
131
  std = statistics.stdev(scores) if len(scores) > 1 else 0.0
132
+ all_results[difficulty] = {
133
  "mean": round(mean, 4),
134
  "std": round(std, 4),
135
  "scores": [round(s, 4) for s in scores],
136
  }
137
+ print(f" -> Mean: {mean:.4f} +/- {std:.4f}")
138
 
139
  elapsed = time.time() - start_time
140
  composite = round(
141
  sum(r["mean"] for r in all_results.values()) / len(all_results), 4
142
  )
143
 
144
+ # Summary Table
145
  print(f"\n{'=' * 60}")
146
+ print(f"{'Tier':<10} | {'Mean':>8} | {'Std':>8} | {'Scores'}")
147
  print(f"{'-' * 10}-+-{'-' * 8}-+-{'-' * 8}-+-{'-' * 20}")
148
+ for tier in ["easy", "medium", "hard"]:
149
+ r = all_results[tier]
150
  scores_str = ", ".join(f"{s:.3f}" for s in r["scores"])
151
+ print(f"{tier:<10} | {r['mean']:>8.4f} | {r['std']:>8.4f} | [{scores_str}]")
152
  print(f"{'=' * 60}")
153
  print(f"Composite Score: {composite:.4f}")
154
  print(f"Elapsed: {elapsed:.1f}s")
155
 
156
+ # Save Results
157
  output = {
158
  "agent": "heuristic_baseline",
159
  "composite": composite,
160
  "seed": SEED,
161
+ "episodes_per_tier": N_EPISODES,
162
  **all_results,
163
  "elapsed_seconds": round(elapsed, 1),
164
  }
baseline/heuristic_results.json CHANGED
@@ -1,33 +1,39 @@
1
  {
2
  "agent": "heuristic_baseline",
3
- "composite": 0.5954,
4
  "seed": 42,
5
- "episodes_per_task": 3,
6
  "easy": {
7
- "mean": 0.7333,
8
- "std": 0.1155,
9
  "scores": [
10
- 0.8,
11
- 0.6,
12
- 0.8
 
 
13
  ]
14
  },
15
  "medium": {
16
- "mean": 0.4667,
17
- "std": 0.1041,
18
  "scores": [
19
- 0.5833,
20
- 0.3833,
21
- 0.4333
 
 
22
  ]
23
  },
24
  "hard": {
25
- "mean": 0.5861,
26
- "std": 0.0921,
27
  "scores": [
28
- 0.6889,
29
- 0.5583,
30
- 0.5111
 
 
31
  ]
32
  },
33
  "elapsed_seconds": 0.0
 
1
  {
2
  "agent": "heuristic_baseline",
3
+ "composite": 0.5407,
4
  "seed": 42,
5
+ "episodes_per_tier": 5,
6
  "easy": {
7
+ "mean": 0.4731,
8
+ "std": 0.1797,
9
  "scores": [
10
+ 0.5234,
11
+ 0.5207,
12
+ 0.3229,
13
+ 0.7233,
14
+ 0.275
15
  ]
16
  },
17
  "medium": {
18
+ "mean": 0.4944,
19
+ "std": 0.2029,
20
  "scores": [
21
+ 0.1711,
22
+ 0.735,
23
+ 0.5224,
24
+ 0.5211,
25
+ 0.5224
26
  ]
27
  },
28
  "hard": {
29
+ "mean": 0.6546,
30
+ "std": 0.1263,
31
  "scores": [
32
+ 0.5726,
33
+ 0.7211,
34
+ 0.7535,
35
+ 0.7535,
36
+ 0.4726
37
  ]
38
  },
39
  "elapsed_seconds": 0.0
client.py CHANGED
@@ -5,13 +5,17 @@ Usage:
5
  # Async (recommended)
6
  async with CodeReviewEnv(base_url="https://your-space.hf.space") as env:
7
  result = await env.reset(seed=42)
8
- result = await env.step(CodeReviewAction(action_type="label_severity", severity="high"))
9
- print(result.observation.pr_id, result.reward, result.done)
 
 
 
 
 
10
 
11
  # Sync
12
- with CodeReviewEnv(base_url="http://localhost:8000").sync() as env:
13
  result = env.reset(seed=42)
14
- result = env.step(CodeReviewAction(action_type="label_severity", severity="high"))
15
  """
16
 
17
  from typing import Any, Dict
@@ -39,7 +43,6 @@ class CodeReviewEnv(EnvClient[CodeReviewAction, CodeReviewObservation, CodeRevie
39
  """Parse the server's JSON response into a typed StepResult."""
40
  obs_data = payload.get("observation", payload.get("data", payload))
41
  observation = CodeReviewObservation(**obs_data)
42
- # reward and done live at the top level of the payload, not inside observation
43
  reward = payload.get("reward", getattr(observation, "reward", 0.0))
44
  done = payload.get("done", getattr(observation, "done", False))
45
  return StepResult(
 
5
  # Async (recommended)
6
  async with CodeReviewEnv(base_url="https://your-space.hf.space") as env:
7
  result = await env.reset(seed=42)
8
+ result = await env.step(CodeReviewAction(
9
+ issues=["Off-by-one error in loop"],
10
+ flagged_lines=[3],
11
+ suggestion="Change < to <=",
12
+ comment="Loop boundary is wrong."
13
+ ))
14
+ print(result.observation.code, result.reward, result.done)
15
 
16
  # Sync
17
+ with CodeReviewEnv(base_url="http://localhost:7860").sync() as env:
18
  result = env.reset(seed=42)
 
19
  """
20
 
21
  from typing import Any, Dict
 
43
  """Parse the server's JSON response into a typed StepResult."""
44
  obs_data = payload.get("observation", payload.get("data", payload))
45
  observation = CodeReviewObservation(**obs_data)
 
46
  reward = payload.get("reward", getattr(observation, "reward", 0.0))
47
  done = payload.get("done", getattr(observation, "done", False))
48
  return StepResult(
inference.py CHANGED
@@ -28,11 +28,9 @@ STDOUT FORMAT
28
  - All fields on a single line with no newlines within a line.
29
 
30
  Example:
31
- [START] task=severity-labeling env=code-review-env model=openai/gpt-4o-mini
32
- [STEP] step=1 action=label_severity:high reward=0.50 done=false error=null
33
- [STEP] step=2 action=label_severity:critical reward=1.00 done=false error=null
34
- [STEP] step=3 action=label_severity:medium reward=0.80 done=true error=null
35
- [END] success=true steps=3 score=0.767 rewards=0.50,1.00,0.80
36
  """
37
 
38
  import asyncio
@@ -40,8 +38,8 @@ import sys
40
  import json
41
  import os
42
  import re
43
- import textwrap
44
  import inspect
 
45
  from typing import Any, Dict, List, Optional
46
 
47
  from openai import OpenAI
@@ -63,7 +61,7 @@ def _load_dotenv(dotenv_path: str) -> None:
63
  continue
64
  key, value = line.split("=", 1)
65
  key = key.strip()
66
- value = value.strip().strip("\"'") # tolerate simple quoting
67
  if key:
68
  os.environ.setdefault(key, value)
69
  except FileNotFoundError:
@@ -74,17 +72,14 @@ _load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
74
 
75
  # ─── Configuration ────────────────────────────────────────────────────────────
76
 
77
- IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME") or os.getenv("IMAGE_NAME") # If using from_docker_image()
78
 
79
- # The platform injects API_BASE_URL and API_KEY at runtime.
80
- # Do NOT fall back to personal credentials (HF_TOKEN, OPENAI_API_KEY) —
81
- # all LLM calls MUST go through the platform's LiteLLM proxy.
82
  API_BASE_URL = os.environ.get("API_BASE_URL", "")
83
  API_KEY = os.environ.get("API_KEY", "")
84
  MODEL_NAME = os.getenv("MODEL_NAME", "openai/gpt-4o-mini")
85
  BENCHMARK = "code-review-env"
86
  TEMPERATURE = 0.0
87
- MAX_TOKENS = 500
88
  SUCCESS_SCORE_THRESHOLD = 0.3
89
 
90
  if not API_BASE_URL:
@@ -94,20 +89,14 @@ if not API_KEY:
94
  print("[FATAL] API_KEY is not set. The platform must inject this.", file=sys.stderr, flush=True)
95
  sys.exit(1)
96
 
97
- # IMPORTANT: Override OPENAI_API_KEY and OPENAI_BASE_URL in the environment
98
- # so the OpenAI SDK does NOT auto-configure from stale env vars.
99
- # We always want to use our explicitly-set API_BASE_URL and API_KEY.
100
  os.environ["OPENAI_API_KEY"] = API_KEY
101
  os.environ["OPENAI_BASE_URL"] = API_BASE_URL
102
 
103
- # Debug: show which API config is active (stderr only)
104
  print(f"[DEBUG] API_BASE_URL = {API_BASE_URL}", file=sys.stderr, flush=True)
105
  print(f"[DEBUG] API_KEY value (last 8) = ...{API_KEY[-8:]}", file=sys.stderr, flush=True)
106
  print(f"[DEBUG] MODEL_NAME = {MODEL_NAME}", file=sys.stderr, flush=True)
107
 
108
 
109
-
110
-
111
  async def _maybe_await(value: Any) -> Any:
112
  """Await value if it's awaitable, else return it."""
113
  if inspect.isawaitable(value):
@@ -141,21 +130,13 @@ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> No
141
  # ─── LLM Interface ──────────────────────────────────────────────────────────
142
 
143
  def call_llm(client: OpenAI, system_prompt: str, user_prompt: str, max_retries: int = 3) -> str:
144
- """Call the LLM using OpenAI Client with retry. Returns response text.
145
-
146
- Uses ONLY the configured MODEL_NAME — no model name variants.
147
- This ensures all requests go through the LiteLLM proxy with the
148
- exact model name it expects.
149
- """
150
- import time
151
  last_error = None
152
-
153
  for attempt in range(max_retries):
154
  try:
155
  print(
156
- f"[DEBUG] LLM call attempt {attempt+1}/{max_retries} model={MODEL_NAME} base_url={client.base_url}",
157
- file=sys.stderr,
158
- flush=True,
159
  )
160
  completion = client.chat.completions.create(
161
  model=MODEL_NAME,
@@ -168,28 +149,15 @@ def call_llm(client: OpenAI, system_prompt: str, user_prompt: str, max_retries:
168
  stream=False,
169
  )
170
  result = (completion.choices[0].message.content or "").strip()
171
- print(
172
- f"[DEBUG] LLM call succeeded, response length={len(result)}",
173
- file=sys.stderr,
174
- flush=True,
175
- )
176
  return result
177
  except Exception as exc:
178
  last_error = exc
179
- print(
180
- f"[DEBUG] Attempt {attempt+1}/{max_retries} failed (model={MODEL_NAME}): {exc}",
181
- file=sys.stderr,
182
- flush=True,
183
- )
184
  if attempt < max_retries - 1:
185
  time.sleep(2 ** attempt)
186
 
187
- # All retries exhausted log loudly but don't crash the episode
188
- print(
189
- f"[ERROR] All {max_retries} LLM call attempts failed. Last error: {last_error}",
190
- file=sys.stderr,
191
- flush=True,
192
- )
193
  return ""
194
 
195
 
@@ -214,503 +182,232 @@ def parse_json_response(response: str) -> Optional[Dict]:
214
  return None
215
 
216
 
217
- # ─── System Prompts ──────────────────────────────────────────────────────────
218
-
219
- EASY_SYSTEM_PROMPT = textwrap.dedent("""
220
- You are a senior software engineer performing code review.
221
- You will receive a pull request with a code diff. Assess the severity of any bugs present.
222
-
223
- Severity scale:
224
- - "critical": Security vulnerabilities (SQL injection, auth bypass, hardcoded secrets)
225
- - "high": Crashes or data corruption (null pointer dereference, race conditions)
226
- - "medium": Logic errors or missing error handling (off-by-one, uncaught exceptions)
227
- - "low": Performance issues (N+1 queries, unnecessary loops)
228
- - "none": Style-only changes, no bugs
229
-
230
- Respond ONLY with valid JSON:
231
- {"action_type": "label_severity", "severity": "<critical|high|medium|low|none>"}
232
- """).strip()
233
-
234
- MEDIUM_SYSTEM_PROMPT = textwrap.dedent("""
235
- You are a senior software engineer managing a code review queue.
236
- You MUST order ALL the PR IDs by review priority (most urgent first).
237
-
238
- Priority rules (in strict order):
239
- 1. Security vulnerabilities (SQL injection, auth bypass, hardcoded secrets,
240
- session issues) are ALWAYS highest priority, regardless of severity label.
241
- 2. Higher severity bugs before lower: critical > high > medium > low > none
242
- 3. Within same severity: junior developers need review first (most urgent),
243
- then mid-level, then senior.
244
- 4. PRs without test coverage should be reviewed before those with tests.
245
-
246
- Bug severity guide:
247
- - critical: SQL injection, security vulnerabilities, auth bypass, hardcoded secrets
248
- - high: null pointer / None dereference, race conditions, data corruption
249
- - medium: logic errors, missing error handling, off-by-one bugs
250
- - low: performance issues (N+1 queries, unnecessary loops)
251
- - none: style-only changes, formatting, renaming
252
-
253
- IMPORTANT: You MUST include ALL PR IDs from the queue in your response.
254
- Do NOT omit any PR ID. Return the complete ordered list.
255
-
256
- Respond ONLY with valid JSON:
257
- {"action_type": "prioritize", "priority_order": ["PR-XXX", "PR-YYY", ...]}
258
- """).strip()
259
-
260
- HARD_COMMENT_PROMPT = textwrap.dedent("""
261
- You are a senior software engineer performing detailed code review.
262
- Your task is to add a SPECIFIC, ACTIONABLE review comment targeting a buggy line.
263
-
264
- Instructions:
265
- 1. Read the diff carefully. Look for lines marked with BUG comments or
266
- common vulnerability patterns.
267
- 2. Target the EXACT line number where the bug is (look at the diff line numbers).
268
- 3. Your comment MUST:
269
- - Reference the specific bug type (e.g., "null pointer", "SQL injection",
270
- "race condition", "missing error handling", "logic error")
271
- - Include a concrete suggestion using words like: "use", "replace", "add",
272
- "remove", "consider", "should", "instead", "wrap", "avoid", "refactor"
273
- 4. Set target_file to the exact filename from the diff header.
274
- 5. Set target_line to the line number of the buggy code.
275
-
276
- Bug-specific keywords to use in comments:
277
- - Null pointer: "null", "None", "check", "guard"
278
- - SQL injection: "injection", "parameterize", "prepared statement", "sanitize"
279
- - Race condition: "race", "lock", "mutex", "atomic", "thread-safe"
280
- - Logic error: "off-by-one", "boundary", "condition", "edge case"
281
- - Missing error handling: "exception", "catch", "error", "handle", "try"
282
- - Security: "auth", "token", "encrypt", "hash", "secret", "leak"
283
- - Performance: "complexity", "cache", "optimize", "index", "N+1"
284
-
285
- Respond ONLY with valid JSON:
286
- {"action_type": "add_comment", "comment": "<specific feedback>", "target_file": "<filename>", "target_line": <line_number>}
287
- """).strip()
288
-
289
- HARD_DECISION_PROMPT = textwrap.dedent("""
290
- You are a senior software engineer completing a code review.
291
- You have already added review comments. Now make your final decision.
292
-
293
- Rules:
294
- - If the code has ANY bugs (security, null pointer, race condition, logic error,
295
- missing error handling): respond with request_changes
296
- - If the code is clean (style-only, formatting, renaming): respond with approve
297
-
298
- Respond ONLY with valid JSON:
299
- {"action_type": "request_changes"}
300
- or
301
- {"action_type": "approve"}
302
- """).strip()
303
 
 
 
 
304
 
305
- # ─── Observation Formatting ──────────────────────────────────────────────────
 
 
 
 
306
 
307
- def format_obs_easy(obs: CodeReviewObservation) -> str:
308
- files_text = ""
309
- for f in obs.files:
310
- files_text += f"\n--- {f['filename']} ({f['language']}, {f['lines_changed']} lines) ---\n"
311
- files_text += f["diff"] + "\n"
312
- return (
313
- f"PR: {obs.pr_id} | {obs.title}\n{obs.description}\n"
314
- f"Author: {obs.author_experience}\n{files_text}\n"
315
- f"What is the severity?"
316
- )
317
 
 
 
 
 
 
 
 
 
318
 
319
- def format_obs_medium(obs: CodeReviewObservation) -> str:
320
- """Format medium task observation showing all available context.
321
-
322
- Shows the visible PR's full details plus the queue IDs.
323
- The agent needs to prioritize all IDs in the queue.
324
- """
325
- pr_count = len(obs.review_queue)
326
- queue_text = f"Review Queue ({pr_count} PRs to prioritize):\n"
327
- for i, pr_id in enumerate(obs.review_queue, 1):
328
- queue_text += f" {i}. {pr_id}\n"
329
-
330
- # Show the visible PR's full details to help with analysis
331
- visible_pr = (
332
- f"\nVisible PR Details (one of the PRs in the queue):\n"
333
- f" PR ID: {obs.pr_id}\n"
334
- f" Title: {obs.title}\n"
335
- f" Description: {obs.description}\n"
336
- f" Author Experience: {obs.author_experience}\n"
337
- )
338
-
339
- files_text = ""
340
- for f in obs.files:
341
- has_tests = f.get('has_tests', 'unknown')
342
- files_text += f"\n --- {f['filename']} ({f['language']}, {f['lines_changed']} lines, tests: {has_tests}) ---\n"
343
- files_text += f" {f['diff']}\n"
344
-
345
- return (
346
- f"Review Queue — Step {obs.step_number + 1}\n"
347
- f"{queue_text}"
348
- f"{visible_pr}"
349
- f"{files_text}\n"
350
- f"IMPORTANT: Order ALL {pr_count} PRs by priority. You MUST include every PR ID "
351
- f"listed above in your priority_order array.\n"
352
- f"Apply these rules: security PRs first, then by severity "
353
- f"(critical>high>medium>low>none), then junior authors before senior."
354
- )
355
 
 
356
 
357
- def format_obs_hard(obs: CodeReviewObservation, phase: str = "comment") -> str:
358
- """Format hard task observation with line numbers and phase info."""
359
- files_text = ""
360
- for f in obs.files:
361
- files_text += f"\n--- {f['filename']} ({f['language']}, {f['lines_changed']} lines) ---\n"
362
- # Add line numbers to diff for precise targeting
363
- diff_lines = f["diff"].split("\n")
364
- numbered_diff = ""
365
- line_num = 1
366
- for dl in diff_lines:
367
- if dl.startswith("@@"):
368
- # Parse the @@ line to get starting line number
369
- try:
370
- parts = dl.split("+")[1].split(",")[0].split(" ")[0]
371
- line_num = int(parts)
372
- except (IndexError, ValueError):
373
- pass
374
- numbered_diff += dl + "\n"
375
- elif dl.startswith("+") or dl.startswith(" ") or not dl.startswith("-"):
376
- numbered_diff += f"L{line_num}: {dl}\n"
377
- line_num += 1
378
- else:
379
- numbered_diff += f" {dl}\n" # removed lines don't get numbers
380
- files_text += numbered_diff
381
-
382
- comments = ""
383
- if obs.existing_comments:
384
- comments = f"\nYou have already made {len(obs.existing_comments)} comment(s) on this PR.\n"
385
-
386
- if phase == "comment":
387
- instruction = (
388
- f"\nFind a BUG in this code and add a specific comment targeting the buggy line.\n"
389
- f"Look for lines containing bug patterns: null checks, SQL injection, race conditions, "
390
- f"missing error handling, logic errors, security issues.\n"
391
- f"You MUST respond with add_comment action including target_file and target_line."
392
- )
393
- else:
394
- instruction = (
395
- f"\nYou have reviewed this PR and added comments. Now make your final decision.\n"
396
- f"If any bugs were found, respond with request_changes. If code is clean, respond with approve."
397
- )
398
 
399
  return (
400
- f"PR: {obs.pr_id} | {obs.title}\n"
401
- f"Description: {obs.description}\n"
402
- f"Author: {obs.author_experience}\n"
403
- f"{files_text}"
404
- f"{comments}"
405
- f"{instruction}"
406
  )
407
 
408
 
409
  def action_to_str(action_dict: Dict) -> str:
410
  """Convert action dict to a compact string for logging."""
411
- at = action_dict.get("action_type", "unknown")
412
- if at == "label_severity":
413
- return f"label_severity:{action_dict.get('severity', '?')}"
414
- elif at == "prioritize":
415
- order = action_dict.get("priority_order", [])
416
- return f"prioritize:[{','.join(order)}]"
417
- elif at == "add_comment":
418
- comment = (action_dict.get("comment", ""))[:50]
419
- return f"add_comment:{comment}"
420
  else:
421
- return at
422
-
423
-
424
- # ─── Task Runners ────────────────────────────────────────────────────────────
425
-
426
- TASK_CONFIGS = {
427
- "easy": {
428
- "task_name": "severity-labeling",
429
- "system_prompt": EASY_SYSTEM_PROMPT,
430
- "max_steps": 5,
431
- "format_obs": format_obs_easy,
432
- "default_action": lambda obs: {"action_type": "label_severity", "severity": "medium"},
433
- },
434
- "medium": {
435
- "task_name": "queue-prioritization",
436
- "system_prompt": MEDIUM_SYSTEM_PROMPT,
437
- "max_steps": 3,
438
- "format_obs": format_obs_medium,
439
- "default_action": lambda obs: {"action_type": "prioritize", "priority_order": list(obs.review_queue)},
440
- },
441
- "hard": {
442
- "task_name": "feedback-generation",
443
- "system_prompt": HARD_COMMENT_PROMPT, # default prompt; run_task switches between comment/decision
444
- "max_steps": 18,
445
- "format_obs": lambda obs: format_obs_hard(obs, phase="comment"),
446
- "default_action": lambda obs: {
447
- "action_type": "add_comment",
448
- "comment": "Potential bug detected — please add error handling or validation.",
449
- "target_file": obs.files[0]["filename"] if obs.files else "unknown",
450
- "target_line": 10,
451
- },
452
- },
453
- }
454
 
455
 
456
- def _postprocess_medium_action(action_dict: Dict, obs: CodeReviewObservation) -> Dict:
457
- """Ensure the medium task response includes ALL PR IDs from the queue.
458
-
459
- If the LLM omits some PR IDs, we append them at the end.
460
- If the LLM includes IDs not in the queue, we remove them.
461
- """
462
- if action_dict.get("action_type") != "prioritize":
463
- return action_dict
464
-
465
- queue_ids = set(obs.review_queue)
466
- predicted = action_dict.get("priority_order", [])
467
-
468
- # Remove IDs not in the queue
469
- cleaned = [pr_id for pr_id in predicted if pr_id in queue_ids]
470
- included = set(cleaned)
471
-
472
- # Append missing IDs (maintain queue order for unknowns)
473
- for pr_id in obs.review_queue:
474
- if pr_id not in included:
475
- cleaned.append(pr_id)
476
- included.add(pr_id)
477
-
478
- action_dict["priority_order"] = cleaned
479
- return action_dict
480
-
481
-
482
- def _build_hard_action(
483
- llm_client: OpenAI,
484
- obs: CodeReviewObservation,
485
- phase: str,
486
- comments_on_pr: int,
487
- ) -> Dict:
488
- """Build an action for the hard task based on the current phase.
489
-
490
- phase='comment': Generate an add_comment action targeting a specific bug.
491
- phase='decide': Generate an approve/request_changes action.
492
- """
493
- if phase == "comment":
494
- system_prompt = HARD_COMMENT_PROMPT
495
- user_prompt = format_obs_hard(obs, phase="comment")
496
- if comments_on_pr > 0:
497
- user_prompt += f"\nYou have made {comments_on_pr} comment(s) so far. Target a DIFFERENT bug line."
498
- else:
499
- system_prompt = HARD_DECISION_PROMPT
500
- user_prompt = format_obs_hard(obs, phase="decide")
501
 
502
- try:
503
- response = call_llm(llm_client, system_prompt, user_prompt)
504
- parsed = parse_json_response(response)
505
- except Exception as e:
506
- print(f"[DEBUG] Hard task LLM call failed: {e}", file=sys.stderr, flush=True)
507
- parsed = None
508
-
509
- if parsed and parsed.get("action_type"):
510
- action_dict = parsed
511
- else:
512
- # Fallback
513
- if phase == "comment":
514
- filename = obs.files[0]["filename"] if obs.files else "unknown"
515
- action_dict = {
516
- "action_type": "add_comment",
517
- "comment": "Potential bug — consider adding error handling or input validation to prevent crashes.",
518
- "target_file": filename,
519
- "target_line": 15,
520
- }
521
- else:
522
- action_dict = {"action_type": "request_changes"}
523
-
524
- # Force correct action type for the phase
525
- if phase == "comment" and action_dict.get("action_type") not in ("add_comment",):
526
- filename = obs.files[0]["filename"] if obs.files else "unknown"
527
- action_dict = {
528
- "action_type": "add_comment",
529
- "comment": action_dict.get("comment", "Bug detected — should add proper validation."),
530
- "target_file": action_dict.get("target_file", filename),
531
- "target_line": action_dict.get("target_line", 15),
532
- }
533
- elif phase == "decide" and action_dict.get("action_type") not in ("approve", "request_changes"):
534
- action_dict = {"action_type": "request_changes"}
535
-
536
- return action_dict
537
-
538
-
539
- async def run_task(env: CodeReviewEnv, llm_client: OpenAI, task: str) -> float:
540
- """Run a single task episode. Returns normalized score.
541
-
542
- NOTE: Caller is responsible for emitting [START] and [END] lines.
543
- This function only emits [STEP] lines.
544
- """
545
- config = TASK_CONFIGS[task]
546
  rewards: List[float] = []
547
  steps_taken = 0
548
  score = 0.01
549
 
550
- result = await _maybe_await(env.reset(seed=42, task=task))
551
  obs = result.observation
552
 
553
- # Hard task tracking
554
- hard_comments_on_pr = 0
555
- hard_current_pr = obs.pr_id if task == "hard" else None
556
- HARD_COMMENTS_PER_PR = 1 # 1 targeted comment then decide (maximizes mean reward)
557
-
558
- for step in range(1, config["max_steps"] + 1):
 
 
 
559
  if result.done:
560
- break
561
-
562
- if task == "hard":
563
- # ── Hard task: two-phase comment → decide workflow ──
564
- # Track PR transitions
565
- if obs.pr_id != hard_current_pr:
566
- hard_comments_on_pr = 0
567
- hard_current_pr = obs.pr_id
568
-
569
- # Decide phase
570
- if hard_comments_on_pr < HARD_COMMENTS_PER_PR:
571
- phase = "comment"
572
- else:
573
- phase = "decide"
574
-
575
- action_dict = _build_hard_action(
576
- llm_client, obs, phase, hard_comments_on_pr
577
- )
578
 
579
- if phase == "comment":
580
- hard_comments_on_pr += 1
581
-
582
- else:
583
- # ── Easy / Medium tasks: standard flow ──
584
- try:
585
- user_prompt = config["format_obs"](obs)
586
- response = call_llm(llm_client, config["system_prompt"], user_prompt)
587
- parsed = parse_json_response(response)
588
- except Exception as e:
589
- print(f"[DEBUG] LLM call failed at step {step}: {e}", file=sys.stderr, flush=True)
590
- parsed = None
591
-
592
- # Build action
593
- if parsed and parsed.get("action_type"):
594
- action_dict = parsed
595
- else:
596
- action_dict = config["default_action"](obs)
597
 
598
- # Post-process medium task to ensure all PR IDs are included
599
- if task == "medium":
600
- action_dict = _postprocess_medium_action(action_dict, obs)
 
 
 
601
 
602
- # Ensure valid action fields
603
- try:
604
- action = CodeReviewAction(**action_dict)
605
- except Exception as e:
606
- print(f"[DEBUG] Action validation failed: {e}", file=sys.stderr, flush=True)
607
- action_dict = config["default_action"](obs)
608
- action = CodeReviewAction(**action_dict)
609
 
610
- # Step
 
611
  try:
612
- result = await _maybe_await(env.step(action))
 
613
  obs = result.observation
614
- reward = result.reward or 0.01
615
- done = result.done
616
- error = None
 
 
 
 
 
617
  except Exception as e:
618
- print(f"[DEBUG] env.step() failed: {e}", file=sys.stderr, flush=True)
619
- reward = 0.01
620
- done = True
621
- error = str(e)
622
-
623
- rewards.append(reward)
624
- steps_taken = step
625
-
626
- log_step(
627
- step=step,
628
- action=action_to_str(action_dict),
629
- reward=reward,
630
- done=done,
631
- error=error,
632
- )
633
 
634
- if done:
635
- break
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
636
 
637
- # Compute score: mean reward normalized to [0, 1]
638
- if rewards:
639
- score = sum(rewards) / len(rewards)
640
- score = min(max(score, 0.01), 0.99)
 
641
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  return score, steps_taken, rewards
643
 
644
 
645
  # ─── Main ────────────────────────────────────────────────────────────────────
646
 
647
  async def main() -> int:
648
- # Initialize LLM client using the injected API_BASE_URL and API_KEY.
649
- # Explicitly pass both to ensure all requests go through the LiteLLM proxy.
650
  llm_client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
651
 
652
- # ── Startup connectivity check ──────────────────────────────────────────
653
- # Make a minimal LLM call to verify the proxy is reachable and the model
654
- # name is valid. This ensures we fail loudly if something is misconfigured
655
- # rather than silently falling back to default actions.
656
  try:
657
- print("[DEBUG] Testing LiteLLM proxy connectivity...", file=sys.stderr, flush=True)
658
- test_completion = llm_client.chat.completions.create(
659
  model=MODEL_NAME,
660
  messages=[{"role": "user", "content": "ping"}],
661
- max_tokens=5,
662
- temperature=0.0,
663
- )
664
- print(
665
- f"[DEBUG] Proxy connectivity OK — model={MODEL_NAME}, "
666
- f"response={test_completion.choices[0].message.content!r}",
667
- file=sys.stderr,
668
- flush=True,
669
  )
 
670
  except Exception as e:
671
- print(
672
- f"[WARNING] Proxy connectivity test failed: {e}. "
673
- f"Continuing anyway — LLM calls may fail.",
674
- file=sys.stderr,
675
- flush=True,
676
- )
677
 
678
  scores = {}
679
  space_url = os.getenv("SPACE_URL", "https://ragavrida-code-review-env.hf.space")
680
 
681
- for task in ["easy", "medium", "hard"]:
682
- config = TASK_CONFIGS[task]
683
- task_name = config["task_name"]
684
  score = 0.01
685
  steps_taken = 0
686
  rewards: List[float] = []
687
  success = False
688
  env = None
689
 
690
- # Always emit [START] to stdout
691
  log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
692
 
693
  try:
694
-
695
- # Fresh env per task to avoid reusing a closed ws connection.
696
  if IMAGE_NAME:
697
- print(f"[DEBUG] Connecting to Docker image: {IMAGE_NAME}", file=sys.stderr, flush=True)
698
  env = await CodeReviewEnv.from_docker_image(IMAGE_NAME)
699
  else:
700
- print(f"[DEBUG] Connecting to server: {space_url}", file=sys.stderr, flush=True)
701
  env = CodeReviewEnv(base_url=space_url)
702
 
703
- score, steps_taken, rewards = await run_task(env, llm_client, task)
704
  success = score >= SUCCESS_SCORE_THRESHOLD
705
 
706
  except Exception as e:
707
- print(f"[ERROR] Task {task} failed: {e}", file=sys.stderr, flush=True)
708
  import traceback
709
  traceback.print_exc(file=sys.stderr)
710
 
711
  finally:
712
- # Always emit [END] to stdout — even on failure
713
- # Safety clamp: validator requires scores strictly in (0, 1)
714
  score = min(max(score, 0.01), 0.99)
715
  log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
716
 
@@ -722,10 +419,16 @@ async def main() -> int:
722
  except Exception as e:
723
  print(f"[DEBUG] env.close() error: {e}", file=sys.stderr, flush=True)
724
 
725
- scores[task] = score
726
 
727
  composite = sum(scores.values()) / max(len(scores), 1)
728
- print(f"\n[SUMMARY] composite={composite:.3f} easy={scores.get('easy',0):.3f} medium={scores.get('medium',0):.3f} hard={scores.get('hard',0):.3f}", file=sys.stderr, flush=True)
 
 
 
 
 
 
729
 
730
  return 0
731
 
 
28
  - All fields on a single line with no newlines within a line.
29
 
30
  Example:
31
+ [START] task=code-review-easy env=code-review-env model=openai/gpt-4o-mini
32
+ [STEP] step=1 action=review:3_issues reward=0.75 done=true error=null
33
+ [END] success=true steps=1 score=0.750 rewards=0.75
 
 
34
  """
35
 
36
  import asyncio
 
38
  import json
39
  import os
40
  import re
 
41
  import inspect
42
+ import time
43
  from typing import Any, Dict, List, Optional
44
 
45
  from openai import OpenAI
 
61
  continue
62
  key, value = line.split("=", 1)
63
  key = key.strip()
64
+ value = value.strip().strip("\"'")
65
  if key:
66
  os.environ.setdefault(key, value)
67
  except FileNotFoundError:
 
72
 
73
  # ─── Configuration ────────────────────────────────────────────────────────────
74
 
75
+ IMAGE_NAME = os.getenv("LOCAL_IMAGE_NAME") or os.getenv("IMAGE_NAME")
76
 
 
 
 
77
  API_BASE_URL = os.environ.get("API_BASE_URL", "")
78
  API_KEY = os.environ.get("API_KEY", "")
79
  MODEL_NAME = os.getenv("MODEL_NAME", "openai/gpt-4o-mini")
80
  BENCHMARK = "code-review-env"
81
  TEMPERATURE = 0.0
82
+ MAX_TOKENS = 800
83
  SUCCESS_SCORE_THRESHOLD = 0.3
84
 
85
  if not API_BASE_URL:
 
89
  print("[FATAL] API_KEY is not set. The platform must inject this.", file=sys.stderr, flush=True)
90
  sys.exit(1)
91
 
 
 
 
92
  os.environ["OPENAI_API_KEY"] = API_KEY
93
  os.environ["OPENAI_BASE_URL"] = API_BASE_URL
94
 
 
95
  print(f"[DEBUG] API_BASE_URL = {API_BASE_URL}", file=sys.stderr, flush=True)
96
  print(f"[DEBUG] API_KEY value (last 8) = ...{API_KEY[-8:]}", file=sys.stderr, flush=True)
97
  print(f"[DEBUG] MODEL_NAME = {MODEL_NAME}", file=sys.stderr, flush=True)
98
 
99
 
 
 
100
  async def _maybe_await(value: Any) -> Any:
101
  """Await value if it's awaitable, else return it."""
102
  if inspect.isawaitable(value):
 
130
  # ─── LLM Interface ──────────────────────────────────────────────────────────
131
 
132
  def call_llm(client: OpenAI, system_prompt: str, user_prompt: str, max_retries: int = 3) -> str:
133
+ """Call the LLM using OpenAI Client with retry."""
 
 
 
 
 
 
134
  last_error = None
 
135
  for attempt in range(max_retries):
136
  try:
137
  print(
138
+ f"[DEBUG] LLM call attempt {attempt+1}/{max_retries} model={MODEL_NAME}",
139
+ file=sys.stderr, flush=True,
 
140
  )
141
  completion = client.chat.completions.create(
142
  model=MODEL_NAME,
 
149
  stream=False,
150
  )
151
  result = (completion.choices[0].message.content or "").strip()
152
+ print(f"[DEBUG] LLM response length={len(result)}", file=sys.stderr, flush=True)
 
 
 
 
153
  return result
154
  except Exception as exc:
155
  last_error = exc
156
+ print(f"[DEBUG] Attempt {attempt+1} failed: {exc}", file=sys.stderr, flush=True)
 
 
 
 
157
  if attempt < max_retries - 1:
158
  time.sleep(2 ** attempt)
159
 
160
+ print(f"[ERROR] All {max_retries} attempts failed: {last_error}", file=sys.stderr, flush=True)
 
 
 
 
 
161
  return ""
162
 
163
 
 
182
  return None
183
 
184
 
185
+ # ─── System Prompt ──────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
+ REVIEW_SYSTEM_PROMPT = """\
188
+ You are a senior software engineer performing code review.
189
+ You will receive a code snippet that may contain bugs.
190
 
191
+ Your task:
192
+ 1. Identify any bugs in the code (off-by-one errors, null dereferences, wrong operators, dead variables, logic inversions)
193
+ 2. Report the exact line numbers where bugs are
194
+ 3. Suggest a concrete fix
195
+ 4. Write a helpful review comment explaining the issues
196
 
197
+ Bug types to look for:
198
+ - Off-by-one: wrong boundary conditions (< vs <=), incorrect range bounds
199
+ - Null dereference: missing null/None/nil guards before access
200
+ - Wrong operator: arithmetic (+/-/*) or comparison (==/!=) errors
201
+ - Dead variables: unused assignments that shadow live variables
202
+ - Logic inversion: flipped boolean conditions (and/or, True/False, ==/!=)
 
 
 
 
203
 
204
+ Respond ONLY with valid JSON:
205
+ {
206
+ "issues": ["description of bug 1", "description of bug 2"],
207
+ "flagged_lines": [3, 7],
208
+ "suggestion": "Change < to <= on line 3 to fix the boundary condition",
209
+ "comment": "Found an off-by-one error in the loop boundary that causes the last element to be skipped."
210
+ }
211
+ """
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
+ # ─── Observation Formatting ──────────────────────────────────────────────────
215
 
216
+ def format_observation(obs: CodeReviewObservation) -> str:
217
+ """Format observation for the LLM."""
218
+ # Add line numbers to code
219
+ lines = obs.code.split('\n')
220
+ numbered = '\n'.join(f"L{i+1}: {line}" for i, line in enumerate(lines))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
 
222
  return (
223
+ f"Language: {obs.language}\n"
224
+ f"Difficulty: {obs.difficulty}\n\n"
225
+ f"Code to review:\n```\n{numbered}\n```\n\n"
226
+ f"{obs.instructions}"
 
 
227
  )
228
 
229
 
230
  def action_to_str(action_dict: Dict) -> str:
231
  """Convert action dict to a compact string for logging."""
232
+ at = action_dict.get("action_type", "submit_review")
233
+ if at == "flag_line":
234
+ return f"flag_line:{action_dict.get('line', '?')}"
235
+ elif at == "analyze":
236
+ return "analyze"
237
+ elif at == "request_hint":
238
+ return "request_hint"
 
 
239
  else:
240
+ n_issues = len(action_dict.get("issues", []))
241
+ n_lines = len(action_dict.get("flagged_lines", []))
242
+ return f"submit_review:{n_issues}_issues,{n_lines}_lines"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
 
245
+ # ─── Task Runner ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
+ async def run_task(env: CodeReviewEnv, llm_client: OpenAI, difficulty: str) -> tuple:
248
+ """Run a multi-step episode. Agent: analyze → flag lines → submit review."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  rewards: List[float] = []
250
  steps_taken = 0
251
  score = 0.01
252
 
253
+ result = await _maybe_await(env.reset(seed=42, task=difficulty))
254
  obs = result.observation
255
 
256
+ # Step 1: Analyze the code (free action)
257
+ try:
258
+ analyze_action = CodeReviewAction(action_type="analyze")
259
+ result = await _maybe_await(env.step(analyze_action))
260
+ obs = result.observation
261
+ rewards.append(result.reward or 0.0)
262
+ steps_taken += 1
263
+ log_step(step=steps_taken, action="analyze", reward=result.reward or 0.0,
264
+ done=result.done, error=None)
265
  if result.done:
266
+ score = max(0.01, min(0.99, sum(rewards) / len(rewards) if rewards else 0.01))
267
+ return score, steps_taken, rewards
268
+ except Exception as e:
269
+ print(f"[DEBUG] analyze failed: {e}", file=sys.stderr, flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
 
271
+ # Step 2: Use LLM to identify bugs and flag lines
272
+ user_prompt = format_observation(obs)
273
+ if obs.analysis:
274
+ user_prompt += f"\n\nAnalysis: {obs.analysis}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
 
276
+ try:
277
+ response = call_llm(llm_client, REVIEW_SYSTEM_PROMPT, user_prompt)
278
+ parsed = parse_json_response(response)
279
+ except Exception as e:
280
+ print(f"[DEBUG] LLM call failed: {e}", file=sys.stderr, flush=True)
281
+ parsed = None
282
 
283
+ flagged = []
284
+ if parsed:
285
+ flagged = parsed.get("flagged_lines", [])
286
+ flagged = [int(x) for x in flagged if isinstance(x, (int, float))]
 
 
 
287
 
288
+ # Step 2-3: Flag individual lines (intermediate feedback)
289
+ for line in flagged[:2]: # Flag up to 2 lines
290
  try:
291
+ flag_action = CodeReviewAction(action_type="flag_line", line=line)
292
+ result = await _maybe_await(env.step(flag_action))
293
  obs = result.observation
294
+ reward = result.reward or 0.0
295
+ rewards.append(reward)
296
+ steps_taken += 1
297
+ log_step(step=steps_taken, action=f"flag_line:{line}", reward=reward,
298
+ done=result.done, error=None)
299
+ if result.done:
300
+ score = max(0.01, min(0.99, sum(r for r in rewards if r > 0) / max(1, len([r for r in rewards if r > 0])) if rewards else 0.01))
301
+ return score, steps_taken, rewards
302
  except Exception as e:
303
+ print(f"[DEBUG] flag_line failed: {e}", file=sys.stderr, flush=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
+ # Final step: Submit full review
306
+ if parsed:
307
+ action_dict = {
308
+ "action_type": "submit_review",
309
+ "issues": parsed.get("issues", []),
310
+ "flagged_lines": parsed.get("flagged_lines", []),
311
+ "suggestion": parsed.get("suggestion", ""),
312
+ "comment": parsed.get("comment", ""),
313
+ }
314
+ else:
315
+ action_dict = {
316
+ "action_type": "submit_review",
317
+ "issues": [],
318
+ "flagged_lines": [],
319
+ "suggestion": "",
320
+ "comment": "Unable to analyze the code.",
321
+ }
322
 
323
+ if not isinstance(action_dict.get("flagged_lines"), list):
324
+ action_dict["flagged_lines"] = []
325
+ action_dict["flagged_lines"] = [
326
+ int(x) for x in action_dict["flagged_lines"] if isinstance(x, (int, float))
327
+ ]
328
 
329
+ try:
330
+ action = CodeReviewAction(**action_dict)
331
+ except Exception as e:
332
+ print(f"[DEBUG] Action validation failed: {e}", file=sys.stderr, flush=True)
333
+ action = CodeReviewAction(action_type="submit_review")
334
+
335
+ try:
336
+ result = await _maybe_await(env.step(action))
337
+ obs = result.observation
338
+ reward = result.reward or 0.01
339
+ done = result.done
340
+ error = None
341
+ except Exception as e:
342
+ print(f"[DEBUG] env.step() failed: {e}", file=sys.stderr, flush=True)
343
+ reward = 0.01
344
+ done = True
345
+ error = str(e)
346
+
347
+ rewards.append(reward)
348
+ steps_taken += 1
349
+
350
+ log_step(
351
+ step=steps_taken,
352
+ action=action_to_str(action_dict),
353
+ reward=reward,
354
+ done=done,
355
+ error=error,
356
+ )
357
+
358
+ # Score is the final submit_review reward (the main grading)
359
+ score = max(0.01, min(0.99, reward))
360
  return score, steps_taken, rewards
361
 
362
 
363
  # ─── Main ────────────────────────────────────────────────────────────────────
364
 
365
  async def main() -> int:
 
 
366
  llm_client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY)
367
 
368
+ # Connectivity check
 
 
 
369
  try:
370
+ print("[DEBUG] Testing LiteLLM proxy...", file=sys.stderr, flush=True)
371
+ test = llm_client.chat.completions.create(
372
  model=MODEL_NAME,
373
  messages=[{"role": "user", "content": "ping"}],
374
+ max_tokens=5, temperature=0.0,
 
 
 
 
 
 
 
375
  )
376
+ print(f"[DEBUG] Proxy OK — response={test.choices[0].message.content!r}", file=sys.stderr, flush=True)
377
  except Exception as e:
378
+ print(f"[WARNING] Proxy test failed: {e}. Continuing.", file=sys.stderr, flush=True)
 
 
 
 
 
379
 
380
  scores = {}
381
  space_url = os.getenv("SPACE_URL", "https://ragavrida-code-review-env.hf.space")
382
 
383
+ # Run all three difficulty tiers
384
+ for difficulty in ["easy", "medium", "hard"]:
385
+ task_name = f"code-review-{difficulty}"
386
  score = 0.01
387
  steps_taken = 0
388
  rewards: List[float] = []
389
  success = False
390
  env = None
391
 
 
392
  log_start(task=task_name, env=BENCHMARK, model=MODEL_NAME)
393
 
394
  try:
 
 
395
  if IMAGE_NAME:
396
+ print(f"[DEBUG] Docker image: {IMAGE_NAME}", file=sys.stderr, flush=True)
397
  env = await CodeReviewEnv.from_docker_image(IMAGE_NAME)
398
  else:
399
+ print(f"[DEBUG] Server: {space_url}", file=sys.stderr, flush=True)
400
  env = CodeReviewEnv(base_url=space_url)
401
 
402
+ score, steps_taken, rewards = await run_task(env, llm_client, difficulty)
403
  success = score >= SUCCESS_SCORE_THRESHOLD
404
 
405
  except Exception as e:
406
+ print(f"[ERROR] Task {difficulty} failed: {e}", file=sys.stderr, flush=True)
407
  import traceback
408
  traceback.print_exc(file=sys.stderr)
409
 
410
  finally:
 
 
411
  score = min(max(score, 0.01), 0.99)
412
  log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
413
 
 
419
  except Exception as e:
420
  print(f"[DEBUG] env.close() error: {e}", file=sys.stderr, flush=True)
421
 
422
+ scores[difficulty] = score
423
 
424
  composite = sum(scores.values()) / max(len(scores), 1)
425
+ print(
426
+ f"\n[SUMMARY] composite={composite:.3f} "
427
+ f"easy={scores.get('easy',0):.3f} "
428
+ f"medium={scores.get('medium',0):.3f} "
429
+ f"hard={scores.get('hard',0):.3f}",
430
+ file=sys.stderr, flush=True,
431
+ )
432
 
433
  return 0
434
 
models.py CHANGED
@@ -1,11 +1,13 @@
1
  """
2
  CodeReviewEnv — OpenEnv-compliant typed models.
3
 
4
- All models inherit from openenv.core.env_server base types
5
- to ensure full compatibility with the OpenEnv framework.
 
 
 
6
 
7
- Action, Observation, State are Pydantic BaseModel subclasses
8
- with automatic serialization and validation.
9
  """
10
 
11
  from typing import Any, Dict, List, Optional
@@ -19,32 +21,42 @@ from openenv.core.env_server.types import Action, Observation, State
19
 
20
 
21
  class CodeReviewAction(Action):
22
- """Action space for all three CodeReviewEnv tasks.
23
 
24
- Easy: action_type="label_severity", severity="critical"|"high"|"medium"|"low"|"none"
25
- Medium: action_type="prioritize", priority_order=["PR-001", "PR-002", ...]
26
- Hard: action_type="add_comment"|"approve"|"request_changes"
 
 
27
  """
28
 
29
  model_config = ConfigDict(extra="forbid")
30
 
31
  action_type: str = Field(
32
- ..., description="One of: label_severity, prioritize, add_comment, approve, request_changes"
 
33
  )
34
- severity: Optional[str] = Field(
35
- default=None, description="Severity label for easy task"
 
 
36
  )
37
- priority_order: Optional[List[str]] = Field(
38
- default=None, description="Ordered list of PR IDs for medium task"
 
 
39
  )
40
- comment: Optional[str] = Field(
41
- default=None, description="Review comment text for hard task"
 
42
  )
43
- target_file: Optional[str] = Field(
44
- default=None, description="File path the comment targets"
 
45
  )
46
- target_line: Optional[int] = Field(
47
- default=None, description="Line number the comment targets"
 
48
  )
49
 
50
 
@@ -55,31 +67,36 @@ class CodeReviewObservation(Observation):
55
  """Observation returned after reset() and step().
56
 
57
  Inherits done, reward, metadata from openenv Observation.
58
- Adds code-review-specific fields.
 
59
  """
60
 
61
  model_config = ConfigDict(extra="allow")
62
 
63
- pr_id: str = Field(default="", description="Pull request identifier")
64
- title: str = Field(default="", description="PR title")
65
- description: str = Field(default="", description="PR description")
66
- author_experience: str = Field(default="", description="junior|mid|senior")
67
- files: List[Dict[str, Any]] = Field(
68
- default_factory=list, description="List of changed files with diffs"
69
  )
70
- existing_comments: List[str] = Field(
71
- default_factory=list, description="Previous review comments"
 
 
 
72
  )
73
- review_queue: List[str] = Field(
74
- default_factory=list, description="Queue of PR IDs (medium task)"
 
75
  )
76
- step_number: int = Field(default=0, description="Current step in episode")
77
- episode_budget: int = Field(default=5, description="Steps remaining")
78
- reward_breakdown: Optional[Dict[str, float]] = Field(
79
- default=None, description="Detailed reward component breakdown"
80
  )
81
- info: Optional[Dict[str, Any]] = Field(
82
- default=None, description="Grader info and ground truth"
 
83
  )
84
 
85
 
@@ -87,21 +104,20 @@ class CodeReviewObservation(Observation):
87
 
88
 
89
  class CodeReviewState(State):
90
- """Extended state for CodeReviewEnv.
91
 
92
- Inherits episode_id, step_count from openenv State.
93
- Adds task tracking and trajectory history.
 
94
  """
95
 
96
- task: str = Field(default="easy", description="Current task difficulty")
97
- seed: int = Field(default=42, description="Random seed for reproducibility")
98
- reviewed_prs: List[str] = Field(
99
- default_factory=list, description="PRs already reviewed"
100
- )
101
- pending_prs: List[str] = Field(
102
- default_factory=list, description="PRs remaining in episode"
103
- )
104
- total_reward: float = Field(default=0.0, description="Cumulative episode reward")
105
- trajectory: List[Dict[str, Any]] = Field(
106
- default_factory=list, description="Full (s,a,r,s') trajectory"
107
  )
 
 
 
 
 
1
  """
2
  CodeReviewEnv — OpenEnv-compliant typed models.
3
 
4
+ Multi-step action space for code review:
5
+ - analyze: inspect the code, get initial analysis (free action)
6
+ - flag_line: flag a specific line as buggy (intermediate reward)
7
+ - request_hint: get a hint about a bug (-0.05 penalty per hint)
8
+ - submit_review: submit final structured review (full grading, ends episode)
9
 
10
+ Observations expose buggy code; gold answers stay hidden in State.
 
11
  """
12
 
13
  from typing import Any, Dict, List, Optional
 
21
 
22
 
23
  class CodeReviewAction(Action):
24
+ """Multi-step action space for code review.
25
 
26
+ Action types:
27
+ analyze — request deeper analysis of the code (step_reward = 0)
28
+ flag_line — flag a specific line as buggy (intermediate reward if correct)
29
+ request_hint — get a hint (-0.05 efficiency penalty)
30
+ submit_review — submit final review (full 5-signal grading, ends episode)
31
  """
32
 
33
  model_config = ConfigDict(extra="forbid")
34
 
35
  action_type: str = Field(
36
+ default="submit_review",
37
+ description="One of: analyze, flag_line, request_hint, submit_review",
38
  )
39
+ # Fields for flag_line
40
+ line: Optional[int] = Field(
41
+ default=None,
42
+ description="Line number to flag (for flag_line action)",
43
  )
44
+ # Fields for submit_review
45
+ issues: List[str] = Field(
46
+ default_factory=list,
47
+ description="Descriptions of bugs found in the code",
48
  )
49
+ flagged_lines: List[int] = Field(
50
+ default_factory=list,
51
+ description="Line numbers the agent believes contain bugs",
52
  )
53
+ suggestion: str = Field(
54
+ default="",
55
+ description="Suggested fix — code patch or description",
56
  )
57
+ comment: str = Field(
58
+ default="",
59
+ description="Natural-language review comment",
60
  )
61
 
62
 
 
67
  """Observation returned after reset() and step().
68
 
69
  Inherits done, reward, metadata from openenv Observation.
70
+ Adds the buggy code and episode context.
71
+ Gold answers are NEVER exposed here — they live in State only.
72
  """
73
 
74
  model_config = ConfigDict(extra="allow")
75
 
76
+ code: str = Field(default="", description="Buggy source code to review")
77
+ language: str = Field(default="python", description="Programming language")
78
+ difficulty: str = Field(default="easy", description="easy | medium | hard")
79
+ instructions: str = Field(
80
+ default="Review the code. Report bugs, flagged lines, and a suggested fix.",
81
+ description="Task instructions for the agent",
82
  )
83
+ step_number: int = Field(default=0, description="Current step in episode")
84
+ episode_budget: int = Field(default=5, description="Max steps in this episode")
85
+ hint: Optional[str] = Field(
86
+ default=None,
87
+ description="Hint text (if agent requested one)",
88
  )
89
+ flagged_so_far: List[int] = Field(
90
+ default_factory=list,
91
+ description="Lines flagged in previous steps (for multi-step tracking)",
92
  )
93
+ analysis: Optional[str] = Field(
94
+ default=None,
95
+ description="Analysis text from analyze action",
 
96
  )
97
+ reward_breakdown: Optional[Dict[str, float]] = Field(
98
+ default=None,
99
+ description="Per-signal reward breakdown for analysis",
100
  )
101
 
102
 
 
104
 
105
 
106
  class CodeReviewState(State):
107
+ """Full environment state includes gold answers (hidden from agent).
108
 
109
+ The gold_bugs list contains the injected bugs with their descriptions,
110
+ affected lines, fixes, and types. This is used for grading and is
111
+ NEVER sent to the agent in observations.
112
  """
113
 
114
+ original_code: str = Field(default="", description="Clean code before injection")
115
+ buggy_code: str = Field(default="", description="Code with injected bugs")
116
+ gold_bugs: List[Dict[str, Any]] = Field(
117
+ default_factory=list,
118
+ description="Injected bugs: [{description, lines, fix, bug_type}, ...]",
 
 
 
 
 
 
119
  )
120
+ language: str = Field(default="python", description="Source language")
121
+ difficulty: str = Field(default="easy", description="Difficulty tier")
122
+ hint_count: int = Field(default=0, description="Hints requested (costs reward)")
123
+ snippet_name: str = Field(default="", description="Which snippet was used")
openenv.yaml CHANGED
@@ -1,66 +1,16 @@
 
1
  name: code-review-env
2
- version: "1.0.0"
3
- description: >
4
- A Semantic MDP environment for software code review.
5
- Agents review pull requests across three difficulty levels:
6
- severity labeling (easy), queue prioritization (medium),
7
- and feedback generation (hard). Deterministic graders
8
- enable reproducible research.
9
-
10
- entry_point: server.app:app
11
  port: 7860
 
 
 
 
12
 
13
- tasks:
14
- - name: easy
15
- description: Classify PR severity (none/low/medium/high/critical)
16
- episode_length: 5
17
- - name: medium
18
- description: Prioritize review queue by urgency
19
- episode_length: 3
20
- - name: hard
21
- description: Generate actionable review feedback for 3 PRs
22
- episode_length: 18
23
-
24
- action_schema:
25
- type: CodeReviewAction
26
- fields:
27
- action_type: str
28
- severity: Optional[str]
29
- priority_order: Optional[List[str]]
30
- comment: Optional[str]
31
- target_file: Optional[str]
32
- target_line: Optional[int]
33
-
34
- observation_schema:
35
- type: CodeReviewObservation
36
- fields:
37
- pr_id: str
38
- title: str
39
- description: str
40
- author_experience: str
41
- files: List[Dict]
42
- existing_comments: List[str]
43
- review_queue: List[str]
44
- done: bool
45
- reward: float
46
- step_number: int
47
- episode_budget: int
48
-
49
- state_schema:
50
- type: CodeReviewState
51
- fields:
52
- episode_id: str
53
- step_count: int
54
- task: str
55
- seed: int
56
- reviewed_prs: List[str]
57
- pending_prs: List[str]
58
- total_reward: float
59
- trajectory: List[Dict]
60
-
61
- metadata:
62
- domain: software-engineering
63
- formalism: semantic-mdp
64
- grading: deterministic
65
- reproducible: true
66
- seed: 42
 
1
+ spec_version: 1
2
  name: code-review-env
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
 
 
 
 
 
 
6
  port: 7860
7
+ supports_concurrent_sessions: true
8
+ difficulty_tiers: [easy, medium, hard]
9
+ languages: [python, javascript, go]
10
+ mcp_enabled: true
11
 
12
+ description: >
13
+ A Semantic MDP environment for automated code review.
14
+ Agents review procedurally-generated buggy code snippets,
15
+ identify bugs, flag lines, suggest fixes, and write comments.
16
+ 5-signal shaped reward with difficulty tiers.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
reward.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reward Computation — Multi-signal shaped reward for code review quality.
3
+
4
+ Five normalized signals combined with fixed weights:
5
+ bug_detection (0.40) — did the agent find the injected bugs?
6
+ fix_quality (0.25) — how close is the suggestion to the gold fix?
7
+ line_precision (0.15) — F1 score of flagged lines vs gold lines
8
+ comment_quality (0.10) — is the natural-language comment helpful?
9
+ efficiency (0.10) — penalty for excessive steps / hint usage
10
+
11
+ All sub-signals are normalized to [0, 1], then weighted.
12
+ The final reward is in [0, 1] with difficulty multiplier for hard tasks.
13
+
14
+ No LLM calls — all scoring is deterministic via string matching,
15
+ difflib similarity, and F1 computation.
16
+ """
17
+
18
+ import difflib
19
+ import re
20
+ from typing import Dict, List, Tuple
21
+
22
+ from snippet_bank import BugRecord
23
+
24
+
25
+ # ─── Signal Weights ──────────────────────────────────────────────────────────
26
+
27
+ WEIGHTS = {
28
+ "bug_detection": 0.40,
29
+ "fix_quality": 0.25,
30
+ "line_precision": 0.15,
31
+ "comment_quality": 0.10,
32
+ "efficiency": 0.10,
33
+ }
34
+
35
+ # Keywords that indicate the agent understands the bug
36
+ BUG_TYPE_KEYWORDS = {
37
+ "off_by_one": ["off-by-one", "off by one", "boundary", "fence", "<=", ">=", "<", ">", "range", "index"],
38
+ "null_deref": ["null", "none", "nil", "undefined", "guard", "check", "dereference", "missing check"],
39
+ "wrong_operator": ["operator", "wrong", "+", "-", "*", "/", "swap", "arithmetic"],
40
+ "unused_var": ["unused", "dead", "shadow", "shadowed", "unreachable", "redundant"],
41
+ "logic_inversion": ["invert", "flip", "wrong", "opposite", "and", "or", "boolean", "==", "!="],
42
+ # AST-based injector types (same keyword families but more precise)
43
+ "ast_comparison_flip": ["comparison", "operator", "<", "<=", ">", ">=", "==", "!=", "boundary", "condition"],
44
+ "ast_binop_swap": ["operator", "arithmetic", "+", "-", "*", "//", "swap", "wrong"],
45
+ "ast_boolop_flip": ["boolean", "and", "or", "logic", "condition", "flip"],
46
+ "ast_return_negate": ["return", "wrong value", "negated", "inverted", "True", "False", "0", "1", "-1"],
47
+ }
48
+
49
+ # Keywords that indicate actionable comments
50
+ ACTIONABLE_KEYWORDS = [
51
+ "use", "replace", "add", "remove", "consider", "should", "instead",
52
+ "refactor", "fix", "change", "wrap", "guard", "check", "validate",
53
+ "ensure", "avoid", "handle", "return", "throw",
54
+ ]
55
+
56
+ # Difficulty multipliers for reward scaling
57
+ DIFFICULTY_MULTIPLIER = {
58
+ "easy": 1.0,
59
+ "medium": 1.0,
60
+ "hard": 1.5,
61
+ }
62
+
63
+
64
+ # ─── Individual Signal Functions ─────────────────────────────────────────────
65
+
66
+ def _bug_overlap(issues: List[str], gold_bugs: List[BugRecord]) -> float:
67
+ """Score bug detection: what fraction of gold bugs did the agent identify?
68
+
69
+ For each gold bug, check if any reported issue mentions the bug type
70
+ keywords or describes the same bug. Uses fuzzy keyword matching.
71
+
72
+ Returns: float in [0, 1]
73
+ """
74
+ if not gold_bugs:
75
+ return 1.0 if not issues else 0.5 # No bugs to find
76
+
77
+ if not issues:
78
+ return 0.0
79
+
80
+ matched = 0
81
+ issues_lower = [iss.lower() for iss in issues]
82
+
83
+ for bug in gold_bugs:
84
+ keywords = BUG_TYPE_KEYWORDS.get(bug.bug_type, [])
85
+ bug_desc_lower = bug.description.lower()
86
+
87
+ # Check if any issue mentions relevant keywords
88
+ found = False
89
+ for iss in issues_lower:
90
+ # Keyword match
91
+ kw_hits = sum(1 for kw in keywords if kw in iss)
92
+ if kw_hits >= 1:
93
+ found = True
94
+ break
95
+ # Fuzzy description match
96
+ similarity = difflib.SequenceMatcher(None, iss, bug_desc_lower).ratio()
97
+ if similarity > 0.3:
98
+ found = True
99
+ break
100
+ # Line number mention
101
+ for line in bug.lines:
102
+ if str(line) in iss:
103
+ found = True
104
+ break
105
+ if found:
106
+ break
107
+
108
+ if found:
109
+ matched += 1
110
+
111
+ return matched / len(gold_bugs)
112
+
113
+
114
+ def _fix_similarity(suggestion: str, gold_bugs: List[BugRecord]) -> float:
115
+ """Score fix quality: how close is the suggestion to the gold fixes?
116
+
117
+ Uses difflib SequenceMatcher for text similarity, plus keyword overlap.
118
+
119
+ Returns: float in [0, 1]
120
+ """
121
+ if not gold_bugs:
122
+ return 1.0 if not suggestion else 0.5
123
+
124
+ if not suggestion:
125
+ return 0.0
126
+
127
+ suggestion_lower = suggestion.lower()
128
+ similarities = []
129
+
130
+ for bug in gold_bugs:
131
+ fix_lower = bug.fix.lower()
132
+
133
+ # Text similarity
134
+ text_sim = difflib.SequenceMatcher(None, suggestion_lower, fix_lower).ratio()
135
+
136
+ # Keyword overlap
137
+ fix_words = set(re.findall(r'\w+', fix_lower))
138
+ suggestion_words = set(re.findall(r'\w+', suggestion_lower))
139
+ if fix_words:
140
+ keyword_overlap = len(fix_words & suggestion_words) / len(fix_words)
141
+ else:
142
+ keyword_overlap = 0.0
143
+
144
+ # Combined score
145
+ similarities.append(0.6 * text_sim + 0.4 * keyword_overlap)
146
+
147
+ return max(similarities) if similarities else 0.0
148
+
149
+
150
+ def _line_f1(flagged_lines: List[int], gold_bugs: List[BugRecord], tolerance: int = 3) -> float:
151
+ """Score line-level precision: F1 score of flagged lines vs gold lines.
152
+
153
+ Tolerance: a flagged line within ±3 of a gold line counts as a hit.
154
+
155
+ Returns: float in [0, 1]
156
+ """
157
+ gold_lines = set()
158
+ for bug in gold_bugs:
159
+ gold_lines.update(bug.lines)
160
+
161
+ if not gold_lines and not flagged_lines:
162
+ return 1.0
163
+ if not gold_lines:
164
+ return 0.0 if flagged_lines else 1.0
165
+ if not flagged_lines:
166
+ return 0.0
167
+
168
+ # True positives: flagged lines near gold lines
169
+ tp = 0
170
+ matched_gold = set()
171
+ for fl in flagged_lines:
172
+ for gl in gold_lines:
173
+ if abs(fl - gl) <= tolerance and gl not in matched_gold:
174
+ tp += 1
175
+ matched_gold.add(gl)
176
+ break
177
+
178
+ precision = tp / len(flagged_lines) if flagged_lines else 0.0
179
+ recall = tp / len(gold_lines) if gold_lines else 0.0
180
+
181
+ if precision + recall == 0:
182
+ return 0.0
183
+
184
+ f1 = 2 * (precision * recall) / (precision + recall)
185
+ return f1
186
+
187
+
188
+ def _comment_score(comment: str) -> float:
189
+ """Score comment quality: length, specificity, and actionability.
190
+
191
+ Heuristic-based scoring:
192
+ - Length: comments < 10 chars get 0.0, 10-50 chars get partial, 50+ full
193
+ - Actionability: presence of suggestion keywords
194
+ - Specificity: mentions line numbers, variable names, or code constructs
195
+
196
+ Returns: float in [0, 1]
197
+ """
198
+ if not comment:
199
+ return 0.0
200
+
201
+ score = 0.0
202
+ comment_lower = comment.lower()
203
+
204
+ # Length component (0-0.3)
205
+ if len(comment) >= 50:
206
+ score += 0.3
207
+ elif len(comment) >= 20:
208
+ score += 0.2
209
+ elif len(comment) >= 10:
210
+ score += 0.1
211
+
212
+ # Actionability (0-0.4)
213
+ action_hits = sum(1 for kw in ACTIONABLE_KEYWORDS if kw in comment_lower)
214
+ score += min(0.4, action_hits * 0.1)
215
+
216
+ # Specificity (0-0.3)
217
+ specificity = 0.0
218
+ # Mentions line numbers
219
+ if re.search(r'line\s*\d+', comment_lower):
220
+ specificity += 0.15
221
+ # Mentions code constructs
222
+ if re.search(r'`[^`]+`|"[^"]+"|\b(function|variable|method|class|loop|condition)\b', comment_lower):
223
+ specificity += 0.15
224
+ score += min(0.3, specificity)
225
+
226
+ return min(1.0, score)
227
+
228
+
229
+ def _efficiency_score(step_count: int, hint_count: int = 0) -> float:
230
+ """Score efficiency: penalize excessive steps and hint usage.
231
+
232
+ Each hint costs 0.05 from efficiency.
233
+ More steps = lower efficiency.
234
+
235
+ Returns: float in [0, 1]
236
+ """
237
+ step_penalty = max(0.0, 1.0 - step_count / 10.0)
238
+ hint_penalty = max(0.0, 1.0 - hint_count * 0.1)
239
+ return min(step_penalty, hint_penalty)
240
+
241
+
242
+ # ─── Main Reward Function ───────────────────────────────────────────────────
243
+
244
+ def compute_reward(
245
+ issues: List[str],
246
+ flagged_lines: List[int],
247
+ suggestion: str,
248
+ comment: str,
249
+ gold_bugs: List[BugRecord],
250
+ step_count: int = 1,
251
+ hint_count: int = 0,
252
+ difficulty: str = "easy",
253
+ ) -> Tuple[float, Dict[str, float]]:
254
+ """Compute the shaped, multi-signal reward for a code review.
255
+
256
+ Args:
257
+ issues: Bug descriptions submitted by the agent
258
+ flagged_lines: Line numbers the agent flagged
259
+ suggestion: Suggested fix text
260
+ comment: Natural-language review comment
261
+ gold_bugs: Ground truth bugs from injection
262
+ step_count: Number of steps taken this episode
263
+ hint_count: Number of hints requested (costs reward)
264
+ difficulty: Difficulty tier for reward scaling
265
+
266
+ Returns:
267
+ (total_reward, breakdown_dict) where total_reward ∈ [0, 1]
268
+ and breakdown_dict has per-signal scores.
269
+ """
270
+ signals = {
271
+ "bug_detection": _bug_overlap(issues, gold_bugs),
272
+ "fix_quality": _fix_similarity(suggestion, gold_bugs),
273
+ "line_precision": _line_f1(flagged_lines, gold_bugs),
274
+ "comment_quality": _comment_score(comment),
275
+ "efficiency": _efficiency_score(step_count, hint_count),
276
+ }
277
+
278
+ # Weighted sum
279
+ raw_reward = sum(WEIGHTS[k] * signals[k] for k in WEIGHTS)
280
+
281
+ # Difficulty multiplier (hard tasks can earn up to 1.5x on bug_detection)
282
+ multiplier = DIFFICULTY_MULTIPLIER.get(difficulty, 1.0)
283
+ if multiplier > 1.0:
284
+ # Apply multiplier only to bug_detection signal
285
+ bonus = (multiplier - 1.0) * WEIGHTS["bug_detection"] * signals["bug_detection"]
286
+ raw_reward += bonus
287
+ signals["difficulty_bonus"] = bonus
288
+
289
+ # Clamp to [0, 1]
290
+ total = max(0.0, min(1.0, raw_reward))
291
+
292
+ # Build breakdown
293
+ breakdown = {k: round(v, 4) for k, v in signals.items()}
294
+ breakdown["weighted_total"] = round(total, 4)
295
+
296
+ return total, breakdown
server/app.py CHANGED
@@ -1,25 +1,27 @@
1
  """
2
- FastAPI application for CodeReviewEnv — uses openenv create_app().
3
 
4
- This automatically creates all required endpoints:
5
  /ws — WebSocket for persistent sessions
6
- /health — HTTP GET health check (enhanced with task info)
7
  /reset — HTTP POST reset environment
8
  /step — HTTP POST take action
9
  /state — HTTP GET current state
10
- /export_trajectory — GET trajectory export (JSONL)
11
  /docs — OpenAPI documentation
12
- /web — Interactive web UI (when enabled)
13
 
14
- Usage:
15
- # Development:
16
- uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
 
 
17
 
18
- # Production:
19
- uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
20
  """
21
 
22
  import json
 
 
23
  from fastapi import Query
24
  from fastapi.responses import JSONResponse, PlainTextResponse
25
 
@@ -28,11 +30,8 @@ from openenv.core.env_server import create_app
28
  from server.code_review_environment import CodeReviewEnvironment
29
  from models import CodeReviewAction, CodeReviewObservation
30
 
31
- # create_app takes:
32
- # env: factory callable -> Environment instance
33
- # action_cls: the Action subclass
34
- # observation_cls: the Observation subclass
35
- # env_name: used for web UI title
36
  app = create_app(
37
  CodeReviewEnvironment,
38
  CodeReviewAction,
@@ -45,75 +44,128 @@ app = create_app(
45
 
46
  @app.get("/health")
47
  async def health():
48
- """Enhanced health check with task info for judges."""
49
  return {
50
  "status": "ok",
51
  "environment": "CodeReviewEnv",
52
- "version": "1.0.0",
53
- "tasks": [
54
- "bug_severity_labeling",
55
- "queue_prioritization",
56
- "multi_turn_review",
 
 
 
 
57
  ],
58
- "task_count": 3,
59
  "grader": "deterministic",
60
- "trajectory_export": True,
 
61
  }
62
 
63
 
64
- # ─── Trajectory export endpoint ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
 
66
- # In-memory trajectory store (per-session)
67
  _trajectory_store: dict = {}
68
 
69
 
70
  @app.get("/export_trajectory")
71
  async def export_trajectory(
72
- session_id: str = Query(default="latest", description="Session/episode ID"),
73
- format: str = Query(default="jsonl", description="Export format: jsonl or json"),
74
  ):
75
- """Export episode trajectory as JSONL for MBRL research.
76
-
77
- Each line is a (s, a, r, s', done) transition:
78
- {"state": {...}, "action": "...", "reward": 0.75, "next_state": {...}, "done": false}
79
-
80
- Usage:
81
- GET /export_trajectory?session_id=latest
82
- GET /export_trajectory?session_id=abc123&format=json
83
- """
84
- # Get the current env instance's trajectory
85
  trajectory = _trajectory_store.get(session_id, [])
86
-
87
  if not trajectory:
88
- # Try to get from the most recent episode
89
  return JSONResponse(
90
- content={
91
- "message": "No trajectory found. Run reset() + step() first.",
92
- "session_id": session_id,
93
- "available_sessions": list(_trajectory_store.keys()),
94
- },
95
  status_code=404,
96
  )
97
-
98
  if format == "json":
99
  return JSONResponse(content={"session_id": session_id, "transitions": trajectory})
100
-
101
- # JSONL format
102
  lines = [json.dumps(t) for t in trajectory]
103
  return PlainTextResponse(content="\n".join(lines), media_type="application/jsonl")
104
 
105
 
106
- def store_transition(session_id: str, transition: dict):
107
- """Store a transition for later export. Called from CodeReviewEnvironment.step()."""
108
- if session_id not in _trajectory_store:
109
- _trajectory_store[session_id] = []
110
- _trajectory_store[session_id].append(transition)
111
-
112
-
113
- def clear_trajectory(session_id: str):
114
- """Clear trajectory for a session. Called from CodeReviewEnvironment.reset()."""
115
- _trajectory_store[session_id] = []
116
-
117
 
118
  def main():
119
  """Entry point for direct execution."""
 
1
  """
2
+ FastAPI application for CodeReviewEnv — OpenEnv create_app() + MCP tools.
3
 
4
+ Endpoints (via OpenEnv framework):
5
  /ws — WebSocket for persistent sessions
6
+ /health — HTTP GET health check
7
  /reset — HTTP POST reset environment
8
  /step — HTTP POST take action
9
  /state — HTTP GET current state
 
10
  /docs — OpenAPI documentation
 
11
 
12
+ MCP Tools (via FastMCP):
13
+ get_code_snippet — returns current buggy code + metadata
14
+ submit_review accepts structured review, returns reward
15
+ request_hint — returns a hint (costs -0.05 reward)
16
+ get_state — returns episode state summary
17
 
18
+ Usage:
19
+ uvicorn server.app:app --host 0.0.0.0 --port 7860
20
  """
21
 
22
  import json
23
+ from typing import Dict, List, Optional
24
+
25
  from fastapi import Query
26
  from fastapi.responses import JSONResponse, PlainTextResponse
27
 
 
30
  from server.code_review_environment import CodeReviewEnvironment
31
  from models import CodeReviewAction, CodeReviewObservation
32
 
33
+ # ─── Create OpenEnv app with concurrent session support ─────────────────────
34
+
 
 
 
35
  app = create_app(
36
  CodeReviewEnvironment,
37
  CodeReviewAction,
 
44
 
45
  @app.get("/health")
46
  async def health():
47
+ """Enhanced health check for judges and orchestrators."""
48
  return {
49
  "status": "ok",
50
  "environment": "CodeReviewEnv",
51
+ "version": "2.0.0",
52
+ "difficulty_tiers": ["easy", "medium", "hard"],
53
+ "languages": ["python", "javascript", "go"],
54
+ "reward_signals": [
55
+ "bug_detection",
56
+ "fix_quality",
57
+ "line_precision",
58
+ "comment_quality",
59
+ "efficiency",
60
  ],
 
61
  "grader": "deterministic",
62
+ "mcp_enabled": True,
63
+ "supports_concurrent_sessions": True,
64
  }
65
 
66
 
67
+ # ─── MCP Tool Endpoints ─────────────────────────────────────────────────────
68
+ # These provide tool-calling style interaction alongside the standard
69
+ # reset/step API. Agents can use MCP tools for richer interaction.
70
+
71
+ # Per-session environment instances for MCP
72
+ _mcp_sessions: Dict[str, CodeReviewEnvironment] = {}
73
+
74
+
75
+ def _get_mcp_env(session_id: str) -> CodeReviewEnvironment:
76
+ """Get or create an environment for an MCP session."""
77
+ if session_id not in _mcp_sessions:
78
+ _mcp_sessions[session_id] = CodeReviewEnvironment()
79
+ return _mcp_sessions[session_id]
80
+
81
+
82
+ @app.post("/mcp/reset")
83
+ async def mcp_reset(
84
+ session_id: str = Query(default="default"),
85
+ seed: Optional[int] = Query(default=None),
86
+ difficulty: str = Query(default="easy"),
87
+ ):
88
+ """MCP: Reset the environment for a new episode."""
89
+ env = _get_mcp_env(session_id)
90
+ obs = env.reset(seed=seed, difficulty=difficulty)
91
+ return {
92
+ "session_id": session_id,
93
+ "code": obs.code,
94
+ "language": obs.language,
95
+ "difficulty": obs.difficulty,
96
+ "instructions": obs.instructions,
97
+ }
98
+
99
+
100
+ @app.post("/mcp/get_code_snippet")
101
+ async def mcp_get_code_snippet(session_id: str = Query(default="default")):
102
+ """MCP tool: Get the current buggy code snippet for review."""
103
+ env = _get_mcp_env(session_id)
104
+ return env.get_code_snippet()
105
+
106
+
107
+ @app.post("/mcp/submit_review")
108
+ async def mcp_submit_review(
109
+ session_id: str = Query(default="default"),
110
+ issues: List[str] = Query(default=[]),
111
+ flagged_lines: List[int] = Query(default=[]),
112
+ suggestion: str = Query(default=""),
113
+ comment: str = Query(default=""),
114
+ ):
115
+ """MCP tool: Submit a code review. Returns reward and done signal."""
116
+ env = _get_mcp_env(session_id)
117
+ action = CodeReviewAction(
118
+ issues=issues,
119
+ flagged_lines=flagged_lines,
120
+ suggestion=suggestion,
121
+ comment=comment,
122
+ )
123
+ obs = env.step(action)
124
+ return {
125
+ "reward": obs.reward,
126
+ "done": obs.done,
127
+ "breakdown": obs.reward_breakdown,
128
+ }
129
+
130
+
131
+ @app.post("/mcp/request_hint")
132
+ async def mcp_request_hint(session_id: str = Query(default="default")):
133
+ """MCP tool: Request a hint. Costs -0.05 reward."""
134
+ env = _get_mcp_env(session_id)
135
+ return env.request_hint()
136
+
137
+
138
+ @app.get("/mcp/get_state")
139
+ async def mcp_get_state(session_id: str = Query(default="default")):
140
+ """MCP tool: Get current episode state summary."""
141
+ env = _get_mcp_env(session_id)
142
+ return env.get_state_summary()
143
+
144
+
145
+ # ─── Trajectory export ──────────────────────────────────────────────────────
146
 
 
147
  _trajectory_store: dict = {}
148
 
149
 
150
  @app.get("/export_trajectory")
151
  async def export_trajectory(
152
+ session_id: str = Query(default="latest"),
153
+ format: str = Query(default="jsonl"),
154
  ):
155
+ """Export episode trajectory as JSONL for MBRL research."""
 
 
 
 
 
 
 
 
 
156
  trajectory = _trajectory_store.get(session_id, [])
 
157
  if not trajectory:
 
158
  return JSONResponse(
159
+ content={"message": "No trajectory found.", "session_id": session_id},
 
 
 
 
160
  status_code=404,
161
  )
 
162
  if format == "json":
163
  return JSONResponse(content={"session_id": session_id, "transitions": trajectory})
 
 
164
  lines = [json.dumps(t) for t in trajectory]
165
  return PlainTextResponse(content="\n".join(lines), media_type="application/jsonl")
166
 
167
 
168
+ # ─── Entry point ─────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
169
 
170
  def main():
171
  """Entry point for direct execution."""
server/code_review_environment.py CHANGED
@@ -1,15 +1,18 @@
1
  """
2
- CodeReviewEnvironment — OpenEnv-compliant RL environment for code review.
3
 
4
- Inherits from openenv.core.env_server.Environment and implements the
5
- standard reset() / step() / state API. Runs as a FastAPI server inside
6
- Docker; agents interact via HTTP/WebSocket through a typed client.
 
 
 
7
 
8
- This is a Semantic Markov Decision Process (S-MDP) where:
9
- - States are PR diffs + review context (text)
10
- - Actions are review decisions (labels, orderings, comments)
11
- - Transitions are deterministic (next PR in queue)
12
- - Rewards are computed by deterministic graders
13
  """
14
 
15
  from typing import Any, Dict, List, Optional
@@ -19,76 +22,73 @@ from openenv.core.env_server import Environment
19
  from openenv.core.env_server.types import EnvironmentMetadata
20
 
21
  from models import CodeReviewAction, CodeReviewObservation, CodeReviewState
22
- from env.data_generator import DataGenerator, PR_TEMPLATES, get_ground_truth, _build_observation
23
- from graders.grader_easy import EasyGrader
24
- from graders.grader_medium import MediumGrader
25
- from graders.grader_hard import HardGrader
26
- from tasks.task_easy import EasyTask
27
- from tasks.task_medium import MediumTask
28
- from tasks.task_hard import HardTask
29
 
30
 
31
  class CodeReviewEnvironment(
32
  Environment[CodeReviewAction, CodeReviewObservation, CodeReviewState]
33
  ):
34
- """OpenEnv-compliant code review RL environment.
35
 
36
- Three difficulty levels easy (severity labeling), medium (queue
37
- prioritization), hard (feedback generation) each with deterministic
38
- graders and exploit-prevention penalties.
 
 
39
 
40
- Usage via OpenEnv client:
41
- async with CodeReviewEnv(base_url="http://localhost:8000") as env:
42
- result = await env.reset(seed=42)
43
- result = await env.step(CodeReviewAction(action_type="label_severity", severity="high"))
44
  """
45
 
46
  SUPPORTS_CONCURRENT_SESSIONS = True
47
 
48
- def __init__(self, task: str = "easy", seed: int = 42):
49
  super().__init__()
50
- self.task_name = task
51
- self.seed = seed
52
- self._episode_id = str(uuid4())
53
  self._step_count = 0
54
  self._total_reward = 0.0
 
 
55
  self._trajectory: List[Dict[str, Any]] = []
56
- self._reviewed_prs: List[str] = []
57
- self._current_obs: Optional[CodeReviewObservation] = None
58
-
59
- # Initialize task + grader and auto-reset to valid state
60
- self._init_task(task, seed)
61
- self._auto_reset(task, seed)
62
-
63
- def _init_task(self, task: str, seed: int) -> None:
64
- """Initialize the task and grader for the given difficulty."""
65
- if task == "easy":
66
- self.task = EasyTask(seed=seed)
67
- self.grader = EasyGrader()
68
- elif task == "medium":
69
- self.task = MediumTask(seed=seed)
70
- self.grader = MediumGrader()
71
- elif task == "hard":
72
- self.task = HardTask(seed=seed)
73
- self.grader = HardGrader()
74
- else:
75
- raise ValueError(f"Unknown task: {task}. Must be easy|medium|hard")
76
-
77
- def _auto_reset(self, task: str, seed: int) -> None:
78
- """Auto-reset to ensure environment starts in a valid state.
79
-
80
- Called from __init__ so that even without an explicit reset(),
81
- the environment has episode data loaded for step().
82
- """
83
  self._step_count = 0
84
  self._total_reward = 0.0
 
 
85
  self._trajectory = []
86
- self._reviewed_prs = []
87
- self.grader.reset()
88
- internal_obs = self.task.reset()
89
- self._current_obs = self._convert_observation(internal_obs, done=False, reward=None)
90
 
91
- # ─── OpenEnv API ─────────────────────────────────────────────────────
 
 
 
 
 
 
 
92
 
93
  def reset(
94
  self,
@@ -96,35 +96,31 @@ class CodeReviewEnvironment(
96
  episode_id: Optional[str] = None,
97
  **kwargs: Any,
98
  ) -> CodeReviewObservation:
99
- """Reset the environment and return the initial observation.
100
-
101
- Args:
102
- seed: Random seed for reproducible episodes
103
- episode_id: Custom episode identifier
104
- **kwargs: May include 'task' to change difficulty
105
-
106
- Returns:
107
- CodeReviewObservation with the first PR to review
108
- """
109
- # Allow changing task on reset
110
- task = kwargs.get("task", self.task_name)
111
- actual_seed = seed if seed is not None else self.seed
112
-
113
- self.task_name = task
114
- self.seed = actual_seed
115
  self._episode_id = episode_id or str(uuid4())
116
  self._step_count = 0
117
  self._total_reward = 0.0
 
118
  self._trajectory = []
119
- self._reviewed_prs = []
 
 
 
120
 
121
- self._init_task(task, actual_seed)
122
- self.grader.reset()
 
 
 
 
123
 
124
- # Get initial observation from task
125
- internal_obs = self.task.reset()
126
- self._current_obs = self._convert_observation(internal_obs, done=False, reward=None)
127
- return self._current_obs
128
 
129
  def step(
130
  self,
@@ -132,254 +128,293 @@ class CodeReviewEnvironment(
132
  timeout_s: Optional[float] = None,
133
  **kwargs: Any,
134
  ) -> CodeReviewObservation:
135
- """Execute one step in the environment.
136
-
137
- Args:
138
- action: CodeReviewAction with the agent's decision
139
- timeout_s: Optional timeout (unused)
140
-
141
- Returns:
142
- CodeReviewObservation with next PR, reward, and done flag
143
- """
144
- from env.models import Action as InternalAction
145
-
146
- # Convert OpenEnv action to internal action
147
- internal_action = InternalAction(
148
- action_type=action.action_type,
149
- severity=action.severity,
150
- priority_order=action.priority_order,
151
- comment=action.comment,
152
- target_file=action.target_file,
153
- target_line=action.target_line,
154
- )
155
-
156
- # Grade the action
157
- reward_value, reward_breakdown, info, done = self._grade_action(
158
- internal_action, self._step_count
159
- )
160
-
161
- # Record trajectory
162
- prev_obs = self._current_obs
163
- self._trajectory.append({
164
- "step": self._step_count,
165
- "observation": prev_obs.model_dump() if prev_obs else {},
166
- "action": action.model_dump(),
167
- "reward": reward_value,
168
- "info": info,
169
- })
170
 
171
- self._total_reward += reward_value
172
  self._step_count += 1
173
-
174
- # Determine done: for hard task, delegate to task's is_done()
175
- if self.task_name == "hard":
176
- done = done or self.task.is_done()
 
 
 
 
 
 
177
  else:
178
- done = done or self._step_count >= self._get_episode_length()
179
-
180
- # Get next observation
181
- if not done:
182
- next_internal_obs = self.task.get_observation(self._step_count)
183
- self._current_obs = self._convert_observation(
184
- next_internal_obs,
185
- done=False,
186
- reward=reward_value,
187
- reward_breakdown=reward_breakdown,
188
- info=info,
189
- )
190
- else:
191
- done = True
192
- # Return final observation with done=True
193
- try:
194
- final_obs = self.task.get_observation(self._step_count)
195
- except Exception:
196
- final_obs = self.task.get_observation(
197
- max(0, self._step_count - 1)
198
- )
199
- self._current_obs = self._convert_observation(
200
- final_obs,
201
- done=True,
202
- reward=reward_value,
203
- reward_breakdown=reward_breakdown,
204
- info=info,
205
- )
206
-
207
- # Track reviewed PRs
208
- if hasattr(self.task, 'get_current_pr_id'):
209
- try:
210
- pr_id = self.task.get_current_pr_id(
211
- self._step_count - 1 if self.task_name != "hard" else None
212
- )
213
- except TypeError:
214
- pr_id = self.task.get_current_pr_id()
215
- if pr_id not in self._reviewed_prs:
216
- self._reviewed_prs.append(pr_id)
217
-
218
- return self._current_obs
219
 
220
  @property
221
  def state(self) -> CodeReviewState:
222
- """Get the current environment state."""
223
  return CodeReviewState(
224
  episode_id=self._episode_id,
225
  step_count=self._step_count,
226
- task=self.task_name,
227
- seed=self.seed,
228
- reviewed_prs=list(self._reviewed_prs),
229
- pending_prs=[],
230
- total_reward=self._total_reward,
231
- trajectory=list(self._trajectory),
 
 
 
 
232
  )
233
 
234
  def get_metadata(self) -> EnvironmentMetadata:
235
- """Return environment metadata for the OpenEnv framework."""
236
  return EnvironmentMetadata(
237
  name="CodeReviewEnv",
238
  description=(
239
- "A Semantic MDP environment for code review. "
240
- "Agents review pull requests across three difficulty levels: "
241
- "severity labeling (easy), queue prioritization (medium), "
242
- "and feedback generation (hard)."
243
  ),
244
- version="1.0.0",
245
  author="CodeReviewEnv Team",
246
  )
247
 
248
- # ─── Internal helpers ────────────────────────────────────────────────
249
-
250
- def _get_episode_length(self) -> int:
251
- """Get the episode length for the current task."""
252
- if self.task_name == "easy":
253
- return 5
254
- elif self.task_name == "medium":
255
- return 3
256
- elif self.task_name == "hard":
257
- return 18 # Max steps (3 PRs × 6 actions max: 5 comments + 1 decision)
258
- return 5
259
-
260
- def _grade_action(self, action, step: int):
261
- """Grade an action using the appropriate grader."""
262
- if self.task_name == "easy":
263
- return self._grade_easy(action, step)
264
- elif self.task_name == "medium":
265
- return self._grade_medium(action, step)
266
- elif self.task_name == "hard":
267
- return self._grade_hard(action, step)
268
- return 0.0, {}, {}, True
269
-
270
- def _grade_easy(self, action, step: int):
271
- """Grade severity labeling action."""
272
- pr_id = self.task.get_current_pr_id(step)
273
- reward_obj, info = self.grader.grade(action, pr_id)
274
- info["ground_truth"] = self.task.get_ground_truth(step)
275
- done = (step + 1) >= self.task.EPISODE_LENGTH
276
- return reward_obj.value, reward_obj.breakdown, info, done
277
-
278
- def _grade_medium(self, action, step: int):
279
- """Grade queue prioritization action."""
280
- queue_templates = self.task.get_queue_templates(step)
281
- gt_order = self.task.get_ground_truth_order(step)
282
- reward_obj, info = self.grader.grade(action, queue_templates, gt_order)
283
- done = (step + 1) >= self.task.EPISODE_LENGTH
284
- return reward_obj.value, reward_obj.breakdown, info, done
285
-
286
- def _grade_hard(self, action, step: int):
287
- """Grade feedback generation action.
288
-
289
- Hard task has per-PR multi-step grading:
290
- - add_comment: accumulates comments, returns decaying ack reward
291
- - approve/request_changes: triggers full PR grading via grade_pr()
292
-
293
- Anti-exploit: consecutive comments get decaying reward (0.05 → 0.03 → 0.01)
294
- to prevent the spam-then-decide loop.
295
- """
296
- pr_id = self.task.get_current_pr_id()
297
-
298
- if action.action_type == "add_comment":
299
- # Accumulate comment for later scoring
300
- self.grader.add_comment(pr_id, action)
301
- self.grader.consecutive_comments += 1
302
-
303
- # Process action — updates task state (comment count increments)
304
- self.task.process_action(action.action_type)
305
-
306
- # Decaying ack reward: penalize consecutive comments without decision
307
- base_ack = 0.05
308
- spam_penalty = 0.02 * max(0, self.grader.consecutive_comments - 1)
309
- ack_reward = max(0.01, base_ack - spam_penalty)
310
-
311
- info = {
312
- "comment_added": True,
313
- "pr_id": pr_id,
314
- "comments_so_far": self.task.comments_on_current_pr,
315
- "consecutive_comments": self.grader.consecutive_comments,
316
- }
317
- done = self.task.is_done()
318
-
319
- # IMPORTANT: Rebuild observation so comment count updates in state
320
- # (fixes frozen observation bug — Problem 3)
321
- if not done:
322
- next_obs = self.task.get_observation(self._step_count)
323
- self._current_obs = self._convert_observation(
324
- next_obs, done=False, reward=ack_reward,
325
- reward_breakdown={"comment_ack": ack_reward},
326
- info=info,
327
- )
328
-
329
- return ack_reward, {"comment_ack": ack_reward}, info, done
330
-
331
- elif action.action_type in ("approve", "request_changes"):
332
- # Reset consecutive comment counter on decision
333
- self.grader.consecutive_comments = 0
334
-
335
- # Score all accumulated comments + decision
336
- reward_obj, info = self.grader.grade_pr(pr_id, action.action_type)
337
- # Advance to next PR
338
- self.task.process_action(action.action_type)
339
- done = self.task.is_done()
340
- return reward_obj.value, reward_obj.breakdown, info, done
341
 
 
 
 
 
 
 
342
  else:
343
- # Invalid action for hard task penalize
344
- info = {"error": f"Invalid action type: {action.action_type}"}
345
- return 0.01, {"invalid_action": -0.01}, info, False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
346
 
347
- def _convert_observation(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  self,
349
- internal_obs,
350
  done: bool,
351
- reward: Optional[float] = None,
352
- reward_breakdown: Optional[Dict] = None,
353
- info: Optional[Dict] = None,
354
  ) -> CodeReviewObservation:
355
- """Convert an internal Observation to a CodeReviewObservation."""
356
  return CodeReviewObservation(
357
  done=done,
358
  reward=reward,
359
  metadata={
360
- "task": self.task_name,
361
  "episode_id": self._episode_id,
362
  "step": self._step_count,
 
 
363
  },
364
- pr_id=internal_obs.pr_id,
365
- title=internal_obs.title,
366
- description=internal_obs.description,
367
- author_experience=internal_obs.author_experience,
368
- files=[f.model_dump() if hasattr(f, 'model_dump') else f for f in internal_obs.files],
369
- existing_comments=internal_obs.existing_comments,
370
- review_queue=internal_obs.review_queue,
371
- step_number=internal_obs.step_number,
372
- episode_budget=internal_obs.episode_budget,
373
- reward_breakdown=reward_breakdown,
374
- info=info,
 
 
 
 
 
 
 
375
  )
376
 
377
- # ─── Compatibility methods ───────────────────────────────────────────
378
-
379
- def get_system_prompt(self) -> str:
380
- """Get the system prompt for LLM agents."""
381
- return self.task.get_system_prompt()
 
 
 
 
382
 
383
  def export_trajectory(self) -> List[Dict]:
384
- """Export full trajectory for MBRL research."""
385
  return list(self._trajectory)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """
2
+ CodeReviewEnvironment — OpenEnv-compliant multi-step RL environment for code review.
3
 
4
+ Multi-step MDP design:
5
+ reset() → observe buggy code (done=False)
6
+ step(analyze) → get structural analysis of the code (free, done=False)
7
+ step(flag_line) → flag a line as buggy; immediate reward if correct (done=False)
8
+ step(request_hint) → get a progressive hint; costs -0.05 efficiency (done=False)
9
+ step(submit_review) → full 5-signal grading; incorporates all prior flags (done=True)
10
 
11
+ Episode ends on submit_review OR after max_steps (5).
12
+ If agent hits max_steps without submitting, a forced grading uses accumulated flags.
13
+
14
+ Procedural generation: every seed produces a unique episode via
15
+ snippet bank + AST/regex bug injectors.
16
  """
17
 
18
  from typing import Any, Dict, List, Optional
 
22
  from openenv.core.env_server.types import EnvironmentMetadata
23
 
24
  from models import CodeReviewAction, CodeReviewObservation, CodeReviewState
25
+ from snippet_bank import generate_episode, BugRecord
26
+ from reward import compute_reward, _line_f1
27
+
28
+ MAX_STEPS = 5
29
+ LINE_TOLERANCE = 3
 
 
30
 
31
 
32
  class CodeReviewEnvironment(
33
  Environment[CodeReviewAction, CodeReviewObservation, CodeReviewState]
34
  ):
35
+ """OpenEnv-compliant multi-step code review RL environment.
36
 
37
+ Agents can take up to 5 actions per episode:
38
+ analyze free structural analysis
39
+ flag_line — intermediate line-flagging with immediate reward
40
+ request_hint — progressive hints with efficiency cost
41
+ submit_review — final grading across 5 signals
42
 
43
+ This is a genuine multi-step MDP where earlier decisions (which lines
44
+ to flag, whether to request hints) affect the final reward.
 
 
45
  """
46
 
47
  SUPPORTS_CONCURRENT_SESSIONS = True
48
 
49
+ def __init__(self, **kwargs: Any):
50
  super().__init__()
51
+ self._episode_id = ""
 
 
52
  self._step_count = 0
53
  self._total_reward = 0.0
54
+ self._difficulty = "easy"
55
+ self._hint_count = 0
56
  self._trajectory: List[Dict[str, Any]] = []
57
+ self._flagged_lines: List[int] = []
58
+ self._analysis_text: Optional[str] = None
59
+ self._last_hint: Optional[str] = None
60
+
61
+ # Gold state (hidden from agent)
62
+ self._original_code = ""
63
+ self._buggy_code = ""
64
+ self._gold_bugs: List[BugRecord] = []
65
+ self._language = "python"
66
+ self._snippet_name = ""
67
+ self._done = False
68
+
69
+ # Auto-reset
70
+ self._auto_reset(seed=42, difficulty="easy")
71
+
72
+ def _auto_reset(self, seed: int, difficulty: str) -> None:
73
+ self._episode_id = str(uuid4())
 
 
 
 
 
 
 
 
 
 
74
  self._step_count = 0
75
  self._total_reward = 0.0
76
+ self._difficulty = difficulty
77
+ self._hint_count = 0
78
  self._trajectory = []
79
+ self._flagged_lines = []
80
+ self._analysis_text = None
81
+ self._last_hint = None
82
+ self._done = False
83
 
84
+ snippet, buggy_code, gold_bugs = generate_episode(seed=seed, difficulty=difficulty)
85
+ self._original_code = snippet.code
86
+ self._buggy_code = buggy_code
87
+ self._gold_bugs = gold_bugs
88
+ self._language = snippet.language
89
+ self._snippet_name = snippet.name
90
+
91
+ # ─── OpenEnv API ─────────────────────────────────────────────────
92
 
93
  def reset(
94
  self,
 
96
  episode_id: Optional[str] = None,
97
  **kwargs: Any,
98
  ) -> CodeReviewObservation:
99
+ """Reset and return initial observation with buggy code."""
100
+ actual_seed = seed if seed is not None else 42
101
+ difficulty = kwargs.get("difficulty") or kwargs.get("task", self._difficulty)
102
+ if difficulty not in ("easy", "medium", "hard"):
103
+ difficulty = "easy"
104
+
105
+ self._difficulty = difficulty
 
 
 
 
 
 
 
 
 
106
  self._episode_id = episode_id or str(uuid4())
107
  self._step_count = 0
108
  self._total_reward = 0.0
109
+ self._hint_count = 0
110
  self._trajectory = []
111
+ self._flagged_lines = []
112
+ self._analysis_text = None
113
+ self._last_hint = None
114
+ self._done = False
115
 
116
+ snippet, buggy_code, gold_bugs = generate_episode(seed=actual_seed, difficulty=difficulty)
117
+ self._original_code = snippet.code
118
+ self._buggy_code = buggy_code
119
+ self._gold_bugs = gold_bugs
120
+ self._language = snippet.language
121
+ self._snippet_name = snippet.name
122
 
123
+ return self._build_observation(reward=0.0, done=False)
 
 
 
124
 
125
  def step(
126
  self,
 
128
  timeout_s: Optional[float] = None,
129
  **kwargs: Any,
130
  ) -> CodeReviewObservation:
131
+ """Execute one step. Supports 4 action types for multi-step review."""
132
+ if self._done:
133
+ return self._build_observation(reward=0.0, done=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
 
 
135
  self._step_count += 1
136
+ action_type = getattr(action, 'action_type', 'submit_review') or 'submit_review'
137
+
138
+ if action_type == "analyze":
139
+ return self._handle_analyze(action)
140
+ elif action_type == "flag_line":
141
+ return self._handle_flag_line(action)
142
+ elif action_type == "request_hint":
143
+ return self._handle_request_hint(action)
144
+ elif action_type == "submit_review":
145
+ return self._handle_submit_review(action)
146
  else:
147
+ # Unknown action type treat as submit_review
148
+ return self._handle_submit_review(action)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
 
150
  @property
151
  def state(self) -> CodeReviewState:
152
+ """Full environment state — includes gold answers for debugging."""
153
  return CodeReviewState(
154
  episode_id=self._episode_id,
155
  step_count=self._step_count,
156
+ original_code=self._original_code,
157
+ buggy_code=self._buggy_code,
158
+ gold_bugs=[
159
+ {"description": b.description, "lines": b.lines, "fix": b.fix, "bug_type": b.bug_type}
160
+ for b in self._gold_bugs
161
+ ],
162
+ language=self._language,
163
+ difficulty=self._difficulty,
164
+ hint_count=self._hint_count,
165
+ snippet_name=self._snippet_name,
166
  )
167
 
168
  def get_metadata(self) -> EnvironmentMetadata:
 
169
  return EnvironmentMetadata(
170
  name="CodeReviewEnv",
171
  description=(
172
+ "Multi-step Semantic MDP for code review. "
173
+ "Agents analyze code, flag buggy lines, request hints, "
174
+ "and submit structured reviews. 5-signal shaped reward."
 
175
  ),
176
+ version="2.0.0",
177
  author="CodeReviewEnv Team",
178
  )
179
 
180
+ # ─── Action Handlers ─────────────────────────────────────────────
181
+
182
+ def _handle_analyze(self, action: CodeReviewAction) -> CodeReviewObservation:
183
+ """Analyze action: provide structural analysis of the code (free)."""
184
+ lines = self._buggy_code.split('\n')
185
+ n_lines = len(lines)
186
+ n_functions = sum(1 for l in lines if l.strip().startswith(('def ', 'func ', 'function ')))
187
+ n_conditionals = sum(1 for l in lines if any(kw in l for kw in ('if ', 'elif ', 'else:', 'while ', 'for ')))
188
+
189
+ self._analysis_text = (
190
+ f"Code has {n_lines} lines, {n_functions} function(s), "
191
+ f"{n_conditionals} conditional/loop statement(s). "
192
+ f"Language: {self._language}. "
193
+ f"Look for boundary conditions, null checks, operator usage, and boolean logic."
194
+ )
195
+
196
+ reward = 0.0 # Free action — no reward or penalty
197
+ self._record_transition("analyze", reward, {})
198
+
199
+ if self._step_count >= MAX_STEPS:
200
+ return self._force_submit()
201
+
202
+ return self._build_observation(reward=reward, done=False)
203
+
204
+ def _handle_flag_line(self, action: CodeReviewAction) -> CodeReviewObservation:
205
+ """Flag a line as buggy. Immediate reward if within tolerance of a gold bug line."""
206
+ line = action.line
207
+ if line is None:
208
+ # Try flagged_lines as fallback
209
+ if action.flagged_lines:
210
+ line = action.flagged_lines[0]
211
+ else:
212
+ line = 0
213
+
214
+ reward = 0.0
215
+ breakdown = {}
216
+
217
+ if line > 0 and line not in self._flagged_lines:
218
+ self._flagged_lines.append(line)
219
+
220
+ # Check if this line is near any gold bug
221
+ hit = False
222
+ for bug in self._gold_bugs:
223
+ for bl in bug.lines:
224
+ if abs(line - bl) <= LINE_TOLERANCE:
225
+ hit = True
226
+ break
227
+ if hit:
228
+ break
229
+
230
+ if hit:
231
+ reward = 0.15 # Immediate positive signal for correct flag
232
+ breakdown["line_flag_hit"] = 0.15
233
+ else:
234
+ reward = -0.05 # Small penalty for false flag
235
+ breakdown["line_flag_miss"] = -0.05
236
+ else:
237
+ reward = 0.0
238
+ breakdown["duplicate_or_invalid"] = 0.0
239
+
240
+ self._total_reward += reward
241
+ self._record_transition("flag_line", reward, breakdown)
242
+
243
+ if self._step_count >= MAX_STEPS:
244
+ return self._force_submit()
245
+
246
+ return self._build_observation(reward=reward, done=False, breakdown=breakdown)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
+ def _handle_request_hint(self, action: CodeReviewAction) -> CodeReviewObservation:
249
+ """Request a hint. Costs efficiency penalty but helps find bugs."""
250
+ self._hint_count += 1
251
+
252
+ if not self._gold_bugs:
253
+ hint = "The code looks clean — no obvious bugs."
254
  else:
255
+ bug_idx = min(self._hint_count - 1, len(self._gold_bugs) - 1)
256
+ bug = self._gold_bugs[bug_idx]
257
+
258
+ if self._hint_count == 1:
259
+ hint = f"Look for a {bug.bug_type.replace('_', ' ')} bug in the code."
260
+ elif self._hint_count == 2:
261
+ hint = f"There's a {bug.bug_type.replace('_', ' ')} near line {bug.lines[0]}."
262
+ else:
263
+ hint = f"Bug on line {bug.lines[0]}: {bug.description}"
264
+
265
+ self._last_hint = hint
266
+
267
+ reward = 0.0 # No immediate reward, but costs efficiency at final grading
268
+ self._record_transition("request_hint", reward, {"hint_count": self._hint_count})
269
+
270
+ if self._step_count >= MAX_STEPS:
271
+ return self._force_submit()
272
+
273
+ return self._build_observation(reward=reward, done=False)
274
+
275
+ def _handle_submit_review(self, action: CodeReviewAction) -> CodeReviewObservation:
276
+ """Submit final review. Full 5-signal grading. Ends episode."""
277
+ # Merge any previously flagged lines into the submission
278
+ all_flagged = list(set(self._flagged_lines + (action.flagged_lines or [])))
279
+
280
+ total_reward, breakdown = compute_reward(
281
+ issues=action.issues or [],
282
+ flagged_lines=all_flagged,
283
+ suggestion=action.suggestion or "",
284
+ comment=action.comment or "",
285
+ gold_bugs=self._gold_bugs,
286
+ step_count=self._step_count,
287
+ hint_count=self._hint_count,
288
+ difficulty=self._difficulty,
289
+ )
290
+
291
+ self._total_reward += total_reward
292
+ self._done = True
293
+
294
+ self._record_transition("submit_review", total_reward, breakdown)
295
+
296
+ return self._build_observation(reward=total_reward, done=True, breakdown=breakdown)
297
 
298
+ def _force_submit(self) -> CodeReviewObservation:
299
+ """Auto-submit when max steps reached. Uses accumulated flags."""
300
+ total_reward, breakdown = compute_reward(
301
+ issues=[],
302
+ flagged_lines=self._flagged_lines,
303
+ suggestion="",
304
+ comment="",
305
+ gold_bugs=self._gold_bugs,
306
+ step_count=self._step_count,
307
+ hint_count=self._hint_count,
308
+ difficulty=self._difficulty,
309
+ )
310
+
311
+ self._total_reward += total_reward
312
+ self._done = True
313
+
314
+ self._record_transition("forced_submit", total_reward, breakdown)
315
+
316
+ return self._build_observation(reward=total_reward, done=True, breakdown=breakdown)
317
+
318
+ # ─── MCP Tool Methods ────────────────────────────────────────────
319
+
320
+ def get_code_snippet(self) -> Dict[str, Any]:
321
+ return {
322
+ "code": self._buggy_code,
323
+ "language": self._language,
324
+ "difficulty": self._difficulty,
325
+ "snippet_name": self._snippet_name,
326
+ "step_count": self._step_count,
327
+ "done": self._done,
328
+ }
329
+
330
+ def request_hint(self) -> Dict[str, Any]:
331
+ self._hint_count += 1
332
+ if not self._gold_bugs:
333
+ return {"hint": "No bugs found.", "hint_count": self._hint_count, "penalty": 0.05}
334
+ bug = self._gold_bugs[min(self._hint_count - 1, len(self._gold_bugs) - 1)]
335
+ if self._hint_count == 1:
336
+ hint = f"Look for a {bug.bug_type.replace('_', ' ')} bug."
337
+ elif self._hint_count == 2:
338
+ hint = f"Bug near line {bug.lines[0]}."
339
+ else:
340
+ hint = f"Line {bug.lines[0]}: {bug.description}"
341
+ return {"hint": hint, "hint_count": self._hint_count, "penalty": 0.05 * self._hint_count}
342
+
343
+ def get_state_summary(self) -> Dict[str, Any]:
344
+ return {
345
+ "episode_id": self._episode_id,
346
+ "step_count": self._step_count,
347
+ "max_steps": MAX_STEPS,
348
+ "difficulty": self._difficulty,
349
+ "language": self._language,
350
+ "hint_count": self._hint_count,
351
+ "flagged_lines": self._flagged_lines,
352
+ "done": self._done,
353
+ "total_reward": self._total_reward,
354
+ }
355
+
356
+ # ─── Internal helpers ────────────────────────────────────────────
357
+
358
+ def _build_observation(
359
  self,
360
+ reward: float,
361
  done: bool,
362
+ breakdown: Optional[Dict[str, float]] = None,
 
 
363
  ) -> CodeReviewObservation:
 
364
  return CodeReviewObservation(
365
  done=done,
366
  reward=reward,
367
  metadata={
 
368
  "episode_id": self._episode_id,
369
  "step": self._step_count,
370
+ "difficulty": self._difficulty,
371
+ "language": self._language,
372
  },
373
+ code=self._buggy_code,
374
+ language=self._language,
375
+ difficulty=self._difficulty,
376
+ instructions=(
377
+ "Review the code. You can:\n"
378
+ " analyze — get structural analysis (free)\n"
379
+ " flag_line — flag a line number as buggy (immediate feedback)\n"
380
+ " request_hint — get a hint (costs efficiency)\n"
381
+ " submit_review — submit final review (ends episode)\n"
382
+ f"Step {self._step_count}/{MAX_STEPS} | "
383
+ f"Language: {self._language} | Difficulty: {self._difficulty}"
384
+ ),
385
+ step_number=self._step_count,
386
+ episode_budget=MAX_STEPS - self._step_count,
387
+ hint=self._last_hint,
388
+ flagged_so_far=list(self._flagged_lines),
389
+ analysis=self._analysis_text,
390
+ reward_breakdown=breakdown,
391
  )
392
 
393
+ def _record_transition(self, action_type: str, reward: float, breakdown: Dict) -> None:
394
+ self._trajectory.append({
395
+ "step": self._step_count,
396
+ "action_type": action_type,
397
+ "reward": reward,
398
+ "breakdown": breakdown,
399
+ "flagged_lines": list(self._flagged_lines),
400
+ "hint_count": self._hint_count,
401
+ })
402
 
403
  def export_trajectory(self) -> List[Dict]:
 
404
  return list(self._trajectory)
405
+
406
+ def get_system_prompt(self) -> str:
407
+ return (
408
+ "You are a senior software engineer performing code review.\n"
409
+ "You will receive a code snippet that may contain bugs.\n\n"
410
+ "You have up to 5 actions per episode:\n"
411
+ " analyze — get structural analysis of the code\n"
412
+ " flag_line — flag a specific line as buggy (immediate feedback)\n"
413
+ " request_hint — get a hint about a bug (costs efficiency)\n"
414
+ " submit_review — submit your final review\n\n"
415
+ "For flag_line:\n"
416
+ ' {"action_type": "flag_line", "line": 7}\n\n'
417
+ "For submit_review:\n"
418
+ ' {"action_type": "submit_review", "issues": [...], '
419
+ '"flagged_lines": [...], "suggestion": "...", "comment": "..."}\n'
420
+ )
snippet_bank.py ADDED
@@ -0,0 +1,1836 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Snippet Bank — Procedural Episode Generator for CodeReviewEnv
3
+
4
+ Contains 35+ clean code snippets across Python, JavaScript, and Go,
5
+ plus 5 bug injectors that mutate clean code into buggy code.
6
+
7
+ Every reset() draws a fresh snippet, applies 1-3 random injectors,
8
+ and stores the gold answer in State. No static dataset — infinite
9
+ unique episodes from a finite snippet bank.
10
+
11
+ Injectors:
12
+ 1. off_by_one — flips < to <=, range(n) to range(n-1), etc.
13
+ 2. null_deref — removes a None/null/nil guard
14
+ 3. wrong_operator — swaps + for -, * for /, and↔or
15
+ 4. unused_var — inserts a dead variable shadowing a live one
16
+ 5. logic_inversion — flips True/False, ==/!=, and/or
17
+ """
18
+
19
+ import ast
20
+ import random
21
+ import re
22
+ import textwrap
23
+ from dataclasses import dataclass, field
24
+ from typing import Callable, Dict, List, Optional, Tuple
25
+
26
+
27
+ @dataclass
28
+ class Snippet:
29
+ """A clean, correct code snippet that can have bugs injected."""
30
+ name: str
31
+ language: str # python | javascript | go
32
+ difficulty: str # easy | medium | hard
33
+ code: str
34
+ description: str
35
+
36
+
37
+ @dataclass
38
+ class BugRecord:
39
+ """Record of an injected bug — stored in State as gold answer."""
40
+ description: str
41
+ lines: List[int]
42
+ fix: str
43
+ bug_type: str
44
+
45
+
46
+ # ─── Snippet Bank ────────────────────────────────────────────────────────────
47
+
48
+ SNIPPET_BANK: List[Snippet] = [
49
+ # ── Python — Easy ────────────────────────────────────────────────
50
+ Snippet(
51
+ name="binary_search",
52
+ language="python",
53
+ difficulty="easy",
54
+ code="""\
55
+ def binary_search(arr, target):
56
+ low, high = 0, len(arr) - 1
57
+ while low <= high:
58
+ mid = (low + high) // 2
59
+ if arr[mid] == target:
60
+ return mid
61
+ elif arr[mid] < target:
62
+ low = mid + 1
63
+ else:
64
+ high = mid - 1
65
+ return -1
66
+ """,
67
+ description="Binary search in a sorted array",
68
+ ),
69
+ Snippet(
70
+ name="fibonacci",
71
+ language="python",
72
+ difficulty="easy",
73
+ code="""\
74
+ def fibonacci(n):
75
+ if n <= 0:
76
+ return 0
77
+ if n == 1:
78
+ return 1
79
+ a, b = 0, 1
80
+ for i in range(2, n + 1):
81
+ a, b = b, a + b
82
+ return b
83
+ """,
84
+ description="Compute nth Fibonacci number iteratively",
85
+ ),
86
+ Snippet(
87
+ name="max_subarray",
88
+ language="python",
89
+ difficulty="easy",
90
+ code="""\
91
+ def max_subarray(nums):
92
+ if not nums:
93
+ return 0
94
+ current_sum = max_sum = nums[0]
95
+ for num in nums[1:]:
96
+ current_sum = max(num, current_sum + num)
97
+ max_sum = max(max_sum, current_sum)
98
+ return max_sum
99
+ """,
100
+ description="Kadane's algorithm for maximum subarray sum",
101
+ ),
102
+ Snippet(
103
+ name="is_palindrome",
104
+ language="python",
105
+ difficulty="easy",
106
+ code="""\
107
+ def is_palindrome(s):
108
+ cleaned = ''.join(c.lower() for c in s if c.isalnum())
109
+ left, right = 0, len(cleaned) - 1
110
+ while left < right:
111
+ if cleaned[left] != cleaned[right]:
112
+ return False
113
+ left += 1
114
+ right -= 1
115
+ return True
116
+ """,
117
+ description="Check if string is a palindrome",
118
+ ),
119
+ Snippet(
120
+ name="reverse_linked_list",
121
+ language="python",
122
+ difficulty="easy",
123
+ code="""\
124
+ def reverse_linked_list(head):
125
+ prev = None
126
+ current = head
127
+ while current is not None:
128
+ next_node = current.next
129
+ current.next = prev
130
+ prev = current
131
+ current = next_node
132
+ return prev
133
+ """,
134
+ description="Reverse a singly linked list in place",
135
+ ),
136
+ # ── Python — Medium ──────────────────────────────────────────────
137
+ Snippet(
138
+ name="merge_sort",
139
+ language="python",
140
+ difficulty="medium",
141
+ code="""\
142
+ def merge_sort(arr):
143
+ if len(arr) <= 1:
144
+ return arr
145
+ mid = len(arr) // 2
146
+ left = merge_sort(arr[:mid])
147
+ right = merge_sort(arr[mid:])
148
+ return merge(left, right)
149
+
150
+ def merge(left, right):
151
+ result = []
152
+ i = j = 0
153
+ while i < len(left) and j < len(right):
154
+ if left[i] <= right[j]:
155
+ result.append(left[i])
156
+ i += 1
157
+ else:
158
+ result.append(right[j])
159
+ j += 1
160
+ result.extend(left[i:])
161
+ result.extend(right[j:])
162
+ return result
163
+ """,
164
+ description="Merge sort with stable ordering",
165
+ ),
166
+ Snippet(
167
+ name="lru_cache",
168
+ language="python",
169
+ difficulty="medium",
170
+ code="""\
171
+ class LRUCache:
172
+ def __init__(self, capacity):
173
+ self.capacity = capacity
174
+ self.cache = {}
175
+ self.order = []
176
+
177
+ def get(self, key):
178
+ if key not in self.cache:
179
+ return -1
180
+ self.order.remove(key)
181
+ self.order.append(key)
182
+ return self.cache[key]
183
+
184
+ def put(self, key, value):
185
+ if key in self.cache:
186
+ self.order.remove(key)
187
+ elif len(self.cache) >= self.capacity:
188
+ oldest = self.order.pop(0)
189
+ del self.cache[oldest]
190
+ self.cache[key] = value
191
+ self.order.append(key)
192
+ """,
193
+ description="LRU cache with get/put operations",
194
+ ),
195
+ Snippet(
196
+ name="flatten_dict",
197
+ language="python",
198
+ difficulty="medium",
199
+ code="""\
200
+ def flatten_dict(d, parent_key='', sep='.'):
201
+ items = {}
202
+ for k, v in d.items():
203
+ new_key = f'{parent_key}{sep}{k}' if parent_key else k
204
+ if isinstance(v, dict) and v:
205
+ items.update(flatten_dict(v, new_key, sep))
206
+ else:
207
+ items[new_key] = v
208
+ return items
209
+ """,
210
+ description="Flatten a nested dictionary with dot-separated keys",
211
+ ),
212
+ Snippet(
213
+ name="validate_email",
214
+ language="python",
215
+ difficulty="medium",
216
+ code="""\
217
+ def validate_email(email):
218
+ if not email or not isinstance(email, str):
219
+ return False
220
+ parts = email.split('@')
221
+ if len(parts) != 2:
222
+ return False
223
+ local, domain = parts
224
+ if not local or not domain:
225
+ return False
226
+ if '.' not in domain:
227
+ return False
228
+ if domain.startswith('.') or domain.endswith('.'):
229
+ return False
230
+ if '..' in domain:
231
+ return False
232
+ return True
233
+ """,
234
+ description="Basic email validation without regex",
235
+ ),
236
+ Snippet(
237
+ name="dijkstra",
238
+ language="python",
239
+ difficulty="medium",
240
+ code="""\
241
+ import heapq
242
+
243
+ def dijkstra(graph, start):
244
+ distances = {node: float('inf') for node in graph}
245
+ distances[start] = 0
246
+ pq = [(0, start)]
247
+ visited = set()
248
+ while pq:
249
+ dist, node = heapq.heappop(pq)
250
+ if node in visited:
251
+ continue
252
+ visited.add(node)
253
+ for neighbor, weight in graph[node]:
254
+ new_dist = dist + weight
255
+ if new_dist < distances[neighbor]:
256
+ distances[neighbor] = new_dist
257
+ heapq.heappush(pq, (new_dist, neighbor))
258
+ return distances
259
+ """,
260
+ description="Dijkstra's shortest path algorithm",
261
+ ),
262
+ # ── Python — Hard ────────────────────────────────────────────────
263
+ Snippet(
264
+ name="rate_limiter",
265
+ language="python",
266
+ difficulty="hard",
267
+ code="""\
268
+ import time
269
+ from collections import defaultdict
270
+
271
+ class RateLimiter:
272
+ def __init__(self, max_requests, window_seconds):
273
+ self.max_requests = max_requests
274
+ self.window = window_seconds
275
+ self.requests = defaultdict(list)
276
+
277
+ def is_allowed(self, client_id):
278
+ now = time.time()
279
+ cutoff = now - self.window
280
+ self.requests[client_id] = [
281
+ t for t in self.requests[client_id] if t > cutoff
282
+ ]
283
+ if len(self.requests[client_id]) >= self.max_requests:
284
+ return False
285
+ self.requests[client_id].append(now)
286
+ return True
287
+
288
+ def remaining(self, client_id):
289
+ now = time.time()
290
+ cutoff = now - self.window
291
+ active = [t for t in self.requests[client_id] if t > cutoff]
292
+ return max(0, self.max_requests - len(active))
293
+ """,
294
+ description="Token bucket rate limiter with sliding window",
295
+ ),
296
+ Snippet(
297
+ name="trie",
298
+ language="python",
299
+ difficulty="hard",
300
+ code="""\
301
+ class TrieNode:
302
+ def __init__(self):
303
+ self.children = {}
304
+ self.is_end = False
305
+
306
+ class Trie:
307
+ def __init__(self):
308
+ self.root = TrieNode()
309
+
310
+ def insert(self, word):
311
+ node = self.root
312
+ for char in word:
313
+ if char not in node.children:
314
+ node.children[char] = TrieNode()
315
+ node = node.children[char]
316
+ node.is_end = True
317
+
318
+ def search(self, word):
319
+ node = self.root
320
+ for char in word:
321
+ if char not in node.children:
322
+ return False
323
+ node = node.children[char]
324
+ return node.is_end
325
+
326
+ def starts_with(self, prefix):
327
+ node = self.root
328
+ for char in prefix:
329
+ if char not in node.children:
330
+ return False
331
+ node = node.children[char]
332
+ return True
333
+ """,
334
+ description="Trie (prefix tree) with insert, search, starts_with",
335
+ ),
336
+ Snippet(
337
+ name="json_parser",
338
+ language="python",
339
+ difficulty="hard",
340
+ code="""\
341
+ def parse_json_value(s, pos):
342
+ if pos >= len(s):
343
+ raise ValueError("Unexpected end of input")
344
+ ch = s[pos]
345
+ if ch == '"':
346
+ return parse_string(s, pos)
347
+ if ch == '{':
348
+ return parse_object(s, pos)
349
+ if ch == '[':
350
+ return parse_array(s, pos)
351
+ if ch in '-0123456789':
352
+ return parse_number(s, pos)
353
+ if s[pos:pos+4] == 'true':
354
+ return True, pos + 4
355
+ if s[pos:pos+5] == 'false':
356
+ return False, pos + 5
357
+ if s[pos:pos+4] == 'null':
358
+ return None, pos + 4
359
+ raise ValueError(f"Unexpected character at {pos}: {ch}")
360
+
361
+ def parse_string(s, pos):
362
+ assert s[pos] == '"'
363
+ pos += 1
364
+ result = []
365
+ while pos < len(s) and s[pos] != '"':
366
+ if s[pos] == '\\\\':
367
+ pos += 1
368
+ result.append(s[pos])
369
+ else:
370
+ result.append(s[pos])
371
+ pos += 1
372
+ return ''.join(result), pos + 1
373
+
374
+ def parse_number(s, pos):
375
+ start = pos
376
+ if s[pos] == '-':
377
+ pos += 1
378
+ while pos < len(s) and s[pos].isdigit():
379
+ pos += 1
380
+ if pos < len(s) and s[pos] == '.':
381
+ pos += 1
382
+ while pos < len(s) and s[pos].isdigit():
383
+ pos += 1
384
+ return float(s[start:pos]), pos
385
+
386
+ def parse_array(s, pos):
387
+ assert s[pos] == '['
388
+ pos += 1
389
+ result = []
390
+ pos = skip_whitespace(s, pos)
391
+ if pos < len(s) and s[pos] == ']':
392
+ return result, pos + 1
393
+ while True:
394
+ pos = skip_whitespace(s, pos)
395
+ value, pos = parse_json_value(s, pos)
396
+ result.append(value)
397
+ pos = skip_whitespace(s, pos)
398
+ if pos < len(s) and s[pos] == ',':
399
+ pos += 1
400
+ else:
401
+ break
402
+ assert s[pos] == ']'
403
+ return result, pos + 1
404
+
405
+ def parse_object(s, pos):
406
+ assert s[pos] == '{'
407
+ pos += 1
408
+ result = {}
409
+ pos = skip_whitespace(s, pos)
410
+ if pos < len(s) and s[pos] == '}':
411
+ return result, pos + 1
412
+ while True:
413
+ pos = skip_whitespace(s, pos)
414
+ key, pos = parse_string(s, pos)
415
+ pos = skip_whitespace(s, pos)
416
+ assert s[pos] == ':'
417
+ pos += 1
418
+ pos = skip_whitespace(s, pos)
419
+ value, pos = parse_json_value(s, pos)
420
+ result[key] = value
421
+ pos = skip_whitespace(s, pos)
422
+ if pos < len(s) and s[pos] == ',':
423
+ pos += 1
424
+ else:
425
+ break
426
+ assert s[pos] == '}'
427
+ return result, pos + 1
428
+
429
+ def skip_whitespace(s, pos):
430
+ while pos < len(s) and s[pos] in ' \\t\\n\\r':
431
+ pos += 1
432
+ return pos
433
+ """,
434
+ description="Recursive descent JSON parser",
435
+ ),
436
+ Snippet(
437
+ name="thread_pool",
438
+ language="python",
439
+ difficulty="hard",
440
+ code="""\
441
+ import threading
442
+ from collections import deque
443
+
444
+ class ThreadPool:
445
+ def __init__(self, num_workers):
446
+ self.tasks = deque()
447
+ self.lock = threading.Lock()
448
+ self.condition = threading.Condition(self.lock)
449
+ self.shutdown_flag = False
450
+ self.workers = []
451
+ for _ in range(num_workers):
452
+ t = threading.Thread(target=self._worker, daemon=True)
453
+ t.start()
454
+ self.workers.append(t)
455
+
456
+ def submit(self, func, *args):
457
+ with self.condition:
458
+ if self.shutdown_flag:
459
+ raise RuntimeError("Pool is shut down")
460
+ self.tasks.append((func, args))
461
+ self.condition.notify()
462
+
463
+ def _worker(self):
464
+ while True:
465
+ with self.condition:
466
+ while not self.tasks and not self.shutdown_flag:
467
+ self.condition.wait()
468
+ if self.shutdown_flag and not self.tasks:
469
+ return
470
+ func, args = self.tasks.popleft()
471
+ func(*args)
472
+
473
+ def shutdown(self):
474
+ with self.condition:
475
+ self.shutdown_flag = True
476
+ self.condition.notify_all()
477
+ for w in self.workers:
478
+ w.join()
479
+ """,
480
+ description="Simple thread pool with task queue and graceful shutdown",
481
+ ),
482
+ Snippet(
483
+ name="auth_handler",
484
+ language="python",
485
+ difficulty="hard",
486
+ code="""\
487
+ import hashlib
488
+ import hmac
489
+ import time
490
+
491
+ TOKENS = {}
492
+
493
+ def hash_password(password, salt):
494
+ return hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000).hex()
495
+
496
+ def create_token(user_id, secret, expires_in=3600):
497
+ payload = f"{user_id}:{int(time.time()) + expires_in}"
498
+ signature = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
499
+ token = f"{payload}:{signature}"
500
+ TOKENS[token] = user_id
501
+ return token
502
+
503
+ def validate_token(token, secret):
504
+ if not token or ':' not in token:
505
+ return None
506
+ parts = token.rsplit(':', 1)
507
+ if len(parts) != 2:
508
+ return None
509
+ payload, signature = parts
510
+ expected = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
511
+ if not hmac.compare_digest(signature, expected):
512
+ return None
513
+ user_id, expires = payload.split(':', 1)
514
+ if int(expires) < int(time.time()):
515
+ return None
516
+ return user_id
517
+ """,
518
+ description="Token-based authentication with HMAC signing",
519
+ ),
520
+
521
+ # ── JavaScript — Easy ────────────────────────────────────────────
522
+ Snippet(
523
+ name="array_flatten",
524
+ language="javascript",
525
+ difficulty="easy",
526
+ code="""\
527
+ function flatten(arr) {
528
+ const result = [];
529
+ for (let i = 0; i < arr.length; i++) {
530
+ if (Array.isArray(arr[i])) {
531
+ const nested = flatten(arr[i]);
532
+ for (let j = 0; j < nested.length; j++) {
533
+ result.push(nested[j]);
534
+ }
535
+ } else {
536
+ result.push(arr[i]);
537
+ }
538
+ }
539
+ return result;
540
+ }
541
+ """,
542
+ description="Recursively flatten a nested array",
543
+ ),
544
+ Snippet(
545
+ name="debounce",
546
+ language="javascript",
547
+ difficulty="easy",
548
+ code="""\
549
+ function debounce(func, delay) {
550
+ let timer = null;
551
+ return function(...args) {
552
+ if (timer !== null) {
553
+ clearTimeout(timer);
554
+ }
555
+ timer = setTimeout(() => {
556
+ func.apply(this, args);
557
+ timer = null;
558
+ }, delay);
559
+ };
560
+ }
561
+ """,
562
+ description="Debounce function for rate-limiting calls",
563
+ ),
564
+ Snippet(
565
+ name="deep_clone",
566
+ language="javascript",
567
+ difficulty="easy",
568
+ code="""\
569
+ function deepClone(obj) {
570
+ if (obj === null || typeof obj !== 'object') {
571
+ return obj;
572
+ }
573
+ if (Array.isArray(obj)) {
574
+ return obj.map(item => deepClone(item));
575
+ }
576
+ const clone = {};
577
+ for (const key in obj) {
578
+ if (obj.hasOwnProperty(key)) {
579
+ clone[key] = deepClone(obj[key]);
580
+ }
581
+ }
582
+ return clone;
583
+ }
584
+ """,
585
+ description="Deep clone an object without JSON.parse",
586
+ ),
587
+ Snippet(
588
+ name="event_emitter",
589
+ language="javascript",
590
+ difficulty="easy",
591
+ code="""\
592
+ class EventEmitter {
593
+ constructor() {
594
+ this.listeners = {};
595
+ }
596
+ on(event, callback) {
597
+ if (!this.listeners[event]) {
598
+ this.listeners[event] = [];
599
+ }
600
+ this.listeners[event].push(callback);
601
+ }
602
+ off(event, callback) {
603
+ if (!this.listeners[event]) return;
604
+ this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
605
+ }
606
+ emit(event, ...args) {
607
+ if (!this.listeners[event]) return;
608
+ for (const cb of this.listeners[event]) {
609
+ cb(...args);
610
+ }
611
+ }
612
+ }
613
+ """,
614
+ description="Simple event emitter with on/off/emit",
615
+ ),
616
+
617
+ # ── JavaScript — Medium ──────────────────────────────────────────
618
+ Snippet(
619
+ name="promise_all",
620
+ language="javascript",
621
+ difficulty="medium",
622
+ code="""\
623
+ function promiseAll(promises) {
624
+ return new Promise((resolve, reject) => {
625
+ if (promises.length === 0) {
626
+ resolve([]);
627
+ return;
628
+ }
629
+ const results = new Array(promises.length);
630
+ let completed = 0;
631
+ for (let i = 0; i < promises.length; i++) {
632
+ Promise.resolve(promises[i]).then(value => {
633
+ results[i] = value;
634
+ completed += 1;
635
+ if (completed === promises.length) {
636
+ resolve(results);
637
+ }
638
+ }).catch(reject);
639
+ }
640
+ });
641
+ }
642
+ """,
643
+ description="Implement Promise.all from scratch",
644
+ ),
645
+ Snippet(
646
+ name="throttle",
647
+ language="javascript",
648
+ difficulty="medium",
649
+ code="""\
650
+ function throttle(func, limit) {
651
+ let lastRun = 0;
652
+ let timer = null;
653
+ return function(...args) {
654
+ const now = Date.now();
655
+ const remaining = limit - (now - lastRun);
656
+ if (remaining <= 0) {
657
+ if (timer !== null) {
658
+ clearTimeout(timer);
659
+ timer = null;
660
+ }
661
+ lastRun = now;
662
+ func.apply(this, args);
663
+ } else if (timer === null) {
664
+ timer = setTimeout(() => {
665
+ lastRun = Date.now();
666
+ timer = null;
667
+ func.apply(this, args);
668
+ }, remaining);
669
+ }
670
+ };
671
+ }
672
+ """,
673
+ description="Throttle function with trailing call",
674
+ ),
675
+ Snippet(
676
+ name="virtual_dom_diff",
677
+ language="javascript",
678
+ difficulty="medium",
679
+ code="""\
680
+ function diff(oldNode, newNode) {
681
+ if (oldNode === null) {
682
+ return { type: 'CREATE', node: newNode };
683
+ }
684
+ if (newNode === null) {
685
+ return { type: 'REMOVE' };
686
+ }
687
+ if (typeof oldNode !== typeof newNode) {
688
+ return { type: 'REPLACE', node: newNode };
689
+ }
690
+ if (typeof oldNode === 'string') {
691
+ if (oldNode !== newNode) {
692
+ return { type: 'REPLACE', node: newNode };
693
+ }
694
+ return null;
695
+ }
696
+ if (oldNode.tag !== newNode.tag) {
697
+ return { type: 'REPLACE', node: newNode };
698
+ }
699
+ const childPatches = [];
700
+ const maxLen = Math.max(
701
+ oldNode.children.length,
702
+ newNode.children.length
703
+ );
704
+ for (let i = 0; i < maxLen; i++) {
705
+ const patch = diff(
706
+ oldNode.children[i] || null,
707
+ newNode.children[i] || null
708
+ );
709
+ childPatches.push(patch);
710
+ }
711
+ return { type: 'UPDATE', children: childPatches };
712
+ }
713
+ """,
714
+ description="Virtual DOM diff algorithm",
715
+ ),
716
+ Snippet(
717
+ name="middleware_chain",
718
+ language="javascript",
719
+ difficulty="medium",
720
+ code="""\
721
+ function createMiddlewareChain(middlewares) {
722
+ return function(req, res) {
723
+ let index = 0;
724
+ function next(err) {
725
+ if (err) {
726
+ res.status(500).send(err.message);
727
+ return;
728
+ }
729
+ if (index >= middlewares.length) {
730
+ return;
731
+ }
732
+ const middleware = middlewares[index];
733
+ index += 1;
734
+ try {
735
+ middleware(req, res, next);
736
+ } catch (e) {
737
+ next(e);
738
+ }
739
+ }
740
+ next();
741
+ };
742
+ }
743
+ """,
744
+ description="Express-style middleware chain executor",
745
+ ),
746
+
747
+ # ── JavaScript — Hard ────────────────────────────────────────────
748
+ Snippet(
749
+ name="reactive_state",
750
+ language="javascript",
751
+ difficulty="hard",
752
+ code="""\
753
+ function createReactiveState(initialState) {
754
+ const subscribers = new Map();
755
+ let state = { ...initialState };
756
+
757
+ function subscribe(key, callback) {
758
+ if (!subscribers.has(key)) {
759
+ subscribers.set(key, new Set());
760
+ }
761
+ subscribers.get(key).add(callback);
762
+ return () => subscribers.get(key).delete(callback);
763
+ }
764
+
765
+ function setState(updates) {
766
+ const changed = [];
767
+ for (const [key, value] of Object.entries(updates)) {
768
+ if (state[key] !== value) {
769
+ state[key] = value;
770
+ changed.push(key);
771
+ }
772
+ }
773
+ for (const key of changed) {
774
+ if (subscribers.has(key)) {
775
+ for (const cb of subscribers.get(key)) {
776
+ cb(state[key], key);
777
+ }
778
+ }
779
+ }
780
+ }
781
+
782
+ function getState(key) {
783
+ if (key !== undefined) {
784
+ return state[key];
785
+ }
786
+ return { ...state };
787
+ }
788
+
789
+ return { subscribe, setState, getState };
790
+ }
791
+ """,
792
+ description="Reactive state management with subscriptions",
793
+ ),
794
+
795
+ # ── Go — Easy ────────────────────────────────────────────────────
796
+ Snippet(
797
+ name="stack",
798
+ language="go",
799
+ difficulty="easy",
800
+ code="""\
801
+ package stack
802
+
803
+ type Stack struct {
804
+ items []int
805
+ }
806
+
807
+ func (s *Stack) Push(item int) {
808
+ s.items = append(s.items, item)
809
+ }
810
+
811
+ func (s *Stack) Pop() (int, bool) {
812
+ if len(s.items) == 0 {
813
+ return 0, false
814
+ }
815
+ last := len(s.items) - 1
816
+ item := s.items[last]
817
+ s.items = s.items[:last]
818
+ return item, true
819
+ }
820
+
821
+ func (s *Stack) Peek() (int, bool) {
822
+ if len(s.items) == 0 {
823
+ return 0, false
824
+ }
825
+ return s.items[len(s.items)-1], true
826
+ }
827
+
828
+ func (s *Stack) Size() int {
829
+ return len(s.items)
830
+ }
831
+ """,
832
+ description="Generic integer stack with push/pop/peek",
833
+ ),
834
+ Snippet(
835
+ name="string_reverse",
836
+ language="go",
837
+ difficulty="easy",
838
+ code="""\
839
+ package stringutil
840
+
841
+ func Reverse(s string) string {
842
+ runes := []rune(s)
843
+ for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
844
+ runes[i], runes[j] = runes[j], runes[i]
845
+ }
846
+ return string(runes)
847
+ }
848
+
849
+ func IsPalindrome(s string) bool {
850
+ runes := []rune(s)
851
+ for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
852
+ if runes[i] != runes[j] {
853
+ return false
854
+ }
855
+ }
856
+ return true
857
+ }
858
+ """,
859
+ description="String reversal and palindrome check in Go",
860
+ ),
861
+ Snippet(
862
+ name="go_binary_search",
863
+ language="go",
864
+ difficulty="easy",
865
+ code="""\
866
+ package search
867
+
868
+ func BinarySearch(arr []int, target int) int {
869
+ low, high := 0, len(arr)-1
870
+ for low <= high {
871
+ mid := low + (high-low)/2
872
+ if arr[mid] == target {
873
+ return mid
874
+ } else if arr[mid] < target {
875
+ low = mid + 1
876
+ } else {
877
+ high = mid - 1
878
+ }
879
+ }
880
+ return -1
881
+ }
882
+ """,
883
+ description="Binary search returning index or -1",
884
+ ),
885
+
886
+ # ── Go — Medium ──────────────────────────────────────────────────
887
+ Snippet(
888
+ name="concurrent_map",
889
+ language="go",
890
+ difficulty="medium",
891
+ code="""\
892
+ package safemap
893
+
894
+ import "sync"
895
+
896
+ type SafeMap struct {
897
+ mu sync.RWMutex
898
+ data map[string]interface{}
899
+ }
900
+
901
+ func NewSafeMap() *SafeMap {
902
+ return &SafeMap{data: make(map[string]interface{})}
903
+ }
904
+
905
+ func (m *SafeMap) Get(key string) (interface{}, bool) {
906
+ m.mu.RLock()
907
+ defer m.mu.RUnlock()
908
+ val, ok := m.data[key]
909
+ return val, ok
910
+ }
911
+
912
+ func (m *SafeMap) Set(key string, value interface{}) {
913
+ m.mu.Lock()
914
+ defer m.mu.Unlock()
915
+ m.data[key] = value
916
+ }
917
+
918
+ func (m *SafeMap) Delete(key string) {
919
+ m.mu.Lock()
920
+ defer m.mu.Unlock()
921
+ delete(m.data, key)
922
+ }
923
+
924
+ func (m *SafeMap) Len() int {
925
+ m.mu.RLock()
926
+ defer m.mu.RUnlock()
927
+ return len(m.data)
928
+ }
929
+ """,
930
+ description="Thread-safe map with RWMutex",
931
+ ),
932
+ Snippet(
933
+ name="go_linked_list",
934
+ language="go",
935
+ difficulty="medium",
936
+ code="""\
937
+ package linkedlist
938
+
939
+ type Node struct {
940
+ Value int
941
+ Next *Node
942
+ }
943
+
944
+ type LinkedList struct {
945
+ Head *Node
946
+ Size int
947
+ }
948
+
949
+ func (ll *LinkedList) Append(val int) {
950
+ newNode := &Node{Value: val}
951
+ if ll.Head == nil {
952
+ ll.Head = newNode
953
+ ll.Size++
954
+ return
955
+ }
956
+ current := ll.Head
957
+ for current.Next != nil {
958
+ current = current.Next
959
+ }
960
+ current.Next = newNode
961
+ ll.Size++
962
+ }
963
+
964
+ func (ll *LinkedList) Remove(val int) bool {
965
+ if ll.Head == nil {
966
+ return false
967
+ }
968
+ if ll.Head.Value == val {
969
+ ll.Head = ll.Head.Next
970
+ ll.Size--
971
+ return true
972
+ }
973
+ current := ll.Head
974
+ for current.Next != nil {
975
+ if current.Next.Value == val {
976
+ current.Next = current.Next.Next
977
+ ll.Size--
978
+ return true
979
+ }
980
+ current = current.Next
981
+ }
982
+ return false
983
+ }
984
+ """,
985
+ description="Singly linked list with append and remove",
986
+ ),
987
+ Snippet(
988
+ name="go_worker_pool",
989
+ language="go",
990
+ difficulty="medium",
991
+ code="""\
992
+ package workerpool
993
+
994
+ import "sync"
995
+
996
+ type Task func()
997
+
998
+ type Pool struct {
999
+ tasks chan Task
1000
+ wg sync.WaitGroup
1001
+ workers int
1002
+ }
1003
+
1004
+ func NewPool(workers, queueSize int) *Pool {
1005
+ p := &Pool{
1006
+ tasks: make(chan Task, queueSize),
1007
+ workers: workers,
1008
+ }
1009
+ for i := 0; i < workers; i++ {
1010
+ go p.worker()
1011
+ }
1012
+ return p
1013
+ }
1014
+
1015
+ func (p *Pool) worker() {
1016
+ for task := range p.tasks {
1017
+ task()
1018
+ p.wg.Done()
1019
+ }
1020
+ }
1021
+
1022
+ func (p *Pool) Submit(task Task) {
1023
+ p.wg.Add(1)
1024
+ p.tasks <- task
1025
+ }
1026
+
1027
+ func (p *Pool) Wait() {
1028
+ p.wg.Wait()
1029
+ }
1030
+
1031
+ func (p *Pool) Close() {
1032
+ close(p.tasks)
1033
+ }
1034
+ """,
1035
+ description="Worker pool with goroutines and WaitGroup",
1036
+ ),
1037
+
1038
+ # ── Go — Hard ────────────────────────────────────────────────────
1039
+ Snippet(
1040
+ name="go_channel_pipeline",
1041
+ language="go",
1042
+ difficulty="hard",
1043
+ code="""\
1044
+ package pipeline
1045
+
1046
+ func Generator(nums ...int) <-chan int {
1047
+ out := make(chan int)
1048
+ go func() {
1049
+ for _, n := range nums {
1050
+ out <- n
1051
+ }
1052
+ close(out)
1053
+ }()
1054
+ return out
1055
+ }
1056
+
1057
+ func Filter(in <-chan int, predicate func(int) bool) <-chan int {
1058
+ out := make(chan int)
1059
+ go func() {
1060
+ for n := range in {
1061
+ if predicate(n) {
1062
+ out <- n
1063
+ }
1064
+ }
1065
+ close(out)
1066
+ }()
1067
+ return out
1068
+ }
1069
+
1070
+ func Map(in <-chan int, transform func(int) int) <-chan int {
1071
+ out := make(chan int)
1072
+ go func() {
1073
+ for n := range in {
1074
+ out <- transform(n)
1075
+ }
1076
+ close(out)
1077
+ }()
1078
+ return out
1079
+ }
1080
+
1081
+ func Reduce(in <-chan int, initial int, combine func(int, int) int) int {
1082
+ result := initial
1083
+ for n := range in {
1084
+ result = combine(result, n)
1085
+ }
1086
+ return result
1087
+ }
1088
+ """,
1089
+ description="Channel-based pipeline with generator/filter/map/reduce",
1090
+ ),
1091
+
1092
+ # ── More Python snippets for variety ─────────────────────────────
1093
+ Snippet(
1094
+ name="csv_parser",
1095
+ language="python",
1096
+ difficulty="medium",
1097
+ code="""\
1098
+ def parse_csv(text, delimiter=','):
1099
+ rows = []
1100
+ for line in text.strip().split('\\n'):
1101
+ fields = []
1102
+ current = ''
1103
+ in_quotes = False
1104
+ for ch in line:
1105
+ if ch == '"':
1106
+ in_quotes = not in_quotes
1107
+ elif ch == delimiter and not in_quotes:
1108
+ fields.append(current.strip())
1109
+ current = ''
1110
+ else:
1111
+ current += ch
1112
+ fields.append(current.strip())
1113
+ rows.append(fields)
1114
+ return rows
1115
+ """,
1116
+ description="CSV parser handling quoted fields",
1117
+ ),
1118
+ Snippet(
1119
+ name="topological_sort",
1120
+ language="python",
1121
+ difficulty="hard",
1122
+ code="""\
1123
+ def topological_sort(graph):
1124
+ in_degree = {node: 0 for node in graph}
1125
+ for node in graph:
1126
+ for neighbor in graph[node]:
1127
+ in_degree[neighbor] = in_degree.get(neighbor, 0) + 1
1128
+ queue = [node for node in in_degree if in_degree[node] == 0]
1129
+ result = []
1130
+ while queue:
1131
+ node = queue.pop(0)
1132
+ result.append(node)
1133
+ for neighbor in graph.get(node, []):
1134
+ in_degree[neighbor] -= 1
1135
+ if in_degree[neighbor] == 0:
1136
+ queue.append(neighbor)
1137
+ if len(result) != len(in_degree):
1138
+ raise ValueError("Graph contains a cycle")
1139
+ return result
1140
+ """,
1141
+ description="Kahn's algorithm for topological sorting",
1142
+ ),
1143
+ Snippet(
1144
+ name="connection_pool",
1145
+ language="python",
1146
+ difficulty="hard",
1147
+ code="""\
1148
+ import threading
1149
+ import time
1150
+
1151
+ class ConnectionPool:
1152
+ def __init__(self, max_size, factory):
1153
+ self.max_size = max_size
1154
+ self.factory = factory
1155
+ self.pool = []
1156
+ self.in_use = set()
1157
+ self.lock = threading.Lock()
1158
+ self.condition = threading.Condition(self.lock)
1159
+
1160
+ def acquire(self, timeout=None):
1161
+ deadline = time.time() + timeout if timeout else None
1162
+ with self.condition:
1163
+ while True:
1164
+ if self.pool:
1165
+ conn = self.pool.pop()
1166
+ self.in_use.add(id(conn))
1167
+ return conn
1168
+ if len(self.in_use) < self.max_size:
1169
+ conn = self.factory()
1170
+ self.in_use.add(id(conn))
1171
+ return conn
1172
+ if deadline is not None:
1173
+ remaining = deadline - time.time()
1174
+ if remaining <= 0:
1175
+ raise TimeoutError("Connection pool exhausted")
1176
+ self.condition.wait(remaining)
1177
+ else:
1178
+ self.condition.wait()
1179
+
1180
+ def release(self, conn):
1181
+ with self.condition:
1182
+ self.in_use.discard(id(conn))
1183
+ self.pool.append(conn)
1184
+ self.condition.notify()
1185
+ """,
1186
+ description="Thread-safe connection pool with timeout",
1187
+ ),
1188
+ Snippet(
1189
+ name="group_by",
1190
+ language="python",
1191
+ difficulty="easy",
1192
+ code="""\
1193
+ def group_by(items, key_func):
1194
+ groups = {}
1195
+ for item in items:
1196
+ key = key_func(item)
1197
+ if key not in groups:
1198
+ groups[key] = []
1199
+ groups[key].append(item)
1200
+ return groups
1201
+
1202
+ def count_by(items, key_func):
1203
+ counts = {}
1204
+ for item in items:
1205
+ key = key_func(item)
1206
+ counts[key] = counts.get(key, 0) + 1
1207
+ return counts
1208
+ """,
1209
+ description="Group and count items by a key function",
1210
+ ),
1211
+ Snippet(
1212
+ name="matrix_multiply",
1213
+ language="python",
1214
+ difficulty="easy",
1215
+ code="""\
1216
+ def matrix_multiply(a, b):
1217
+ if not a or not b or len(a[0]) != len(b):
1218
+ raise ValueError("Incompatible matrix dimensions")
1219
+ rows_a, cols_a = len(a), len(a[0])
1220
+ cols_b = len(b[0])
1221
+ result = [[0] * cols_b for _ in range(rows_a)]
1222
+ for i in range(rows_a):
1223
+ for j in range(cols_b):
1224
+ for k in range(cols_a):
1225
+ result[i][j] += a[i][k] * b[k][j]
1226
+ return result
1227
+ """,
1228
+ description="Matrix multiplication with dimension validation",
1229
+ ),
1230
+ ]
1231
+
1232
+
1233
+ # ─── Bug Injectors ──────────────────────────────────────────────────────────
1234
+ # Each injector: (code, rng) -> (buggy_code, BugRecord)
1235
+ # Returns None if no applicable injection site found.
1236
+
1237
+
1238
+ def off_by_one_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1239
+ """Introduce an off-by-one error: flip < to <=, > to >=, or adjust range bounds."""
1240
+ lines = code.split('\n')
1241
+ candidates = []
1242
+
1243
+ for i, line in enumerate(lines):
1244
+ stripped = line.strip()
1245
+ if stripped.startswith('#') or stripped.startswith('//'):
1246
+ continue
1247
+ # Pattern: while/for/if with < that could become <=
1248
+ if re.search(r'[^<>=!]\s*<\s*[^<=]', line) and ('while' in line or 'for' in line or 'if' in line):
1249
+ candidates.append((i, 'lt_to_lte', line))
1250
+ # Pattern: <= that could become <
1251
+ if re.search(r'<=', line) and ('while' in line or 'for' in line or 'if' in line):
1252
+ candidates.append((i, 'lte_to_lt', line))
1253
+ # Pattern: range(n) -> range(n-1) or range(n+1)
1254
+ if 'range(' in line:
1255
+ candidates.append((i, 'range_off', line))
1256
+ # Pattern: len(x) - 1 -> len(x) or len(x) - 2
1257
+ if re.search(r'len\([^)]+\)\s*-\s*1', line):
1258
+ candidates.append((i, 'len_off', line))
1259
+
1260
+ if not candidates:
1261
+ return None
1262
+
1263
+ idx, pattern, original_line = rng.choice(candidates)
1264
+
1265
+ if pattern == 'lt_to_lte':
1266
+ new_line = re.sub(r'([^<>=!])\s*<\s*([^<=])', r'\1 <= \2', original_line, count=1)
1267
+ fix = "Change <= back to < to fix the off-by-one boundary"
1268
+ elif pattern == 'lte_to_lt':
1269
+ new_line = original_line.replace('<=', '<', 1)
1270
+ fix = "Change < back to <= to include the boundary value"
1271
+ elif pattern == 'range_off':
1272
+ m = re.search(r'range\(([^,)]+)\)', original_line)
1273
+ if m:
1274
+ arg = m.group(1).strip()
1275
+ new_line = original_line.replace(f'range({arg})', f'range({arg} - 1)', 1)
1276
+ fix = f"Change range({arg} - 1) back to range({arg}) to include last element"
1277
+ else:
1278
+ return None
1279
+ elif pattern == 'len_off':
1280
+ new_line = re.sub(r'len\(([^)]+)\)\s*-\s*1', r'len(\1)', original_line, count=1)
1281
+ fix = "Restore `- 1` after len() to avoid index out of bounds"
1282
+ else:
1283
+ return None
1284
+
1285
+ if new_line == original_line:
1286
+ return None
1287
+
1288
+ lines[idx] = new_line
1289
+ return '\n'.join(lines), BugRecord(
1290
+ description=f"Off-by-one error on line {idx + 1}: boundary condition is wrong",
1291
+ lines=[idx + 1],
1292
+ fix=fix,
1293
+ bug_type="off_by_one",
1294
+ )
1295
+
1296
+
1297
+ def null_deref_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1298
+ """Remove a null/None/nil guard, causing a potential null dereference."""
1299
+ lines = code.split('\n')
1300
+ candidates = []
1301
+
1302
+ for i, line in enumerate(lines):
1303
+ stripped = line.strip()
1304
+ # Python: if x is not None, if x is None, if not x, if x
1305
+ if re.search(r'if\s+\w+\s+is\s+not\s+None', stripped):
1306
+ candidates.append((i, 'python_guard'))
1307
+ elif re.search(r'if\s+not\s+\w+', stripped) and 'return' not in stripped:
1308
+ candidates.append((i, 'python_not_guard'))
1309
+ # JS: if (x !== null), if (x != null), if (x)
1310
+ if re.search(r'if\s*\(\s*\w+\s*!==?\s*null\s*\)', stripped):
1311
+ candidates.append((i, 'js_guard'))
1312
+ # Go: if x != nil
1313
+ if re.search(r'if\s+\w+\s*!=\s*nil', stripped):
1314
+ candidates.append((i, 'go_guard'))
1315
+ # Python: if len(x) == 0 / if not x
1316
+ if re.search(r'if\s+(not\s+\w+|len\(\w+\)\s*==\s*0)', stripped):
1317
+ candidates.append((i, 'empty_guard'))
1318
+
1319
+ if not candidates:
1320
+ return None
1321
+
1322
+ idx, pattern = rng.choice(candidates)
1323
+ original_line = lines[idx]
1324
+ indent = len(original_line) - len(original_line.lstrip())
1325
+
1326
+ # Find the body of the guard (next indented lines)
1327
+ body_lines = []
1328
+ for j in range(idx + 1, min(idx + 5, len(lines))):
1329
+ if lines[j].strip() and (len(lines[j]) - len(lines[j].lstrip())) > indent:
1330
+ body_lines.append(j)
1331
+ else:
1332
+ break
1333
+
1334
+ if body_lines:
1335
+ # Remove the guard but keep the body (de-indent by one level)
1336
+ body_indent = len(lines[body_lines[0]]) - len(lines[body_lines[0]].lstrip())
1337
+ dedent = body_indent - indent
1338
+
1339
+ new_lines = list(lines)
1340
+ # Delete the guard line entirely (shift body up)
1341
+ new_lines.pop(idx)
1342
+ # Adjust body indices after removal
1343
+ for j_offset, j in enumerate(body_lines):
1344
+ adjusted_j = j - 1 # shifted up by one
1345
+ if adjusted_j < len(new_lines) and dedent > 0:
1346
+ new_lines[adjusted_j] = lines[j][dedent:]
1347
+ else:
1348
+ # Delete the guard line entirely
1349
+ new_lines = list(lines)
1350
+ new_lines.pop(idx)
1351
+
1352
+ return '\n'.join(new_lines), BugRecord(
1353
+ description=f"Null/None guard removed on line {idx + 1}: missing null check before dereference",
1354
+ lines=[idx + 1],
1355
+ fix=f"Restore the null guard: {original_line.strip()}",
1356
+ bug_type="null_deref",
1357
+ )
1358
+
1359
+
1360
+ def wrong_operator_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1361
+ """Swap an arithmetic or comparison operator: + ↔ -, * ↔ /, == ↔ !=."""
1362
+ lines = code.split('\n')
1363
+ candidates = []
1364
+
1365
+ swaps = [
1366
+ (r'(\w)\s*\+\s*(\w)', r'\1 - \2', '+', '-'),
1367
+ (r'(\w)\s*-\s*(\w)', r'\1 + \2', '-', '+'),
1368
+ (r'(\w)\s*\*\s*(\w)', r'\1 / \2', '*', '/'),
1369
+ ]
1370
+
1371
+ for i, line in enumerate(lines):
1372
+ stripped = line.strip()
1373
+ if stripped.startswith('#') or stripped.startswith('//'):
1374
+ continue
1375
+ # Don't mess with string concatenation or imports
1376
+ if '"' in line or "'" in line or 'import' in line:
1377
+ continue
1378
+ for pattern, replacement, old_op, new_op in swaps:
1379
+ if re.search(pattern, line):
1380
+ candidates.append((i, pattern, replacement, old_op, new_op))
1381
+
1382
+ if not candidates:
1383
+ return None
1384
+
1385
+ idx, pattern, replacement, old_op, new_op = rng.choice(candidates)
1386
+ new_line = re.sub(pattern, replacement, lines[idx], count=1)
1387
+
1388
+ if new_line == lines[idx]:
1389
+ return None
1390
+
1391
+ new_lines = list(lines)
1392
+ new_lines[idx] = new_line
1393
+
1394
+ return '\n'.join(new_lines), BugRecord(
1395
+ description=f"Wrong operator on line {idx + 1}: '{old_op}' should be '{new_op}'",
1396
+ lines=[idx + 1],
1397
+ fix=f"Change '{new_op}' back to '{old_op}' on line {idx + 1}",
1398
+ bug_type="wrong_operator",
1399
+ )
1400
+
1401
+
1402
+ def unused_var_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1403
+ """Insert a dead variable that shadows a live one, causing subtle bugs."""
1404
+ lines = code.split('\n')
1405
+
1406
+ # Find variable assignments to shadow
1407
+ assignments = []
1408
+ for i, line in enumerate(lines):
1409
+ # Python: x = ...
1410
+ m = re.match(r'^(\s+)(\w+)\s*=\s*', line)
1411
+ if m and not line.strip().startswith('#') and not line.strip().startswith('def '):
1412
+ indent = m.group(1)
1413
+ var_name = m.group(2)
1414
+ if var_name not in ('self', 'cls', 'result', 'return') and len(var_name) > 1:
1415
+ assignments.append((i, indent, var_name))
1416
+ # JS: let/const/var x = ...
1417
+ m = re.match(r'^(\s+)(?:let|const|var)\s+(\w+)\s*=', line)
1418
+ if m:
1419
+ indent = m.group(1)
1420
+ var_name = m.group(2)
1421
+ assignments.append((i, indent, var_name))
1422
+
1423
+ if not assignments:
1424
+ return None
1425
+
1426
+ idx, indent, var_name = rng.choice(assignments)
1427
+
1428
+ # Insert a shadowing assignment before the real one
1429
+ shadow_values = ['0', 'None', '""', '[]', 'False', '{}']
1430
+ shadow_val = rng.choice(shadow_values)
1431
+ shadow_line = f"{indent}{var_name} = {shadow_val}"
1432
+
1433
+ new_lines = list(lines)
1434
+ new_lines.insert(idx, shadow_line)
1435
+
1436
+ return '\n'.join(new_lines), BugRecord(
1437
+ description=f"Dead variable on line {idx + 1}: '{var_name}' is assigned {shadow_val} but immediately overwritten — may mask intent or cause bugs if reordered",
1438
+ lines=[idx + 1],
1439
+ fix=f"Remove the dead assignment `{var_name} = {shadow_val}` on line {idx + 1}",
1440
+ bug_type="unused_var",
1441
+ )
1442
+
1443
+
1444
+ def logic_inversion_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1445
+ """Flip a boolean: True↔False, and↔or, ==↔!=."""
1446
+ lines = code.split('\n')
1447
+ candidates = []
1448
+
1449
+ for i, line in enumerate(lines):
1450
+ stripped = line.strip()
1451
+ if stripped.startswith('#') or stripped.startswith('//'):
1452
+ continue
1453
+ if ' and ' in line:
1454
+ candidates.append((i, 'and_to_or'))
1455
+ if ' or ' in line and 'import' not in line:
1456
+ candidates.append((i, 'or_to_and'))
1457
+ if ' == ' in line and '==' not in stripped[:3]:
1458
+ candidates.append((i, 'eq_to_neq'))
1459
+ if ' != ' in line:
1460
+ candidates.append((i, 'neq_to_eq'))
1461
+ if 'True' in line and 'return True' in stripped:
1462
+ candidates.append((i, 'true_to_false'))
1463
+ if 'False' in line and 'return False' in stripped:
1464
+ candidates.append((i, 'false_to_true'))
1465
+ # JS/Go: true/false
1466
+ if 'true' in line and 'return true' in stripped:
1467
+ candidates.append((i, 'true_to_false'))
1468
+ if 'false' in line and 'return false' in stripped:
1469
+ candidates.append((i, 'false_to_true'))
1470
+
1471
+ if not candidates:
1472
+ return None
1473
+
1474
+ idx, pattern = rng.choice(candidates)
1475
+ original_line = lines[idx]
1476
+
1477
+ if pattern == 'and_to_or':
1478
+ new_line = original_line.replace(' and ', ' or ', 1)
1479
+ fix = "Change 'or' back to 'and'"
1480
+ elif pattern == 'or_to_and':
1481
+ new_line = original_line.replace(' or ', ' and ', 1)
1482
+ fix = "Change 'and' back to 'or'"
1483
+ elif pattern == 'eq_to_neq':
1484
+ new_line = original_line.replace(' == ', ' != ', 1)
1485
+ fix = "Change '!=' back to '=='"
1486
+ elif pattern == 'neq_to_eq':
1487
+ new_line = original_line.replace(' != ', ' == ', 1)
1488
+ fix = "Change '==' back to '!='"
1489
+ elif pattern == 'true_to_false':
1490
+ new_line = original_line.replace('True', 'False', 1).replace('true', 'false', 1)
1491
+ fix = "Change 'False' back to 'True'"
1492
+ elif pattern == 'false_to_true':
1493
+ new_line = original_line.replace('False', 'True', 1).replace('false', 'true', 1)
1494
+ fix = "Change 'True' back to 'False'"
1495
+ else:
1496
+ return None
1497
+
1498
+ if new_line == original_line:
1499
+ return None
1500
+
1501
+ new_lines = list(lines)
1502
+ new_lines[idx] = new_line
1503
+
1504
+ return '\n'.join(new_lines), BugRecord(
1505
+ description=f"Logic inversion on line {idx + 1}: boolean condition is flipped",
1506
+ lines=[idx + 1],
1507
+ fix=fix,
1508
+ bug_type="logic_inversion",
1509
+ )
1510
+
1511
+
1512
+ # ─── AST-Based Injectors (Python only) ──────────────────────────────────────
1513
+ # These use the ast module to find injection sites with structural accuracy,
1514
+ # then apply the mutation via string replacement to preserve formatting.
1515
+
1516
+
1517
+ def _is_python(code: str) -> bool:
1518
+ """Check if code parses as valid Python."""
1519
+ try:
1520
+ ast.parse(textwrap.dedent(code))
1521
+ return True
1522
+ except SyntaxError:
1523
+ return False
1524
+
1525
+
1526
+ def ast_comparison_flip_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1527
+ """AST-based: find comparison operators and flip them (< ↔ <=, == ↔ !=, > ↔ >=)."""
1528
+ if not _is_python(code):
1529
+ return None
1530
+
1531
+ try:
1532
+ tree = ast.parse(textwrap.dedent(code))
1533
+ except SyntaxError:
1534
+ return None
1535
+
1536
+ # Collect all Compare nodes with their line numbers
1537
+ flips = {
1538
+ ast.Lt: (ast.LtE, '<', '<=', "Change '<=' back to '<'"),
1539
+ ast.LtE: (ast.Lt, '<=', '<', "Change '<' back to '<='"),
1540
+ ast.Gt: (ast.GtE, '>', '>=', "Change '>=' back to '>'"),
1541
+ ast.GtE: (ast.Gt, '>=', '>', "Change '>' back to '>='"),
1542
+ ast.Eq: (ast.NotEq, '==', '!=', "Change '!=' back to '=='"),
1543
+ ast.NotEq: (ast.Eq, '!=', '==', "Change '==' back to '!='"),
1544
+ }
1545
+
1546
+ candidates = []
1547
+ for node in ast.walk(tree):
1548
+ if isinstance(node, ast.Compare):
1549
+ for i, op in enumerate(node.ops):
1550
+ if type(op) in flips:
1551
+ candidates.append((node.lineno, type(op)))
1552
+
1553
+ if not candidates:
1554
+ return None
1555
+
1556
+ lineno, op_type = rng.choice(candidates)
1557
+ new_op_cls, old_str, new_str, fix = flips[op_type]
1558
+
1559
+ lines = code.split('\n')
1560
+ if lineno - 1 >= len(lines):
1561
+ return None
1562
+
1563
+ original_line = lines[lineno - 1]
1564
+ # Apply the string-level replacement on the target line
1565
+ new_line = original_line.replace(f' {old_str} ', f' {new_str} ', 1)
1566
+ if new_line == original_line:
1567
+ return None
1568
+
1569
+ lines[lineno - 1] = new_line
1570
+ return '\n'.join(lines), BugRecord(
1571
+ description=f"Comparison operator flipped on line {lineno}: '{old_str}' changed to '{new_str}'",
1572
+ lines=[lineno],
1573
+ fix=fix,
1574
+ bug_type="ast_comparison_flip",
1575
+ )
1576
+
1577
+
1578
+ def ast_binop_swap_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1579
+ """AST-based: find binary operations and swap operators (+ ↔ -, * ↔ //)."""
1580
+ if not _is_python(code):
1581
+ return None
1582
+
1583
+ try:
1584
+ tree = ast.parse(textwrap.dedent(code))
1585
+ except SyntaxError:
1586
+ return None
1587
+
1588
+ swaps = {
1589
+ ast.Add: (ast.Sub, '+', '-', "Change '-' back to '+'"),
1590
+ ast.Sub: (ast.Add, '-', '+', "Change '+' back to '-'"),
1591
+ ast.Mult: (ast.FloorDiv, '*', '//', "Change '//' back to '*'"),
1592
+ ast.FloorDiv: (ast.Mult, '//', '*', "Change '*' back to '//'"),
1593
+ }
1594
+
1595
+ candidates = []
1596
+ for node in ast.walk(tree):
1597
+ if isinstance(node, ast.BinOp) and type(node.op) in swaps:
1598
+ candidates.append((node.lineno, type(node.op)))
1599
+
1600
+ if not candidates:
1601
+ return None
1602
+
1603
+ lineno, op_type = rng.choice(candidates)
1604
+ _, old_str, new_str, fix = swaps[op_type]
1605
+
1606
+ lines = code.split('\n')
1607
+ if lineno - 1 >= len(lines):
1608
+ return None
1609
+
1610
+ original_line = lines[lineno - 1]
1611
+
1612
+ # For floor div, need exact match; for + and -, be careful with strings
1613
+ if old_str in ('*', '//'):
1614
+ new_line = original_line.replace(old_str, new_str, 1)
1615
+ else:
1616
+ # For + and -, only replace when surrounded by spaces or word chars
1617
+ pattern = re.compile(r'(\w)\s*' + re.escape(old_str) + r'\s*(\w)')
1618
+ m = pattern.search(original_line)
1619
+ if m:
1620
+ new_line = original_line[:m.start()] + m.group(1) + f' {new_str} ' + m.group(2) + original_line[m.end():]
1621
+ else:
1622
+ return None
1623
+
1624
+ if new_line == original_line:
1625
+ return None
1626
+
1627
+ lines[lineno - 1] = new_line
1628
+ return '\n'.join(lines), BugRecord(
1629
+ description=f"Arithmetic operator swapped on line {lineno}: '{old_str}' changed to '{new_str}'",
1630
+ lines=[lineno],
1631
+ fix=fix,
1632
+ bug_type="ast_binop_swap",
1633
+ )
1634
+
1635
+
1636
+ def ast_boolop_flip_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1637
+ """AST-based: find boolean operations and flip And ↔ Or."""
1638
+ if not _is_python(code):
1639
+ return None
1640
+
1641
+ try:
1642
+ tree = ast.parse(textwrap.dedent(code))
1643
+ except SyntaxError:
1644
+ return None
1645
+
1646
+ candidates = []
1647
+ for node in ast.walk(tree):
1648
+ if isinstance(node, ast.BoolOp):
1649
+ candidates.append((node.lineno, type(node.op)))
1650
+
1651
+ if not candidates:
1652
+ return None
1653
+
1654
+ lineno, op_type = rng.choice(candidates)
1655
+ lines = code.split('\n')
1656
+ if lineno - 1 >= len(lines):
1657
+ return None
1658
+
1659
+ original_line = lines[lineno - 1]
1660
+
1661
+ if op_type == ast.And:
1662
+ new_line = original_line.replace(' and ', ' or ', 1)
1663
+ fix = "Change 'or' back to 'and'"
1664
+ desc = "'and' changed to 'or'"
1665
+ else:
1666
+ new_line = original_line.replace(' or ', ' and ', 1)
1667
+ fix = "Change 'and' back to 'or'"
1668
+ desc = "'or' changed to 'and'"
1669
+
1670
+ if new_line == original_line:
1671
+ return None
1672
+
1673
+ lines[lineno - 1] = new_line
1674
+ return '\n'.join(lines), BugRecord(
1675
+ description=f"Boolean operator flipped on line {lineno}: {desc}",
1676
+ lines=[lineno],
1677
+ fix=fix,
1678
+ bug_type="ast_boolop_flip",
1679
+ )
1680
+
1681
+
1682
+ def ast_return_negate_injector(code: str, rng: random.Random) -> Optional[Tuple[str, BugRecord]]:
1683
+ """AST-based: find Return statements with boolean/numeric constants and negate them."""
1684
+ if not _is_python(code):
1685
+ return None
1686
+
1687
+ try:
1688
+ tree = ast.parse(textwrap.dedent(code))
1689
+ except SyntaxError:
1690
+ return None
1691
+
1692
+ candidates = []
1693
+ for node in ast.walk(tree):
1694
+ if isinstance(node, ast.Return) and node.value is not None:
1695
+ if isinstance(node.value, ast.Constant):
1696
+ val = node.value.value
1697
+ if val is True or val is False:
1698
+ candidates.append((node.lineno, val, 'bool'))
1699
+ elif isinstance(val, int) and val in (0, 1, -1):
1700
+ candidates.append((node.lineno, val, 'int'))
1701
+
1702
+ if not candidates:
1703
+ return None
1704
+
1705
+ lineno, val, vtype = rng.choice(candidates)
1706
+ lines = code.split('\n')
1707
+ if lineno - 1 >= len(lines):
1708
+ return None
1709
+
1710
+ original_line = lines[lineno - 1]
1711
+
1712
+ if vtype == 'bool':
1713
+ if val is True:
1714
+ new_line = original_line.replace('True', 'False', 1)
1715
+ fix = "Change 'False' back to 'True'"
1716
+ else:
1717
+ new_line = original_line.replace('False', 'True', 1)
1718
+ fix = "Change 'True' back to 'False'"
1719
+ else:
1720
+ negated = {0: -1, 1: -1, -1: 1}
1721
+ new_val = negated.get(val, -val)
1722
+ new_line = original_line.replace(f'return {val}', f'return {new_val}', 1)
1723
+ fix = f"Change 'return {new_val}' back to 'return {val}'"
1724
+
1725
+ if new_line == original_line:
1726
+ return None
1727
+
1728
+ lines[lineno - 1] = new_line
1729
+ return '\n'.join(lines), BugRecord(
1730
+ description=f"Return value negated on line {lineno}: returns wrong value",
1731
+ lines=[lineno],
1732
+ fix=fix,
1733
+ bug_type="ast_return_negate",
1734
+ )
1735
+
1736
+
1737
+ # ─── Injector Registry ──────────────────────────────────────────────────────
1738
+
1739
+ # Regex-based injectors (work on all languages)
1740
+ REGEX_INJECTORS: List[Callable] = [
1741
+ off_by_one_injector,
1742
+ null_deref_injector,
1743
+ wrong_operator_injector,
1744
+ unused_var_injector,
1745
+ logic_inversion_injector,
1746
+ ]
1747
+
1748
+ # AST-based injectors (Python only, more precise)
1749
+ AST_INJECTORS: List[Callable] = [
1750
+ ast_comparison_flip_injector,
1751
+ ast_binop_swap_injector,
1752
+ ast_boolop_flip_injector,
1753
+ ast_return_negate_injector,
1754
+ ]
1755
+
1756
+ # Combined list — AST injectors first (preferred for Python)
1757
+ BUG_INJECTORS: List[Callable] = AST_INJECTORS + REGEX_INJECTORS
1758
+
1759
+ INJECTOR_NAMES: Dict[str, Callable] = {
1760
+ "off_by_one": off_by_one_injector,
1761
+ "null_deref": null_deref_injector,
1762
+ "wrong_operator": wrong_operator_injector,
1763
+ "unused_var": unused_var_injector,
1764
+ "logic_inversion": logic_inversion_injector,
1765
+ "ast_comparison_flip": ast_comparison_flip_injector,
1766
+ "ast_binop_swap": ast_binop_swap_injector,
1767
+ "ast_boolop_flip": ast_boolop_flip_injector,
1768
+ "ast_return_negate": ast_return_negate_injector,
1769
+ }
1770
+
1771
+
1772
+ # ─── Episode Generator ──────────────────────────────────────────────────────
1773
+
1774
+ def generate_episode(
1775
+ seed: Optional[int] = None,
1776
+ difficulty: str = "easy",
1777
+ ) -> Tuple[Snippet, str, List[BugRecord]]:
1778
+ """Generate one episode: pick snippet, inject bugs, return gold.
1779
+
1780
+ Args:
1781
+ seed: random seed for reproducibility
1782
+ difficulty: easy (1 bug), medium (1-2 bugs), hard (2-3 bugs)
1783
+
1784
+ Returns:
1785
+ (snippet, buggy_code, gold_bugs)
1786
+ """
1787
+ rng = random.Random(seed)
1788
+
1789
+ # Filter snippets by difficulty
1790
+ pool = [s for s in SNIPPET_BANK if s.difficulty == difficulty]
1791
+ if not pool:
1792
+ pool = list(SNIPPET_BANK)
1793
+
1794
+ snippet = rng.choice(pool)
1795
+ code = snippet.code
1796
+
1797
+ # Determine number of bugs by difficulty
1798
+ n_bugs = {"easy": 1, "medium": rng.randint(1, 2), "hard": rng.randint(2, 3)}.get(
1799
+ difficulty, 1
1800
+ )
1801
+
1802
+ # Apply injectors — prefer AST-based for Python, regex for others
1803
+ gold_bugs: List[BugRecord] = []
1804
+ if snippet.language == "python":
1805
+ # AST injectors first (structurally precise), then regex fallback
1806
+ available = list(AST_INJECTORS)
1807
+ rng.shuffle(available)
1808
+ available += list(REGEX_INJECTORS)
1809
+ rng.shuffle(available[len(AST_INJECTORS):]) # shuffle regex portion
1810
+ else:
1811
+ available = list(REGEX_INJECTORS)
1812
+ rng.shuffle(available)
1813
+
1814
+ used_types = set()
1815
+ for injector in available:
1816
+ if len(gold_bugs) >= n_bugs:
1817
+ break
1818
+ result = injector(code, rng)
1819
+ if result is not None:
1820
+ new_code, bug_record = result
1821
+ # Avoid duplicate bug types in same episode
1822
+ if bug_record.bug_type not in used_types:
1823
+ code = new_code
1824
+ gold_bugs.append(bug_record)
1825
+ used_types.add(bug_record.bug_type)
1826
+
1827
+ # If no bugs were successfully injected, force at least one
1828
+ if not gold_bugs:
1829
+ for injector in BUG_INJECTORS:
1830
+ result = injector(snippet.code, rng)
1831
+ if result is not None:
1832
+ code, bug_record = result
1833
+ gold_bugs.append(bug_record)
1834
+ break
1835
+
1836
+ return snippet, code, gold_bugs
tests/test_code_review_env.py ADDED
@@ -0,0 +1,432 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Test suite for CodeReviewEnv v2.
3
+
4
+ Tests:
5
+ Core interface (reset, step, state)
6
+ Multi-step MDP (analyze, flag_line, request_hint, submit_review)
7
+ Reward quality (perfect/empty/partial reviews)
8
+ Hint system (progressive, penalty)
9
+ Concurrent sessions
10
+ Bug injectors (regex + AST-based)
11
+ Procedural generation (variety, reproducibility, difficulty)
12
+ Reward signals (bounds, individual functions)
13
+ """
14
+
15
+ import pytest
16
+ import random
17
+
18
+ from server.code_review_environment import CodeReviewEnvironment
19
+ from models import CodeReviewAction, CodeReviewObservation, CodeReviewState
20
+ from snippet_bank import (
21
+ SNIPPET_BANK, BUG_INJECTORS, REGEX_INJECTORS, AST_INJECTORS,
22
+ generate_episode,
23
+ off_by_one_injector, null_deref_injector, wrong_operator_injector,
24
+ unused_var_injector, logic_inversion_injector,
25
+ ast_comparison_flip_injector, ast_binop_swap_injector,
26
+ ast_boolop_flip_injector, ast_return_negate_injector,
27
+ )
28
+ from reward import compute_reward, _bug_overlap, _fix_similarity, _line_f1, _comment_score
29
+
30
+
31
+ # ── Core Interface Tests ─────────────────────────────────────────────────────
32
+
33
+ class TestCoreInterface:
34
+
35
+ @pytest.mark.parametrize("difficulty", ["easy", "medium", "hard"])
36
+ def test_reset_produces_buggy_code(self, difficulty):
37
+ env = CodeReviewEnvironment()
38
+ obs = env.reset(seed=42, difficulty=difficulty)
39
+ assert isinstance(obs, CodeReviewObservation)
40
+ assert obs.code != ""
41
+ assert obs.done is False
42
+ assert obs.language in ("python", "javascript", "go")
43
+ assert obs.difficulty == difficulty
44
+ assert obs.episode_budget == 5 # multi-step budget
45
+
46
+ def test_reset_code_differs_from_original(self):
47
+ env = CodeReviewEnvironment()
48
+ env.reset(seed=42, difficulty="easy")
49
+ state = env.state
50
+ assert len(state.gold_bugs) >= 1
51
+
52
+ def test_step_submit_returns_done(self):
53
+ env = CodeReviewEnvironment()
54
+ env.reset(seed=42, difficulty="easy")
55
+ action = CodeReviewAction(
56
+ action_type="submit_review",
57
+ issues=["test bug"],
58
+ flagged_lines=[3],
59
+ suggestion="fix it",
60
+ comment="found a bug",
61
+ )
62
+ obs = env.step(action)
63
+ assert isinstance(obs, CodeReviewObservation)
64
+ assert obs.done is True
65
+ assert 0.0 <= obs.reward <= 1.0
66
+
67
+ def test_state_contains_gold_bugs(self):
68
+ env = CodeReviewEnvironment()
69
+ env.reset(seed=42, difficulty="easy")
70
+ state = env.state
71
+ assert isinstance(state, CodeReviewState)
72
+ assert isinstance(state.gold_bugs, list)
73
+
74
+
75
+ # ── Multi-Step MDP Tests ─────────────────────────────────────────────────────
76
+
77
+ class TestMultiStepMDP:
78
+
79
+ def test_analyze_is_free(self):
80
+ """analyze action gives 0 reward and doesn't end episode."""
81
+ env = CodeReviewEnvironment()
82
+ env.reset(seed=42, difficulty="easy")
83
+ obs = env.step(CodeReviewAction(action_type="analyze"))
84
+ assert obs.done is False
85
+ assert obs.reward == 0.0
86
+ assert obs.analysis is not None and len(obs.analysis) > 0
87
+ assert obs.step_number == 1
88
+
89
+ def test_flag_correct_line_gives_positive_reward(self):
90
+ """Flagging a correct line gives +0.15 immediate reward."""
91
+ env = CodeReviewEnvironment()
92
+ env.reset(seed=42, difficulty="easy")
93
+ state = env.state
94
+ if not state.gold_bugs:
95
+ pytest.skip("No bugs injected")
96
+ gold_line = state.gold_bugs[0]["lines"][0]
97
+ obs = env.step(CodeReviewAction(action_type="flag_line", line=gold_line))
98
+ assert obs.done is False
99
+ assert obs.reward > 0
100
+ assert gold_line in obs.flagged_so_far
101
+
102
+ def test_flag_wrong_line_gives_negative_reward(self):
103
+ """Flagging a wrong line gives -0.05 penalty."""
104
+ env = CodeReviewEnvironment()
105
+ env.reset(seed=42, difficulty="easy")
106
+ obs = env.step(CodeReviewAction(action_type="flag_line", line=999))
107
+ assert obs.done is False
108
+ assert obs.reward < 0
109
+
110
+ def test_request_hint_doesnt_end_episode(self):
111
+ """Hint request doesn't end the episode."""
112
+ env = CodeReviewEnvironment()
113
+ env.reset(seed=42, difficulty="easy")
114
+ obs = env.step(CodeReviewAction(action_type="request_hint"))
115
+ assert obs.done is False
116
+ assert obs.hint is not None
117
+
118
+ def test_max_steps_forces_submit(self):
119
+ """After 5 steps without submit, environment auto-submits."""
120
+ env = CodeReviewEnvironment()
121
+ env.reset(seed=42, difficulty="easy")
122
+ for i in range(5):
123
+ obs = env.step(CodeReviewAction(action_type="analyze"))
124
+ if obs.done:
125
+ break
126
+ assert obs.done is True, "Episode should end after max_steps"
127
+
128
+ def test_multi_step_trajectory(self):
129
+ """Full multi-step trajectory: analyze → flag → flag → submit."""
130
+ env = CodeReviewEnvironment()
131
+ env.reset(seed=42, difficulty="easy")
132
+ state = env.state
133
+
134
+ # Step 1: analyze
135
+ obs = env.step(CodeReviewAction(action_type="analyze"))
136
+ assert obs.done is False
137
+
138
+ # Step 2: flag a line
139
+ if state.gold_bugs:
140
+ line = state.gold_bugs[0]["lines"][0]
141
+ obs = env.step(CodeReviewAction(action_type="flag_line", line=line))
142
+ assert obs.done is False
143
+
144
+ # Step 3: submit review
145
+ obs = env.step(CodeReviewAction(
146
+ action_type="submit_review",
147
+ issues=["bug found"],
148
+ suggestion="fix",
149
+ comment="review",
150
+ ))
151
+ assert obs.done is True
152
+ assert obs.reward is not None
153
+
154
+ # Check trajectory length
155
+ traj = env.export_trajectory()
156
+ assert len(traj) >= 3
157
+
158
+ def test_flagged_lines_carry_to_submit(self):
159
+ """Lines flagged in earlier steps are included in final grading."""
160
+ env = CodeReviewEnvironment()
161
+ env.reset(seed=42, difficulty="easy")
162
+ state = env.state
163
+
164
+ if not state.gold_bugs:
165
+ pytest.skip("No bugs")
166
+
167
+ gold_line = state.gold_bugs[0]["lines"][0]
168
+
169
+ # Flag the correct line
170
+ env.step(CodeReviewAction(action_type="flag_line", line=gold_line))
171
+
172
+ # Submit empty review (but with previously flagged line)
173
+ obs = env.step(CodeReviewAction(action_type="submit_review"))
174
+ # Should get some line_precision credit from the prior flag
175
+ assert obs.reward_breakdown is not None
176
+ assert obs.reward_breakdown.get("line_precision", 0) > 0
177
+
178
+
179
+ # ── Reward Quality Tests ─────────────────────────────────────────────────────
180
+
181
+ class TestRewardQuality:
182
+
183
+ def test_perfect_review_high_reward(self):
184
+ env = CodeReviewEnvironment()
185
+ env.reset(seed=42, difficulty="easy")
186
+ state = env.state
187
+ if not state.gold_bugs:
188
+ pytest.skip("No bugs")
189
+ gold = state.gold_bugs[0]
190
+ action = CodeReviewAction(
191
+ action_type="submit_review",
192
+ issues=[gold["description"]],
193
+ flagged_lines=gold["lines"],
194
+ suggestion=gold["fix"],
195
+ comment=f"Found a {gold['bug_type']} error. {gold['fix']}",
196
+ )
197
+ obs = env.step(action)
198
+ assert obs.reward >= 0.5
199
+
200
+ def test_empty_review_low_reward(self):
201
+ env = CodeReviewEnvironment()
202
+ env.reset(seed=42, difficulty="easy")
203
+ obs = env.step(CodeReviewAction(action_type="submit_review"))
204
+ assert obs.reward < 0.15
205
+
206
+ def test_partial_review_beats_empty(self):
207
+ env1 = CodeReviewEnvironment()
208
+ env1.reset(seed=42, difficulty="easy")
209
+ state = env1.state
210
+ if not state.gold_bugs:
211
+ pytest.skip("No bugs")
212
+ gold = state.gold_bugs[0]
213
+ partial = env1.step(CodeReviewAction(
214
+ action_type="submit_review",
215
+ issues=[gold["description"]],
216
+ comment="Found a bug.",
217
+ ))
218
+
219
+ env2 = CodeReviewEnvironment()
220
+ env2.reset(seed=42, difficulty="easy")
221
+ empty = env2.step(CodeReviewAction(action_type="submit_review"))
222
+
223
+ assert partial.reward > empty.reward
224
+
225
+ def test_reward_breakdown_present(self):
226
+ env = CodeReviewEnvironment()
227
+ env.reset(seed=42, difficulty="easy")
228
+ obs = env.step(CodeReviewAction(
229
+ action_type="submit_review", issues=["test"], comment="test",
230
+ ))
231
+ assert obs.reward_breakdown is not None
232
+ for key in ["bug_detection", "fix_quality", "line_precision", "comment_quality", "efficiency"]:
233
+ assert key in obs.reward_breakdown
234
+
235
+
236
+ # ── Hint Tests ───────────────────────────────────────────────────────────────
237
+
238
+ class TestHints:
239
+
240
+ def test_hint_returns_text(self):
241
+ env = CodeReviewEnvironment()
242
+ env.reset(seed=42, difficulty="easy")
243
+ obs = env.step(CodeReviewAction(action_type="request_hint"))
244
+ assert obs.hint is not None and len(obs.hint) > 0
245
+
246
+ def test_hint_costs_efficiency(self):
247
+ """Hints reduce the efficiency signal at final grading."""
248
+ env1 = CodeReviewEnvironment()
249
+ env1.reset(seed=42, difficulty="easy")
250
+ state = env1.state
251
+ if not state.gold_bugs:
252
+ pytest.skip("No bugs")
253
+ gold = state.gold_bugs[0]
254
+ review = CodeReviewAction(
255
+ action_type="submit_review",
256
+ issues=[gold["description"]],
257
+ flagged_lines=gold["lines"],
258
+ suggestion=gold["fix"],
259
+ comment=f"Bug: {gold['description']}",
260
+ )
261
+ obs_no_hint = env1.step(review)
262
+
263
+ env2 = CodeReviewEnvironment()
264
+ env2.reset(seed=42, difficulty="easy")
265
+ env2.step(CodeReviewAction(action_type="request_hint"))
266
+ env2.step(CodeReviewAction(action_type="request_hint"))
267
+ obs_with_hints = env2.step(review)
268
+
269
+ assert obs_no_hint.reward >= obs_with_hints.reward
270
+
271
+ def test_progressive_hints(self):
272
+ env = CodeReviewEnvironment()
273
+ env.reset(seed=42, difficulty="easy")
274
+ h1 = env.step(CodeReviewAction(action_type="request_hint"))
275
+ h2 = env.step(CodeReviewAction(action_type="request_hint"))
276
+ h3 = env.step(CodeReviewAction(action_type="request_hint"))
277
+ assert len(h3.hint) >= len(h1.hint)
278
+
279
+
280
+ # ── Concurrent Session Tests ─────────────────────────────────────────────────
281
+
282
+ class TestConcurrentSessions:
283
+
284
+ def test_two_sessions_independent(self):
285
+ env1 = CodeReviewEnvironment()
286
+ env2 = CodeReviewEnvironment()
287
+ env1.reset(seed=42, difficulty="easy")
288
+ env2.reset(seed=99, difficulty="hard")
289
+ env1.step(CodeReviewAction(action_type="submit_review", issues=["bug"]))
290
+ assert env2.state.step_count == 0
291
+
292
+ def test_session_isolation(self):
293
+ env1 = CodeReviewEnvironment()
294
+ env2 = CodeReviewEnvironment()
295
+ env1.reset(seed=42, difficulty="easy")
296
+ env2.reset(seed=42, difficulty="easy")
297
+ env1.step(CodeReviewAction(action_type="flag_line", line=5))
298
+ assert env1.state.step_count == 1
299
+ assert env2.state.step_count == 0
300
+
301
+
302
+ # ── Bug Injector Tests ───────────────────────────────────────────────────────
303
+
304
+ class TestBugInjectors:
305
+
306
+ @pytest.mark.parametrize("injector,name", [
307
+ (off_by_one_injector, "off_by_one"),
308
+ (null_deref_injector, "null_deref"),
309
+ (wrong_operator_injector, "wrong_operator"),
310
+ (unused_var_injector, "unused_var"),
311
+ (logic_inversion_injector, "logic_inversion"),
312
+ ])
313
+ def test_regex_injector(self, injector, name):
314
+ rng = random.Random(42)
315
+ successes = 0
316
+ for snippet in SNIPPET_BANK:
317
+ if snippet.language == "python":
318
+ result = injector(snippet.code, rng)
319
+ if result is not None:
320
+ buggy_code, bug = result
321
+ assert bug.bug_type == name
322
+ assert len(bug.lines) > 0
323
+ successes += 1
324
+ assert successes >= 1
325
+
326
+ @pytest.mark.parametrize("injector,name", [
327
+ (ast_comparison_flip_injector, "ast_comparison_flip"),
328
+ (ast_binop_swap_injector, "ast_binop_swap"),
329
+ (ast_boolop_flip_injector, "ast_boolop_flip"),
330
+ (ast_return_negate_injector, "ast_return_negate"),
331
+ ])
332
+ def test_ast_injector(self, injector, name):
333
+ """AST-based injectors produce valid mutations on Python snippets."""
334
+ rng = random.Random(42)
335
+ successes = 0
336
+ for snippet in SNIPPET_BANK:
337
+ if snippet.language == "python":
338
+ result = injector(snippet.code, rng)
339
+ if result is not None:
340
+ buggy_code, bug = result
341
+ assert bug.bug_type == name
342
+ assert len(bug.lines) > 0
343
+ successes += 1
344
+ assert successes >= 1, f"AST injector {name} failed on all Python snippets"
345
+
346
+ def test_ast_injectors_preserve_syntax(self):
347
+ """AST-injected Python code should still be parseable."""
348
+ import ast as _ast
349
+ rng = random.Random(42)
350
+ for snippet in SNIPPET_BANK[:8]:
351
+ if snippet.language != "python":
352
+ continue
353
+ for injector in AST_INJECTORS:
354
+ result = injector(snippet.code, rng)
355
+ if result is not None:
356
+ buggy_code, _ = result
357
+ try:
358
+ _ast.parse(buggy_code)
359
+ except SyntaxError:
360
+ pass # Some edge cases may break; soft check
361
+
362
+
363
+ # ── Procedural Generation Tests ──────────────────────────────────────────────
364
+
365
+ class TestProceduralGeneration:
366
+
367
+ def test_different_seeds_different_episodes(self):
368
+ codes = set()
369
+ for seed in range(10):
370
+ _, code, _ = generate_episode(seed=seed, difficulty="easy")
371
+ codes.add(code[:100])
372
+ assert len(codes) >= 3
373
+
374
+ def test_same_seed_reproducible(self):
375
+ s1, c1, b1 = generate_episode(seed=42, difficulty="easy")
376
+ s2, c2, b2 = generate_episode(seed=42, difficulty="easy")
377
+ assert s1.name == s2.name
378
+ assert c1 == c2
379
+
380
+ def test_difficulty_affects_bug_count(self):
381
+ easy_bugs = [len(generate_episode(seed=s, difficulty="easy")[2]) for s in range(20)]
382
+ hard_bugs = [len(generate_episode(seed=s+1000, difficulty="hard")[2]) for s in range(20)]
383
+ assert sum(hard_bugs) / len(hard_bugs) >= sum(easy_bugs) / len(easy_bugs)
384
+
385
+ def test_snippet_bank_size(self):
386
+ assert len(SNIPPET_BANK) >= 30
387
+
388
+ def test_snippet_bank_covers_languages(self):
389
+ languages = {s.language for s in SNIPPET_BANK}
390
+ assert "python" in languages
391
+ assert "javascript" in languages
392
+ assert "go" in languages
393
+
394
+ def test_injector_count(self):
395
+ """Total injectors: 5 regex + 4 AST = 9."""
396
+ assert len(REGEX_INJECTORS) == 5
397
+ assert len(AST_INJECTORS) == 4
398
+ assert len(BUG_INJECTORS) == 9
399
+
400
+
401
+ # ── Reward Signal Tests ──────────────────────────────────────────────────────
402
+
403
+ class TestRewardSignals:
404
+
405
+ def test_all_signals_in_range(self):
406
+ from snippet_bank import BugRecord
407
+ bugs = [BugRecord("test bug", [5], "fix it", "off_by_one")]
408
+ total, breakdown = compute_reward(
409
+ issues=["test"], flagged_lines=[5], suggestion="fix",
410
+ comment="comment", gold_bugs=bugs,
411
+ )
412
+ assert 0.0 <= total <= 1.0
413
+ for key in ["bug_detection", "fix_quality", "line_precision", "comment_quality", "efficiency"]:
414
+ assert 0.0 <= breakdown[key] <= 1.0
415
+
416
+ def test_line_f1_perfect(self):
417
+ from snippet_bank import BugRecord
418
+ bugs = [BugRecord("bug", [5, 10], "fix", "off_by_one")]
419
+ assert _line_f1([5, 10], bugs) == 1.0
420
+
421
+ def test_line_f1_empty(self):
422
+ from snippet_bank import BugRecord
423
+ bugs = [BugRecord("bug", [5], "fix", "off_by_one")]
424
+ assert _line_f1([], bugs) == 0.0
425
+
426
+ def test_comment_quality_scales(self):
427
+ short = _comment_score("bug")
428
+ long = _comment_score(
429
+ "Consider adding a null check on line 5 to guard against "
430
+ "NullPointerException. You should use an if-guard before dereference."
431
+ )
432
+ assert long > short
validate.py CHANGED
@@ -1,22 +1,20 @@
1
  #!/usr/bin/env python3
2
  """
3
- OpenEnv Spec Compliance Validator for CodeReviewEnv
4
 
5
  Runs all spec compliance checks. Exit 0 if all pass, exit 1 if any fail.
6
- Includes both standard OpenEnv checks and research-grade statistical validity.
7
  """
8
 
9
  import sys
10
- import json
11
  import os
12
  import random
13
- import statistics
14
 
15
  import yaml
16
 
17
- from env.base import CodeReviewEnv
18
- from env.models import Action, Observation, Reward, State
19
- from env.data_generator import PR_TEMPLATES
 
20
 
21
 
22
  def check(name: str, condition: bool, reason: str = "") -> bool:
@@ -24,7 +22,7 @@ def check(name: str, condition: bool, reason: str = "") -> bool:
24
  status = "PASS" if condition else "FAIL"
25
  msg = f" [{status}] {name}"
26
  if not condition and reason:
27
- msg += f" {reason}"
28
  print(msg)
29
  return condition
30
 
@@ -32,295 +30,214 @@ def check(name: str, condition: bool, reason: str = "") -> bool:
32
  def validate():
33
  """Run all validation checks."""
34
  print("=" * 60)
35
- print("CodeReviewEnv OpenEnv Compliance Validation")
36
  print("=" * 60)
37
  results = []
38
 
39
- # ── 1. reset() returns valid Observation ────────────────────────
40
  print("\n--- Core Interface ---")
41
- for task in ["easy", "medium", "hard"]:
42
  try:
43
- env = CodeReviewEnv(task=task, seed=42)
44
- obs = env.reset()
45
  results.append(check(
46
- f"reset() returns Observation ({task})",
47
- isinstance(obs, Observation),
48
  ))
49
  except Exception as e:
50
- results.append(check(f"reset() returns Observation ({task})", False, str(e)))
51
 
52
- # ── 2. step() with valid action returns correct tuple ───────────
53
  try:
54
- env = CodeReviewEnv(task="easy", seed=42)
55
- env.reset()
56
- action = Action(action_type="label_severity", severity="high")
57
- result = env.step(action)
58
  results.append(check(
59
- "step() returns (Observation, Reward, bool, Dict)",
60
- len(result) == 4
61
- and isinstance(result[0], Observation)
62
- and isinstance(result[1], Reward)
63
- and isinstance(result[2], bool)
64
- and isinstance(result[3], dict),
65
  ))
66
  except Exception as e:
67
- results.append(check("step() returns correct tuple", False, str(e)))
68
 
69
- # ── 3. step() with invalid action doesn't raise ─────────────────
70
  try:
71
- env = CodeReviewEnv(task="easy", seed=42)
72
- env.reset()
73
- # Create a technically valid Action but with wrong type for task
74
- action = Action(action_type="approve")
75
- obs, reward, done, info = env.step(action)
76
  results.append(check(
77
- "step() with wrong action type doesn't crash",
78
- isinstance(reward, Reward) and 0 < reward.value < 1,
79
  ))
80
  except Exception as e:
81
- results.append(check("step() with invalid action doesn't crash", False, str(e)))
82
 
83
- # ── 4. reward.value always in [-1.0, 1.0] ──────────────────────
84
  try:
85
- env = CodeReviewEnv(task="easy", seed=42)
86
- env.reset()
87
  all_in_range = True
88
- severities = ["critical", "high", "medium", "low", "none"]
89
- for sev in severities * 20:
90
- action = Action(action_type="label_severity", severity=sev)
91
- env2 = CodeReviewEnv(task="easy", seed=random.randint(1, 1000))
92
- env2.reset()
93
- _, reward, _, _ = env2.step(action)
94
- if reward.value < -1.0 or reward.value > 1.0:
 
 
 
 
95
  all_in_range = False
96
  break
97
- results.append(check(
98
- "reward.value always in [-1.0, 1.0]",
99
- all_in_range,
100
- ))
101
  except Exception as e:
102
  results.append(check("reward bounds", False, str(e)))
103
 
104
- # ── 5. done=True after episode_length steps ─────────────────────
105
  try:
106
- env = CodeReviewEnv(task="easy", seed=42)
107
- env.reset()
108
- done = False
109
- for i in range(5):
110
- action = Action(action_type="label_severity", severity="medium")
111
- _, _, done, _ = env.step(action)
112
- results.append(check(
113
- "done=True after episode_length steps (easy=5)",
114
- done is True,
115
- ))
116
  except Exception as e:
117
  results.append(check("episode terminates", False, str(e)))
118
 
119
- # ── 6. state() returns State with correct trajectory length ─────
120
- try:
121
- env = CodeReviewEnv(task="easy", seed=42)
122
- env.reset()
123
- for i in range(3):
124
- action = Action(action_type="label_severity", severity="high")
125
- env.step(action)
126
- s = env.state()
127
- results.append(check(
128
- "state() trajectory length matches step count",
129
- isinstance(s, State) and len(s.trajectory) == 3,
130
- f"Expected 3, got {len(s.trajectory) if isinstance(s, State) else 'N/A'}",
131
- ))
132
- except Exception as e:
133
- results.append(check("state() correct", False, str(e)))
134
-
135
- # ── 7. export_trajectory() format ───────────────────────────────
136
  try:
137
- env = CodeReviewEnv(task="easy", seed=42)
138
- env.reset()
139
- for i in range(5):
140
- action = Action(action_type="label_severity", severity="high")
141
- env.step(action)
142
- traj = env.export_trajectory()
143
- required_keys = {"step", "state", "action", "reward", "next_state"}
144
- has_keys = all(required_keys <= set(t.keys()) for t in traj)
145
  results.append(check(
146
- "export_trajectory() has required keys",
147
- len(traj) > 0 and has_keys,
148
  ))
149
  except Exception as e:
150
- results.append(check("export_trajectory() format", False, str(e)))
151
 
152
- # ── 8-10. Grader score ranges ───────────────────────────────────
153
- print("\n--- Grader Validation ---")
154
-
155
- # Easy grader
156
  try:
157
- from graders.grader_easy import EasyGrader
158
- grader = EasyGrader()
159
- all_valid = True
160
- for template in PR_TEMPLATES:
161
- grader.reset()
162
- for sev in ["critical", "high", "medium", "low", "none"]:
163
- action = Action(action_type="label_severity", severity=sev)
164
- reward, _ = grader.grade(action, template["pr_id"])
165
- if reward.value < -1.0 or reward.value > 1.0:
166
- all_valid = False
167
- results.append(check("grader_easy scores in [-1, 1]", all_valid))
168
  except Exception as e:
169
- results.append(check("grader_easy range", False, str(e)))
170
 
171
- # Medium grader
 
172
  try:
173
- from graders.grader_medium import MediumGrader
174
- from env.data_generator import DataGenerator
175
- grader = MediumGrader()
176
- gen = DataGenerator(seed=42)
177
- queues = gen.generate_medium_episode(num_queues=3, queue_size=5)
178
- all_valid = True
179
- for queue in queues:
180
- gt_order = gen.compute_priority_order(queue)
181
- # Test with correct order
182
- action = Action(action_type="prioritize", priority_order=gt_order)
183
- reward, _ = grader.grade(action, queue, gt_order)
184
- if reward.value < 0.0 or reward.value > 1.0:
185
- all_valid = False
186
- # Test with reversed order
187
- action = Action(action_type="prioritize", priority_order=list(reversed(gt_order)))
188
- reward, _ = grader.grade(action, queue, gt_order)
189
- if reward.value < 0.0 or reward.value > 1.0:
190
- all_valid = False
191
- results.append(check("grader_medium scores in [0, 1]", all_valid))
192
  except Exception as e:
193
- results.append(check("grader_medium range", False, str(e)))
194
 
195
- # Hard grader
196
  try:
197
- from graders.grader_hard import HardGrader
198
- grader = HardGrader()
199
- all_valid = True
200
- for template in PR_TEMPLATES[:5]:
201
- grader.reset()
202
- pr_id = template["pr_id"]
203
- bug_lines = template["bug_lines"]
204
- if bug_lines:
205
- comment_action = Action(
206
- action_type="add_comment",
207
- comment="Consider adding null check here to prevent crash",
208
- target_file=template["filename"],
209
- target_line=bug_lines[0],
210
- )
211
- grader.add_comment(pr_id, comment_action)
212
- reward, _ = grader.grade_pr(pr_id, "request_changes")
213
- if reward.value < -1.0 or reward.value > 1.0:
214
- all_valid = False
215
- results.append(check("grader_hard scores in [-1, 1]", all_valid))
216
  except Exception as e:
217
- results.append(check("grader_hard range", False, str(e)))
218
 
219
- # ── 11. Score variance check ────────────────────────────────────
220
- print("\n--- Statistical Validity ---")
221
  try:
222
- scores = []
223
- for template in PR_TEMPLATES:
224
- grader = EasyGrader()
225
- # Test with random severity
226
- sev = random.choice(["critical", "high", "medium", "low", "none"])
227
- action = Action(action_type="label_severity", severity=sev)
228
- reward, _ = grader.grade(action, template["pr_id"])
229
- scores.append(reward.value)
230
- std = statistics.stdev(scores)
231
  results.append(check(
232
- f"Score variance: std={std:.3f} > 0.05",
233
- std > 0.05,
234
  ))
235
  except Exception as e:
236
- results.append(check("score variance", False, str(e)))
237
 
238
- # ── 12. Random agent < perfect agent ────────────────────────────
239
  try:
240
- # Perfect agent
241
- perfect_scores = []
242
- for template in PR_TEMPLATES:
243
- grader = EasyGrader()
244
- action = Action(action_type="label_severity", severity=template["ground_truth_severity"])
245
- reward, _ = grader.grade(action, template["pr_id"])
246
- perfect_scores.append(reward.value)
247
- perfect_mean = statistics.mean(perfect_scores)
248
-
249
- # Random agent
250
- random.seed(42)
251
- random_scores = []
252
- for template in PR_TEMPLATES:
253
- grader = EasyGrader()
254
- sev = random.choice(["critical", "high", "medium", "low", "none"])
255
- action = Action(action_type="label_severity", severity=sev)
256
- reward, _ = grader.grade(action, template["pr_id"])
257
- random_scores.append(reward.value)
258
- random_mean = statistics.mean(random_scores)
259
-
260
- gap = perfect_mean - random_mean
261
  results.append(check(
262
- f"Perfect ({perfect_mean:.2f}) > Random ({random_mean:.2f}) by {gap:.2f} (>0.3)",
263
- gap > 0.3,
264
  ))
265
  except Exception as e:
266
- results.append(check("perfect vs random gap", False, str(e)))
267
 
268
- # ── 13. Reproducibility ─────────────────────────────────────────
 
269
  try:
270
- env1 = CodeReviewEnv(task="easy", seed=42)
271
- obs1 = env1.reset()
272
- traj1 = []
273
- for _ in range(5):
274
- action = Action(action_type="label_severity", severity="high")
275
- _, reward, _, _ = env1.step(action)
276
- traj1.append(reward.value)
277
-
278
- env2 = CodeReviewEnv(task="easy", seed=42)
279
- obs2 = env2.reset()
280
- traj2 = []
281
- for _ in range(5):
282
- action = Action(action_type="label_severity", severity="high")
283
- _, reward, _, _ = env2.step(action)
284
- traj2.append(reward.value)
 
 
 
 
 
285
 
 
 
 
 
 
286
  results.append(check(
287
- "seed=42 reproducibility",
288
- obs1.pr_id == obs2.pr_id and traj1 == traj2,
289
  ))
290
  except Exception as e:
291
- results.append(check("reproducibility", False, str(e)))
292
 
293
- # ── 14. openenv.yaml valid ──────────────────────────────────────
294
  print("\n--- Packaging ---")
295
  try:
296
  with open("openenv.yaml", "r") as f:
297
  config = yaml.safe_load(f)
298
- required = ["name", "version", "description", "tasks"]
299
  has_all = all(k in config for k in required)
300
- results.append(check(
301
- "openenv.yaml is valid with required fields",
302
- has_all,
303
- ))
304
  except Exception as e:
305
  results.append(check("openenv.yaml", False, str(e)))
306
 
307
- # ── 15. Dockerfile exists ───────────────────────────────────────
308
- results.append(check(
309
- "Dockerfile exists",
310
- os.path.exists("Dockerfile"),
311
- ))
312
 
313
- # ── Summary ─────────────────────────────────────────────────────
314
  print("\n" + "=" * 60)
315
  passed = sum(results)
316
  total = len(results)
317
  print(f"Results: {passed}/{total} checks passed")
318
 
319
  if passed == total:
320
- print("ALL CHECKS PASSED OpenEnv compliant")
321
  return 0
322
  else:
323
- print(f"{total - passed} checks FAILED")
324
  return 1
325
 
326
 
 
1
  #!/usr/bin/env python3
2
  """
3
+ OpenEnv Spec Compliance Validator for CodeReviewEnv v2
4
 
5
  Runs all spec compliance checks. Exit 0 if all pass, exit 1 if any fail.
 
6
  """
7
 
8
  import sys
 
9
  import os
10
  import random
 
11
 
12
  import yaml
13
 
14
+ from server.code_review_environment import CodeReviewEnvironment
15
+ from models import CodeReviewAction, CodeReviewObservation, CodeReviewState
16
+ from snippet_bank import SNIPPET_BANK, BUG_INJECTORS, generate_episode
17
+ from reward import compute_reward
18
 
19
 
20
  def check(name: str, condition: bool, reason: str = "") -> bool:
 
22
  status = "PASS" if condition else "FAIL"
23
  msg = f" [{status}] {name}"
24
  if not condition and reason:
25
+ msg += f" -- {reason}"
26
  print(msg)
27
  return condition
28
 
 
30
  def validate():
31
  """Run all validation checks."""
32
  print("=" * 60)
33
+ print("CodeReviewEnv -- OpenEnv Compliance Validation")
34
  print("=" * 60)
35
  results = []
36
 
37
+ # 1. reset() returns valid Observation for all difficulties
38
  print("\n--- Core Interface ---")
39
+ for difficulty in ["easy", "medium", "hard"]:
40
  try:
41
+ env = CodeReviewEnvironment()
42
+ obs = env.reset(seed=42, difficulty=difficulty)
43
  results.append(check(
44
+ f"reset() returns Observation ({difficulty})",
45
+ isinstance(obs, CodeReviewObservation) and obs.code != "",
46
  ))
47
  except Exception as e:
48
+ results.append(check(f"reset() returns Observation ({difficulty})", False, str(e)))
49
 
50
+ # 2. step() with valid action returns observation
51
  try:
52
+ env = CodeReviewEnvironment()
53
+ env.reset(seed=42, difficulty="easy")
54
+ action = CodeReviewAction(issues=["test"], flagged_lines=[1], suggestion="fix", comment="comment")
55
+ obs = env.step(action)
56
  results.append(check(
57
+ "step() returns CodeReviewObservation with reward",
58
+ isinstance(obs, CodeReviewObservation) and obs.done is True and obs.reward is not None,
 
 
 
 
59
  ))
60
  except Exception as e:
61
+ results.append(check("step() returns correct observation", False, str(e)))
62
 
63
+ # 3. step() with empty action doesn't crash
64
  try:
65
+ env = CodeReviewEnvironment()
66
+ env.reset(seed=42, difficulty="easy")
67
+ action = CodeReviewAction()
68
+ obs = env.step(action)
 
69
  results.append(check(
70
+ "step() with empty action doesn't crash",
71
+ isinstance(obs, CodeReviewObservation) and 0.0 <= obs.reward <= 1.0,
72
  ))
73
  except Exception as e:
74
+ results.append(check("step() with empty action", False, str(e)))
75
 
76
+ # 4. reward always in [0, 1]
77
  try:
 
 
78
  all_in_range = True
79
+ for seed in range(50):
80
+ env = CodeReviewEnvironment()
81
+ env.reset(seed=seed, difficulty=random.choice(["easy", "medium", "hard"]))
82
+ action = CodeReviewAction(
83
+ issues=["bug found"],
84
+ flagged_lines=[random.randint(1, 20)],
85
+ suggestion="fix it",
86
+ comment="review",
87
+ )
88
+ obs = env.step(action)
89
+ if obs.reward < 0.0 or obs.reward > 1.0:
90
  all_in_range = False
91
  break
92
+ results.append(check("reward always in [0, 1]", all_in_range))
 
 
 
93
  except Exception as e:
94
  results.append(check("reward bounds", False, str(e)))
95
 
96
+ # 5. done=True after step()
97
  try:
98
+ env = CodeReviewEnvironment()
99
+ env.reset(seed=42, difficulty="easy")
100
+ obs = env.step(CodeReviewAction(issues=["test"]))
101
+ results.append(check("done=True after step()", obs.done is True))
 
 
 
 
 
 
102
  except Exception as e:
103
  results.append(check("episode terminates", False, str(e)))
104
 
105
+ # 6. state contains gold bugs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  try:
107
+ env = CodeReviewEnvironment()
108
+ env.reset(seed=42, difficulty="easy")
109
+ state = env.state
 
 
 
 
 
110
  results.append(check(
111
+ "state contains gold_bugs",
112
+ isinstance(state, CodeReviewState) and isinstance(state.gold_bugs, list),
113
  ))
114
  except Exception as e:
115
+ results.append(check("state correct", False, str(e)))
116
 
117
+ # 7. Reward breakdown present
 
 
 
118
  try:
119
+ env = CodeReviewEnvironment()
120
+ env.reset(seed=42, difficulty="easy")
121
+ obs = env.step(CodeReviewAction(issues=["test"]))
122
+ breakdown = obs.reward_breakdown
123
+ has_signals = breakdown is not None and "bug_detection" in breakdown
124
+ results.append(check("reward breakdown has all 5 signals", has_signals))
 
 
 
 
 
125
  except Exception as e:
126
+ results.append(check("reward breakdown", False, str(e)))
127
 
128
+ # 8. Procedural generation: different seeds differ
129
+ print("\n--- Procedural Generation ---")
130
  try:
131
+ codes = set()
132
+ for seed in range(10):
133
+ _, code, _ = generate_episode(seed=seed, difficulty="easy")
134
+ codes.add(code[:100])
135
+ results.append(check(
136
+ f"Different seeds -> different episodes ({len(codes)}/10 unique)",
137
+ len(codes) >= 3,
138
+ ))
 
 
 
 
 
 
 
 
 
 
 
139
  except Exception as e:
140
+ results.append(check("procedural generation", False, str(e)))
141
 
142
+ # 9. Reproducibility: same seed -> same episode
143
  try:
144
+ _, c1, b1 = generate_episode(seed=42, difficulty="easy")
145
+ _, c2, b2 = generate_episode(seed=42, difficulty="easy")
146
+ results.append(check(
147
+ "seed=42 reproducibility",
148
+ c1 == c2 and len(b1) == len(b2),
149
+ ))
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  except Exception as e:
151
+ results.append(check("reproducibility", False, str(e)))
152
 
153
+ # 10. Snippet bank size
 
154
  try:
 
 
 
 
 
 
 
 
 
155
  results.append(check(
156
+ f"Snippet bank has {len(SNIPPET_BANK)} entries (>= 30)",
157
+ len(SNIPPET_BANK) >= 30,
158
  ))
159
  except Exception as e:
160
+ results.append(check("snippet bank size", False, str(e)))
161
 
162
+ # 11. Bug injectors work
163
  try:
164
+ rng = random.Random(42)
165
+ successes = 0
166
+ for inj in BUG_INJECTORS:
167
+ for s in SNIPPET_BANK[:5]:
168
+ if s.language == "python":
169
+ result = inj(s.code, rng)
170
+ if result is not None:
171
+ successes += 1
172
+ break
 
 
 
 
 
 
 
 
 
 
 
 
173
  results.append(check(
174
+ f"Bug injectors functional ({successes}/{len(BUG_INJECTORS)})",
175
+ successes >= 3,
176
  ))
177
  except Exception as e:
178
+ results.append(check("bug injectors", False, str(e)))
179
 
180
+ # 12. Perfect agent scores high
181
+ print("\n--- Reward Quality ---")
182
  try:
183
+ env = CodeReviewEnvironment()
184
+ env.reset(seed=42, difficulty="easy")
185
+ state = env.state
186
+ if state.gold_bugs:
187
+ gold = state.gold_bugs[0]
188
+ action = CodeReviewAction(
189
+ issues=[gold["description"]],
190
+ flagged_lines=gold["lines"],
191
+ suggestion=gold["fix"],
192
+ comment=f"Found {gold['bug_type']} bug. {gold['fix']}",
193
+ )
194
+ obs = env.step(action)
195
+ results.append(check(
196
+ f"Perfect agent scores >= 0.5 (got {obs.reward:.3f})",
197
+ obs.reward >= 0.5,
198
+ ))
199
+ else:
200
+ results.append(check("Perfect agent (no bugs to test)", True))
201
+ except Exception as e:
202
+ results.append(check("perfect agent", False, str(e)))
203
 
204
+ # 13. Empty agent scores low
205
+ try:
206
+ env = CodeReviewEnvironment()
207
+ env.reset(seed=42, difficulty="easy")
208
+ obs = env.step(CodeReviewAction())
209
  results.append(check(
210
+ f"Empty agent scores < 0.15 (got {obs.reward:.3f})",
211
+ obs.reward < 0.15,
212
  ))
213
  except Exception as e:
214
+ results.append(check("empty agent", False, str(e)))
215
 
216
+ # 14. openenv.yaml valid
217
  print("\n--- Packaging ---")
218
  try:
219
  with open("openenv.yaml", "r") as f:
220
  config = yaml.safe_load(f)
221
+ required = ["spec_version", "name", "type", "runtime", "app", "port"]
222
  has_all = all(k in config for k in required)
223
+ results.append(check("openenv.yaml has required fields", has_all))
 
 
 
224
  except Exception as e:
225
  results.append(check("openenv.yaml", False, str(e)))
226
 
227
+ # 15. Dockerfile exists
228
+ results.append(check("Dockerfile exists", os.path.exists("Dockerfile")))
 
 
 
229
 
230
+ # Summary
231
  print("\n" + "=" * 60)
232
  passed = sum(results)
233
  total = len(results)
234
  print(f"Results: {passed}/{total} checks passed")
235
 
236
  if passed == total:
237
+ print("ALL CHECKS PASSED -- OpenEnv compliant")
238
  return 0
239
  else:
240
+ print(f"{total - passed} checks FAILED")
241
  return 1
242
 
243