Spaces:
Runtime error
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>
- Dockerfile +15 -7
- README.md +108 -601
- baseline.py +97 -114
- baseline/heuristic_results.json +23 -17
- client.py +8 -5
- inference.py +174 -471
- models.py +66 -50
- openenv.yaml +13 -63
- reward.py +296 -0
- server/app.py +110 -58
- server/code_review_environment.py +332 -297
- snippet_bank.py +1836 -0
- tests/test_code_review_env.py +432 -0
- validate.py +137 -220
|
@@ -1,19 +1,27 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
-
#
|
|
|
|
| 4 |
WORKDIR /app
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
-
# Install dependencies
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 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"]
|
|
|
|
@@ -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: "
|
| 9 |
tags:
|
| 10 |
- openenv
|
| 11 |
- reinforcement-learning
|
| 12 |
- code-review
|
| 13 |
- mbrl
|
| 14 |
-
-
|
| 15 |
- llm-agents
|
| 16 |
-
- semantic-world-model
|
| 17 |
---
|
| 18 |
|
| 19 |
-
#
|
| 20 |
|
| 21 |
-
**An OpenEnv-compliant RL environment for
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
|
| 26 |
|
| 27 |
-
|
| 28 |
-
[](https://www.python.org/downloads/)
|
| 29 |
-
[](LICENSE)
|
| 30 |
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
-
|
| 36 |
-
|
| 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 |
-
|
| 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 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 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 |
-
##
|
| 207 |
|
| 208 |
-
|
|
| 209 |
-
|----------|-------|
|
| 210 |
-
|
|
| 211 |
-
|
|
| 212 |
-
|
|
| 213 |
-
|
|
| 214 |
-
|
|
| 215 |
-
| Expected Score (random) | ~0.31 |
|
| 216 |
-
| Expected Score (GPT-4o-mini) | ~0.94 |
|
| 217 |
-
| Expected Score (perfect) | 1.00 |
|
| 218 |
|
| 219 |
-
|
| 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 |
-
##
|
| 241 |
|
| 242 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
-
##
|
| 245 |
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
|
| 255 |
-
|
| 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 |
-
#
|
| 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 |
-
#
|
| 292 |
-
|
| 293 |
-
```
|
| 294 |
|
| 295 |
-
#
|
| 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
|
| 326 |
-
from
|
| 327 |
-
|
| 328 |
-
# Initialize with task and seed
|
| 329 |
-
env = CodeReviewEnv(task="easy", seed=42)
|
| 330 |
-
obs = env.reset()
|
| 331 |
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
obs, reward, done, info = env.step(action)
|
| 335 |
|
| 336 |
-
print(
|
| 337 |
-
print(
|
| 338 |
-
print(
|
| 339 |
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
| 344 |
|
| 345 |
-
|
| 346 |
-
|
|
|
|
| 347 |
```
|
| 348 |
|
| 349 |
-
##
|
| 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 |
-
|
| 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 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 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 |
-
|
| 408 |
-
|
| 409 |
-
|
| 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 |
-
##
|
| 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 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 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 |
-
@
|
| 663 |
-
title={CodeReviewEnv: A
|
| 664 |
-
author={
|
| 665 |
-
year={
|
| 666 |
-
|
| 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 |
```
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,10 +1,10 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
-
CodeReviewEnv
|
| 4 |
======================================
|
| 5 |
-
Runs a simple heuristic agent (no LLM) against all three
|
| 6 |
-
and reports per-
|
| 7 |
-
|
| 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
|
| 23 |
-
from
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
# ──
|
| 27 |
-
|
| 28 |
-
def
|
| 29 |
-
"""Simple
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 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
|
| 101 |
-
"""Run one
|
| 102 |
-
env =
|
| 103 |
-
obs = env.reset()
|
| 104 |
-
|
| 105 |
-
done = False
|
| 106 |
-
step_in_pr = 0
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
-
#
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
|
| 123 |
|
| 124 |
-
# ──
|
| 125 |
|
| 126 |
def main():
|
| 127 |
SEED = 42
|
| 128 |
-
N_EPISODES =
|
| 129 |
|
| 130 |
print("=" * 60)
|
| 131 |
-
print("CodeReviewEnv
|
| 132 |
print("=" * 60)
|
| 133 |
|
| 134 |
start_time = time.time()
|
| 135 |
all_results: Dict[str, Dict] = {}
|
| 136 |
|
| 137 |
-
for
|
| 138 |
-
print(f"\n--- {
|
| 139 |
scores = []
|
| 140 |
|
| 141 |
for ep in range(N_EPISODES):
|
| 142 |
ep_seed = SEED + ep
|
| 143 |
-
score, steps =
|
| 144 |
scores.append(score)
|
| 145 |
-
print(f" Episode {ep + 1}: score={score:.4f} ({steps}
|
| 146 |
|
| 147 |
mean = statistics.mean(scores)
|
| 148 |
std = statistics.stdev(scores) if len(scores) > 1 else 0.0
|
| 149 |
-
all_results[
|
| 150 |
"mean": round(mean, 4),
|
| 151 |
"std": round(std, 4),
|
| 152 |
"scores": [round(s, 4) for s in scores],
|
| 153 |
}
|
| 154 |
-
print(f"
|
| 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 |
-
#
|
| 162 |
print(f"\n{'=' * 60}")
|
| 163 |
-
print(f"{'
|
| 164 |
print(f"{'-' * 10}-+-{'-' * 8}-+-{'-' * 8}-+-{'-' * 20}")
|
| 165 |
-
for
|
| 166 |
-
r = all_results[
|
| 167 |
scores_str = ", ".join(f"{s:.3f}" for s in r["scores"])
|
| 168 |
-
print(f"{
|
| 169 |
print(f"{'=' * 60}")
|
| 170 |
print(f"Composite Score: {composite:.4f}")
|
| 171 |
print(f"Elapsed: {elapsed:.1f}s")
|
| 172 |
|
| 173 |
-
#
|
| 174 |
output = {
|
| 175 |
"agent": "heuristic_baseline",
|
| 176 |
"composite": composite,
|
| 177 |
"seed": SEED,
|
| 178 |
-
"
|
| 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 |
}
|
|
@@ -1,33 +1,39 @@
|
|
| 1 |
{
|
| 2 |
"agent": "heuristic_baseline",
|
| 3 |
-
"composite": 0.
|
| 4 |
"seed": 42,
|
| 5 |
-
"
|
| 6 |
"easy": {
|
| 7 |
-
"mean": 0.
|
| 8 |
-
"std": 0.
|
| 9 |
"scores": [
|
| 10 |
-
0.
|
| 11 |
-
0.
|
| 12 |
-
0.
|
|
|
|
|
|
|
| 13 |
]
|
| 14 |
},
|
| 15 |
"medium": {
|
| 16 |
-
"mean": 0.
|
| 17 |
-
"std": 0.
|
| 18 |
"scores": [
|
| 19 |
-
0.
|
| 20 |
-
0.
|
| 21 |
-
0.
|
|
|
|
|
|
|
| 22 |
]
|
| 23 |
},
|
| 24 |
"hard": {
|
| 25 |
-
"mean": 0.
|
| 26 |
-
"std": 0.
|
| 27 |
"scores": [
|
| 28 |
-
0.
|
| 29 |
-
0.
|
| 30 |
-
0.
|
|
|
|
|
|
|
| 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
|
|
@@ -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(
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
# Sync
|
| 12 |
-
with CodeReviewEnv(base_url="http://localhost:
|
| 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(
|
|
@@ -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=
|
| 32 |
-
[STEP] step=1 action=
|
| 33 |
-
[
|
| 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("\"'")
|
| 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")
|
| 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 =
|
| 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.
|
| 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}
|
| 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 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 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
|
| 358 |
-
"""Format
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 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"
|
| 401 |
-
f"
|
| 402 |
-
f"
|
| 403 |
-
f"{
|
| 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", "
|
| 412 |
-
if at == "
|
| 413 |
-
return f"
|
| 414 |
-
elif at == "
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
comment = (action_dict.get("comment", ""))[:50]
|
| 419 |
-
return f"add_comment:{comment}"
|
| 420 |
else:
|
| 421 |
-
|
| 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 |
-
|
| 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 |
-
|
| 503 |
-
|
| 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=
|
| 551 |
obs = result.observation
|
| 552 |
|
| 553 |
-
#
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
| 559 |
if result.done:
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 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 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 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 |
-
|
| 599 |
-
|
| 600 |
-
|
|
|
|
|
|
|
|
|
|
| 601 |
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 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 |
-
|
|
|
|
| 611 |
try:
|
| 612 |
-
|
|
|
|
| 613 |
obs = result.observation
|
| 614 |
-
reward = result.reward or 0.
|
| 615 |
-
|
| 616 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
except Exception as e:
|
| 618 |
-
print(f"[DEBUG]
|
| 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 |
-
|
| 635 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
|
|
|
| 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 |
-
#
|
| 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
|
| 658 |
-
|
| 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 |
-
|
| 682 |
-
|
| 683 |
-
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]
|
| 698 |
env = await CodeReviewEnv.from_docker_image(IMAGE_NAME)
|
| 699 |
else:
|
| 700 |
-
print(f"[DEBUG]
|
| 701 |
env = CodeReviewEnv(base_url=space_url)
|
| 702 |
|
| 703 |
-
score, steps_taken, rewards = await run_task(env, llm_client,
|
| 704 |
success = score >= SUCCESS_SCORE_THRESHOLD
|
| 705 |
|
| 706 |
except Exception as e:
|
| 707 |
-
print(f"[ERROR] Task {
|
| 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[
|
| 726 |
|
| 727 |
composite = sum(scores.values()) / max(len(scores), 1)
|
| 728 |
-
print(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -1,11 +1,13 @@
|
|
| 1 |
"""
|
| 2 |
CodeReviewEnv — OpenEnv-compliant typed models.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 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 |
-
"""
|
| 23 |
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
| 27 |
"""
|
| 28 |
|
| 29 |
model_config = ConfigDict(extra="forbid")
|
| 30 |
|
| 31 |
action_type: str = Field(
|
| 32 |
-
|
|
|
|
| 33 |
)
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
| 36 |
)
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
| 39 |
)
|
| 40 |
-
|
| 41 |
-
|
|
|
|
| 42 |
)
|
| 43 |
-
|
| 44 |
-
default=
|
|
|
|
| 45 |
)
|
| 46 |
-
|
| 47 |
-
default=
|
|
|
|
| 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
|
|
|
|
| 59 |
"""
|
| 60 |
|
| 61 |
model_config = ConfigDict(extra="allow")
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
)
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
| 72 |
)
|
| 73 |
-
|
| 74 |
-
default_factory=list,
|
|
|
|
| 75 |
)
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
default=None, description="Detailed reward component breakdown"
|
| 80 |
)
|
| 81 |
-
|
| 82 |
-
default=None,
|
|
|
|
| 83 |
)
|
| 84 |
|
| 85 |
|
|
@@ -87,21 +104,20 @@ class CodeReviewObservation(Observation):
|
|
| 87 |
|
| 88 |
|
| 89 |
class CodeReviewState(State):
|
| 90 |
-
"""
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
|
|
|
| 94 |
"""
|
| 95 |
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
default_factory=list,
|
| 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")
|
|
@@ -1,66 +1,16 @@
|
|
|
|
|
| 1 |
name: code-review-env
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
@@ -1,25 +1,27 @@
|
|
| 1 |
"""
|
| 2 |
-
FastAPI application for CodeReviewEnv —
|
| 3 |
|
| 4 |
-
|
| 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 |
-
/export_trajectory — GET trajectory export (JSONL)
|
| 11 |
/docs — OpenAPI documentation
|
| 12 |
-
/web — Interactive web UI (when enabled)
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
uvicorn server.app:app --host 0.0.0.0 --port
|
| 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 |
-
#
|
| 32 |
-
|
| 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
|
| 49 |
return {
|
| 50 |
"status": "ok",
|
| 51 |
"environment": "CodeReviewEnv",
|
| 52 |
-
"version": "
|
| 53 |
-
"
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
],
|
| 58 |
-
"task_count": 3,
|
| 59 |
"grader": "deterministic",
|
| 60 |
-
"
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
|
| 64 |
-
# ───
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 73 |
-
format: str = Query(default="jsonl"
|
| 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 |
-
|
| 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."""
|
|
@@ -1,15 +1,18 @@
|
|
| 1 |
"""
|
| 2 |
-
CodeReviewEnvironment — OpenEnv-compliant RL environment for code review.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 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
|
| 23 |
-
from
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 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 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 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,
|
| 49 |
super().__init__()
|
| 50 |
-
self.
|
| 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.
|
| 57 |
-
self.
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
self.
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 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.
|
| 87 |
-
self.
|
| 88 |
-
|
| 89 |
-
self.
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 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.
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
-
|
| 122 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
-
|
| 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
|
| 136 |
-
|
| 137 |
-
|
| 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 |
-
|
| 175 |
-
if
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
else:
|
| 178 |
-
|
| 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 |
-
"""
|
| 223 |
return CodeReviewState(
|
| 224 |
episode_id=self._episode_id,
|
| 225 |
step_count=self._step_count,
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 240 |
-
"Agents
|
| 241 |
-
"
|
| 242 |
-
"and feedback generation (hard)."
|
| 243 |
),
|
| 244 |
-
version="
|
| 245 |
author="CodeReviewEnv Team",
|
| 246 |
)
|
| 247 |
|
| 248 |
-
# ───
|
| 249 |
-
|
| 250 |
-
def
|
| 251 |
-
"""
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 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 |
-
|
| 344 |
-
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
|
| 347 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
self,
|
| 349 |
-
|
| 350 |
done: bool,
|
| 351 |
-
|
| 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 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
)
|
| 376 |
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
| 18 |
-
from
|
| 19 |
-
from
|
|
|
|
| 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"
|
| 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
|
| 36 |
print("=" * 60)
|
| 37 |
results = []
|
| 38 |
|
| 39 |
-
#
|
| 40 |
print("\n--- Core Interface ---")
|
| 41 |
-
for
|
| 42 |
try:
|
| 43 |
-
env =
|
| 44 |
-
obs = env.reset()
|
| 45 |
results.append(check(
|
| 46 |
-
f"reset() returns Observation ({
|
| 47 |
-
isinstance(obs,
|
| 48 |
))
|
| 49 |
except Exception as e:
|
| 50 |
-
results.append(check(f"reset() returns Observation ({
|
| 51 |
|
| 52 |
-
#
|
| 53 |
try:
|
| 54 |
-
env =
|
| 55 |
-
env.reset()
|
| 56 |
-
action =
|
| 57 |
-
|
| 58 |
results.append(check(
|
| 59 |
-
"step() returns
|
| 60 |
-
|
| 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
|
| 68 |
|
| 69 |
-
#
|
| 70 |
try:
|
| 71 |
-
env =
|
| 72 |
-
env.reset()
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
obs, reward, done, info = env.step(action)
|
| 76 |
results.append(check(
|
| 77 |
-
"step() with
|
| 78 |
-
isinstance(
|
| 79 |
))
|
| 80 |
except Exception as e:
|
| 81 |
-
results.append(check("step() with
|
| 82 |
|
| 83 |
-
#
|
| 84 |
try:
|
| 85 |
-
env = CodeReviewEnv(task="easy", seed=42)
|
| 86 |
-
env.reset()
|
| 87 |
all_in_range = True
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 105 |
try:
|
| 106 |
-
env =
|
| 107 |
-
env.reset()
|
| 108 |
-
|
| 109 |
-
|
| 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 |
-
#
|
| 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 =
|
| 138 |
-
env.reset()
|
| 139 |
-
|
| 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 |
-
"
|
| 147 |
-
|
| 148 |
))
|
| 149 |
except Exception as e:
|
| 150 |
-
results.append(check("
|
| 151 |
|
| 152 |
-
#
|
| 153 |
-
print("\n--- Grader Validation ---")
|
| 154 |
-
|
| 155 |
-
# Easy grader
|
| 156 |
try:
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 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("
|
| 170 |
|
| 171 |
-
#
|
|
|
|
| 172 |
try:
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 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("
|
| 194 |
|
| 195 |
-
#
|
| 196 |
try:
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 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("
|
| 218 |
|
| 219 |
-
#
|
| 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"
|
| 233 |
-
|
| 234 |
))
|
| 235 |
except Exception as e:
|
| 236 |
-
results.append(check("
|
| 237 |
|
| 238 |
-
#
|
| 239 |
try:
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
for
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 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"
|
| 263 |
-
|
| 264 |
))
|
| 265 |
except Exception as e:
|
| 266 |
-
results.append(check("
|
| 267 |
|
| 268 |
-
#
|
|
|
|
| 269 |
try:
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
results.append(check(
|
| 287 |
-
"
|
| 288 |
-
|
| 289 |
))
|
| 290 |
except Exception as e:
|
| 291 |
-
results.append(check("
|
| 292 |
|
| 293 |
-
#
|
| 294 |
print("\n--- Packaging ---")
|
| 295 |
try:
|
| 296 |
with open("openenv.yaml", "r") as f:
|
| 297 |
config = yaml.safe_load(f)
|
| 298 |
-
required = ["name", "
|
| 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 |
-
#
|
| 308 |
-
results.append(check(
|
| 309 |
-
"Dockerfile exists",
|
| 310 |
-
os.path.exists("Dockerfile"),
|
| 311 |
-
))
|
| 312 |
|
| 313 |
-
#
|
| 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("
|
| 321 |
return 0
|
| 322 |
else:
|
| 323 |
-
print(f"
|
| 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 |
|